diff --git a/exam_hypercorn.py b/exam_hypercorn.py new file mode 100644 index 0000000..efdb962 --- /dev/null +++ b/exam_hypercorn.py @@ -0,0 +1,8 @@ +from creart import it +from launart import Launart + +from graia.amnesia.builtins.asgi import HypercornASGIService + +manager = it(Launart) +manager.add_component(HypercornASGIService("127.0.0.1", 5333, patch_logger=True)) +manager.launch_blocking() diff --git a/pdm.lock b/pdm.lock index 067b850..7a28e06 100644 --- a/pdm.lock +++ b/pdm.lock @@ -2,10 +2,10 @@ # It is not intended for manual editing. [metadata] -groups = ["default", "aiohttp", "asgi", "dev", "httpx", "sqla"] -strategy = ["cross_platform", "inherit_metadata"] +groups = ["default", "aiohttp", "dev", "httpx", "hypercorn", "sqla", "uvicorn"] +strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:f317f79d9e2be96e6ea088b7a84dee52eb0ea2c646d019a72d8da2bd3f188cb1" +content_hash = "sha256:8d39b74439d96ba081fa195449a91d0b17c898d6782e28105d92ea54b535f4a2" [[metadata.targets]] requires_python = "~=3.9" @@ -219,7 +219,7 @@ name = "click" version = "8.1.7" requires_python = ">=3.7" summary = "Composable command line interface toolkit" -groups = ["asgi", "dev"] +groups = ["dev", "uvicorn"] dependencies = [ "colorama; platform_system == \"Windows\"", "importlib-metadata; python_version < \"3.8\"", @@ -234,7 +234,7 @@ name = "colorama" version = "0.4.6" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" summary = "Cross-platform colored terminal text." -groups = ["default", "asgi", "dev"] +groups = ["default", "dev", "uvicorn"] marker = "sys_platform == \"win32\" or platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, @@ -260,7 +260,7 @@ name = "exceptiongroup" version = "1.2.0" requires_python = ">=3.7" summary = "Backport of PEP 654 (exception groups)" -groups = ["dev", "httpx"] +groups = ["dev", "httpx", "hypercorn"] marker = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, @@ -416,7 +416,7 @@ name = "h11" version = "0.14.0" requires_python = ">=3.7" summary = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -groups = ["asgi", "dev", "httpx"] +groups = ["dev", "httpx", "hypercorn", "uvicorn"] dependencies = [ "typing-extensions; python_version < \"3.8\"", ] @@ -425,6 +425,32 @@ files = [ {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, ] +[[package]] +name = "h2" +version = "4.3.0" +requires_python = ">=3.9" +summary = "Pure-Python HTTP/2 protocol implementation" +groups = ["dev", "hypercorn"] +dependencies = [ + "hpack<5,>=4.1", + "hyperframe<7,>=6.1", +] +files = [ + {file = "h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd"}, + {file = "h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1"}, +] + +[[package]] +name = "hpack" +version = "4.1.0" +requires_python = ">=3.9" +summary = "Pure-Python HPACK header encoding" +groups = ["dev", "hypercorn"] +files = [ + {file = "hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496"}, + {file = "hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca"}, +] + [[package]] name = "httpcore" version = "1.0.2" @@ -458,6 +484,38 @@ files = [ {file = "httpx-0.26.0.tar.gz", hash = "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf"}, ] +[[package]] +name = "hypercorn" +version = "0.17.3" +requires_python = ">=3.8" +summary = "A ASGI Server based on Hyper libraries and inspired by Gunicorn" +groups = ["dev", "hypercorn"] +dependencies = [ + "exceptiongroup>=1.1.0; python_version < \"3.11\"", + "h11", + "h2>=3.1.0", + "priority", + "taskgroup; python_version < \"3.11\"", + "tomli; python_version < \"3.11\"", + "typing-extensions; python_version < \"3.11\"", + "wsproto>=0.14.0", +] +files = [ + {file = "hypercorn-0.17.3-py3-none-any.whl", hash = "sha256:059215dec34537f9d40a69258d323f56344805efb462959e727152b0aa504547"}, + {file = "hypercorn-0.17.3.tar.gz", hash = "sha256:1b37802ee3ac52d2d85270700d565787ab16cf19e1462ccfa9f089ca17574165"}, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +requires_python = ">=3.9" +summary = "Pure-Python HTTP/2 framing" +groups = ["dev", "hypercorn"] +files = [ + {file = "hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5"}, + {file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"}, +] + [[package]] name = "idna" version = "3.6" @@ -663,6 +721,17 @@ files = [ {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, ] +[[package]] +name = "priority" +version = "2.0.0" +requires_python = ">=3.6.1" +summary = "A pure-Python implementation of the HTTP/2 priority tree" +groups = ["dev", "hypercorn"] +files = [ + {file = "priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa"}, + {file = "priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0"}, +] + [[package]] name = "pytest" version = "7.4.4" @@ -761,12 +830,27 @@ files = [ {file = "statv-0.3.2.tar.gz", hash = "sha256:fb4df2a37bf7a792e36a6e657c74cbdef9e2cdb8de3a0762b28b35c9e13f0fdd"}, ] +[[package]] +name = "taskgroup" +version = "0.2.2" +summary = "backport of asyncio.TaskGroup, asyncio.Runner and asyncio.timeout" +groups = ["dev", "hypercorn"] +marker = "python_version < \"3.11\"" +dependencies = [ + "exceptiongroup", + "typing-extensions<5,>=4.12.2", +] +files = [ + {file = "taskgroup-0.2.2-py2.py3-none-any.whl", hash = "sha256:e2c53121609f4ae97303e9ea1524304b4de6faf9eb2c9280c7f87976479a52fb"}, + {file = "taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d"}, +] + [[package]] name = "tomli" version = "2.0.1" requires_python = ">=3.7" summary = "A lil' TOML parser" -groups = ["dev"] +groups = ["dev", "hypercorn"] marker = "python_version < \"3.11\"" files = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, @@ -775,29 +859,70 @@ files = [ [[package]] name = "typing-extensions" -version = "4.9.0" -requires_python = ">=3.8" -summary = "Backported and Experimental Type Hints for Python 3.8+" -groups = ["default", "asgi", "dev", "httpx", "sqla"] +version = "4.15.0" +requires_python = ">=3.9" +summary = "Backported and Experimental Type Hints for Python 3.9+" +groups = ["default", "dev", "httpx", "hypercorn", "sqla", "uvicorn"] files = [ - {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, - {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] [[package]] name = "uvicorn" -version = "0.25.0" -requires_python = ">=3.8" +version = "0.35.0" +requires_python = ">=3.9" summary = "The lightning-fast ASGI server." -groups = ["asgi", "dev"] +groups = ["dev", "uvicorn"] dependencies = [ "click>=7.0", "h11>=0.8", "typing-extensions>=4.0; python_version < \"3.11\"", ] files = [ - {file = "uvicorn-0.25.0-py3-none-any.whl", hash = "sha256:ce107f5d9bd02b4636001a77a4e74aab5e1e2b146868ebbad565237145af444c"}, - {file = "uvicorn-0.25.0.tar.gz", hash = "sha256:6dddbad1d7ee0f5140aba5ec138ddc9612c5109399903828b4874c9937f009c2"}, + {file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"}, + {file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"}, +] + +[[package]] +name = "uvloop" +version = "0.21.0" +requires_python = ">=3.8.0" +summary = "Fast implementation of asyncio event loop on top of libuv" +groups = ["uvicorn"] +marker = "sys_platform != \"win32\"" +files = [ + {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f"}, + {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d"}, + {file = "uvloop-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f38b2e090258d051d68a5b14d1da7203a3c3677321cf32a95a6f4db4dd8b6f26"}, + {file = "uvloop-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c43e0f13022b998eb9b973b5e97200c8b90823454d4bc06ab33829e09fb9bb"}, + {file = "uvloop-0.21.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10d66943def5fcb6e7b37310eb6b5639fd2ccbc38df1177262b0640c3ca68c1f"}, + {file = "uvloop-0.21.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:67dd654b8ca23aed0a8e99010b4c34aca62f4b7fce88f39d452ed7622c94845c"}, + {file = "uvloop-0.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c0f3fa6200b3108919f8bdabb9a7f87f20e7097ea3c543754cabc7d717d95cf8"}, + {file = "uvloop-0.21.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0"}, + {file = "uvloop-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9fb766bb57b7388745d8bcc53a359b116b8a04c83a2288069809d2b3466c37e"}, + {file = "uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a375441696e2eda1c43c44ccb66e04d61ceeffcd76e4929e527b7fa401b90fb"}, + {file = "uvloop-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:baa0e6291d91649c6ba4ed4b2f982f9fa165b5bbd50a9e203c416a2797bab3c6"}, + {file = "uvloop-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4509360fcc4c3bd2c70d87573ad472de40c13387f5fda8cb58350a1d7475e58d"}, + {file = "uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c"}, + {file = "uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2"}, + {file = "uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d"}, + {file = "uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc"}, + {file = "uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb"}, + {file = "uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f"}, + {file = "uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281"}, + {file = "uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af"}, + {file = "uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6"}, + {file = "uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816"}, + {file = "uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc"}, + {file = "uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553"}, + {file = "uvloop-0.21.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c097078b8031190c934ed0ebfee8cc5f9ba9642e6eb88322b9958b649750f72b"}, + {file = "uvloop-0.21.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:46923b0b5ee7fc0020bef24afe7836cb068f5050ca04caf6b487c513dc1a20b2"}, + {file = "uvloop-0.21.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53e420a3afe22cdcf2a0f4846e377d16e718bc70103d7088a4f7623567ba5fb0"}, + {file = "uvloop-0.21.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88cb67cdbc0e483da00af0b2c3cdad4b7c61ceb1ee0f33fe00e09c81e3a6cb75"}, + {file = "uvloop-0.21.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:221f4f2a1f46032b403bf3be628011caf75428ee3cc204a22addf96f586b19fd"}, + {file = "uvloop-0.21.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2d1f581393673ce119355d56da84fe1dd9d2bb8b3d13ce792524e1607139feff"}, + {file = "uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3"}, ] [[package]] @@ -812,6 +937,20 @@ files = [ {file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"}, ] +[[package]] +name = "wsproto" +version = "1.2.0" +requires_python = ">=3.7.0" +summary = "WebSockets state-machine based protocol implementation" +groups = ["dev", "hypercorn"] +dependencies = [ + "h11<1,>=0.9.0", +] +files = [ + {file = "wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"}, + {file = "wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"}, +] + [[package]] name = "yarl" version = "1.9.4" diff --git a/pyproject.toml b/pyproject.toml index e0225c1..fed0309 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,9 +17,6 @@ readme = "README.md" description = "a collection of shared components for graia" [project.optional-dependencies] -asgi = [ - "uvicorn>=0.23.2", -] httpx = [ "httpx>=0.26.0", ] @@ -29,6 +26,13 @@ aiohttp = [ sqla = [ "sqlalchemy>=2.0.25", ] +hypercorn = [ + "hypercorn>=0.17.3", +] +uvicorn = [ + "uvicorn>=0.35.0", + "uvloop>=0.18.0; sys_platform != \"win32\"", +] [build-system] requires = ["pdm-backend"] @@ -42,8 +46,8 @@ force_sort_within_sections = false extra_standard_library = ["typing_extensions"] [tool.black] -line_length = 120 -target-version = ["py38", "py39", "py310", "py311"] +line-length = 120 +target-version = ["py39", "py310", "py311", "py312", "py313"] include = '\.pyi?$' extend-exclude = ''' ''' @@ -51,7 +55,11 @@ extend-exclude = ''' [tool.pdm.build] includes = ["src/graia"] -[tool.pdm.dev-dependencies] +[tool.pdm.scripts] +test = "pytest -v ./tests/" +format = { composite = ["isort ./src/ ./tests/","black ./src/ ./tests/"] } + +[dependency-groups] dev = [ "black>=25.0.0", "uvicorn>=0.23.2", @@ -60,8 +68,5 @@ dev = [ "sqlalchemy>=2.0.25", "isort==5.13.2", "pytest>=7.4.4", + "hypercorn>=0.17.3", ] - -[tool.pdm.scripts] -test = "pytest -v ./tests/" -format = { composite = ["isort ./src/ ./tests/","black ./src/ ./tests/"] } diff --git a/pyrightconfig.json b/pyrightconfig.json index 6ea2310..25d23b0 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -1,3 +1,3 @@ { - "exclude": ["__pypackages__"] + "exclude": ["__pypackages__", ".venv"] } diff --git a/src/graia/amnesia/builtins/asgi/__init__.py b/src/graia/amnesia/builtins/asgi/__init__.py index c206a0f..a4f8fce 100644 --- a/src/graia/amnesia/builtins/asgi/__init__.py +++ b/src/graia/amnesia/builtins/asgi/__init__.py @@ -1,116 +1,21 @@ -from __future__ import annotations - -import asyncio -import logging - -from launart import Launart, Service -from launart.status import Phase -from launart.utilles import any_completed -from loguru import logger - try: - from uvicorn import Config, Server + from .uvicorn import UvicornASGIService as _UvicornASGIService except ImportError: - raise ImportError( - "dependency 'uvicorn' is required for asgi service\nplease install it or install 'graia-amnesia[asgi]'" - ) - -from . import asgitypes -from .middleware import DispatcherMiddleware - - -async def _empty_asgi_handler(scope, receive, send): - if scope["type"] == "lifespan": - while True: - message = await receive() - if message["type"] == "lifespan.startup": - await send({"type": "lifespan.startup.complete"}) - return - elif message["type"] == "lifespan.shutdown": - await send({"type": "lifespan.shutdown.complete"}) - return - - await send( - { - "type": "http.response.start", - "status": 404, - "headers": [(b"content-length", b"0")], - } - ) - await send({"type": "http.response.body"}) - - -class LoguruHandler(logging.Handler): - def emit(self, record: logging.LogRecord) -> None: - try: - level = logger.level(record.levelname).name - except ValueError: - level = str(record.levelno) - - frame, depth = logging.currentframe(), 2 - while frame and frame.f_code.co_filename == logging.__file__: - frame = frame.f_back - depth += 1 - - logger.opt(depth=depth, exception=record.exc_info).log( - level, - record.getMessage(), - ) - - -class WithoutSigHandlerServer(Server): - def install_signal_handlers(self) -> None: - pass + _UvicornASGIService = None - -class UvicornASGIService(Service): - id = "asgi.service/uvicorn" - - middleware: DispatcherMiddleware - host: str - port: int - - def __init__( - self, - host: str, - port: int, - mounts: dict[str, asgitypes.ASGI3Application] | None = None, - ): - self.host = host - self.port = port - self.middleware = DispatcherMiddleware(mounts or {"\0\0\0": _empty_asgi_handler}) - super().__init__() - - @property - def required(self): - return set() - - @property - def stages(self) -> set[Phase]: - return {"preparing", "blocking", "cleanup"} - - async def launch(self, manager: Launart) -> None: - async with self.stage("preparing"): - self.server = WithoutSigHandlerServer( - Config(self.middleware, host=self.host, port=self.port, factory=False) - ) - - level = logging.getLevelName(20) # default level for uvicorn - logging.basicConfig(handlers=[LoguruHandler()], level=level) - PATCHES = ["uvicorn.error", "uvicorn.asgi", "uvicorn.access", ""] - for name in PATCHES: - target = logging.getLogger(name) - target.handlers = [LoguruHandler(level=level)] - target.propagate = False - - serve_task = asyncio.create_task(self.server.serve()) - - async with self.stage("blocking"): - await any_completed(serve_task, manager.status.wait_for_sigexit()) - - async with self.stage("cleanup"): - logger.warning("try to shutdown uvicorn server...") - self.server.should_exit = True - await any_completed(serve_task, asyncio.sleep(5)) - if not serve_task.done(): - logger.warning("timeout, force exit uvicorn server...") +try: + from .hypercorn import HypercornASGIService as _HypercornASGIService +except ImportError: + _HypercornASGIService = None + + +def __getattr__(name): + if name == "UvicornASGIService": + if _UvicornASGIService is None: + raise ImportError("Please install `uvicorn` first. Install with `pip install graia-amnesia[uvicorn]`") + return _UvicornASGIService + if name == "HypercornASGIService": + if _HypercornASGIService is None: + raise ImportError("Please install `hypercorn` first. Install with `pip install graia-amnesia[hypercorn]`") + return _HypercornASGIService + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/graia/amnesia/builtins/asgi/__init__.pyi b/src/graia/amnesia/builtins/asgi/__init__.pyi new file mode 100644 index 0000000..d4dd30d --- /dev/null +++ b/src/graia/amnesia/builtins/asgi/__init__.pyi @@ -0,0 +1,2 @@ +from .hypercorn import HypercornASGIService as HypercornASGIService +from .uvicorn import UvicornASGIService as UvicornASGIService diff --git a/src/graia/amnesia/builtins/asgi/common.py b/src/graia/amnesia/builtins/asgi/common.py new file mode 100644 index 0000000..95f58ad --- /dev/null +++ b/src/graia/amnesia/builtins/asgi/common.py @@ -0,0 +1,19 @@ +async def empty_asgi_handler(scope, receive, send): + if scope["type"] == "lifespan": + while True: + message = await receive() + if message["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + return + elif message["type"] == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + + await send( + { + "type": "http.response.start", + "status": 404, + "headers": [(b"content-length", b"0")], + } + ) + await send({"type": "http.response.body"}) diff --git a/src/graia/amnesia/builtins/asgi/hypercorn.py b/src/graia/amnesia/builtins/asgi/hypercorn.py new file mode 100644 index 0000000..697cba4 --- /dev/null +++ b/src/graia/amnesia/builtins/asgi/hypercorn.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import asyncio +import logging +import re +from ssl import VerifyFlags, VerifyMode +from typing import Any, List, Optional, TypedDict, Union + +from hypercorn.asyncio import serve +from hypercorn.config import Config +from hypercorn.logging import Logger +from hypercorn.typing import ResponseSummary, WWWScope +from launart import Launart, Service +from launart.status import Phase +from launart.utilles import any_completed +from loguru import logger + +from . import asgitypes +from .common import empty_asgi_handler +from .middleware import DispatcherMiddleware + + +class HypercornOptions(TypedDict, total=False): + insecure_bind: Union[List[str], str] + """default: []""" + quic_bind: Union[List[str], str] + """default: []""" + root_path: str + """default: ''""" + + access_log_format: str + """default: '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'""" + accesslog: Union[logging.Logger, str, None] + """default: None""" + alpn_protocols: list[str] + """default: ['h2', 'http/1.1']""" + alt_svc_headers: List[str] + """default: []""" + backlog: int + """default: 100""" + ca_certs: Optional[str] + """default: None""" + certfile: Optional[str] + """default: None""" + ciphers: str + """default: 'ECDHE+AESGCM'""" + dogstatsd_tags: str + """default: ''""" + errorlog: Union[logging.Logger, str, None] + """default: '-'""" + graceful_timeout: float + """default: 3.0""" + read_timeout: Optional[int] + """default: None""" + group: Optional[int] + """default: None""" + h11_max_incomplete_size: int + """default: 16 * 1024""" + h11_pass_raw_headers: bool + """default: False""" + h2_max_concurrent_streams: int + """default: 100""" + h2_max_header_list_size: int + """default: 2 ** 16""" + h2_max_inbound_frame_size: int + """default: 2 ** 14""" + include_date_header: bool + """default: True""" + include_server_header: bool + """default: True""" + keep_alive_timeout: float + """default: 5.0""" + keep_alive_max_requests: int + """default: 1000""" + keyfile: Optional[str] + """default: None""" + keyfile_password: Optional[str] + """default: None""" + logger_class: type + """default: hypercorn.logging.Logger""" + logconfig: Optional[str] + """default: None""" + logconfig_dict: Optional[dict] + """default: None""" + loglevel: str + """default: 'INFO'""" + max_app_queue_size: int + """default: 10""" + max_requests: Optional[int] + """default: None""" + max_requests_jitter: int + """default: 0""" + pid_path: Optional[str] + """default: None""" + server_names: List[str] + """default: []""" + shutdown_timeout: float + """default: 60.0""" + ssl_handshake_timeout: float + """default: 60.0""" + startup_timeout: float + """default: 60.0""" + statsd_host: Optional[str] + """default: None""" + statsd_prefix: str + """default: ''""" + umask: Optional[int] + """default: None""" + use_reloader: bool + """default: False""" + user: Optional[int] + """default: None""" + verify_flags: Optional[VerifyFlags] + """default: None""" + verify_mode: Optional[VerifyMode] + """default: None""" + websocket_max_message_size: int + """default: 16 * 1024 * 1024""" + websocket_ping_interval: Optional[float] + """default: None""" + worker_class: str + """default: 'asyncio'""" + wsgi_max_body_size: int + """default: 16 * 1024 * 1024""" + + +class LoguruLogger(Logger): + def __init__(self, config: "Config") -> None: + super().__init__(config) + self.access_template = re.sub(r"%\(([^)]+)\)s", r"{\1}", self.access_log_format) + self.access_template = self.access_template.replace("{t} ", "") + + async def access(self, request: "WWWScope", response: "ResponseSummary", request_time: float) -> None: + if self.access_logger is not None: + logger.info(self.access_template.format(**self.atoms(request, response, request_time))) + + async def critical(self, message: str, *args: Any, **kwargs: Any) -> None: + logger.critical(message, *args, **kwargs) + + async def error(self, message: str, *args: Any, **kwargs: Any) -> None: + logger.error(message, *args, **kwargs) + + async def warning(self, message: str, *args: Any, **kwargs: Any) -> None: + logger.warning(message, *args, **kwargs) + + async def info(self, message: str, *args: Any, **kwargs: Any) -> None: + logger.info(message, *args, **kwargs) + + async def debug(self, message: str, *args: Any, **kwargs: Any) -> None: + logger.debug(message, *args, **kwargs) + + async def exception(self, message: str, *args: Any, **kwargs: Any) -> None: + logger.exception(message, *args, **kwargs) + + async def log(self, level: int, message: str, *args: Any, **kwargs: Any) -> None: + logger.log(level, message, *args, **kwargs) + + +class HypercornASGIService(Service): + id = "asgi.service/hypercorn" + + middleware: DispatcherMiddleware + host: str + port: int + + def __init__( + self, + host: str, + port: int, + mounts: dict[str, asgitypes.ASGI3Application] | None = None, + options: HypercornOptions | None = None, + patch_logger: bool = True, + ): + self.host = host + self.port = port + self.middleware = DispatcherMiddleware(mounts or {"\0\0\0": empty_asgi_handler}) + self.options = options or {} + if patch_logger: + self.options["logger_class"] = LoguruLogger # type: ignore + super().__init__() + + @property + def required(self): + return set() + + @property + def stages(self) -> set[Phase]: + return {"preparing", "blocking", "cleanup"} + + async def launch(self, manager: Launart) -> None: + async with self.stage("preparing"): + shutdown_trigger = asyncio.Event() + serve_task = asyncio.create_task( + serve( + self.middleware, # type: ignore + Config.from_mapping(bind=f"{self.host}:{self.port}", **self.options), + shutdown_trigger=shutdown_trigger.wait, + ) + ) + + async with self.stage("blocking"): + await any_completed(serve_task, manager.status.wait_for_sigexit()) + + async with self.stage("cleanup"): + logger.warning("trying to shutdown hypercorn server...") + shutdown_trigger.set() + try: + await asyncio.wait_for(serve_task, timeout=5.0) + except asyncio.TimeoutError: + logger.warning("timeout, force exit hypercorn server...") diff --git a/src/graia/amnesia/builtins/asgi/uvicorn.py b/src/graia/amnesia/builtins/asgi/uvicorn.py new file mode 100644 index 0000000..c897bbc --- /dev/null +++ b/src/graia/amnesia/builtins/asgi/uvicorn.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import asyncio +import logging +import os +from typing import IO, Any, Awaitable, Callable, TypedDict + +from launart import Launart, Service +from launart.status import Phase +from launart.utilles import any_completed +from loguru import logger +from uvicorn import Config, Server +from uvicorn.config import LOG_LEVELS, HTTPProtocolType, LifespanType, LoopSetupType, WSProtocolType + +from ..utils import LoguruHandler +from . import asgitypes +from .common import empty_asgi_handler +from .middleware import DispatcherMiddleware + + +class WithoutSigHandlerServer(Server): + def install_signal_handlers(self) -> None: + pass + + +class UvicornOptions(TypedDict, total=False): + uds: str | None + """default: None""" + fd: int | None + """default: None""" + loop: LoopSetupType + """default: 'auto'""" + http: type[asyncio.Protocol] | HTTPProtocolType + """default: 'auto'""" + ws: type[asyncio.Protocol] | WSProtocolType + """default: 'auto'""" + ws_max_size: int + """default: 16 * 1024 * 1024""" + ws_max_queue: int + """default: 32""" + ws_ping_interval: float | None + """default: 20.0""" + ws_ping_timeout: float | None + """default: 20.0""" + ws_per_message_deflate: bool + """default: True""" + lifespan: LifespanType + """default: 'auto'""" + env_file: str | os.PathLike[str] | None + """default: None""" + log_config: dict[str, Any] | str | IO[Any] | None + """default: LOGGING_CONFIG""" + log_level: str | int | None + """default: None""" + access_log: bool + """default: True""" + use_colors: bool | None + """default: None""" + # interface: InterfaceType + # """default: 'auto'""" + reload: bool + """default: False""" + reload_dirs: list[str] | str | None + """default: None""" + reload_delay: float + """default: 0.25""" + reload_includes: list[str] | str | None + """default: None""" + reload_excludes: list[str] | str | None + """default: None""" + workers: int | None + """default: None""" + proxy_headers: bool + """default: True""" + server_header: bool + """default: True""" + date_header: bool + """default: True""" + forwarded_allow_ips: list[str] | str | None + """default: None""" + root_path: str + """default: ''""" + limit_concurrency: int | None + """default: None""" + limit_max_requests: int | None + """default: None""" + backlog: int + """default: 2048""" + timeout_keep_alive: int + """default: 5""" + timeout_notify: int + """default: 30""" + timeout_graceful_shutdown: int | None + """default: None""" + callback_notify: Callable[..., Awaitable[None]] | None + """default: None""" + ssl_keyfile: str | os.PathLike[str] | None + """default: None""" + ssl_certfile: str | os.PathLike[str] | None + """default: None""" + ssl_keyfile_password: str | None + """default: None""" + ssl_version: int + """default: ssl.PROTOCOL_TLS""" + ssl_cert_reqs: int + """default: ssl.CERT_NONE""" + ssl_ca_certs: str | None + """default: None""" + ssl_ciphers: str + """default: 'TLSv1'""" + headers: list[tuple[str, str]] | None + """default: None""" + h11_max_incomplete_event_size: int | None + """default: None""" + + +class UvicornASGIService(Service): + id = "asgi.service/uvicorn" + + middleware: DispatcherMiddleware + host: str + port: int + + def __init__( + self, + host: str, + port: int, + mounts: dict[str, asgitypes.ASGI3Application] | None = None, + options: UvicornOptions | None = None, + patch_logger: bool = True, + ): + self.host = host + self.port = port + self.patch_logger = patch_logger + self.middleware = DispatcherMiddleware(mounts or {"\0\0\0": empty_asgi_handler}) + self.options: UvicornOptions = options or {} + super().__init__() + + @property + def required(self): + return set() + + @property + def stages(self) -> set[Phase]: + return {"preparing", "blocking", "cleanup"} + + async def launch(self, manager: Launart) -> None: + async with self.stage("preparing"): + self.server = WithoutSigHandlerServer( + Config(self.middleware, host=self.host, port=self.port, factory=False, **self.options) + ) + if self.patch_logger: + self._patch_logger() + serve_task = asyncio.create_task(self.server.serve()) + + async with self.stage("blocking"): + await any_completed(serve_task, manager.status.wait_for_sigexit()) + + async with self.stage("cleanup"): + logger.warning("try to shutdown uvicorn server...") + self.server.should_exit = True + await any_completed(serve_task, asyncio.sleep(5)) + if not serve_task.done(): + logger.warning("timeout, force exit uvicorn server...") + + def _patch_logger(self) -> None: + log_level = 20 + if "log_level" in self.options and (_log_level := self.options["log_level"]) is not None: + if isinstance(_log_level, str): + log_level = LOG_LEVELS[_log_level] + else: + log_level = _log_level + PATCHES = ["uvicorn.error", "uvicorn.asgi", "uvicorn.access", "uvicorn"] + for name in PATCHES: + target = logging.getLogger(name) + target.handlers = [LoguruHandler()] + target.propagate = False + target.setLevel(log_level) diff --git a/src/graia/amnesia/builtins/sqla/__init__.py b/src/graia/amnesia/builtins/sqla/__init__.py index c32145d..61ca3f6 100644 --- a/src/graia/amnesia/builtins/sqla/__init__.py +++ b/src/graia/amnesia/builtins/sqla/__init__.py @@ -11,12 +11,12 @@ "dependency 'sqlalchemy' is required for sqlalchemy service\nplease install it or install 'graia-amnesia[sqla]'" ) +from ..utils import LoguruHandler, get_subclasses from .model import Base as Base from .service import SqlalchemyService as SqlalchemyService -from .utils import LoguruHandler, get_subclasses -def pacth_logger(log_level: str | int = "INFO", sqlalchemy_echo: bool = False) -> None: +def patch_logger(log_level: str | int = "INFO", sqlalchemy_echo: bool = False) -> None: handler = LoguruHandler() logging.getLogger("sqlalchemy").addHandler(handler) diff --git a/src/graia/amnesia/builtins/sqla/service.py b/src/graia/amnesia/builtins/sqla/service.py index 4b654ad..9350983 100644 --- a/src/graia/amnesia/builtins/sqla/service.py +++ b/src/graia/amnesia/builtins/sqla/service.py @@ -14,9 +14,9 @@ from sqlalchemy.sql.base import Executable from sqlalchemy.sql.selectable import TypedReturnsRows +from ..utils import get_subclasses from .model import Base from .types import EngineOptions -from .utils import get_subclasses T_Row = TypeVar("T_Row", bound=DeclarativeBase) diff --git a/src/graia/amnesia/builtins/sqla/utils.py b/src/graia/amnesia/builtins/utils.py similarity index 73% rename from src/graia/amnesia/builtins/sqla/utils.py rename to src/graia/amnesia/builtins/utils.py index 54b096a..16c1635 100644 --- a/src/graia/amnesia/builtins/sqla/utils.py +++ b/src/graia/amnesia/builtins/utils.py @@ -8,8 +8,6 @@ class LoguruHandler(logging.Handler): def emit(self, record: logging.LogRecord): try: level = logger.level(record.levelname).name - if record.levelno <= logging.INFO: - level = {"DEBUG": "TRACE", "INFO": "DEBUG"}.get(level, level) except ValueError: level = record.levelno @@ -18,7 +16,9 @@ def emit(self, record: logging.LogRecord): frame = frame.f_back depth += 1 - logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) + logger.opt(depth=depth, exception=record.exc_info).patch(lambda rec: rec.update(name=record.name)).log( + level, record.getMessage() + ) def get_subclasses(cls):