create core framework
This commit is contained in:
@@ -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
2
.gitignore
vendored
@@ -1,7 +1,7 @@
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
src/bugis/_version.py
|
||||
_version.py
|
||||
*.egg-info
|
||||
/build
|
||||
/dist
|
||||
|
54
cli/pyproject.toml
Normal file
54
cli/pyproject.toml
Normal 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
171
cli/requirements-dev.txt
Normal 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
59
cli/requirements.txt
Normal 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
|
118
cli/src/bugis/cli/__init__.py
Normal file
118
cli/src/bugis/cli/__init__.py
Normal 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()
|
4
cli/src/bugis/cli/__main__.py
Normal file
4
cli/src/bugis/cli/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from . import main
|
||||
import sys
|
||||
|
||||
main(sys.argv[1:])
|
30
cli/src/bugis/cli/default-conf/logging.yaml
Normal file
30
cli/src/bugis/cli/default-conf/logging.yaml
Normal 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
46
core/conf/logging.json
Normal 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
20
core/example/hello.py
Normal 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
55
core/pyproject.toml
Normal 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
138
core/requirements-dev.txt
Normal 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
21
core/requirements.txt
Normal 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)
|
14
core/src/bugis/core/__init__.py
Normal file
14
core/src/bugis/core/__init__.py
Normal 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
126
core/src/bugis/core/_app.py
Normal 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,))
|
148
core/src/bugis/core/_asgi.py
Normal file
148
core/src/bugis/core/_asgi.py
Normal 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
|
||||
})
|
51
core/src/bugis/core/_http_context.py
Normal file
51
core/src/bugis/core/_http_context.py
Normal 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
|
11
core/src/bugis/core/_http_method.py
Normal file
11
core/src/bugis/core/_http_method.py
Normal 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'
|
95
core/src/bugis/core/_rsgi.py
Normal file
95
core/src/bugis/core/_rsgi.py
Normal 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)
|
171
core/src/bugis/core/_tree.py
Normal file
171
core/src/bugis/core/_tree.py
Normal 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
|
70
core/src/bugis/core/_types.py
Normal file
70
core/src/bugis/core/_types.py
Normal 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]
|
3
core/src/bugis/core/_types/__init__.py
Normal file
3
core/src/bugis/core/_types/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from typing import Sequence
|
||||
|
||||
type StrOrStrings = (str | Sequence[str])
|
57
core/src/bugis/core/_types/asgi.py
Normal file
57
core/src/bugis/core/_types/asgi.py
Normal 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]]
|
26
core/src/bugis/core/_types/rsgi.py
Normal file
26
core/src/bugis/core/_types/rsgi.py
Normal 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]
|
0
core/src/bugis/core/py.typed
Normal file
0
core/src/bugis/core/py.typed
Normal file
40
core/tests/test_asgi.py
Normal file
40
core/tests/test_asgi.py
Normal 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
74
core/tests/test_tree.py
Normal 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)
|
||||
|
@@ -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'],
|
||||
|
@@ -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
0
src/bugis/py.typed
Normal file
Reference in New Issue
Block a user