diff --git a/cli.py b/cli.py new file mode 100644 index 0000000..565de6b --- /dev/null +++ b/cli.py @@ -0,0 +1,16 @@ +import click + +from pkg_resources import iter_entry_points + +[entry_point] = list(iter_entry_points('console_scripts', name="toot")) + +cli = entry_point.resolve() +print(cli) +ctx = click.Context(cli) +commands = getattr(cli, 'commands', {}) + +print(ctx) +command: click.Command +for name, command in cli.commands.items(): + print(name, command) + print(command.help) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..0f4ed98 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +addopts = -v diff --git a/requirements-test.txt b/requirements-test.txt index 3a35c72..7c99f21 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,5 +1,7 @@ +faker flake8 psycopg2-binary pytest +pytest-asyncio pytest-xdist[psutil] vermin diff --git a/setup.py b/setup.py index a7b5c51..98ca545 100644 --- a/setup.py +++ b/setup.py @@ -38,6 +38,8 @@ setup( "beautifulsoup4>=4.5.0,<5.0", "wcwidth>=0.1.7", "urwid>=2.0.0,<3.0", + "aiohttp>=3.5.0,<4.0", + "click~=8.1.3" ], entry_points={ 'console_scripts': [ diff --git a/toot/__main__.py b/toot/__main__.py new file mode 100644 index 0000000..5ba94c6 --- /dev/null +++ b/toot/__main__.py @@ -0,0 +1,3 @@ +from toot.asynch.commands import cli + +cli() diff --git a/toot/asynch/README.md b/toot/asynch/README.md new file mode 100644 index 0000000..9ab6aec --- /dev/null +++ b/toot/asynch/README.md @@ -0,0 +1,41 @@ +Toot Async Refactor +=================== + +Moving to `aiohttp` for full async support + websockets for streaming. + +Design goals: + +- keep the functional API design +- not coupled with underlying library: requests/aiohttp/httpx +- can easily read returned data decoded from json +- can read returned data without it being decoded (currently not possible) +- can read returned headers +- raises an exception on error +- asynchronous by default, with synchronous variants +- rewrite TUI to use async functions instead of the current callback hell + +To think about: + +- ability to reuse a aiohttp ClientSession without _having_ to do so. toot's own + Session object? are there performance issues if a new session is creted each + time? +- couple app and user into one "context" object to avoid having to pass two + params for each fn? +- further namespace CLI commands? for example "mute" can be applied to both a + status and an account, having "toot status mute" and "toot account mute" + would solve it at expense of verbosity and breaking BC. alternatively, "toot + mute" could act on a status or account. +- how to implement updating credentials via commandline? + https://mastodon.example/api/v1/accounts/update_credentials + +Unrelated to async refactor: + +- how to configure toot? global settings, per-server settings, ... +- is it worth to switch to `click` for CLI, see: + https://click.palletsprojects.com/en/8.1.x/why/#why-not-argparse + +Yak shaving: + +- update mastodon template for api methods to include `name="..."` so individual + API endpoints can be linked in their API docs. + https://github.com/mastodon/documentation/blob/master/layouts/shortcodes/api-method.html diff --git a/toot/asynch/__init__.py b/toot/asynch/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/toot/asynch/api.py b/toot/asynch/api.py new file mode 100644 index 0000000..f6a1f31 --- /dev/null +++ b/toot/asynch/api.py @@ -0,0 +1,177 @@ +import uuid + +from toot import CLIENT_NAME, CLIENT_WEBSITE, config, App, User +from typing import Literal, List, Optional +from toot.asynch.http import request, Params, Response +from toot.utils import str_bool + + +# ------------------------------------------------------------------------------ +# Types +# ------------------------------------------------------------------------------ + +Visibility = Literal["public", "unlisted", "private", "direct"] + + +# ------------------------------------------------------------------------------ +# Accounts +# https://docs.joinmastodon.org/methods/accounts/ +# ------------------------------------------------------------------------------ + +async def register_account( + app: App, + auth_token: str, + username: str, + email: str, + password: str, + locale: str = "en", + agreement: bool = True +) -> Response: + url = f"{app.base_url}/api/v1/accounts" + headers = {"Authorization": f"Bearer {auth_token}"} + + json = { + "username": username, + "email": email, + "password": password, + "agreement": agreement, + "locale": locale + } + + return await request("POST", url, json=json, headers=headers) + + +async def verify_credentials(app, user): + return await auth_get(app, user, "/api/v1/accounts/verify_credentials") + + +# ------------------------------------------------------------------------------ +# Apps +# https://docs.joinmastodon.org/methods/apps/ +# ------------------------------------------------------------------------------ + +async def create_app(domain: str, scheme: str = "https") -> Response: + url = f"{scheme}://{domain}/api/v1/apps" + + json = { + "client_name": CLIENT_NAME, + "redirect_uris": "urn:ietf:wg:oauth:2.0:oob", + "scopes": "read write follow", + "website": CLIENT_WEBSITE, + } + + return await request("POST", url, json=json) + + +# ------------------------------------------------------------------------------ +# Instance +# https://docs.joinmastodon.org/methods/instance/ +# ------------------------------------------------------------------------------ + +async def instance_v1(url: str) -> Response: + return await request("GET", f"{url}/api/v1/instance") + + +async def instance_v2(url: str) -> Response: + return await request("GET", f"{url}/api/v2/instance") + + +# ------------------------------------------------------------------------------ +# Statuses +# https://docs.joinmastodon.org/methods/statuses/ +# ------------------------------------------------------------------------------ + +async def post_status( + app: App, + user: User, + status: str, + visibility: Visibility = "public", + media_ids: Optional[List[int]] = None, + sensitive: bool = False, + spoiler_text: Optional[str] = None, + in_reply_to_id: Optional[int] = None, + language: Optional[str] = None, + scheduled_at: Optional[str] = None, + content_type: Optional[str] = None, +) -> Response: + """ + Posts a new status. + https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#posting-a-new-status + """ + + # Idempotency key assures the same status is not posted multiple times + # if the request is retried. + headers = {"Idempotency-Key": uuid.uuid4().hex} + + params = { + "status": status, + "media_ids[]": media_ids, + "visibility": visibility, + "sensitive": str_bool(sensitive), + "spoiler_text": spoiler_text, + "in_reply_to_id": in_reply_to_id, + "language": language, + "scheduled_at": scheduled_at + } + + if content_type: + params["content_type"] = content_type + + return await auth_request(app, user, "POST", "/api/v1/statuses", json=params, headers=headers) + + +async def get_status(app: App, user: User, id: int) -> Response: + return await auth_request(app, user, "GET", f"/api/v1/statuses/{id}") + + +async def delete_status(app: App, user: User, id: int) -> Response: + return await auth_request(app, user, "DELETE", f"/api/v1/statuses/{id}") + + +async def timeline(app: App, user: User) -> Response: + return await auth_request(app, user, "GET", "/api/v1/timelines/home") + + +# ------------------------------------------------------------------------------ +# OAuth +# https://docs.joinmastodon.org/methods/apps/oauth/ +# ------------------------------------------------------------------------------ + +async def app_token(app): + json = { + "client_id": app.client_id, + "client_secret": app.client_secret, + "grant_type": "client_credentials", + "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", + "scope": "read write" + } + + return await request("POST", f"{app.base_url}/oauth/token", json=json) + + +# ------------------------------------------------------------------------------ +# ??? +# ------------------------------------------------------------------------------ + +async def search_accounts(app, user, query): + return await auth_request(app, user, "GET", "/api/v1/accounts/search", params={"q": query}) + + +# ------------------------------------------------------------------------------ +# Common +# ------------------------------------------------------------------------------ + +async def anon_get(url: str, params: Optional[Params] = None): + return await request("GET", url, params=params) + + +async def auth_get(app, user, path, params: Optional[Params] = None): + url = app.base_url + path + headers = {"Authorization": f"Bearer {user.access_token}"} + return await request("GET", url, params=params, headers=headers) + + +async def auth_request(app, user, method, path, /, *, headers={}, **kwargs): + url = app.base_url + path + headers.update({"Authorization": f"Bearer {user.access_token}"}) + return await request(method, url, headers=headers, **kwargs) diff --git a/toot/asynch/commands.py b/toot/asynch/commands.py new file mode 100644 index 0000000..7c824b5 --- /dev/null +++ b/toot/asynch/commands.py @@ -0,0 +1,227 @@ +import asyncio +import click +import logging +import os +import random +import sys + + +from functools import wraps +from typing import List, NamedTuple, Optional, Tuple + +from toot import App, User, __version__, config +from toot.asynch import api +from toot.asynch.entities import Account, InstanceV2, Status, from_dict, from_response +from toot.output import echo, print_out +from toot.utils import EOF_KEY, editor_input, multiline_input + +# Allow overriding options using environment variables +# https://click.palletsprojects.com/en/8.1.x/options/?highlight=auto_env#values-from-environment-variables + +# Tweak the Click context +# https://click.palletsprojects.com/en/8.1.x/api/#context +CONTEXT = dict( + # Enable using environment variables to set options + auto_envvar_prefix='TOOT', + # Add shorthand -h for invoking help + help_option_names=['-h', '--help'], + # Give help some more room (default is 80) + max_content_width=100, + # Always show default values for options + show_default=True, +) + + +def async_command(f): + # Integrating click with asyncio: + # https://github.com/pallets/click/issues/85#issuecomment-503464628 + @wraps(f) + def wrapper(*args, **kwargs): + return asyncio.run(f(*args, **kwargs)) + + return wrapper + + +def validate_language(ctx, param, value: str) -> str: + if value and len(value) != 3: + raise click.BadParameter( + "Expected a 3 letter abbreviation according to ISO 639-2 standard." + ) + + return value + + +# Data object to add to Click context +class Obj(NamedTuple): + app: Optional[App] + user: Optional[User] + color: bool + debug: bool + json: bool + quiet: bool + + +@click.group(context_settings=CONTEXT) +@click.option("--debug/--no-debug", default=False, help="Log debug info to stderr") +@click.option("--color/--no-color", default=sys.stdout.isatty(), help="Use ANSI color in output") +@click.option("--quiet/--no-quiet", default=False, help="Don't print anything to stdout") +@click.option("--json/--no-json", default=False, help="Print data as JSON rather than human readable textv") +@click.version_option(version=__version__, prog_name="toot") +@click.pass_context +def cli(ctx, debug: bool, color: bool, quiet: bool, json: bool): + user, app = config.get_active_user_app() + ctx.color = color + ctx.obj = Obj(app, user, color, debug, json, quiet) + if debug: + logging.basicConfig(level=logging.DEBUG) + + +@cli.command() +@click.argument("url", required=False) +@click.pass_context +@async_command +async def instance(ctx, url: Optional[str]): + base_url = url or ctx.obj.app.base_url + response = await api.instance_v2(base_url) + + if ctx.obj.json: + click.echo(response.body) + else: + instance = from_response(InstanceV2, response) + click.secho(instance.title, fg="green") + click.secho(url, fg="blue") + click.echo(f"Running Mastodon {instance.version}") + + +@cli.command() +@click.pass_context +@async_command +async def whoami(ctx): + response = await api.verify_credentials(ctx.obj.app, ctx.obj.user) + + if ctx.obj.json: + click.echo(response.body) + else: + account = from_response(Account, response) + click.echo(click.style(account.acct, fg="green", bold=True)) + click.echo(click.style(account.display_name, fg="yellow")) + click.echo(account.note_plaintext) + + +@cli.command() +@click.pass_context +@async_command +async def timeline(ctx): + response = await api.timeline(ctx.obj.app, ctx.obj.user) + + if ctx.obj.json: + click.echo(response.body) + else: + timeline = [from_dict(Status, s) for s in response.json()] + for status in timeline: + click.echo() + click.echo(status.original.account.username) + click.echo(status.original.content) + + +@cli.command() +@click.argument("text", required=False) +@click.option( + "-e", "--editor", is_flag=True, + flag_value=os.environ.get("EDITOR"), + show_default=os.environ.get("EDITOR"), + help="""Use an editor to compose your toot, defaults to editor defined in + the $EDITOR environment variable.""" +) +@click.option( + "-m", "--media", multiple=True, + help="""Path to a media file to attach (specify multiple times to attach up + to 4 files)""", +) +@click.option( + "-d", "--description", multiple=True, + help="""Plain-text description of the media for accessibility purposes, one + per attached media""", +) +@click.option( + "-l", "--language", + help="ISO 639-2 language code of the toot, to skip automatic detection", + callback=validate_language +) +def post( + text: str, + editor: str, + media: Tuple[str, ...], + description: Tuple[str, ...], + language: Optional[str], +): + if editor and not sys.stdin.isatty(): + raise click.UsageError("Cannot run editor if not in tty.") + + if media and len(media) > 4: + raise click.UsageError("Cannot attach more than 4 files.") + + echo("unstyled posting dim underline bold unstlyed") + echo("Bold bold and italic italic") + echo("foobar") + echo("\\foobar") + echo("plain blue blue and underline blue plain") + # echo("Done") + # media_ids = _upload_media(app, user, args) + # status_text = _get_status_text(text, editor) + + # if not status_text and not media_ids: + # raise click.UsageError("You must specify either text or media to post.") + + # response = api.post_status( + # app, user, status_text, + # visibility=args.visibility, + # media_ids=media_ids, + # sensitive=args.sensitive, + # spoiler_text=args.spoiler_text, + # in_reply_to_id=args.reply_to, + # language=args.language, + # scheduled_at=args.scheduled_at, + # content_type=args.content_type + # ) + + # if "scheduled_at" in response: + # print_out("Toot scheduled for: {}".format(response["scheduled_at"])) + # else: + # print_out("Toot posted: {}".format(response.get('url'))) + + +def _get_status_text(text, editor): + isatty = sys.stdin.isatty() + + if not text and not isatty: + text = sys.stdin.read().rstrip() + + if isatty: + if editor: + text = editor_input(editor, text) + elif not text: + print_out("Write or paste your toot. Press {} to post it.".format(EOF_KEY)) + text = multiline_input() + + return text + + +def _upload_media(app, user, args): + # Match media to corresponding description and upload + media = args.media or [] + descriptions = args.description or [] + uploaded_media = [] + + for idx, file in enumerate(media): + description = descriptions[idx].strip() if idx < len(descriptions) else None + result = _do_upload(app, user, file, description) + uploaded_media.append(result) + + return [m["id"] for m in uploaded_media] + + +def _do_upload(app, user, file: str, description: Optional[str]): + print("Faking upload:", file, description) + id = random.randint(1, 99999) + return {"id": id, "text_url": f"http://example.com/{id}"} diff --git a/toot/asynch/entities.py b/toot/asynch/entities.py new file mode 100644 index 0000000..10f2765 --- /dev/null +++ b/toot/asynch/entities.py @@ -0,0 +1,289 @@ +import dataclasses + +from dataclasses import dataclass, is_dataclass +from datetime import date, datetime +from typing import Dict, List, Optional, Type, TypeVar, Union +from typing import get_type_hints + +from toot.asynch.http import Response +from toot.typing_compat import get_args, get_origin +from toot.utils import get_text + + +@dataclass +class AccountField: + """ + https://docs.joinmastodon.org/entities/Account/#Field + """ + name: str + value: str + verified_at: Optional[datetime] + + +@dataclass +class CustomEmoji: + """ + https://docs.joinmastodon.org/entities/CustomEmoji/ + """ + shortcode: str + url: str + static_url: str + visible_in_picker: bool + category: str + + +@dataclass +class Account: + """ + https://docs.joinmastodon.org/entities/Account/ + """ + id: str + username: str + acct: str + url: str + display_name: str + note: str + avatar: str + avatar_static: str + header: str + header_static: str + locked: bool + fields: List[AccountField] + emojis: List[CustomEmoji] + bot: bool + group: bool + discoverable: Optional[bool] + noindex: Optional[bool] + moved: Optional["Account"] + suspended: Optional[bool] + limited: Optional[bool] + created_at: datetime + last_status_at: Optional[date] + statuses_count: int + followers_count: int + following_count: int + + @property + def note_plaintext(self) -> str: + return get_text(self.note) + + +@dataclass +class Application: + name: str + website: Optional[str] + + +@dataclass +class MediaAttachment: + id: str + type: str + url: str + preview_url: str + remote_url: Optional[str] + meta: dict + description: str + blurhash: str + + +@dataclass +class StatusMention: + """ + https://docs.joinmastodon.org/entities/Status/#Mention + """ + id: str + username: str + url: str + acct: str + + +@dataclass +class StatusTag: + """ + https://docs.joinmastodon.org/entities/Status/#Tag + """ + name: str + url: str + + +@dataclass +class PollOption: + """ + https://docs.joinmastodon.org/entities/Poll/#Option + """ + title: str + votes_count: Optional[int] + + +@dataclass +class Poll: + """ + https://docs.joinmastodon.org/entities/Poll/ + """ + id: str + expires_at: Optional[datetime] + expired: bool + multiple: bool + votes_count: int + voters_count: Optional[int] + options: List[PollOption] + emojis: List[CustomEmoji] + voted: Optional[bool] + own_votes: Optional[List[int]] + + +@dataclass +class PreviewCard: + url: str + title: str + description: str + type: str + author_name: str + author_url: str + provider_name: str + provider_url: str + html: str + width: int + height: int + image: Optional[str] + embed_url: str + blurhash: Optional[str] + + +@dataclass +class FilterKeyword: + id: str + keyword: str + whole_word: str + + +@dataclass +class FilterStatus: + id: str + status_id: str + + +@dataclass +class Filter: + id: str + title: str + context: List[str] + expires_at: Optional[datetime] + filter_action: str + keywords: List[FilterKeyword] + statuses: List[FilterStatus] + + +@dataclass +class FilterResult: + filter: Filter + keyword_matches: Optional[List[str]] + status_matches: Optional[str] + + +@dataclass +class Status: + id: str + uri: str + created_at: datetime + account: Account + content: str + visibility: str + sensitive: bool + spoiler_text: str + media_attachments: List[MediaAttachment] + application: Optional[Application] + mentions: List[StatusMention] + tags: List[StatusTag] + emojis: List[CustomEmoji] + reblogs_count: int + favourites_count: int + replies_count: int + url: Optional[str] + in_reply_to_id: Optional[str] + in_reply_to_account_id: Optional[str] + reblog: Optional["Status"] + poll: Optional[Poll] + card: Optional[PreviewCard] + language: Optional[str] + text: Optional[str] + edited_at: Optional[datetime] + favourited: Optional[bool] + reblogged: Optional[bool] + muted: Optional[bool] + bookmarked: Optional[bool] + pinned: Optional[bool] + filtered: Optional[List[FilterResult]] + + @property + def original(self) -> "Status": + return self.reblog or self + + +@dataclass +class InstanceV2: + domain: str + title: str + version: str + source_url: str + description: str + usage: dict # TODO expand + thumbnail: dict # TODO expand + languages: List[str] + configuration: dict # TODO expand + registrations: dict # TODO expand + contact: dict # TODO expand + rules: List[dict] # TODO expand + + +# Generic data class instance +T = TypeVar("T") + + +def from_dict(cls: Type[T], data: Dict) -> T: + def _fields(): + hints = get_type_hints(cls) + for field in dataclasses.fields(cls): + default = field.default if field.default is not dataclasses.MISSING else None + field_type = prune_optional(hints[field.name]) + value = data.get(field.name, default) + yield convert(field_type, value) + + return cls(*_fields()) + + +def from_response(cls: Type[T], response: Response) -> T: + return from_dict(cls, response.json()) + + +def convert(field_type, value): + if value is None: + return None + + if field_type in [str, int, bool, dict]: + return value + + if field_type == datetime: + return datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f%z") + + if field_type == date: + return date.fromisoformat(value) + + if get_origin(field_type) == list: + (inner_type,) = get_args(field_type) + return [convert(inner_type, x) for x in value] + + if is_dataclass(field_type): + return from_dict(field_type, value) + + raise ValueError(f"Not implemented for type '{field_type}'") + + +def prune_optional(field_type): + """For `Optional[]` returnes the encapsulated ``.""" + if get_origin(field_type) == Union: + args = get_args(field_type) + if len(args) == 2 and args[1] == type(None): + return args[0] + + return field_type diff --git a/toot/asynch/http.py b/toot/asynch/http.py new file mode 100644 index 0000000..1adac9c --- /dev/null +++ b/toot/asynch/http.py @@ -0,0 +1,80 @@ +import asyncio +import logging +import json + +from http import HTTPStatus +from dataclasses import dataclass +from toot import __version__ +from typing import Mapping, Dict, Optional, Tuple +from aiohttp import ClientSession, ClientResponse, TraceConfig + +logger = logging.getLogger(__name__) + +Params = Dict[str, str] +Headers = Dict[str, str] + + +@dataclass +class Response(): + body: str + headers: Mapping[str, str] + + def json(self): + return json.loads(self.body) + + +class ResponseError(Exception): + """Raised when the API retruns a response with status code >= 400.""" + def __init__(self, status_code, error, description): + self.status_code = status_code + self.error = error + self.description = description + + status_message = HTTPStatus(status_code).phrase + msg = f"HTTP {status_code} {status_message}" + msg += f". Error: {error}" if error else "" + msg += f". Description: {description}" if description else "" + super().__init__(msg) + + +async def request(method, url, **kwargs) -> Response: + common_headers = {"User-Agent": f"toot/{__version__}"} + trace_config = logger_trace_config() + + async with ClientSession(headers=common_headers, trace_configs=[trace_config]) as session: + async with session.request(method, url, **kwargs) as response: + if not response.ok: + error, description = await get_error(response) + raise ResponseError(response.status, error, description) + + body = await response.text() + return Response(body, response.headers) + + +async def get_error(response: ClientResponse) -> Tuple[Optional[str], Optional[str]]: + """Attempt to extract the error and error description from response body. + + See: https://docs.joinmastodon.org/entities/error/ + """ + try: + data = await response.json() + return data.get("error"), data.get("error_description") + except Exception: + pass + + return None, None + + +def logger_trace_config() -> TraceConfig: + async def on_request_start(session, context, params): + context.start = asyncio.get_event_loop().time() + logger.debug(f">>> {params.method} {params.url}") + + async def on_request_end(session, context, params): + elapsed = round(100 * (asyncio.get_event_loop().time() - context.start)) + logger.debug(f"<<< {params.method} {params.url} HTTP {params.response.status} {elapsed}ms") + + trace_config = TraceConfig() + trace_config.on_request_start.append(on_request_start) + trace_config.on_request_end.append(on_request_end) + return trace_config diff --git a/toot/asynch/lazy_entities.py b/toot/asynch/lazy_entities.py new file mode 100644 index 0000000..e896a3b --- /dev/null +++ b/toot/asynch/lazy_entities.py @@ -0,0 +1,287 @@ +import inspect +import json +import typing + +from datetime import date, datetime +from typing import List, Optional +from typing import get_origin, get_args +from functools import cache + +from toot.utils import get_text + + +@cache +def get_type_hints_cached(cls): + return typing.get_type_hints(cls) + + +def prune_optional(hint): + if get_origin(hint) == typing.Union: + args = get_args(hint) + if len(args) == 2 and args[1] == type(None): + return args[0] + + return hint + + +class Entity: + def __init__(self, json_data: str): + self._json = json_data + self._data = json.loads(json_data) + + def __getattr__(self, name): + hints = get_type_hints_cached(self.__class__) + if name not in hints: + raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'") + + # TODO: read default value from field definition somehow + default = None + value = self._data.get(name, default) + hint = prune_optional(hints[name]) + return self.convert(value, hint) + + def __repr__(self): + def _fields(): + hints = get_type_hints_cached(self.__class__) + for name, hint in hints.items(): + hint = prune_optional(hints[name]) + value = self._data.get(name) + if value is None: + yield f"{name}=None" + elif hint in [str, date, datetime]: + yield f"{name}='{value}'" + elif hint in [int, bool, dict]: + yield f"{name}={value}" + else: + yield f"{name}=..." + + name = self.__class__.__name__ + fields = ", ".join(_fields()) + return f"{name}({fields})" + + @property + def __dict__(self): + return self._data + + # TODO: override __dict__? + # TODO: make readonly + + # def __setattribute__(self, name): + # raise Exception("Entities are read-only") + + # def __delattribute__(self, name): + # raise Exception("Entities are read-only") + + def convert(self, value, hint): + if hint in [str, int, bool, dict]: + return value + + if hint == datetime: + return datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f%z") + + if hint == date: + return date.fromisoformat(value) + + if get_origin(hint) == list: + (inner_hint,) = get_args(hint) + return [self.convert(v, inner_hint) for v in value] + + if inspect.isclass(hint) and issubclass(hint, Entity): + return hint(value) + + raise ValueError(f"hint??? {hint}") + + +class AccountField(Entity): + """ + https://docs.joinmastodon.org/entities/Account/#Field + """ + name: str + value: str + verified_at: Optional[datetime] + + +class CustomEmoji(Entity): + """ + https://docs.joinmastodon.org/entities/CustomEmoji/ + """ + shortcode: str + url: str + static_url: str + visible_in_picker: bool + category: str + + +class Account(Entity): + """ + https://docs.joinmastodon.org/entities/Account/ + """ + id: str + username: str + acct: str + url: str + display_name: str + note: str + avatar: str + avatar_static: str + header: str + header_static: str + locked: bool + fields: List[AccountField] + emojis: List[CustomEmoji] + bot: bool + group: bool + discoverable: Optional[bool] + noindex: Optional[bool] + moved: Optional["Account"] + suspended: bool = False + limited: bool = False + created_at: datetime + last_status_at: Optional[date] + statuses_count: int + followers_count: int + following_count: int + + @property + def note_plaintext(self) -> str: + return get_text(self.note) + + +class Application(Entity): + name: str + website: str + + +class MediaAttachment(Entity): + id: str + type: str + url: str + preview_url: str + remote_url: Optional[str] + meta: dict + description: str + blurhash: str + + +class StatusMention(Entity): + """ + https://docs.joinmastodon.org/entities/Status/#Mention + """ + id: str + username: str + url: str + acct: str + + +class StatusTag(Entity): + """ + https://docs.joinmastodon.org/entities/Status/#Tag + """ + name: str + url: str + + +class PollOption(Entity): + """ + https://docs.joinmastodon.org/entities/Poll/#Option + """ + title: str + votes_count: Optional[int] + + +class Poll(Entity): + """ + https://docs.joinmastodon.org/entities/Poll/ + """ + id: str + expires_at: Optional[datetime] + expired: bool + multiple: bool + votes_count: int + voters_count: Optional[int] + options: List[PollOption] + emojis: List[CustomEmoji] + voted: Optional[bool] + own_votes: Optional[List[int]] + + +class PreviewCard(Entity): + url: str + title: str + description: str + type: str + author_name: str + author_url: str + provider_name: str + provider_url: str + html: str + width: int + height: int + image: Optional[str] + embed_url: str + blurhash: Optional[str] + + +class FilterKeyword(Entity): + id: str + keyword: str + whole_word: str + + +class FilterStatus(Entity): + id: str + status_id: str + + +class Filter(Entity): + id: str + title: str + context: List[str] + expires_at: Optional[datetime] + filter_action: str + keywords: List[FilterKeyword] + statuses: List[FilterStatus] + + +class FilterResult(Entity): + filter: Filter + keyword_matches: Optional[List[str]] + status_matches: Optional[str] + + +class Status(Entity): + id: str + uri: str + created_at: datetime + account: Account + content: str + visibility: str + sensitive: bool + spoiler_text: str + media_attachments: List[MediaAttachment] + application: Optional[Application] + mentions: List[StatusMention] + tags: List[StatusTag] + emojis: List[CustomEmoji] + reblogs_count: int + favourites_count: int + replies_count: int + url: Optional[str] + in_reply_to_id: Optional[str] + in_reply_to_account_id: Optional[str] + reblog: Optional["Status"] + poll: Optional[Poll] + card: Optional[PreviewCard] + language: Optional[str] + text: Optional[str] + edited_at: Optional[datetime] + favourited: bool = False + reblogged: bool = False + muted: bool = False + bookmarked: bool = False + pinned: bool = False + filtered: List[FilterResult] + + @property + def original(self): + return self.reblog or self diff --git a/toot/asynch/speed_test.py b/toot/asynch/speed_test.py new file mode 100644 index 0000000..b10fe3b --- /dev/null +++ b/toot/asynch/speed_test.py @@ -0,0 +1,50 @@ +from datetime import datetime +import asyncio +import httpx +import aiohttp + + +async def httpx_get(client, url): + start = datetime.now() + response = await client.get(url) + response.raise_for_status() + text = response.json() + print("httpx ", url, datetime.now() - start) + + +async def aiohttp_get(session, url): + start = datetime.now() + async with session.get(url) as response: + text = await response.json() + print("aiohttp", url, datetime.now() - start) + + +urls = [ + "https://chaos.social/api/v1/instance", + "https://chaos.social/api/v1/instance/peers", + "https://chaos.social/api/v1/timelines/public", +] + + +async def test_httpx(): + start = datetime.now() + async with httpx.AsyncClient() as client: + for url in urls: + for _ in range(3): + await httpx_get(client, url) + print("TOTAL", datetime.now() - start) + + +async def test_aiohttp(): + start = datetime.now() + async with aiohttp.ClientSession() as session: + for url in urls: + for _ in range(3): + await aiohttp_get(session, url) + print("TOTAL", datetime.now() - start) + + +def run(): + asyncio.run(test_httpx()) + print("") + asyncio.run(test_aiohttp()) diff --git a/toot/console.py b/toot/console.py index da4f3ce..0fd8374 100644 --- a/toot/console.py +++ b/toot/console.py @@ -1,3 +1,4 @@ +import asyncio import logging import os import re @@ -924,7 +925,8 @@ def main(): user, app = config.get_active_user_app() try: - run_command(app, user, command_name, args) + coro = run_command(app, user, command_name, args) + asyncio.run(coro) except (ConsoleError, ApiError) as e: print_err(str(e)) sys.exit(1) diff --git a/toot/output.py b/toot/output.py index 3414cdb..cc1a0bc 100644 --- a/toot/output.py +++ b/toot/output.py @@ -124,6 +124,14 @@ USE_ANSI_COLOR = use_ansi_color() QUIET = "--quiet" in sys.argv +def echo(message, nl=True, err=False): + import click + ctx = click.get_current_context() + if not ctx.obj.quiet: + message = colorize(message) + click.echo(message, nl=nl, err=err) + + def print_out(*args, **kwargs): if not QUIET: args = [colorize(a) if USE_ANSI_COLOR else strip_tags(a) for a in args] diff --git a/toot/tui/async_app.py b/toot/tui/async_app.py new file mode 100644 index 0000000..022b8f9 --- /dev/null +++ b/toot/tui/async_app.py @@ -0,0 +1,95 @@ +import asyncio +import httpx +import logging +import urwid + +from toot import App, User, config, __version__ +from urwid import font + +from toot.tui.constants import PALETTE + +logging.basicConfig(filename="debug.log", level=logging.INFO) +logger = logging.getLogger(__name__) + +urwid.set_encoding('UTF-8') + + +class Toot: + def __init__(self, user: User, app: App): + logger.info("init") + self.user = user + self.app = app + self.config = config.load_config() + self.layout = loading_screen() + + # Default max status length, updated on startup + self.max_toot_chars = 500 + + def create_layout(self): + self.txt = urwid.Text(u"Hello World") + return urwid.Filler(self.txt, "top") + + async def boot(self): + logger.info("boot") + await asyncio.gather( + self.load_max_toot_chars() + ) + logger.info(f"self.max_toot_chars: {self.max_toot_chars}") + + async def load_max_toot_chars(self): + """Some instances may have a non-default limit on toot size.""" + instance = await get_instance(self.app.instance) + if "max_toot_chars" in instance: + self.max_toot_chars = instance["max_toot_chars"] + + def run(self): + asyncio_loop = asyncio.get_event_loop() + main_loop = urwid.MainLoop( + self.layout, + event_loop=urwid.AsyncioEventLoop(loop=asyncio_loop), + palette=PALETTE, + unhandled_input=self.handle_keypress + ) + self._boot_task = asyncio_loop.create_task(self.boot()) + main_loop.run() + + def handle_keypress(self, key): + if key.lower() == "q": + raise urwid.ExitMainLoop() + + +async def get_instance(domain): + url = "https://{}/api/v1/instance".format(domain) + logger.info(f">>> {url}") + + async with httpx.AsyncClient() as client: + response = await client.get(url) + return response.json() + + +def main(): + user, app = config.get_active_user_app() + if user and app: + Toot(user, app).run() + + +def loading_screen(): + # NB: Padding with width="clip" will convert the fixed BigText widget + # to a flow widget so it can be used in a Pile. + big_text = "Toot {}".format(__version__) + big_text = urwid.BigText(("intro_bigtext", big_text), font.Thin6x6Font()) + big_text = urwid.Padding(big_text, align="center", width="clip") + + contents = urwid.Pile([ + big_text, + urwid.Divider(), + urwid.Text([ + "Maintained by ", + ("intro_smalltext", "@ihabunek"), + " and contributors" + ], align="center"), + urwid.Divider(), + urwid.Text(("intro_smalltext", "Loading toots..."), align="center"), + ]) + + return urwid.Filler(contents)