diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index fe230e8..05f187b 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -16,19 +16,22 @@ jobs: - uses: actions/setup-python@v5 with: cache: 'pip' - - name: Create virtualenv + - name: Bugis Core run: | + cd core python -m venv .venv .venv/bin/pip install -r requirements-dev.txt - - name: Execute build - run: | .venv/bin/python -m build + .venv/bin/pip install . + .venv/bin/python -m mypy -p src + .venv/bin/python -m unittest discover -s tests - name: Publish artifacts env: TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }} TWINE_USERNAME: ${{ vars.PUBLISHER_USERNAME }} TWINE_PASSWORD: ${{ secrets.PUBLISHER_TOKEN }} run: | + cd core .venv/bin/python -m twine upload --repository gitea dist/*{.whl,tar.gz} build_docker_image: name: "Build Docker image" diff --git a/.gitignore b/.gitignore index d6b6d9c..46bc0dc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ .venv __pycache__ *.pyc -src/bugis/_version.py +_version.py *.egg-info /build /dist diff --git a/cli/pyproject.toml b/cli/pyproject.toml new file mode 100644 index 0000000..0628dc3 --- /dev/null +++ b/cli/pyproject.toml @@ -0,0 +1,54 @@ +[build-system] +requires = ["setuptools>=61.0", "setuptools-scm>=8"] +build-backend = "setuptools.build_meta" + +[project] +name = "bugis_cli" +dynamic = ["version"] +authors = [ + { name="Walter Oggioni", email="oggioni.walter@gmail.com" }, +] +description = "Markdown to HTML renderer" +readme = "README.md" +requires-python = ">=3.10" +classifiers = [ + 'Development Status :: 3 - Alpha', + 'Topic :: Utilities', + 'License :: OSI Approved :: MIT License', + 'Intended Audience :: System Administrators', + 'Intended Audience :: Developers', + 'Environment :: Console', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3', +] + +dependencies = [ + "granian", + "bugis" +] + +[project.optional-dependencies] +dev = [ + "build", "mypy", "ipdb", "twine" +] + +[project.urls] +"Homepage" = "https://github.com/woggioni/bugis" +"Bug Tracker" = "https://github.com/woggioni/bugis/issues" + +[tool.mypy] +python_version = "3.12" +disallow_untyped_defs = true +show_error_codes = true +no_implicit_optional = true +warn_return_any = true +warn_unused_ignores = true +exclude = ["scripts", "docs", "test"] +strict = true + +[tool.setuptools_scm] +root='..' +version_file = "src/bugis/cli/_version.py" + +[project.scripts] +bugis = "bugis.cli:main" \ No newline at end of file diff --git a/cli/requirements-dev.txt b/cli/requirements-dev.txt new file mode 100644 index 0000000..da7f16e --- /dev/null +++ b/cli/requirements-dev.txt @@ -0,0 +1,171 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --extra=dev --output-file=requirements-dev.txt +# +--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple +--extra-index-url https://pypi.org/simple + +aiofiles==24.1.0 + # via bugis +anyio==4.6.2.post1 + # via httpx +asttokens==2.4.1 + # via stack-data +bugis==0.2.2 + # via bugis_cli (pyproject.toml) +build==1.2.2.post1 + # via bugis_cli (pyproject.toml) +certifi==2024.8.30 + # via + # httpcore + # httpx + # requests +cffi==1.17.1 + # via cryptography +charset-normalizer==3.4.0 + # via requests +click==8.1.7 + # via granian +cryptography==43.0.3 + # via secretstorage +decorator==5.1.1 + # via + # ipdb + # ipython +docutils==0.21.2 + # via readme-renderer +executing==2.1.0 + # via stack-data +granian==1.6.3 + # via bugis_cli (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 +hyperframe==6.0.1 + # via h2 +idna==3.10 + # via + # anyio + # httpx + # requests +importlib-metadata==8.5.0 + # via twine +ipdb==0.13.13 + # via bugis_cli (pyproject.toml) +ipython==8.29.0 + # via ipdb +jaraco-classes==3.4.0 + # via keyring +jaraco-context==6.0.1 + # via keyring +jaraco-functools==4.1.0 + # via keyring +jedi==0.19.1 + # via ipython +jeepney==0.8.0 + # via + # keyring + # secretstorage +keyring==25.5.0 + # via twine +markdown==3.7 + # via bugis +markdown-it-py==3.0.0 + # via rich +matplotlib-inline==0.1.7 + # via ipython +mdurl==0.1.2 + # via markdown-it-py +more-itertools==10.5.0 + # via + # jaraco-classes + # jaraco-functools +mypy==1.13.0 + # via bugis_cli (pyproject.toml) +mypy-extensions==1.0.0 + # via mypy +nh3==0.2.18 + # via readme-renderer +packaging==24.1 + # via build +parso==0.8.4 + # via jedi +pexpect==4.9.0 + # via ipython +pkginfo==1.10.0 + # via twine +prompt-toolkit==3.0.48 + # via ipython +ptyprocess==0.7.0 + # via pexpect +pure-eval==0.2.3 + # via stack-data +pwo==0.0.4 + # via bugis +pycparser==2.22 + # via cffi +pygments==2.18.0 + # via + # bugis + # ipython + # readme-renderer + # rich +pygraphviz==1.14 + # via bugis +pyproject-hooks==1.2.0 + # via build +pyyaml==6.0.2 + # via bugis +readme-renderer==44.0 + # via twine +requests==2.32.3 + # via + # requests-toolbelt + # twine +requests-toolbelt==1.0.0 + # via twine +rfc3986==2.0.0 + # via twine +rich==13.9.3 + # via twine +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 +traitlets==5.14.3 + # via + # ipython + # matplotlib-inline +twine==5.1.1 + # via bugis_cli (pyproject.toml) +typing-extensions==4.12.2 + # via + # mypy + # pwo +urllib3==2.2.3 + # via + # requests + # twine +uvloop==0.21.0 + # via granian +watchdog==5.0.3 + # via bugis +wcwidth==0.2.13 + # via prompt-toolkit +zipp==3.20.2 + # via importlib-metadata diff --git a/cli/requirements.txt b/cli/requirements.txt new file mode 100644 index 0000000..342000b --- /dev/null +++ b/cli/requirements.txt @@ -0,0 +1,59 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --output-file=requirements.txt +# +--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple +--extra-index-url https://pypi.org/simple + +aiofiles==24.1.0 + # via bugis +anyio==4.6.2.post1 + # via httpx +bugis==0.2.2 + # via bugis_cli (pyproject.toml) +certifi==2024.8.30 + # via + # httpcore + # httpx +click==8.1.7 + # via granian +granian==1.6.3 + # via bugis_cli (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 +hyperframe==6.0.1 + # via h2 +idna==3.10 + # via + # anyio + # httpx +markdown==3.7 + # via bugis +pwo==0.0.4 + # via bugis +pygments==2.18.0 + # via bugis +pygraphviz==1.14 + # via bugis +pyyaml==6.0.2 + # via bugis +sniffio==1.3.1 + # via + # anyio + # httpx +typing-extensions==4.12.2 + # via pwo +uvloop==0.21.0 + # via granian +watchdog==5.0.3 + # via bugis diff --git a/cli/src/bugis/cli/__init__.py b/cli/src/bugis/cli/__init__.py new file mode 100644 index 0000000..f317a85 --- /dev/null +++ b/cli/src/bugis/cli/__init__.py @@ -0,0 +1,118 @@ +from os import environ +from pathlib import Path +from typing import Optional, Sequence +import argparse +import yaml +from granian import Granian +from pwo import Maybe + +from bugis.configuration import instance, Configuration +from granian.constants import HTTPModes, ThreadModes, Loops +from dataclasses import asdict +from typing import Any, Mapping + +def main(args: Optional[Sequence[str]] = None) -> None: + parser = argparse.ArgumentParser(description="A simple CLI program to render Markdown files") + default_configuration_file = (Maybe.of_nullable(environ.get('XDG_CONFIG_HOME')) + .map(lambda it: Path(it)) + .map(lambda it: it / 'bugis' / 'bugis.yaml') + .or_else_get( + lambda: Maybe.of_nullable(environ.get('HOME')) + .map(lambda it: Path(it) / '.config' / 'bugis' / 'bugis.yaml').or_none()) + .filter(Path.exists) + .or_none() + ) + parser.add_argument( + '-c', + '--configuration', + help='Path to the configuration file', + default=default_configuration_file, + type=Path, + ) + parser.add_argument( + '-a', + '--address', + help='Server bind address', + default='127.0.0.1', + ) + parser.add_argument( + '-p', + '--port', + help='Server port', + default='8000', + type=int + ) + parser.add_argument( + '--access-log', + help='Enable access log', + action='store_true', + dest='log_access' + ) + parser.add_argument( + '--logging-configuration', + help='Logging configuration file', + dest='log_config_file' + ) + parser.add_argument( + '-w', '--workers', + help='Number of worker processes', + default='1', + dest='workers', + type=int + ) + parser.add_argument( + '-t', '--threads', + help='Number of threads per worker', + default='1', + dest='threads', + type=int + ) + parser.add_argument( + '--http', + help='HTTP protocol version', + dest='http', + type=lambda it: HTTPModes(it), + choices=[str(mode) for mode in HTTPModes], + default='auto', + ) + parser.add_argument( + '--threading-mode', + help='Threading mode', + dest='threading_mode', + type=lambda it: ThreadModes(it), + choices=[str(mode) for mode in ThreadModes], + default=ThreadModes.workers + ) + parser.add_argument( + '--loop', + help='Loop', + dest='loop', + type=lambda it: Loops(it), + choices=[str(mode) for mode in Loops] + ) + arguments = parser.parse_args(args) + + def parse(configuration: Path) -> Any: + with open(configuration, 'r') as f: + return yaml.safe_load(f) + + def assign(it: Configuration) -> None: + global instance + instance = it + + Maybe.of_nullable(arguments.configuration).map(parse).if_present(assign) + conf = instance + + granian_conf = asdict(conf).setdefault('granian', dict()) + for k, v in vars(arguments).items(): + if v is not None: + granian_conf[k] = v + if arguments.log_config_file: + with open(arguments.log_config_file, 'r') as f: + granian_conf['log_dictconfig'] = yaml.safe_load(f) + granian_conf = Configuration.GranianConfiguration.from_dict(granian_conf) + + Granian( + "bugis.asgi:application", + **asdict(granian_conf) + ).serve() diff --git a/cli/src/bugis/cli/__main__.py b/cli/src/bugis/cli/__main__.py new file mode 100644 index 0000000..b12985c --- /dev/null +++ b/cli/src/bugis/cli/__main__.py @@ -0,0 +1,4 @@ +from . import main +import sys + +main(sys.argv[1:]) diff --git a/cli/src/bugis/cli/default-conf/logging.yaml b/cli/src/bugis/cli/default-conf/logging.yaml new file mode 100644 index 0000000..6f66f1a --- /dev/null +++ b/cli/src/bugis/cli/default-conf/logging.yaml @@ -0,0 +1,30 @@ +version: 1 +disable_existing_loggers: False +handlers: + console: + class : logging.StreamHandler + formatter: default + level : INFO + stream : ext://sys.stderr + access: + class : logging.StreamHandler + formatter: access + level : INFO + stream : ext://sys.stdout +formatters: + default: + format: '{asctime}.{msecs:0<3.0f} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}' + style: '{' + datefmt: '%Y-%m-%d %H:%M:%S' + access: + format: '%(message)s' +loggers: + root: + handlers: [console] + _granian: + level: INFO + propagate: False + granian.access: + handlers: [ access ] + level: INFO + propagate: False diff --git a/core/conf/logging.json b/core/conf/logging.json new file mode 100644 index 0000000..8278baa --- /dev/null +++ b/core/conf/logging.json @@ -0,0 +1,46 @@ +{ + "version": 1, + "disable_existing_loggers": false, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "default", + "level": "DEBUG", + "stream": "ext://sys.stderr" + }, + "access": { + "class": "logging.StreamHandler", + "formatter": "access", + "level": "DEBUG", + "stream": "ext://sys.stdout" + } + }, + "formatters": { + "default": { + "format": "{asctime}.{msecs:0<3.0f} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}", + "style": "{", + "datefmt": "%Y-%m-%d %H:%M:%S" + }, + "access": { + "format": "%(message)s" + } + }, + "loggers": { + "root": { + "handlers": [ + "console" + ] + }, + "_granian": { + "level": "DEBUG", + "propagate": false + }, + "granian.access": { + "handlers": [ + "access" + ], + "level": "DEBUG", + "propagate": false + } + } +} diff --git a/core/example/hello.py b/core/example/hello.py new file mode 100644 index 0000000..f262334 --- /dev/null +++ b/core/example/hello.py @@ -0,0 +1,20 @@ +from bugis.core import BugisApp, HttpContext + + +class Hello(BugisApp): + + async def handle_request(self, ctx: HttpContext) -> None: + async for chunk in ctx.request_body: + print(chunk) + await ctx.send_str(200, 'Hello World') + + +app = BugisApp() + + +@app.GET('/hello') +@app.GET('/hello2') +async def handle_request(ctx: HttpContext) -> None: + async for chunk in ctx.request_body: + print(chunk) + await ctx.send_str(200, 'Hello World') \ No newline at end of file diff --git a/core/pyproject.toml b/core/pyproject.toml new file mode 100644 index 0000000..a0a28c2 --- /dev/null +++ b/core/pyproject.toml @@ -0,0 +1,55 @@ +[build-system] +requires = ["setuptools>=61.0", "setuptools-scm>=8"] +build-backend = "setuptools.build_meta" + +[project] +name = "bugis_core" +dynamic = ["version"] +authors = [ + { name="Walter Oggioni", email="oggioni.walter@gmail.com" }, +] +description = "Markdown to HTML renderer" +readme = "README.md" +requires-python = ">=3.10" +classifiers = [ + 'Development Status :: 3 - Alpha', + 'Topic :: Utilities', + 'License :: OSI Approved :: MIT License', + 'Intended Audience :: System Administrators', + 'Intended Audience :: Developers', + 'Environment :: Console', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3', +] + +dependencies = [ + "pwo", +] + +[project.optional-dependencies] +dev = [ + "build", "mypy", "ipdb", "twine", "granian", "httpx" +] + +rsgi = [ + "granian" +] + +[project.urls] +"Homepage" = "https://github.com/woggioni/bugis" +"Bug Tracker" = "https://github.com/woggioni/bugis/issues" + +[tool.mypy] +python_version = "3.12" +disallow_untyped_defs = true +show_error_codes = true +no_implicit_optional = true +warn_return_any = true +warn_unused_ignores = true +exclude = ["scripts", "docs", "test"] +strict = true + +[tool.setuptools_scm] +root='..' +version_file = "src/bugis/core/_version.py" + diff --git a/core/requirements-dev.txt b/core/requirements-dev.txt new file mode 100644 index 0000000..d1a2457 --- /dev/null +++ b/core/requirements-dev.txt @@ -0,0 +1,138 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --extra=dev --output-file=requirements-dev.txt +# +--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple +--extra-index-url https://pypi.org/simple + +aiofiles==24.1.0 + # via bugis_core (pyproject.toml) +asttokens==2.4.1 + # via stack-data +build==1.2.2.post1 + # via bugis_core (pyproject.toml) +certifi==2024.8.30 + # via requests +cffi==1.17.1 + # via cryptography +charset-normalizer==3.4.0 + # via requests +cryptography==43.0.3 + # via secretstorage +decorator==5.1.1 + # via + # ipdb + # ipython +docutils==0.21.2 + # via readme-renderer +executing==2.1.0 + # via stack-data +idna==3.10 + # via requests +importlib-metadata==8.5.0 + # via twine +ipdb==0.13.13 + # via bugis_core (pyproject.toml) +ipython==8.29.0 + # via ipdb +jaraco-classes==3.4.0 + # via keyring +jaraco-context==6.0.1 + # via keyring +jaraco-functools==4.1.0 + # via keyring +jedi==0.19.1 + # via ipython +jeepney==0.8.0 + # via + # keyring + # secretstorage +keyring==25.5.0 + # via twine +markdown-it-py==3.0.0 + # via rich +matplotlib-inline==0.1.7 + # via ipython +mdurl==0.1.2 + # via markdown-it-py +more-itertools==10.5.0 + # via + # jaraco-classes + # jaraco-functools +mypy==1.13.0 + # via bugis_core (pyproject.toml) +mypy-extensions==1.0.0 + # via mypy +nh3==0.2.18 + # via readme-renderer +packaging==24.1 + # via build +parso==0.8.4 + # via jedi +pexpect==4.9.0 + # via ipython +pkginfo==1.10.0 + # via twine +prompt-toolkit==3.0.48 + # via ipython +ptyprocess==0.7.0 + # via pexpect +pure-eval==0.2.3 + # via stack-data +pwo==0.0.5 + # via bugis_core (pyproject.toml) +pycparser==2.22 + # via cffi +pygments==2.18.0 + # via + # ipython + # readme-renderer + # rich +pyproject-hooks==1.2.0 + # via build +pyyaml==6.0.2 + # via bugis_core (pyproject.toml) +readme-renderer==44.0 + # via twine +requests==2.32.3 + # via + # requests-toolbelt + # twine +requests-toolbelt==1.0.0 + # via twine +rfc3986==2.0.0 + # via twine +rich==13.9.3 + # via twine +secretstorage==3.3.3 + # via keyring +six==1.16.0 + # via asttokens +sortedcontainers==2.4.0 + # via bugis_core (pyproject.toml) +stack-data==0.6.3 + # via ipython +traitlets==5.14.3 + # via + # ipython + # matplotlib-inline +twine==5.1.1 + # via bugis_core (pyproject.toml) +types-pyyaml==6.0.12.20240917 + # via bugis_core (pyproject.toml) +typing-extensions==4.12.2 + # via + # mypy + # pwo +urllib3==2.2.3 + # via + # requests + # twine +watchdog==5.0.3 + # via bugis_core (pyproject.toml) +wcwidth==0.2.13 + # via prompt-toolkit +zipp==3.20.2 + # via importlib-metadata diff --git a/core/requirements.txt b/core/requirements.txt new file mode 100644 index 0000000..bf68fba --- /dev/null +++ b/core/requirements.txt @@ -0,0 +1,21 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --output-file=requirements.txt +# +--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple +--extra-index-url https://pypi.org/simple + +aiofiles==24.1.0 + # via bugis_core (pyproject.toml) +pwo==0.0.5 + # via bugis_core (pyproject.toml) +pyyaml==6.0.2 + # via bugis_core (pyproject.toml) +sortedcontainers==2.4.0 + # via bugis_core (pyproject.toml) +typing-extensions==4.12.2 + # via pwo +watchdog==5.0.3 + # via bugis_core (pyproject.toml) diff --git a/core/src/bugis/core/__init__.py b/core/src/bugis/core/__init__.py new file mode 100644 index 0000000..ffec733 --- /dev/null +++ b/core/src/bugis/core/__init__.py @@ -0,0 +1,14 @@ +from ._app import BugisApp +from ._http_method import HttpMethod +from ._http_context import HttpContext +from ._tree import Tree, PathHandler, PathIterator + + +__all__ = [ + 'HttpMethod', + 'BugisApp', + 'HttpContext', + 'Tree', + 'PathHandler', + 'PathIterator' +] diff --git a/core/src/bugis/core/_app.py b/core/src/bugis/core/_app.py new file mode 100644 index 0000000..a41f836 --- /dev/null +++ b/core/src/bugis/core/_app.py @@ -0,0 +1,126 @@ +from abc import ABC, abstractmethod +from asyncio import Queue, AbstractEventLoop +from asyncio import get_running_loop +from logging import getLogger +from typing import Callable, Awaitable, Any, Mapping, Sequence, Optional + +from pwo import Maybe, AsyncQueueIterator + +from ._http_context import HttpContext +from ._http_method import HttpMethod + +try: + from ._rsgi import RsgiContext + from granian._granian import RSGIHTTPProtocol, RSGIHTTPScope # type: ignore +except ImportError: + pass + +from ._asgi import AsgiContext +from ._tree import Tree +from ._types.asgi import LifespanScope, HTTPScope as ASGIHTTPScope, WebSocketScope + +log = getLogger(__name__) + +type HttpHandler = Callable[[HttpContext], Awaitable[None]] + +class AbstractBugisApp(ABC): + async def __call__(self, + scope: ASGIHTTPScope|WebSocketScope|LifespanScope, + receive: Callable[[], Awaitable[Any]], + send: Callable[[Mapping[str, Any]], Awaitable[None]]) -> None: + loop = get_running_loop() + if scope['type'] == 'lifespan': + while True: + message = await receive() + if message['type'] == 'lifespan.startup': + self.setup(loop) + await send({'type': 'lifespan.startup.complete'}) + elif message['type'] == 'lifespan.shutdown': + self.shutdown(loop) + await send({'type': 'lifespan.shutdown.complete'}) + elif scope['type'] == 'http': + queue: Queue[Optional[bytes]] = Queue() + ctx = AsgiContext(scope, receive, send, AsyncQueueIterator(queue)) + request_handling = loop.create_task(self.handle_request(ctx)) + while True: + message = await receive() + if message['type'] == 'http.request': + Maybe.of(message['body']).filter(lambda it: len(it) > 0).if_present(queue.put_nowait) + if not message.get('more_body', False): + queue.put_nowait(None) + await request_handling + break + elif message['type'] == 'http.disconnect': + request_handling.cancel() + break + else: + raise NotImplementedError() + + def setup(self, loop: AbstractEventLoop) -> None: + pass + + def shutdown(self, loop: AbstractEventLoop) -> None: + pass + + @abstractmethod + async def handle_request(self, ctx: HttpContext) -> None: + raise NotImplementedError() + + def __rsgi_init__(self, loop: AbstractEventLoop) -> None: + self.setup(loop) + + def __rsgi_del__(self, loop: AbstractEventLoop) -> None: + self.shutdown(loop) + + async def __rsgi__(self, scope: RSGIHTTPScope, protocol: RSGIHTTPProtocol) -> None: + ctx = RsgiContext(scope, protocol) + await self.handle_request(ctx) + + +class BugisApp(AbstractBugisApp): + _tree: Tree + + def __init__(self) -> None: + self._tree = Tree() + + async def handle_request(self, ctx: HttpContext) -> None: + handler = self._tree.get_handler(ctx.path, ctx.method) + if handler is not None: + await handler.handle_request(ctx) + else: + await ctx.send_empty(404) + pass + + def route(self, + path: str, + methods: Optional[Sequence[HttpMethod]] = None) -> Callable[[HttpHandler], HttpHandler]: + def wrapped(handler: HttpHandler) -> HttpHandler: + if methods is not None: + for method in methods: + self._tree.register(path, method, handler) + else: + self._tree.register(path, None, handler) + return handler + + return wrapped + + def GET(self, path: str) -> Callable[[HttpHandler], HttpHandler]: + return self.route(path, (HttpMethod.GET,)) + + def POST(self, path: str) -> Callable[[HttpHandler], HttpHandler]: + return self.route(path, (HttpMethod.POST,)) + + def PUT(self, path: str) -> Callable[[HttpHandler], HttpHandler]: + return self.route(path, (HttpMethod.PUT,)) + + def DELETE(self, path: str) -> Callable[[HttpHandler], HttpHandler]: + return self.route(path, (HttpMethod.DELETE,)) + + def OPTIONS(self, path: str) -> Callable[[HttpHandler], HttpHandler]: + return self.route(path, (HttpMethod.OPTIONS,)) + + def HEAD(self, path: str) -> Callable[[HttpHandler], HttpHandler]: + return self.route(path, (HttpMethod.HEAD,)) + + def PATCH(self, path: str) -> Callable[[HttpHandler], HttpHandler]: + return self.route(path, (HttpMethod.PATCH,)) diff --git a/core/src/bugis/core/_asgi.py b/core/src/bugis/core/_asgi.py new file mode 100644 index 0000000..20b83c5 --- /dev/null +++ b/core/src/bugis/core/_asgi.py @@ -0,0 +1,148 @@ +from typing import ( + Sequence, + Tuple, + Dict, + Mapping, + Callable, + Any, + AsyncIterator, + Awaitable, + AsyncGenerator, + Optional, + List, + Iterable +) + +from pwo import Maybe +from pathlib import Path +from ._http_method import HttpMethod +from ._http_context import HttpContext +from ._types import StrOrStrings +from ._types.asgi import HTTPScope + + +def decode_headers(headers: Iterable[Tuple[bytes, bytes]]) -> Dict[str, Sequence[str]]: + result: Dict[str, List[str]] = dict() + for key, value in headers: + key_str: str + value_str: str + if isinstance(key, bytes): + key_str = key.decode() + elif isinstance(key, str): + key_str = key + else: + raise NotImplementedError('This should never happen') + if isinstance(value, bytes): + value_str = value.decode() + elif isinstance(key, str): + value_str = value + else: + raise NotImplementedError('This should never happen') + ls = result.setdefault(key_str, list()) + ls.append(value_str) + return { + k: tuple(v) for k, v in result.items() + } + + +def encode_headers(headers: Mapping[str, StrOrStrings]) -> Tuple[Tuple[bytes, bytes], ...]: + result = [] + for key, value in headers.items(): + if isinstance(value, str): + result.append((key.encode(), value.encode())) + elif isinstance(value, Sequence): + for single_value in value: + result.append((key.encode(), single_value.encode())) + return tuple(result) + + +class AsgiContext(HttpContext): + pathsend: bool + receive: Callable[[], Awaitable[Any]] + send: Callable[[Mapping[str, Any]], Awaitable[None]] + scheme: str + method: HttpMethod + path: str + query_string: str + headers: Mapping[str, Sequence[str]] + client: Optional[Tuple[str, int]] + server: Optional[Tuple[str, Optional[int]]] + request_body: AsyncIterator[bytes] + + def __init__(self, + scope: HTTPScope, + receive: Callable[[], Awaitable[Any]], + send: Callable[[Mapping[str, Any]], Awaitable[None]], + request_body_iterator: AsyncIterator[bytes]): + self.receive = receive + self.send = send + self.pathsend = (Maybe.of_nullable(scope.get('extensions')) + .map(lambda it: it.get("http.response.pathsend")) + .is_present) + self.path = scope['path'] + self.query_string = scope['query_string'].decode() + self.method = HttpMethod(scope['method']) + self.scheme = scope['scheme'] + self.client = scope['client'] + self.server = scope['server'] + self.headers = decode_headers(scope['headers']) + self.request_body = request_body_iterator + + async def stream_body(self, + status: int, + body_generator: AsyncGenerator[bytes, None], + headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + await self._send_head(status, headers) + async for chunk in body_generator: + await self.send({ + 'type': 'http.response.body', + 'body': chunk, + 'more_body': True + }) + await self.send({ + 'type': 'http.response.body', + 'body': '', + 'more_body': False + }) + + async def send_bytes(self, status: int, body: bytes, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + await self._send_head(status, headers) + await self.send({ + 'type': 'http.response.body', + 'body': body, + }) + + async def send_str(self, status: int, body: str, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + await self._send_head(status, headers) + await self.send({ + 'type': 'http.response.body', + 'body': body.encode(), + }) + + async def _send_head(self, status: int, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + await self.send({ + 'type': 'http.response.start', + 'status': status, + 'headers': Maybe.of_nullable(headers).map(encode_headers).or_else(tuple()) + }) + + async def send_file(self, + status: int, + path: Path, + headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + if self.pathsend: + await self._send_head(status, headers) + await self.send({ + 'type': 'http.response.pathsend', + 'path': path + }) + else: + raise NotImplementedError() + + async def send_empty(self, status: int, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + await self._send_head(status, headers) + await self.send({ + 'type': 'http.response.body', + 'body': '', + 'more_body': False + }) diff --git a/core/src/bugis/core/_http_context.py b/core/src/bugis/core/_http_context.py new file mode 100644 index 0000000..1380855 --- /dev/null +++ b/core/src/bugis/core/_http_context.py @@ -0,0 +1,51 @@ +from typing import ( + Callable, + Awaitable, + Tuple, + AsyncIterator, + AsyncGenerator, + Mapping, + Sequence, + Any, + Optional +) +from abc import ABC, abstractmethod +from pathlib import Path + +from ._http_method import HttpMethod + + +class HttpContext(ABC): + pathsend: bool + receive: Callable[[None], Awaitable[Any]] + send: Callable[[Mapping[str, Any]], Awaitable[None]] + scheme: str + method: HttpMethod + path: str + query_string: str + headers: Mapping[str, Sequence[str]] + client: Optional[Tuple[str, int]] + server: Optional[Tuple[str, Optional[int]]] + request_body: AsyncIterator[bytes] + + @abstractmethod + async def stream_body(self, + status: int, + body_generator: AsyncGenerator[bytes, None], + headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + pass + + @abstractmethod + async def send_bytes(self, status: int, body: bytes, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + pass + + async def send_str(self, status: int, body: str, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + await self.send_bytes(status, body.encode(), headers) + + @abstractmethod + async def send_file(self, status: int, path: Path, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + pass + + @abstractmethod + async def send_empty(self, status: int, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + pass diff --git a/core/src/bugis/core/_http_method.py b/core/src/bugis/core/_http_method.py new file mode 100644 index 0000000..2b3adec --- /dev/null +++ b/core/src/bugis/core/_http_method.py @@ -0,0 +1,11 @@ +from enum import StrEnum + + +class HttpMethod(StrEnum): + OPTIONS = 'OPTIONS' + HEAD = 'HEAD' + GET = 'GET' + POST = 'POST' + PUT = 'PUT' + DELETE = 'DELETE' + PATCH = 'PATCH' diff --git a/core/src/bugis/core/_rsgi.py b/core/src/bugis/core/_rsgi.py new file mode 100644 index 0000000..e19980b --- /dev/null +++ b/core/src/bugis/core/_rsgi.py @@ -0,0 +1,95 @@ +from functools import reduce +from pathlib import Path +from typing import ( + Any, + Sequence, + Mapping, + AsyncIterator, + Tuple, + AsyncGenerator, + Optional, + List, + Dict, + Callable, + cast +) + +from granian.rsgi import Scope +from granian._granian import RSGIHTTPProtocol +from pwo import Maybe + +from ._http_context import HttpContext +from ._http_method import HttpMethod + + +class RsgiContext(HttpContext): + protocol: RSGIHTTPProtocol + scheme: str + method: HttpMethod + path: str + query_string: str + headers: Mapping[str, Sequence[str]] + client: Optional[Tuple[str, int]] + server: Optional[Tuple[str, Optional[int]]] + request_body: AsyncIterator[bytes] + head = Optional[Tuple[int, Sequence[Tuple[str, str]]]] + + def __init__(self, scope: Scope, protocol: RSGIHTTPProtocol): + self.scheme = scope.scheme + self.path = scope.path + self.method = HttpMethod(scope.method) + self.query_string = scope.query_string + + def acc(d: Dict[str, List[str]], t: Tuple[str, str]) -> Dict[str, List[str]]: + d.setdefault(t[0], list()).append(t[1]) + return d + + fun = cast(Callable[[Mapping[str, Sequence[str]], tuple[str, str]], Mapping[str, Sequence[str]]], acc) + self.headers = reduce(fun, scope.headers.items(), {}) + self.client = (Maybe.of(scope.client.split(':')) + .map(lambda it: (it[0], int(it[1]))) + .or_else_throw(RuntimeError)) + self.server = (Maybe.of(scope.server.split(':')) + .map(lambda it: (it[0], int(it[1]))) + .or_else_throw(RuntimeError)) + self.request_body = aiter(protocol) + self.protocol = protocol + + @staticmethod + def _rearrange_headers(headers: Mapping[str, Sequence[str]]) -> List[Tuple[str, str]]: + return list( + ((key, value) for key, values in headers.items() for value in values) + ) + + async def stream_body(self, + status: int, + body_generator: AsyncGenerator[bytes, None], + headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + transport = self.protocol.response_stream(status, + Maybe.of_nullable(headers) + .map(self._rearrange_headers) + .or_else([])) + async for chunk in body_generator: + await transport.send_bytes(chunk) + + async def send_bytes(self, status: int, body: bytes, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + rearranged_headers = Maybe.of_nullable(headers).map(RsgiContext._rearrange_headers).or_else(list()) + if len(body) > 0: + self.protocol.response_bytes(status, rearranged_headers, body) + else: + self.protocol.response_empty(status, rearranged_headers) + + async def send_str(self, status: int, body: str, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + rearranged_headers = Maybe.of_nullable(headers).map(RsgiContext._rearrange_headers).or_else(list()) + if len(body) > 0: + self.protocol.response_str(status, rearranged_headers, body) + else: + self.protocol.response_empty(status, rearranged_headers) + + async def send_file(self, status: int, path: Path, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + rearranged_headers = Maybe.of_nullable(headers).map(RsgiContext._rearrange_headers).or_else(list()) + self.protocol.response_file(status, rearranged_headers, str(path)) + + async def send_empty(self, status: int, headers: Optional[Mapping[str, Sequence[str]]] = None) -> None: + rearranged_headers = Maybe.of_nullable(headers).map(RsgiContext._rearrange_headers).or_else(list()) + self.protocol.response_empty(status, rearranged_headers) diff --git a/core/src/bugis/core/_tree.py b/core/src/bugis/core/_tree.py new file mode 100644 index 0000000..5506798 --- /dev/null +++ b/core/src/bugis/core/_tree.py @@ -0,0 +1,171 @@ +from typing import Sequence, Dict, Awaitable, Callable, Optional, Generator, Self, List +from ._http_method import HttpMethod +from ._http_context import HttpContext +from dataclasses import dataclass +from abc import ABC, abstractmethod +from itertools import chain +from urllib.parse import urlparse +from pwo import Maybe + +type NodeType = (str | HttpMethod) + +type PathHandlers = (PathHandler | Sequence[PathHandler]) + + +class PathHandler(ABC): + + @abstractmethod + def match(self, subpath: Sequence[str], method: HttpMethod) -> bool: + raise NotImplementedError() + + @abstractmethod + async def handle_request(self, ctx: HttpContext) -> None: + pass + + +@dataclass +class Node: + key: NodeType + parent: Optional['Node'] + children: Dict[NodeType, 'Node'] + handlers: Sequence[PathHandler] + + +class Tree: + + def __init__(self) -> None: + self.root = Node('/', None, {}, []) + + def search(self, path: Generator[str, None, None], method: HttpMethod) -> Optional[Node]: + lineage: Generator[NodeType, None, None] = (it for it in chain(path, (method,))) + result = self.root + it = iter(lineage) + while True: + node = result + leaf = next(it, None) + if leaf is None: + break + child = node.children.get(leaf) + if child is None: + break + else: + result = child + return None if result == self.root else result + + def add(self, path: Generator[str, None, None], method: Optional[HttpMethod], *path_handlers: PathHandler) -> Node: + lineage: Generator[NodeType, None, None] = (it for it in + chain(path, + Maybe.of_nullable(method) + .map(lambda it: [it]) + .or_else([]))) + result = self.root + it = iter(lineage) + + while True: + node = result + leaf = next(it, None) + if leaf is None: + break + child = node.children.get(leaf) + if child is None: + break + else: + result = child + key = leaf + while key is not None: + new_node = Node(key=key, parent=result, children={}, handlers=[]) + result.children[key] = new_node + result = new_node + key = next(it, None) + + result.handlers = tuple(chain(result.handlers, path_handlers)) + return result + + def register(self, + path: str, + method: Optional[HttpMethod], + callback: Callable[[HttpContext], Awaitable[None]]) -> None: + class Handler(PathHandler): + + def match(self, subpath: Sequence[str], method: HttpMethod) -> bool: + return len(subpath) == 0 + + async def handle_request(self, ctx: HttpContext) -> None: + await callback(ctx) + handler = Handler() + self.add((p for p in PathIterator(path)), method, handler) + + def find_node(self, path: Generator[str, None, None], method: HttpMethod = HttpMethod.GET) -> Optional[Node]: + return (Maybe.of_nullable(self.search(path, method)) + .filter(lambda it: len(it.handlers) > 0) + .or_none()) + + def get_handler(self, url: str, method: HttpMethod = HttpMethod.GET) -> Optional[PathHandler]: + path = urlparse(url).path + node = self.find_node((p for p in PathIterator(path)), method) + if node is None: + return None + requested = (p for p in PathIterator(path)) + found = reversed([n.key for n in NodeAncestryIterator(node) if n.key != '/']) + unmatched: List[str] = [] + for r, f in zip(requested, found): + if f is None: + unmatched.append(r) + for handler in node.handlers: + if handler.match(unmatched, method): + return handler + return None + + +class PathIterator: + path: str + cursor: int + + def __init__(self, path: str): + self.path = path + self.cursor = 0 + + def __iter__(self) -> Self: + return self + + def advance_cursor(self, next_value: int) -> None: + if next_value < len(self.path): + self.cursor = next_value + else: + self.cursor = -1 + + def __next__(self) -> str: + if self.cursor < 0: + raise StopIteration() + else: + while self.cursor >= 0: + next_separator = self.path.find('/', self.cursor) + if next_separator < 0: + result = self.path[self.cursor:] + self.cursor = next_separator + return result + elif next_separator == self.cursor: + self.advance_cursor(next_separator + 1) + else: + result = self.path[self.cursor:next_separator] + self.advance_cursor(next_separator + 1) + return result + raise StopIteration() + + +class NodeAncestryIterator: + node: Node + + def __init__(self, node: Node): + self.node = node + + def __iter__(self) -> Self: + return self + + def __next__(self) -> Node: + parent = self.node.parent + if parent is None: + raise StopIteration() + else: + self.node = parent + return parent diff --git a/core/src/bugis/core/_types.py b/core/src/bugis/core/_types.py new file mode 100644 index 0000000..667197d --- /dev/null +++ b/core/src/bugis/core/_types.py @@ -0,0 +1,70 @@ +from typing import ( + Sequence, + TypedDict, + Literal, + Iterable, + Tuple, + Optional, + NotRequired, + Dict, + Any, + Union +) + +type StrOrStrings = (str | Sequence[str]) + +class ASGIVersions(TypedDict): + spec_version: str + version: Union[Literal["2.0"], Literal["3.0"]] + + +class HTTPScope(TypedDict): + type: Literal["http"] + asgi: ASGIVersions + http_version: str + method: str + scheme: str + path: str + raw_path: bytes + query_string: bytes + root_path: str + headers: Iterable[Tuple[bytes, bytes]] + client: Optional[Tuple[str, int]] + server: Optional[Tuple[str, Optional[int]]] + state: NotRequired[Dict[str, Any]] + extensions: Optional[Dict[str, Dict[object, object]]] +class WebSocketScope(TypedDict): + type: Literal["websocket"] + asgi: ASGIVersions + http_version: str + scheme: str + path: str + raw_path: bytes + query_string: bytes + root_path: str + headers: Iterable[Tuple[bytes, bytes]] + client: Optional[Tuple[str, int]] + server: Optional[Tuple[str, Optional[int]]] + subprotocols: Iterable[str] + state: NotRequired[Dict[str, Any]] + extensions: Optional[Dict[str, Dict[object, object]]] + + +class LifespanScope(TypedDict): + type: Literal["lifespan"] + asgi: ASGIVersions + state: NotRequired[Dict[str, Any]] + +class RSGI: + class Scope(TypedDict): + proto: Literal['http'] = 'http' + rsgi_version: str + http_version: str + server: str + client: str + scheme: str + method: str + path: str + query_string: str + headers: Mapping[str, str] + authority: Optional[str] \ No newline at end of file diff --git a/core/src/bugis/core/_types/__init__.py b/core/src/bugis/core/_types/__init__.py new file mode 100644 index 0000000..8e6574f --- /dev/null +++ b/core/src/bugis/core/_types/__init__.py @@ -0,0 +1,3 @@ +from typing import Sequence + +type StrOrStrings = (str | Sequence[str]) \ No newline at end of file diff --git a/core/src/bugis/core/_types/asgi.py b/core/src/bugis/core/_types/asgi.py new file mode 100644 index 0000000..0a7592c --- /dev/null +++ b/core/src/bugis/core/_types/asgi.py @@ -0,0 +1,57 @@ +from typing import ( + Sequence, + TypedDict, + Literal, + Iterable, + Tuple, + Optional, + NotRequired, + Dict, + Any, + Union +) + + +class ASGIVersions(TypedDict): + spec_version: str + version: Union[Literal["2.0"], Literal["3.0"]] + + +class HTTPScope(TypedDict): + type: Literal["http"] + asgi: ASGIVersions + http_version: str + method: str + scheme: str + path: str + raw_path: bytes + query_string: bytes + root_path: str + headers: Iterable[Tuple[bytes, bytes]] + client: Optional[Tuple[str, int]] + server: Optional[Tuple[str, Optional[int]]] + state: NotRequired[Dict[str, Any]] + extensions: Optional[Dict[str, Dict[object, object]]] + + +class WebSocketScope(TypedDict): + type: Literal["websocket"] + asgi: ASGIVersions + http_version: str + scheme: str + path: str + raw_path: bytes + query_string: bytes + root_path: str + headers: Iterable[Tuple[bytes, bytes]] + client: Optional[Tuple[str, int]] + server: Optional[Tuple[str, Optional[int]]] + subprotocols: Iterable[str] + state: NotRequired[Dict[str, Any]] + extensions: Optional[Dict[str, Dict[object, object]]] + + +class LifespanScope(TypedDict): + type: Literal["lifespan"] + asgi: ASGIVersions + state: NotRequired[Dict[str, Any]] diff --git a/core/src/bugis/core/_types/rsgi.py b/core/src/bugis/core/_types/rsgi.py new file mode 100644 index 0000000..36b205a --- /dev/null +++ b/core/src/bugis/core/_types/rsgi.py @@ -0,0 +1,26 @@ +from typing import ( + Sequence, + TypedDict, + Literal, + Iterable, + Tuple, + Optional, + NotRequired, + Dict, + Any, + Union, + Mapping, +) + +class HTTPScope(TypedDict): + proto: Literal['http'] + rsgi_version: str + http_version: str + server: str + client: str + scheme: str + method: str + path: str + query_string: str + headers: Mapping[str, str] + authority: Optional[str] \ No newline at end of file diff --git a/core/src/bugis/core/py.typed b/core/src/bugis/core/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/core/tests/test_asgi.py b/core/tests/test_asgi.py new file mode 100644 index 0000000..ccd4ef1 --- /dev/null +++ b/core/tests/test_asgi.py @@ -0,0 +1,40 @@ +import unittest +import httpx +from pwo import async_test +from bugis.core import BugisApp, HttpContext + + +class AsgiTest(unittest.TestCase): + app: BugisApp + + def setUp(self): + self.app = BugisApp() + + @self.app.GET('/hello') + @self.app.GET('/hello2') + @self.app.route('/hello3') + async def handle_request(ctx: HttpContext) -> None: + async for chunk in ctx.request_body: + print(chunk) + await ctx.send_str(200, 'Hello World!') + + @async_test + async def test_hello(self): + transport = httpx.ASGITransport(app=self.app) + + async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:80") as client: + r = await client.get("/hello") + self.assertEqual(r.status_code, 200) + self.assertEqual(r.text, "Hello World!") + + r = await client.get("/hello2") + self.assertEqual(r.status_code, 200) + self.assertEqual(r.text, "Hello World!") + + r = await client.post("/hello3") + self.assertEqual(r.status_code, 200) + self.assertEqual(r.text, "Hello World!") + + r = await client.get("/hello4") + self.assertEqual(r.status_code, 404) + self.assertTrue(len(r.text) == 0) diff --git a/core/tests/test_tree.py b/core/tests/test_tree.py new file mode 100644 index 0000000..6f0145c --- /dev/null +++ b/core/tests/test_tree.py @@ -0,0 +1,74 @@ +from typing import Sequence, Tuple, Optional, List + +from bugis.core import Tree, PathHandler, HttpContext, HttpMethod, PathIterator +from bugis.core import HttpMethod +from pwo import Maybe +import unittest + + +class PathIteratorTest(unittest.TestCase): + cases: Tuple[Tuple[str, Tuple[str, ...]], ...] = ( + ('/', tuple()), + ('root/foo', ('root', 'foo')), + ('/root', ('root',)), + ('/root', ('root',)), + ('/root/', ('root',)), + ('/root/bar/', ('root', 'bar')), + ) + + def test_path_iterator(self): + for (case, expected) in self.cases: + with self.subTest(case) as _: + components = tuple((c for c in PathIterator(case))) + self.assertEqual(expected, components) + + +class TreeTest(unittest.TestCase): + tree: Tree + handlers: List[PathHandler] + + def setUp(self): + self.tree = Tree() + + class TestHandler(PathHandler): + + def match(self, subpath: Sequence[str], method: HttpMethod) -> bool: + return True + + def handle_request(self, ctx: HttpContext): + pass + + self.handlers = [TestHandler() for _ in range(10)] + + routes: Tuple[Tuple[Tuple[str, ...], Optional[HttpMethod], PathHandler], ...] = ( + (('home', 'something'), HttpMethod.GET, self.handlers[0]), + (('home', 'something_else'), HttpMethod.GET, self.handlers[1]), + (('home', 'something_else'), HttpMethod.POST, self.handlers[2]), + (('home', 'something', 'object'), HttpMethod.GET, self.handlers[3]), + (('home', 'something_else', 'foo'), HttpMethod.GET, self.handlers[4]), + (('home',), HttpMethod.GET, self.handlers[5]), + (('home',), HttpMethod.POST, self.handlers[6]), + (('home',), None, self.handlers[7]), + ) + + for path, method, handler in routes: + self.tree.add((p for p in path), method, handler) + + def test_tree(self): + + cases: Tuple[Tuple[str, HttpMethod, Optional[int]], ...] = ( + ('http://localhost:127.0.0.1:5432/home/something', HttpMethod.GET, 0), + ('http://localhost:127.0.0.1:5432/home/something_else', HttpMethod.GET, 1), + ('http://localhost:127.0.0.1:5432/home/something_else', HttpMethod.POST, 2), + ('http://localhost:127.0.0.1:5432/home/something/object', HttpMethod.GET, 3), + ('http://localhost:127.0.0.1:5432/home/something_else/foo', HttpMethod.GET, 4), + ('http://localhost:127.0.0.1:5432/', HttpMethod.GET, None), + ('http://localhost:127.0.0.1:5432/home', HttpMethod.GET, 5), + ('http://localhost:127.0.0.1:5432/home', HttpMethod.POST, 6), + ('http://localhost:127.0.0.1:5432/home', HttpMethod.PUT, 7), + ) + for url, method, handler_num in cases: + with self.subTest(f"{str(method)} {url}"): + res = self.tree.get_handler(url, method) + self.assertIs(Maybe.of(handler_num).map(self.handlers.__getitem__).or_none(), res) + diff --git a/src/bugis/async_watchdog.py b/src/bugis/async_watchdog.py index 710c505..e4bf075 100644 --- a/src/bugis/async_watchdog.py +++ b/src/bugis/async_watchdog.py @@ -46,7 +46,7 @@ class FileWatcher(PatternMatchingEventHandler): _topic_manager: TopicManager _loop: AbstractEventLoop _topic_manager_loop: Task - _running_tasks : Future + _running_tasks: Future def __init__(self, path): super().__init__(patterns=['*.md'], diff --git a/src/bugis/configuration.py b/src/bugis/configuration.py index 8edf9eb..db551af 100644 --- a/src/bugis/configuration.py +++ b/src/bugis/configuration.py @@ -20,17 +20,6 @@ def parse_log_config(conf_file=None) -> Dict[str, Any]: @dataclass(frozen=True) class Configuration: plant_uml_server_address: str = environ.get('PLANT_UML_SERVER_ADDRESS', None) - _instance = None - - @classproperty - def instance(cls) -> 'Configuration': - if cls._instance is None: - cls._instance = Configuration() - return cls._instance - - @instance.setter - def bar(cls, value): - cls._instance = value @dataclass(frozen=True) class GranianConfiguration: @@ -151,3 +140,5 @@ class Configuration: return Configuration.from_dict(conf) finally: loader.dispose() + +instance = Configuration() \ No newline at end of file diff --git a/src/bugis/py.typed b/src/bugis/py.typed new file mode 100644 index 0000000..e69de29