diff --git a/docs.py b/docs.py
new file mode 100644
index 0000000..0f61aaf
--- /dev/null
+++ b/docs.py
@@ -0,0 +1,213 @@
+from dataclasses import dataclass
+from enum import Enum
+from inspect import _empty
+from typing import Any, Optional
+
+from fuzzly import Client, FuzzlyClient
+
+
+client: FuzzlyClient = FuzzlyClient()
+c: Client = Client()
+
+funcs: list[str] = [i for i in client.__dir__() if not i.startswith('_') and i not in set(c.__dir__())]
+models: list[type] = []
+
+bullet = "\n- "
+
+client_header = """[See the home page for client setup](.)
+
+# Client Methods
+Notice that all client functions are `async`. This is because http requests are made under the hood and they are done in parallel with other requests."""
+models_header = """[See the client page for how to retrieve and use these models](./Client)"""
+
+
+def docstrip(doc: str) -> str :
+ doc = doc.strip('\n\r')
+ indent = len(doc) - len(doc.lstrip('\t'))
+ return '\n'.join(docstr.removeprefix('\t' * indent) for docstr in doc.split('\n')).strip()
+
+
+def model_name(model: type) -> str :
+ module = model.__module__
+
+ if module.endswith('._shared') :
+ module = module[:-8]
+
+ return f'{module}.{model.__name__}'
+
+
+def model_link(model: type, local: bool = False) -> str :
+ if local :
+ return '#' + model_name(model).replace('.', '').lower()
+ return './Models#' + model_name(model).replace('.', '').lower()
+
+
+def valuestr(value: Any) -> str :
+ for cls in getattr(value.__class__, '__mro__', []) :
+ if cls == Enum :
+ return f'{value.__class__.__name__}.{value.name}'
+
+ if cls in { int, float, str } :
+ return str(value)
+
+ raise ValueError(f'no known conversion for {value.__class__}')
+
+
+def modelstr(model: type, link: bool = False) -> str :
+ m = model
+ fmt = '{}'
+ name = m.__name__
+
+ if hasattr(model, '__args__') :
+ m = model.__args__[0]
+ fmt = f'{model.__name__}[{{}}]'
+ name = modelstr(m)
+
+ if link :
+ return fmt.format(f'{name}')
+
+ else :
+ return fmt.format(name)
+
+
+@dataclass
+class Param :
+ name: str
+ type: str
+ default: Optional[str] = None
+
+ def doc(self) -> str :
+ doc = f'{self.name}: '
+
+ if 'fuzzly' in self.type.__module__ :
+ models.append(self.type)
+
+ doc += modelstr(self.type, link=True)
+ doc = f'{doc}'
+
+ else :
+ doc += modelstr(self.type)
+ doc = f'`{doc}`'
+
+ if self.default :
+ doc += f' (optional, default: `{valuestr(self.default)}`)'
+
+ return doc
+
+ def __str__(self) -> str :
+ if self.default :
+ return f'{self.name}: {modelstr(self.type)} = {valuestr(self.default)}'
+
+ else :
+ return f'{self.name}: {modelstr(self.type)}'
+
+
+def funcdoc(funcstr: str) -> str :
+ func = getattr(client, funcstr)
+ docstr = docstrip(func.__doc__)
+
+ params: list[Param] = []
+ __omit__ = { 'self', 'auth' }
+ for key, param in func.__signature__.parameters.items() :
+ if key in __omit__ :
+ continue
+
+ p = Param(name=param.name, type=param.annotation)
+ if param.default != _empty :
+ p.default = param.default
+
+ params.append(p)
+
+ r = func.__signature__.return_annotation
+ if 'fuzzly' in r.__module__ :
+ models.append(r)
+ if hasattr(r, '__args__') :
+ for m in r.__args__ :
+ if 'fuzzly' in m.__module__ :
+ models.append(m)
+
+ title = f'({func.__self__.__class__.__name__}).{func.__name__}({", ".join([i.name for i in params])}) -> {modelstr(r)}'
+
+ doc = f'## {title}\n{docstr}'
+
+ if params :
+ doc += f'\n\n#### params{bullet}{bullet.join(map(Param.doc, params))}'
+
+ return f'{doc}\n\n#### returns\n- {modelstr(r, link=True)}'
+
+
+def model_subtypes(model: type) -> list[type] :
+ subtypes: list[type] = []
+
+ if 'fuzzly' in model.__module__ :
+ subtypes.append(model)
+
+ if hasattr(model, '__args__') :
+ for m in model.__args__ :
+ subtypes += model_subtypes(m)
+
+ return subtypes
+
+
+def modeldoc(model: type) -> str :
+ doc = ""
+ if model.__doc__ :
+ doc = docstrip(model.__doc__)
+
+ classdef = f'```python\nclass {model.__name__}'
+ if len(model.__mro__) > 2 :
+ classdef += f'({model.__mro__[1].__name__})'
+ classdef += ':'
+
+ subtypes: list[type] = []
+
+ if hasattr(model, 'model_fields') :
+ if doc :
+ doc += '\n\n'
+ doc += classdef
+
+ for name, field in model.model_fields.items() :
+ s = model_subtypes(field.annotation)
+ models.extend(s)
+ subtypes.extend(s)
+
+ doc += f'\n\t{name}: {modelstr(field.annotation)}'
+ doc += '\n```'
+
+ if issubclass(model, Enum) :
+ if doc :
+ doc += '\n\n'
+ doc += classdef
+
+ for member in model.__members__.values() :
+ doc += f'\n\t{member.name}: {type(member.value).__name__} = \'{member.value}\''
+
+ doc += '\n```'
+
+ _completed_subtypes: set[type] = set()
+ if subtypes :
+ doc += '\n\n#### subtypes'
+ for subtype in subtypes :
+ if subtype in _completed_subtypes :
+ continue
+
+ doc += f'{bullet}[`{subtype.__name__}`]({model_link(subtype, local=True)})'
+ _completed_subtypes.add(subtype)
+
+ return f'### `{model_name(model)}`\n{doc}'
+
+
+with open('client.md', 'w') as file :
+ file.write(client_header + '\n\n\n' + '\n\n\n'.join(map(funcdoc, funcs)))
+
+with open('models.md', 'w') as file :
+ doc = models_header
+ _completed_models: set[type] = set()
+ for model in models :
+ if model in _completed_models :
+ continue
+
+ doc += '\n\n\n' + modeldoc(model)
+ _completed_models.add(model)
+
+ file.write(doc)
diff --git a/fuzzly/__init__.py b/fuzzly/__init__.py
index 898051c..405e951 100644
--- a/fuzzly/__init__.py
+++ b/fuzzly/__init__.py
@@ -1,35 +1,112 @@
"""A python client library for fuzz.ly""" # this comment should match the package slogan under the logo in the readme
-__version__: str = '0.0.4'
+__version__: str = '0.0.5'
from typing import List
from .api.post import FetchMyPosts, FetchPost
+from .api.set import FetchPostSets, FetchSet, FetchUserSets
from .api.tag import FetchPostTags, FetchTag
from .client import Client
from .models.post import Post, PostId, PostSort
+from .models.set import PostSet, Set, SetId
from .models.tag import Tag, TagGroups
class FuzzlyClient(Client) :
+ """
+ Notice that all client functions are `async`. This is because http requests are made under the hood and they are done in parallel with other requests.
+ """
+
+ ################################################## POST ##################################################
@Client.authenticated
async def post(self: Client, post_id: PostId, auth: str = None) -> Post :
+ """
+ Retrieves the post indicated by the provided `post_id`.
+ ```python
+ client: FuzzlyClient
+ await client.post('Lw_KpQM6')
+ ```
+ """
return await FetchPost(post_id=post_id, auth=auth)
@Client.authenticated
async def my_posts(self: Client, sort: PostSort = PostSort.new, count: int = 64, page: int = 1, auth: str = None) -> List[Post] :
+ """
+ Retrieves the user's own posts. Requires a bot token to be provided to the client.
+ ```python
+ client: FuzzlyClient
+ await client.my_posts()
+ ```
+ """
return await FetchMyPosts({ 'sort': sort.name, 'count': count, 'page': page }, auth=auth)
+ ################################################## TAGS ##################################################
+
@Client.authenticated
async def tag(self: Client, tag: str, auth: str = None) -> Tag :
+ """
+ Retrieves the tag specified by the provided `tag`.
+ ```python
+ client: FuzzlyClient
+ await client.tag('female')
+ ```
+ """
return await FetchTag(tag=tag, auth=auth)
@Client.authenticated
async def post_tags(self: Client, post_id: PostId, auth: str = None) -> TagGroups :
+ """
+ Retrieves the tags belonging to a post, specified by the provided `post_id`.
+ ```python
+ client: FuzzlyClient
+ await client.post_tags('Lw_KpQM6')
+ ```
+ """
return await FetchPostTags(post_id=post_id, auth=auth)
+
+
+ ################################################## SETS ##################################################
+
+ @Client.authenticated
+ async def set(self: Client, set_id: SetId, auth: str = None) -> Set :
+ """
+ Retrieves the set indicated by the provided `set_id`.
+ ```python
+ client: FuzzlyClient
+ await client.set('abc-123')
+ ```
+ """
+ return await FetchSet(set_id=set_id, auth=auth)
+
+
+ @Client.authenticated
+ async def user_sets(self: Client, handle: str, auth: str = None) -> List[Set] :
+ """
+ Retrieves the sets owned by the user indicated by the provided `handle`.
+ ```python
+ client: FuzzlyClient
+ await client.user_sets('handle')
+ ```
+ """
+ return await FetchUserSets(handle=handle, auth=auth)
+
+
+ @Client.authenticated
+ async def post_sets(self: Client, post_id: PostId, auth: str = None) -> List[PostSet] :
+ """
+ Retrieves all sets that contain the post indicated by the provided `post_id`.
+
+ NOTE: the return model also contains the post's neighbors within the set
+ ```python
+ client: FuzzlyClient
+ await client.post_sets('abcd1234')
+ ```
+ """
+ return await FetchPostSets(post_id=post_id, auth=auth)
diff --git a/fuzzly/api/set.py b/fuzzly/api/set.py
new file mode 100644
index 0000000..10d5225
--- /dev/null
+++ b/fuzzly/api/set.py
@@ -0,0 +1,18 @@
+from typing import List
+
+from kh_common.gateway import Gateway
+
+from ..constants import SetHost
+from ..models.set import PostSet, Set
+
+
+# Usage: FetchSet(set_id='abcd123')
+FetchSet = Gateway(SetHost + '/v1/set/{set_id}', Set, method='GET')
+
+
+# Usage: FetchUserSets(handle='@handle')
+FetchUserSets = Gateway(SetHost + '/v1/user/{handle}', List[Set], method='GET')
+
+
+# Usage: FetchPostSets(post_id='abcd1234')
+FetchPostSets = Gateway(SetHost + '/v1/post/{post_id}', List[PostSet], method='GET')
diff --git a/fuzzly/client/__init__.py b/fuzzly/client/__init__.py
index 8b8502f..1365686 100644
--- a/fuzzly/client/__init__.py
+++ b/fuzzly/client/__init__.py
@@ -63,7 +63,7 @@ def error_handler(func: Callable) -> Callable :
Transforms aiohttp.ClientResponseError back to their original kh_common.exceptions.http_error.HttpError instance for re-raising and/or handling internally.
Usage
- ```
+ ```python
class MyClient(Client) :
@Client.authenticated
async test(auth: str = None) -> str :
@@ -107,7 +107,7 @@ def authenticated(func: Callable) -> Callable :
Injects an authenticated bot token into the 'auth' kwarg of the passed function
Usage
- ```
+ ```python
class MyClient(Client) :
@Client.authenticated
async test(auth: str = None) -> str :
diff --git a/fuzzly/constants.py b/fuzzly/constants.py
index dbe1876..9d389c1 100644
--- a/fuzzly/constants.py
+++ b/fuzzly/constants.py
@@ -9,6 +9,7 @@
PostHost: str
AccountHost: str
UserHost: str
+SetHost: str
ConfigHost: str
AvroHost: str
@@ -23,6 +24,7 @@
'UserHost': 'http://localhost:5005',
'ConfigHost': 'http://localhost:5006',
'AvroHost': 'http://localhost:5007',
+ 'SetHost': 'http://localhost:5008',
},
Environment.local: {
'AuthHost': 'http://127.0.0.1:5000',
@@ -33,6 +35,7 @@
'UserHost': 'http://localhost:5005',
'ConfigHost': 'http://localhost:5006',
'AvroHost': 'http://localhost:5007',
+ 'SetHost': 'http://localhost:5008',
},
Environment.dev: {
'AuthHost': 'https://auth-dev.fuzz.ly',
@@ -43,6 +46,7 @@
'UserHost': 'https://users-dev.fuzz.ly',
'ConfigHost': 'https://config-dev.fuzz.ly',
'AvroHost': 'https://avro-dev.fuzz.ly',
+ 'SetHost': 'https://sets-dev.fuzz.ly',
},
Environment.prod: {
'AuthHost': 'https://auth.fuzz.ly',
@@ -53,6 +57,7 @@
'UserHost': 'https://users.fuzz.ly',
'ConfigHost': 'https://config.fuzz.ly',
'AvroHost': 'https://avro.fuzz.ly',
+ 'SetHost': 'https://sets.fuzz.ly',
},
}
diff --git a/fuzzly/models/__init__.py b/fuzzly/models/__init__.py
index e69de29..189eb41 100644
--- a/fuzzly/models/__init__.py
+++ b/fuzzly/models/__init__.py
@@ -0,0 +1 @@
+from ._shared import *
diff --git a/fuzzly/models/_shared.py b/fuzzly/models/_shared.py
index 224b514..9fdcf9c 100644
--- a/fuzzly/models/_shared.py
+++ b/fuzzly/models/_shared.py
@@ -3,10 +3,12 @@
from functools import lru_cache
from re import Pattern
from re import compile as re_compile
-from typing import List, Optional, Union
+from secrets import token_bytes
+from typing import Any, List, Optional, Type, Union
from kh_common.base64 import b64decode, b64encode
from pydantic import BaseModel, validator
+from pydantic_core import core_schema
"""
@@ -16,6 +18,34 @@
"""
+################################################## MANY ##################################################
+
+@unique
+class Privacy(Enum) :
+ public: str = 'public'
+ unlisted: str = 'unlisted'
+ private: str = 'private'
+ unpublished: str = 'unpublished'
+ draft: str = 'draft'
+
+
+@unique
+class Rating(Enum) :
+ general: str = 'general'
+ mature: str = 'mature'
+ explicit: str = 'explicit'
+
+
+@unique
+class PostSort(Enum) :
+ new: str = 'new'
+ old: str = 'old'
+ top: str = 'top'
+ hot: str = 'hot'
+ best: str = 'best'
+ controversial: str = 'controversial'
+
+
################################################## POST ##################################################
class PostId(str) :
@@ -23,11 +53,19 @@ class PostId(str) :
automatically converts post ids in int, byte, or string format to their user-friendly str format.
also checks for valid values.
- NOTE: when used in fastapi or pydantic ensure PostId is called directly. either through a validator or manually.
- EX: _post_id_validator = validator('post_id', pre=True, always=True, allow_reuse=True)(PostId)
+ ```python
+ PostId(123)
+ PostId('abcd1234')
+ PostId(b'abc123')
+ ```
"""
__str_format__: Pattern = re_compile(r'^[a-zA-Z0-9_-]{8}$')
+ __int_max_value__: int = 281474976710655
+
+
+ def generate() -> 'PostId' :
+ return PostId(token_bytes(6))
@lru_cache(maxsize=128)
@@ -56,8 +94,8 @@ def __new__(cls, value: Union[str, bytes, int]) :
elif value_type == int :
# the range of a 48 bit int stored in a 64 bit int (both starting at min values)
- if not 0 <= value <= 281474976710655 :
- raise ValueError('int values must be between 0 and 281474976710655.')
+ if not 0 <= value <= PostId.__int_max_value__ :
+ raise ValueError(f'int values must be between 0 and {PostId.__int_max_value__:,}.')
return super(PostId, cls).__new__(cls, PostId._str_from_int(value))
@@ -77,6 +115,13 @@ def __new__(cls, value: Union[str, bytes, int]) :
raise NotImplementedError('value must be of type str, bytes, or int.')
+ def __get_pydantic_core_schema__(self, _: Type[Any]) -> core_schema.CoreSchema :
+ return core_schema.no_info_after_validator_function(
+ PostId,
+ core_schema.any_schema(serialization=core_schema.str_schema()),
+ )
+
+
@lru_cache(maxsize=128)
def int(self: 'PostId') -> int :
return int.from_bytes(b64decode(self), 'big')
@@ -161,3 +206,89 @@ def portable(self: 'User') -> UserPortable :
verified = self.verified,
following = self.following,
)
+
+
+################################################## SETS ##################################################
+
+class SetId(str) :
+ """
+ automatically converts set ids in int, byte, or string format to their user-friendly str format.
+ also checks for valid values.
+
+ ```python
+ SetId(123)
+ SetId('abc-123')
+ SetId(b'abcde')
+ ```
+ """
+
+ __str_format__: Pattern = re_compile(r'^[a-zA-Z0-9_-]{7}$')
+ __int_max_value__: int = 1099511627775
+
+
+ def generate() -> 'SetId' :
+ return SetId(token_bytes(5))
+
+
+ @lru_cache(maxsize=128)
+ def _str_from_int(value: int) -> str :
+ return b64encode(int.to_bytes(value, 5, 'big')).decode()
+
+
+ @lru_cache(maxsize=128)
+ def _str_from_bytes(value: bytes) -> str :
+ return b64encode(value).decode()
+
+
+ def __new__(cls, value: Union[str, bytes, int]) :
+ # technically, the only thing needed to be done here to utilize the full 64 bit range is update the 4 bytes encoding to 8 and the allowed range in the int subtype
+
+ value_type: type = type(value)
+
+ if value_type == SetId :
+ return super(SetId, cls).__new__(cls, value)
+
+ elif value_type == str :
+ if not SetId.__str_format__.match(value) :
+ raise ValueError('str values must be in the format of /^[a-zA-Z0-9_-]{7}$/')
+
+ return super(SetId, cls).__new__(cls, value)
+
+ elif value_type == int :
+ # the range of a 40 bit int stored in a 64 bit int (both starting at min values)
+ if not 0 <= value <= SetId.__int_max_value__ :
+ raise ValueError(f'int values must be between 0 and {SetId.__int_max_value__:,}.')
+
+ return super(SetId, cls).__new__(cls, SetId._str_from_int(value))
+
+ elif value_type == bytes :
+ if len(value) != 5 :
+ raise ValueError('bytes values must be exactly 5 bytes.')
+
+ return super(SetId, cls).__new__(cls, SetId._str_from_bytes(value))
+
+ # just in case there's some weirdness happening with types, but it's still a string
+ if isinstance(value, str) :
+ if not SetId.__str_format__.match(value) :
+ raise ValueError('str values must be in the format of /^[a-zA-Z0-9_-]{7}$/')
+
+ return super(SetId, cls).__new__(cls, value)
+
+ raise NotImplementedError('value must be of type str, bytes, or int.')
+
+
+ def __get_pydantic_core_schema__(self, _: Type[Any]) -> core_schema.CoreSchema :
+ return core_schema.no_info_after_validator_function(
+ SetId,
+ core_schema.any_schema(serialization=core_schema.str_schema()),
+ )
+
+
+ @lru_cache(maxsize=128)
+ def int(self: 'SetId') -> int :
+ return int.from_bytes(b64decode(self), 'big')
+
+ __int__ = int
+
+
+SetIdValidator = validator('set_id', pre=True, always=True, allow_reuse=True)(SetId)
diff --git a/fuzzly/models/config.py b/fuzzly/models/config.py
index 0b20160..76f2f39 100644
--- a/fuzzly/models/config.py
+++ b/fuzzly/models/config.py
@@ -2,9 +2,9 @@
from typing import Dict, List, Literal, Optional, Set, Union
from avrofastapi.schema import AvroInt
-from pydantic import BaseModel, conbytes
+from pydantic import BaseModel, conbytes, validator
-from ._shared import PostId
+from ._shared import PostId, _post_id_converter
UserConfigKeyFormat: str = 'user.{user_id}'
@@ -111,6 +111,8 @@ class UserConfig(BaseModel) :
class UserConfigRequest(BaseModel) :
+ _post_id_converter = validator('wallpaper', pre=True, always=True, allow_reuse=True)(_post_id_converter)
+
blocking_behavior: Optional[BlockingBehavior]
blocked_tags: Optional[List[Set[str]]]
blocked_users: Optional[List[str]]
diff --git a/fuzzly/models/internal.py b/fuzzly/models/internal.py
index 49cf2c6..58bba8e 100644
--- a/fuzzly/models/internal.py
+++ b/fuzzly/models/internal.py
@@ -1,21 +1,25 @@
from asyncio import Task, ensure_future
from collections import defaultdict
from datetime import datetime
-from typing import Any, Callable, Coroutine, Dict, Iterable, List, Optional, Set, Tuple
+from typing import Any, Callable, Coroutine, Dict, Iterable, List, Optional
+from typing import Set as SetType
+from typing import Tuple
-from kh_common.auth import KhUser
+from kh_common.auth import KhUser, Scope
+from kh_common.base64 import b64decode, b64encode
from kh_common.caching import AerospikeCache, ArgsCache
from kh_common.caching.key_value_store import KeyValueStore
from kh_common.gateway import Gateway
from kh_common.utilities import flatten
-from pydantic import BaseModel
+from pydantic import BaseModel, validator
from ..client import Client
-from ..constants import ConfigHost, PostHost, TagHost, UserHost
+from ..constants import ConfigHost, PostHost, SetHost, TagHost, UserHost
from ._database import DBI, FollowKVS, InternalScore, InternalUser, ScoreCache, UserKVS, VoteCache
-from ._shared import PostId, PostSize, User, UserPortable
+from ._shared import PostId, PostSize, SetId, UserPortable, UserPrivacy, _post_id_converter
from .config import UserConfig
from .post import MediaType, Post, PostId, PostSize, PostSort, Privacy, Rating, Score
+from .set import Set
from .tag import Tag, TagGroupPortable, TagGroups
from .user import UserPortable
@@ -24,6 +28,7 @@
UserConfigKVS: KeyValueStore = KeyValueStore('kheina', 'configs')
TagKVS: KeyValueStore = KeyValueStore('kheina', 'tags')
PostKVS: KeyValueStore = KeyValueStore('kheina', 'posts')
+SetKVS: KeyValueStore = KeyValueStore('kheina', 'sets')
# internal functions sometimes need to interact with the db, this is done through this interface
DB: DBI = DBI()
@@ -41,6 +46,7 @@ class _InternalClient(Client) :
_post: Gateway # this will be assigned later
_user: Gateway # this will be assigned later
_tag: Gateway # this will be assigned later
+ _set: Gateway # this will be assigned later
following_many: Callable[[KhUser, List[int]], Coroutine[Any, Any, Dict[int, bool]]]
users_many: Callable[[List[int]], Coroutine[Any, Any, Dict[int, InternalUser]]]
@@ -79,6 +85,12 @@ async def post(self: Client, post_id: PostId, auth: str = None) -> 'InternalPost
return await _InternalClient._post(post_id=post_id, auth=auth)
+ @AerospikeCache('kheina', 'sets', '{set_id}', read_only=True, _kvs=SetKVS)
+ @Client.authenticated
+ async def set(self: Client, set_id: SetId, auth: str = None) -> 'InternalSet' :
+ return await _InternalClient._set(set_id=set_id, auth=auth)
+
+
# not cached (should be?)
@Client.authenticated
async def user_posts(self: Client, user_id: int, sort: PostSort = PostSort.new, count: int = 64, page: int = 1, auth: str = None) -> 'InternalPost' :
@@ -109,7 +121,7 @@ def dict(self: 'BlockTree') :
def __init__(self: 'BlockTree') :
- self.tags: Set[str] = None
+ self.tags: SetType[str] = None
self.match: Dict[str, BlockTree] = None
self.nomatch: Dict[str, BlockTree] = None
@@ -206,13 +218,29 @@ async def is_post_blocked(client: _InternalClient, user: KhUser, uploader: str,
if user_config.blocked_users and uploader_id in user_config.blocked_users :
return True
- tags: Set[str] = set(tags)
+ tags: SetType[str] = set(tags)
tags.add('@' + uploader) # TODO: user ids need to be added here instead of just handle, once changeable handles are added
return block_tree.blocked(tags)
+def _thumbhash_converter(value: Any) -> Optional[bytes] :
+ if not value or isinstance(value, bytes):
+ return value
+
+ if isinstance(value, str) :
+ return b64decode(value)
+
+ return bytes(value)
+
class InternalPost(BaseModel) :
+ _thumbhash_converter = validator('thumbhash', pre=True, always=True, allow_reuse=True)(_thumbhash_converter)
+
+ class Config:
+ json_encoders = {
+ bytes: lambda x: b64encode(x).decode(),
+ }
+
post_id: int
title: Optional[str]
description: Optional[str]
@@ -225,6 +253,7 @@ class InternalPost(BaseModel) :
filename: Optional[str]
media_type: Optional[MediaType]
size: Optional[PostSize]
+ thumbhash: Optional[bytes]
async def user_portable(self: 'InternalPost', client: _InternalClient, user: KhUser) -> UserPortable :
@@ -255,6 +284,7 @@ async def post(self: 'InternalPost', client: _InternalClient, user: KhUser) -> P
media_type=self.media_type,
size=self.size,
blocked=blocked,
+ thumbhash=self.thumbhash,
)
@@ -276,9 +306,15 @@ async def authorized(self: 'InternalPost', client: _InternalClient, user: KhUser
if self.privacy in { Privacy.public, Privacy.unlisted } :
return True
+ if not await user.authenticated(raise_error=False) :
+ return False
+
if user.user_id == self.user_id :
return True
+ if await user.verify_scope(Scope.mod, raise_error=False) :
+ return True
+
# use client to fetch the user and any other associated info to determine other methods of being authorized
return False
@@ -490,6 +526,7 @@ async def posts(self: 'InternalPosts', client: _InternalClient, user: KhUser) ->
size=post.size,
# only the first call retrieves blocked info, all the rest should be cached and not actually await
blocked=await is_post_blocked(client, user, uploaders[post.user_id].handle, post.user_id, tags[post_id]),
+ thumbhash=post.thumbhash,
))
return posts
@@ -526,6 +563,91 @@ async def tag(self: 'InternalTag', client: _InternalClient, user: KhUser) -> Tag
count=await tag_count,
)
-
# this has to be defined here because of the response model
_InternalClient._tag: Gateway = Gateway(TagHost + '/i1/tag/{tag}', InternalTag, method='GET')
+
+
+class InternalSet(BaseModel) :
+ _post_id_converter = validator('first', 'last', pre=True, always=True, allow_reuse=True)(_post_id_converter)
+
+ set_id: int
+ owner: int
+ count: int
+ title: Optional[str]
+ description: Optional[str]
+ privacy: UserPrivacy
+ created: datetime
+ updated: datetime
+ first: Optional[PostId]
+ last: Optional[PostId]
+
+
+ async def user_portable(self: 'InternalSet', client: _InternalClient, user: KhUser) -> Optional[UserPortable] :
+ iuser: InternalUser = await client.user(self.owner)
+ return await iuser.portable(user)
+
+
+ async def _post(self: 'InternalSet', client: _InternalClient, user: KhUser, post_id: Optional[PostId]) -> Optional[Post] :
+ if not post_id :
+ return None
+
+ ipost: InternalPost = await client.post(post_id)
+
+ if not ipost.authorized(client, user) :
+ return None
+
+ return await ipost.post(client, user)
+
+
+ async def set(self: 'InternalSet', client: _InternalClient, user: KhUser) -> Set :
+ owner: Task[Optional[UserPortable]] = ensure_future(self.user_portable(client, user))
+ first: Task[Optional[Post]] = ensure_future(self._post(client, user, self.first))
+ last: Task[Optional[Post]] = ensure_future(self._post(client, user, self.last))
+
+ return Set(
+ set_id=SetId(self.set_id),
+ owner=await owner,
+ count=self.count,
+ title=self.title,
+ description=self.description,
+ privacy=self.privacy,
+ created=self.created,
+ updated=self.updated,
+ first=await first,
+ last=await last,
+ )
+
+
+ async def authorized(self: 'InternalSet', client: _InternalClient, user: KhUser) -> bool :
+ """
+ Checks if the given user is able to view this set. Follows the given rules:
+
+ - is the set public
+ - is the user the owner
+ - TODO:
+ - if private, has the user been given explicit permission
+ - if user is private, does the user follow the uploader
+
+ :param client: client used to retrieve user details
+ :param user: the user to check set availablility against
+ :return: boolean - True if the user has permission, otherwise False
+ """
+
+ if self.privacy == UserPrivacy.public :
+ return True
+
+ if not await user.authenticated(raise_error=False) :
+ return False
+
+ if user.user_id == self.owner :
+ return True
+
+ if await user.verify_scope(Scope.mod, raise_error=False) :
+ return True
+
+ # use client to fetch the user and any other associated info to determine other methods of being authorized
+
+ return False
+
+# this has to be defined here because of the response model
+_InternalClient._set: Gateway = Gateway(SetHost + '/i1/set/{set_id}', InternalSet, method='GET')
diff --git a/fuzzly/models/post.py b/fuzzly/models/post.py
index 5504308..20867e8 100644
--- a/fuzzly/models/post.py
+++ b/fuzzly/models/post.py
@@ -1,36 +1,11 @@
from datetime import datetime
from enum import Enum, unique
-from typing import Dict, List, Optional, Union
+from typing import Any, Dict, List, Optional, Union
+from kh_common.base64 import b64encode
from pydantic import BaseModel, validator
-from ._shared import PostId, PostIdValidator, PostSize, Score, UserPortable, _post_id_converter
-
-
-@unique
-class Privacy(Enum) :
- public: str = 'public'
- unlisted: str = 'unlisted'
- private: str = 'private'
- unpublished: str = 'unpublished'
- draft: str = 'draft'
-
-
-@unique
-class Rating(Enum) :
- general: str = 'general'
- mature: str = 'mature'
- explicit: str = 'explicit'
-
-
-@unique
-class PostSort(Enum) :
- new: str = 'new'
- old: str = 'old'
- top: str = 'top'
- hot: str = 'hot'
- best: str = 'best'
- controversial: str = 'controversial'
+from ._shared import PostId, PostIdValidator, PostSize, PostSort, Privacy, Rating, Score, UserPortable, _post_id_converter
class VoteRequest(BaseModel) :
@@ -84,9 +59,17 @@ class TagGroups(Dict[TagGroupPortable, List[str]]) :
pass
+def _thumbhash_converter(value: Any) -> Any :
+ if value and not isinstance(value, str) :
+ return b64encode(value)
+
+ return value
+
+
class Post(BaseModel) :
_post_id_validator = PostIdValidator
_post_id_converter = validator('parent', pre=True, always=True, allow_reuse=True)(_post_id_converter)
+ _thumbhash_converter = validator('thumbhash', pre=True, always=True, allow_reuse=True)(_thumbhash_converter)
post_id: PostId
title: Optional[str]
@@ -102,3 +85,4 @@ class Post(BaseModel) :
media_type: Optional[MediaType]
size: Optional[PostSize]
blocked: bool
+ thumbhash: Optional[str]
diff --git a/fuzzly/models/set.py b/fuzzly/models/set.py
new file mode 100644
index 0000000..8ccbccf
--- /dev/null
+++ b/fuzzly/models/set.py
@@ -0,0 +1,49 @@
+from datetime import datetime
+from typing import List, Optional
+
+from pydantic import BaseModel
+
+from ._shared import PostId, SetId, SetIdValidator, UserPortable, UserPrivacy
+from .post import Post
+
+
+class Set(BaseModel) :
+ _set_id_validator = SetIdValidator
+
+ set_id: SetId
+ owner: UserPortable
+ count: int
+ title: Optional[str]
+ description: Optional[str]
+ privacy: UserPrivacy
+ created: datetime
+ updated: datetime
+ first: Optional[Post]
+ last: Optional[Post]
+
+
+class SetNeighbors(BaseModel) :
+ index: int
+ """
+ the central index post around which the neighbors exist in the set
+ """
+
+ before: List[Optional[Post]]
+ """
+ neighbors before the index are arranged in descending order such that the first item in the list is always index - 1 where index is PostNeighbors.index
+
+ EX:
+ before: [index - 1, index - 2, index - 3, ...]
+ """
+
+ after: List[Optional[Post]]
+ """
+ neighbors after the index are arranged in ascending order such that the first item in the list is always index + 1 where index is PostNeighbors.index
+
+ EX:
+ after: [index + 1, index + 2, index + 3, ...]
+ """
+
+
+class PostSet(Set) :
+ neighbors: SetNeighbors
diff --git a/fuzzly/models/tag.py b/fuzzly/models/tag.py
index 894d537..35d3913 100644
--- a/fuzzly/models/tag.py
+++ b/fuzzly/models/tag.py
@@ -17,6 +17,13 @@ class TagGroupPortable(Enum) :
class TagGroups(Dict[TagGroupPortable, List[str]]) :
+ # TODO: write a better docstr for this
+ """
+```python
+class TagGroups(Dict[TagGroupPortable, List[str]]) :
+ pass
+```
+"""
pass
diff --git a/tests/test_models.py b/tests/test_models.py
index 3716f63..de4f93c 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -3,6 +3,7 @@
import pytest
from fuzzly.models.post import PostId
+from fuzzly.models.set import SetId
@pytest.mark.parametrize(
@@ -25,3 +26,25 @@ def test_PostId_ValidValue(value: Any, expected: str) :
def test_PostId_InvalidValue(value: Any) :
with pytest.raises(ValueError) :
assert PostId(value)
+
+
+@pytest.mark.parametrize(
+ 'value, expected',
+ [
+ (0, 'AAAAAAA'),
+ (2**40-1, '______8'),
+ ('JPIlC50', 'JPIlC50'),
+ (b'$\xf2%\x0b\x9d', 'JPIlC50')
+ ]
+)
+def test_SetId_ValidValue(value: Any, expected: str) :
+ assert SetId(value) == expected
+
+
+@pytest.mark.parametrize(
+ 'value',
+ [-1, 2**40, 'abcd1234', 'abcd12', b'\x00\x00\x00\x00\x00\x00', b'\x00\x00\x00\x00']
+)
+def test_SetId_InvalidValue(value: Any) :
+ with pytest.raises(ValueError) :
+ assert SetId(value)