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)