diff --git a/Dockerfile b/Dockerfile index e8c50fb..9349fef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ RUN adduser -D luser USER luser WORKDIR /home/luser COPY --chown=luser:users ./requirements-dev.txt ./requirements-dev.txt -COPY --chown=luser:users ./requirements-dev.txt ./requirements-run.txt +COPY --chown=luser:users ./requirements-run.txt ./requirements-run.txt WORKDIR /home/luser/ RUN python -m venv .venv RUN --mount=type=cache,target=/home/luser/.cache/pip,uid=1000,gid=1000 .venv/bin/pip wheel -w /home/luser/wheel -r requirements-dev.txt pygraphviz diff --git a/pyproject.toml b/pyproject.toml index 8e6bb7e..8ee307c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "PyYAML", "pygraphviz", "aiofiles", - "aiohttp[speedups]" + "httpx[http2]" ] [project.optional-dependencies] diff --git a/requirements-dev.txt b/requirements-dev.txt index 6b1cbe2..22ac57a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,38 +2,27 @@ # This file is autogenerated by pip-compile with Python 3.10 # by the following command: # -# pip-compile --extra-index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --extra=dev --output-file=requirements-dev.txt --strip-extras pyproject.toml +# pip-compile --extra-index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --extra=dev --output-file=requirements-dev.txt pyproject.toml # --extra-index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple -aiodns==3.2.0 - # via aiohttp aiofiles==24.1.0 # via bugis (pyproject.toml) -aiohappyeyeballs==2.4.3 - # via aiohttp -aiohttp==3.10.10 - # via bugis (pyproject.toml) -aiosignal==1.3.1 - # via aiohttp +anyio==4.6.2.post1 + # via httpx asttokens==2.4.1 # via stack-data -async-timeout==4.0.3 - # via aiohttp -attrs==24.2.0 - # via aiohttp backports-tarfile==1.2.0 # via jaraco-context -brotli==1.1.0 - # via aiohttp build==1.2.2.post1 # via bugis (pyproject.toml) certifi==2024.8.30 - # via requests -cffi==1.17.1 # via - # cryptography - # pycares + # httpcore + # httpx + # requests +cffi==1.17.1 + # via cryptography charset-normalizer==3.4.0 # via requests click==8.1.7 @@ -47,19 +36,30 @@ decorator==5.1.1 docutils==0.21.2 # via readme-renderer exceptiongroup==1.2.2 - # via ipython + # via + # anyio + # ipython executing==2.1.0 # via stack-data -frozenlist==1.4.1 - # via - # aiohttp - # aiosignal granian==1.6.1 # via bugis (pyproject.toml) +h11==0.14.0 + # via httpcore +h2==4.1.0 + # via httpx +hpack==4.0.0 + # via h2 +httpcore==1.0.6 + # via httpx +httpx[http2]==0.27.2 + # via bugis (pyproject.toml) +hyperframe==6.0.1 + # via h2 idna==3.10 # via + # anyio + # httpx # requests - # yarl importlib-metadata==8.5.0 # via # keyring @@ -94,10 +94,6 @@ more-itertools==10.5.0 # via # jaraco-classes # jaraco-functools -multidict==6.1.0 - # via - # aiohttp - # yarl mypy==1.12.1 # via bugis (pyproject.toml) mypy-extensions==1.0.0 @@ -114,16 +110,12 @@ pkginfo==1.10.0 # via twine prompt-toolkit==3.0.48 # via ipython -propcache==0.2.0 - # via yarl ptyprocess==0.7.0 # via pexpect pure-eval==0.2.3 # via stack-data pwo==0.0.3 # via bugis (pyproject.toml) -pycares==4.4.0 - # via aiodns pycparser==2.22 # via cffi pygments==2.18.0 @@ -154,6 +146,10 @@ secretstorage==3.3.3 # via keyring six==1.16.0 # via asttokens +sniffio==1.3.1 + # via + # anyio + # httpx stack-data==0.6.3 # via ipython tomli==2.0.2 @@ -169,8 +165,8 @@ twine==5.1.1 # via bugis (pyproject.toml) typing-extensions==4.7.1 # via + # anyio # ipython - # multidict # mypy # pwo # rich @@ -184,7 +180,5 @@ watchdog==5.0.3 # via bugis (pyproject.toml) wcwidth==0.2.13 # via prompt-toolkit -yarl==1.16.0 - # via aiohttp zipp==3.20.2 # via importlib-metadata diff --git a/requirements-run.txt b/requirements-run.txt index e56da45..9dd9a6f 100644 --- a/requirements-run.txt +++ b/requirements-run.txt @@ -2,65 +2,59 @@ # This file is autogenerated by pip-compile with Python 3.10 # by the following command: # -# pip-compile --extra-index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --extra=run --output-file=requirements-run.txt --strip-extras pyproject.toml +# pip-compile --extra-index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --extra=run --output-file=requirements-run.txt pyproject.toml # --extra-index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple -aiodns==3.2.0 - # via aiohttp aiofiles==24.1.0 # via bugis (pyproject.toml) -aiohappyeyeballs==2.4.3 - # via aiohttp -aiohttp==3.10.10 - # via bugis (pyproject.toml) -aiosignal==1.3.1 - # via aiohttp -async-timeout==4.0.3 - # via aiohttp -attrs==24.2.0 - # via aiohttp -brotli==1.1.0 - # via aiohttp -cffi==1.17.1 - # via pycares +anyio==4.6.2.post1 + # via httpx +certifi==2024.8.30 + # via + # httpcore + # httpx click==8.1.7 # via granian -frozenlist==1.4.1 - # via - # aiohttp - # aiosignal +exceptiongroup==1.2.2 + # via anyio granian==1.6.1 # via bugis (pyproject.toml) +h11==0.14.0 + # via httpcore +h2==4.1.0 + # via httpx +hpack==4.0.0 + # via h2 +httpcore==1.0.6 + # via httpx +httpx[http2]==0.27.2 + # via bugis (pyproject.toml) +hyperframe==6.0.1 + # via h2 idna==3.10 - # via yarl + # via + # anyio + # httpx markdown==3.7 # via bugis (pyproject.toml) -multidict==6.1.0 - # via - # aiohttp - # yarl -propcache==0.2.0 - # via yarl pwo==0.0.3 # via bugis (pyproject.toml) -pycares==4.4.0 - # via aiodns -pycparser==2.22 - # via cffi pygments==2.18.0 # via bugis (pyproject.toml) pygraphviz==1.14 # via bugis (pyproject.toml) pyyaml==6.0.2 # via bugis (pyproject.toml) +sniffio==1.3.1 + # via + # anyio + # httpx typing-extensions==4.7.1 # via - # multidict + # anyio # pwo uvloop==0.21.0 # via granian watchdog==5.0.3 # via bugis (pyproject.toml) -yarl==1.16.0 - # via aiohttp diff --git a/requirements.txt b/requirements.txt index bd0a0e2..8323611 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,55 +6,49 @@ # --extra-index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple -aiodns==3.2.0 - # via aiohttp aiofiles==24.1.0 # via bugis (pyproject.toml) -aiohappyeyeballs==2.4.3 - # via aiohttp -aiohttp==3.10.10 - # via bugis (pyproject.toml) -aiosignal==1.3.1 - # via aiohttp -async-timeout==4.0.3 - # via aiohttp -attrs==24.2.0 - # via aiohttp -brotli==1.1.0 - # via aiohttp -cffi==1.17.1 - # via pycares -frozenlist==1.4.1 +anyio==4.6.2.post1 + # via httpx +certifi==2024.8.30 # via - # aiohttp - # aiosignal + # httpcore + # httpx +exceptiongroup==1.2.2 + # via anyio +h11==0.14.0 + # via httpcore +h2==4.1.0 + # via httpx +hpack==4.0.0 + # via h2 +httpcore==1.0.6 + # via httpx +httpx==0.27.2 + # via bugis (pyproject.toml) +hyperframe==6.0.1 + # via h2 idna==3.10 - # via yarl + # via + # anyio + # httpx markdown==3.7 # via bugis (pyproject.toml) -multidict==6.1.0 - # via - # aiohttp - # yarl -propcache==0.2.0 - # via yarl pwo==0.0.3 # via bugis (pyproject.toml) -pycares==4.4.0 - # via aiodns -pycparser==2.22 - # via cffi pygments==2.18.0 # via bugis (pyproject.toml) pygraphviz==1.14 # via bugis (pyproject.toml) pyyaml==6.0.2 # via bugis (pyproject.toml) +sniffio==1.3.1 + # via + # anyio + # httpx typing-extensions==4.7.1 # via - # multidict + # anyio # pwo watchdog==5.0.3 # via bugis (pyproject.toml) -yarl==1.16.0 - # via aiohttp diff --git a/src/bugis/asgi.py b/src/bugis/asgi.py index c40d468..232b2e2 100644 --- a/src/bugis/asgi.py +++ b/src/bugis/asgi.py @@ -14,14 +14,14 @@ from pwo import Maybe from .server import Server from asyncio import get_running_loop from .asgi_utils import decode_headers -from typing import Optional +from typing import Optional, Awaitable, Callable, Any, Mapping log = logging.getLogger('access') log.propagate = False _server : Optional[Server] = None -async def application(scope, receive, send): +async def application(scope, receive, send : Callable[[Mapping[str, Any]], Awaitable[None]]): global _server if scope['type'] == 'lifespan': while True: diff --git a/src/bugis/plantuml.py b/src/bugis/plantuml.py index aa27cd1..f9dc15f 100644 --- a/src/bugis/plantuml.py +++ b/src/bugis/plantuml.py @@ -1,17 +1,19 @@ from typing import TYPE_CHECKING from aiofiles import open as async_open -from aiohttp import ClientSession from .configuration import Configuration -from yarl import URL if TYPE_CHECKING: from _typeshed import StrOrBytesPath +from httpx import AsyncClient, URL +from typing import Callable, Awaitable +from urllib.parse import urljoin -async def render_plant_uml(path: 'StrOrBytesPath') -> bytes: - async with ClientSession() as session: - url = URL(Configuration.instance.plant_uml_server_address) / 'svg' - async with async_open(path, 'rb') as file: - source = await file.read() - async with session.post(url, data=source) as response: - response.raise_for_status() - return await response.read() +chunk_size = 0x10000 +async def render_plant_uml(client: AsyncClient, path: 'StrOrBytesPath', send : Callable[[bytes], Awaitable[None]]): + url = URL(urljoin(Configuration.instance.plant_uml_server_address, 'svg')) + async with async_open(path, 'rb') as file: + source = await file.read() + response = await client.post(url, content=source) + response.raise_for_status() + async for chunk in response.aiter_bytes(chunk_size=chunk_size): + await send(chunk) diff --git a/src/bugis/server.py b/src/bugis/server.py index 640b5b0..490dfc3 100644 --- a/src/bugis/server.py +++ b/src/bugis/server.py @@ -6,7 +6,7 @@ from io import BytesIO from mimetypes import init as mimeinit, guess_type from os import getcwd from os.path import join, normpath, splitext, relpath, basename -from typing import Callable, TYPE_CHECKING, Optional, Awaitable, AsyncGenerator, Any +from typing import Callable, TYPE_CHECKING, Optional, Awaitable, AsyncGenerator, Any, Mapping import pygraphviz as pgv from aiofiles import open as async_open @@ -14,6 +14,7 @@ from aiofiles.base import AiofilesContextManager from aiofiles.os import listdir from aiofiles.ospath import exists, isdir, isfile, getmtime from aiofiles.threadpool.binary import AsyncBufferedReader +from httpx import AsyncClient from pwo import Maybe from .asgi_utils import encode_headers @@ -52,7 +53,10 @@ def is_plant_uml(filepath): logger = logging.getLogger(__name__) class Server: - _loop : AbstractEventLoop + root_dir: 'StrOrBytesPath' + prefix: Optional['StrOrBytesPath'] + _loop: AbstractEventLoop + _client: AsyncClient def __init__(self, root_dir: 'StrOrBytesPath' = getcwd(), @@ -63,8 +67,15 @@ class Server: self.file_watcher = FileWatcher(cwd) self.prefix = prefix and normpath(f'{prefix.decode()}') self._loop = loop + self._client = AsyncClient() - async def handle_request(self, method: str, url_path: str, etag: Optional[str], query_string: Optional[str], send): + async def handle_request(self, + method: str, + url_path: str, + etag: Optional[str], + query_string: Optional[str], + send: Callable[[Mapping[str, Any]], Awaitable[None]] + ): if method != 'GET': await send({ 'type': 'http.response.start', @@ -88,7 +99,7 @@ class Server: lambda: completed_future(mtime) ) if etag and etag == digest: - await self.not_modified(send, digest, ('Cache-Control', 'must-revalidate, max-age=86400')) + await self.not_modified(send, digest, 'must-revalidate, max-age=86400') return elif content: mime_type = guess_type(basename(url_path))[0] or 'application/octet-stream' @@ -163,7 +174,6 @@ class Server: }) elif is_plant_uml(path): logger.debug("Starting PlantUML rendering for file '%s'", path) - body = await render_plant_uml(path) logger.debug("Completed PlantUML rendering for file '%s'", path) await send({ 'type': 'http.response.start', @@ -174,9 +184,15 @@ class Server: 'Cache-Control': 'no-cache' }) }) + await render_plant_uml(self._client, path, lambda chunk: send({ + 'type': 'http.response.body', + 'body': chunk, + 'more_body': True + })) await send({ 'type': 'http.response.body', - 'body': body + 'body': '', + 'more_body': False }) else: async def read_file(file_path): @@ -373,4 +389,5 @@ class Server: return result async def stop(self): - await self.file_watcher.stop() \ No newline at end of file + await self.file_watcher.stop() + await self._client.aclose()