diff --git a/toot/async_api/__init__.py b/toot/async_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/toot/async_api/accounts.py b/toot/async_api/accounts.py new file mode 100644 index 00000000..c0c18f20 --- /dev/null +++ b/toot/async_api/accounts.py @@ -0,0 +1,121 @@ +""" +Accounts +https://docs.joinmastodon.org/methods/accounts/ +""" +from typing import List, Optional +from aiohttp import ClientResponse +from toot.async_http import request +from toot.cli import AsyncContext +from toot.utils import drop_empty_values, str_bool + +async def lookup(ctx: AsyncContext, acct: str) -> ClientResponse: + """ + Look up an account by name and return its info. + https://docs.joinmastodon.org/methods/accounts/#lookup + """ + return await request(ctx, "GET", "/api/v1/accounts/lookup", params={"acct": acct}) + + +async def relationships(ctx: AsyncContext, account_ids: List[str], *, with_suspended: bool) -> ClientResponse: + """ + Check relationships to other accounts + https://docs.joinmastodon.org/methods/accounts/#relationships + """ + # TODO: verify this works with passing a list here, worked in httpx + params = {"id[]": account_ids, "with_suspended": str_bool(with_suspended)} + return await request(ctx, "GET", "/api/v1/accounts/relationships", params=params) + + +async def verify_credentials(ctx: AsyncContext) -> ClientResponse: + """ + Test to make sure that the user token works. + https://docs.joinmastodon.org/methods/accounts/#verify_credentials + """ + return await request(ctx, "GET", "/api/v1/accounts/verify_credentials") + + +async def follow( + ctx: AsyncContext, + account_id: str, *, + reblogs: Optional[bool] = None, + notify: Optional[bool] = None, +) -> ClientResponse: + """ + Follow the given account. + Can also be used to update whether to show reblogs or enable notifications. + https://docs.joinmastodon.org/methods/accounts/#follow + """ + json = drop_empty_values({"reblogs": reblogs, "notify": notify}) + return await request(ctx, "POST", f"/api/v1/accounts/{account_id}/follow", json=json) + + +async def unfollow(ctx: AsyncContext, account_id: str) -> ClientResponse: + """ + Unfollow the given account. + https://docs.joinmastodon.org/methods/accounts/#unfollow + """ + return await request(ctx, "POST", f"/api/v1/accounts/{account_id}/unfollow") + + +async def remove_from_followers(ctx: AsyncContext, account_id: str) -> ClientResponse: + """ + Remove the given account from your followers. + https://docs.joinmastodon.org/methods/accounts/#remove_from_followers + """ + return await request(ctx, "POST", f"/api/v1/accounts/{account_id}/remove_from_followers") + + +async def block(ctx: AsyncContext, account_id: str) -> ClientResponse: + """ + Block the given account. + https://docs.joinmastodon.org/methods/accounts/#block + """ + return await request(ctx, "POST", f"/api/v1/accounts/{account_id}/block") + + +async def unblock(ctx: AsyncContext, account_id: str) -> ClientResponse: + """ + Unblock the given account. + https://docs.joinmastodon.org/methods/accounts/#unblock + """ + return await request(ctx, "POST", f"/api/v1/accounts/{account_id}/unblock") + + +async def mute(ctx: AsyncContext, account_id: str) -> ClientResponse: + """ + Mute the given account. + https://docs.joinmastodon.org/methods/accounts/#mute + """ + return await request(ctx, "POST", f"/api/v1/accounts/{account_id}/mute") + + +async def unmute(ctx: AsyncContext, account_id: str) -> ClientResponse: + """ + Unmute the given account. + https://docs.joinmastodon.org/methods/accounts/#unmute + """ + return await request(ctx, "POST", f"/api/v1/accounts/{account_id}/unmute") + + +async def pin(ctx: AsyncContext, account_id: str) -> ClientResponse: + """ + Add the given account to the user’s featured profiles. + https://docs.joinmastodon.org/methods/accounts/#pin + """ + return await request(ctx, "POST", f"/api/v1/accounts/{account_id}/pin") + + +async def unpin(ctx: AsyncContext, account_id: str) -> ClientResponse: + """ + Remove the given account from the user’s featured profiles. + https://docs.joinmastodon.org/methods/accounts/#unpin + """ + return await request(ctx, "POST", f"/api/v1/accounts/{account_id}/unpin") + + +async def note(ctx: AsyncContext, account_id: str, comment: str) -> ClientResponse: + """ + Sets a private note on a user. + https://docs.joinmastodon.org/methods/accounts/#note + """ + return await request(ctx, "POST", f"/api/v1/accounts/{account_id}/note", json={"comment": comment}) diff --git a/toot/async_api/instance.py b/toot/async_api/instance.py new file mode 100644 index 00000000..a22182d4 --- /dev/null +++ b/toot/async_api/instance.py @@ -0,0 +1,40 @@ +""" +Accounts +https://docs.joinmastodon.org/methods/instance/ +""" +from aiohttp import ClientResponse + +from toot.async_http import request +from toot.cli import Context + + +async def server_information(ctx: Context) -> ClientResponse: + """ + Obtain general information about the server. + https://docs.joinmastodon.org/methods/instance/#v1 + """ + return await request(ctx, "GET", "/api/v1/instance") + + +async def server_information_v2(ctx: Context) -> ClientResponse: + """ + Obtain general information about the server. + https://docs.joinmastodon.org/methods/instance/#v2 + """ + return await request(ctx, "GET", "/api/v2/instance") + + +async def extended_description(ctx: Context) -> ClientResponse: + """ + Obtain an extended description of this server + https://docs.joinmastodon.org/methods/instance/#extended_description + """ + return await request(ctx, "GET", "/api/v1/instance/extended_description") + + +async def user_preferences(ctx: Context) -> ClientResponse: + """ + Fetch the user's server-side preferences for this instance. + https://docs.joinmastodon.org/methods/preferences/ + """ + return await request(ctx, "GET", "/api/v1/preferences") diff --git a/toot/async_api/notifications.py b/toot/async_api/notifications.py new file mode 100644 index 00000000..1abaf0fd --- /dev/null +++ b/toot/async_api/notifications.py @@ -0,0 +1,16 @@ +""" +Notifications API +https://docs.joinmastodon.org/methods/notifications/ +""" +from aiohttp import ClientResponse + +from toot.async_http import request +from toot.cli import Context + + +async def get(ctx: Context, notification_id: str) -> ClientResponse: + """ + Fetch a single notification. + https://docs.joinmastodon.org/methods/notifications/#get-one + """ + return await request(ctx, "GET", f"/api/v1/notifications/{notification_id}") diff --git a/toot/async_api/search.py b/toot/async_api/search.py new file mode 100644 index 00000000..7ded6064 --- /dev/null +++ b/toot/async_api/search.py @@ -0,0 +1,19 @@ +""" +Search endpoints +https://docs.joinmastodon.org/methods/search/ +""" + +from aiohttp import ClientResponse +from toot.async_http import request +from toot.cli import Context + + +async def search(ctx: Context, query: str) -> ClientResponse: + """ + Perform a search + https://docs.joinmastodon.org/methods/search/#v2 + """ + return await request(ctx, "GET", "/api/v2/search", params={ + "q": query, + # "type": "hashtags" + }) diff --git a/toot/async_api/statuses.py b/toot/async_api/statuses.py new file mode 100644 index 00000000..6ae86423 --- /dev/null +++ b/toot/async_api/statuses.py @@ -0,0 +1,132 @@ +""" +Statuses API +https://docs.joinmastodon.org/methods/statuses/ +""" +from typing import Any, Dict, List, Optional +from aiohttp import ClientResponse +from uuid import uuid4 + +from toot.async_http import request +from toot.cli import Context + + +async def get(ctx: Context, status_id: str)-> ClientResponse: + """ + Fetch a single status. + https://docs.joinmastodon.org/methods/statuses/#get + """ + return await request(ctx, "GET", f"/api/v1/statuses/{status_id}") + + +async def context(ctx: Context, status_id: str)-> ClientResponse: + """ + View statuses above and below this status in the thread. + https://docs.joinmastodon.org/methods/statuses/#context + """ + return await request(ctx, "GET", f"/api/v1/statuses/{status_id}/context") + + +async def post( + ctx: Context, + status: str, + visibility: str = "public", + sensitive: bool = False, + spoiler_text: Optional[str] = None, + in_reply_to: Optional[str] = None, + local_only: Optional[bool] = None, + media_ids: Optional[List[str]] = None, +)-> ClientResponse: + # Idempotency key assures the same status is not posted multiple times + # if the request is retried. + headers = {"Idempotency-Key": uuid4().hex} + + payload = drop_empty_values({ + "status": status, + "visibility": visibility, + "sensitive": sensitive, + "spoiler_text": spoiler_text, + "in_reply_to_id": in_reply_to, + "local_only": local_only, + "media_ids": media_ids, + }) + + return await request(ctx, "POST", "/api/v1/statuses", headers=headers, json=payload) + + +async def edit( + ctx: Context, + status_id: str, + status: str, + visibility: str = "public", + sensitive: bool = False, + spoiler_text: Optional[str] = None, + media_ids: Optional[List[str]] = None, +)-> ClientResponse: + """ + Edit an existing status. + https://docs.joinmastodon.org/methods/statuses/#edit + """ + + payload = drop_empty_values({ + "status": status, + "visibility": visibility, + "sensitive": sensitive, + "spoiler_text": spoiler_text, + "media_ids": media_ids, + }) + + return await request(ctx, "PUT", f"/api/v1/statuses/{status_id}", json=payload) + + +async def delete(ctx: Context, status_id: str)-> ClientResponse: + return await request(ctx, "DELETE", f"/api/v1/statuses/{status_id}") + + +def drop_empty_values(data: Dict[Any, Any]) -> Dict[Any, Any]: + """Remove keys whose values are null""" + return {k: v for k, v in data.items() if v is not None} + + +async def source(ctx: Context, status_id: str): + """ + Fetch the original plaintext source for a status. Only works on locally-posted statuses. + https://docs.joinmastodon.org/methods/statuses/#source + """ + path = f"/api/v1/statuses/{status_id}/source" + return await request(ctx, "GET", path) + + +async def favourite(ctx: Context, status_id: str): + """ + Add a status to your favourites list. + https://docs.joinmastodon.org/methods/statuses/#favourite + """ + path = f"/api/v1/statuses/{status_id}/favourite" + return await request(ctx, "POST", path) + + +async def unfavourite(ctx: Context, status_id: str): + """ + Remove a status from your favourites list. + https://docs.joinmastodon.org/methods/statuses/#unfavourite + """ + path = f"/api/v1/statuses/{status_id}/unfavourite" + return await request(ctx, "POST", path) + + +async def boost(ctx: Context, status_id: str): + """ + Reshare a status on your own profile. + https://docs.joinmastodon.org/methods/statuses/#boost + """ + path = f"/api/v1/statuses/{status_id}/reblog" + return await request(ctx, "POST", path) + + +async def unboost(ctx: Context, status_id: str): + """ + Undo a reshare of a status. + https://docs.joinmastodon.org/methods/statuses/#unreblog + """ + path = f"/api/v1/statuses/{status_id}/unreblog" + return await request(ctx, "POST", path) diff --git a/toot/async_http.py b/toot/async_http.py new file mode 100644 index 00000000..c24d92d9 --- /dev/null +++ b/toot/async_http.py @@ -0,0 +1,96 @@ +import logging +import time +from typing import TYPE_CHECKING, Optional, Tuple + +import aiohttp +from aiohttp import ClientResponse +from aiohttp.client import _RequestOptions + +from toot import App, User +from toot.cli import AsyncContext + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from typing_extensions import Unpack + + +class APIError(Exception): + """Represents an error response from the API.""" + + def __init__(self, message: Optional[str] = None, cause: Optional[Exception] = None): + assert message or cause + self.message = message or str(cause) + self.cause = cause + super().__init__(self.message) + + +class ResponseError(APIError): + """Raised when the API returns a response with status code >= 400.""" + + def __init__(self, status_code: int, error: Optional[str], description: Optional[str]): + self.status_code = status_code + self.error = error + self.description = description + + msg = f"HTTP {status_code}" + msg += f". Error: {error}" if error else "" + msg += f". Description: {description}" if description else "" + super().__init__(msg) + + +def create_client_session(user: User, app: App): + return aiohttp.ClientSession( + base_url=app.base_url, + headers={"Authorization": f"Bearer {user.access_token}"}, + ) + + +async def request(ctx: AsyncContext, method: str, url: str, **kwargs: "Unpack[_RequestOptions]") -> ClientResponse: + started_at = time.time() + log_request(method, url, **kwargs) + + try: + async with ctx.session.request(method, url, **kwargs) as response: + log_response(response, started_at) + if response.ok: + await response.read() + return response + else: + error, description = await get_error(response) + raise ResponseError(response.status, error, description) + except aiohttp.ClientError as ex: + log_error(method, url, ex) + raise APIError(cause=ex) + + +def log_request(method: str, url: str, **kwargs: "Unpack[_RequestOptions]"): + logger.info(f"--> {method} {url}") + for key in ["params", "data", "json", "files"]: + if key in kwargs: + logger.debug(f"--> {key}={kwargs[key]}") + + +def log_response(response: ClientResponse, started_at: float): + request = response.request_info + duration_ms = int(1000 * (time.time() - started_at)) + logger.info( + f"<-- {request.method} {request.url.path} HTTP {response.status} {duration_ms}ms" + ) + + +def log_error(method: str, url: str, ex: Exception): + logger.error(f"<-- {method} {url} Exception: {str(ex)}") + logger.exception(ex) + + +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: + return None, None diff --git a/toot/cli/__init__.py b/toot/cli/__init__.py index f5258410..5c494c83 100644 --- a/toot/cli/__init__.py +++ b/toot/cli/__init__.py @@ -1,3 +1,7 @@ +import asyncio +from dataclasses import dataclass +from aiohttp import ClientSession +import aiohttp import click import logging import os @@ -73,7 +77,6 @@ def get_default_map(): default_map=get_default_map(), ) - class Context(t.NamedTuple): app: t.Optional[App] user: t.Optional[User] = None @@ -81,6 +84,15 @@ class Context(t.NamedTuple): debug: bool = False +@dataclass(frozen=True) +class AsyncContext(): + session: ClientSession + app: t.Optional[App] + user: t.Optional[User] = None + color: bool = False + debug: bool = False + + class TootObj(t.NamedTuple): """Data to add to Click context""" color: bool = True @@ -117,6 +129,7 @@ def shell_complete(self, ctx, param, incomplete: str): ] + def pass_context(f: "t.Callable[te.Concatenate[Context, P], R]") -> "t.Callable[P, R]": """Pass the toot Context as first argument.""" @wraps(f) @@ -126,6 +139,37 @@ def wrapped(*args: "P.args", **kwargs: "P.kwargs") -> R: return wrapped +def async_cmd(f): + @wraps(f) + def wrapper(*args, **kwargs): + return asyncio.run(f(*args, **kwargs)) + + return wrapper + + +def async_pass_context(f: "t.Callable[te.Concatenate[AsyncContext, P], t.Awaitable[R]]") -> "t.Callable[P, t.Awaitable[R]]": + """Pass the toot Context as first argument.""" + @wraps(f) + async def wrapped(*args: "P.args", **kwargs: "P.kwargs") -> R: + ctx = get_context() + + print(ctx.app) + print(ctx.user) + + # TODO: anon stuff + base_url = ctx.app.base_url + headers = {"Authorization": f"Bearer {ctx.user.access_token}"} + + async with aiohttp.ClientSession(base_url=base_url, headers=headers) as session: + async_ctx = AsyncContext(session, ctx.app, ctx.user, ctx.color, ctx.debug) + try: + return await f(async_ctx, *args, **kwargs) + finally: + await session.close() + + return wrapped + + def get_context() -> Context: click_context = click.get_current_context() obj: TootObj = click_context.obj diff --git a/toot/cli/read.py b/toot/cli/read.py index 32ce49a5..6a9b2949 100644 --- a/toot/cli/read.py +++ b/toot/cli/read.py @@ -5,24 +5,26 @@ from typing import Optional from toot import api +from toot.async_api import accounts from toot.cli.validators import validate_instance from toot.entities import Instance, Status, from_dict, Account from toot.exceptions import ApiError, ConsoleError from toot.output import print_account, print_instance, print_search_results, print_status, print_timeline -from toot.cli import InstanceParamType, cli, get_context, json_option, pass_context, Context +from toot.cli import AsyncContext, InstanceParamType, async_cmd, async_pass_context, cli, get_context, json_option, pass_context, Context @cli.command() @json_option -@pass_context -def whoami(ctx: Context, json: bool): +@async_cmd +@async_pass_context +async def whoami(ctx: AsyncContext, json: bool): """Display logged in user details""" - response = api.verify_credentials(ctx.app, ctx.user) + response = await accounts.verify_credentials(ctx) if json: - click.echo(response.text) + click.echo(await response.text()) else: - account = from_dict(Account, response.json()) + account = from_dict(Account, await response.json()) print_account(account)