import asyncio import functools from contextlib import asynccontextmanager from dataclasses import dataclass from types import TracebackType from typing import Any, AsyncIterator, Awaitable, Callable, List, Optional, Type import aiohttp import click from yarl import URL @dataclass(frozen=True) class Post: id: int owner: str editor: str title: str text: Optional[str] # post listing doesn't return text field def pprint(self) -> None: click.echo(f"Post {self.id}") click.echo(f" Owner: {self.owner}") click.echo(f" Editor: {self.editor}") click.echo(f" Title: {self.title}") if self.text is not None: click.echo(f" Text: {self.text}") class Client: def __init__(self, base_url: URL, user: str) -> None: self._base_url = base_url self._user = user self._client = aiohttp.ClientSession(raise_for_status=True) async def close(self) -> None: return await self._client.close() async def __aenter__(self) -> "Client": return self async def __aexit__( self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType], ) -> Optional[bool]: await self.close() return None def _make_url(self, path: str) -> URL: return self._base_url / path async def create(self, title: str, text: str) -> Post: async with self._client.post( self._make_url("api"), json={"owner": self._user, "title": title, "text": text}, ) as resp: ret = await resp.json() return Post(**ret["data"]) async def get(self, post_id: int) -> Post: async with self._client.get(self._make_url(f"api/{post_id}")) as resp: ret = await resp.json() return Post(**ret["data"]) async def delete(self, post_id: int) -> None: async with self._client.delete(self._make_url(f"api/{post_id}")) as resp: resp # to make linter happy async def update( self, post_id: int, title: Optional[str] = None, text: Optional[str] = None ) -> Post: json = {"editor": self._user} if title is not None: json["title"] = title if text is not None: json["text"] = text async with self._client.patch( self._make_url(f"api/{post_id}"), json=json ) as resp: ret = await resp.json() return Post(**ret["data"]) async def list(self) -> List[Post]: async with self._client.get(self._make_url(f"api")) as resp: ret = await resp.json() return [Post(text=None, **item) for item in ret["data"]] @dataclass(frozen=True) class Root: base_url: URL user: str show_traceback: bool @asynccontextmanager async def client(self) -> AsyncIterator[Client]: client = Client(self.base_url, self.user) try: yield client finally: await client.close() def async_cmd(func: Callable[..., Awaitable[None]]) -> Callable[..., None]: @functools.wraps(func) def inner(root: Root, **kwargs: Any) -> None: try: return asyncio.run(func(root, **kwargs)) except Exception as exc: if root.show_traceback: raise else: click.echo(f"Error: {exc}") inner = click.pass_obj(inner) return inner @click.group() @click.option( "--base-url", type=str, default="http://localhost:8080", show_default=True ) @click.option("--user", type=str, default="Anonymous", show_default=True) @click.option("--show-traceback", is_flag=True, default=False, show_default=True) @click.pass_context def main(ctx: click.Context, base_url: str, user: str, show_traceback: bool) -> None: """REST client for tutorial server""" ctx.obj = Root(URL(base_url), user, show_traceback) @main.command() @click.option("--title", type=str, required=True) @click.option("--text", type=str, required=True) @async_cmd async def create(root: Root, title: str, text: str) -> None: """Create new blog post""" async with root.client() as client: post = await client.create(title, text) click.echo(f"Created post {post.id}") post.pprint() @main.command() @click.argument("post_id", type=int) @async_cmd async def get(root: Root, post_id: int) -> None: """Get detailed info about blog post""" async with root.client() as client: post = await client.get(post_id) post.pprint() @main.command() @click.argument("post_id", type=int) @async_cmd async def delete(root: Root, post_id: int) -> None: """Delete blog post""" async with root.client() as client: await client.delete(post_id) click.echo(f"Post {post_id} is deleted") @main.command() @click.argument("post_id", type=int) @click.option("--text", type=str) @click.option("--title", type=str) @async_cmd async def update( root: Root, post_id: int, title: Optional[str], text: Optional[str] ) -> None: """Update existing blog post""" async with root.client() as client: post = await client.update(post_id, title, text) post.pprint() @main.command() @async_cmd async def list(root: Root) -> None: """List existing blog posts""" async with root.client() as client: posts = await client.list() click.echo("List posts:") for post in posts: post.pprint() if __name__ == "__main__": main()