pax_global_header00006660000000000000000000000064145650710000014510gustar00rootroot0000000000000052 comment=55675c05a2e761ac84a9d6880d4e1f9b648e75fc a2wsgi-1.10.2/000077500000000000000000000000001456507100000127655ustar00rootroot00000000000000a2wsgi-1.10.2/.coveragerc000066400000000000000000000006601456507100000151100ustar00rootroot00000000000000[run] omit = .venv/* */tests/* [report] # Regexes for lines to exclude from consideration exclude_lines = # Don't complain about missing debug-only code: def __repr__ # Don't complain if tests don't hit defensive assertion code: raise AssertionError raise NotImplementedError # Don't complain if non-runnable code isn't run: if False: assert False show_missing = true skip_covered = true a2wsgi-1.10.2/.gitattributes000066400000000000000000000000141456507100000156530ustar00rootroot00000000000000* text=auto a2wsgi-1.10.2/.github/000077500000000000000000000000001456507100000143255ustar00rootroot00000000000000a2wsgi-1.10.2/.github/workflows/000077500000000000000000000000001456507100000163625ustar00rootroot00000000000000a2wsgi-1.10.2/.github/workflows/ci.yml000066400000000000000000000022271456507100000175030ustar00rootroot00000000000000name: CI/CD on: [push, pull_request] jobs: tests: name: "Python ${{ matrix.python-version }} ${{ matrix.os }}" runs-on: "${{ matrix.os }}" strategy: matrix: python-version: [3.8, 3.9, "3.10", "3.11", "3.12"] os: [ubuntu-latest, windows-latest, macos-latest] steps: - uses: actions/checkout@v2 - uses: pdm-project/setup-pdm@v2.6 name: Setup Python and PDM with: python-version: ${{ matrix.python-version }} architecture: x64 version: 2.9 - name: Install dependencies run: | pdm sync -v -dG dev -dG test --no-self - name: Tests run: pdm run pytest tests -o log_cli=true -o log_cli_level=DEBUG publish: needs: tests if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: pdm-project/setup-pdm@v2.6 name: Setup Python and PDM with: python-version: "3.10" architecture: x64 version: 2.9 - name: Publish run: | pdm publish --username __token__ --password ${{ secrets.PYPI_API_TOKEN }} a2wsgi-1.10.2/.gitignore000066400000000000000000000022051456507100000147540ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # IPython Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # dotenv .env # virtualenv venv/ ENV/ .venv/ # Spyder project settings .spyderproject # Rope project settings .ropeproject *.npy *.pkl # mypy .mypy_cache/ # VSCode .vscode/ # PyCharm .idea/ # mkdocs site/ # PDM .pdm-python a2wsgi-1.10.2/LICENSE000066400000000000000000000261151456507100000137770ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2022 abersheeran Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. a2wsgi-1.10.2/README.md000066400000000000000000000064661456507100000142600ustar00rootroot00000000000000# a2wsgi Convert WSGI app to ASGI app or ASGI app to WSGI app. Pure Python. Only depend on the standard library. Compared with other converters, the advantage is that a2wsgi will not accumulate the requested content or response content in the memory, so you don't have to worry about the memory limit caused by a2wsgi. This problem exists in converters implemented by uvicorn/startlette or hypercorn. ## Install ``` pip install a2wsgi ``` ## How to use ### `WSGIMiddleware` Convert WSGI app to ASGI app: ```python from a2wsgi import WSGIMiddleware ASGI_APP = WSGIMiddleware(WSGI_APP) ``` WSGIMiddleware executes WSGI applications with a thread pool of up to 10 threads by default. If you want to increase or decrease this number, just like `WSGIMiddleware(..., workers=15)`. WSGIMiddleware utilizes a queue to direct traffic from the WSGI App to the client. To adjust the queue size, simply specify the send_queue_size parameter (default to `10`) during initialization, like so: WSGIMiddleware(..., send_queue_size=15). This enable developers to balance memory usage and application responsiveness. ### `ASGIMiddleware` Convert ASGI app to WSGI app: ```python from a2wsgi import ASGIMiddleware WSGI_APP = ASGIMiddleware(ASGI_APP) ``` `ASGIMiddleware` will wait for the ASGI application's Background Task to complete before returning the last null byte. But sometimes you may not want to wait indefinitely for the execution of the Background Task of the ASGI application, then you only need to give the parameter `ASGIMiddleware(..., wait_time=5.0)`, after the time exceeds, the ASGI task corresponding to the request will be tried to cancel, and the last null byte will be returned. You can also specify your own event loop through the `loop` parameter instead of the default event loop. Like `ASGIMiddleware(..., loop=faster_loop)` ### Access the original `Scope`/`Environ` Sometimes you may need to access the original WSGI Environ in the ASGI application, just use `scope["wsgi_environ"]`; it is also easy to access the ASGI Scope in the WSGI Application, use `environ["asgi.scope"]`. ## Benchmark Run `pytest ./benchmark.py -s` to compare the performance of `a2wsgi` and `uvicorn.middleware.wsgi.WSGIMiddleware` / `asgiref.wsgi.WsgiToAsgi`. ## Why a2wsgi ### Convert WSGI app to ASGI app You can convert an existing WSGI project to an ASGI project to make it easier to migrate from WSGI applications to ASGI applications. ### Convert ASGI app to WSGI app There is a lot of support for WSGI. Converting ASGI to WSGI, you will be able to use many existing services to deploy ASGI applications. ## Compatibility list This list quickly demonstrates the compatibility of some common frameworks for users who are unfamiliar with the WSGI and ASGI protocols. - WSGI: [Django(wsgi)](https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/)/[Kuí(wsgi)](https://kui.aber.sh/wsgi/)/[Pyramid](https://trypyramid.com/)/[Bottle](https://bottlepy.org/)/[Flask](https://flask.palletsprojects.com/) - ASGI: [Django(asgi)](https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/)/[Kuí(asgi)](https://kui.aber.sh/asgi/)/[Starlette](https://www.starlette.io/)/[FastAPI](https://fastapi.tiangolo.com/)/[Sanic](https://sanic.readthedocs.io/en/stable/)/[Quart](https://pgjones.gitlab.io/quart/) - **Unsupport**: [aiohttp](https://docs.aiohttp.org/en/stable/) a2wsgi-1.10.2/a2wsgi/000077500000000000000000000000001456507100000141615ustar00rootroot00000000000000a2wsgi-1.10.2/a2wsgi/__init__.py000066400000000000000000000002701456507100000162710ustar00rootroot00000000000000from .asgi import ASGIMiddleware from .wsgi import WSGIMiddleware VERSION = (1, 10, 2) __version__: str = ".".join(map(str, VERSION)) __all__ = ("WSGIMiddleware", "ASGIMiddleware") a2wsgi-1.10.2/a2wsgi/asgi.py000066400000000000000000000216321456507100000154620ustar00rootroot00000000000000import asyncio import collections import threading from http import HTTPStatus from io import BytesIO from typing import Any, Coroutine, Deque, Iterable, Optional from typing import cast as typing_cast from .asgi_typing import HTTPScope, ASGIApp, ReceiveEvent, SendEvent from .wsgi_typing import Environ, StartResponse, IterableChunks class defaultdict(dict): def __init__(self, default_factory, *args, **kwargs) -> None: self.default_factory = default_factory super().__init__(*args, **kwargs) def __missing__(self, key): return self.default_factory(key) StatusStringMapping = defaultdict( lambda status: f"{status} Unknown Status Code", {int(status): f"{status} {status.phrase}" for status in HTTPStatus}, ) class AsyncEvent: def __init__(self, loop: asyncio.AbstractEventLoop) -> None: self.loop = loop self.__waiters: Deque[asyncio.Future] = collections.deque() self.__nowait = False def _set(self, message: Any) -> None: for future in filter(lambda f: not f.done(), self.__waiters): future.set_result(message) def set(self, message: Any) -> None: self.loop.call_soon_threadsafe(self._set, message) async def wait(self) -> Any: if self.__nowait: return None future = self.loop.create_future() self.__waiters.append(future) try: result = await future return result finally: self.__waiters.remove(future) def set_nowait(self) -> None: self.__nowait = True class SyncEvent: def __init__(self) -> None: self.__write_event = threading.Event() self.__message: Any = None def set(self, message: Any) -> None: self.__message = message self.__write_event.set() def wait(self) -> Any: self.__write_event.wait() self.__write_event.clear() message, self.__message = self.__message, None return message def build_scope(environ: Environ) -> HTTPScope: headers = [ ( (key[5:] if key.startswith("HTTP_") else key) .lower() .replace("_", "-") .encode("latin-1"), value.encode("latin-1"), # type: ignore ) for key, value in environ.items() if ( key.startswith("HTTP_") and key not in ("HTTP_CONTENT_TYPE", "HTTP_CONTENT_LENGTH") ) or key in ("CONTENT_TYPE", "CONTENT_LENGTH") ] root_path = environ.get("SCRIPT_NAME", "").encode("latin1").decode("utf8") path = root_path + environ.get("PATH_INFO", "").encode("latin1").decode("utf8") scope: HTTPScope = { "wsgi_environ": environ, # type: ignore a2wsgi "type": "http", "asgi": {"version": "3.0", "spec_version": "3.0"}, "http_version": environ.get("SERVER_PROTOCOL", "http/1.0").split("/")[1], "method": environ["REQUEST_METHOD"], "scheme": environ.get("wsgi.url_scheme", "http"), "path": path, "query_string": environ.get("QUERY_STRING", "").encode("ascii"), "root_path": root_path, "server": (environ["SERVER_NAME"], int(environ["SERVER_PORT"])), "headers": headers, "extensions": {}, } if environ.get("REMOTE_ADDR") and environ.get("REMOTE_PORT"): client = (environ.get("REMOTE_ADDR", ""), int(environ.get("REMOTE_PORT", "0"))) scope["client"] = client return scope class ASGIMiddleware: """ Convert ASGIApp to WSGIApp. wait_time: After the http response ends, the maximum time to wait for the ASGI app to run. """ def __init__( self, app: ASGIApp, wait_time: Optional[float] = None, loop: Optional[asyncio.AbstractEventLoop] = None, ) -> None: self.app = app if loop is None: loop = asyncio.new_event_loop() loop_threading = threading.Thread(target=loop.run_forever, daemon=True) loop_threading.start() self.loop = loop self.wait_time = wait_time def __call__( self, environ: Environ, start_response: StartResponse ) -> Iterable[bytes]: return ASGIResponder(self.app, self.loop, self.wait_time)( environ, start_response ) class ASGIResponder: def __init__( self, app: ASGIApp, loop: asyncio.AbstractEventLoop, wait_time: Optional[float] = None, ) -> None: self.app = app self.loop = loop self.wait_time = wait_time self.sync_event = SyncEvent() self.async_event = AsyncEvent(loop) self.async_lock: asyncio.Lock def _init_async_lock(): self.async_lock = asyncio.Lock() loop.call_soon_threadsafe(_init_async_lock) self.asgi_done = threading.Event() self.wsgi_should_stop: bool = False async def asgi_receive(self) -> ReceiveEvent: async with self.async_lock: self.sync_event.set({"type": "receive"}) return await self.async_event.wait() async def asgi_send(self, message: SendEvent) -> None: async with self.async_lock: self.sync_event.set(message) await self.async_event.wait() def asgi_done_callback(self, future: asyncio.Future) -> None: try: exception = future.exception() except asyncio.CancelledError: pass else: if exception is not None: self.sync_event.set( { "type": "a2wsgi.error", "exception": ( type(exception), exception, exception.__traceback__, ), } ) finally: self.asgi_done.set() def start_asgi_app(self, environ: Environ) -> asyncio.Task: run_asgi: asyncio.Task = self.loop.create_task( typing_cast( Coroutine[None, None, None], self.app(build_scope(environ), self.asgi_receive, self.asgi_send), ) ) run_asgi.add_done_callback(self.asgi_done_callback) return run_asgi def __call__( self, environ: Environ, start_response: StartResponse ) -> IterableChunks: read_count: int = 0 body = environ["wsgi.input"] or BytesIO() content_length = int(environ.get("CONTENT_LENGTH", None) or 0) asgi_task = self.start_asgi_app(environ) # activate loop self.loop.call_soon_threadsafe(lambda: None) while True: message = self.sync_event.wait() message_type = message["type"] if message_type == "http.response.start": start_response( StatusStringMapping[message["status"]], [ ( name.strip().decode("latin1"), value.strip().decode("latin1"), ) for name, value in message["headers"] ], None, ) elif message_type == "http.response.body": yield message.get("body", b"") self.wsgi_should_stop = not message.get("more_body", False) elif message_type == "http.response.disconnect": self.wsgi_should_stop = True # ASGI application error elif message_type == "a2wsgi.error": start_response( "500 Internal Server Error", [ ("Content-Type", "text/plain; charset=utf-8"), ("Content-Length", "28"), ], message["exception"], ) yield b"Server got itself in trouble" self.wsgi_should_stop = True elif message_type == "receive": pass else: raise RuntimeError(f"Unknown message type: {message_type}") if message_type == "receive": read_size = min(65536, content_length - read_count) data: bytes = body.read(read_size) if data == b"": self.async_event.set({"type": "http.disconnect"}) else: read_count += len(data) more_body = read_count < content_length self.async_event.set( {"type": "http.request", "body": data, "more_body": more_body} ) else: self.async_event.set(None) if self.wsgi_should_stop: self.async_event.set_nowait() break if asgi_task.done(): break # HTTP response ends, wait for run_asgi's background tasks self.asgi_done.wait(self.wait_time) asgi_task.cancel() yield b"" a2wsgi-1.10.2/a2wsgi/asgi_typing.py000066400000000000000000000100261456507100000170470ustar00rootroot00000000000000""" https://asgi.readthedocs.io/en/latest/specs/index.html """ import sys from typing import ( Any, Awaitable, Callable, Dict, Iterable, Literal, Optional, Tuple, TypedDict, Union, ) if sys.version_info >= (3, 11): from typing import NotRequired else: from typing_extensions import NotRequired class ASGIVersions(TypedDict): spec_version: str version: Literal["3.0"] class HTTPScope(TypedDict): type: Literal["http"] asgi: ASGIVersions http_version: str method: str scheme: str path: str raw_path: NotRequired[bytes] query_string: bytes root_path: str headers: Iterable[Tuple[bytes, bytes]] client: NotRequired[Tuple[str, int]] server: NotRequired[Tuple[str, Optional[int]]] state: NotRequired[Dict[str, Any]] extensions: NotRequired[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: NotRequired[Tuple[str, int]] server: NotRequired[Tuple[str, Optional[int]]] subprotocols: Iterable[str] state: NotRequired[Dict[str, Any]] extensions: NotRequired[Dict[str, Dict[object, object]]] class LifespanScope(TypedDict): type: Literal["lifespan"] asgi: ASGIVersions state: NotRequired[Dict[str, Any]] WWWScope = Union[HTTPScope, WebSocketScope] Scope = Union[HTTPScope, WebSocketScope, LifespanScope] class HTTPRequestEvent(TypedDict): type: Literal["http.request"] body: bytes more_body: NotRequired[bool] class HTTPResponseStartEvent(TypedDict): type: Literal["http.response.start"] status: int headers: NotRequired[Iterable[Tuple[bytes, bytes]]] trailers: NotRequired[bool] class HTTPResponseBodyEvent(TypedDict): type: Literal["http.response.body"] body: NotRequired[bytes] more_body: NotRequired[bool] class HTTPDisconnectEvent(TypedDict): type: Literal["http.disconnect"] class WebSocketConnectEvent(TypedDict): type: Literal["websocket.connect"] class WebSocketAcceptEvent(TypedDict): type: Literal["websocket.accept"] subprotocol: NotRequired[str] headers: NotRequired[Iterable[Tuple[bytes, bytes]]] class WebSocketReceiveEvent(TypedDict): type: Literal["websocket.receive"] bytes: NotRequired[bytes] text: NotRequired[str] class WebSocketSendEvent(TypedDict): type: Literal["websocket.send"] bytes: NotRequired[bytes] text: NotRequired[str] class WebSocketDisconnectEvent(TypedDict): type: Literal["websocket.disconnect"] code: int class WebSocketCloseEvent(TypedDict): type: Literal["websocket.close"] code: NotRequired[int] reason: NotRequired[str] class LifespanStartupEvent(TypedDict): type: Literal["lifespan.startup"] class LifespanShutdownEvent(TypedDict): type: Literal["lifespan.shutdown"] class LifespanStartupCompleteEvent(TypedDict): type: Literal["lifespan.startup.complete"] class LifespanStartupFailedEvent(TypedDict): type: Literal["lifespan.startup.failed"] message: str class LifespanShutdownCompleteEvent(TypedDict): type: Literal["lifespan.shutdown.complete"] class LifespanShutdownFailedEvent(TypedDict): type: Literal["lifespan.shutdown.failed"] message: str ReceiveEvent = Union[ HTTPRequestEvent, HTTPDisconnectEvent, WebSocketConnectEvent, WebSocketReceiveEvent, WebSocketDisconnectEvent, LifespanStartupEvent, LifespanShutdownEvent, ] SendEvent = Union[ HTTPResponseStartEvent, HTTPResponseBodyEvent, HTTPDisconnectEvent, WebSocketAcceptEvent, WebSocketSendEvent, WebSocketCloseEvent, LifespanStartupCompleteEvent, LifespanStartupFailedEvent, LifespanShutdownCompleteEvent, LifespanShutdownFailedEvent, ] Receive = Callable[[], Awaitable[ReceiveEvent]] Send = Callable[[SendEvent], Awaitable[None]] ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]] a2wsgi-1.10.2/a2wsgi/py.typed000066400000000000000000000000001456507100000156460ustar00rootroot00000000000000a2wsgi-1.10.2/a2wsgi/wsgi.py000066400000000000000000000221541456507100000155100ustar00rootroot00000000000000import asyncio import collections import contextvars import functools import os import sys import typing from concurrent.futures import ThreadPoolExecutor from .asgi_typing import HTTPScope, Scope, Receive, Send, SendEvent from .wsgi_typing import Environ, StartResponse, ExceptionInfo, WSGIApp, WriteCallable class Body: def __init__(self, loop: asyncio.AbstractEventLoop, receive: Receive) -> None: self.buffer = bytearray() self.loop = loop self.receive = receive self._has_more = True @property def has_more(self) -> bool: if self._has_more or self.buffer: return True return False def _receive_more_data(self) -> bytes: if not self._has_more: return b"" future = asyncio.run_coroutine_threadsafe(self.receive(), loop=self.loop) message = future.result() self._has_more = message.get("more_body", False) return message.get("body", b"") def read(self, size: int = -1) -> bytes: while size == -1 or size > len(self.buffer): self.buffer.extend(self._receive_more_data()) if not self._has_more: break if size == -1: result = bytes(self.buffer) self.buffer.clear() else: result = bytes(self.buffer[:size]) del self.buffer[:size] return result def readline(self, limit: int = -1) -> bytes: while True: lf_index = self.buffer.find(b"\n", 0, limit if limit > -1 else None) if lf_index != -1: result = bytes(self.buffer[: lf_index + 1]) del self.buffer[: lf_index + 1] return result elif limit != -1: result = bytes(self.buffer[:limit]) del self.buffer[:limit] return result if not self._has_more: break self.buffer.extend(self._receive_more_data()) result = bytes(self.buffer) self.buffer.clear() return result def readlines(self, hint: int = -1) -> typing.List[bytes]: if not self.has_more: return [] if hint == -1: raw_data = self.read(-1) bytelist = raw_data.split(b"\n") if raw_data[-1] == 10: # 10 -> b"\n" bytelist.pop(len(bytelist) - 1) return [line + b"\n" for line in bytelist] return [self.readline() for _ in range(hint)] def __iter__(self) -> typing.Generator[bytes, None, None]: while self.has_more: yield self.readline() ENC, ESC = sys.getfilesystemencoding(), "surrogateescape" def unicode_to_wsgi(u): """Convert an environment variable to a WSGI "bytes-as-unicode" string""" return u.encode(ENC, ESC).decode("iso-8859-1") def build_environ(scope: HTTPScope, body: Body) -> Environ: """ Builds a scope and request body into a WSGI environ object. """ script_name = scope.get("root_path", "").encode("utf8").decode("latin1") path_info = scope["path"].encode("utf8").decode("latin1") if path_info.startswith(script_name): path_info = path_info[len(script_name) :] script_name_environ_var = os.environ.get("SCRIPT_NAME", "") if script_name_environ_var: script_name = unicode_to_wsgi(script_name_environ_var) environ: Environ = { "asgi.scope": scope, # type: ignore a2wsgi "REQUEST_METHOD": scope["method"], "SCRIPT_NAME": script_name, "PATH_INFO": path_info, "QUERY_STRING": scope["query_string"].decode("ascii"), "SERVER_PROTOCOL": f"HTTP/{scope['http_version']}", "wsgi.version": (1, 0), "wsgi.url_scheme": scope.get("scheme", "http"), "wsgi.input": body, "wsgi.errors": sys.stdout, "wsgi.multithread": True, "wsgi.multiprocess": True, "wsgi.run_once": False, } # Get server name and port - required in WSGI, not in ASGI server_addr, server_port = scope.get("server") or ("localhost", 80) environ["SERVER_NAME"] = server_addr environ["SERVER_PORT"] = str(server_port or 0) # Get client IP address client = scope.get("client") if client is not None: addr, port = client environ["REMOTE_ADDR"] = addr environ["REMOTE_PORT"] = str(port) # Go through headers and make them into environ entries for name, value in scope.get("headers", []): name = name.decode("latin1") if name == "content-length": corrected_name = "CONTENT_LENGTH" elif name == "content-type": corrected_name = "CONTENT_TYPE" else: corrected_name = f"HTTP_{name}".upper().replace("-", "_") # HTTPbis say only ASCII chars are allowed in headers, but we latin1 just in case value = value.decode("latin1") if corrected_name in environ: value = environ[corrected_name] + "," + value environ[corrected_name] = value return environ class WSGIMiddleware: """ Convert WSGIApp to ASGIApp. """ def __init__( self, app: WSGIApp, workers: int = 10, send_queue_size: int = 10 ) -> None: self.app = app self.send_queue_size = send_queue_size self.executor = ThreadPoolExecutor( thread_name_prefix="WSGI", max_workers=workers ) async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] == "http": responder = WSGIResponder(self.app, self.executor, self.send_queue_size) return await responder(scope, receive, send) if scope["type"] == "websocket": await send({"type": "websocket.close", "code": 1000}) return if scope["type"] == "lifespan": message = await receive() assert message["type"] == "lifespan.startup" await send({"type": "lifespan.startup.complete"}) message = await receive() assert message["type"] == "lifespan.shutdown" await send({"type": "lifespan.shutdown.complete"}) return class WSGIResponder: def __init__( self, app: WSGIApp, executor: ThreadPoolExecutor, send_queue_size: int ) -> None: self.app = app self.executor = executor self.loop = asyncio.get_event_loop() self.send_queue = asyncio.Queue(send_queue_size) self.response_started = False self.exc_info: typing.Any = None async def __call__(self, scope: HTTPScope, receive: Receive, send: Send) -> None: body = Body(self.loop, receive) environ = build_environ(scope, body) sender = None try: sender = self.loop.create_task(self.sender(send)) context = contextvars.copy_context() func = functools.partial(context.run, self.wsgi) await self.loop.run_in_executor( self.executor, func, environ, self.start_response ) await self.send_queue.put(None) await self.send_queue.join() await asyncio.wait_for(sender, None) if self.exc_info is not None: raise self.exc_info[0].with_traceback( self.exc_info[1], self.exc_info[2] ) finally: if sender and not sender.done(): sender.cancel() # pragma: no cover def send(self, message: typing.Optional[SendEvent]) -> None: future = asyncio.run_coroutine_threadsafe( self.send_queue.put(message), loop=self.loop, ) future.result() async def sender(self, send: Send) -> None: while True: message = await self.send_queue.get() self.send_queue.task_done() if message is None: return await send(message) def start_response( self, status: str, response_headers: typing.List[typing.Tuple[str, str]], exc_info: typing.Optional[ExceptionInfo] = None, ) -> WriteCallable: self.exc_info = exc_info if not self.response_started: self.response_started = True status_code_string, _ = status.split(" ", 1) status_code = int(status_code_string) headers = [ (name.strip().encode("latin1").lower(), value.strip().encode("latin1")) for name, value in response_headers ] self.send( { "type": "http.response.start", "status": status_code, "headers": headers, } ) return lambda chunk: self.send( {"type": "http.response.body", "body": chunk, "more_body": True} ) def wsgi(self, environ: Environ, start_response: StartResponse) -> None: iterable = self.app(environ, start_response) try: for chunk in iterable: self.send( {"type": "http.response.body", "body": chunk, "more_body": True} ) self.send({"type": "http.response.body", "body": b""}) finally: getattr(iterable, "close", lambda: None)() a2wsgi-1.10.2/a2wsgi/wsgi_typing.py000066400000000000000000000173401456507100000171030ustar00rootroot00000000000000""" https://peps.python.org/pep-3333/ """ from types import TracebackType from typing import ( Any, Callable, Iterable, List, Optional, Protocol, Tuple, Type, TypedDict, ) CGIRequiredDefined = TypedDict( "CGIRequiredDefined", { # The HTTP request method, such as GET or POST. This cannot ever be an # empty string, and so is always required. "REQUEST_METHOD": str, # When HTTP_HOST is not set, these variables can be combined to determine # a default. # SERVER_NAME and SERVER_PORT are required strings and must never be empty. "SERVER_NAME": str, "SERVER_PORT": str, # The version of the protocol the client used to send the request. # Typically this will be something like "HTTP/1.0" or "HTTP/1.1" and # may be used by the application to determine how to treat any HTTP # request headers. (This variable should probably be called REQUEST_PROTOCOL, # since it denotes the protocol used in the request, and is not necessarily # the protocol that will be used in the server's response. However, for # compatibility with CGI we have to keep the existing name.) "SERVER_PROTOCOL": str, }, ) CGIOptionalDefined = TypedDict( "CGIOptionalDefined", { "REQUEST_URI": str, "REMOTE_ADDR": str, "REMOTE_PORT": str, # The initial portion of the request URL’s “path” that corresponds to the # application object, so that the application knows its virtual “location”. # This may be an empty string, if the application corresponds to the “root” # of the server. "SCRIPT_NAME": str, # The remainder of the request URL’s “path”, designating the virtual # “location” of the request’s target within the application. This may be an # empty string, if the request URL targets the application root and does # not have a trailing slash. "PATH_INFO": str, # The portion of the request URL that follows the “?”, if any. May be empty # or absent. "QUERY_STRING": str, # The contents of any Content-Type fields in the HTTP request. May be empty # or absent. "CONTENT_TYPE": str, # The contents of any Content-Length fields in the HTTP request. May be empty # or absent. "CONTENT_LENGTH": str, }, total=False, ) class InputStream(Protocol): """ An input stream (file-like object) from which the HTTP request body bytes can be read. (The server or gateway may perform reads on-demand as requested by the application, or it may pre- read the client's request body and buffer it in-memory or on disk, or use any other technique for providing such an input stream, according to its preference.) """ def read(self, size: int = -1, /) -> bytes: """ The server is not required to read past the client's specified Content-Length, and should simulate an end-of-file condition if the application attempts to read past that point. The application should not attempt to read more data than is specified by the CONTENT_LENGTH variable. A server should allow read() to be called without an argument, and return the remainder of the client's input stream. A server should return empty bytestrings from any attempt to read from an empty or exhausted input stream. """ raise NotImplementedError def readline(self, limit: int = -1, /) -> bytes: """ Servers should support the optional "size" argument to readline(), but as in WSGI 1.0, they are allowed to omit support for it. (In WSGI 1.0, the size argument was not supported, on the grounds that it might have been complex to implement, and was not often used in practice... but then the cgi module started using it, and so practical servers had to start supporting it anyway!) """ raise NotImplementedError def readlines(self, hint: int = -1, /) -> List[bytes]: """ Note that the hint argument to readlines() is optional for both caller and implementer. The application is free not to supply it, and the server or gateway is free to ignore it. """ raise NotImplementedError class ErrorStream(Protocol): """ An output stream (file-like object) to which error output can be written, for the purpose of recording program or other errors in a standardized and possibly centralized location. This should be a "text mode" stream; i.e., applications should use "\n" as a line ending, and assume that it will be converted to the correct line ending by the server/gateway. (On platforms where the str type is unicode, the error stream should accept and log arbitrary unicode without raising an error; it is allowed, however, to substitute characters that cannot be rendered in the stream's encoding.) For many servers, wsgi.errors will be the server's main error log. Alternatively, this may be sys.stderr, or a log file of some sort. The server's documentation should include an explanation of how to configure this or where to find the recorded output. A server or gateway may supply different error streams to different applications, if this is desired. """ def flush(self) -> None: """ Since the errors stream may not be rewound, servers and gateways are free to forward write operations immediately, without buffering. In this case, the flush() method may be a no-op. Portable applications, however, cannot assume that output is unbuffered or that flush() is a no-op. They must call flush() if they need to ensure that output has in fact been written. (For example, to minimize intermingling of data from multiple processes writing to the same error log.) """ raise NotImplementedError def write(self, s: str, /) -> Any: raise NotImplementedError def writelines(self, seq: List[str], /) -> Any: raise NotImplementedError WSGIDefined = TypedDict( "WSGIDefined", { "wsgi.version": Tuple[int, int], # e.g. (1, 0) "wsgi.url_scheme": str, # e.g. "http" or "https" "wsgi.input": InputStream, "wsgi.errors": ErrorStream, # This value should evaluate true if the application object may be simultaneously # invoked by another thread in the same process, and should evaluate false otherwise. "wsgi.multithread": bool, # This value should evaluate true if an equivalent application object may be # simultaneously invoked by another process, and should evaluate false otherwise. "wsgi.multiprocess": bool, # This value should evaluate true if the server or gateway expects (but does # not guarantee!) that the application will only be invoked this one time during # the life of its containing process. Normally, this will only be true for a # gateway based on CGI (or something similar). "wsgi.run_once": bool, }, ) class Environ(CGIRequiredDefined, CGIOptionalDefined, WSGIDefined): """ WSGI Environ """ ExceptionInfo = Tuple[Type[BaseException], BaseException, Optional[TracebackType]] # https://peps.python.org/pep-3333/#the-write-callable WriteCallable = Callable[[bytes], None] class StartResponse(Protocol): def __call__( self, status: str, response_headers: List[Tuple[str, str]], exc_info: Optional[ExceptionInfo] = None, /, ) -> WriteCallable: raise NotImplementedError IterableChunks = Iterable[bytes] WSGIApp = Callable[[Environ, StartResponse], IterableChunks] a2wsgi-1.10.2/benchmark.py000066400000000000000000000057461456507100000153050ustar00rootroot00000000000000""" **Need Python3.7+** """ import asyncio import time import httpx import pytest from asgiref.wsgi import WsgiToAsgi from uvicorn.middleware.wsgi import WSGIMiddleware as UvicornWSGIMiddleware from a2wsgi import ASGIMiddleware, WSGIMiddleware try: import uvloop uvloop.install() except ImportError: pass async def asgi_echo(scope, receive, send): assert scope["type"] == "http" await send( { "type": "http.response.start", "status": 200, "headers": [[b"content-type", b"text/plain"]], } ) while True: message = await receive() more_body = message.get("more_body", False) await send( { "type": "http.response.body", "body": message.get("body", b""), "more_body": more_body, } ) if not more_body: break def wsgi_echo(environ, start_response): status = "200 OK" body = environ["wsgi.input"].read() headers = [ ("Content-Type", "text/plain; charset=utf-8"), ("Content-Length", str(len(body))), ] start_response(status, headers) return [body] @pytest.fixture(scope="module", autouse=True) def print_title(): print(f"\n{'Name':^30}", "Average Time", end="", flush=True) @pytest.mark.parametrize( "app, name", [ (asgi_echo, "pure-ASGI"), (WSGIMiddleware(wsgi_echo), "a2wsgi-WSGIMiddleware"), (UvicornWSGIMiddleware(wsgi_echo), "uvicorn-WSGIMiddleware"), (WsgiToAsgi(wsgi_echo), "asgiref-WsgiToAsgi"), ], ) @pytest.mark.asyncio async def test_convert_wsgi_to_asgi(app, name): async with httpx.AsyncClient(app=app, base_url="http://testserver") as client: start_time = time.time_ns() await asyncio.gather( *[client.post("/", content=b"hello world") for _ in range(100)] ) time_count_100 = time.time_ns() - start_time start_time = time.time_ns() await asyncio.gather( *[client.post("/", content=b"hello world") for _ in range(10100)] ) time_count_100100 = time.time_ns() - start_time print( f"\n{name:^30}", (time_count_100100 - time_count_100) / 10000 / 10**9, end="", ) @pytest.mark.parametrize( "app, name", [(wsgi_echo, "pure-WSGI"), (ASGIMiddleware(asgi_echo), "a2wsgi-ASGIMiddleware")], ) def test_convert_asgi_to_wsgi(app, name): with httpx.Client(app=app, base_url="http://testserver") as client: start_time = time.time_ns() for _ in range(100): client.post("/", content=b"hello world") time_count_100 = time.time_ns() - start_time start_time = time.time_ns() for _ in range(10100): client.post("/", content=b"hello world") time_count_100100 = time.time_ns() - start_time print( f"\n{name:^30}", (time_count_100100 - time_count_100) / 10000 / 10**9, end="", ) a2wsgi-1.10.2/pdm.lock000066400000000000000000000711221456507100000144220ustar00rootroot00000000000000# This file is @generated by PDM. # It is not intended for manual editing. [metadata] groups = ["default", "benchmark", "dev"] cross_platform = true static_urls = false lock_version = "4.3" content_hash = "sha256:04b453f6930ee54954cb3995dfa10b60f119b494ef5413bd3cc91fe4ab5819dc" [[package]] name = "anyio" version = "3.6.1" requires_python = ">=3.6.2" summary = "High level compatibility layer for multiple asynchronous event loop implementations" dependencies = [ "idna>=2.8", "sniffio>=1.1", ] files = [ {file = "anyio-3.6.1-py3-none-any.whl", hash = "sha256:cb29b9c70620506a9a8f87a309591713446953302d7d995344d0d7c6c0c9a7be"}, {file = "anyio-3.6.1.tar.gz", hash = "sha256:413adf95f93886e442aea925f3ee43baa5a765a64a0f52c6081894f9992fdd0b"}, ] [[package]] name = "asgiref" version = "3.7.2" requires_python = ">=3.7" summary = "ASGI specs, helper code, and adapters" dependencies = [ "typing-extensions>=4; python_version < \"3.11\"", ] files = [ {file = "asgiref-3.7.2-py3-none-any.whl", hash = "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e"}, {file = "asgiref-3.7.2.tar.gz", hash = "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed"}, ] [[package]] name = "black" version = "23.9.1" requires_python = ">=3.8" summary = "The uncompromising code formatter." dependencies = [ "click>=8.0.0", "mypy-extensions>=0.4.3", "packaging>=22.0", "pathspec>=0.9.0", "platformdirs>=2", "tomli>=1.1.0; python_version < \"3.11\"", "typing-extensions>=4.0.1; python_version < \"3.11\"", ] files = [ {file = "black-23.9.1-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:d6bc09188020c9ac2555a498949401ab35bb6bf76d4e0f8ee251694664df6301"}, {file = "black-23.9.1-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:13ef033794029b85dfea8032c9d3b92b42b526f1ff4bf13b2182ce4e917f5100"}, {file = "black-23.9.1-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:75a2dc41b183d4872d3a500d2b9c9016e67ed95738a3624f4751a0cb4818fe71"}, {file = "black-23.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13a2e4a93bb8ca74a749b6974925c27219bb3df4d42fc45e948a5d9feb5122b7"}, {file = "black-23.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:adc3e4442eef57f99b5590b245a328aad19c99552e0bdc7f0b04db6656debd80"}, {file = "black-23.9.1-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:8431445bf62d2a914b541da7ab3e2b4f3bc052d2ccbf157ebad18ea126efb91f"}, {file = "black-23.9.1-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:8fc1ddcf83f996247505db6b715294eba56ea9372e107fd54963c7553f2b6dfe"}, {file = "black-23.9.1-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:7d30ec46de88091e4316b17ae58bbbfc12b2de05e069030f6b747dfc649ad186"}, {file = "black-23.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031e8c69f3d3b09e1aa471a926a1eeb0b9071f80b17689a655f7885ac9325a6f"}, {file = "black-23.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:538efb451cd50f43aba394e9ec7ad55a37598faae3348d723b59ea8e91616300"}, {file = "black-23.9.1-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:638619a559280de0c2aa4d76f504891c9860bb8fa214267358f0a20f27c12948"}, {file = "black-23.9.1-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:a732b82747235e0542c03bf352c126052c0fbc458d8a239a94701175b17d4855"}, {file = "black-23.9.1-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:cf3a4d00e4cdb6734b64bf23cd4341421e8953615cba6b3670453737a72ec204"}, {file = "black-23.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf99f3de8b3273a8317681d8194ea222f10e0133a24a7548c73ce44ea1679377"}, {file = "black-23.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:14f04c990259576acd093871e7e9b14918eb28f1866f91968ff5524293f9c573"}, {file = "black-23.9.1-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:c619f063c2d68f19b2d7270f4cf3192cb81c9ec5bc5ba02df91471d0b88c4c5c"}, {file = "black-23.9.1-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:6a3b50e4b93f43b34a9d3ef00d9b6728b4a722c997c99ab09102fd5efdb88325"}, {file = "black-23.9.1-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:c46767e8df1b7beefb0899c4a95fb43058fa8500b6db144f4ff3ca38eb2f6393"}, {file = "black-23.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50254ebfa56aa46a9fdd5d651f9637485068a1adf42270148cd101cdf56e0ad9"}, {file = "black-23.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:403397c033adbc45c2bd41747da1f7fc7eaa44efbee256b53842470d4ac5a70f"}, {file = "black-23.9.1-py3-none-any.whl", hash = "sha256:6ccd59584cc834b6d127628713e4b6b968e5f79572da66284532525a042549f9"}, {file = "black-23.9.1.tar.gz", hash = "sha256:24b6b3ff5c6d9ea08a8888f6977eae858e1f340d7260cf56d70a49823236b62d"}, ] [[package]] name = "certifi" version = "2022.6.15.1" requires_python = ">=3.6" summary = "Python package for providing Mozilla's CA Bundle." files = [ {file = "certifi-2022.6.15.1-py3-none-any.whl", hash = "sha256:43dadad18a7f168740e66944e4fa82c6611848ff9056ad910f8f7a3e46ab89e0"}, {file = "certifi-2022.6.15.1.tar.gz", hash = "sha256:cffdcd380919da6137f76633531a5817e3a9f268575c128249fb637e4f9e73fb"}, ] [[package]] name = "click" version = "8.0.4" requires_python = ">=3.6" summary = "Composable command line interface toolkit" dependencies = [ "colorama; platform_system == \"Windows\"", ] files = [ {file = "click-8.0.4-py3-none-any.whl", hash = "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1"}, {file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"}, ] [[package]] name = "colorama" version = "0.4.5" requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" summary = "Cross-platform colored terminal text." files = [ {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, ] [[package]] name = "coverage" version = "6.2" requires_python = ">=3.6" summary = "Code coverage measurement for Python" files = [ {file = "coverage-6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6dbc1536e105adda7a6312c778f15aaabe583b0e9a0b0a324990334fd458c94b"}, {file = "coverage-6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:174cf9b4bef0db2e8244f82059a5a72bd47e1d40e71c68ab055425172b16b7d0"}, {file = "coverage-6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:92b8c845527eae547a2a6617d336adc56394050c3ed8a6918683646328fbb6da"}, {file = "coverage-6.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c7912d1526299cb04c88288e148c6c87c0df600eca76efd99d84396cfe00ef1d"}, {file = "coverage-6.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5d2033d5db1d58ae2d62f095e1aefb6988af65b4b12cb8987af409587cc0739"}, {file = "coverage-6.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3feac4084291642165c3a0d9eaebedf19ffa505016c4d3db15bfe235718d4971"}, {file = "coverage-6.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:276651978c94a8c5672ea60a2656e95a3cce2a3f31e9fb2d5ebd4c215d095840"}, {file = "coverage-6.2-cp310-cp310-win32.whl", hash = "sha256:f506af4f27def639ba45789fa6fde45f9a217da0be05f8910458e4557eed020c"}, {file = "coverage-6.2-cp310-cp310-win_amd64.whl", hash = "sha256:3f7c17209eef285c86f819ff04a6d4cbee9b33ef05cbcaae4c0b4e8e06b3ec8f"}, {file = "coverage-6.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:13362889b2d46e8d9f97c421539c97c963e34031ab0cb89e8ca83a10cc71ac76"}, {file = "coverage-6.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:22e60a3ca5acba37d1d4a2ee66e051f5b0e1b9ac950b5b0cf4aa5366eda41d47"}, {file = "coverage-6.2-cp311-cp311-win_amd64.whl", hash = "sha256:b637c57fdb8be84e91fac60d9325a66a5981f8086c954ea2772efe28425eaf64"}, {file = "coverage-6.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f9afb5b746781fc2abce26193d1c817b7eb0e11459510fba65d2bd77fe161d9e"}, {file = "coverage-6.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edcada2e24ed68f019175c2b2af2a8b481d3d084798b8c20d15d34f5c733fa58"}, {file = "coverage-6.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a9c8c4283e17690ff1a7427123ffb428ad6a52ed720d550e299e8291e33184dc"}, {file = "coverage-6.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f614fc9956d76d8a88a88bb41ddc12709caa755666f580af3a688899721efecd"}, {file = "coverage-6.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9365ed5cce5d0cf2c10afc6add145c5037d3148585b8ae0e77cc1efdd6aa2953"}, {file = "coverage-6.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8bdfe9ff3a4ea37d17f172ac0dff1e1c383aec17a636b9b35906babc9f0f5475"}, {file = "coverage-6.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:63c424e6f5b4ab1cf1e23a43b12f542b0ec2e54f99ec9f11b75382152981df57"}, {file = "coverage-6.2-cp38-cp38-win32.whl", hash = "sha256:49dbff64961bc9bdd2289a2bda6a3a5a331964ba5497f694e2cbd540d656dc1c"}, {file = "coverage-6.2-cp38-cp38-win_amd64.whl", hash = "sha256:9a29311bd6429be317c1f3fe4bc06c4c5ee45e2fa61b2a19d4d1d6111cb94af2"}, {file = "coverage-6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03b20e52b7d31be571c9c06b74746746d4eb82fc260e594dc662ed48145e9efd"}, {file = "coverage-6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:215f8afcc02a24c2d9a10d3790b21054b58d71f4b3c6f055d4bb1b15cecce685"}, {file = "coverage-6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a4bdeb0a52d1d04123b41d90a4390b096f3ef38eee35e11f0b22c2d031222c6c"}, {file = "coverage-6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c332d8f8d448ded473b97fefe4a0983265af21917d8b0cdcb8bb06b2afe632c3"}, {file = "coverage-6.2-cp39-cp39-win32.whl", hash = "sha256:6e1394d24d5938e561fbeaa0cd3d356207579c28bd1792f25a068743f2d5b282"}, {file = "coverage-6.2-cp39-cp39-win_amd64.whl", hash = "sha256:86f2e78b1eff847609b1ca8050c9e1fa3bd44ce755b2ec30e70f2d3ba3844644"}, {file = "coverage-6.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:5829192582c0ec8ca4a2532407bc14c2f338d9878a10442f5d03804a95fac9de"}, {file = "coverage-6.2.tar.gz", hash = "sha256:e2cad8093172b7d1595b4ad66f24270808658e11acf43a8f95b41276162eb5b8"}, ] [[package]] name = "coverage" version = "6.2" extras = ["toml"] requires_python = ">=3.6" summary = "Code coverage measurement for Python" dependencies = [ "coverage==6.2", "tomli", ] files = [ {file = "coverage-6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6dbc1536e105adda7a6312c778f15aaabe583b0e9a0b0a324990334fd458c94b"}, {file = "coverage-6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:174cf9b4bef0db2e8244f82059a5a72bd47e1d40e71c68ab055425172b16b7d0"}, {file = "coverage-6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:92b8c845527eae547a2a6617d336adc56394050c3ed8a6918683646328fbb6da"}, {file = "coverage-6.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c7912d1526299cb04c88288e148c6c87c0df600eca76efd99d84396cfe00ef1d"}, {file = "coverage-6.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5d2033d5db1d58ae2d62f095e1aefb6988af65b4b12cb8987af409587cc0739"}, {file = "coverage-6.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3feac4084291642165c3a0d9eaebedf19ffa505016c4d3db15bfe235718d4971"}, {file = "coverage-6.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:276651978c94a8c5672ea60a2656e95a3cce2a3f31e9fb2d5ebd4c215d095840"}, {file = "coverage-6.2-cp310-cp310-win32.whl", hash = "sha256:f506af4f27def639ba45789fa6fde45f9a217da0be05f8910458e4557eed020c"}, {file = "coverage-6.2-cp310-cp310-win_amd64.whl", hash = "sha256:3f7c17209eef285c86f819ff04a6d4cbee9b33ef05cbcaae4c0b4e8e06b3ec8f"}, {file = "coverage-6.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:13362889b2d46e8d9f97c421539c97c963e34031ab0cb89e8ca83a10cc71ac76"}, {file = "coverage-6.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:22e60a3ca5acba37d1d4a2ee66e051f5b0e1b9ac950b5b0cf4aa5366eda41d47"}, {file = "coverage-6.2-cp311-cp311-win_amd64.whl", hash = "sha256:b637c57fdb8be84e91fac60d9325a66a5981f8086c954ea2772efe28425eaf64"}, {file = "coverage-6.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f9afb5b746781fc2abce26193d1c817b7eb0e11459510fba65d2bd77fe161d9e"}, {file = "coverage-6.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edcada2e24ed68f019175c2b2af2a8b481d3d084798b8c20d15d34f5c733fa58"}, {file = "coverage-6.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a9c8c4283e17690ff1a7427123ffb428ad6a52ed720d550e299e8291e33184dc"}, {file = "coverage-6.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f614fc9956d76d8a88a88bb41ddc12709caa755666f580af3a688899721efecd"}, {file = "coverage-6.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9365ed5cce5d0cf2c10afc6add145c5037d3148585b8ae0e77cc1efdd6aa2953"}, {file = "coverage-6.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8bdfe9ff3a4ea37d17f172ac0dff1e1c383aec17a636b9b35906babc9f0f5475"}, {file = "coverage-6.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:63c424e6f5b4ab1cf1e23a43b12f542b0ec2e54f99ec9f11b75382152981df57"}, {file = "coverage-6.2-cp38-cp38-win32.whl", hash = "sha256:49dbff64961bc9bdd2289a2bda6a3a5a331964ba5497f694e2cbd540d656dc1c"}, {file = "coverage-6.2-cp38-cp38-win_amd64.whl", hash = "sha256:9a29311bd6429be317c1f3fe4bc06c4c5ee45e2fa61b2a19d4d1d6111cb94af2"}, {file = "coverage-6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03b20e52b7d31be571c9c06b74746746d4eb82fc260e594dc662ed48145e9efd"}, {file = "coverage-6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:215f8afcc02a24c2d9a10d3790b21054b58d71f4b3c6f055d4bb1b15cecce685"}, {file = "coverage-6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a4bdeb0a52d1d04123b41d90a4390b096f3ef38eee35e11f0b22c2d031222c6c"}, {file = "coverage-6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c332d8f8d448ded473b97fefe4a0983265af21917d8b0cdcb8bb06b2afe632c3"}, {file = "coverage-6.2-cp39-cp39-win32.whl", hash = "sha256:6e1394d24d5938e561fbeaa0cd3d356207579c28bd1792f25a068743f2d5b282"}, {file = "coverage-6.2-cp39-cp39-win_amd64.whl", hash = "sha256:86f2e78b1eff847609b1ca8050c9e1fa3bd44ce755b2ec30e70f2d3ba3844644"}, {file = "coverage-6.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:5829192582c0ec8ca4a2532407bc14c2f338d9878a10442f5d03804a95fac9de"}, {file = "coverage-6.2.tar.gz", hash = "sha256:e2cad8093172b7d1595b4ad66f24270808658e11acf43a8f95b41276162eb5b8"}, ] [[package]] name = "exceptiongroup" version = "1.1.0" requires_python = ">=3.7" summary = "Backport of PEP 654 (exception groups)" files = [ {file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"}, {file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"}, ] [[package]] name = "flake8" version = "5.0.4" requires_python = ">=3.6.1" summary = "the modular source code checker: pep8 pyflakes and co" dependencies = [ "mccabe<0.8.0,>=0.7.0", "pycodestyle<2.10.0,>=2.9.0", "pyflakes<2.6.0,>=2.5.0", ] files = [ {file = "flake8-5.0.4-py2.py3-none-any.whl", hash = "sha256:7a1cf6b73744f5806ab95e526f6f0d8c01c66d7bbe349562d22dfca20610b248"}, {file = "flake8-5.0.4.tar.gz", hash = "sha256:6fbe320aad8d6b95cec8b8e47bc933004678dc63095be98528b7bdd2a9f510db"}, ] [[package]] name = "h11" version = "0.14.0" requires_python = ">=3.7" summary = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" files = [ {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, ] [[package]] name = "httpcore" version = "0.18.0" requires_python = ">=3.8" summary = "A minimal low-level HTTP client." dependencies = [ "anyio<5.0,>=3.0", "certifi", "h11<0.15,>=0.13", "sniffio==1.*", ] files = [ {file = "httpcore-0.18.0-py3-none-any.whl", hash = "sha256:adc5398ee0a476567bf87467063ee63584a8bce86078bf748e48754f60202ced"}, {file = "httpcore-0.18.0.tar.gz", hash = "sha256:13b5e5cd1dca1a6636a6aaea212b19f4f85cd88c366a2b82304181b769aab3c9"}, ] [[package]] name = "httpx" version = "0.25.0" requires_python = ">=3.8" summary = "The next generation HTTP client." dependencies = [ "certifi", "httpcore<0.19.0,>=0.18.0", "idna", "sniffio", ] files = [ {file = "httpx-0.25.0-py3-none-any.whl", hash = "sha256:181ea7f8ba3a82578be86ef4171554dd45fec26a02556a744db029a0a27b7100"}, {file = "httpx-0.25.0.tar.gz", hash = "sha256:47ecda285389cb32bb2691cc6e069e3ab0205956f681c5b2ad2325719751d875"}, ] [[package]] name = "idna" version = "3.3" requires_python = ">=3.5" summary = "Internationalized Domain Names in Applications (IDNA)" files = [ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, ] [[package]] name = "iniconfig" version = "1.1.1" summary = "iniconfig: brain-dead simple config-ini parsing" files = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] [[package]] name = "mccabe" version = "0.7.0" requires_python = ">=3.6" summary = "McCabe checker, plugin for flake8" files = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] [[package]] name = "mypy" version = "1.6.0" requires_python = ">=3.8" summary = "Optional static typing for Python" dependencies = [ "mypy-extensions>=1.0.0", "tomli>=1.1.0; python_version < \"3.11\"", "typing-extensions>=4.1.0", ] files = [ {file = "mypy-1.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:091f53ff88cb093dcc33c29eee522c087a438df65eb92acd371161c1f4380ff0"}, {file = "mypy-1.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eb7ff4007865833c470a601498ba30462b7374342580e2346bf7884557e40531"}, {file = "mypy-1.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49499cf1e464f533fc45be54d20a6351a312f96ae7892d8e9f1708140e27ce41"}, {file = "mypy-1.6.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4c192445899c69f07874dabda7e931b0cc811ea055bf82c1ababf358b9b2a72c"}, {file = "mypy-1.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:3df87094028e52766b0a59a3e46481bb98b27986ed6ded6a6cc35ecc75bb9182"}, {file = "mypy-1.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c8835a07b8442da900db47ccfda76c92c69c3a575872a5b764332c4bacb5a0a"}, {file = "mypy-1.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:24f3de8b9e7021cd794ad9dfbf2e9fe3f069ff5e28cb57af6f873ffec1cb0425"}, {file = "mypy-1.6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:856bad61ebc7d21dbc019b719e98303dc6256cec6dcc9ebb0b214b81d6901bd8"}, {file = "mypy-1.6.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:89513ddfda06b5c8ebd64f026d20a61ef264e89125dc82633f3c34eeb50e7d60"}, {file = "mypy-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:9f8464ed410ada641c29f5de3e6716cbdd4f460b31cf755b2af52f2d5ea79ead"}, {file = "mypy-1.6.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:971104bcb180e4fed0d7bd85504c9036346ab44b7416c75dd93b5c8c6bb7e28f"}, {file = "mypy-1.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab98b8f6fdf669711f3abe83a745f67f50e3cbaea3998b90e8608d2b459fd566"}, {file = "mypy-1.6.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a69db3018b87b3e6e9dd28970f983ea6c933800c9edf8c503c3135b3274d5ad"}, {file = "mypy-1.6.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:dccd850a2e3863891871c9e16c54c742dba5470f5120ffed8152956e9e0a5e13"}, {file = "mypy-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:f8598307150b5722854f035d2e70a1ad9cc3c72d392c34fffd8c66d888c90f17"}, {file = "mypy-1.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fea451a3125bf0bfe716e5d7ad4b92033c471e4b5b3e154c67525539d14dc15a"}, {file = "mypy-1.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e28d7b221898c401494f3b77db3bac78a03ad0a0fff29a950317d87885c655d2"}, {file = "mypy-1.6.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4b7a99275a61aa22256bab5839c35fe8a6887781862471df82afb4b445daae6"}, {file = "mypy-1.6.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7469545380dddce5719e3656b80bdfbb217cfe8dbb1438532d6abc754b828fed"}, {file = "mypy-1.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:7807a2a61e636af9ca247ba8494031fb060a0a744b9fee7de3a54bed8a753323"}, {file = "mypy-1.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d2dad072e01764823d4b2f06bc7365bb1d4b6c2f38c4d42fade3c8d45b0b4b67"}, {file = "mypy-1.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b19006055dde8a5425baa5f3b57a19fa79df621606540493e5e893500148c72f"}, {file = "mypy-1.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31eba8a7a71f0071f55227a8057468b8d2eb5bf578c8502c7f01abaec8141b2f"}, {file = "mypy-1.6.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e0db37ac4ebb2fee7702767dfc1b773c7365731c22787cb99f507285014fcaf"}, {file = "mypy-1.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:c69051274762cccd13498b568ed2430f8d22baa4b179911ad0c1577d336ed849"}, {file = "mypy-1.6.0-py3-none-any.whl", hash = "sha256:9e1589ca150a51d9d00bb839bfeca2f7a04f32cd62fad87a847bc0818e15d7dc"}, {file = "mypy-1.6.0.tar.gz", hash = "sha256:4f3d27537abde1be6d5f2c96c29a454da333a2a271ae7d5bc7110e6d4b7beb3f"}, ] [[package]] name = "mypy-extensions" version = "1.0.0" requires_python = ">=3.5" summary = "Type system extensions for programs checked with the mypy type checker." files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] [[package]] name = "packaging" version = "23.2" requires_python = ">=3.7" summary = "Core utilities for Python packages" files = [ {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, ] [[package]] name = "pathspec" version = "0.9.0" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" summary = "Utility library for gitignore style pattern matching of file paths." files = [ {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, ] [[package]] name = "platformdirs" version = "2.4.0" requires_python = ">=3.6" summary = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." files = [ {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"}, {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"}, ] [[package]] name = "pluggy" version = "1.0.0" requires_python = ">=3.6" summary = "plugin and hook calling mechanisms for python" files = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] [[package]] name = "pycodestyle" version = "2.9.1" requires_python = ">=3.6" summary = "Python style guide checker" files = [ {file = "pycodestyle-2.9.1-py2.py3-none-any.whl", hash = "sha256:d1735fc58b418fd7c5f658d28d943854f8a849b01a5d0a1e6f3f3fdd0166804b"}, {file = "pycodestyle-2.9.1.tar.gz", hash = "sha256:2c9607871d58c76354b697b42f5d57e1ada7d261c261efac224b664affdc5785"}, ] [[package]] name = "pyflakes" version = "2.5.0" requires_python = ">=3.6" summary = "passive checker of Python programs" files = [ {file = "pyflakes-2.5.0-py2.py3-none-any.whl", hash = "sha256:4579f67d887f804e67edb544428f264b7b24f435b263c4614f384135cea553d2"}, {file = "pyflakes-2.5.0.tar.gz", hash = "sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3"}, ] [[package]] name = "pytest" version = "7.4.2" requires_python = ">=3.7" summary = "pytest: simple powerful testing with Python" dependencies = [ "colorama; sys_platform == \"win32\"", "exceptiongroup>=1.0.0rc8; python_version < \"3.11\"", "iniconfig", "packaging", "pluggy<2.0,>=0.12", "tomli>=1.0.0; python_version < \"3.11\"", ] files = [ {file = "pytest-7.4.2-py3-none-any.whl", hash = "sha256:1d881c6124e08ff0a1bb75ba3ec0bfd8b5354a01c194ddd5a0a870a48d99b002"}, {file = "pytest-7.4.2.tar.gz", hash = "sha256:a766259cfab564a2ad52cb1aae1b881a75c3eb7e34ca3779697c23ed47c47069"}, ] [[package]] name = "pytest-asyncio" version = "0.21.1" requires_python = ">=3.7" summary = "Pytest support for asyncio" dependencies = [ "pytest>=7.0.0", ] files = [ {file = "pytest-asyncio-0.21.1.tar.gz", hash = "sha256:40a7eae6dded22c7b604986855ea48400ab15b069ae38116e8c01238e9eeb64d"}, {file = "pytest_asyncio-0.21.1-py3-none-any.whl", hash = "sha256:8666c1c8ac02631d7c51ba282e0c69a8a452b211ffedf2599099845da5c5c37b"}, ] [[package]] name = "pytest-cov" version = "3.0.0" requires_python = ">=3.6" summary = "Pytest plugin for measuring coverage." dependencies = [ "coverage[toml]>=5.2.1", "pytest>=4.6", ] files = [ {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, ] [[package]] name = "sniffio" version = "1.2.0" requires_python = ">=3.5" summary = "Sniff out which async library your code is running under" files = [ {file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"}, {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"}, ] [[package]] name = "tomli" version = "1.2.3" requires_python = ">=3.6" summary = "A lil' TOML parser" files = [ {file = "tomli-1.2.3-py3-none-any.whl", hash = "sha256:e3069e4be3ead9668e21cb9b074cd948f7b3113fd9c8bba083f48247aab8b11c"}, {file = "tomli-1.2.3.tar.gz", hash = "sha256:05b6166bff487dc068d322585c7ea4ef78deed501cc124060e0f238e89a9231f"}, ] [[package]] name = "typing-extensions" version = "4.1.1" requires_python = ">=3.6" summary = "Backported and Experimental Type Hints for Python 3.6+" files = [ {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"}, {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"}, ] [[package]] name = "uvicorn" version = "0.23.2" requires_python = ">=3.8" summary = "The lightning-fast ASGI server." dependencies = [ "click>=7.0", "h11>=0.8", "typing-extensions>=4.0; python_version < \"3.11\"", ] files = [ {file = "uvicorn-0.23.2-py3-none-any.whl", hash = "sha256:1f9be6558f01239d4fdf22ef8126c39cb1ad0addf76c40e760549d2c2f43ab53"}, {file = "uvicorn-0.23.2.tar.gz", hash = "sha256:4d3cc12d7727ba72b64d12d3cc7743124074c0a69f7b201512fc50c3e3f1569a"}, ] a2wsgi-1.10.2/pyproject.toml000066400000000000000000000015201456507100000156770ustar00rootroot00000000000000[project] authors = [ {name = "abersheeran", email = "me@abersheeran.com"}, ] classifiers = ["Programming Language :: Python :: 3"] dependencies = [] description = "Convert WSGI app to ASGI app or ASGI app to WSGI app." license = {text = "Apache-2.0"} name = "a2wsgi" readme = "README.md" requires-python = ">=3.8.0" version = "1.10.2" [project.urls] homepage = "https://github.com/abersheeran/a2wsgi" repository = "https://github.com/abersheeran/a2wsgi" [tool.pdm.dev-dependencies] dev = [ "asgiref<4.0.0,>=3.2.7", "black", "flake8", "pytest<8.0.0,>=7.0.1", "pytest-cov<4.0.0,>=3.0.0", "pytest-asyncio<1.0.0,>=0.11.0", "mypy", "httpx<1.0.0,>=0.22.0", ] benchmark = [ "uvicorn>=0.16.0", "asgiref>=3.4.1", ] [tool.pdm.build] includes = ["a2wsgi"] [build-system] build-backend = "pdm.backend" requires = ["pdm-backend"] a2wsgi-1.10.2/script/000077500000000000000000000000001456507100000142715ustar00rootroot00000000000000a2wsgi-1.10.2/script/version.py000066400000000000000000000012461456507100000163330ustar00rootroot00000000000000import importlib import os import subprocess import sys here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) def get_version() -> str: """ Return version. """ sys.path.insert(0, here) return importlib.import_module("a2wsgi").__version__ os.chdir(here) subprocess.check_call(f"pdm version {get_version()}", shell=True) subprocess.check_call("git add a2wsgi/__init__.py pyproject.toml", shell=True) subprocess.check_call(f'git commit -m "v{get_version()}"', shell=True) subprocess.check_call("git push", shell=True) subprocess.check_call("git tag v{0}".format(get_version()), shell=True) subprocess.check_call("git push --tags", shell=True) a2wsgi-1.10.2/tests/000077500000000000000000000000001456507100000141275ustar00rootroot00000000000000a2wsgi-1.10.2/tests/__init__.py000066400000000000000000000000001456507100000162260ustar00rootroot00000000000000a2wsgi-1.10.2/tests/test_asgi.py000066400000000000000000000124421456507100000164660ustar00rootroot00000000000000import asyncio import concurrent.futures from collections import Counter import httpx import pytest from a2wsgi.asgi import ASGIMiddleware, build_scope async def hello_world(scope, receive, send): assert scope["type"] == "http" await send( { "type": "http.response.start", "status": 200, "headers": [ [b"content-type", b"text/plain"], ], } ) await send( {"type": "http.response.body", "body": b"Hello, world!", "more_body": True} ) await send({"type": "http.response.disconnect"}) async def echo_body(scope, receive, send): assert scope["type"] == "http" body = b"" more_body = True while more_body: msg = await receive() body += msg.get("body", b"") more_body = msg.get("more_body", False) await send( { "type": "http.response.start", "status": 200, "headers": [ (b"content-type", b"text/plain"), (b"Content-Length", str(len(body)).encode("latin1")), ], } ) await send({"type": "http.response.body", "body": body}) async def raise_exception(scope, receive, send): raise RuntimeError("Something went wrong") async def background_tasks(scope, receive, send): await hello_world(scope, receive, send) await asyncio.sleep(10) async def concurrent_rw(scope, receive, send): async def listen_for_disconnect() -> None: while True: message = await receive() if message["type"] == "http.disconnect": break async def stream_response() -> None: await send( { "type": "http.response.start", "status": 200, "headers": [], } ) for chunk in range(10): await send( { "type": "http.response.body", "body": chunk.to_bytes(4, "big"), "more_body": True, } ) await send({"type": "http.response.body", "body": b"", "more_body": False}) done, pending = await asyncio.wait( [ asyncio.create_task(listen_for_disconnect()), asyncio.create_task(stream_response()), ], return_when=asyncio.ALL_COMPLETED, ) [task.cancel() for task in pending] [task.result() for task in done] def test_asgi_get(): app = ASGIMiddleware(hello_world) with httpx.Client(app=app, base_url="http://testserver:80") as client: response = client.get("/") assert response.status_code == 200 assert response.text == "Hello, world!" def test_asgi_post(): app = ASGIMiddleware(echo_body) with httpx.Client(app=app, base_url="http://testserver:80") as client: response = client.post("/", content="hi boy") assert response.status_code == 200 assert response.text == "hi boy" def test_asgi_exception(): app = ASGIMiddleware(raise_exception) with httpx.Client(app=app, base_url="http://testserver:80") as client: with pytest.raises(RuntimeError): client.get("/") def test_asgi_exception_info(): app = ASGIMiddleware(raise_exception) with httpx.Client( transport=httpx.WSGITransport(app, raise_app_exceptions=False), base_url="http://testserver:80", ) as client: response = client.get("/") assert response.status_code == 500 assert response.text == "Server got itself in trouble" def test_background_app(): executor = concurrent.futures.ThreadPoolExecutor() def _(): app = ASGIMiddleware(background_tasks) with httpx.Client(app=app, base_url="http://testserver:80") as client: response = client.get("/") assert response.status_code == 200 assert response.text == "Hello, world!" future = executor.submit(_) with pytest.raises(concurrent.futures.TimeoutError): future.result(1) future.cancel() def test_background_app_wait_time(): executor = concurrent.futures.ThreadPoolExecutor() def _(): app = ASGIMiddleware(background_tasks, wait_time=1) with httpx.Client(app=app, base_url="http://testserver:80") as client: response = client.get("/") assert response.status_code == 200 assert response.text == "Hello, world!" future = executor.submit(_) future.result(2) def test_concurrent_rw(): app = ASGIMiddleware(concurrent_rw) with httpx.Client(app=app, base_url="http://testserver:80") as client: response = client.get("/") assert response.status_code == 200 def test_http_content_headers(): content_type = "application/json" content_length = "5" environ = { "REQUEST_METHOD": "POST", "QUERY_STRING": "", "PATH_INFO": "/foo", "SERVER_NAME": "foo.invalid", "SERVER_PORT": "80", "CONTENT_TYPE": content_type, "HTTP_CONTENT_TYPE": content_type, "CONTENT_LENGTH": content_length, "HTTP_CONTENT_LENGTH": content_length, } scope = build_scope(environ) counter = Counter(scope["headers"]) assert counter[(b"content-type", content_type.encode())] == 1 assert counter[(b"content-length", content_length.encode())] == 1 a2wsgi-1.10.2/tests/test_wsgi.py000066400000000000000000000161071456507100000165160ustar00rootroot00000000000000import asyncio import os import sys import threading import httpx import pytest from a2wsgi.wsgi import Body, WSGIMiddleware, build_environ def test_body(): event_loop = asyncio.new_event_loop() threading.Thread(target=event_loop.run_forever, daemon=True).start() async def receive(): return { "type": "http.request.body", "body": b"""This is a body test. Why do this? To prevent memory leaks. And cancel pre-reading. Newline.0 Newline.1 Newline.2 Newline.3 """, } body = Body(event_loop, receive) assert body.readline() == b"This is a body test.\n" assert body.read(4) == b"Why " assert body.readline(2) == b"do" assert body.readline(20) == b" this?\n" assert body.readlines(2) == [ b"To prevent memory leaks.\n", b"And cancel pre-reading.\n", ] for index, line in enumerate(body): assert line == b"Newline." + str(index).encode("utf8") + b"\n" if index == 1: break assert body.readlines() == [ b"Newline.2\n", b"Newline.3\n", ] assert body.readlines() == [] assert body.readline() == b"" assert body.read() == b"" for line in body: assert False def hello_world(environ, start_response): status = "200 OK" output = b"Hello World!\n" headers = [ ("Content-Type", "text/plain; charset=utf-8"), ("Content-Length", str(len(output))), ] start_response(status, headers) return [output] def echo_body(environ, start_response): status = "200 OK" output = environ["wsgi.input"].read() headers = [ ("Content-Type", "text/plain; charset=utf-8"), ("Content-Length", str(len(output))), ] start_response(status, headers) return [output] def raise_exception(environ, start_response): raise RuntimeError("Something went wrong") def return_exc_info(environ, start_response): try: raise RuntimeError("Something went wrong") except RuntimeError: status = "500 Internal Server Error" output = b"Internal Server Error" headers = [ ("Content-Type", "text/plain; charset=utf-8"), ("Content-Length", str(len(output))), ] start_response(status, headers, exc_info=sys.exc_info()) return [output] @pytest.mark.asyncio async def test_wsgi_get(): app = WSGIMiddleware(hello_world) async with httpx.AsyncClient(app=app, base_url="http://testserver") as client: response = await client.get("/") assert response.status_code == 200 assert response.text == "Hello World!\n" @pytest.mark.asyncio async def test_wsgi_post(): app = WSGIMiddleware(echo_body) async with httpx.AsyncClient(app=app, base_url="http://testserver") as client: response = await client.post("/", json={"example": 123}) assert response.status_code == 200 assert response.text == '{"example": 123}' @pytest.mark.asyncio async def test_wsgi_exception(): # Note that we're testing the WSGI app directly here. # The HTTP protocol implementations would catch this error and return 500. app = WSGIMiddleware(raise_exception) async with httpx.AsyncClient(app=app, base_url="http://testserver") as client: with pytest.raises(RuntimeError): await client.get("/") @pytest.mark.asyncio async def test_wsgi_exc_info(): # Note that we're testing the WSGI app directly here. # The HTTP protocol implementations would catch this error and return 500. app = WSGIMiddleware(return_exc_info) async with httpx.AsyncClient(app=app, base_url="http://testserver") as client: with pytest.raises(RuntimeError): response = await client.get("/") app = WSGIMiddleware(return_exc_info) async with httpx.AsyncClient( transport=httpx.ASGITransport(app, raise_app_exceptions=False), base_url="http://testserver", ) as client: response = await client.get("/") assert response.status_code == 500 assert response.text == "Internal Server Error" @pytest.mark.asyncio async def test_build_environ(): scope = { "type": "http", "http_version": "1.1", "method": "GET", "scheme": "https", "path": "/中文", "query_string": b"a=123&b=456", "headers": [ (b"host", b"www.example.org"), (b"content-type", b"application/json"), (b"content-length", b"18"), (b"accept", b"application/json"), (b"accept", b"text/plain"), ], "client": ("134.56.78.4", 1453), "server": ("www.example.org", 443), } async def receive(): raise NotImplementedError environ = build_environ(scope, Body(asyncio.get_event_loop(), receive)) environ.pop("wsgi.input") environ.pop("asgi.scope") assert environ == { "CONTENT_LENGTH": "18", "CONTENT_TYPE": "application/json", "HTTP_ACCEPT": "application/json,text/plain", "HTTP_HOST": "www.example.org", "PATH_INFO": "/中文".encode("utf8").decode("latin-1"), "QUERY_STRING": "a=123&b=456", "REMOTE_ADDR": "134.56.78.4", "REMOTE_PORT": "1453", "REQUEST_METHOD": "GET", "SCRIPT_NAME": "", "SERVER_NAME": "www.example.org", "SERVER_PORT": "443", "SERVER_PROTOCOL": "HTTP/1.1", "wsgi.errors": sys.stdout, "wsgi.multiprocess": True, "wsgi.multithread": True, "wsgi.run_once": False, "wsgi.url_scheme": "https", "wsgi.version": (1, 0), } @pytest.mark.asyncio async def test_build_environ_with_env(): os.environ["SCRIPT_NAME"] = "/urlprefix" scope = { "type": "http", "http_version": "1.1", "method": "GET", "scheme": "https", "path": "/中文", "query_string": b"a=123&b=456", "headers": [ (b"host", b"www.example.org"), (b"content-type", b"application/json"), (b"content-length", b"18"), (b"accept", b"application/json"), (b"accept", b"text/plain"), ], "client": ("134.56.78.4", 1453), "server": ("www.example.org", 443), } async def receive(): raise NotImplementedError environ = build_environ(scope, Body(asyncio.get_event_loop(), receive)) environ.pop("wsgi.input") environ.pop("asgi.scope") assert environ == { "CONTENT_LENGTH": "18", "CONTENT_TYPE": "application/json", "HTTP_ACCEPT": "application/json,text/plain", "HTTP_HOST": "www.example.org", "PATH_INFO": "/中文".encode("utf8").decode("latin-1"), "QUERY_STRING": "a=123&b=456", "REMOTE_ADDR": "134.56.78.4", "REMOTE_PORT": "1453", "REQUEST_METHOD": "GET", "SCRIPT_NAME": "/urlprefix", "SERVER_NAME": "www.example.org", "SERVER_PORT": "443", "SERVER_PROTOCOL": "HTTP/1.1", "wsgi.errors": sys.stdout, "wsgi.multiprocess": True, "wsgi.multithread": True, "wsgi.run_once": False, "wsgi.url_scheme": "https", "wsgi.version": (1, 0), }