create core framework

This commit is contained in:
2024-11-02 23:04:36 +08:00
parent 6acf6d1d6e
commit 544229b7a6
30 changed files with 1612 additions and 16 deletions

View File

@@ -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"

2
.gitignore vendored
View File

@@ -1,7 +1,7 @@
.venv
__pycache__
*.pyc
src/bugis/_version.py
_version.py
*.egg-info
/build
/dist

54
cli/pyproject.toml Normal file
View File

@@ -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"

171
cli/requirements-dev.txt Normal file
View File

@@ -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

59
cli/requirements.txt Normal file
View File

@@ -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

View File

@@ -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()

View File

@@ -0,0 +1,4 @@
from . import main
import sys
main(sys.argv[1:])

View File

@@ -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

46
core/conf/logging.json Normal file
View File

@@ -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
}
}
}

20
core/example/hello.py Normal file
View File

@@ -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')

55
core/pyproject.toml Normal file
View File

@@ -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"

138
core/requirements-dev.txt Normal file
View File

@@ -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

21
core/requirements.txt Normal file
View File

@@ -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)

View File

@@ -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'
]

126
core/src/bugis/core/_app.py Normal file
View File

@@ -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,))

View File

@@ -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
})

View File

@@ -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

View File

@@ -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'

View File

@@ -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)

View File

@@ -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

View File

@@ -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]

View File

@@ -0,0 +1,3 @@
from typing import Sequence
type StrOrStrings = (str | Sequence[str])

View File

@@ -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]]

View File

@@ -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]

View File

40
core/tests/test_asgi.py Normal file
View File

@@ -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)

74
core/tests/test_tree.py Normal file
View File

@@ -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)

View File

@@ -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'],

View File

@@ -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()

0
src/bugis/py.typed Normal file
View File