From 20ea305fe793eb206b52af9eb5c5f3c1e9f57dbb Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Fri, 22 Dec 2023 20:26:07 +0000 Subject: [PATCH] v2 (#186) --- .coveragerc | 5 - .flake8 | 3 - .github/workflows/tests.yml | 2 +- README.md | 21 +- docs/api.rst | 106 +- docs/extensions.rst | 502 ++- docs/freezing.rst | 110 + docs/index.rst | 8 +- docs/intro.rst | 401 ++- docs/migrating.rst | 142 + examples/benchmark/mem.py | 2 +- examples/benchmark/mem_asgi.py | 2 +- examples/benchmark/mem_async.py | 11 - examples/benchmark/mem_fastapi.py | 2 +- examples/benchmark/mem_quart.py | 2 +- examples/benchmark/mem_wsgi.py | 2 +- examples/benchmark/requirements.in | 9 + examples/benchmark/requirements.txt | 134 +- examples/benchmark/run.py | 36 +- examples/cors/app.py | 2 +- examples/hello/hello.py | 8 +- examples/hello/hello_asgi.py | 6 +- examples/hello/hello_async.py | 33 - examples/hello/hello_wsgi.py | 6 +- examples/sessions/login.py | 15 +- examples/sse/counter.py | 16 + examples/static/static.py | 5 +- examples/static/static/index.css | 10 + examples/static/static_async.py | 18 - examples/streaming/video_stream.py | 49 +- examples/streaming/video_stream_async.py | 65 - examples/templates/jinja/async_template.py | 18 + examples/templates/jinja/bootstrap.py | 10 +- examples/templates/jinja/hello.py | 6 +- .../jinja/hello_asgi.py} | 6 +- examples/templates/jinja/hello_wsgi.py | 17 + examples/templates/jinja/streaming.py | 17 + .../templates/utemplate/async_template.py | 17 + examples/templates/utemplate/bootstrap.py | 10 +- examples/templates/utemplate/hello.py | 6 +- examples/templates/utemplate/hello_asgi.py | 17 + examples/templates/utemplate/hello_wsgi.py | 17 + examples/templates/utemplate/streaming.py | 17 + examples/tls/echo_async_tls.py | 23 - examples/tls/echo_tls.py | 24 - examples/tls/{hello_async_tls.py => hello.py} | 6 +- examples/tls/hello_tls.py | 37 - examples/tls/index.html | 36 - examples/uploads/uploads.py | 6 +- examples/uploads/uploads_async.py | 34 - examples/websocket/echo.py | 10 +- examples/websocket/echo_asgi.py | 9 +- examples/websocket/echo_async.py | 20 - examples/websocket/echo_wsgi.py | 15 +- libs/common/utemplate/README.md | 2 - .../{uasyncio => asyncio}/__init__.py | 3 +- .../micropython/{uasyncio => asyncio}/core.py | 12 +- .../{uasyncio => asyncio}/event.py | 12 +- .../{uasyncio => asyncio}/funcs.py | 9 +- .../micropython/{uasyncio => asyncio}/lock.py | 6 +- .../{uasyncio => asyncio}/manifest.py | 5 +- .../{uasyncio => asyncio}/stream.py | 55 +- .../micropython/{uasyncio => asyncio}/task.py | 2 +- libs/micropython/asyncio/uasyncio.py | 8 + libs/micropython/datetime.py | 2714 +++++------------ libs/micropython/ffilib.py | 14 +- libs/micropython/time.py | 143 +- libs/micropython/unittest.py | 7 +- libs/refresh.sh | 14 + pyproject.toml | 23 +- src/microdot/__init__.py | 2 + src/{microdot_asgi.py => microdot/asgi.py} | 101 +- src/{microdot_cors.py => microdot/cors.py} | 0 src/microdot/jinja.py | 58 + src/{ => microdot}/microdot.py | 512 ++-- src/microdot/session.py | 148 + src/microdot/sse.py | 95 + .../test_client.py} | 115 +- src/microdot/utemplate.py | 68 + .../websocket.py} | 85 +- src/microdot/wsgi.py | 154 + src/microdot_asgi_websocket.py | 86 - src/microdot_asyncio.py | 455 --- src/microdot_asyncio_test_client.py | 208 -- src/microdot_asyncio_websocket.py | 103 - src/microdot_jinja.py | 33 - src/microdot_session.py | 98 - src/microdot_ssl.py | 61 - src/microdot_utemplate.py | 34 - src/microdot_websocket_alt.py | 114 - src/microdot_wsgi.py | 64 - tests/__init__.py | 25 +- tests/mock_asyncio.py | 39 - tests/{test_microdot_asgi.py => test_asgi.py} | 36 +- tests/test_cors.py | 51 +- tests/test_end2end.py | 104 + tests/test_jinja.py | 87 +- tests/test_microdot.py | 316 +- tests/test_microdot_asyncio.py | 775 ----- tests/test_microdot_asyncio_websocket.py | 71 - tests/test_microdot_websocket.py | 73 - tests/test_multidict.py | 2 +- tests/test_request.py | 95 +- tests/test_request_asyncio.py | 131 - tests/test_response.py | 201 +- tests/test_response_asyncio.py | 139 - tests/test_session.py | 103 +- tests/test_sse.py | 38 + tests/test_url_pattern.py | 2 +- tests/test_urlencode.py | 2 +- tests/test_utemplate.py | 62 +- tests/test_websocket.py | 114 + tests/{test_microdot_wsgi.py => test_wsgi.py} | 62 +- tox.ini | 16 +- 114 files changed, 3868 insertions(+), 6410 deletions(-) delete mode 100644 .coveragerc delete mode 100644 .flake8 create mode 100644 docs/freezing.rst create mode 100644 docs/migrating.rst delete mode 100644 examples/benchmark/mem_async.py create mode 100644 examples/benchmark/requirements.in delete mode 100644 examples/hello/hello_async.py create mode 100644 examples/sse/counter.py create mode 100644 examples/static/static/index.css delete mode 100644 examples/static/static_async.py delete mode 100644 examples/streaming/video_stream_async.py create mode 100644 examples/templates/jinja/async_template.py rename examples/{hello/hello_utemplate_async.py => templates/jinja/hello_asgi.py} (63%) create mode 100644 examples/templates/jinja/hello_wsgi.py create mode 100644 examples/templates/jinja/streaming.py create mode 100644 examples/templates/utemplate/async_template.py create mode 100644 examples/templates/utemplate/hello_asgi.py create mode 100644 examples/templates/utemplate/hello_wsgi.py create mode 100644 examples/templates/utemplate/streaming.py delete mode 100644 examples/tls/echo_async_tls.py delete mode 100644 examples/tls/echo_tls.py rename examples/tls/{hello_async_tls.py => hello.py} (84%) delete mode 100644 examples/tls/hello_tls.py delete mode 100644 examples/tls/index.html delete mode 100644 examples/uploads/uploads_async.py delete mode 100644 examples/websocket/echo_async.py rename libs/micropython/{uasyncio => asyncio}/__init__.py (95%) rename libs/micropython/{uasyncio => asyncio}/core.py (97%) rename libs/micropython/{uasyncio => asyncio}/event.py (91%) rename libs/micropython/{uasyncio => asyncio}/funcs.py (97%) rename libs/micropython/{uasyncio => asyncio}/lock.py (96%) rename libs/micropython/{uasyncio => asyncio}/manifest.py (76%) rename libs/micropython/{uasyncio => asyncio}/stream.py (76%) rename libs/micropython/{uasyncio => asyncio}/task.py (99%) create mode 100644 libs/micropython/asyncio/uasyncio.py create mode 100755 libs/refresh.sh create mode 100644 src/microdot/__init__.py rename src/{microdot_asgi.py => microdot/asgi.py} (58%) rename src/{microdot_cors.py => microdot/cors.py} (100%) create mode 100644 src/microdot/jinja.py rename src/{ => microdot}/microdot.py (75%) create mode 100644 src/microdot/session.py create mode 100644 src/microdot/sse.py rename src/{microdot_test_client.py => microdot/test_client.py} (73%) create mode 100644 src/microdot/utemplate.py rename src/{microdot_websocket.py => microdot/websocket.py} (71%) create mode 100644 src/microdot/wsgi.py delete mode 100644 src/microdot_asgi_websocket.py delete mode 100644 src/microdot_asyncio.py delete mode 100644 src/microdot_asyncio_test_client.py delete mode 100644 src/microdot_asyncio_websocket.py delete mode 100644 src/microdot_jinja.py delete mode 100644 src/microdot_session.py delete mode 100644 src/microdot_ssl.py delete mode 100644 src/microdot_utemplate.py delete mode 100644 src/microdot_websocket_alt.py delete mode 100644 src/microdot_wsgi.py delete mode 100644 tests/mock_asyncio.py rename tests/{test_microdot_asgi.py => test_asgi.py} (88%) create mode 100644 tests/test_end2end.py delete mode 100644 tests/test_microdot_asyncio.py delete mode 100644 tests/test_microdot_asyncio_websocket.py delete mode 100644 tests/test_microdot_websocket.py delete mode 100644 tests/test_request_asyncio.py delete mode 100644 tests/test_response_asyncio.py create mode 100644 tests/test_sse.py create mode 100644 tests/test_websocket.py rename tests/{test_microdot_wsgi.py => test_wsgi.py} (67%) diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index c3f9e8c..0000000 --- a/.coveragerc +++ /dev/null @@ -1,5 +0,0 @@ -[run] -omit= - src/microdot_websocket_alt.py - src/microdot_asgi_websocket.py - src/microdot_ssl.py diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 9c37432..0000000 --- a/.flake8 +++ /dev/null @@ -1,3 +0,0 @@ -[flake8] -select = C,E,F,W,B,B950 -per-file-ignores = ./*/__init__.py:F401 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e6977c9..3c72e84 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,7 +21,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python: ['3.8', '3.9', '3.10', '3.11', '3.12'] fail-fast: false runs-on: ${{ matrix.os }} steps: diff --git a/README.md b/README.md index 6352f4a..f652728 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ *“The impossibly small web framework for Python and MicroPython”* -Microdot is a minimalistic Python web framework inspired by Flask, and designed -to run on systems with limited resources such as microcontrollers. It runs on -standard Python and on MicroPython. +Microdot is a minimalistic Python web framework inspired by Flask. Given its +small size, it can run on systems with limited resources such as +microcontrollers. Both standard Python (CPython) and MicroPython are supported. ```python from microdot import Microdot @@ -13,13 +13,24 @@ from microdot import Microdot app = Microdot() @app.route('/') -def index(request): +async def index(request): return 'Hello, world!' app.run() ``` +## Migrating to Microdot 2 + +Version 2 of Microdot incorporates feedback received from users of earlier +releases, and attempts to improve and correct some design decisions that have +proven to be problematic. + +For this reason most applications built for earlier versions will need to be +updated to work correctly with Microdot 2. The +[Migration Guide](https://microdot.readthedocs.io/en/stable/migrating.html) +describes the backwards incompatible changes that were made. + ## Resources -- [Documentation](https://microdot.readthedocs.io/en/latest/) +- [Documentation](https://microdot.readthedocs.io/en/stable/) - [Change Log](https://github.com/miguelgrinberg/microdot/blob/main/CHANGES.md) diff --git a/docs/api.rst b/docs/api.rst index e47b02b..b9487a9 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -13,103 +13,53 @@ API Reference .. autoclass:: microdot.Response :members: -.. autoclass:: microdot.NoCaseDict - :members: -.. autoclass:: microdot.MultiDict - :members: - -``microdot_asyncio`` module ---------------------------- - -.. autoclass:: microdot_asyncio.Microdot - :inherited-members: - :members: - -.. autoclass:: microdot_asyncio.Request - :inherited-members: - :members: - -.. autoclass:: microdot_asyncio.Response - :inherited-members: - :members: - -``microdot_utemplate`` module ------------------------------ - -.. automodule:: microdot_utemplate - :members: - -``microdot_jinja`` module -------------------------- - -.. automodule:: microdot_jinja - :members: - -``microdot_session`` module ---------------------------- - -.. automodule:: microdot_session - :members: - -``microdot_cors`` module ------------------------- - -.. automodule:: microdot_cors - :members: - -``microdot_websocket`` module ------------------------------- - -.. automodule:: microdot_websocket - :members: - -``microdot_asyncio_websocket`` module -------------------------------------- - -.. automodule:: microdot_asyncio_websocket - :members: - -``microdot_asgi_websocket`` module -------------------------------------- - -.. automodule:: microdot_asgi_websocket - :members: - -``microdot_ssl`` module +``websocket`` extension ----------------------- -.. automodule:: microdot_ssl +.. automodule:: microdot.websocket :members: -``microdot_test_client`` module -------------------------------- +``utemplate`` templating extension +---------------------------------- -.. autoclass:: microdot_test_client.TestClient +.. automodule:: microdot.utemplate :members: -.. autoclass:: microdot_test_client.TestResponse +``jinja`` templating extension +------------------------------ + +.. automodule:: microdot.jinja :members: -``microdot_asyncio_test_client`` module ---------------------------------------- +``session`` extension +--------------------- -.. autoclass:: microdot_asyncio_test_client.TestClient +.. automodule:: microdot.session :members: -.. autoclass:: microdot_asyncio_test_client.TestResponse +``cors`` extension +------------------ + +.. automodule:: microdot.cors :members: -``microdot_wsgi`` module ------------------------- +``test_client`` extension +------------------------- -.. autoclass:: microdot_wsgi.Microdot +.. automodule:: microdot.test_client + :members: + +``asgi`` extension +------------------ + +.. autoclass:: microdot.asgi.Microdot :members: :exclude-members: shutdown, run -``microdot_asgi`` module ------------------------- +``wsgi`` extension +------------------- -.. autoclass:: microdot_asgi.Microdot +.. autoclass:: microdot.wsgi.Microdot :members: :exclude-members: shutdown, run diff --git a/docs/extensions.rst b/docs/extensions.rst index 2a49686..2445131 100644 --- a/docs/extensions.rst +++ b/docs/extensions.rst @@ -2,11 +2,11 @@ Core Extensions --------------- Microdot is a highly extensible web application framework. The extensions -described in this section are maintained as part of the Microdot project and -can be obtained from the same source code repository. +described in this section are maintained as part of the Microdot project in +the same source code repository. -Asynchronous Support with Asyncio -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +WebSocket Support +~~~~~~~~~~~~~~~~~ .. list-table:: :align: left @@ -15,35 +15,71 @@ Asynchronous Support with Asyncio - | CPython & MicroPython * - Required Microdot source files - - | `microdot.py `_ - | `microdot_asyncio.py `_ + - | `websocket.py `_ * - Required external dependencies - - | CPython: None - | MicroPython: `uasyncio `_ + - | None * - Examples - - | `hello_async.py `_ + - | `echo.py `_ -Microdot can be extended to use an asynchronous programming model based on the -``asyncio`` package. When the :class:`Microdot ` -class is imported from the ``microdot_asyncio`` package, an asynchronous server -is used, and handlers can be defined as coroutines. +The WebSocket extension gives the application the ability to handle WebSocket +requests. The :func:`with_websocket ` +decorator is used to mark a route handler as a WebSocket handler. Decorated +routes receive a WebSocket object as a second argument. The WebSocket object +provides ``send()`` and ``receive()`` asynchronous methods to send and receive +messages respectively. -The example that follows uses ``asyncio`` coroutines for concurrency:: +Example:: - from microdot_asyncio import Microdot + @app.route('/echo') + @with_websocket + async def echo(request, ws): + while True: + message = await ws.receive() + await ws.send(message) - app = Microdot() +Server-Sent Events Support +~~~~~~~~~~~~~~~~~~~~~~~~~~ - @app.route('/') - async def hello(request): - return 'Hello, world!' +.. list-table:: + :align: left - app.run() + * - Compatibility + - | CPython & MicroPython -Rendering HTML Templates -~~~~~~~~~~~~~~~~~~~~~~~~ + * - Required Microdot source files + - | `sse.py `_ + + * - Required external dependencies + - | None + + * - Examples + - | `counter.py `_ + +The Server-Sent Events (SSE) extension simplifies the creation of a streaming +endpoint that follows the SSE web standard. The :func:`with_sse ` +decorator is used to mark a route as an SSE handler. Decorated routes receive +an SSE object as second argument. The SSE object provides a ``send()`` +asynchronous method to send an event to the client. + +Example:: + + @app.route('/events') + @with_sse + async def events(request, sse): + for i in range(10): + await asyncio.sleep(1) + await sse.send({'counter': i}) # unnamed event + await sse.send('end', event='comment') # named event + +.. note:: + The SSE protocol is unidirectional, so there is no ``receive()`` method in + the SSE object. For bidirectional communication with the client, use the + WebSocket extension. + +Rendering Templates +~~~~~~~~~~~~~~~~~~~ Many web applications use HTML templates for rendering content to clients. Microdot includes extensions to render templates with the @@ -61,35 +97,41 @@ Using the uTemplate Engine - | CPython & MicroPython * - Required Microdot source files - - | `microdot.py `_ - | `microdot_utemplate.py `_ + - | `utemplate.py `_ * - Required external dependencies - | `utemplate `_ * - Examples - | `hello.py `_ - | `hello_utemplate_async.py `_ -The :func:`render_template ` function is -used to render HTML templates with the uTemplate engine. The first argument is -the template filename, relative to the templates directory, which is -*templates* by default. Any additional arguments are passed to the template -engine to be used as arguments. +The :class:`Template ` class is used to load a +template. The argument is the template filename, relative to the templates +directory, which is *templates* by default. + +The ``Template`` object has a :func:`render() ` +method that renders the template to a string. This method receives any +arguments that are used by the template. Example:: - from microdot_utemplate import render_template + from microdot.utemplate import Template @app.get('/') - def index(req): - return render_template('index.html') + async def index(req): + return Template('index.html').render() + +The ``Template`` object also has a :func:`generate() ` +method, which returns a generator instead of a string. The +:func:`render_async() ` and +:func:`generate_async() ` methods +are the asynchronous versions of these two methods. The default location from where templates are loaded is the *templates* subdirectory. This location can be changed with the -:func:`init_templates ` function:: +:func:`init_templates ` function:: - from microdot_utemplate import init_templates + from microdot.utemplate import init_templates init_templates('my_templates') @@ -103,8 +145,7 @@ Using the Jinja Engine - | CPython only * - Required Microdot source files - - | `microdot.py `_ - | `microdot_jinja.py `_ + - | `jinja.py `_ * - Required external dependencies - | `Jinja2 `_ @@ -112,28 +153,40 @@ Using the Jinja Engine * - Examples - | `hello.py `_ -The :func:`render_template ` function is used -to render HTML templates with the Jinja engine. The first argument is the -template filename, relative to the templates directory, which is *templates* by -default. Any additional arguments are passed to the template engine to be used -as arguments. +The :class:`Template ` class is used to load a +template. The argument is the template filename, relative to the templates +directory, which is *templates* by default. + +The ``Template`` object has a :func:`render() ` +method that renders the template to a string. This method receives any +arguments that are used by the template. Example:: - from microdot_jinja import render_template + from microdot.jinja import Template @app.get('/') - def index(req): - return render_template('index.html') + async def index(req): + return Template('index.html').render() + +The ``Template`` object also has a :func:`generate() ` +method, which returns a generator instead of a string. The default location from where templates are loaded is the *templates* subdirectory. This location can be changed with the -:func:`init_templates ` function:: +:func:`init_templates ` function:: - from microdot_jinja import init_templates + from microdot.jinja import init_templates init_templates('my_templates') +The ``init_templates()`` function also accepts ``enable_async`` argument, which +can be set to ``True`` if asynchronous rendering of templates is desired. If +this option is enabled, then the +:func:`render_async() ` and +:func:`generate_async() ` methods +must be used. + .. note:: The Jinja extension is not compatible with MicroPython. @@ -147,56 +200,48 @@ Maintaining Secure User Sessions - | CPython & MicroPython * - Required Microdot source files - - | `microdot.py `_ - | `microdot_session.py `_ + - | `session.py `_ * - Required external dependencies - | CPython: `PyJWT `_ | MicroPython: `jwt.py `_, - `hmac `_ + `hmac.py `_ * - Examples - | `login.py `_ The session extension provides a secure way for the application to maintain -user sessions. The session is stored as a signed cookie in the client's +user sessions. The session data is stored as a signed cookie in the client's browser, in `JSON Web Token (JWT) `_ format. -To work with user sessions, the application first must configure the secret key +To work with user sessions, the application first must configure a secret key that will be used to sign the session cookies. It is very important that this -key is kept secret. An attacker who is in possession of this key can generate -valid user session cookies with any contents. +key is kept secret, as its name implies. An attacker who is in possession of +this key can generate valid user session cookies with any contents. -To set the secret key, use the :func:`set_session_secret_key ` function:: +To initialize the session extension and configure the secret key, create a +:class:`Session ` object:: - from microdot_session import set_session_secret_key + Session(app, secret_key='top-secret') - set_session_secret_key('top-secret!') +The :func:`with_session ` decorator is the +most convenient way to retrieve the session at the start of a request:: -To :func:`get_session `, -:func:`update_session ` and -:func:`delete_session ` functions are used -inside route handlers to retrieve, store and delete session data respectively. -The :func:`with_session ` decorator is provided -as a convenient way to retrieve the session at the start of a route handler. - -Example:: - - from microdot import Microdot - from microdot_session import set_session_secret_key, with_session, \ - update_session, delete_session + from microdot import Microdot, redirect + from microdot.session import Session, with_session app = Microdot() - set_session_secret_key('top-secret') + Session(app, secret_key='top-secret') @app.route('/', methods=['GET', 'POST']) @with_session - def index(req, session): + async def index(req, session): username = session.get('username') if req.method == 'POST': username = req.form.get('username') - update_session(req, {'username': username}) + session['username'] = username + session.save() return redirect('/') if username is None: return 'Not logged in' @@ -204,10 +249,15 @@ Example:: return 'Logged in as ' + username @app.post('/logout') - def logout(req): - delete_session(req) + @with_session + async def logout(req, session): + session.delete() return redirect('/') +The :func:`save() ` and +:func:`delete() ` methods are used to update +and destroy the user session respectively. + Cross-Origin Resource Sharing (CORS) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -218,8 +268,7 @@ Cross-Origin Resource Sharing (CORS) - | CPython & MicroPython * - Required Microdot source files - - | `microdot.py `_ - | `microdot_cors.py `_ + - | `cors.py `_ * - Required external dependencies - | None @@ -234,18 +283,18 @@ resources from each other. For example, a web application running on ``https://example.com`` can access resources from ``https://api.example.com``. To enable CORS support, create an instance of the -:class:`CORS ` class and configure the desired options. +:class:`CORS ` class and configure the desired options. Example:: from microdot import Microdot - from microdot_cors import CORS + from microdot.cors import CORS app = Microdot() cors = CORS(app, allowed_origins=['https://example.com'], allow_credentials=True) -WebSocket Support -~~~~~~~~~~~~~~~~~ +Testing with the Test Client +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. list-table:: :align: left @@ -254,96 +303,18 @@ WebSocket Support - | CPython & MicroPython * - Required Microdot source files - - | `microdot.py `_ - | `microdot_websocket.py `_ + - | `test_client.py `_ * - Required external dependencies - | None - * - Examples - - | `echo.py `_ - | `echo_wsgi.py `_ - -The WebSocket extension provides a way for the application to handle WebSocket -requests. The :func:`websocket ` decorator -is used to mark a route handler as a WebSocket handler. The handler receives -a WebSocket object as a second argument. The WebSocket object provides -``send()`` and ``receive()`` methods to send and receive messages respectively. - -Example:: - - @app.route('/echo') - @with_websocket - def echo(request, ws): - while True: - message = ws.receive() - ws.send(message) - -.. note:: - An unsupported *microdot_websocket_alt.py* module, with the same - interface, is also provided. This module uses the native WebSocket support - in MicroPython that powers the WebREPL, and may provide slightly better - performance for MicroPython low-end boards. This module is not compatible - with CPython. - -Asynchronous WebSocket -~~~~~~~~~~~~~~~~~~~~~~ - -.. list-table:: - :align: left - - * - Compatibility - - | CPython & MicroPython - - * - Required Microdot source files - - | `microdot.py `_ - | `microdot_asyncio.py `_ - | `microdot_websocket.py `_ - | `microdot_asyncio_websocket.py `_ - - * - Required external dependencies - - | CPython: None - | MicroPython: `uasyncio `_ - - * - Examples - - | `echo_async.py `_ - -This extension has the same interface as the synchronous WebSocket extension, -but the ``receive()`` and ``send()`` methods are asynchronous. - -.. note:: - An unsupported *microdot_asgi_websocket.py* module, with the same - interface, is also provided. This module must be used instead of - *microdot_asyncio_websocket.py* when the ASGI support is used. The - `echo_asgi.py `_ - example shows how to use this module. - -HTTPS Support -~~~~~~~~~~~~~ - -.. list-table:: - :align: left - - * - Compatibility - - | CPython & MicroPython - - * - Required Microdot source files - - | `microdot.py `_ - | `microdot_ssl.py `_ - - * - Examples - - | `hello_tls.py `_ - | `hello_async_tls.py `_ - -The ``run()`` function accepts an optional ``ssl`` argument, through which an -initialized ``SSLContext`` object can be passed. MicroPython does not currently -have a ``SSLContext`` implementation, so the ``microdot_ssl`` module provides -a basic implementation that can be used to create a context. +The Microdot Test Client is a utility class that can be used in tests to send +requests into the application without having to start a web server. Example:: from microdot import Microdot - from microdot_ssl import create_ssl_context + from microdot.test_client import TestClient app = Microdot() @@ -351,88 +322,13 @@ Example:: def index(req): return 'Hello, World!' - sslctx = create_ssl_context('cert.der', 'key.der') - app.run(port=4443, debug=True, ssl=sslctx) - -.. note:: - The ``microdot_ssl`` module is only needed for MicroPython. When used under - CPython, this module creates a standard ``SSLContext`` instance. - -.. note:: - The ``uasyncio`` library for MicroPython does not currently support TLS, so - this feature is not available for asynchronous applications on that - platform. The ``asyncio`` library for CPython is fully supported. - -Test Client -~~~~~~~~~~~ - -.. list-table:: - :align: left - - * - Compatibility - - | CPython & MicroPython - - * - Required Microdot source files - - | `microdot.py `_ - | `microdot_test_client.py `_ - - * - Required external dependencies - - | None - -The Microdot Test Client is a utility class that can be used during testing to -send requests into the application. - -Example:: - - from microdot import Microdot - from microdot_test_client import TestClient - - app = Microdot() - - @app.route('/') - def index(req): - return 'Hello, World!' - - def test_app(): - client = TestClient(app) - response = client.get('/') - assert response.text == 'Hello, World!' - -See the documentation for the :class:`TestClient ` -class for more details. - -Asynchronous Test Client -~~~~~~~~~~~~~~~~~~~~~~~~ - -.. list-table:: - :align: left - - * - Compatibility - - | CPython & MicroPython - - * - Required Microdot source files - - | `microdot.py `_ - | `microdot_asyncio.py `_ - | `microdot_test_client.py `_ - | `microdot_asyncio_test_client.py `_ - - * - Required external dependencies - - | None - -Similar to the :class:`TestClient ` class -above, but for asynchronous applications. - -Example usage:: - - from microdot_asyncio_test_client import TestClient - async def test_app(): client = TestClient(app) response = await client.get('/') assert response.text == 'Hello, World!' -See the :class:`reference documentation ` -for details. +See the documentation for the :class:`TestClient ` +class for more details. Deploying on a Production Web Server ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -440,54 +336,7 @@ Deploying on a Production Web Server The ``Microdot`` class creates its own simple web server. This is enough for an application deployed with MicroPython, but when using CPython it may be useful to use a separate, battle-tested web server. To address this need, Microdot -provides extensions that implement the WSGI and ASGI protocols. - -Using a WSGI Web Server -^^^^^^^^^^^^^^^^^^^^^^^ - -.. list-table:: - :align: left - - * - Compatibility - - | CPython only - - * - Required Microdot source files - - | `microdot.py `_ - | `microdot_wsgi.py `_ - - * - Required external dependencies - - | A WSGI web server, such as `Gunicorn `_. - - * - Examples - - | `hello_wsgi.py `_ - - -The ``microdot_wsgi`` module provides an extended ``Microdot`` class that -implements the WSGI protocol and can be used with a compliant WSGI web server -such as `Gunicorn `_ or -`uWSGI `_. - -To use a WSGI web server, the application must import the -:class:`Microdot ` class from the ``microdot_wsgi`` -module:: - - from microdot_wsgi import Microdot - - app = Microdot() - - @app.route('/') - def index(req): - return 'Hello, World!' - -The ``app`` application instance created from this class is a WSGI application -that can be used with any complaint WSGI web server. If the above application -is stored in a file called *test.py*, then the following command runs the -web application using the Gunicorn web server:: - - gunicorn test:app - -When using this WSGI adapter, the ``environ`` dictionary provided by the web -server is available to request handlers as ``request.environ``. +provides extensions that implement the ASGI and WSGI protocols. Using an ASGI Web Server ^^^^^^^^^^^^^^^^^^^^^^^^ @@ -499,25 +348,25 @@ Using an ASGI Web Server - | CPython only * - Required Microdot source files - - | `microdot.py `_ - | `microdot_asyncio.py `_ - | `microdot_asgi.py `_ + - | `asgi.py `_ * - Required external dependencies - | An ASGI web server, such as `Uvicorn `_. * - Examples - | `hello_asgi.py `_ + | `hello_asgi.py (uTemplate) `_ + | `hello_asgi.py (Jinja) `_ + | `echo_asgi.py (WebSocket) `_ -The ``microdot_asgi`` module provides an extended ``Microdot`` class that +The ``asgi`` module provides an extended ``Microdot`` class that implements the ASGI protocol and can be used with a compliant ASGI server such as `Uvicorn `_. To use an ASGI web server, the application must import the -:class:`Microdot ` class from the ``microdot_asgi`` -module:: +:class:`Microdot ` class from the ``asgi`` module:: - from microdot_asgi import Microdot + from microdot.asgi import Microdot app = Microdot() @@ -525,12 +374,67 @@ module:: async def index(req): return 'Hello, World!' -The ``app`` application instance created from this class is an ASGI application -that can be used with any complaint ASGI web server. If the above application -is stored in a file called *test.py*, then the following command runs the -web application using the Uvicorn web server:: +The ``app`` application instance created from this class can be used as the +ASGI callable with any complaint ASGI web server. If the above example +application was stored in a file called *test.py*, then the following command +runs the web application using the Uvicorn web server:: uvicorn test:app -When using this ASGI adapter, the ``scope`` dictionary provided by the web +When using the ASGI support, the ``scope`` dictionary provided by the web server is available to request handlers as ``request.asgi_scope``. + +Using a WSGI Web Server +^^^^^^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :align: left + + * - Compatibility + - | CPython only + + * - Required Microdot source files + - | `wsgi.py `_ + + * - Required external dependencies + - | A WSGI web server, such as `Gunicorn `_. + + * - Examples + - | `hello_wsgi.py `_ + | `hello_wsgi.py (uTemplate) `_ + | `hello_wsgi.py (Jinja) `_ + | `echo_wsgi.py (WebSocket) `_ + + +The ``wsgi`` module provides an extended ``Microdot`` class that implements the +WSGI protocol and can be used with a compliant WSGI web server such as +`Gunicorn `_ or +`uWSGI `_. + +To use a WSGI web server, the application must import the +:class:`Microdot ` class from the ``wsgi`` module:: + + from microdot.wsgi import Microdot + + app = Microdot() + + @app.route('/') + def index(req): + return 'Hello, World!' + +The ``app`` application instance created from this class can be used as a WSGI +callbable with any complaint WSGI web server. If the above application +was stored in a file called *test.py*, then the following command runs the +web application using the Gunicorn web server:: + + gunicorn test:app + +When using the WSGI support, the ``environ`` dictionary provided by the web +server is available to request handlers as ``request.environ``. + +.. note:: + In spite of WSGI being a synchronous protocol, the Microdot application + internally runs under an asyncio event loop. For that reason, the + recommendation to prefer ``async def`` handlers over ``def`` still applies + under WSGI. Consult the :ref:`Concurrency` section for a discussion of how + the two types of functions are handled by Microdot. diff --git a/docs/freezing.rst b/docs/freezing.rst new file mode 100644 index 0000000..0a85bc2 --- /dev/null +++ b/docs/freezing.rst @@ -0,0 +1,110 @@ +Cross-Compiling and Freezing Microdot (MicroPython Only) +-------------------------------------------------------- + +Microdot is a fairly small framework, so its size is not something you need to +be concerned about unless you are working with MicroPython on hardware with a +very small amount of disk space and/or RAM. In such cases every byte counts, so +this section provides some recommendations on how to keep Microdot's footprint +as small as possible. + +Choosing What Modules to Install +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Microdot has a modular design that allows you to only install the modules that +your application needs. + +For minimal web application support based on the core Microdot web server +without extensions, you can just copy `microdot.py `_ +to the source directory on your device. The core Microdot web server does not +have any dependencies, so you don't need to install anything else. + +If your application uses some of the provided extensions to the core web +server, then instead of installing *microdot.py* you'll need to create a +*microdot* subdirectory and install the following files in it: + +- `__init__.py `_ +- `microdot.py `_ +- Any extension modules that you need from the `microdot `_ source directory. + +Some of the extensions also have dependencies of their own, so you may need to +install those in your device as well (outside of the ``microdot`` +subdirectory). Consult the documentation of each extension to learn if any +third-party dependencies are required. + +Cross-Compiling +~~~~~~~~~~~~~~~ + +An issue that is common with low-end microcontroller boards is that they do not +have enough RAM for the MicroPython compiler to compile the source files, but +once the code is compiled they are able to run it without problems. + +To address this, MicroPython allows you to cross-compile source files on your +desktop or laptop computer and then upload their compiled versions to the +device. A good strategy is to cross-compile all the dependencies that are used +by your application, since these are not going to be updated very often. If the +goal is to minimize the use of RAM, you can also opt to cross-compile your +application source files. + +The MicroPython cross-compiler is available as a package that you can install +on standard Python. You must determine the version of MicroPython that you will +be running on your device, and install the compiler that matches that version. +For example, if you plan to use MicroPython 1.21.0 on your device, you can +install the cross-compiler for this version with the following command:: + + pip install mpy-cross==1.21.0 + +Then run the cross-compiler for each source file that you want to compile. +Since the cross-compilation happens on your computer, you will need to have +copies of all the source files you need to compile locally on your disk. Here +is how you can compile the *microdot.py* file, assuming you have a copy in the +current directory in your computer:: + + mpy-cross microdot.py + +The cross-compiler will create a file with the same name as the source file, +but with the extension changed to *.mpy*. + +Once you have all your dependencies compiled, you can replace the *.py* files +in your device with their corresponding *.mpy* versions. MicroPython +automatically recognizes *.mpy* files, so there is no need to make any changes +to any source code to start using compiled files. + +Freezing +~~~~~~~~ + +The ultimate option to reduce the size of a MicroPython application is to +"freeze" it. Freezing is a process that takes MicroPython source code (either +dependencies, application code or both), pre-compiles it and incorporates it +into a custom-built MicroPython firmware that is flashed to the device. + +Freezing MicroPython modules to firmware has the advantage that the code is +imported directly from the device's ROM, leaving more RAM available for +application use. + +The process to create a custom firmware is unfortunately non-trivial and +different depending on the device, so you will need to consult the MicroPython +documentation that applies to your device to learn how to do this. + +The part of the process that is common to all devices is the creation of a +`manifest file `_ +to tell the MicroPython firmware builder which packages and modules to freeze. + +For a minimal installation of Microdot consisting only in its *microdot.py* +source file, the manifest file that you need use to build the firmware must +include the following declaration:: + + module('microdot') + +If instead you are working with a version of Microdot that includes some or all +of its extensions, then the manifest file must reference the ``microdot`` +package plus any third-party dependencies that are needed. Below is a manifest +file for a complete Microdot installation that includes all the extensions:: + + package('microdot') + package('utemplate') # required only if templates are used + module('pyjwt') # required only if user sessions are used + +In this example, the *microdot* and *utemplate* packages must be available in +the directory where the manifest file is located so that the MicroPython build +can find them. The `pyjwt` module is part of the MicroPython standard library +and will be downloaded as part of the build. diff --git a/docs/index.rst b/docs/index.rst index 68d81b3..aaddcd0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,15 +9,17 @@ Microdot *"The impossibly small web framework for Python and MicroPython"* Microdot is a minimalistic Python web framework inspired by -`Flask `_, and designed to run on -systems with limited resources such as microcontrollers. It runs on standard -Python and on `MicroPython `_. +`Flask `_. Given its size, it can run on +systems with limited resources such as microcontrollers. Both standard Python +(CPython) and `MicroPython `_ are supported. .. toctree:: :maxdepth: 3 intro extensions + migrating + freezing api * :ref:`genindex` diff --git a/docs/intro.rst b/docs/intro.rst index 535efcb..62f2f16 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -1,26 +1,49 @@ Installation ------------ -For standard Python (CPython) projects, Microdot and all of its core extensions -can be installed with ``pip``:: +The installation method is different depending on the version of Python. + +CPython Installation +~~~~~~~~~~~~~~~~~~~~ + +For use with standard Python (CPython) projects, Microdot and all of its core +extensions are installed with ``pip``:: pip install microdot -For MicroPython, you can install it with ``upip`` if that option is available, -but the recommended approach is to manually copy *microdot.py* and any -desired optional extension source files from the +MicroPython Installation +~~~~~~~~~~~~~~~~~~~~~~~~ + +For MicroPython, the recommended approach is to manually copy the necessary +source files from the `GitHub repository `_ -into your device, possibly after +into your device, ideally after `compiling `_ them to *.mpy* files. These source files can also be `frozen `_ and incorporated into a custom MicroPython firmware. +Use the following guidelines to know what files to copy: + +- For a minimal setup with only the base web server functionality, copy + `microdot.py `_ + into your project. +- For a configuration that includes one or more optional extensions, create a + *microdot* directory in your device and copy the following files: + - `__init__.py `_ + - `microdot.py `_ + - any needed `extensions `_. + + Getting Started --------------- -This section describes the main features of Microdot in an informal manner. For -detailed reference information, consult the :ref:`API Reference`. +This section describes the main features of Microdot in an informal manner. + +For detailed reference information, consult the :ref:`API Reference`. + +If you are familiar with releases of Microdot before 2.x, review the +:ref:`Migration Guide `. A Simple Microdot Web Server ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -32,7 +55,7 @@ The following is an example of a simple web server:: app = Microdot() @app.route('/') - def index(request): + async def index(request): return 'Hello, world!' app.run() @@ -46,17 +69,23 @@ application. The ``route()`` decorator takes the path portion of the URL as an argument, and maps it to the decorated function, so that the function is called -when the client requests the URL. The function is passed a -:class:`Request ` object as an argument, which provides -access to the information passed by the client. The value returned by the -function is sent back to the client as the response. +when the client requests the URL. + +When the function is called, it is passed a :class:`Request ` +object as an argument, which provides access to the information passed by the +client. The value returned by the function is sent back to the client as the +response. + +Microdot is an asynchronous framework that uses the ``asyncio`` package. Route +handler functions can be defined as ``async def`` or ``def`` functions, but +``async def`` functions are recommended for performance. The :func:`run() ` method starts the application's web -server on port 5000 (or the port number passed in the ``port`` argument). This -method blocks while it waits for connections from clients. +server on port 5000 by default. This method blocks while it waits for +connections from clients. Running with CPython -~~~~~~~~~~~~~~~~~~~~ +^^^^^^^^^^^^^^^^^^^^ .. list-table:: :align: left @@ -71,17 +100,18 @@ Running with CPython - | `hello.py `_ When using CPython, you can start the web server by running the script that -defines and runs the application instance:: +has the ``app.run()`` call at the bottom:: python main.py -While the script is running, you can open a web browser and navigate to -*http://localhost:5000/*, which is the default address for the Microdot web -server. From other computers in the same network, use the IP address or -hostname of the computer running the script instead of ``localhost``. +After starting the script, open a web browser and navigate to +*http://localhost:5000/* to access the application at the default address for +the Microdot web server. From other computers in the same network, use the IP +address or hostname of the computer running the script instead of +``localhost``. Running with MicroPython -~~~~~~~~~~~~~~~~~~~~~~~~ +^^^^^^^^^^^^^^^^^^^^^^^^ .. list-table:: :align: left @@ -97,11 +127,13 @@ Running with MicroPython | `gpio.py `_ When using MicroPython, you can upload a *main.py* file containing the web -server code to your device along with *microdot.py*. MicroPython will -automatically run *main.py* when the device is powered on, so the web server -will automatically start. The application can be accessed on port 5000 at the -device's IP address. As indicated above, the port can be changed by passing the -``port`` argument to the ``run()`` method. +server code to your device, along with the required Microdot files, as defined +in the :ref:`MicroPython Installation` section. + +MicroPython will automatically run *main.py* when the device is powered on, so +the web server will automatically start. The application can be accessed on +port 5000 at the device's IP address. As indicated above, the port can be +changed by passing the ``port`` argument to the ``run()`` method. .. note:: Microdot does not configure the network interface of the device in which it @@ -109,6 +141,41 @@ device's IP address. As indicated above, the port can be changed by passing the advance, for example to a Wi-Fi access point, this must be configured before the ``run()`` method is invoked. +Web Server Configuration +^^^^^^^^^^^^^^^^^^^^^^^^ + +The :func:`run() ` method supports a few arguments to +configure the web server. + +- ``port``: The port number to listen on. Pass the desired port number in this + argument to use a port different than the default of 5000. For example:: + + app.run(port=6000) + +- ``host``: The IP address of the network interface to listen on. By default + the server listens on all available interfaces. To listen only on the local + loopback interface, pass ``'127.0.0.1'`` as value for this argument. +- ``debug``: when set to ``True``, the server ouputs logging information to the + console. The default is ``False``. +- ``ssl``: an ``SSLContext`` instance that configures the server to use TLS + encryption, or ``None`` to disable TLS use. The default is ``None``. The + following example demonstrates how to configure the server with an SSL + certificate stored in *cert.pem* and *key.pem* files:: + + import ssl + + # ... + + sslctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + sslctx.load_cert_chain('cert.pem', 'key.pem') + app.run(port=4443, debug=True, ssl=sslctx) + +.. note:: + The ``ssl`` argument can only be used with CPython at this time, because + MicroPython's asyncio module does not currently support SSL certificates or + TLS encryption. Work on this is + `in progress `_. + Defining Routes ~~~~~~~~~~~~~~~ @@ -119,7 +186,7 @@ to the decorator is the path portion of the URL. The following example creates a route for the root URL of the application:: @app.route('/') - def index(request): + async def index(request): return 'Hello, world!' When a client requests the root URL (for example, *http://localhost:5000/*), @@ -127,11 +194,11 @@ Microdot will call the ``index()`` function, passing it a :class:`Request ` object. The return value of the function is the response that is sent to the client. -Below is a another example, this one with a route for a URL with two components +Below is another example, this one with a route for a URL with two components in its path:: @app.route('/users/active') - def active_users(request): + async def active_users(request): return 'Active users: Susan, Joe, and Bob' The complete URL that maps to this route is @@ -144,46 +211,49 @@ request. Choosing the HTTP Method ^^^^^^^^^^^^^^^^^^^^^^^^ -All the example routes shown above are associated with ``GET`` requests. But -applications often need to define routes for other HTTP methods, such as -``POST``, ``PUT``, ``PATCH`` and ``DELETE``. The ``route()`` decorator takes a -``methods`` optional argument, in which the application can provide a list of -HTTP methods that the route should be associated with on the given path. +All the example routes shown above are associated with ``GET`` requests, which +are the default. Applications often need to define routes for other HTTP +methods, such as ``POST``, ``PUT``, ``PATCH`` and ``DELETE``. The ``route()`` +decorator takes a ``methods`` optional argument, in which the application can +provide a list of HTTP methods that the route should be associated with on the +given path. The following example defines a route that handles ``GET`` and ``POST`` requests within the same function:: @app.route('/invoices', methods=['GET', 'POST']) - def invoices(request): + async def invoices(request): if request.method == 'GET': return 'get invoices' elif request.method == 'POST': return 'create an invoice' -In cases like the above, where a single URL is used to handle multiple HTTP -methods, it may be desirable to write a separate function for each HTTP method. -The above example can be implemented with two routes as follows:: +As an alternative to the example above, in which a single function is used to +handle multiple HTTP methods, sometimes it may be desirable to write a separate +function for each HTTP method. The above example can be implemented with two +routes as follows:: @app.route('/invoices', methods=['GET']) - def get_invoices(request): + async def get_invoices(request): return 'get invoices' @app.route('/invoices', methods=['POST']) - def create_invoice(request): + async def create_invoice(request): return 'create an invoice' Microdot provides the :func:`get() `, :func:`post() `, :func:`put() `, :func:`patch() `, and -:func:`delete() ` decorator shortcuts as well. The -two example routes above can be written more concisely with them:: +:func:`delete() ` decorators as shortcuts for the +corresponding HTTP methods. The two example routes above can be written more +concisely with them:: @app.get('/invoices') - def get_invoices(request): + async def get_invoices(request): return 'get invoices' @app.post('/invoices') - def create_invoice(request): + async def create_invoice(request): return 'create an invoice' Including Dynamic Components in the URL Path @@ -195,19 +265,19 @@ the following route associates all URLs that have a path following the pattern *http://localhost:5000/users/* with the ``get_user()`` function:: @app.get('/users/') - def get_user(request, username): + async def get_user(request, username): return 'User: ' + username -As shown in the example, a path components that is enclosed in angle brackets -is considered dynamic. Microdot accepts any values for that section of the URL -path, and passes the value received to the function as an argument after -the request object. +As shown in the example, a path component that is enclosed in angle brackets +is considered a placeholder. Microdot accepts any values for that portion of +the URL path, and passes the value received to the function as an argument +after the request object. Routes are not limited to a single dynamic component. The following route shows how multiple dynamic components can be included in the path:: @app.get('/users//') - def get_user(request, firstname, lastname): + async def get_user(request, firstname, lastname): return 'User: ' + firstname + ' ' + lastname Dynamic path components are considered to be strings by default. An explicit @@ -216,7 +286,7 @@ a colon. The following route has two dynamic components declared as an integer and a string respectively:: @app.get('/users//') - def get_user(request, id, username): + async def get_user(request, id, username): return 'User: ' + username + ' (' + str(id) + ')' If a dynamic path component is defined as an integer, the value passed to the @@ -225,10 +295,12 @@ integer in the corresponding section of the URL path, then the URL will not match and the route will not be called. A special type ``path`` can be used to capture the remainder of the path as a -single argument:: +single argument. The difference between an argument of type ``path`` and one of +type ``string`` is that the latter stops capturing when a ``/`` appears in the +URL. @app.get('/tests/') - def get_test(request, path): + async def get_test(request, path): return 'Test: ' + path For the most control, the ``re`` type allows the application to provide a @@ -237,7 +309,7 @@ a route that only matches usernames that begin with an upper or lower case letter, followed by a sequence of letters or numbers:: @app.get('/users/') - def get_user(request, username): + async def get_user(request, username): return 'User: ' + username .. note:: @@ -255,54 +327,56 @@ resource can be obtained from a cache. The :func:`before_request() ` decorator registers a function to be called before the request is dispatched to the route function. -The following example registers a before request handler that ensures that the +The following example registers a before-request handler that ensures that the client is authenticated before the request is handled:: @app.before_request - def authenticate(request): + async def authenticate(request): user = authorize(request) if not user: return 'Unauthorized', 401 request.g.user = user -Before request handlers receive the request object as an argument. If the +Before-request handlers receive the request object as an argument. If the function returns a value, Microdot sends it to the client as the response, and -does not invoke the route function. This gives before request handlers the +does not invoke the route function. This gives before-request handlers the power to intercept a request if necessary. The example above uses this technique to prevent an unauthorized user from accessing the requested -resource. +route. -After request handlers registered with the +After-request handlers registered with the :func:`after_request() ` decorator are called after the route function returns a response. Their purpose is to perform any -common closing or cleanup tasks. The next example shows a combination of before -and after request handlers that print the time it takes for a request to be -handled:: +common closing or cleanup tasks. The next example shows a combination of +before- and after-request handlers that print the time it takes for a request +to be handled:: @app.before_request - def start_timer(request): + async def start_timer(request): request.g.start_time = time.time() @app.after_request - def end_timer(request, response): + async def end_timer(request, response): duration = time.time() - request.g.start_time print(f'Request took {duration:0.2f} seconds') -After request handlers receive the request and response objects as arguments. -The function can return a modified response object to replace the original. If -the function does not return a value, then the original response object is -used. +After-request handlers receive the request and response objects as arguments, +and they can return a modified response object to replace the original. If +no value is returned from an after-request handler, then the original response +object is used. -The after request handlers are only invoked for successful requests. The +The after-request handlers are only invoked for successful requests. The :func:`after_error_request() ` decorator can be used to register a function that is called after an error occurs. The function receives the request and the error response and is -expected to return an updated response object. +expected to return an updated response object after performing any necessary +cleanup. .. note:: - The :ref:`request.g ` object is a special object that allows - the before and after request handlers, as well as the route function to - share data during the life of the request. + The :ref:`request.g ` object used in many of the above + examples is a special object that allows the before- and after-request + handlers, as well as the route function to share data during the life of the + request. Error Handlers ^^^^^^^^^^^^^^ @@ -312,10 +386,11 @@ the client receives an appropriate error response. Some of the common errors automatically handled by Microdot are: - 400 for malformed requests. -- 404 for URLs that are not defined. -- 405 for URLs that are defined, but not for the requested HTTP method. +- 404 for URLs that are unknown. +- 405 for URLs that are known, but not implemented for the requested HTTP + method. - 413 for requests that are larger than the allowed size. -- 500 when the application raises an exception. +- 500 when the application raises an unhandled exception. While the above errors are fully complaint with the HTTP specification, the application might want to provide custom responses for them. The @@ -324,30 +399,31 @@ functions to respond to specific error codes. The following example shows a custom error handler for 404 errors:: @app.errorhandler(404) - def not_found(request): + async def not_found(request): return {'error': 'resource not found'}, 404 The ``errorhandler()`` decorator has a second form, in which it takes an -exception class as an argument. Microdot will then invoke the handler when the -exception is an instance of the given class is raised. The next example -provides a custom response for division by zero errors:: +exception class as an argument. Microdot will invoke the handler when an +unhandled exception that is an instance of the given class is raised. The next +example provides a custom response for division by zero errors:: @app.errorhandler(ZeroDivisionError) - def division_by_zero(request, exception): + async def division_by_zero(request, exception): return {'error': 'division by zero'}, 500 When the raised exception class does not have an error handler defined, but -one or more of its base classes do, Microdot makes an attempt to invoke the +one or more of its parent classes do, Microdot makes an attempt to invoke the most specific handler. Mounting a Sub-Application ^^^^^^^^^^^^^^^^^^^^^^^^^^ -Small Microdot applications can be written an a single source file, but this -is not the best option for applications that past certain size. To make it +Small Microdot applications can be written as a single source file, but this +is not the best option for applications that past a certain size. To make it simpler to write large applications, Microdot supports the concept of sub-applications that can be "mounted" on a larger application, possibly with -a common URL prefix applied to all of its routes. +a common URL prefix applied to all of its routes. For developers familiar with +the Flask framework, this is a similar concept to Flask's blueprints. Consider, for example, a *customers.py* sub-application that implements operations on customers:: @@ -357,14 +433,14 @@ operations on customers:: customers_app = Microdot() @customers_app.get('/') - def get_customers(request): + async def get_customers(request): # return all customers @customers_app.post('/') - def new_customer(request): + async def new_customer(request): # create a new customer -In the same way, the *orders.py* sub-application implements operations on +Similar to the above, the *orders.py* sub-application implements operations on customer orders:: from microdot import Microdot @@ -372,21 +448,21 @@ customer orders:: orders_app = Microdot() @orders_app.get('/') - def get_orders(request): + async def get_orders(request): # return all orders @orders_app.post('/') - def new_order(request): + async def new_order(request): # create a new order Now the main application, which is stored in *main.py*, can import and mount -the sub-applications to build the combined application:: +the sub-applications to build the larger combined application:: from microdot import Microdot from customers import customers_app from orders import orders_app - def create_app(): + async def create_app(): app = Microdot() app.mount(customers_app, url_prefix='/customers') app.mount(orders_app, url_prefix='/orders') @@ -399,7 +475,7 @@ The resulting application will have the customer endpoints available at */customers/* and the order endpoints available at */orders/*. .. note:: - Before request, after request and error handlers defined in the + Before-request, after-request and error handlers defined in the sub-application are also copied over to the main application at mount time. Once installed in the main application, these handlers will apply to the whole application and not just the sub-application in which they were @@ -416,16 +492,20 @@ during the handling of a route to gracefully shut down the server when that request completes. The next example shows how to use this feature:: @app.get('/shutdown') - def shutdown(request): + async def shutdown(request): request.app.shutdown() return 'The server is shutting down...' +The request that invokes the ``shutdown()`` method will complete, and then the +server will not accept any new requests and stop once any remaining requests +complete. At this point the ``app.run()`` call will return. + The Request Object ~~~~~~~~~~~~~~~~~~ The :class:`Request ` object encapsulates all the information passed by the client. It is passed as an argument to route handlers, as well as -to before request, after request and error handlers. +to before-request, after-request and error handlers. Request Attributes ^^^^^^^^^^^^^^^^^^ @@ -448,6 +528,9 @@ The request object provides access to the request attributes, including: the client, as a tuple (host, port). - :attr:`app `: The application instance that created the request. +- :attr:`g `: The ``g`` object, where handlers can store + request-specific data to be shared among handlers. See :ref:`The "g" Object` + for details. JSON Payloads ^^^^^^^^^^^^^ @@ -458,7 +541,7 @@ application can access the parsed JSON data using the to use this attribute:: @app.post('/customers') - def create_customer(request): + async def create_customer(request): customer = request.json # do something with customer return {'success': True} @@ -475,7 +558,7 @@ The request object also supports standard HTML form submissions through the as a :class:`MultiDict ` object. Example:: @app.route('/', methods=['GET', 'POST']) - def index(req): + async def index(req): name = 'Unknown' if req.method == 'POST': name = req.form.get('name') @@ -493,9 +576,12 @@ For cases in which neither JSON nor form data is expected, the :attr:`body ` request attribute returns the entire body of the request as a byte sequence. -If the expected body is too large to fit in memory, the application can use the -:attr:`stream ` request attribute to read the body -contents as a file-like object. +If the expected body is too large to fit safely in memory, the application can +use the :attr:`stream ` request attribute to read the +body contents as a file-like object. The +:attr:`max_body_length ` attribute of the +request object defines the size at which bodies are streamed instead of loaded +into memory. Cookies ^^^^^^^ @@ -508,41 +594,40 @@ The "g" Object ^^^^^^^^^^^^^^ Sometimes applications need to store data during the lifetime of a request, so -that it can be shared between the before or after request handlers and the -route function. The request object provides the :attr:`g ` -attribute for that purpose. +that it can be shared between the before- and after-request handlers, the +route function and any error handlers. The request object provides the +:attr:`g ` attribute for that purpose. -In the following example, a before request handler -authorizes the client and stores the username so that the route function can -use it:: +In the following example, a before request handler authorizes the client and +stores the username so that the route function can use it:: @app.before_request - def authorize(request): + async def authorize(request): username = authenticate_user(request) if not username: return 'Unauthorized', 401 request.g.username = username @app.get('/') - def index(request): + async def index(request): return f'Hello, {request.g.username}!' -Request-Specific After Request Handlers +Request-Specific After-Request Handlers ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Sometimes applications need to perform operations on the response object, +Sometimes applications need to perform operations on the response object before it is sent to the client, for example to set or remove a cookie. A good -option to use for this is to define a request-specific after request handler +option to use for this is to define a request-specific after-request handler using the :func:`after_request ` decorator. -Request-specific after request handlers are called by Microdot after the route -function returns and all the application's after request handlers have been +Request-specific after-request handlers are called by Microdot after the route +function returns and all the application-wide after-request handlers have been called. The next example shows how a cookie can be updated using a request-specific -after request handler defined inside a route function:: +after-request handler defined inside a route function:: @app.post('/logout') - def logout(request): + async def logout(request): @request.after_request def reset_session(request, response): response.set_cookie('session', '', http_only=True) @@ -556,22 +641,24 @@ Request Limits To help prevent malicious attacks, Microdot provides some configuration options to limit the amount of information that is accepted: -- :attr:`max_content_length `: The +- :attr:`max_content_length `: The maximum size accepted for the request body, in bytes. When a client sends a request that is larger than this, the server will respond with a 413 error. The default is 16KB. -- :attr:`max_body_length `: The maximum +- :attr:`max_body_length `: The maximum size that is loaded in the :attr:`body ` attribute, in bytes. Requests that have a body that is larger than this size but smaller than the size set for ``max_content_length`` can only be accessed through the :attr:`stream ` attribute. The default is also 16KB. -- :attr:`max_readline `: The maximum allowed +- :attr:`max_readline `: The maximum allowed size for a request line, in bytes. The default is 2KB. The following example configures the application to accept requests with -payloads up to 1MB big, but prevents requests that are larger than 8KB from +payloads up to 1MB in size, but prevents requests that are larger than 8KB from being loaded into memory:: + from microdot import Request + Request.max_content_length = 1024 * 1024 Request.max_body_length = 8 * 1024 @@ -589,25 +676,26 @@ Route functions can return one, two or three values. The first or only value is always returned to the client in the response body:: @app.get('/') - def index(request): + async def index(request): return 'Hello, World!' In the above example, Microdot issues a standard 200 status code response, and -inserts the necessary headers. +inserts default headers. The application can provide its own status code as a second value returned from -the route. The example below returns a 202 status code:: +the route to override the 200 default. The example below returns a 202 status +code:: @app.get('/') - def index(request): + async def index(request): return 'Hello, World!', 202 The application can also return a third value, a dictionary with additional -headers that are added to, or replace the default ones provided by Microdot. +headers that are added to, or replace the default ones included by Microdot. The next example returns an HTML response, instead of a default text response:: @app.get('/') - def index(request): + async def index(request): return '

Hello, World!

', 202, {'Content-Type': 'text/html'} If the application needs to return custom headers, but does not need to change @@ -615,7 +703,7 @@ the default status code, then it can return two values, omitting the status code:: @app.get('/') - def index(request): + async def index(request): return '

Hello, World!

', {'Content-Type': 'text/html'} The application can also return a :class:`Response ` object @@ -631,7 +719,7 @@ automatically format the response as JSON. Example:: @app.get('/') - def index(request): + async def index(request): return {'hello': 'world'} .. note:: @@ -647,7 +735,7 @@ creates redirect responses:: from microdot import redirect @app.get('/') - def index(request): + async def index(request): return redirect('/about') File Responses @@ -659,7 +747,7 @@ object for a file:: from microdot import send_file @app.get('/') - def index(request): + async def index(request): return send_file('/static/index.html') A suggested caching duration can be returned to the client in the ``max_age`` @@ -668,7 +756,7 @@ argument:: from microdot import send_file @app.get('/') - def image(request): + async def image(request): return send_file('/static/image.jpg', max_age=3600) # in seconds .. note:: @@ -678,7 +766,7 @@ argument:: the project:: @app.route('/static/') - def static(request, path): + async def static(request, path): if '..' in path: # directory traversal is not allowed return 'Not found', 404 @@ -688,12 +776,12 @@ Streaming Responses ^^^^^^^^^^^^^^^^^^^ Instead of providing a response as a single value, an application can opt to -return a response that is generated in chunks by returning a generator. The -example below returns all the numbers in the fibonacci sequence below 100:: +return a response that is generated in chunks, by returning a Python generator. +The example below returns all the numbers in the fibonacci sequence below 100:: @app.get('/fibonacci') - def fibonacci(request): - def generate_fibonacci(): + async def fibonacci(request): + async def generate_fibonacci(): a, b = 0, 1 while a < 100: yield str(a) + '\n' @@ -701,6 +789,14 @@ example below returns all the numbers in the fibonacci sequence below 100:: return generate_fibonacci() +.. note:: + Under CPython, the generator function can be a ``def`` or ``async def`` + function, as well as a class-based generator. + + Under MicroPython, asynchronous generator functions are not supported, so + only ``def`` generator functions can be used. Asynchronous class-based + generators are supported. + Changing the Default Response Content Type ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -728,14 +824,14 @@ object to add a properly formatted cookie header to the response. Given that route functions do not normally work directly with the response object, the recommended way to set a cookie is to do it in a -:ref:`Request-Specific After Request Handler `. +:ref:`request-specific after-request handler `. Example:: @app.get('/') - def index(request): + async def index(request): @request.after_request - def set_cookie(request, response): + async def set_cookie(request, response): response.set_cookie('name', 'value') return response @@ -744,7 +840,7 @@ Example:: Another option is to create a response object directly in the route function:: @app.get('/') - def index(request): + async def index(request): response = Response('Hello, World!') response.set_cookie('name', 'value') return response @@ -759,15 +855,18 @@ Another option is to create a response object directly in the route function:: Concurrency ~~~~~~~~~~~ -By default, Microdot runs in synchronous (single-threaded) mode. However, if -the ``threading`` module is available, each request will be started on a -separate thread and requests will be handled concurrently. +Microdot implements concurrency through the ``asyncio`` package. Applications +must ensure their handlers do not block, as this will prevent other concurrent +requests from being handled. -Be aware that most microcontroller boards support a very limited form of -multi-threading that is not appropriate for concurrent request handling. For -that reason, use of the `threading `_ -module on microcontroller platforms is not recommended. +When running under CPython, ``async def`` handler functions run as native +asyncio tasks, while ``def`` handler functions are executed in a +`thread executor `_ +to prevent them from blocking the asynchronous loop. -The :ref:`micropython_asyncio ` extension -provides a more robust concurrency option that is supported even on low-end -MicroPython boards. +Under MicroPython the situation is different. Most microcontroller boards +implementing MicroPython do not have threading support or executors, so ``def`` +handler functions in this platform can only run in the main and only thread. +These functions will block the asynchronous loop when they take too long to +complete so ``async def`` handlers properly written to allow other handlers to +run in parallel should be preferred. diff --git a/docs/migrating.rst b/docs/migrating.rst new file mode 100644 index 0000000..e12855a --- /dev/null +++ b/docs/migrating.rst @@ -0,0 +1,142 @@ +Migrating to Microdot 2.x from Older Releases +--------------------------------------------- + +Version 2 of Microdot incorporates feedback received from users of earlier +releases, and attempts to improve and correct some design decisions that have +proven to be problematic. + +For this reason most applications built for earlier versions will need to be +updated to work correctly with Microdot 2. This section describes the backwards +incompatible changes that were made. + +Code reorganization +~~~~~~~~~~~~~~~~~~~ + +The Microdot source code has been moved into a ``microdot`` package, +eliminating the need for each extension to be named with a *microdot_* prefix. + +As a result of this change, all extensions have been renamed to shorter names. +For example, the *microdot_cors.py* module is now called *cors.py*. + +This change affects the way extensions are imported. Instead of this:: + + from microdot_cors import CORS + +the import statement should be:: + + from microdot.cors import CORS + +No more synchronous web server +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In earlier releases of Microdot the core web server was built on synchronous +Python, and asynchronous support was enabled with the asyncio extension. + +Microdot 2 eliminates the synchronous web server, and implements the core +server logic directly with asyncio, eliminating the need for an asyncio +extension. + +Any applications built using the asyncio extension will need to update their +imports from this:: + + from microdot.asyncio import Microdot + +to this:: + + from microdot import Microdot + +Applications that were built using the synchronous web server do not need to +change their imports, but will now work asynchronously. Review the +:ref:`Concurrency` section to learn about the potential issues when using +``def`` function handlers, and the benefits of transitioning to ``async def`` +handlers. + +Removed extensions +~~~~~~~~~~~~~~~~~~ + +Some extensions became unnecessary and have been removed or merged with other +extensions: + +- *microdot_asyncio.py*: this is now the core web server. +- *microdot_asyncio_websocket.py*: this is now the main WebSocket extension. +- *microdot_asyncio_test_client.py*: this is now the main test client + extension. +- *microdot_asgi_websocket.py*: the functionality in this extension is now + available in the ASGI extension. +- *microdot_ssl.py*: this extension was only used with the synchronous web + server, so it is not needed anymore. +- *microdot_websocket_alt.py*: this extension was only used with the + synchronous web server, so it is not needed anymore. + +No more ``render_template()`` function +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The Jinja and uTemplate extensions have been redesigned to work better under +the asynchronous engine, and as a result, the ``render_template()`` function +has been eliminated. + +Instead of this:: + + return render_template('index.html', title='Home') + +use this:: + + return Template('index.html').render(title='Home') + +As a result of this change, it is now possible to use asynchronous rendering:: + + return await Template('index.html').render_async(title='Home') + +Also thanks to this redesign, the template can be streamed instead of returned +as a single string:: + + return Template('index.html').generate(title='Home') + +Streamed templates also have an asynchronous version:: + + return await Template('index.html').generate_async(title='Home') + +Class-based user sessions +~~~~~~~~~~~~~~~~~~~~~~~~~ + +The session extension has been completely redesigned. To initialize session +support for the application, create a ``Session`` object:: + + app = Microdot() + Session(app, secret_key='top-secret!') + +The ``@with_session`` decorator is used to include the session in a request:: + + @app.get('/') + @with_session + async def index(request, session): + # ... + +The ``session`` can be used as a dictionary to retrieve or change the session. +To save the session when it has been modified, call its ``save()`` method:: + + @app.get('/') + @with_session + async def index(request, session): + # ... + session.save() + return 'OK' + +To delete the session, call its ``delete()`` method before returning from the +request. + +WSGI extension redesign +~~~~~~~~~~~~~~~~~~~~~~~ + +Given that the synchronous web server has been removed, the WSGI extension has +been redesigned to work as a synchronous wrapper for the asynchronous web +server. + +Applications using the WSGI extension continue to run under an asynchronous +loop and should try to use the recommended ``async def`` handlers, but can be +deployed with standard WSGI servers such as Gunicorn. + +WebSocket support when using the WSGI extension is enabled when using a +compatible web server. At this time only Gunicorn is supported for WebSocket. + +As before, the WSGI extension is not available under MicroPython. diff --git a/examples/benchmark/mem.py b/examples/benchmark/mem.py index 2a55d60..6450fea 100644 --- a/examples/benchmark/mem.py +++ b/examples/benchmark/mem.py @@ -4,7 +4,7 @@ app = Microdot() @app.get('/') -def index(req): +async def index(req): return {'hello': 'world'} diff --git a/examples/benchmark/mem_asgi.py b/examples/benchmark/mem_asgi.py index 16261ba..fe28b3f 100644 --- a/examples/benchmark/mem_asgi.py +++ b/examples/benchmark/mem_asgi.py @@ -1,4 +1,4 @@ -from microdot_asgi import Microdot +from microdot.asgi import Microdot app = Microdot() diff --git a/examples/benchmark/mem_async.py b/examples/benchmark/mem_async.py deleted file mode 100644 index 941cfc6..0000000 --- a/examples/benchmark/mem_async.py +++ /dev/null @@ -1,11 +0,0 @@ -from microdot_asyncio import Microdot - -app = Microdot() - - -@app.get('/') -async def index(req): - return {'hello': 'world'} - - -app.run() diff --git a/examples/benchmark/mem_fastapi.py b/examples/benchmark/mem_fastapi.py index 390938e..b9b07c3 100644 --- a/examples/benchmark/mem_fastapi.py +++ b/examples/benchmark/mem_fastapi.py @@ -4,5 +4,5 @@ app = FastAPI() @app.get('/') -def index(): +async def index(): return {'hello': 'world'} diff --git a/examples/benchmark/mem_quart.py b/examples/benchmark/mem_quart.py index 22ef155..ffab880 100644 --- a/examples/benchmark/mem_quart.py +++ b/examples/benchmark/mem_quart.py @@ -4,5 +4,5 @@ app = Quart(__name__) @app.get('/') -def index(): +async def index(): return {'hello': 'world'} diff --git a/examples/benchmark/mem_wsgi.py b/examples/benchmark/mem_wsgi.py index 787712c..51dea5b 100644 --- a/examples/benchmark/mem_wsgi.py +++ b/examples/benchmark/mem_wsgi.py @@ -1,4 +1,4 @@ -from microdot_wsgi import Microdot +from microdot.wsgi import Microdot app = Microdot() diff --git a/examples/benchmark/requirements.in b/examples/benchmark/requirements.in new file mode 100644 index 0000000..7b4c3eb --- /dev/null +++ b/examples/benchmark/requirements.in @@ -0,0 +1,9 @@ +pip-tools +flask +quart +fastapi +gunicorn +uvicorn +requests +psutil +humanize diff --git a/examples/benchmark/requirements.txt b/examples/benchmark/requirements.txt index 069fc2c..f303b47 100644 --- a/examples/benchmark/requirements.txt +++ b/examples/benchmark/requirements.txt @@ -1,33 +1,115 @@ -aiofiles==0.8.0 -anyio==3.6.1 -blinker==1.5 -certifi==2023.7.22 -charset-normalizer==2.1.0 -click==8.1.3 -fastapi==0.79.0 -Flask==2.3.2 -gunicorn==20.1.0 -h11==0.13.0 +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile requirements.in +# +aiofiles==23.2.1 + # via quart +annotated-types==0.6.0 + # via pydantic +anyio==3.7.1 + # via + # fastapi + # starlette +blinker==1.7.0 + # via + # flask + # quart +build==1.0.3 + # via pip-tools +certifi==2023.11.17 + # via requests +charset-normalizer==3.3.2 + # via requests +click==8.1.7 + # via + # flask + # pip-tools + # quart + # uvicorn +fastapi==0.104.1 + # via -r requirements.in +flask==3.0.0 + # via + # -r requirements.in + # quart +gunicorn==21.2.0 + # via -r requirements.in +h11==0.14.0 + # via + # hypercorn + # uvicorn + # wsproto h2==4.1.0 + # via hypercorn hpack==4.0.0 -humanize==4.3.0 -hypercorn==0.13.2 + # via h2 +humanize==4.9.0 + # via -r requirements.in +hypercorn==0.15.0 + # via quart hyperframe==6.0.1 -idna==3.3 + # via h2 +idna==3.6 + # via + # anyio + # requests itsdangerous==2.1.2 -Jinja2==3.1.2 -MarkupSafe==2.1.1 -microdot + # via + # flask + # quart +jinja2==3.1.2 + # via + # flask + # quart +markupsafe==2.1.3 + # via + # jinja2 + # quart + # werkzeug +packaging==23.2 + # via + # build + # gunicorn +pip-tools==7.3.0 + # via -r requirements.in priority==2.0.0 -psutil==5.9.1 -pydantic==1.9.1 -quart==0.18.0 + # via hypercorn +psutil==5.9.6 + # via -r requirements.in +pydantic==2.5.2 + # via fastapi +pydantic-core==2.14.5 + # via pydantic +pyproject-hooks==1.0.0 + # via build +quart==0.19.4 + # via -r requirements.in requests==2.31.0 -sniffio==1.2.0 + # via -r requirements.in +sniffio==1.3.0 + # via anyio starlette==0.27.0 -toml==0.10.2 -typing_extensions==4.3.0 -urllib3==1.26.18 -uvicorn==0.18.2 -Werkzeug==2.2.3 -wsproto==1.1.0 + # via fastapi +typing-extensions==4.9.0 + # via + # fastapi + # pydantic + # pydantic-core +urllib3==2.1.0 + # via requests +uvicorn==0.24.0.post1 + # via -r requirements.in +werkzeug==3.0.1 + # via + # flask + # quart +wheel==0.42.0 + # via pip-tools +wsproto==1.2.0 + # via hypercorn + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/examples/benchmark/run.py b/examples/benchmark/run.py index de4099b..c1dc6b6 100644 --- a/examples/benchmark/run.py +++ b/examples/benchmark/run.py @@ -14,13 +14,8 @@ apps = [ ), ( 'micropython mem.py', - {'MICROPYPATH': '../../src'}, - 'microdot-micropython-sync' - ), - ( - 'micropython mem_async.py', {'MICROPYPATH': '../../src:../../libs/micropython'}, - 'microdot-micropython-async' + 'microdot-micropython' ), ( ['python', '-c', 'import time; time.sleep(10)'], @@ -30,47 +25,42 @@ apps = [ ( 'python mem.py', {'PYTHONPATH': '../../src'}, - 'microdot-cpython-sync' - ), - ( - 'python mem_async.py', - {'PYTHONPATH': '../../src'}, - 'microdot-cpython-async' - ), - ( - 'gunicorn --workers 1 --bind :5000 mem_wsgi:app', - {'PYTHONPATH': '../../src'}, - 'microdot-gunicorn-sync' + 'microdot-cpython' ), ( 'uvicorn --workers 1 --port 5000 mem_asgi:app', {'PYTHONPATH': '../../src'}, - 'microdot-uvicorn-async' + 'microdot-uvicorn' + ), + ( + 'gunicorn --workers 1 --bind :5000 mem_wsgi:app', + {'PYTHONPATH': '../../src'}, + 'microdot-gunicorn' ), ( 'flask run', {'FLASK_APP': 'mem_flask.py'}, - 'flask-run-sync' + 'flask-run' ), ( 'quart run', {'QUART_APP': 'mem_quart.py'}, - 'quart-run-async' + 'quart-run' ), ( 'gunicorn --workers 1 --bind :5000 mem_flask:app', {}, - 'flask-gunicorn-sync' + 'flask-gunicorn' ), ( 'uvicorn --workers 1 --port 5000 mem_quart:app', {}, - 'quart-uvicorn-async' + 'quart-uvicorn' ), ( 'uvicorn --workers 1 --port 5000 mem_fastapi:app', {}, - 'fastapi-uvicorn-async' + 'fastapi-uvicorn' ), ] diff --git a/examples/cors/app.py b/examples/cors/app.py index ffd168e..d2b9078 100644 --- a/examples/cors/app.py +++ b/examples/cors/app.py @@ -1,5 +1,5 @@ from microdot import Microdot -from microdot_cors import CORS +from microdot.cors import CORS app = Microdot() CORS(app, allowed_origins=['https://example.org'], allow_credentials=True) diff --git a/examples/hello/hello.py b/examples/hello/hello.py index 9dcdea7..191629e 100644 --- a/examples/hello/hello.py +++ b/examples/hello/hello.py @@ -2,7 +2,7 @@ from microdot import Microdot app = Microdot() -htmldoc = ''' +html = ''' Microdot Example Page @@ -20,12 +20,12 @@ htmldoc = ''' @app.route('/') -def hello(request): - return htmldoc, 200, {'Content-Type': 'text/html'} +async def hello(request): + return html, 200, {'Content-Type': 'text/html'} @app.route('/shutdown') -def shutdown(request): +async def shutdown(request): request.app.shutdown() return 'The server is shutting down...' diff --git a/examples/hello/hello_asgi.py b/examples/hello/hello_asgi.py index 5ddb682..a059e99 100644 --- a/examples/hello/hello_asgi.py +++ b/examples/hello/hello_asgi.py @@ -1,8 +1,8 @@ -from microdot_asgi import Microdot +from microdot.asgi import Microdot app = Microdot() -htmldoc = ''' +html = ''' Microdot Example Page @@ -21,7 +21,7 @@ htmldoc = ''' @app.route('/') async def hello(request): - return htmldoc, 200, {'Content-Type': 'text/html'} + return html, 200, {'Content-Type': 'text/html'} @app.route('/shutdown') diff --git a/examples/hello/hello_async.py b/examples/hello/hello_async.py deleted file mode 100644 index 01c37a8..0000000 --- a/examples/hello/hello_async.py +++ /dev/null @@ -1,33 +0,0 @@ -from microdot_asyncio import Microdot - -app = Microdot() - -htmldoc = ''' - - - Microdot Example Page - - - -
-

Microdot Example Page

-

Hello from Microdot!

-

Click to shutdown the server

-
- - -''' - - -@app.route('/') -async def hello(request): - return htmldoc, 200, {'Content-Type': 'text/html'} - - -@app.route('/shutdown') -async def shutdown(request): - request.app.shutdown() - return 'The server is shutting down...' - - -app.run(debug=True) diff --git a/examples/hello/hello_wsgi.py b/examples/hello/hello_wsgi.py index 1ce4617..336b1ce 100644 --- a/examples/hello/hello_wsgi.py +++ b/examples/hello/hello_wsgi.py @@ -1,8 +1,8 @@ -from microdot_wsgi import Microdot +from microdot.wsgi import Microdot app = Microdot() -htmldoc = ''' +html = ''' Microdot Example Page @@ -21,7 +21,7 @@ htmldoc = ''' @app.route('/') def hello(request): - return htmldoc, 200, {'Content-Type': 'text/html'} + return html, 200, {'Content-Type': 'text/html'} @app.route('/shutdown') diff --git a/examples/sessions/login.py b/examples/sessions/login.py index 739a574..e7fd2ef 100644 --- a/examples/sessions/login.py +++ b/examples/sessions/login.py @@ -1,6 +1,5 @@ from microdot import Microdot, Response, redirect -from microdot_session import set_session_secret_key, with_session, \ - update_session, delete_session +from microdot.session import Session, with_session BASE_TEMPLATE = ''' @@ -29,18 +28,19 @@ LOGGED_IN = '''

Hello {username}!

''' app = Microdot() -set_session_secret_key('top-secret') +Session(app, secret_key='top-secret') Response.default_content_type = 'text/html' @app.get('/') @app.post('/') @with_session -def index(req, session): +async def index(req, session): username = session.get('username') if req.method == 'POST': username = req.form.get('username') - update_session(req, {'username': username}) + session['username'] = username + session.save() return redirect('/') if username is None: return BASE_TEMPLATE.format(content=LOGGED_OUT) @@ -50,8 +50,9 @@ def index(req, session): @app.post('/logout') -def logout(req): - delete_session(req) +@with_session +async def logout(req, session): + session.delete() return redirect('/') diff --git a/examples/sse/counter.py b/examples/sse/counter.py new file mode 100644 index 0000000..36f4bec --- /dev/null +++ b/examples/sse/counter.py @@ -0,0 +1,16 @@ +import asyncio +from microdot import Microdot +from microdot.sse import with_sse + +app = Microdot() + + +@app.route('/events') +@with_sse +async def events(request, sse): + for i in range(10): + await asyncio.sleep(1) + await sse.send({'counter': i}) + + +app.run(debug=True) diff --git a/examples/static/static.py b/examples/static/static.py index 9292cb5..5b2399d 100644 --- a/examples/static/static.py +++ b/examples/static/static.py @@ -1,15 +1,14 @@ from microdot import Microdot, send_file - app = Microdot() @app.route('/') -def index(request): +async def index(request): return send_file('static/index.html') @app.route('/static/') -def static(request, path): +async def static(request, path): if '..' in path: # directory traversal is not allowed return 'Not found', 404 diff --git a/examples/static/static/index.css b/examples/static/static/index.css new file mode 100644 index 0000000..f379934 --- /dev/null +++ b/examples/static/static/index.css @@ -0,0 +1,10 @@ +p { + font-family: Arial, Helvetica, sans-serif; + color: #333333; +} + +h1 { + font-family: Arial, Helvetica, sans-serif; + color: #3070b3; + text-align: center; +} diff --git a/examples/static/static_async.py b/examples/static/static_async.py deleted file mode 100644 index 6c9c0d5..0000000 --- a/examples/static/static_async.py +++ /dev/null @@ -1,18 +0,0 @@ -from microdot_asyncio import Microdot, send_file -app = Microdot() - - -@app.route('/') -async def index(request): - return send_file('static/index.html') - - -@app.route('/static/') -async def static(request, path): - if '..' in path: - # directory traversal is not allowed - return 'Not found', 404 - return send_file('static/' + path) - - -app.run(debug=True) diff --git a/examples/streaming/video_stream.py b/examples/streaming/video_stream.py index 5e06577..856b152 100644 --- a/examples/streaming/video_stream.py +++ b/examples/streaming/video_stream.py @@ -1,8 +1,5 @@ -try: - import utime as time -except ImportError: - import time - +import sys +import asyncio from microdot import Microdot app = Microdot() @@ -14,7 +11,7 @@ for file in ['1.jpg', '2.jpg', '3.jpg']: @app.route('/') -def index(request): +async def index(request): return ''' @@ -29,14 +26,38 @@ def index(request): @app.route('/video_feed') -def video_feed(request): - def stream(): - yield b'--frame\r\n' - while True: - for frame in frames: - yield b'Content-Type: image/jpeg\r\n\r\n' + frame + \ - b'\r\n--frame\r\n' - time.sleep(1) +async def video_feed(request): + print('Starting video stream.') + + if sys.implementation.name != 'micropython': + # CPython supports async generator function + async def stream(): + try: + yield b'--frame\r\n' + while True: + for frame in frames: + yield b'Content-Type: image/jpeg\r\n\r\n' + frame + \ + b'\r\n--frame\r\n' + await asyncio.sleep(1) + except GeneratorExit: + print('Stopping video stream.') + else: + # MicroPython can only use class-based async generators + class stream(): + def __init__(self): + self.i = 0 + + def __aiter__(self): + return self + + async def __anext__(self): + await asyncio.sleep(1) + self.i = (self.i + 1) % len(frames) + return b'Content-Type: image/jpeg\r\n\r\n' + \ + frames[self.i] + b'\r\n--frame\r\n' + + async def aclose(self): + print('Stopping video stream.') return stream(), 200, {'Content-Type': 'multipart/x-mixed-replace; boundary=frame'} diff --git a/examples/streaming/video_stream_async.py b/examples/streaming/video_stream_async.py deleted file mode 100644 index 82e77c6..0000000 --- a/examples/streaming/video_stream_async.py +++ /dev/null @@ -1,65 +0,0 @@ -import sys - -try: - import uasyncio as asyncio -except ImportError: - import asyncio - -from microdot_asyncio import Microdot - -app = Microdot() - -frames = [] -for file in ['1.jpg', '2.jpg', '3.jpg']: - with open(file, 'rb') as f: - frames.append(f.read()) - - -@app.route('/') -def index(request): - return ''' - - - Microdot Video Streaming - - - -

Microdot Video Streaming

- - -''', 200, {'Content-Type': 'text/html'} - - -@app.route('/video_feed') -async def video_feed(request): - if sys.implementation.name != 'micropython': - # CPython supports yielding async generators - async def stream(): - yield b'--frame\r\n' - while True: - for frame in frames: - yield b'Content-Type: image/jpeg\r\n\r\n' + frame + \ - b'\r\n--frame\r\n' - await asyncio.sleep(1) - - else: - # MicroPython can only use class-based async generators - class stream(): - def __init__(self): - self.i = 0 - - def __aiter__(self): - return self - - async def __anext__(self): - await asyncio.sleep(1) - self.i = (self.i + 1) % len(frames) - return b'Content-Type: image/jpeg\r\n\r\n' + \ - frames[self.i] + b'\r\n--frame\r\n' - - return stream(), 200, {'Content-Type': - 'multipart/x-mixed-replace; boundary=frame'} - - -if __name__ == '__main__': - app.run(debug=True) diff --git a/examples/templates/jinja/async_template.py b/examples/templates/jinja/async_template.py new file mode 100644 index 0000000..842adb2 --- /dev/null +++ b/examples/templates/jinja/async_template.py @@ -0,0 +1,18 @@ +from microdot import Microdot, Response +from microdot.jinja import template, init_templates + +init_templates('templates', enable_async=True) +app = Microdot() +Response.default_content_type = 'text/html' + + +@app.route('/', methods=['GET', 'POST']) +async def index(req): + name = None + if req.method == 'POST': + name = req.form.get('name') + return await template('index.html').render_async(name=name) + + +if __name__ == '__main__': + app.run() diff --git a/examples/templates/jinja/bootstrap.py b/examples/templates/jinja/bootstrap.py index 7a5b65e..a3005e0 100644 --- a/examples/templates/jinja/bootstrap.py +++ b/examples/templates/jinja/bootstrap.py @@ -1,18 +1,18 @@ from microdot import Microdot, Response -from microdot_jinja import render_template +from microdot.jinja import template app = Microdot() Response.default_content_type = 'text/html' @app.route('/') -def index(req): - return render_template('page1.html', page='Page 1') +async def index(req): + return template('page1.html').render(page='Page 1') @app.route('/page2') -def page2(req): - return render_template('page2.html', page='Page 2') +async def page2(req): + return template('page2.html').render(page='Page 2') if __name__ == '__main__': diff --git a/examples/templates/jinja/hello.py b/examples/templates/jinja/hello.py index 8dcfb45..3fc5d98 100644 --- a/examples/templates/jinja/hello.py +++ b/examples/templates/jinja/hello.py @@ -1,16 +1,16 @@ from microdot import Microdot, Response -from microdot_jinja import render_template +from microdot.jinja import template app = Microdot() Response.default_content_type = 'text/html' @app.route('/', methods=['GET', 'POST']) -def index(req): +async def index(req): name = None if req.method == 'POST': name = req.form.get('name') - return render_template('index.html', name=name) + return template('index.html').render(name=name) if __name__ == '__main__': diff --git a/examples/hello/hello_utemplate_async.py b/examples/templates/jinja/hello_asgi.py similarity index 63% rename from examples/hello/hello_utemplate_async.py rename to examples/templates/jinja/hello_asgi.py index b4ca0c8..6057880 100644 --- a/examples/hello/hello_utemplate_async.py +++ b/examples/templates/jinja/hello_asgi.py @@ -1,5 +1,5 @@ -from microdot_asyncio import Microdot, Response -from microdot_utemplate import render_template +from microdot.asgi import Microdot, Response +from microdot.jinja import template app = Microdot() Response.default_content_type = 'text/html' @@ -10,7 +10,7 @@ async def index(req): name = None if req.method == 'POST': name = req.form.get('name') - return render_template('index.html', name=name) + return template('index.html').render(name=name) if __name__ == '__main__': diff --git a/examples/templates/jinja/hello_wsgi.py b/examples/templates/jinja/hello_wsgi.py new file mode 100644 index 0000000..a38fca8 --- /dev/null +++ b/examples/templates/jinja/hello_wsgi.py @@ -0,0 +1,17 @@ +from microdot.wsgi import Microdot, Response +from microdot.jinja import template + +app = Microdot() +Response.default_content_type = 'text/html' + + +@app.route('/', methods=['GET', 'POST']) +async def index(req): + name = None + if req.method == 'POST': + name = req.form.get('name') + return template('index.html').render(name=name) + + +if __name__ == '__main__': + app.run() diff --git a/examples/templates/jinja/streaming.py b/examples/templates/jinja/streaming.py new file mode 100644 index 0000000..b066a94 --- /dev/null +++ b/examples/templates/jinja/streaming.py @@ -0,0 +1,17 @@ +from microdot import Microdot, Response +from microdot.jinja import template + +app = Microdot() +Response.default_content_type = 'text/html' + + +@app.route('/', methods=['GET', 'POST']) +async def index(req): + name = None + if req.method == 'POST': + name = req.form.get('name') + return template('index.html').generate(name=name) + + +if __name__ == '__main__': + app.run() diff --git a/examples/templates/utemplate/async_template.py b/examples/templates/utemplate/async_template.py new file mode 100644 index 0000000..df0f7e3 --- /dev/null +++ b/examples/templates/utemplate/async_template.py @@ -0,0 +1,17 @@ +from microdot import Microdot, Response +from microdot.utemplate import template + +app = Microdot() +Response.default_content_type = 'text/html' + + +@app.route('/', methods=['GET', 'POST']) +async def index(req): + name = None + if req.method == 'POST': + name = req.form.get('name') + return await template('index.html').render_async(name=name) + + +if __name__ == '__main__': + app.run() diff --git a/examples/templates/utemplate/bootstrap.py b/examples/templates/utemplate/bootstrap.py index df21500..ca798c9 100644 --- a/examples/templates/utemplate/bootstrap.py +++ b/examples/templates/utemplate/bootstrap.py @@ -1,18 +1,18 @@ from microdot import Microdot, Response -from microdot_utemplate import render_template +from microdot.utemplate import template app = Microdot() Response.default_content_type = 'text/html' @app.route('/') -def index(req): - return render_template('page1.html', page='Page 1') +async def index(req): + return template('page1.html').render(page='Page 1') @app.route('/page2') -def page2(req): - return render_template('page2.html', page='Page 2') +async def page2(req): + return template('page2.html').render(page='Page 2') if __name__ == '__main__': diff --git a/examples/templates/utemplate/hello.py b/examples/templates/utemplate/hello.py index 8c1a256..7615dda 100644 --- a/examples/templates/utemplate/hello.py +++ b/examples/templates/utemplate/hello.py @@ -1,16 +1,16 @@ from microdot import Microdot, Response -from microdot_utemplate import render_template +from microdot.utemplate import template app = Microdot() Response.default_content_type = 'text/html' @app.route('/', methods=['GET', 'POST']) -def index(req): +async def index(req): name = None if req.method == 'POST': name = req.form.get('name') - return render_template('index.html', name=name) + return template('index.html').render(name=name) if __name__ == '__main__': diff --git a/examples/templates/utemplate/hello_asgi.py b/examples/templates/utemplate/hello_asgi.py new file mode 100644 index 0000000..ef901a3 --- /dev/null +++ b/examples/templates/utemplate/hello_asgi.py @@ -0,0 +1,17 @@ +from microdot.asgi import Microdot, Response +from microdot.utemplate import template + +app = Microdot() +Response.default_content_type = 'text/html' + + +@app.route('/', methods=['GET', 'POST']) +async def index(req): + name = None + if req.method == 'POST': + name = req.form.get('name') + return template('index.html').render(name=name) + + +if __name__ == '__main__': + app.run() diff --git a/examples/templates/utemplate/hello_wsgi.py b/examples/templates/utemplate/hello_wsgi.py new file mode 100644 index 0000000..97646fc --- /dev/null +++ b/examples/templates/utemplate/hello_wsgi.py @@ -0,0 +1,17 @@ +from microdot.wsgi import Microdot, Response +from microdot.utemplate import template + +app = Microdot() +Response.default_content_type = 'text/html' + + +@app.route('/', methods=['GET', 'POST']) +async def index(req): + name = None + if req.method == 'POST': + name = req.form.get('name') + return template('index.html').render(name=name) + + +if __name__ == '__main__': + app.run() diff --git a/examples/templates/utemplate/streaming.py b/examples/templates/utemplate/streaming.py new file mode 100644 index 0000000..6595c62 --- /dev/null +++ b/examples/templates/utemplate/streaming.py @@ -0,0 +1,17 @@ +from microdot import Microdot, Response +from microdot.utemplate import template + +app = Microdot() +Response.default_content_type = 'text/html' + + +@app.route('/', methods=['GET', 'POST']) +async def index(req): + name = None + if req.method == 'POST': + name = req.form.get('name') + return template('index.html').generate(name=name) + + +if __name__ == '__main__': + app.run() diff --git a/examples/tls/echo_async_tls.py b/examples/tls/echo_async_tls.py deleted file mode 100644 index 640e98b..0000000 --- a/examples/tls/echo_async_tls.py +++ /dev/null @@ -1,23 +0,0 @@ -import ssl -from microdot_asyncio import Microdot, send_file -from microdot_asyncio_websocket import with_websocket - -app = Microdot() - - -@app.route('/') -def index(request): - return send_file('index.html') - - -@app.route('/echo') -@with_websocket -async def echo(request, ws): - while True: - data = await ws.receive() - await ws.send(data) - - -sslctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -sslctx.load_cert_chain('cert.pem', 'key.pem') -app.run(port=4443, debug=True, ssl=sslctx) diff --git a/examples/tls/echo_tls.py b/examples/tls/echo_tls.py deleted file mode 100644 index 7ae56cb..0000000 --- a/examples/tls/echo_tls.py +++ /dev/null @@ -1,24 +0,0 @@ -import sys -from microdot import Microdot, send_file -from microdot_websocket import with_websocket -from microdot_ssl import create_ssl_context - -app = Microdot() - - -@app.route('/') -def index(request): - return send_file('index.html') - - -@app.route('/echo') -@with_websocket -def echo(request, ws): - while True: - data = ws.receive() - ws.send(data) - - -ext = 'der' if sys.implementation.name == 'micropython' else 'pem' -sslctx = create_ssl_context('cert.' + ext, 'key.' + ext) -app.run(port=4443, debug=True, ssl=sslctx) diff --git a/examples/tls/hello_async_tls.py b/examples/tls/hello.py similarity index 84% rename from examples/tls/hello_async_tls.py rename to examples/tls/hello.py index 333e006..631e2dc 100644 --- a/examples/tls/hello_async_tls.py +++ b/examples/tls/hello.py @@ -1,9 +1,9 @@ import ssl -from microdot_asyncio import Microdot +from microdot import Microdot app = Microdot() -htmldoc = ''' +html = ''' Microdot Example Page @@ -22,7 +22,7 @@ htmldoc = ''' @app.route('/') async def hello(request): - return htmldoc, 200, {'Content-Type': 'text/html'} + return html, 200, {'Content-Type': 'text/html'} @app.route('/shutdown') diff --git a/examples/tls/hello_tls.py b/examples/tls/hello_tls.py deleted file mode 100644 index 61c3c6f..0000000 --- a/examples/tls/hello_tls.py +++ /dev/null @@ -1,37 +0,0 @@ -import sys -from microdot import Microdot -from microdot_ssl import create_ssl_context - -app = Microdot() - -htmldoc = ''' - - - Microdot Example Page - - - -
-

Microdot Example Page

-

Hello from Microdot!

-

Click to shutdown the server

-
- - -''' - - -@app.route('/') -def hello(request): - return htmldoc, 200, {'Content-Type': 'text/html'} - - -@app.route('/shutdown') -def shutdown(request): - request.app.shutdown() - return 'The server is shutting down...' - - -ext = 'der' if sys.implementation.name == 'micropython' else 'pem' -sslctx = create_ssl_context('cert.' + ext, 'key.' + ext) -app.run(port=4443, debug=True, ssl=sslctx) diff --git a/examples/tls/index.html b/examples/tls/index.html deleted file mode 100644 index 1ca3eef..0000000 --- a/examples/tls/index.html +++ /dev/null @@ -1,36 +0,0 @@ - - - - Microdot TLS WebSocket Demo - - - -

Microdot TLS WebSocket Demo

-
-
-
- - -
- - - diff --git a/examples/uploads/uploads.py b/examples/uploads/uploads.py index 5c1b339..37648e9 100644 --- a/examples/uploads/uploads.py +++ b/examples/uploads/uploads.py @@ -5,12 +5,12 @@ Request.max_content_length = 1024 * 1024 # 1MB (change as needed) @app.get('/') -def index(request): +async def index(request): return send_file('index.html') @app.post('/upload') -def upload(request): +async def upload(request): # obtain the filename and size from request headers filename = request.headers['Content-Disposition'].split( 'filename=')[1].strip('"') @@ -22,7 +22,7 @@ def upload(request): # write the file to the files directory in 1K chunks with open('files/' + filename, 'wb') as f: while size > 0: - chunk = request.stream.read(min(size, 1024)) + chunk = await request.stream.read(min(size, 1024)) f.write(chunk) size -= len(chunk) diff --git a/examples/uploads/uploads_async.py b/examples/uploads/uploads_async.py deleted file mode 100644 index f4bc639..0000000 --- a/examples/uploads/uploads_async.py +++ /dev/null @@ -1,34 +0,0 @@ -from microdot_asyncio import Microdot, send_file, Request - -app = Microdot() -Request.max_content_length = 1024 * 1024 # 1MB (change as needed) - - -@app.get('/') -async def index(request): - return send_file('index.html') - - -@app.post('/upload') -async def upload(request): - # obtain the filename and size from request headers - filename = request.headers['Content-Disposition'].split( - 'filename=')[1].strip('"') - size = int(request.headers['Content-Length']) - - # sanitize the filename - filename = filename.replace('/', '_') - - # write the file to the files directory in 1K chunks - with open('files/' + filename, 'wb') as f: - while size > 0: - chunk = await request.stream.read(min(size, 1024)) - f.write(chunk) - size -= len(chunk) - - print('Successfully saved file: ' + filename) - return '' - - -if __name__ == '__main__': - app.run(debug=True) diff --git a/examples/websocket/echo.py b/examples/websocket/echo.py index 7bb67cb..0bd4f1c 100644 --- a/examples/websocket/echo.py +++ b/examples/websocket/echo.py @@ -1,20 +1,20 @@ from microdot import Microdot, send_file -from microdot_websocket import with_websocket +from microdot.websocket import with_websocket app = Microdot() @app.route('/') -def index(request): +async def index(request): return send_file('index.html') @app.route('/echo') @with_websocket -def echo(request, ws): +async def echo(request, ws): while True: - data = ws.receive() - ws.send(data) + data = await ws.receive() + await ws.send(data) app.run() diff --git a/examples/websocket/echo_asgi.py b/examples/websocket/echo_asgi.py index b322803..cdee273 100644 --- a/examples/websocket/echo_asgi.py +++ b/examples/websocket/echo_asgi.py @@ -1,11 +1,10 @@ -from microdot_asgi import Microdot, send_file -from microdot_asgi_websocket import with_websocket +from microdot.asgi import Microdot, send_file, with_websocket app = Microdot() @app.route('/') -def index(request): +async def index(request): return send_file('index.html') @@ -15,3 +14,7 @@ async def echo(request, ws): while True: data = await ws.receive() await ws.send(data) + + +if __name__ == '__main__': + app.run() diff --git a/examples/websocket/echo_async.py b/examples/websocket/echo_async.py deleted file mode 100644 index 95d9f62..0000000 --- a/examples/websocket/echo_async.py +++ /dev/null @@ -1,20 +0,0 @@ -from microdot_asyncio import Microdot, send_file -from microdot_asyncio_websocket import with_websocket - -app = Microdot() - - -@app.route('/') -def index(request): - return send_file('index.html') - - -@app.route('/echo') -@with_websocket -async def echo(request, ws): - while True: - data = await ws.receive() - await ws.send(data) - - -app.run() diff --git a/examples/websocket/echo_wsgi.py b/examples/websocket/echo_wsgi.py index 267a646..2494a30 100644 --- a/examples/websocket/echo_wsgi.py +++ b/examples/websocket/echo_wsgi.py @@ -1,17 +1,20 @@ -from microdot_wsgi import Microdot, send_file -from microdot_websocket import with_websocket +from microdot.wsgi import Microdot, send_file, with_websocket app = Microdot() @app.route('/') -def index(request): +async def index(request): return send_file('index.html') @app.route('/echo') @with_websocket -def echo(request, ws): +async def echo(request, ws): while True: - data = ws.receive() - ws.send(data) + data = await ws.receive() + await ws.send(data) + + +if __name__ == '__main__': + app.run() diff --git a/libs/common/utemplate/README.md b/libs/common/utemplate/README.md index cc0b95f..2cacb40 100644 --- a/libs/common/utemplate/README.md +++ b/libs/common/utemplate/README.md @@ -1,8 +1,6 @@ utemplate ========= -*Release: 1.4.1, Source: https://github.com/pfalcon/utemplate* - `utemplate` is a lightweight and memory-efficient template engine for Python, primarily designed for use with Pycopy, a lightweight Python implementation (https://github.com/pfalcon/pycopy). It is also fully diff --git a/libs/micropython/uasyncio/__init__.py b/libs/micropython/asyncio/__init__.py similarity index 95% rename from libs/micropython/uasyncio/__init__.py rename to libs/micropython/asyncio/__init__.py index fa64438..1f83750 100644 --- a/libs/micropython/uasyncio/__init__.py +++ b/libs/micropython/asyncio/__init__.py @@ -1,4 +1,4 @@ -# MicroPython uasyncio module +# MicroPython asyncio module # MIT license; Copyright (c) 2019 Damien P. George from .core import * @@ -18,6 +18,7 @@ _attrs = { "StreamWriter": "stream", } + # Lazy loader, effectively does: # global attr # from .mod import attr diff --git a/libs/micropython/uasyncio/core.py b/libs/micropython/asyncio/core.py similarity index 97% rename from libs/micropython/uasyncio/core.py rename to libs/micropython/asyncio/core.py index 10a3108..214cc52 100644 --- a/libs/micropython/uasyncio/core.py +++ b/libs/micropython/asyncio/core.py @@ -1,4 +1,4 @@ -# MicroPython uasyncio module +# MicroPython asyncio module # MIT license; Copyright (c) 2019 Damien P. George from time import ticks_ms as ticks, ticks_diff, ticks_add @@ -6,7 +6,7 @@ import sys, select # Import TaskQueue and Task, preferring built-in C code over Python code try: - from _uasyncio import TaskQueue, Task + from _asyncio import TaskQueue, Task except: from .task import TaskQueue, Task @@ -30,6 +30,7 @@ _exc_context = {"message": "Task exception wasn't retrieved", "exception": None, ################################################################################ # Sleep functions + # "Yield" once, then raise StopIteration class SingletonGenerator: def __init__(self): @@ -132,6 +133,7 @@ class IOQueue: ################################################################################ # Main run loop + # Ensure the awaitable is a task def _promote_to_task(aw): return aw if isinstance(aw, Task) else create_task(aw) @@ -270,9 +272,9 @@ class Loop: return Loop._exc_handler def default_exception_handler(loop, context): - print(context["message"]) - print("future:", context["future"], "coro=", context["future"].coro) - sys.print_exception(context["exception"]) + print(context["message"], file=sys.stderr) + print("future:", context["future"], "coro=", context["future"].coro, file=sys.stderr) + sys.print_exception(context["exception"], sys.stderr) def call_exception_handler(context): (Loop._exc_handler or Loop.default_exception_handler)(Loop, context) diff --git a/libs/micropython/uasyncio/event.py b/libs/micropython/asyncio/event.py similarity index 91% rename from libs/micropython/uasyncio/event.py rename to libs/micropython/asyncio/event.py index 3b5e79d..f11bb14 100644 --- a/libs/micropython/uasyncio/event.py +++ b/libs/micropython/asyncio/event.py @@ -1,8 +1,9 @@ -# MicroPython uasyncio module +# MicroPython asyncio module # MIT license; Copyright (c) 2019-2020 Damien P. George from . import core + # Event class for primitive events that can be waited on, set, and cleared class Event: def __init__(self): @@ -23,7 +24,8 @@ class Event: def clear(self): self.state = False - async def wait(self): + # async + def wait(self): if not self.state: # Event not set, put the calling task on the event's waiting queue self.waiting.push(core.cur_task) @@ -38,16 +40,16 @@ class Event: # that asyncio will poll until a flag is set. # Note: Unlike Event, this is self-clearing after a wait(). try: - import uio + import io - class ThreadSafeFlag(uio.IOBase): + class ThreadSafeFlag(io.IOBase): def __init__(self): self.state = 0 def ioctl(self, req, flags): if req == 3: # MP_STREAM_POLL return self.state * flags - return None + return -1 # Other requests are unsupported def set(self): self.state = 1 diff --git a/libs/micropython/uasyncio/funcs.py b/libs/micropython/asyncio/funcs.py similarity index 97% rename from libs/micropython/uasyncio/funcs.py rename to libs/micropython/asyncio/funcs.py index 96883e4..599091d 100644 --- a/libs/micropython/uasyncio/funcs.py +++ b/libs/micropython/asyncio/funcs.py @@ -1,10 +1,10 @@ -# MicroPython uasyncio module +# MicroPython asyncio module # MIT license; Copyright (c) 2019-2022 Damien P. George from . import core -def _run(waiter, aw): +async def _run(waiter, aw): try: result = await aw status = True @@ -61,7 +61,8 @@ class _Remove: pass -async def gather(*aws, return_exceptions=False): +# async +def gather(*aws, return_exceptions=False): if not aws: return [] @@ -122,7 +123,7 @@ async def gather(*aws, return_exceptions=False): # Either this gather was cancelled, or one of the sub-tasks raised an exception with # return_exceptions==False, so reraise the exception here. - if state is not 0: + if state: raise state # Return the list of return values of each sub-task. diff --git a/libs/micropython/uasyncio/lock.py b/libs/micropython/asyncio/lock.py similarity index 96% rename from libs/micropython/uasyncio/lock.py rename to libs/micropython/asyncio/lock.py index f50213d..0a46ac3 100644 --- a/libs/micropython/uasyncio/lock.py +++ b/libs/micropython/asyncio/lock.py @@ -1,8 +1,9 @@ -# MicroPython uasyncio module +# MicroPython asyncio module # MIT license; Copyright (c) 2019-2020 Damien P. George from . import core + # Lock class for primitive mutex capability class Lock: def __init__(self): @@ -28,7 +29,8 @@ class Lock: # No Task waiting so unlock self.state = 0 - async def acquire(self): + # async + def acquire(self): if self.state != 0: # Lock unavailable, put the calling Task on the waiting queue self.waiting.push(core.cur_task) diff --git a/libs/micropython/uasyncio/manifest.py b/libs/micropython/asyncio/manifest.py similarity index 76% rename from libs/micropython/uasyncio/manifest.py rename to libs/micropython/asyncio/manifest.py index d425a46..e8dc764 100644 --- a/libs/micropython/uasyncio/manifest.py +++ b/libs/micropython/asyncio/manifest.py @@ -1,7 +1,7 @@ # This list of package files doesn't include task.py because that's provided # by the C module. package( - "uasyncio", + "asyncio", ( "__init__.py", "core.py", @@ -13,3 +13,6 @@ package( base_path="..", opt=3, ) + +# Backwards-compatible uasyncio module. +module("uasyncio.py", opt=3) diff --git a/libs/micropython/uasyncio/stream.py b/libs/micropython/asyncio/stream.py similarity index 76% rename from libs/micropython/uasyncio/stream.py rename to libs/micropython/asyncio/stream.py index 785e435..5547bfb 100644 --- a/libs/micropython/uasyncio/stream.py +++ b/libs/micropython/asyncio/stream.py @@ -1,4 +1,4 @@ -# MicroPython uasyncio module +# MicroPython asyncio module # MIT license; Copyright (c) 2019-2020 Damien P. George from . import core @@ -26,7 +26,8 @@ class Stream: # TODO yield? self.s.close() - async def read(self, n=-1): + # async + def read(self, n=-1): r = b"" while True: yield core._io_queue.queue_read(self.s) @@ -38,11 +39,13 @@ class Stream: return r r += r2 - async def readinto(self, buf): + # async + def readinto(self, buf): yield core._io_queue.queue_read(self.s) return self.s.readinto(buf) - async def readexactly(self, n): + # async + def readexactly(self, n): r = b"" while n: yield core._io_queue.queue_read(self.s) @@ -54,7 +57,8 @@ class Stream: n -= len(r2) return r - async def readline(self): + # async + def readline(self): l = b"" while True: yield core._io_queue.queue_read(self.s) @@ -73,10 +77,11 @@ class Stream: buf = buf[ret:] self.out_buf += buf - async def drain(self): + # async + def drain(self): if not self.out_buf: # Drain must always yield, so a tight loop of write+drain can't block the scheduler. - return await core.sleep_ms(0) + return (yield from core.sleep_ms(0)) mv = memoryview(self.out_buf) off = 0 while off < len(mv): @@ -93,9 +98,11 @@ StreamWriter = Stream # Create a TCP stream connection to a remote host -async def open_connection(host, port): - from uerrno import EINPROGRESS - import usocket as socket +# +# async +def open_connection(host, port): + from errno import EINPROGRESS + import socket ai = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)[0] # TODO this is blocking! s = socket.socket(ai[0], ai[1], ai[2]) @@ -120,20 +127,30 @@ class Server: await self.wait_closed() def close(self): + # Note: the _serve task must have already started by now due to the sleep + # in start_server, so `state` won't be clobbered at the start of _serve. + self.state = True self.task.cancel() async def wait_closed(self): await self.task async def _serve(self, s, cb): + self.state = False # Accept incoming connections while True: try: yield core._io_queue.queue_read(s) - except core.CancelledError: - # Shutdown server + except core.CancelledError as er: + # The server task was cancelled, shutdown server and close socket. s.close() - return + if self.state: + # If the server was explicitly closed, ignore the cancellation. + return + else: + # Otherwise e.g. the parent task was cancelled, propagate + # cancellation. + raise er try: s2, addr = s.accept() except: @@ -147,7 +164,7 @@ class Server: # Helper function to start a TCP stream server, running as a new task # TODO could use an accept-callback on socket read activity instead of creating a task async def start_server(cb, host, port, backlog=5): - import usocket as socket + import socket # Create and bind server socket. host = socket.getaddrinfo(host, port)[0] # TODO this is blocking! @@ -160,6 +177,16 @@ async def start_server(cb, host, port, backlog=5): # Create and return server object and task. srv = Server() srv.task = core.create_task(srv._serve(s, cb)) + try: + # Ensure that the _serve task has been scheduled so that it gets to + # handle cancellation. + await core.sleep_ms(0) + except core.CancelledError as er: + # If the parent task is cancelled during this first sleep, then + # we will leak the task and it will sit waiting for the socket, so + # cancel it. + srv.task.cancel() + raise er return srv diff --git a/libs/micropython/uasyncio/task.py b/libs/micropython/asyncio/task.py similarity index 99% rename from libs/micropython/uasyncio/task.py rename to libs/micropython/asyncio/task.py index 4ead2a1..30be217 100644 --- a/libs/micropython/uasyncio/task.py +++ b/libs/micropython/asyncio/task.py @@ -1,4 +1,4 @@ -# MicroPython uasyncio module +# MicroPython asyncio module # MIT license; Copyright (c) 2019-2020 Damien P. George # This file contains the core TaskQueue based on a pairing heap, and the core Task class. diff --git a/libs/micropython/asyncio/uasyncio.py b/libs/micropython/asyncio/uasyncio.py new file mode 100644 index 0000000..67e6ddc --- /dev/null +++ b/libs/micropython/asyncio/uasyncio.py @@ -0,0 +1,8 @@ +# This module just allows `import uasyncio` to work. It lazy-loads from +# `asyncio` without duplicating its globals dict. + + +def __getattr__(attr): + import asyncio + + return getattr(asyncio, attr) diff --git a/libs/micropython/datetime.py b/libs/micropython/datetime.py index cf9167f..0f2a891 100644 --- a/libs/micropython/datetime.py +++ b/libs/micropython/datetime.py @@ -1,2143 +1,877 @@ -"""Concrete date/time and related types. +# datetime.py -See http://www.iana.org/time-zones/repository/tz-link.html for -time zone and DST data sources. -""" +import time as _tmod -import time as _time -import math as _math +_DBM = (0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334) +_DIM = (0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) +_TIME_SPEC = ("auto", "hours", "minutes", "seconds", "milliseconds", "microseconds") + + +def _leap(y): + return y % 4 == 0 and (y % 100 != 0 or y % 400 == 0) + + +def _dby(y): + # year -> number of days before January 1st of year. + Y = y - 1 + return Y * 365 + Y // 4 - Y // 100 + Y // 400 + + +def _dim(y, m): + # year, month -> number of days in that month in that year. + if m == 2 and _leap(y): + return 29 + return _DIM[m] + + +def _dbm(y, m): + # year, month -> number of days in year preceding first day of month. + return _DBM[m] + (m > 2 and _leap(y)) + + +def _ymd2o(y, m, d): + # y, month, day -> ordinal, considering 01-Jan-0001 as day 1. + return _dby(y) + _dbm(y, m) + d + + +def _o2ymd(n): + # ordinal -> (year, month, day), considering 01-Jan-0001 as day 1. + n -= 1 + n400, n = divmod(n, 146_097) + y = n400 * 400 + 1 + n100, n = divmod(n, 36_524) + n4, n = divmod(n, 1_461) + n1, n = divmod(n, 365) + y += n100 * 100 + n4 * 4 + n1 + if n1 == 4 or n100 == 4: + return y - 1, 12, 31 + m = (n + 50) >> 5 + prec = _dbm(y, m) + if prec > n: + m -= 1 + prec -= _dim(y, m) + n -= prec + return y, m, n + 1 -def _cmp(x, y): - return 0 if x == y else 1 if x > y else -1 MINYEAR = 1 -MAXYEAR = 9999 -_MAXORDINAL = 3652059 # date.max.toordinal() +MAXYEAR = 9_999 -# Utility functions, adapted from Python's Demo/classes/Dates.py, which -# also assumes the current Gregorian calendar indefinitely extended in -# both directions. Difference: Dates.py calls January 1 of year 0 day -# number 1. The code here calls January 1 of year 1 day number 1. This is -# to match the definition of the "proleptic Gregorian" calendar in Dershowitz -# and Reingold's "Calendrical Calculations", where it's the base calendar -# for all computations. See the book for algorithms for converting between -# proleptic Gregorian ordinals and many other calendar systems. - -_DAYS_IN_MONTH = [None, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] - -_DAYS_BEFORE_MONTH = [None] -dbm = 0 -for dim in _DAYS_IN_MONTH[1:]: - _DAYS_BEFORE_MONTH.append(dbm) - dbm += dim -del dbm, dim - -def _is_leap(year): - "year -> 1 if leap year, else 0." - return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) - -def _days_before_year(year): - "year -> number of days before January 1st of year." - y = year - 1 - return y*365 + y//4 - y//100 + y//400 - -def _days_in_month(year, month): - "year, month -> number of days in that month in that year." - assert 1 <= month <= 12, month - if month == 2 and _is_leap(year): - return 29 - return _DAYS_IN_MONTH[month] - -def _days_before_month(year, month): - "year, month -> number of days in year preceding first day of month." - assert 1 <= month <= 12, 'month must be in 1..12' - return _DAYS_BEFORE_MONTH[month] + (month > 2 and _is_leap(year)) - -def _ymd2ord(year, month, day): - "year, month, day -> ordinal, considering 01-Jan-0001 as day 1." - assert 1 <= month <= 12, 'month must be in 1..12' - dim = _days_in_month(year, month) - assert 1 <= day <= dim, ('day must be in 1..%d' % dim) - return (_days_before_year(year) + - _days_before_month(year, month) + - day) - -_DI400Y = _days_before_year(401) # number of days in 400 years -_DI100Y = _days_before_year(101) # " " " " 100 " -_DI4Y = _days_before_year(5) # " " " " 4 " - -# A 4-year cycle has an extra leap day over what we'd get from pasting -# together 4 single years. -assert _DI4Y == 4 * 365 + 1 - -# Similarly, a 400-year cycle has an extra leap day over what we'd get from -# pasting together 4 100-year cycles. -assert _DI400Y == 4 * _DI100Y + 1 - -# OTOH, a 100-year cycle has one fewer leap day than we'd get from -# pasting together 25 4-year cycles. -assert _DI100Y == 25 * _DI4Y - 1 - -def _ord2ymd(n): - "ordinal -> (year, month, day), considering 01-Jan-0001 as day 1." - - # n is a 1-based index, starting at 1-Jan-1. The pattern of leap years - # repeats exactly every 400 years. The basic strategy is to find the - # closest 400-year boundary at or before n, then work with the offset - # from that boundary to n. Life is much clearer if we subtract 1 from - # n first -- then the values of n at 400-year boundaries are exactly - # those divisible by _DI400Y: - # - # D M Y n n-1 - # -- --- ---- ---------- ---------------- - # 31 Dec -400 -_DI400Y -_DI400Y -1 - # 1 Jan -399 -_DI400Y +1 -_DI400Y 400-year boundary - # ... - # 30 Dec 000 -1 -2 - # 31 Dec 000 0 -1 - # 1 Jan 001 1 0 400-year boundary - # 2 Jan 001 2 1 - # 3 Jan 001 3 2 - # ... - # 31 Dec 400 _DI400Y _DI400Y -1 - # 1 Jan 401 _DI400Y +1 _DI400Y 400-year boundary - n -= 1 - n400, n = divmod(n, _DI400Y) - year = n400 * 400 + 1 # ..., -399, 1, 401, ... - - # Now n is the (non-negative) offset, in days, from January 1 of year, to - # the desired date. Now compute how many 100-year cycles precede n. - # Note that it's possible for n100 to equal 4! In that case 4 full - # 100-year cycles precede the desired day, which implies the desired - # day is December 31 at the end of a 400-year cycle. - n100, n = divmod(n, _DI100Y) - - # Now compute how many 4-year cycles precede it. - n4, n = divmod(n, _DI4Y) - - # And now how many single years. Again n1 can be 4, and again meaning - # that the desired day is December 31 at the end of the 4-year cycle. - n1, n = divmod(n, 365) - - year += n100 * 100 + n4 * 4 + n1 - if n1 == 4 or n100 == 4: - assert n == 0 - return year-1, 12, 31 - - # Now the year is correct, and n is the offset from January 1. We find - # the month via an estimate that's either exact or one too large. - leapyear = n1 == 3 and (n4 != 24 or n100 == 3) - assert leapyear == _is_leap(year) - month = (n + 50) >> 5 - preceding = _DAYS_BEFORE_MONTH[month] + (month > 2 and leapyear) - if preceding > n: # estimate is too large - month -= 1 - preceding -= _DAYS_IN_MONTH[month] + (month == 2 and leapyear) - n -= preceding - assert 0 <= n < _days_in_month(year, month) - - # Now the year and month are correct, and n is the offset from the - # start of that month: we're done! - return year, month, n+1 - -# Month and day names. For localized versions, see the calendar module. -_MONTHNAMES = [None, "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] -_DAYNAMES = [None, "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - - -def _build_struct_time(y, m, d, hh, mm, ss, dstflag): - wday = (_ymd2ord(y, m, d) + 6) % 7 - dnum = _days_before_month(y, m) + d - return _time.struct_time((y, m, d, hh, mm, ss, wday, dnum, dstflag)) - -def _format_time(hh, mm, ss, us): - # Skip trailing microseconds when us==0. - result = "%02d:%02d:%02d" % (hh, mm, ss) - if us: - result += ".%06d" % us - return result - -# Correctly substitute for %z and %Z escapes in strftime formats. -def _wrap_strftime(object, format, timetuple): - # Don't call utcoffset() or tzname() unless actually needed. - freplace = None # the string to use for %f - zreplace = None # the string to use for %z - Zreplace = None # the string to use for %Z - - # Scan format for %z and %Z escapes, replacing as needed. - newformat = [] - push = newformat.append - i, n = 0, len(format) - while i < n: - ch = format[i] - i += 1 - if ch == '%': - if i < n: - ch = format[i] - i += 1 - if ch == 'f': - if freplace is None: - freplace = '%06d' % getattr(object, - 'microsecond', 0) - newformat.append(freplace) - elif ch == 'z': - if zreplace is None: - zreplace = "" - if hasattr(object, "utcoffset"): - offset = object.utcoffset() - if offset is not None: - sign = '+' - if offset.days < 0: - offset = -offset - sign = '-' - h, m = divmod(offset, timedelta(hours=1)) - assert not m % timedelta(minutes=1), "whole minute" - m //= timedelta(minutes=1) - zreplace = '%c%02d%02d' % (sign, h, m) - assert '%' not in zreplace - newformat.append(zreplace) - elif ch == 'Z': - if Zreplace is None: - Zreplace = "" - if hasattr(object, "tzname"): - s = object.tzname() - if s is not None: - # strftime is going to have at this: escape % - Zreplace = s.replace('%', '%%') - newformat.append(Zreplace) - else: - push('%') - push(ch) - else: - push('%') - else: - push(ch) - newformat = "".join(newformat) - return _time.strftime(newformat, timetuple) - -def _call_tzinfo_method(tzinfo, methname, tzinfoarg): - if tzinfo is None: - return None - return getattr(tzinfo, methname)(tzinfoarg) - -# Just raise TypeError if the arg isn't None or a string. -def _check_tzname(name): - if name is not None and not isinstance(name, str): - raise TypeError("tzinfo.tzname() must return None or string, " - "not '%s'" % type(name)) - -# name is the offset-producing method, "utcoffset" or "dst". -# offset is what it returned. -# If offset isn't None or timedelta, raises TypeError. -# If offset is None, returns None. -# Else offset is checked for being in range, and a whole # of minutes. -# If it is, its integer value is returned. Else ValueError is raised. -def _check_utc_offset(name, offset): - assert name in ("utcoffset", "dst") - if offset is None: - return - if not isinstance(offset, timedelta): - raise TypeError("tzinfo.%s() must return None " - "or timedelta, not '%s'" % (name, type(offset))) - if offset % timedelta(minutes=1) or offset.microseconds: - raise ValueError("tzinfo.%s() must return a whole number " - "of minutes, got %s" % (name, offset)) - if not -timedelta(1) < offset < timedelta(1): - raise ValueError("%s()=%s, must be must be strictly between" - " -timedelta(hours=24) and timedelta(hours=24)" - % (name, offset)) - -def _check_date_fields(year, month, day): - if not isinstance(year, int): - raise TypeError('int expected') - if not MINYEAR <= year <= MAXYEAR: - raise ValueError('year must be in %d..%d' % (MINYEAR, MAXYEAR), year) - if not 1 <= month <= 12: - raise ValueError('month must be in 1..12', month) - dim = _days_in_month(year, month) - if not 1 <= day <= dim: - raise ValueError('day must be in 1..%d' % dim, day) - -def _check_time_fields(hour, minute, second, microsecond): - if not isinstance(hour, int): - raise TypeError('int expected') - if not 0 <= hour <= 23: - raise ValueError('hour must be in 0..23', hour) - if not 0 <= minute <= 59: - raise ValueError('minute must be in 0..59', minute) - if not 0 <= second <= 59: - raise ValueError('second must be in 0..59', second) - if not 0 <= microsecond <= 999999: - raise ValueError('microsecond must be in 0..999999', microsecond) - -def _check_tzinfo_arg(tz): - if tz is not None and not isinstance(tz, tzinfo): - raise TypeError("tzinfo argument must be None or of a tzinfo subclass") - -def _cmperror(x, y): - raise TypeError("can't compare '%s' to '%s'" % ( - type(x).__name__, type(y).__name__)) class timedelta: - """Represent the difference between two datetime objects. - - Supported operators: - - - add, subtract timedelta - - unary plus, minus, abs - - compare to timedelta - - multiply, divide by int - - In addition, datetime supports subtraction of two datetime objects - returning a timedelta, and addition or subtraction of a datetime - and a timedelta giving a datetime. - - Representation: (days, seconds, microseconds). Why? Because I - felt like it. - """ - __slots__ = '_days', '_seconds', '_microseconds' - - def __new__(cls, days=0, seconds=0, microseconds=0, - milliseconds=0, minutes=0, hours=0, weeks=0): - # Doing this efficiently and accurately in C is going to be difficult - # and error-prone, due to ubiquitous overflow possibilities, and that - # C double doesn't have enough bits of precision to represent - # microseconds over 10K years faithfully. The code here tries to make - # explicit where go-fast assumptions can be relied on, in order to - # guide the C implementation; it's way more convoluted than speed- - # ignoring auto-overflow-to-long idiomatic Python could be. - - # XXX Check that all inputs are ints or floats. - - # Final values, all integer. - # s and us fit in 32-bit signed ints; d isn't bounded. - d = s = us = 0 - - # Normalize everything to days, seconds, microseconds. - days += weeks*7 - seconds += minutes*60 + hours*3600 - microseconds += milliseconds*1000 - - # Get rid of all fractions, and normalize s and us. - # Take a deep breath . - if isinstance(days, float): - dayfrac, days = _math.modf(days) - daysecondsfrac, daysecondswhole = _math.modf(dayfrac * (24.*3600.)) - assert daysecondswhole == int(daysecondswhole) # can't overflow - s = int(daysecondswhole) - assert days == int(days) - d = int(days) - else: - daysecondsfrac = 0.0 - d = days - assert isinstance(daysecondsfrac, float) - assert abs(daysecondsfrac) <= 1.0 - assert isinstance(d, int) - assert abs(s) <= 24 * 3600 - # days isn't referenced again before redefinition - - if isinstance(seconds, float): - secondsfrac, seconds = _math.modf(seconds) - assert seconds == int(seconds) - seconds = int(seconds) - secondsfrac += daysecondsfrac - assert abs(secondsfrac) <= 2.0 - else: - secondsfrac = daysecondsfrac - # daysecondsfrac isn't referenced again - assert isinstance(secondsfrac, float) - assert abs(secondsfrac) <= 2.0 - - assert isinstance(seconds, int) - days, seconds = divmod(seconds, 24*3600) - d += days - s += int(seconds) # can't overflow - assert isinstance(s, int) - assert abs(s) <= 2 * 24 * 3600 - # seconds isn't referenced again before redefinition - - usdouble = secondsfrac * 1e6 - assert abs(usdouble) < 2.1e6 # exact value not critical - # secondsfrac isn't referenced again - - if isinstance(microseconds, float): - microseconds += usdouble - microseconds = round(microseconds, 0) - seconds, microseconds = divmod(microseconds, 1e6) - assert microseconds == int(microseconds) - assert seconds == int(seconds) - days, seconds = divmod(seconds, 24.*3600.) - assert days == int(days) - assert seconds == int(seconds) - d += int(days) - s += int(seconds) # can't overflow - assert isinstance(s, int) - assert abs(s) <= 3 * 24 * 3600 - else: - seconds, microseconds = divmod(microseconds, 1000000) - days, seconds = divmod(seconds, 24*3600) - d += days - s += int(seconds) # can't overflow - assert isinstance(s, int) - assert abs(s) <= 3 * 24 * 3600 - microseconds = float(microseconds) - microseconds += usdouble - microseconds = round(microseconds, 0) - assert abs(s) <= 3 * 24 * 3600 - assert abs(microseconds) < 3.1e6 - - # Just a little bit of carrying possible for microseconds and seconds. - assert isinstance(microseconds, float) - assert int(microseconds) == microseconds - us = int(microseconds) - seconds, us = divmod(us, 1000000) - s += seconds # cant't overflow - assert isinstance(s, int) - days, s = divmod(s, 24*3600) - d += days - - assert isinstance(d, int) - assert isinstance(s, int) and 0 <= s < 24*3600 - assert isinstance(us, int) and 0 <= us < 1000000 - - self = object.__new__(cls) - - self._days = d - self._seconds = s - self._microseconds = us - if abs(d) > 999999999: - raise OverflowError("timedelta # of days is too large: %d" % d) - - return self + def __init__( + self, days=0, seconds=0, microseconds=0, milliseconds=0, minutes=0, hours=0, weeks=0 + ): + s = (((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 + seconds + self._us = round((s * 1000 + milliseconds) * 1000 + microseconds) def __repr__(self): - if self._microseconds: - return "%s(%d, %d, %d)" % ('datetime.' + self.__class__.__name__, - self._days, - self._seconds, - self._microseconds) - if self._seconds: - return "%s(%d, %d)" % ('datetime.' + self.__class__.__name__, - self._days, - self._seconds) - return "%s(%d)" % ('datetime.' + self.__class__.__name__, self._days) - - def __str__(self): - mm, ss = divmod(self._seconds, 60) - hh, mm = divmod(mm, 60) - s = "%d:%02d:%02d" % (hh, mm, ss) - if self._days: - def plural(n): - return n, abs(n) != 1 and "s" or "" - s = ("%d day%s, " % plural(self._days)) + s - if self._microseconds: - s = s + ".%06d" % self._microseconds - return s + return "datetime.timedelta(microseconds={})".format(self._us) def total_seconds(self): - """Total seconds in the duration.""" - return ((self.days * 86400 + self.seconds)*10**6 + - self.microseconds) / 10**6 + return self._us / 1_000_000 - # Read-only field accessors @property def days(self): - """days""" - return self._days + return self._tuple(2)[0] @property def seconds(self): - """seconds""" - return self._seconds + return self._tuple(3)[1] @property def microseconds(self): - """microseconds""" - return self._microseconds + return self._tuple(3)[2] def __add__(self, other): - if isinstance(other, timedelta): - # for CPython compatibility, we cannot use - # our __class__ here, but need a real timedelta - return timedelta(self._days + other._days, - self._seconds + other._seconds, - self._microseconds + other._microseconds) - return NotImplemented - - __radd__ = __add__ + if isinstance(other, datetime): + return other.__add__(self) + else: + us = other._us + return timedelta(0, 0, self._us + us) def __sub__(self, other): - if isinstance(other, timedelta): - # for CPython compatibility, we cannot use - # our __class__ here, but need a real timedelta - return timedelta(self._days - other._days, - self._seconds - other._seconds, - self._microseconds - other._microseconds) - return NotImplemented - - def __rsub__(self, other): - if isinstance(other, timedelta): - return -self + other - return NotImplemented + return timedelta(0, 0, self._us - other._us) def __neg__(self): - # for CPython compatibility, we cannot use - # our __class__ here, but need a real timedelta - return timedelta(-self._days, - -self._seconds, - -self._microseconds) + return timedelta(0, 0, -self._us) def __pos__(self): return self def __abs__(self): - if self._days < 0: - return -self - else: - return self + return -self if self._us < 0 else self def __mul__(self, other): - if isinstance(other, int): - # for CPython compatibility, we cannot use - # our __class__ here, but need a real timedelta - return timedelta(self._days * other, - self._seconds * other, - self._microseconds * other) - if isinstance(other, float): - #a, b = other.as_integer_ratio() - #return self * a / b - usec = self._to_microseconds() - return timedelta(0, 0, round(usec * other)) - return NotImplemented + return timedelta(0, 0, round(other * self._us)) __rmul__ = __mul__ - def _to_microseconds(self): - return ((self._days * (24*3600) + self._seconds) * 1000000 + - self._microseconds) + def __truediv__(self, other): + if isinstance(other, timedelta): + return self._us / other._us + else: + return timedelta(0, 0, round(self._us / other)) def __floordiv__(self, other): - if not isinstance(other, (int, timedelta)): - return NotImplemented - usec = self._to_microseconds() if isinstance(other, timedelta): - return usec // other._to_microseconds() - if isinstance(other, int): - return timedelta(0, 0, usec // other) - - def __truediv__(self, other): - if not isinstance(other, (int, float, timedelta)): - return NotImplemented - usec = self._to_microseconds() - if isinstance(other, timedelta): - return usec / other._to_microseconds() - if isinstance(other, int): - return timedelta(0, 0, usec / other) - if isinstance(other, float): -# a, b = other.as_integer_ratio() -# return timedelta(0, 0, b * usec / a) - return timedelta(0, 0, round(usec / other)) + return self._us // other._us + else: + return timedelta(0, 0, int(self._us // other)) def __mod__(self, other): - if isinstance(other, timedelta): - r = self._to_microseconds() % other._to_microseconds() - return timedelta(0, 0, r) - return NotImplemented + return timedelta(0, 0, self._us % other._us) def __divmod__(self, other): - if isinstance(other, timedelta): - q, r = divmod(self._to_microseconds(), - other._to_microseconds()) - return q, timedelta(0, 0, r) - return NotImplemented - - # Comparisons of timedelta objects with other. + q, r = divmod(self._us, other._us) + return q, timedelta(0, 0, r) def __eq__(self, other): - if isinstance(other, timedelta): - return self._cmp(other) == 0 - else: - return False - - def __ne__(self, other): - if isinstance(other, timedelta): - return self._cmp(other) != 0 - else: - return True + return self._us == other._us def __le__(self, other): - if isinstance(other, timedelta): - return self._cmp(other) <= 0 - else: - _cmperror(self, other) + return self._us <= other._us def __lt__(self, other): - if isinstance(other, timedelta): - return self._cmp(other) < 0 - else: - _cmperror(self, other) + return self._us < other._us def __ge__(self, other): - if isinstance(other, timedelta): - return self._cmp(other) >= 0 - else: - _cmperror(self, other) + return self._us >= other._us def __gt__(self, other): - if isinstance(other, timedelta): - return self._cmp(other) > 0 - else: - _cmperror(self, other) - - def _cmp(self, other): - assert isinstance(other, timedelta) - return _cmp(self._getstate(), other._getstate()) - - def __hash__(self): - return hash(self._getstate()) + return self._us > other._us def __bool__(self): - return (self._days != 0 or - self._seconds != 0 or - self._microseconds != 0) + return self._us != 0 - # Pickle support. - - def _getstate(self): - return (self._days, self._seconds, self._microseconds) - - def __reduce__(self): - return (self.__class__, self._getstate()) - -timedelta.min = timedelta(-999999999) -timedelta.max = timedelta(days=999999999, hours=23, minutes=59, seconds=59, - microseconds=999999) -timedelta.resolution = timedelta(microseconds=1) - -class date: - """Concrete date type. - - Constructors: - - __new__() - fromtimestamp() - today() - fromordinal() - - Operators: - - __repr__, __str__ - __cmp__, __hash__ - __add__, __radd__, __sub__ (add/radd only with timedelta arg) - - Methods: - - timetuple() - toordinal() - weekday() - isoweekday(), isocalendar(), isoformat() - ctime() - strftime() - - Properties (readonly): - year, month, day - """ - __slots__ = '_year', '_month', '_day' - - def __new__(cls, year, month=None, day=None): - """Constructor. - - Arguments: - - year, month, day (required, base 1) - """ - if (isinstance(year, bytes) and len(year) == 4 and - 1 <= year[2] <= 12 and month is None): # Month is sane - # Pickle support - self = object.__new__(cls) - self.__setstate(year) - return self - _check_date_fields(year, month, day) - self = object.__new__(cls) - self._year = year - self._month = month - self._day = day - return self - - # Additional constructors - - @classmethod - def fromtimestamp(cls, t): - "Construct a date from a POSIX timestamp (like time.time())." - y, m, d, hh, mm, ss, weekday, jday, dst = _time.localtime(t) - return cls(y, m, d) - - @classmethod - def today(cls): - "Construct a date from time.time()." - t = _time.time() - return cls.fromtimestamp(t) - - @classmethod - def fromordinal(cls, n): - """Contruct a date from a proleptic Gregorian ordinal. - - January 1 of year 1 is day 1. Only the year, month and day are - non-zero in the result. - """ - y, m, d = _ord2ymd(n) - return cls(y, m, d) - - # Conversions to string - - def __repr__(self): - """Convert to formal string, for repr(). - - >>> dt = datetime(2010, 1, 1) - >>> repr(dt) - 'datetime.datetime(2010, 1, 1, 0, 0)' - - >>> dt = datetime(2010, 1, 1, tzinfo=timezone.utc) - >>> repr(dt) - 'datetime.datetime(2010, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)' - """ - return "%s(%d, %d, %d)" % ('datetime.' + self.__class__.__name__, - self._year, - self._month, - self._day) - # XXX These shouldn't depend on time.localtime(), because that - # clips the usable dates to [1970 .. 2038). At least ctime() is - # easily done without using strftime() -- that's better too because - # strftime("%c", ...) is locale specific. - - - def ctime(self): - "Return ctime() style string." - weekday = self.toordinal() % 7 or 7 - return "%s %s %2d 00:00:00 %04d" % ( - _DAYNAMES[weekday], - _MONTHNAMES[self._month], - self._day, self._year) - - def strftime(self, fmt): - "Format using strftime()." - return _wrap_strftime(self, fmt, self.timetuple()) - - def __format__(self, fmt): - if len(fmt) != 0: - return self.strftime(fmt) - return str(self) - - def isoformat(self): - """Return the date formatted according to ISO. - - This is 'YYYY-MM-DD'. - - References: - - http://www.w3.org/TR/NOTE-datetime - - http://www.cl.cam.ac.uk/~mgk25/iso-time.html - """ - return "%04d-%02d-%02d" % (self._year, self._month, self._day) - - __str__ = isoformat - - # Read-only field accessors - @property - def year(self): - """year (1-9999)""" - return self._year - - @property - def month(self): - """month (1-12)""" - return self._month - - @property - def day(self): - """day (1-31)""" - return self._day - - # Standard conversions, __cmp__, __hash__ (and helpers) - - def timetuple(self): - "Return local time tuple compatible with time.localtime()." - return _build_struct_time(self._year, self._month, self._day, - 0, 0, 0, -1) - - def toordinal(self): - """Return proleptic Gregorian ordinal for the year, month and day. - - January 1 of year 1 is day 1. Only the year, month and day values - contribute to the result. - """ - return _ymd2ord(self._year, self._month, self._day) - - def replace(self, year=None, month=None, day=None): - """Return a new date with new values for the specified fields.""" - if year is None: - year = self._year - if month is None: - month = self._month - if day is None: - day = self._day - _check_date_fields(year, month, day) - return date(year, month, day) - - # Comparisons of date objects with other. - - def __eq__(self, other): - if isinstance(other, date): - return self._cmp(other) == 0 - return NotImplemented - - def __ne__(self, other): - if isinstance(other, date): - return self._cmp(other) != 0 - return NotImplemented - - def __le__(self, other): - if isinstance(other, date): - return self._cmp(other) <= 0 - return NotImplemented - - def __lt__(self, other): - if isinstance(other, date): - return self._cmp(other) < 0 - return NotImplemented - - def __ge__(self, other): - if isinstance(other, date): - return self._cmp(other) >= 0 - return NotImplemented - - def __gt__(self, other): - if isinstance(other, date): - return self._cmp(other) > 0 - return NotImplemented - - def _cmp(self, other): - assert isinstance(other, date) - y, m, d = self._year, self._month, self._day - y2, m2, d2 = other._year, other._month, other._day - return _cmp((y, m, d), (y2, m2, d2)) + def __str__(self): + return self._format(0x40) def __hash__(self): - "Hash." - return hash(self._getstate()) + if not hasattr(self, "_hash"): + self._hash = hash(self._us) + return self._hash - # Computations + def isoformat(self): + return self._format(0) - def __add__(self, other): - "Add a date to a timedelta." - if isinstance(other, timedelta): - o = self.toordinal() + other.days - if 0 < o <= _MAXORDINAL: - return date.fromordinal(o) - raise OverflowError("result out of range") - return NotImplemented + def _format(self, spec=0): + if self._us >= 0: + td = self + g = "" + else: + td = -self + g = "-" + d, h, m, s, us = td._tuple(5) + ms, us = divmod(us, 1000) + r = "" + if spec & 0x40: + spec &= ~0x40 + hr = str(h) + else: + hr = f"{h:02d}" + if spec & 0x20: + spec &= ~0x20 + spec |= 0x10 + r += "UTC" + if spec & 0x10: + spec &= ~0x10 + if not g: + g = "+" + if d: + p = "s" if d > 1 else "" + r += f"{g}{d} day{p}, " + g = "" + if spec == 0: + spec = 5 if (ms or us) else 3 + if spec >= 1 or h: + r += f"{g}{hr}" + if spec >= 2 or m: + r += f":{m:02d}" + if spec >= 3 or s: + r += f":{s:02d}" + if spec >= 4 or ms: + r += f".{ms:03d}" + if spec >= 5 or us: + r += f"{us:03d}" + return r - __radd__ = __add__ + def tuple(self): + return self._tuple(5) - def __sub__(self, other): - """Subtract two dates, or a date and a timedelta.""" - if isinstance(other, timedelta): - return self + timedelta(-other.days) - if isinstance(other, date): - days1 = self.toordinal() - days2 = other.toordinal() - return timedelta(days1 - days2) - return NotImplemented + def _tuple(self, n): + d, us = divmod(self._us, 86_400_000_000) + if n == 2: + return d, us + s, us = divmod(us, 1_000_000) + if n == 3: + return d, s, us + h, s = divmod(s, 3600) + m, s = divmod(s, 60) + return d, h, m, s, us - def weekday(self): - "Return day of the week, where Monday == 0 ... Sunday == 6." - return (self.toordinal() + 6) % 7 - # Day-of-the-week and week-of-the-year, according to ISO +timedelta.min = timedelta(days=-999_999_999) +timedelta.max = timedelta(days=999_999_999, hours=23, minutes=59, seconds=59, microseconds=999_999) +timedelta.resolution = timedelta(microseconds=1) - def isoweekday(self): - "Return day of the week, where Monday == 1 ... Sunday == 7." - # 1-Jan-0001 is a Monday - return self.toordinal() % 7 or 7 - - def isocalendar(self): - """Return a 3-tuple containing ISO year, week number, and weekday. - - The first ISO week of the year is the (Mon-Sun) week - containing the year's first Thursday; everything else derives - from that. - - The first week is 1; Monday is 1 ... Sunday is 7. - - ISO calendar algorithm taken from - http://www.phys.uu.nl/~vgent/calendar/isocalendar.htm - """ - year = self._year - week1monday = _isoweek1monday(year) - today = _ymd2ord(self._year, self._month, self._day) - # Internally, week and day have origin 0 - week, day = divmod(today - week1monday, 7) - if week < 0: - year -= 1 - week1monday = _isoweek1monday(year) - week, day = divmod(today - week1monday, 7) - elif week >= 52: - if today >= _isoweek1monday(year+1): - year += 1 - week = 0 - return year, week+1, day+1 - - # Pickle support. - - def _getstate(self): - yhi, ylo = divmod(self._year, 256) - return bytes([yhi, ylo, self._month, self._day]), - - def __setstate(self, string): - if len(string) != 4 or not (1 <= string[2] <= 12): - raise TypeError("not enough arguments") - yhi, ylo, self._month, self._day = string - self._year = yhi * 256 + ylo - - def __reduce__(self): - return (self.__class__, self._getstate()) - -_date_class = date # so functions w/ args named "date" can get at the class - -date.min = date(1, 1, 1) -date.max = date(9999, 12, 31) -date.resolution = timedelta(days=1) class tzinfo: - """Abstract base class for time zone info classes. - - Subclasses must override the name(), utcoffset() and dst() methods. - """ - __slots__ = () - - def __new__(cls): - self = object.__new__(cls) - return self - + # abstract class def tzname(self, dt): - "datetime -> string name of time zone." - raise NotImplementedError("tzinfo subclass must override tzname()") + raise NotImplementedError def utcoffset(self, dt): - "datetime -> minutes east of UTC (negative for west of UTC)" - raise NotImplementedError("tzinfo subclass must override utcoffset()") + raise NotImplementedError def dst(self, dt): - """datetime -> DST offset in minutes east of UTC. - - Return 0 if DST not in effect. utcoffset() must include the DST - offset. - """ - raise NotImplementedError("tzinfo subclass must override dst()") + raise NotImplementedError def fromutc(self, dt): - "datetime in UTC -> datetime in local time." - - if not isinstance(dt, datetime): - raise TypeError("fromutc() requires a datetime argument") - if dt.tzinfo is not self: - raise ValueError("dt.tzinfo is not self") + if dt._tz is not self: + raise ValueError + # See original datetime.py for an explanation of this algorithm. dtoff = dt.utcoffset() - if dtoff is None: - raise ValueError("fromutc() requires a non-None utcoffset() " - "result") - - # See the long comment block at the end of this file for an - # explanation of this algorithm. dtdst = dt.dst() - if dtdst is None: - raise ValueError("fromutc() requires a non-None dst() result") delta = dtoff - dtdst if delta: dt += delta dtdst = dt.dst() - if dtdst is None: - raise ValueError("fromutc(): dt.dst gave inconsistent " - "results; cannot convert") return dt + dtdst - # Pickle support. + def isoformat(self, dt): + return self.utcoffset(dt)._format(0x12) - def __reduce__(self): - getinitargs = getattr(self, "__getinitargs__", None) - if getinitargs: - args = getinitargs() - else: - args = () - getstate = getattr(self, "__getstate__", None) - if getstate: - state = getstate() - else: - state = getattr(self, "__dict__", None) or None - if state is None: - return (self.__class__, args) - else: - return (self.__class__, args, state) - -_tzinfo_class = tzinfo - -class time: - """Time with time zone. - - Constructors: - - __new__() - - Operators: - - __repr__, __str__ - __cmp__, __hash__ - - Methods: - - strftime() - isoformat() - utcoffset() - tzname() - dst() - - Properties (readonly): - hour, minute, second, microsecond, tzinfo - """ - - def __new__(cls, hour=0, minute=0, second=0, microsecond=0, tzinfo=None): - """Constructor. - - Arguments: - - hour, minute (required) - second, microsecond (default to zero) - tzinfo (default to None) - """ - self = object.__new__(cls) - if isinstance(hour, bytes) and len(hour) == 6: - # Pickle support - self.__setstate(hour, minute or None) - return self - _check_tzinfo_arg(tzinfo) - _check_time_fields(hour, minute, second, microsecond) - self._hour = hour - self._minute = minute - self._second = second - self._microsecond = microsecond - self._tzinfo = tzinfo - return self - - # Read-only field accessors - @property - def hour(self): - """hour (0-23)""" - return self._hour - - @property - def minute(self): - """minute (0-59)""" - return self._minute - - @property - def second(self): - """second (0-59)""" - return self._second - - @property - def microsecond(self): - """microsecond (0-999999)""" - return self._microsecond - - @property - def tzinfo(self): - """timezone info object""" - return self._tzinfo - - # Standard conversions, __hash__ (and helpers) - - # Comparisons of time objects with other. - - def __eq__(self, other): - if isinstance(other, time): - return self._cmp(other, allow_mixed=True) == 0 - else: - return False - - def __ne__(self, other): - if isinstance(other, time): - return self._cmp(other, allow_mixed=True) != 0 - else: - return True - - def __le__(self, other): - if isinstance(other, time): - return self._cmp(other) <= 0 - else: - _cmperror(self, other) - - def __lt__(self, other): - if isinstance(other, time): - return self._cmp(other) < 0 - else: - _cmperror(self, other) - - def __ge__(self, other): - if isinstance(other, time): - return self._cmp(other) >= 0 - else: - _cmperror(self, other) - - def __gt__(self, other): - if isinstance(other, time): - return self._cmp(other) > 0 - else: - _cmperror(self, other) - - def _cmp(self, other, allow_mixed=False): - assert isinstance(other, time) - mytz = self._tzinfo - ottz = other._tzinfo - myoff = otoff = None - - if mytz is ottz: - base_compare = True - else: - myoff = self.utcoffset() - otoff = other.utcoffset() - base_compare = myoff == otoff - - if base_compare: - return _cmp((self._hour, self._minute, self._second, - self._microsecond), - (other._hour, other._minute, other._second, - other._microsecond)) - if myoff is None or otoff is None: - if allow_mixed: - return 2 # arbitrary non-zero value - else: - raise TypeError("cannot compare naive and aware times") - myhhmm = self._hour * 60 + self._minute - myoff//timedelta(minutes=1) - othhmm = other._hour * 60 + other._minute - otoff//timedelta(minutes=1) - return _cmp((myhhmm, self._second, self._microsecond), - (othhmm, other._second, other._microsecond)) - - def __hash__(self): - """Hash.""" - tzoff = self.utcoffset() - if not tzoff: # zero or None - return hash(self._getstate()[0]) - h, m = divmod(timedelta(hours=self.hour, minutes=self.minute) - tzoff, - timedelta(hours=1)) - assert not m % timedelta(minutes=1), "whole minute" - m //= timedelta(minutes=1) - if 0 <= h < 24: - return hash(time(h, m, self.second, self.microsecond)) - return hash((h, m, self.second, self.microsecond)) - - # Conversion to string - - def _tzstr(self, sep=":"): - """Return formatted timezone offset (+xx:xx) or None.""" - off = self.utcoffset() - if off is not None: - if off.days < 0: - sign = "-" - off = -off - else: - sign = "+" - hh, mm = divmod(off, timedelta(hours=1)) - assert not mm % timedelta(minutes=1), "whole minute" - mm //= timedelta(minutes=1) - assert 0 <= hh < 24 - off = "%s%02d%s%02d" % (sign, hh, sep, mm) - return off - - def __repr__(self): - """Convert to formal string, for repr().""" - if self._microsecond != 0: - s = ", %d, %d" % (self._second, self._microsecond) - elif self._second != 0: - s = ", %d" % self._second - else: - s = "" - s= "%s(%d, %d%s)" % ('datetime.' + self.__class__.__name__, - self._hour, self._minute, s) - if self._tzinfo is not None: - assert s[-1:] == ")" - s = s[:-1] + ", tzinfo=%r" % self._tzinfo + ")" - return s - - def isoformat(self): - """Return the time formatted according to ISO. - - This is 'HH:MM:SS.mmmmmm+zz:zz', or 'HH:MM:SS+zz:zz' if - self.microsecond == 0. - """ - s = _format_time(self._hour, self._minute, self._second, - self._microsecond) - tz = self._tzstr() - if tz: - s += tz - return s - - __str__ = isoformat - - def strftime(self, fmt): - """Format using strftime(). The date part of the timestamp passed - to underlying strftime should not be used. - """ - # The year must be >= 1000 else Python's strftime implementation - # can raise a bogus exception. - timetuple = (1900, 1, 1, - self._hour, self._minute, self._second, - 0, 1, -1) - return _wrap_strftime(self, fmt, timetuple) - - def __format__(self, fmt): - if len(fmt) != 0: - return self.strftime(fmt) - return str(self) - - # Timezone functions - - def utcoffset(self): - """Return the timezone offset in minutes east of UTC (negative west of - UTC).""" - if self._tzinfo is None: - return None - offset = self._tzinfo.utcoffset(None) - _check_utc_offset("utcoffset", offset) - return offset - - def tzname(self): - """Return the timezone name. - - Note that the name is 100% informational -- there's no requirement that - it mean anything in particular. For example, "GMT", "UTC", "-500", - "-5:00", "EDT", "US/Eastern", "America/New York" are all valid replies. - """ - if self._tzinfo is None: - return None - name = self._tzinfo.tzname(None) - _check_tzname(name) - return name - - def dst(self): - """Return 0 if DST is not in effect, or the DST offset (in minutes - eastward) if DST is in effect. - - This is purely informational; the DST offset has already been added to - the UTC offset returned by utcoffset() if applicable, so there's no - need to consult dst() unless you're interested in displaying the DST - info. - """ - if self._tzinfo is None: - return None - offset = self._tzinfo.dst(None) - _check_utc_offset("dst", offset) - return offset - - def replace(self, hour=None, minute=None, second=None, microsecond=None, - tzinfo=True): - """Return a new time with new values for the specified fields.""" - if hour is None: - hour = self.hour - if minute is None: - minute = self.minute - if second is None: - second = self.second - if microsecond is None: - microsecond = self.microsecond - if tzinfo is True: - tzinfo = self.tzinfo - _check_time_fields(hour, minute, second, microsecond) - _check_tzinfo_arg(tzinfo) - return time(hour, minute, second, microsecond, tzinfo) - - def __bool__(self): - if self.second or self.microsecond: - return True - offset = self.utcoffset() or timedelta(0) - return timedelta(hours=self.hour, minutes=self.minute) != offset - - # Pickle support. - - def _getstate(self): - us2, us3 = divmod(self._microsecond, 256) - us1, us2 = divmod(us2, 256) - basestate = bytes([self._hour, self._minute, self._second, - us1, us2, us3]) - if self._tzinfo is None: - return (basestate,) - else: - return (basestate, self._tzinfo) - - def __setstate(self, string, tzinfo): - if len(string) != 6 or string[0] >= 24: - raise TypeError("an integer is required") - (self._hour, self._minute, self._second, - us1, us2, us3) = string - self._microsecond = (((us1 << 8) | us2) << 8) | us3 - if tzinfo is None or isinstance(tzinfo, _tzinfo_class): - self._tzinfo = tzinfo - else: - raise TypeError("bad tzinfo state arg %r" % tzinfo) - - def __reduce__(self): - return (time, self._getstate()) - -_time_class = time # so functions w/ args named "time" can get at the class - -time.min = time(0, 0, 0) -time.max = time(23, 59, 59, 999999) -time.resolution = timedelta(microseconds=1) - -class datetime(date): - """datetime(year, month, day[, hour[, minute[, second[, microsecond[,tzinfo]]]]]) - - The year, month and day arguments are required. tzinfo may be None, or an - instance of a tzinfo subclass. The remaining arguments may be ints. - """ - - __slots__ = date.__slots__ + ( - '_hour', '_minute', '_second', - '_microsecond', '_tzinfo') - def __new__(cls, year, month=None, day=None, hour=0, minute=0, second=0, - microsecond=0, tzinfo=None): - if isinstance(year, bytes) and len(year) == 10: - # Pickle support - self = date.__new__(cls, year[:4]) - self.__setstate(year, month) - return self - _check_tzinfo_arg(tzinfo) - _check_time_fields(hour, minute, second, microsecond) - self = date.__new__(cls, year, month, day) - self._hour = hour - self._minute = minute - self._second = second - self._microsecond = microsecond - self._tzinfo = tzinfo - return self - - # Read-only field accessors - @property - def hour(self): - """hour (0-23)""" - return self._hour - - @property - def minute(self): - """minute (0-59)""" - return self._minute - - @property - def second(self): - """second (0-59)""" - return self._second - - @property - def microsecond(self): - """microsecond (0-999999)""" - return self._microsecond - - @property - def tzinfo(self): - """timezone info object""" - return self._tzinfo - - @classmethod - def fromtimestamp(cls, t, tz=None): - """Construct a datetime from a POSIX timestamp (like time.time()). - - A timezone info object may be passed in as well. - """ - - _check_tzinfo_arg(tz) - - converter = _time.localtime if tz is None else _time.gmtime - - t, frac = divmod(t, 1.0) - us = int(frac * 1e6) - - # If timestamp is less than one microsecond smaller than a - # full second, us can be rounded up to 1000000. In this case, - # roll over to seconds, otherwise, ValueError is raised - # by the constructor. - if us == 1000000: - t += 1 - us = 0 - y, m, d, hh, mm, ss, weekday, jday, dst = converter(t) - ss = min(ss, 59) # clamp out leap seconds if the platform has them - result = cls(y, m, d, hh, mm, ss, us, tz) - if tz is not None: - result = tz.fromutc(result) - return result - - @classmethod - def utcfromtimestamp(cls, t): - "Construct a UTC datetime from a POSIX timestamp (like time.time())." - t, frac = divmod(t, 1.0) - us = int(frac * 1e6) - - # If timestamp is less than one microsecond smaller than a - # full second, us can be rounded up to 1000000. In this case, - # roll over to seconds, otherwise, ValueError is raised - # by the constructor. - if us == 1000000: - t += 1 - us = 0 - y, m, d, hh, mm, ss, weekday, jday, dst = _time.gmtime(t) - ss = min(ss, 59) # clamp out leap seconds if the platform has them - return cls(y, m, d, hh, mm, ss, us) - - # XXX This is supposed to do better than we *can* do by using time.time(), - # XXX if the platform supports a more accurate way. The C implementation - # XXX uses gettimeofday on platforms that have it, but that isn't - # XXX available from Python. So now() may return different results - # XXX across the implementations. - @classmethod - def now(cls, tz=None): - "Construct a datetime from time.time() and optional time zone info." - t = _time.time() - return cls.fromtimestamp(t, tz) - - @classmethod - def utcnow(cls): - "Construct a UTC datetime from time.time()." - t = _time.time() - return cls.utcfromtimestamp(t) - - @classmethod - def combine(cls, date, time): - "Construct a datetime from a given date and a given time." - if not isinstance(date, _date_class): - raise TypeError("date argument must be a date instance") - if not isinstance(time, _time_class): - raise TypeError("time argument must be a time instance") - return cls(date.year, date.month, date.day, - time.hour, time.minute, time.second, time.microsecond, - time.tzinfo) - - def timetuple(self): - "Return local time tuple compatible with time.localtime()." - dst = self.dst() - if dst is None: - dst = -1 - elif dst: - dst = 1 - else: - dst = 0 - return _build_struct_time(self.year, self.month, self.day, - self.hour, self.minute, self.second, - dst) - - def timestamp(self): - "Return POSIX timestamp as float" - if self._tzinfo is None: - return _time.mktime((self.year, self.month, self.day, - self.hour, self.minute, self.second, - -1, -1, -1)) + self.microsecond / 1e6 - else: - return (self - _EPOCH).total_seconds() - - def utctimetuple(self): - "Return UTC time tuple compatible with time.gmtime()." - offset = self.utcoffset() - if offset: - self -= offset - y, m, d = self.year, self.month, self.day - hh, mm, ss = self.hour, self.minute, self.second - return _build_struct_time(y, m, d, hh, mm, ss, 0) - - def date(self): - "Return the date part." - return date(self._year, self._month, self._day) - - def time(self): - "Return the time part, with tzinfo None." - return time(self.hour, self.minute, self.second, self.microsecond) - - def timetz(self): - "Return the time part, with same tzinfo." - return time(self.hour, self.minute, self.second, self.microsecond, - self._tzinfo) - - def replace(self, year=None, month=None, day=None, hour=None, - minute=None, second=None, microsecond=None, tzinfo=True): - """Return a new datetime with new values for the specified fields.""" - if year is None: - year = self.year - if month is None: - month = self.month - if day is None: - day = self.day - if hour is None: - hour = self.hour - if minute is None: - minute = self.minute - if second is None: - second = self.second - if microsecond is None: - microsecond = self.microsecond - if tzinfo is True: - tzinfo = self.tzinfo - _check_date_fields(year, month, day) - _check_time_fields(hour, minute, second, microsecond) - _check_tzinfo_arg(tzinfo) - return datetime(year, month, day, hour, minute, second, - microsecond, tzinfo) - - def astimezone(self, tz=None): - if tz is None: - if self.tzinfo is None: - raise ValueError("astimezone() requires an aware datetime") - ts = (self - _EPOCH) // timedelta(seconds=1) - localtm = _time.localtime(ts) - local = datetime(*localtm[:6]) - try: - # Extract TZ data if available - gmtoff = localtm.tm_gmtoff - zone = localtm.tm_zone - except AttributeError: - # Compute UTC offset and compare with the value implied - # by tm_isdst. If the values match, use the zone name - # implied by tm_isdst. - delta = local - datetime(*_time.gmtime(ts)[:6]) - dst = _time.daylight and localtm.tm_isdst > 0 - gmtoff = -(_time.altzone if dst else _time.timezone) - if delta == timedelta(seconds=gmtoff): - tz = timezone(delta, _time.tzname[dst]) - else: - tz = timezone(delta) - else: - tz = timezone(timedelta(seconds=gmtoff), zone) - - elif not isinstance(tz, tzinfo): - raise TypeError("tz argument must be an instance of tzinfo") - - mytz = self.tzinfo - if mytz is None: - raise ValueError("astimezone() requires an aware datetime") - - if tz is mytz: - return self - - # Convert self to UTC, and attach the new time zone object. - myoffset = self.utcoffset() - if myoffset is None: - raise ValueError("astimezone() requires an aware datetime") - utc = (self - myoffset).replace(tzinfo=tz) - - # Convert from UTC to tz's local time. - return tz.fromutc(utc) - - # Ways to produce a string. - - def ctime(self): - "Return ctime() style string." - weekday = self.toordinal() % 7 or 7 - return "%s %s %2d %02d:%02d:%02d %04d" % ( - _DAYNAMES[weekday], - _MONTHNAMES[self._month], - self._day, - self._hour, self._minute, self._second, - self._year) - - def isoformat(self, sep='T'): - """Return the time formatted according to ISO. - - This is 'YYYY-MM-DD HH:MM:SS.mmmmmm', or 'YYYY-MM-DD HH:MM:SS' if - self.microsecond == 0. - - If self.tzinfo is not None, the UTC offset is also attached, giving - 'YYYY-MM-DD HH:MM:SS.mmmmmm+HH:MM' or 'YYYY-MM-DD HH:MM:SS+HH:MM'. - - Optional argument sep specifies the separator between date and - time, default 'T'. - """ - s = ("%04d-%02d-%02d%s" % (self._year, self._month, self._day, - sep) + - _format_time(self._hour, self._minute, self._second, - self._microsecond)) - off = self.utcoffset() - if off is not None: - if off.days < 0: - sign = "-" - off = -off - else: - sign = "+" - hh, mm = divmod(off, timedelta(hours=1)) - assert not mm % timedelta(minutes=1), "whole minute" - mm //= timedelta(minutes=1) - s += "%s%02d:%02d" % (sign, hh, mm) - return s - - def __repr__(self): - """Convert to formal string, for repr().""" - L = [self._year, self._month, self._day, # These are never zero - self._hour, self._minute, self._second, self._microsecond] - if L[-1] == 0: - del L[-1] - if L[-1] == 0: - del L[-1] - s = ", ".join(map(str, L)) - s = "%s(%s)" % ('datetime.' + self.__class__.__name__, s) - if self._tzinfo is not None: - assert s[-1:] == ")" - s = s[:-1] + ", tzinfo=%r" % self._tzinfo + ")" - return s - - def __str__(self): - "Convert to string, for str()." - return self.isoformat(sep=' ') - - @classmethod - def strptime(cls, date_string, format): - 'string, format -> new datetime parsed from a string (like time.strptime()).' - import _strptime - return _strptime._strptime_datetime(cls, date_string, format) - - def utcoffset(self): - """Return the timezone offset in minutes east of UTC (negative west of - UTC).""" - if self._tzinfo is None: - return None - offset = self._tzinfo.utcoffset(self) - _check_utc_offset("utcoffset", offset) - return offset - - def tzname(self): - """Return the timezone name. - - Note that the name is 100% informational -- there's no requirement that - it mean anything in particular. For example, "GMT", "UTC", "-500", - "-5:00", "EDT", "US/Eastern", "America/New York" are all valid replies. - """ - name = _call_tzinfo_method(self._tzinfo, "tzname", self) - _check_tzname(name) - return name - - def dst(self): - """Return 0 if DST is not in effect, or the DST offset (in minutes - eastward) if DST is in effect. - - This is purely informational; the DST offset has already been added to - the UTC offset returned by utcoffset() if applicable, so there's no - need to consult dst() unless you're interested in displaying the DST - info. - """ - if self._tzinfo is None: - return None - offset = self._tzinfo.dst(self) - _check_utc_offset("dst", offset) - return offset - - # Comparisons of datetime objects with other. - - def __eq__(self, other): - if isinstance(other, datetime): - return self._cmp(other, allow_mixed=True) == 0 - elif not isinstance(other, date): - return NotImplemented - else: - return False - - def __ne__(self, other): - if isinstance(other, datetime): - return self._cmp(other, allow_mixed=True) != 0 - elif not isinstance(other, date): - return NotImplemented - else: - return True - - def __le__(self, other): - if isinstance(other, datetime): - return self._cmp(other) <= 0 - elif not isinstance(other, date): - return NotImplemented - else: - _cmperror(self, other) - - def __lt__(self, other): - if isinstance(other, datetime): - return self._cmp(other) < 0 - elif not isinstance(other, date): - return NotImplemented - else: - _cmperror(self, other) - - def __ge__(self, other): - if isinstance(other, datetime): - return self._cmp(other) >= 0 - elif not isinstance(other, date): - return NotImplemented - else: - _cmperror(self, other) - - def __gt__(self, other): - if isinstance(other, datetime): - return self._cmp(other) > 0 - elif not isinstance(other, date): - return NotImplemented - else: - _cmperror(self, other) - - def _cmp(self, other, allow_mixed=False): - assert isinstance(other, datetime) - mytz = self._tzinfo - ottz = other._tzinfo - myoff = otoff = None - - if mytz is ottz: - base_compare = True - else: - myoff = self.utcoffset() - otoff = other.utcoffset() - base_compare = myoff == otoff - - if base_compare: - return _cmp((self._year, self._month, self._day, - self._hour, self._minute, self._second, - self._microsecond), - (other._year, other._month, other._day, - other._hour, other._minute, other._second, - other._microsecond)) - if myoff is None or otoff is None: - if allow_mixed: - return 2 # arbitrary non-zero value - else: - raise TypeError("cannot compare naive and aware datetimes") - # XXX What follows could be done more efficiently... - diff = self - other # this will take offsets into account - if diff.days < 0: - return -1 - return diff and 1 or 0 - - def __add__(self, other): - "Add a datetime and a timedelta." - if not isinstance(other, timedelta): - return NotImplemented - delta = timedelta(self.toordinal(), - hours=self._hour, - minutes=self._minute, - seconds=self._second, - microseconds=self._microsecond) - delta += other - hour, rem = divmod(delta.seconds, 3600) - minute, second = divmod(rem, 60) - if 0 < delta.days <= _MAXORDINAL: - return datetime.combine(date.fromordinal(delta.days), - time(hour, minute, second, - delta.microseconds, - tzinfo=self._tzinfo)) - raise OverflowError("result out of range") - - __radd__ = __add__ - - def __sub__(self, other): - "Subtract two datetimes, or a datetime and a timedelta." - if not isinstance(other, datetime): - if isinstance(other, timedelta): - return self + -other - return NotImplemented - - days1 = self.toordinal() - days2 = other.toordinal() - secs1 = self._second + self._minute * 60 + self._hour * 3600 - secs2 = other._second + other._minute * 60 + other._hour * 3600 - base = timedelta(days1 - days2, - secs1 - secs2, - self._microsecond - other._microsecond) - if self._tzinfo is other._tzinfo: - return base - myoff = self.utcoffset() - otoff = other.utcoffset() - if myoff == otoff: - return base - if myoff is None or otoff is None: - raise TypeError("cannot mix naive and timezone-aware time") - return base + otoff - myoff - - def __hash__(self): - tzoff = self.utcoffset() - if tzoff is None: - return hash(self._getstate()[0]) - days = _ymd2ord(self.year, self.month, self.day) - seconds = self.hour * 3600 + self.minute * 60 + self.second - return hash(timedelta(days, seconds, self.microsecond) - tzoff) - - # Pickle support. - - def _getstate(self): - yhi, ylo = divmod(self._year, 256) - us2, us3 = divmod(self._microsecond, 256) - us1, us2 = divmod(us2, 256) - basestate = bytes([yhi, ylo, self._month, self._day, - self._hour, self._minute, self._second, - us1, us2, us3]) - if self._tzinfo is None: - return (basestate,) - else: - return (basestate, self._tzinfo) - - def __setstate(self, string, tzinfo): - (yhi, ylo, self._month, self._day, self._hour, - self._minute, self._second, us1, us2, us3) = string - self._year = yhi * 256 + ylo - self._microsecond = (((us1 << 8) | us2) << 8) | us3 - if tzinfo is None or isinstance(tzinfo, _tzinfo_class): - self._tzinfo = tzinfo - else: - raise TypeError("bad tzinfo state arg %r" % tzinfo) - - def __reduce__(self): - return (self.__class__, self._getstate()) - - -datetime.min = datetime(1, 1, 1) -datetime.max = datetime(9999, 12, 31, 23, 59, 59, 999999) -datetime.resolution = timedelta(microseconds=1) - - -def _isoweek1monday(year): - # Helper to calculate the day number of the Monday starting week 1 - # XXX This could be done more efficiently - THURSDAY = 3 - firstday = _ymd2ord(year, 1, 1) - firstweekday = (firstday + 6) % 7 # See weekday() above - week1monday = firstday - firstweekday - if firstweekday > THURSDAY: - week1monday += 7 - return week1monday class timezone(tzinfo): - __slots__ = '_offset', '_name' - - # Sentinel value to disallow None - _Omitted = object() - def __new__(cls, offset, name=_Omitted): - if not isinstance(offset, timedelta): - raise TypeError("offset must be a timedelta") - if name is cls._Omitted: - if not offset: - return cls.utc - name = None - elif not isinstance(name, str): - raise TypeError("name must be a string") - if not cls._minoffset <= offset <= cls._maxoffset: - raise ValueError("offset must be a timedelta" - " strictly between -timedelta(hours=24) and" - " timedelta(hours=24).") - if (offset.microseconds != 0 or - offset.seconds % 60 != 0): - raise ValueError("offset must be a timedelta" - " representing a whole number of minutes") - return cls._create(offset, name) - - @classmethod - def _create(cls, offset, name=None): - self = tzinfo.__new__(cls) + def __init__(self, offset, name=None): + if not (abs(offset._us) < 86_400_000_000): + raise ValueError self._offset = offset self._name = name - return self - - def __getinitargs__(self): - """pickle support""" - if self._name is None: - return (self._offset,) - return (self._offset, self._name) - - def __eq__(self, other): - if type(other) != timezone: - return False - return self._offset == other._offset - - def __hash__(self): - return hash(self._offset) def __repr__(self): - """Convert to formal string, for repr(). + return "datetime.timezone({}, {})".format(repr(self._offset), repr(self._name)) - >>> tz = timezone.utc - >>> repr(tz) - 'datetime.timezone.utc' - >>> tz = timezone(timedelta(hours=-5), 'EST') - >>> repr(tz) - "datetime.timezone(datetime.timedelta(-1, 68400), 'EST')" - """ - if self is self.utc: - return 'datetime.timezone.utc' - if self._name is None: - return "%s(%r)" % ('datetime.' + self.__class__.__name__, - self._offset) - return "%s(%r, %r)" % ('datetime.' + self.__class__.__name__, - self._offset, self._name) + def __eq__(self, other): + if isinstance(other, timezone): + return self._offset == other._offset + return NotImplemented def __str__(self): return self.tzname(None) - def utcoffset(self, dt): - if isinstance(dt, datetime) or dt is None: - return self._offset - raise TypeError("utcoffset() argument must be a datetime instance" - " or None") + def __hash__(self): + if not hasattr(self, "_hash"): + self._hash = hash((self._offset, self._name)) + return self._hash - def tzname(self, dt): - if isinstance(dt, datetime) or dt is None: - if self._name is None: - return self._name_from_offset(self._offset) - return self._name - raise TypeError("tzname() argument must be a datetime instance" - " or None") + def utcoffset(self, dt): + return self._offset def dst(self, dt): - if isinstance(dt, datetime) or dt is None: - return None - raise TypeError("dst() argument must be a datetime instance" - " or None") + return None + + def tzname(self, dt): + if self._name: + return self._name + return self._offset._format(0x22) def fromutc(self, dt): - if isinstance(dt, datetime): - if dt.tzinfo is not self: - raise ValueError("fromutc: dt.tzinfo " - "is not self") - return dt + self._offset - raise TypeError("fromutc() argument must be a datetime instance" - " or None") + return dt + self._offset - _maxoffset = timedelta(hours=23, minutes=59) - _minoffset = -_maxoffset - @staticmethod - def _name_from_offset(delta): - if delta < timedelta(0): - sign = '-' - delta = -delta +timezone.utc = timezone(timedelta(0)) + + +def _date(y, m, d): + if MINYEAR <= y <= MAXYEAR and 1 <= m <= 12 and 1 <= d <= _dim(y, m): + return _ymd2o(y, m, d) + elif y == 0 and m == 0 and 1 <= d <= 3_652_059: + return d + else: + raise ValueError + + +def _iso2d(s): # ISO -> date + if len(s) < 10 or s[4] != "-" or s[7] != "-": + raise ValueError + return int(s[0:4]), int(s[5:7]), int(s[8:10]) + + +def _d2iso(o): # date -> ISO + return "%04d-%02d-%02d" % _o2ymd(o) + + +class date: + def __init__(self, year, month, day): + self._ord = _date(year, month, day) + + @classmethod + def fromtimestamp(cls, ts): + return cls(*_tmod.localtime(ts)[:3]) + + @classmethod + def today(cls): + return cls(*_tmod.localtime()[:3]) + + @classmethod + def fromordinal(cls, n): + return cls(0, 0, n) + + @classmethod + def fromisoformat(cls, s): + return cls(*_iso2d(s)) + + @property + def year(self): + return self.tuple()[0] + + @property + def month(self): + return self.tuple()[1] + + @property + def day(self): + return self.tuple()[2] + + def toordinal(self): + return self._ord + + def timetuple(self): + y, m, d = self.tuple() + yday = _dbm(y, m) + d + return (y, m, d, 0, 0, 0, self.weekday(), yday, -1) + + def replace(self, year=None, month=None, day=None): + year_, month_, day_ = self.tuple() + if year is None: + year = year_ + if month is None: + month = month_ + if day is None: + day = day_ + return date(year, month, day) + + def __add__(self, other): + return date.fromordinal(self._ord + other.days) + + def __sub__(self, other): + if isinstance(other, date): + return timedelta(days=self._ord - other._ord) else: - sign = '+' - hours, rest = divmod(delta, timedelta(hours=1)) - minutes = rest // timedelta(minutes=1) - return 'UTC{}{:02d}:{:02d}'.format(sign, hours, minutes) + return date.fromordinal(self._ord - other.days) -timezone.utc = timezone._create(timedelta(0)) -timezone.min = timezone._create(timezone._minoffset) -timezone.max = timezone._create(timezone._maxoffset) -_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) -""" -Some time zone algebra. For a datetime x, let - x.n = x stripped of its timezone -- its naive time. - x.o = x.utcoffset(), and assuming that doesn't raise an exception or - return None - x.d = x.dst(), and assuming that doesn't raise an exception or - return None - x.s = x's standard offset, x.o - x.d + def __eq__(self, other): + if isinstance(other, date): + return self._ord == other._ord + else: + return False -Now some derived rules, where k is a duration (timedelta). + def __le__(self, other): + return self._ord <= other._ord -1. x.o = x.s + x.d - This follows from the definition of x.s. + def __lt__(self, other): + return self._ord < other._ord -2. If x and y have the same tzinfo member, x.s = y.s. - This is actually a requirement, an assumption we need to make about - sane tzinfo classes. + def __ge__(self, other): + return self._ord >= other._ord -3. The naive UTC time corresponding to x is x.n - x.o. - This is again a requirement for a sane tzinfo class. + def __gt__(self, other): + return self._ord > other._ord -4. (x+k).s = x.s - This follows from #2, and that datimetimetz+timedelta preserves tzinfo. + def weekday(self): + return (self._ord + 6) % 7 -5. (x+k).n = x.n + k - Again follows from how arithmetic is defined. + def isoweekday(self): + return self._ord % 7 or 7 -Now we can explain tz.fromutc(x). Let's assume it's an interesting case -(meaning that the various tzinfo methods exist, and don't blow up or return -None when called). + def isoformat(self): + return _d2iso(self._ord) -The function wants to return a datetime y with timezone tz, equivalent to x. -x is already in UTC. + def __repr__(self): + return "datetime.date(0, 0, {})".format(self._ord) -By #3, we want + __str__ = isoformat - y.n - y.o = x.n [1] + def __hash__(self): + if not hasattr(self, "_hash"): + self._hash = hash(self._ord) + return self._hash -The algorithm starts by attaching tz to x.n, and calling that y. So -x.n = y.n at the start. Then it wants to add a duration k to y, so that [1] -becomes true; in effect, we want to solve [2] for k: + def tuple(self): + return _o2ymd(self._ord) - (y+k).n - (y+k).o = x.n [2] -By #1, this is the same as +date.min = date(MINYEAR, 1, 1) +date.max = date(MAXYEAR, 12, 31) +date.resolution = timedelta(days=1) - (y+k).n - ((y+k).s + (y+k).d) = x.n [3] -By #5, (y+k).n = y.n + k, which equals x.n + k because x.n=y.n at the start. -Substituting that into [3], +def _time(h, m, s, us, fold): + if ( + 0 <= h < 24 + and 0 <= m < 60 + and 0 <= s < 60 + and 0 <= us < 1_000_000 + and (fold == 0 or fold == 1) + ) or (h == 0 and m == 0 and s == 0 and 0 < us < 86_400_000_000): + return timedelta(0, s, us, 0, m, h) + else: + raise ValueError - x.n + k - (y+k).s - (y+k).d = x.n; the x.n terms cancel, leaving - k - (y+k).s - (y+k).d = 0; rearranging, - k = (y+k).s - (y+k).d; by #4, (y+k).s == y.s, so - k = y.s - (y+k).d -On the RHS, (y+k).d can't be computed directly, but y.s can be, and we -approximate k by ignoring the (y+k).d term at first. Note that k can't be -very large, since all offset-returning methods return a duration of magnitude -less than 24 hours. For that reason, if y is firmly in std time, (y+k).d must -be 0, so ignoring it has no consequence then. +def _iso2t(s): + hour = 0 + minute = 0 + sec = 0 + usec = 0 + tz_sign = "" + tz_hour = 0 + tz_minute = 0 + tz_sec = 0 + tz_usec = 0 + l = len(s) + i = 0 + if l < 2: + raise ValueError + i += 2 + hour = int(s[i - 2 : i]) + if l > i and s[i] == ":": + i += 3 + if l - i < 0: + raise ValueError + minute = int(s[i - 2 : i]) + if l > i and s[i] == ":": + i += 3 + if l - i < 0: + raise ValueError + sec = int(s[i - 2 : i]) + if l > i and s[i] == ".": + i += 4 + if l - i < 0: + raise ValueError + usec = 1000 * int(s[i - 3 : i]) + if l > i and s[i] != "+": + i += 3 + if l - i < 0: + raise ValueError + usec += int(s[i - 3 : i]) + if l > i: + if s[i] not in "+-": + raise ValueError + tz_sign = s[i] + i += 6 + if l - i < 0: + raise ValueError + tz_hour = int(s[i - 5 : i - 3]) + tz_minute = int(s[i - 2 : i]) + if l > i and s[i] == ":": + i += 3 + if l - i < 0: + raise ValueError + tz_sec = int(s[i - 2 : i]) + if l > i and s[i] == ".": + i += 7 + if l - i < 0: + raise ValueError + tz_usec = int(s[i - 6 : i]) + if l != i: + raise ValueError + if tz_sign: + td = timedelta(hours=tz_hour, minutes=tz_minute, seconds=tz_sec, microseconds=tz_usec) + if tz_sign == "-": + td = -td + tz = timezone(td) + else: + tz = None + return hour, minute, sec, usec, tz -In any case, the new value is - z = y + y.s [4] +def _t2iso(td, timespec, dt, tz): + s = td._format(_TIME_SPEC.index(timespec)) + if tz is not None: + s += tz.isoformat(dt) + return s -It's helpful to step back at look at [4] from a higher level: it's simply -mapping from UTC to tz's standard time. -At this point, if +class time: + def __init__(self, hour=0, minute=0, second=0, microsecond=0, tzinfo=None, *, fold=0): + self._td = _time(hour, minute, second, microsecond, fold) + self._tz = tzinfo + self._fd = fold - z.n - z.o = x.n [5] + @classmethod + def fromisoformat(cls, s): + return cls(*_iso2t(s)) -we have an equivalent time, and are almost done. The insecurity here is -at the start of daylight time. Picture US Eastern for concreteness. The wall -time jumps from 1:59 to 3:00, and wall hours of the form 2:MM don't make good -sense then. The docs ask that an Eastern tzinfo class consider such a time to -be EDT (because it's "after 2"), which is a redundant spelling of 1:MM EST -on the day DST starts. We want to return the 1:MM EST spelling because that's -the only spelling that makes sense on the local wall clock. + @property + def hour(self): + return self.tuple()[0] -In fact, if [5] holds at this point, we do have the standard-time spelling, -but that takes a bit of proof. We first prove a stronger result. What's the -difference between the LHS and RHS of [5]? Let + @property + def minute(self): + return self.tuple()[1] - diff = x.n - (z.n - z.o) [6] + @property + def second(self): + return self.tuple()[2] -Now - z.n = by [4] - (y + y.s).n = by #5 - y.n + y.s = since y.n = x.n - x.n + y.s = since z and y are have the same tzinfo member, - y.s = z.s by #2 - x.n + z.s + @property + def microsecond(self): + return self.tuple()[3] -Plugging that back into [6] gives + @property + def tzinfo(self): + return self._tz - diff = - x.n - ((x.n + z.s) - z.o) = expanding - x.n - x.n - z.s + z.o = cancelling - - z.s + z.o = by #2 - z.d + @property + def fold(self): + return self._fd -So diff = z.d. + def replace( + self, hour=None, minute=None, second=None, microsecond=None, tzinfo=True, *, fold=None + ): + h, m, s, us, tz, fl = self.tuple() + if hour is None: + hour = h + if minute is None: + minute = m + if second is None: + second = s + if microsecond is None: + microsecond = us + if tzinfo is True: + tzinfo = tz + if fold is None: + fold = fl + return time(hour, minute, second, microsecond, tzinfo, fold=fold) -If [5] is true now, diff = 0, so z.d = 0 too, and we have the standard-time -spelling we wanted in the endcase described above. We're done. Contrarily, -if z.d = 0, then we have a UTC equivalent, and are also done. + def isoformat(self, timespec="auto"): + return _t2iso(self._td, timespec, None, self._tz) -If [5] is not true now, diff = z.d != 0, and z.d is the offset we need to -add to z (in effect, z is in tz's standard time, and we need to shift the -local clock into tz's daylight time). + def __repr__(self): + return "datetime.time(microsecond={}, tzinfo={}, fold={})".format( + self._td._us, repr(self._tz), self._fd + ) -Let + __str__ = isoformat - z' = z + z.d = z + diff [7] + def __bool__(self): + return True -and we can again ask whether + def __eq__(self, other): + if (self._tz == None) ^ (other._tz == None): + return False + return self._sub(other) == 0 - z'.n - z'.o = x.n [8] + def __le__(self, other): + return self._sub(other) <= 0 -If so, we're done. If not, the tzinfo class is insane, according to the -assumptions we've made. This also requires a bit of proof. As before, let's -compute the difference between the LHS and RHS of [8] (and skipping some of -the justifications for the kinds of substitutions we've done several times -already): + def __lt__(self, other): + return self._sub(other) < 0 - diff' = x.n - (z'.n - z'.o) = replacing z'.n via [7] - x.n - (z.n + diff - z'.o) = replacing diff via [6] - x.n - (z.n + x.n - (z.n - z.o) - z'.o) = - x.n - z.n - x.n + z.n - z.o + z'.o = cancel x.n - - z.n + z.n - z.o + z'.o = cancel z.n - - z.o + z'.o = #1 twice - -z.s - z.d + z'.s + z'.d = z and z' have same tzinfo - z'.d - z.d + def __ge__(self, other): + return self._sub(other) >= 0 -So z' is UTC-equivalent to x iff z'.d = z.d at this point. If they are equal, -we've found the UTC-equivalent so are done. In fact, we stop with [7] and -return z', not bothering to compute z'.d. + def __gt__(self, other): + return self._sub(other) > 0 -How could z.d and z'd differ? z' = z + z.d [7], so merely moving z' by -a dst() offset, and starting *from* a time already in DST (we know z.d != 0), -would have to change the result dst() returns: we start in DST, and moving -a little further into it takes us out of DST. + def _sub(self, other): + tz1 = self._tz + if (tz1 is None) ^ (other._tz is None): + raise TypeError + us1 = self._td._us + us2 = other._td._us + if tz1 is not None: + os1 = self.utcoffset()._us + os2 = other.utcoffset()._us + if os1 != os2: + us1 -= os1 + us2 -= os2 + return us1 - us2 -There isn't a sane case where this can happen. The closest it gets is at -the end of DST, where there's an hour in UTC with no spelling in a hybrid -tzinfo class. In US Eastern, that's 5:MM UTC = 0:MM EST = 1:MM EDT. During -that hour, on an Eastern clock 1:MM is taken as being in standard time (6:MM -UTC) because the docs insist on that, but 0:MM is taken as being in daylight -time (4:MM UTC). There is no local time mapping to 5:MM UTC. The local -clock jumps from 1:59 back to 1:00 again, and repeats the 1:MM hour in -standard time. Since that's what the local clock *does*, we want to map both -UTC hours 5:MM and 6:MM to 1:MM Eastern. The result is ambiguous -in local time, but so it goes -- it's the way the local clock works. + def __hash__(self): + if not hasattr(self, "_hash"): + # fold doesn't make any difference + self._hash = hash((self._td, self._tz)) + return self._hash -When x = 5:MM UTC is the input to this algorithm, x.o=0, y.o=-5 and y.d=0, -so z=0:MM. z.d=60 (minutes) then, so [5] doesn't hold and we keep going. -z' = z + z.d = 1:MM then, and z'.d=0, and z'.d - z.d = -60 != 0 so [8] -(correctly) concludes that z' is not UTC-equivalent to x. + def utcoffset(self): + return None if self._tz is None else self._tz.utcoffset(None) -Because we know z.d said z was in daylight time (else [5] would have held and -we would have stopped then), and we know z.d != z'.d (else [8] would have held -and we have stopped then), and there are only 2 possible values dst() can -return in Eastern, it follows that z'.d must be 0 (which it is in the example, -but the reasoning doesn't depend on the example -- it depends on there being -two possible dst() outcomes, one zero and the other non-zero). Therefore -z' must be in standard time, and is the spelling we want in this case. + def dst(self): + return None if self._tz is None else self._tz.dst(None) -Note again that z' is not UTC-equivalent as far as the hybrid tzinfo class is -concerned (because it takes z' as being in standard time rather than the -daylight time we intend here), but returning it gives the real-life "local -clock repeats an hour" behavior when mapping the "unspellable" UTC hour into -tz. + def tzname(self): + return None if self._tz is None else self._tz.tzname(None) -When the input is 6:MM, z=1:MM and z.d=0, and we stop at once, again with -the 1:MM standard time spelling we want. + def tuple(self): + d, h, m, s, us = self._td.tuple() + return h, m, s, us, self._tz, self._fd -So how can this break? One of the assumptions must be violated. Two -possibilities: -1) [2] effectively says that y.s is invariant across all y belong to a given - time zone. This isn't true if, for political reasons or continental drift, - a region decides to change its base offset from UTC. +time.min = time(0) +time.max = time(23, 59, 59, 999_999) +time.resolution = timedelta.resolution -2) There may be versions of "double daylight" time where the tail end of - the analysis gives up a step too early. I haven't thought about that - enough to say. -In any case, it's clear that the default fromutc() is strong enough to handle -"almost all" time zones: so long as the standard offset is invariant, it -doesn't matter if daylight time transition points change from year to year, or -if daylight time is skipped in some years; it doesn't matter how large or -small dst() may get within its bounds; and it doesn't even matter if some -perverse time zone returns a negative dst()). So a breaking case must be -pretty bizarre, and a tzinfo subclass can override fromutc() if it is. -""" -try: - from _datetime import * -except ImportError: - pass -else: - # Clean up unused names - del (_DAYNAMES, _DAYS_BEFORE_MONTH, _DAYS_IN_MONTH, - _DI100Y, _DI400Y, _DI4Y, _MAXORDINAL, _MONTHNAMES, - _build_struct_time, _call_tzinfo_method, _check_date_fields, - _check_time_fields, _check_tzinfo_arg, _check_tzname, - _check_utc_offset, _cmp, _cmperror, _date_class, _days_before_month, - _days_before_year, _days_in_month, _format_time, _is_leap, - _isoweek1monday, _math, _ord2ymd, _time, _time_class, _tzinfo_class, - _wrap_strftime, _ymd2ord) - # XXX Since import * above excludes names that start with _, - # docstring does not get overwritten. In the future, it may be - # appropriate to maintain a single module level docstring and - # remove the following line. - from _datetime import __doc__ +class datetime: + def __init__( + self, year, month, day, hour=0, minute=0, second=0, microsecond=0, tzinfo=None, *, fold=0 + ): + self._d = _date(year, month, day) + self._t = _time(hour, minute, second, microsecond, fold) + self._tz = tzinfo + self._fd = fold + + @classmethod + def fromtimestamp(cls, ts, tz=None): + if isinstance(ts, float): + ts, us = divmod(round(ts * 1_000_000), 1_000_000) + else: + us = 0 + if tz is None: + raise NotImplementedError + else: + dt = cls(*_tmod.gmtime(ts)[:6], microsecond=us, tzinfo=tz) + dt = tz.fromutc(dt) + return dt + + @classmethod + def now(cls, tz=None): + return cls.fromtimestamp(_tmod.time(), tz) + + @classmethod + def fromordinal(cls, n): + return cls(0, 0, n) + + @classmethod + def fromisoformat(cls, s): + d = _iso2d(s) + if len(s) <= 12: + return cls(*d) + t = _iso2t(s[11:]) + return cls(*(d + t)) + + @classmethod + def combine(cls, date, time, tzinfo=None): + return cls( + 0, 0, date.toordinal(), 0, 0, 0, time._td._us, tzinfo or time._tz, fold=time._fd + ) + + @property + def year(self): + return _o2ymd(self._d)[0] + + @property + def month(self): + return _o2ymd(self._d)[1] + + @property + def day(self): + return _o2ymd(self._d)[2] + + @property + def hour(self): + return self._t.tuple()[1] + + @property + def minute(self): + return self._t.tuple()[2] + + @property + def second(self): + return self._t.tuple()[3] + + @property + def microsecond(self): + return self._t.tuple()[4] + + @property + def tzinfo(self): + return self._tz + + @property + def fold(self): + return self._fd + + def __add__(self, other): + us = self._t._us + other._us + d, us = divmod(us, 86_400_000_000) + d += self._d + return datetime(0, 0, d, 0, 0, 0, us, self._tz) + + def __sub__(self, other): + if isinstance(other, timedelta): + return self.__add__(-other) + elif isinstance(other, datetime): + d, us = self._sub(other) + return timedelta(d, 0, us) + else: + raise TypeError + + def _sub(self, other): + # Subtract two datetime instances. + tz1 = self._tz + if (tz1 is None) ^ (other._tz is None): + raise TypeError + dt1 = self + dt2 = other + if tz1 is not None: + os1 = dt1.utcoffset() + os2 = dt2.utcoffset() + if os1 != os2: + dt1 -= os1 + dt2 -= os2 + D = dt1._d - dt2._d + us = dt1._t._us - dt2._t._us + d, us = divmod(us, 86_400_000_000) + return D + d, us + + def __eq__(self, other): + if (self._tz == None) ^ (other._tz == None): + return False + return self._cmp(other) == 0 + + def __le__(self, other): + return self._cmp(other) <= 0 + + def __lt__(self, other): + return self._cmp(other) < 0 + + def __ge__(self, other): + return self._cmp(other) >= 0 + + def __gt__(self, other): + return self._cmp(other) > 0 + + def _cmp(self, other): + # Compare two datetime instances. + d, us = self._sub(other) + if d < 0: + return -1 + if d > 0: + return 1 + + if us < 0: + return -1 + if us > 0: + return 1 + + return 0 + + def date(self): + return date.fromordinal(self._d) + + def time(self): + return time(microsecond=self._t._us, fold=self._fd) + + def timetz(self): + return time(microsecond=self._t._us, tzinfo=self._tz, fold=self._fd) + + def replace( + self, + year=None, + month=None, + day=None, + hour=None, + minute=None, + second=None, + microsecond=None, + tzinfo=True, + *, + fold=None, + ): + Y, M, D, h, m, s, us, tz, fl = self.tuple() + if year is None: + year = Y + if month is None: + month = M + if day is None: + day = D + if hour is None: + hour = h + if minute is None: + minute = m + if second is None: + second = s + if microsecond is None: + microsecond = us + if tzinfo is True: + tzinfo = tz + if fold is None: + fold = fl + return datetime(year, month, day, hour, minute, second, microsecond, tzinfo, fold=fold) + + def astimezone(self, tz=None): + if self._tz is tz: + return self + _tz = self._tz + if _tz is None: + raise NotImplementedError + else: + os = _tz.utcoffset(self) + utc = self - os + utc = utc.replace(tzinfo=tz) + return tz.fromutc(utc) + + def utcoffset(self): + return None if self._tz is None else self._tz.utcoffset(self) + + def dst(self): + return None if self._tz is None else self._tz.dst(self) + + def tzname(self): + return None if self._tz is None else self._tz.tzname(self) + + def timetuple(self): + if self._tz is None: + conv = _tmod.gmtime + epoch = datetime.EPOCH.replace(tzinfo=None) + else: + conv = _tmod.localtime + epoch = datetime.EPOCH + return conv(round((self - epoch).total_seconds())) + + def toordinal(self): + return self._d + + def timestamp(self): + if self._tz is None: + raise NotImplementedError + else: + return (self - datetime.EPOCH).total_seconds() + + def weekday(self): + return (self._d + 6) % 7 + + def isoweekday(self): + return self._d % 7 or 7 + + def isoformat(self, sep="T", timespec="auto"): + return _d2iso(self._d) + sep + _t2iso(self._t, timespec, self, self._tz) + + def __repr__(self): + Y, M, D, h, m, s, us, tz, fold = self.tuple() + tz = repr(tz) + return "datetime.datetime({}, {}, {}, {}, {}, {}, {}, {}, fold={})".format( + Y, M, D, h, m, s, us, tz, fold + ) + + def __str__(self): + return self.isoformat(" ") + + def __hash__(self): + if not hasattr(self, "_hash"): + self._hash = hash((self._d, self._t, self._tz)) + return self._hash + + def tuple(self): + d = _o2ymd(self._d) + t = self._t.tuple()[1:] + return d + t + (self._tz, self._fd) + + +datetime.EPOCH = datetime(*_tmod.gmtime(0)[:6], tzinfo=timezone.utc) diff --git a/libs/micropython/ffilib.py b/libs/micropython/ffilib.py index dc4d672..e79448f 100644 --- a/libs/micropython/ffilib.py +++ b/libs/micropython/ffilib.py @@ -1,4 +1,5 @@ import sys + try: import ffi except ImportError: @@ -6,6 +7,7 @@ except ImportError: _cache = {} + def open(name, maxver=10, extra=()): if not ffi: return None @@ -13,16 +15,18 @@ def open(name, maxver=10, extra=()): return _cache[name] except KeyError: pass + def libs(): if sys.platform == "linux": - yield '%s.so' % name + yield "%s.so" % name for i in range(maxver, -1, -1): - yield '%s.so.%u' % (name, i) + yield "%s.so.%u" % (name, i) else: - for ext in ('dylib', 'dll'): - yield '%s.%s' % (name, ext) + for ext in ("dylib", "dll"): + yield "%s.%s" % (name, ext) for n in extra: yield n + err = None for n in libs(): try: @@ -33,9 +37,11 @@ def open(name, maxver=10, extra=()): err = e raise err + def libc(): return open("libc", 6) + # Find out bitness of the platform, even if long ints are not supported # TODO: All bitness differences should be removed from micropython-lib, and # this snippet too. diff --git a/libs/micropython/time.py b/libs/micropython/time.py index f46a076..68f7d92 100644 --- a/libs/micropython/time.py +++ b/libs/micropython/time.py @@ -1,76 +1,79 @@ from utime import * -from ucollections import namedtuple -import ustruct -import uctypes -import ffi -import ffilib -import array +from micropython import const -libc = ffilib.libc() +_TS_YEAR = const(0) +_TS_MON = const(1) +_TS_MDAY = const(2) +_TS_HOUR = const(3) +_TS_MIN = const(4) +_TS_SEC = const(5) +_TS_WDAY = const(6) +_TS_YDAY = const(7) +_TS_ISDST = const(8) -# struct tm *gmtime(const time_t *timep); -# struct tm *localtime(const time_t *timep); -# size_t strftime(char *s, size_t max, const char *format, -# const struct tm *tm); -gmtime_ = libc.func("P", "gmtime", "P") -localtime_ = libc.func("P", "localtime", "P") -strftime_ = libc.func("i", "strftime", "sisP") -mktime_ = libc.func("i", "mktime", "P") - -_struct_time = namedtuple("struct_time", - ["tm_year", "tm_mon", "tm_mday", "tm_hour", "tm_min", "tm_sec", "tm_wday", "tm_yday", "tm_isdst"]) - -def _tuple_to_c_tm(t): - return ustruct.pack("@iiiiiiiii", t[5], t[4], t[3], t[2], t[1] - 1, t[0] - 1900, (t[6] + 1) % 7, t[7] - 1, t[8]) +_WDAY = const(("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")) +_MDAY = const( + ( + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ) +) -def _c_tm_to_tuple(tm): - t = ustruct.unpack("@iiiiiiiii", tm) - return _struct_time(t[5] + 1900, t[4] + 1, t[3], t[2], t[1], t[0], (t[6] - 1) % 7, t[7] + 1, t[8]) +def strftime(datefmt, ts): + from io import StringIO -def struct_time(tm): - return _struct_time(*tm) - - -def strftime(format, t=None): - if t is None: - t = localtime() - - buf = bytearray(32) - l = strftime_(buf, 32, format, _tuple_to_c_tm(t)) - return str(buf[:l], "utf-8") - - -def localtime(t=None): - if t is None: - t = time() - - t = int(t) - a = ustruct.pack('l', t) - tm_p = localtime_(a) - return _c_tm_to_tuple(uctypes.bytearray_at(tm_p, 36)) - - -def gmtime(t=None): - if t is None: - t = time() - - t = int(t) - a = ustruct.pack('l', t) - tm_p = gmtime_(a) - return _c_tm_to_tuple(uctypes.bytearray_at(tm_p, 36)) - - -def mktime(tt): - return mktime_(_tuple_to_c_tm(tt)) - - -def perf_counter(): - return time() - -def process_time(): - return clock() - - -daylight = 0 -timezone = 0 + fmtsp = False + ftime = StringIO() + for k in datefmt: + if fmtsp: + if k == "a": + ftime.write(_WDAY[ts[_TS_WDAY]][0:3]) + elif k == "A": + ftime.write(_WDAY[ts[_TS_WDAY]]) + elif k == "b": + ftime.write(_MDAY[ts[_TS_MON] - 1][0:3]) + elif k == "B": + ftime.write(_MDAY[ts[_TS_MON] - 1]) + elif k == "d": + ftime.write("%02d" % ts[_TS_MDAY]) + elif k == "H": + ftime.write("%02d" % ts[_TS_HOUR]) + elif k == "I": + ftime.write("%02d" % (ts[_TS_HOUR] % 12)) + elif k == "j": + ftime.write("%03d" % ts[_TS_YDAY]) + elif k == "m": + ftime.write("%02d" % ts[_TS_MON]) + elif k == "M": + ftime.write("%02d" % ts[_TS_MIN]) + elif k == "P": + ftime.write("AM" if ts[_TS_HOUR] < 12 else "PM") + elif k == "S": + ftime.write("%02d" % ts[_TS_SEC]) + elif k == "w": + ftime.write(str(ts[_TS_WDAY])) + elif k == "y": + ftime.write("%02d" % (ts[_TS_YEAR] % 100)) + elif k == "Y": + ftime.write(str(ts[_TS_YEAR])) + else: + ftime.write(k) + fmtsp = False + elif k == "%": + fmtsp = True + else: + ftime.write(k) + val = ftime.getvalue() + ftime.close() + return val diff --git a/libs/micropython/unittest.py b/libs/micropython/unittest.py index ac24e75..f15f304 100644 --- a/libs/micropython/unittest.py +++ b/libs/micropython/unittest.py @@ -300,9 +300,10 @@ class TestResult: return self.errorsNum == 0 and self.failuresNum == 0 def printErrors(self): - print() - self.printErrorList(self.errors) - self.printErrorList(self.failures) + if self.errors or self.failures: + print() + self.printErrorList(self.errors) + self.printErrorList(self.failures) def printErrorList(self, lst): sep = "----------------------------------------------------------------------" diff --git a/libs/refresh.sh b/libs/refresh.sh new file mode 100755 index 0000000..273dd7f --- /dev/null +++ b/libs/refresh.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# this script downloads updated versions of all the MicroPython libraries + +cd micropython +curl https://codeload.github.com/micropython/micropython/tar.gz/master | tar -xz --strip=2 micropython-master/extmod/asyncio +curl https://raw.githubusercontent.com/micropython/micropython-lib/master/python-stdlib/datetime/datetime.py > datetime.py +curl https://raw.githubusercontent.com/micropython/micropython-lib/d8e163bb5f3ef45e71e145c27bc4f207beaad70f/unix-ffi/ffilib/ffilib.py > ffilib.py +curl https://raw.githubusercontent.com/micropython/micropython-lib/master/python-stdlib/hmac/hmac.py > hmac.py +curl https://raw.githubusercontent.com/micropython/micropython-lib/master/python-stdlib/time/time.py > time.py +curl https://raw.githubusercontent.com/micropython/micropython-lib/master/python-stdlib/unittest/unittest/__init__.py > unittest.py +cd ../common +curl https://raw.githubusercontent.com/pfalcon/utemplate/master/README.md > utemplate/README.md +curl https://codeload.github.com/pfalcon/utemplate/tar.gz/master | tar -xz --strip=1 utemplate-master/utemplate +cd .. diff --git a/pyproject.toml b/pyproject.toml index 9488cfb..f9c9ab9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] +requires-python = ">=3.8" [project.readme] file = "README.md" @@ -30,26 +31,16 @@ docs = [ [tool.setuptools] zip-safe = false include-package-data = true -py-modules = [ - "microdot", - "microdot_asyncio", - "microdot_utemplate", - "microdot_jinja", - "microdot_session", - "microdot_cors", - "microdot_websocket", - "microdot_websocket_alt", - "microdot_asyncio_websocket", - "microdot_test_client", - "microdot_asyncio_test_client", - "microdot_wsgi", - "microdot_asgi", - "microdot_asgi_websocket", -] [tool.setuptools.package-dir] "" = "src" +[tool.setuptools.packages.find] +where = [ + "src", +] +namespaces = false + [build-system] requires = [ "setuptools>=61.2", diff --git a/src/microdot/__init__.py b/src/microdot/__init__.py new file mode 100644 index 0000000..b619686 --- /dev/null +++ b/src/microdot/__init__.py @@ -0,0 +1,2 @@ +from microdot.microdot import Microdot, Request, Response, abort, redirect, \ + send_file # noqa: F401 diff --git a/src/microdot_asgi.py b/src/microdot/asgi.py similarity index 58% rename from src/microdot_asgi.py rename to src/microdot/asgi.py index bf4a9fe..37b3f2e 100644 --- a/src/microdot_asgi.py +++ b/src/microdot/asgi.py @@ -1,10 +1,10 @@ import asyncio import os import signal -from microdot_asyncio import * # noqa: F401, F403 -from microdot_asyncio import Microdot as BaseMicrodot -from microdot_asyncio import Request -from microdot import NoCaseDict +from microdot import * # noqa: F401, F403 +from microdot.microdot import Microdot as BaseMicrodot, Request, Response, \ + NoCaseDict, abort +from microdot.websocket import WebSocket as BaseWebSocket, websocket_wrapper class _BodyStream: # pragma: no cover @@ -21,7 +21,7 @@ class _BodyStream: # pragma: no cover async def read(self, n=-1): while self.more and len(self.data) < n: - self.read_more() + await self.read_more() if len(self.data) < n: data = self.data self.data = b'' @@ -32,14 +32,14 @@ class _BodyStream: # pragma: no cover return data async def readline(self): - return self.readuntil() + return await self.readuntil() async def readexactly(self, n): - return self.read(n) + return await self.read(n) async def readuntil(self, separator=b'\n'): if self.more and separator not in self.data: - self.read_more() + await self.read_more() data, self.data = self.data.split(separator, 1) return data @@ -113,11 +113,12 @@ class Microdot(BaseMicrodot): while True: event = await receive() - if event['type'] == 'http.disconnect': # pragma: no branch + if event is None or \ + event['type'] == 'http.disconnect': # pragma: no cover cancelled = True break - asyncio.ensure_future(cancel_monitor()) + monitor_task = asyncio.ensure_future(cancel_monitor()) body_iter = res.body_iter().__aiter__() res_body = b'' @@ -133,6 +134,10 @@ class Microdot(BaseMicrodot): await send({'type': 'http.response.body', 'body': res_body, 'more_body': False}) + if hasattr(body_iter, 'aclose'): # pragma: no branch + await body_iter.aclose() + cancelled = True + await monitor_task async def __call__(self, scope, receive, send): return await self.asgi_app(scope, receive, send) @@ -152,3 +157,79 @@ class Microdot(BaseMicrodot): """ self.embedded_server = True super().run(host=host, port=port, debug=debug, **options) + + +class WebSocket(BaseWebSocket): # pragma: no cover + async def handshake(self): + connect = await self.request.sock[0]() + if connect['type'] != 'websocket.connect': + abort(400) + await self.request.sock[1]({'type': 'websocket.accept'}) + + async def receive(self): + message = await self.request.sock[0]() + if message['type'] == 'websocket.disconnect': + raise OSError(32, 'Websocket connection closed') + elif message['type'] != 'websocket.receive': + raise OSError(32, 'Websocket message type not supported') + return message.get('bytes', message.get('text')) + + async def send(self, data): + if isinstance(data, str): + await self.request.sock[1]( + {'type': 'websocket.send', 'text': data}) + else: + await self.request.sock[1]( + {'type': 'websocket.send', 'bytes': data}) + + async def close(self): + if not self.closed: + self.closed = True + try: + await self.request.sock[1]({'type': 'websocket.close'}) + except: # noqa E722 + pass + + +async def websocket_upgrade(request): # pragma: no cover + """Upgrade a request handler to a websocket connection. + + This function can be called directly inside a route function to process a + WebSocket upgrade handshake, for example after the user's credentials are + verified. The function returns the websocket object:: + + @app.route('/echo') + async def echo(request): + if not (await authenticate_user(request)): + abort(401) + ws = await websocket_upgrade(request) + while True: + message = await ws.receive() + await ws.send(message) + """ + ws = WebSocket(request) if not request.app.embedded_server else \ + BaseWebSocket(request) + await ws.handshake() + + @request.after_request + async def after_request(request, response): + return Response.already_handled + + return ws + + +def with_websocket(f): # pragma: no cover + """Decorator to make a route a WebSocket endpoint. + + This decorator is used to define a route that accepts websocket + connections. The route then receives a websocket object as a second + argument that it can use to send and receive messages:: + + @app.route('/echo') + @with_websocket + async def echo(request, ws): + while True: + message = await ws.receive() + await ws.send(message) + """ + return websocket_wrapper(f, websocket_upgrade) diff --git a/src/microdot_cors.py b/src/microdot/cors.py similarity index 100% rename from src/microdot_cors.py rename to src/microdot/cors.py diff --git a/src/microdot/jinja.py b/src/microdot/jinja.py new file mode 100644 index 0000000..95a4ded --- /dev/null +++ b/src/microdot/jinja.py @@ -0,0 +1,58 @@ +from jinja2 import Environment, FileSystemLoader, select_autoescape + +_jinja_env = None + + +def init_templates(template_dir='templates', enable_async=False, **kwargs): + """Initialize the templating subsystem. + + :param template_dir: the directory where templates are stored. This + argument is optional. The default is to load templates + from a *templates* subdirectory. + :param enable_async: set to ``True`` to enable the async rendering engine + in Jinja, and the ``render_async()`` and + ``generate_async()`` methods. + :param kwargs: any additional options to be passed to Jinja's + ``Environment`` class. + """ + global _jinja_env + _jinja_env = Environment( + loader=FileSystemLoader(template_dir), + autoescape=select_autoescape(), + enable_async=enable_async, + **kwargs + ) + + +class Template: + """A template object. + + :param template: The filename of the template to render, relative to the + configured template directory. + """ + def __init__(self, template): + if _jinja_env is None: # pragma: no cover + init_templates() + #: The name of the template + self.name = template + self.template = _jinja_env.get_template(template) + + def generate(self, *args, **kwargs): + """Return a generator that renders the template in chunks, with the + given arguments.""" + return self.template.generate(*args, **kwargs) + + def render(self, *args, **kwargs): + """Render the template with the given arguments and return it as a + string.""" + return self.template.render(*args, **kwargs) + + def generate_async(self, *args, **kwargs): + """Return an asynchronous generator that renders the template in + chunks, using the given arguments.""" + return self.template.generate_async(*args, **kwargs) + + async def render_async(self, *args, **kwargs): + """Render the template with the given arguments asynchronously and + return it as a string.""" + return await self.template.render_async(*args, **kwargs) diff --git a/src/microdot.py b/src/microdot/microdot.py similarity index 75% rename from src/microdot.py rename to src/microdot/microdot.py index 0906c8e..65c8d37 100644 --- a/src/microdot.py +++ b/src/microdot/microdot.py @@ -3,9 +3,43 @@ microdot -------- The ``microdot`` module defines a few classes that help implement HTTP-based -servers for MicroPython and standard Python, with multithreading support for -Python interpreters that support it. +servers for MicroPython and standard Python. """ +import asyncio +import io +import json +import re +import time + +try: + from inspect import iscoroutinefunction, iscoroutine + + async def invoke_handler(handler, *args, **kwargs): + """Invoke a handler and return the result. + + This method runs sync handlers in a thread pool executor. + """ + if iscoroutinefunction(handler): + ret = await handler(*args, **kwargs) + else: + ret = await asyncio.get_running_loop().run_in_executor( + None, handler, *args, **kwargs) + return ret +except ImportError: # pragma: no cover + def iscoroutine(coro): + return hasattr(coro, 'send') and hasattr(coro, 'throw') + + async def invoke_handler(handler, *args, **kwargs): + """Invoke a handler and return the result. + + This method runs sync handlers in the asyncio thread, which can + potentially cause blocking and performance issues. + """ + ret = handler(*args, **kwargs) + if iscoroutine(ret): + ret = await ret + return ret + try: from sys import print_exception except ImportError: # pragma: no cover @@ -13,45 +47,6 @@ except ImportError: # pragma: no cover def print_exception(exc): traceback.print_exc() -try: - import uerrno as errno -except ImportError: - import errno - -concurrency_mode = 'threaded' - -try: # pragma: no cover - import threading - - def create_thread(f, *args, **kwargs): - # use the threading module - threading.Thread(target=f, args=args, kwargs=kwargs).start() -except ImportError: # pragma: no cover - def create_thread(f, *args, **kwargs): - # no threads available, call function synchronously - f(*args, **kwargs) - - concurrency_mode = 'sync' - -try: - import ujson as json -except ImportError: - import json - -try: - import ure as re -except ImportError: - import re - -socket_timeout_error = OSError -try: - import usocket as socket -except ImportError: - try: - import socket - socket_timeout_error = socket.timeout - except ImportError: # pragma: no cover - socket = None MUTED_SOCKET_ERRORS = [ 32, # Broken pipe @@ -275,7 +270,31 @@ class MultiDict(dict): return values -class Request(): +class AsyncBytesIO: + """An async wrapper for BytesIO.""" + def __init__(self, data): + self.stream = io.BytesIO(data) + + async def read(self, n=-1): + return self.stream.read(n) + + async def readline(self): # pragma: no cover + return self.stream.readline() + + async def readexactly(self, n): # pragma: no cover + return self.stream.read(n) + + async def readuntil(self, separator=b'\n'): # pragma: no cover + return self.stream.readuntil(separator=separator) + + async def awrite(self, data): # pragma: no cover + return self.stream.write(data) + + async def aclose(self): # pragma: no cover + pass + + +class Request: """An HTTP request.""" #: Specify the maximum payload size that is accepted. Requests with larger #: payloads will be rejected with a 413 status code. Applications can @@ -306,12 +325,6 @@ class Request(): #: Request.max_readline = 16 * 1024 # 16KB lines allowed max_readline = 2 * 1024 - #: Specify a suggested read timeout to use when reading the request. Set to - #: 0 to disable the use of a timeout. This timeout should be considered a - #: suggestion only, as some platforms may not support it. The default is - #: 1 second. - socket_read_timeout = 1 - class G: pass @@ -361,48 +374,62 @@ class Request(): self._body = body self.body_used = False self._stream = stream - self.stream_used = False self.sock = sock self._json = None self._form = None self.after_request_handlers = [] @staticmethod - def create(app, client_stream, client_addr, client_sock=None): + async def create(app, client_reader, client_writer, client_addr): """Create a request object. - :param app: The Microdot application instance. - :param client_stream: An input stream from where the request data can + :param client_reader: An input stream from where the request data can be read. + :param client_writer: An output stream where the response data can be + written. :param client_addr: The address of the client, as a tuple. - :param client_sock: The low-level socket associated with the request. - This method returns a newly created ``Request`` object. + This method is a coroutine. It returns a newly created ``Request`` + object. """ # request line - line = Request._safe_readline(client_stream).strip().decode() - if not line: + line = (await Request._safe_readline(client_reader)).strip().decode() + if not line: # pragma: no cover return None method, url, http_version = line.split() http_version = http_version.split('/', 1)[1] # headers headers = NoCaseDict() + content_length = 0 while True: - line = Request._safe_readline(client_stream).strip().decode() + line = (await Request._safe_readline( + client_reader)).strip().decode() if line == '': break header, value = line.split(':', 1) value = value.strip() headers[header] = value + if header.lower() == 'content-length': + content_length = int(value) + + # body + body = b'' + if content_length and content_length <= Request.max_body_length: + body = await client_reader.readexactly(content_length) + stream = None + else: + body = b'' + stream = client_reader return Request(app, client_addr, method, url, http_version, headers, - stream=client_stream, sock=client_sock) + body=body, stream=stream, + sock=(client_reader, client_writer)) def _parse_urlencoded(self, urlencoded): data = MultiDict() - if len(urlencoded) > 0: + if len(urlencoded) > 0: # pragma: no branch if isinstance(urlencoded, str): for kv in [pair.split('=', 1) for pair in urlencoded.split('&') if pair]: @@ -418,27 +445,13 @@ class Request(): @property def body(self): """The body of the request, as bytes.""" - if self.stream_used: - raise RuntimeError('Cannot use both stream and body') - if self._body is None: - self._body = b'' - if self.content_length and \ - self.content_length <= Request.max_body_length: - while len(self._body) < self.content_length: - data = self._stream.read( - self.content_length - len(self._body)) - if len(data) == 0: # pragma: no cover - raise EOFError() - self._body += data - self.body_used = True return self._body @property def stream(self): - """The input stream, containing the request body.""" - if self.body_used: - raise RuntimeError('Cannot use both stream and body') - self.stream_used = True + """The body of the request, as a bytes stream.""" + if self._stream is None: + self._stream = AsyncBytesIO(self._body) return self._stream @property @@ -494,21 +507,21 @@ class Request(): return f @staticmethod - def _safe_readline(stream): - line = stream.readline(Request.max_readline + 1) + async def _safe_readline(stream): + line = (await stream.readline()) if len(line) > Request.max_readline: raise ValueError('line too long') return line -class Response(): +class Response: """An HTTP response class. :param body: The body of the response. If a dictionary or list is given, a JSON formatter is used to generate the body. If a file-like - object or a generator is given, a streaming response is used. - If a string is given, it is encoded from UTF-8. Else, the - body should be a byte sequence. + object or an async generator is given, a streaming response is + used. If a string is given, it is encoded from UTF-8. Else, + the body should be a byte sequence. :param status_code: The numeric HTTP status code of the response. The default is 200. :param headers: A dictionary of headers to include in the response. @@ -526,6 +539,7 @@ class Response(): 'png': 'image/png', 'txt': 'text/plain', } + send_file_buffer_size = 1024 #: The content type to use for responses that do not explicitly define a @@ -558,7 +572,8 @@ class Response(): self.is_head = False def set_cookie(self, cookie, value, path=None, domain=None, expires=None, - max_age=None, secure=False, http_only=False): + max_age=None, secure=False, http_only=False, + partitioned=False): """Add a cookie to the response. :param cookie: The cookie's name. @@ -570,6 +585,7 @@ class Response(): :param max_age: The cookie's ``Max-Age`` value. :param secure: The cookie's ``secure`` flag. :param http_only: The cookie's ``HttpOnly`` flag. + :param partitioned: Whether the cookie is partitioned. """ http_cookie = '{cookie}={value}'.format(cookie=cookie, value=value) if path: @@ -580,19 +596,31 @@ class Response(): if isinstance(expires, str): http_cookie += '; Expires=' + expires else: - http_cookie += '; Expires=' + expires.strftime( - '%a, %d %b %Y %H:%M:%S GMT') + http_cookie += '; Expires=' + time.strftime( + '%a, %d %b %Y %H:%M:%S GMT', expires.timetuple()) if max_age: http_cookie += '; Max-Age=' + str(max_age) if secure: http_cookie += '; Secure' if http_only: http_cookie += '; HttpOnly' + if partitioned: + http_cookie += '; Partitioned' if 'Set-Cookie' in self.headers: self.headers['Set-Cookie'].append(http_cookie) else: self.headers['Set-Cookie'] = [http_cookie] + def delete_cookie(self, cookie, **kwargs): + """Delete a cookie. + + :param cookie: The cookie's name. + :param kwargs: Any cookie opens and flags supported by + ``set_cookie()`` except ``expires``. + """ + self.set_cookie(cookie, '', expires='Thu, 01 Jan 1970 00:00:01 GMT', + **kwargs) + def complete(self): if isinstance(self.body, bytes) and \ 'Content-Length' not in self.headers: @@ -602,54 +630,101 @@ class Response(): if 'charset=' not in self.headers['Content-Type']: self.headers['Content-Type'] += '; charset=UTF-8' - def write(self, stream): + async def write(self, stream): self.complete() - # status code - reason = self.reason if self.reason is not None else \ - ('OK' if self.status_code == 200 else 'N/A') - stream.write('HTTP/1.0 {status_code} {reason}\r\n'.format( - status_code=self.status_code, reason=reason).encode()) + try: + # status code + reason = self.reason if self.reason is not None else \ + ('OK' if self.status_code == 200 else 'N/A') + await stream.awrite('HTTP/1.0 {status_code} {reason}\r\n'.format( + status_code=self.status_code, reason=reason).encode()) - # headers - for header, value in self.headers.items(): - values = value if isinstance(value, list) else [value] - for value in values: - stream.write('{header}: {value}\r\n'.format( - header=header, value=value).encode()) - stream.write(b'\r\n') + # headers + for header, value in self.headers.items(): + values = value if isinstance(value, list) else [value] + for value in values: + await stream.awrite('{header}: {value}\r\n'.format( + header=header, value=value).encode()) + await stream.awrite(b'\r\n') - # body - if not self.is_head: - can_flush = hasattr(stream, 'flush') - try: - for body in self.body_iter(): + # body + if not self.is_head: + iter = self.body_iter() + async for body in iter: if isinstance(body, str): # pragma: no cover body = body.encode() - stream.write(body) - if can_flush: # pragma: no cover - stream.flush() - except OSError as exc: # pragma: no cover - if exc.errno in MUTED_SOCKET_ERRORS: - pass - else: - raise + try: + await stream.awrite(body) + except OSError as exc: # pragma: no cover + if exc.errno in MUTED_SOCKET_ERRORS or \ + exc.args[0] == 'Connection lost': + if hasattr(iter, 'aclose'): + await iter.aclose() + raise + if hasattr(iter, 'aclose'): # pragma: no branch + await iter.aclose() + + except OSError as exc: # pragma: no cover + if exc.errno in MUTED_SOCKET_ERRORS or \ + exc.args[0] == 'Connection lost': + pass + else: + raise def body_iter(self): - if self.body: - if hasattr(self.body, 'read'): - while True: - buf = self.body.read(self.send_file_buffer_size) - if len(buf): - yield buf - if len(buf) < self.send_file_buffer_size: - break - if hasattr(self.body, 'close'): # pragma: no cover - self.body.close() - elif hasattr(self.body, '__next__'): - yield from self.body - else: - yield self.body + if hasattr(self.body, '__anext__'): + # response body is an async generator + return self.body + + response = self + + class iter: + ITER_UNKNOWN = 0 + ITER_SYNC_GEN = 1 + ITER_FILE_OBJ = 2 + ITER_NO_BODY = -1 + + def __aiter__(self): + if response.body: + self.i = self.ITER_UNKNOWN # need to determine type + else: + self.i = self.ITER_NO_BODY + return self + + async def __anext__(self): + if self.i == self.ITER_NO_BODY: + await self.aclose() + raise StopAsyncIteration + if self.i == self.ITER_UNKNOWN: + if hasattr(response.body, 'read'): + self.i = self.ITER_FILE_OBJ + elif hasattr(response.body, '__next__'): + self.i = self.ITER_SYNC_GEN + return next(response.body) + else: + self.i = self.ITER_NO_BODY + return response.body + elif self.i == self.ITER_SYNC_GEN: + try: + return next(response.body) + except StopIteration: + await self.aclose() + raise StopAsyncIteration + buf = response.body.read(response.send_file_buffer_size) + if iscoroutine(buf): # pragma: no cover + buf = await buf + if len(buf) < response.send_file_buffer_size: + self.i = self.ITER_NO_BODY + return buf + + async def aclose(self): + if hasattr(response.body, 'close'): + result = response.body.close() + if iscoroutine(result): # pragma: no cover + await result + + return iter() @classmethod def redirect(cls, location, status_code=302): @@ -781,7 +856,7 @@ class HTTPException(Exception): return 'HTTPException: {}'.format(self.status_code) -class Microdot(): +class Microdot: """An HTTP application class. This class implements an HTTP application instance and is heavily @@ -1048,6 +1123,88 @@ class Microdot(): """ raise HTTPException(status_code, reason) + async def start_server(self, host='0.0.0.0', port=5000, debug=False, + ssl=None): + """Start the Microdot web server as a coroutine. This coroutine does + not normally return, as the server enters an endless listening loop. + The :func:`shutdown` function provides a method for terminating the + server gracefully. + + :param host: The hostname or IP address of the network interface that + will be listening for requests. A value of ``'0.0.0.0'`` + (the default) indicates that the server should listen for + requests on all the available interfaces, and a value of + ``127.0.0.1`` indicates that the server should listen + for requests only on the internal networking interface of + the host. + :param port: The port number to listen for requests. The default is + port 5000. + :param debug: If ``True``, the server logs debugging information. The + default is ``False``. + :param ssl: An ``SSLContext`` instance or ``None`` if the server should + not use TLS. The default is ``None``. + + This method is a coroutine. + + Example:: + + import asyncio + from microdot_asyncio import Microdot + + app = Microdot() + + @app.route('/') + async def index(request): + return 'Hello, world!' + + async def main(): + await app.start_server(debug=True) + + asyncio.run(main()) + """ + self.debug = debug + + async def serve(reader, writer): + if not hasattr(writer, 'awrite'): # pragma: no cover + # CPython provides the awrite and aclose methods in 3.8+ + async def awrite(self, data): + self.write(data) + await self.drain() + + async def aclose(self): + self.close() + await self.wait_closed() + + from types import MethodType + writer.awrite = MethodType(awrite, writer) + writer.aclose = MethodType(aclose, writer) + + await self.handle_request(reader, writer) + + if self.debug: # pragma: no cover + print('Starting async server on {host}:{port}...'.format( + host=host, port=port)) + + try: + self.server = await asyncio.start_server(serve, host, port, + ssl=ssl) + except TypeError: # pragma: no cover + self.server = await asyncio.start_server(serve, host, port) + + while True: + try: + if hasattr(self.server, 'serve_forever'): # pragma: no cover + try: + await self.server.serve_forever() + except asyncio.CancelledError: + pass + await self.server.wait_closed() + break + except AttributeError: # pragma: no cover + # the task hasn't been initialized in the server object yet + # wait a bit and try again + await asyncio.sleep(0.1) + def run(self, host='0.0.0.0', port=5000, debug=False, ssl=None): """Start the web server. This function does not normally return, as the server enters an endless listening loop. The :func:`shutdown` @@ -1069,45 +1226,18 @@ class Microdot(): Example:: - from microdot import Microdot + from microdot_asyncio import Microdot app = Microdot() @app.route('/') - def index(request): + async def index(request): return 'Hello, world!' app.run(debug=True) """ - self.debug = debug - self.shutdown_requested = False - - self.server = socket.socket() - ai = socket.getaddrinfo(host, port) - addr = ai[0][-1] - - if self.debug: # pragma: no cover - print('Starting {mode} server on {host}:{port}...'.format( - mode=concurrency_mode, host=host, port=port)) - self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - self.server.bind(addr) - self.server.listen(5) - - if ssl: - self.server = ssl.wrap_socket(self.server, server_side=True) - - while not self.shutdown_requested: - try: - sock, addr = self.server.accept() - except OSError as exc: # pragma: no cover - if exc.errno == errno.ECONNABORTED: - break - else: - print_exception(exc) - except Exception as exc: # pragma: no cover - print_exception(exc) - else: - create_thread(self.handle_request, sock, addr) + asyncio.run(self.start_server(host=host, port=port, debug=debug, + ssl=ssl)) # pragma: no cover def shutdown(self): """Request a server shutdown. The server will then exit its request @@ -1122,7 +1252,7 @@ class Microdot(): request.app.shutdown() return 'The server is shutting down...' """ - self.shutdown_requested = True + self.server.close() def find_route(self, req): method = req.method.upper() @@ -1151,51 +1281,35 @@ class Microdot(): allow.append('OPTIONS') return {'Allow': ', '.join(allow)} - def handle_request(self, sock, addr): - if Request.socket_read_timeout and \ - hasattr(sock, 'settimeout'): # pragma: no cover - sock.settimeout(Request.socket_read_timeout) - if not hasattr(sock, 'readline'): # pragma: no cover - stream = sock.makefile("rwb") - else: - stream = sock - + async def handle_request(self, reader, writer): req = None - res = None try: - req = Request.create(self, stream, addr, sock) - res = self.dispatch_request(req) - except socket_timeout_error as exc: # pragma: no cover - if exc.errno and exc.errno != errno.ETIMEDOUT: - print_exception(exc) # not a timeout + req = await Request.create(self, reader, writer, + writer.get_extra_info('peername')) except Exception as exc: # pragma: no cover print_exception(exc) + + res = await self.dispatch_request(req) + if res != Response.already_handled: # pragma: no branch + await res.write(writer) try: - if res and res != Response.already_handled: # pragma: no branch - res.write(stream) - stream.close() + await writer.aclose() except OSError as exc: # pragma: no cover if exc.errno in MUTED_SOCKET_ERRORS: pass else: - print_exception(exc) - except Exception as exc: # pragma: no cover - print_exception(exc) - if stream != sock: # pragma: no cover - sock.close() - if self.shutdown_requested: # pragma: no cover - self.server.close() + raise if self.debug and req: # pragma: no cover print('{method} {path} {status_code}'.format( method=req.method, path=req.path, status_code=res.status_code)) - def dispatch_request(self, req): + async def dispatch_request(self, req): after_request_handled = False if req: if req.content_length > req.max_content_length: if 413 in self.error_handlers: - res = self.error_handlers[413](req) + res = await invoke_handler(self.error_handlers[413], req) else: res = 'Payload too large', 413 else: @@ -1204,11 +1318,12 @@ class Microdot(): res = None if callable(f): for handler in self.before_request_handlers: - res = handler(req) + res = await invoke_handler(handler, req) if res: break if res is None: - res = f(req, **req.url_args) + res = await invoke_handler( + f, req, **req.url_args) if isinstance(res, tuple): body = res[0] if isinstance(res[1], int): @@ -1221,14 +1336,16 @@ class Microdot(): elif not isinstance(res, Response): res = Response(res) for handler in self.after_request_handlers: - res = handler(req, res) or res + res = await invoke_handler( + handler, req, res) or res for handler in req.after_request_handlers: - res = handler(req, res) or res + res = await invoke_handler( + handler, req, res) or res after_request_handled = True elif isinstance(f, dict): res = Response(headers=f) elif f in self.error_handlers: - res = self.error_handlers[f](req) + res = await invoke_handler(self.error_handlers[f], req) else: res = 'Not found', f except HTTPException as exc: @@ -1249,32 +1366,35 @@ class Microdot(): break if exc_class: try: - res = self.error_handlers[exc_class](req, exc) + res = await invoke_handler( + self.error_handlers[exc_class], req, exc) except Exception as exc2: # pragma: no cover print_exception(exc2) if res is None: if 500 in self.error_handlers: - res = self.error_handlers[500](req) + res = await invoke_handler( + self.error_handlers[500], req) else: res = 'Internal server error', 500 else: if 400 in self.error_handlers: - res = self.error_handlers[400](req) + res = await invoke_handler(self.error_handlers[400], req) else: res = 'Bad request', 400 - if isinstance(res, tuple): res = Response(*res) elif not isinstance(res, Response): res = Response(res) if not after_request_handled: for handler in self.after_error_request_handlers: - res = handler(req, res) or res + res = await invoke_handler( + handler, req, res) or res res.is_head = (req and req.method == 'HEAD') return res -abort = Microdot.abort Response.already_handled = Response() + +abort = Microdot.abort redirect = Response.redirect send_file = Response.send_file diff --git a/src/microdot/session.py b/src/microdot/session.py new file mode 100644 index 0000000..0701122 --- /dev/null +++ b/src/microdot/session.py @@ -0,0 +1,148 @@ +import jwt +from microdot.microdot import invoke_handler + +secret_key = None + + +class SessionDict(dict): + """A session dictionary. + + The session dictionary is a standard Python dictionary that has been + extended with convenience ``save()`` and ``delete()`` methods. + """ + def __init__(self, request, session_dict): + super().__init__(session_dict) + self.request = request + + def save(self): + """Update the session cookie.""" + self.request.app._session.update(self.request, self) + + def delete(self): + """Delete the session cookie.""" + self.request.app._session.delete(self.request) + + +class Session: + """ + :param app: The application instance. + :param key: The secret key, as a string or bytes object. + """ + secret_key = None + + def __init__(self, app=None, secret_key=None): + self.secret_key = secret_key + if app is not None: + self.initialize(app) + + def initialize(self, app, secret_key=None): + if secret_key is not None: + self.secret_key = secret_key + app._session = self + + def get(self, request): + """Retrieve the user session. + + :param request: The client request. + + The return value is a session dictionary with the data stored in the + user's session, or ``{}`` if the session data is not available or + invalid. + """ + if not self.secret_key: + raise ValueError('The session secret key is not configured') + if hasattr(request.g, '_session'): + return request.g._session + session = request.cookies.get('session') + if session is None: + request.g._session = SessionDict(request, {}) + return request.g._session + try: + session = jwt.decode(session, self.secret_key, + algorithms=['HS256']) + except jwt.exceptions.PyJWTError: # pragma: no cover + request.g._session = SessionDict(request, {}) + else: + request.g._session = SessionDict(request, session) + return request.g._session + + def update(self, request, session): + """Update the user session. + + :param request: The client request. + :param session: A dictionary with the update session data for the user. + + Applications would normally not call this method directly, instead they + would use the :meth:`SessionDict.save` method on the session + dictionary, which calls this method. For example:: + + @app.route('/') + @with_session + def index(request, session): + session['foo'] = 'bar' + session.save() + return 'Hello, World!' + + Calling this method adds a cookie with the updated session to the + request currently being processed. + """ + if not self.secret_key: + raise ValueError('The session secret key is not configured') + + encoded_session = jwt.encode(session, self.secret_key, + algorithm='HS256') + + @request.after_request + def _update_session(request, response): + response.set_cookie('session', encoded_session, http_only=True) + return response + + def delete(self, request): + """Remove the user session. + + :param request: The client request. + + Applications would normally not call this method directly, instead they + would use the :meth:`SessionDict.delete` method on the session + dictionary, which calls this method. For example:: + + @app.route('/') + @with_session + def index(request, session): + session.delete() + return 'Hello, World!' + + Calling this method adds a cookie removal header to the request + currently being processed. + """ + @request.after_request + def _delete_session(request, response): + response.set_cookie('session', '', http_only=True, + expires='Thu, 01 Jan 1970 00:00:01 GMT') + return response + + +def with_session(f): + """Decorator that passes the user session to the route handler. + + The session dictionary is passed to the decorated function as an argument + after the request object. Example:: + + @app.route('/') + @with_session + def index(request, session): + return 'Hello, World!' + + Note that the decorator does not save the session. To update the session, + call the :func:`update_session ` function. + """ + async def wrapper(request, *args, **kwargs): + return await invoke_handler( + f, request, request.app._session.get(request), *args, **kwargs) + + for attr in ['__name__', '__doc__', '__module__', '__qualname__']: + try: + setattr(wrapper, attr, getattr(f, attr)) + except AttributeError: # pragma: no cover + pass + return wrapper diff --git a/src/microdot/sse.py b/src/microdot/sse.py new file mode 100644 index 0000000..0e3396c --- /dev/null +++ b/src/microdot/sse.py @@ -0,0 +1,95 @@ +import asyncio +import json + + +class SSE: + def __init__(self): + self.event = asyncio.Event() + self.queue = [] + + async def send(self, data, event=None): + if isinstance(data, (dict, list)): + data = json.dumps(data) + elif not isinstance(data, str): + data = str(data) + data = f'data: {data}\n\n' + if event: + data = f'event: {event}\n{data}' + self.queue.append(data) + self.event.set() + + +def sse_response(request, event_function, *args, **kwargs): + """Return a response object that initiates an event stream. + + :param request: the request object. + :param event_function: an asynchronous function that will send events to + the client. The function is invoked with ``request`` + and an ``sse`` object. The function should use + ``sse.send()`` to send events to the client. + :param args: additional positional arguments to be passed to the response. + :param kwargs: additional keyword arguments to be passed to the response. + + Example:: + + @app.route('/events') + async def events_route(request): + async def events(request, sse): + # send an unnamed event with string data + await sse.send('hello') + # send an unnamed event with JSON data + await sse.send({'foo': 'bar'}) + # send a named event + await sse.send('hello', event='greeting') + + return sse_response(request, events) + """ + sse = SSE() + + async def sse_task_wrapper(): + await event_function(request, sse, *args, **kwargs) + sse.event.set() + + task = asyncio.create_task(sse_task_wrapper()) + + class sse_loop: + def __aiter__(self): + return self + + async def __anext__(self): + event = None + while sse.queue or not task.done(): + try: + event = sse.queue.pop(0) + break + except IndexError: + await sse.event.wait() + sse.event.clear() + if event is None: + raise StopAsyncIteration + return event + + async def aclose(self): + task.cancel() + + return sse_loop(), 200, {'Content-Type': 'text/event-stream'} + + +def with_sse(f): + """Decorator to make a route a Server-Sent Events endpoint. + + This decorator is used to define a route that accepts SSE connections. The + route then receives a sse object as a second argument that it can use to + send events to the client:: + + @app.route('/events') + @with_sse + async def events(request, sse): + for i in range(10): + await asyncio.sleep(1) + await sse.send(f'{i}') + """ + async def sse_handler(request, *args, **kwargs): + return sse_response(request, f, *args, **kwargs) + + return sse_handler diff --git a/src/microdot_test_client.py b/src/microdot/test_client.py similarity index 73% rename from src/microdot_test_client.py rename to src/microdot/test_client.py index 298459d..1530ee6 100644 --- a/src/microdot_test_client.py +++ b/src/microdot/test_client.py @@ -1,11 +1,13 @@ -from io import BytesIO import json -from microdot import Request, Response, NoCaseDict +from microdot.microdot import Request, Response, AsyncBytesIO + try: - from microdot_websocket import WebSocket + from microdot.websocket import WebSocket except: # pragma: no cover # noqa: E722 WebSocket = None +__all__ = ['TestClient', 'TestResponse'] + class TestResponse: """A response object issued by the Microdot test client.""" @@ -32,12 +34,15 @@ class TestResponse: self.reason = res.reason self.headers = res.headers - def _initialize_body(self, res): + async def _initialize_body(self, res): self.body = b'' - for body in res.body_iter(): + iter = res.body_iter() + async for body in iter: # pragma: no branch if isinstance(body, str): body = body.encode() self.body += body + if hasattr(iter, 'aclose'): # pragma: no branch + await iter.aclose() def _process_text_body(self): try: @@ -52,13 +57,13 @@ class TestResponse: self.json = json.loads(self.text) @classmethod - def create(cls, res): + async def create(cls, res): test_res = cls() test_res._initialize_response(res) - test_res._initialize_body(res) - test_res._process_text_body() - test_res._process_json_body() - test_res.is_head = res.is_head + if not res.is_head: + await test_res._initialize_body(res) + test_res._process_text_body() + test_res._process_json_body() return test_res @@ -72,17 +77,17 @@ class TestClient: The following example shows how to create a test client for an application and send a test request:: - from microdot import Microdot + from microdot_asyncio import Microdot app = Microdot() @app.get('/') - def index(): + async def index(): return 'Hello, World!' - def test_hello_world(self): + async def test_hello_world(self): client = TestClient(app) - res = client.get('/') + res = await client.get('/') assert res.status_code == 200 assert res.text == 'Hello, World!' """ @@ -150,23 +155,30 @@ class TestClient: else: self.cookies[cookie_name] = cookie_options[0] - def request(self, method, path, headers=None, body=None, sock=None): - headers = NoCaseDict(headers or {}) + async def request(self, method, path, headers=None, body=None, sock=None): + headers = headers or {} body, headers = self._process_body(body, headers) cookies, headers = self._process_cookies(headers) request_bytes = self._render_request(method, path, headers, body) + if sock: + reader = sock[0] + reader.buffer = request_bytes + writer = sock[1] + else: + reader = AsyncBytesIO(request_bytes) + writer = AsyncBytesIO(b'') - req = Request.create(self.app, BytesIO(request_bytes), - ('127.0.0.1', 1234), client_sock=sock) - res = self.app.dispatch_request(req) + req = await Request.create(self.app, reader, writer, + ('127.0.0.1', 1234)) + res = await self.app.dispatch_request(req) if res == Response.already_handled: return None res.complete() self._update_cookies(res) - return TestResponse.create(res) + return await TestResponse.create(res) - def get(self, path, headers=None): + async def get(self, path, headers=None): """Send a GET request to the application. :param path: The request URL. @@ -175,9 +187,9 @@ class TestClient: This method returns a :class:`TestResponse ` object. """ - return self.request('GET', path, headers=headers) + return await self.request('GET', path, headers=headers) - def post(self, path, headers=None, body=None): + async def post(self, path, headers=None, body=None): """Send a POST request to the application. :param path: The request URL. @@ -189,9 +201,9 @@ class TestClient: This method returns a :class:`TestResponse ` object. """ - return self.request('POST', path, headers=headers, body=body) + return await self.request('POST', path, headers=headers, body=body) - def put(self, path, headers=None, body=None): + async def put(self, path, headers=None, body=None): """Send a PUT request to the application. :param path: The request URL. @@ -203,9 +215,9 @@ class TestClient: This method returns a :class:`TestResponse ` object. """ - return self.request('PUT', path, headers=headers, body=body) + return await self.request('PUT', path, headers=headers, body=body) - def patch(self, path, headers=None, body=None): + async def patch(self, path, headers=None, body=None): """Send a PATCH request to the application. :param path: The request URL. @@ -217,9 +229,9 @@ class TestClient: This method returns a :class:`TestResponse ` object. """ - return self.request('PATCH', path, headers=headers, body=body) + return await self.request('PATCH', path, headers=headers, body=body) - def delete(self, path, headers=None): + async def delete(self, path, headers=None): """Send a DELETE request to the application. :param path: The request URL. @@ -228,9 +240,9 @@ class TestClient: This method returns a :class:`TestResponse ` object. """ - return self.request('DELETE', path, headers=headers) + return await self.request('DELETE', path, headers=headers) - def websocket(self, path, client, headers=None): + async def websocket(self, path, client, headers=None): """Send a websocket connection request to the application. :param path: The request URL. @@ -245,27 +257,39 @@ class TestClient: self.closed = False self.buffer = b'' - def _next(self, data=None): + async def _next(self, data=None): try: - data = gen.send(data) - except StopIteration: - if self.closed: # pragma: no cover - return - self.closed = True - raise OSError(32, 'Websocket connection closed') + data = (await gen.asend(data)) if hasattr(gen, 'asend') \ + else gen.send(data) + except (StopIteration, StopAsyncIteration): + if not self.closed: + self.closed = True + raise OSError(32, 'Websocket connection closed') + return # pragma: no cover opcode = WebSocket.TEXT if isinstance(data, str) \ else WebSocket.BINARY return WebSocket._encode_websocket_frame(opcode, data) - def recv(self, n): - self.started = True + async def read(self, n): if not self.buffer: - self.buffer = self._next() + self.started = True + self.buffer = await self._next() data = self.buffer[:n] self.buffer = self.buffer[n:] return data - def send(self, data): + async def readexactly(self, n): # pragma: no cover + return await self.read(n) + + async def readline(self): + line = b'' + while True: + line += await self.read(1) + if line[-1] in [b'\n', 10]: + break + return line + + async def awrite(self, data): if self.started: h = WebSocket._parse_frame_header(data[0:2]) if h[3] < 0: @@ -274,7 +298,7 @@ class TestClient: data = data[2:] if h[1] == WebSocket.TEXT: data = data.decode() - self.buffer = self._next(data) + self.buffer = await self._next(data) ws_headers = { 'Upgrade': 'websocket', @@ -283,5 +307,6 @@ class TestClient: 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', } ws_headers.update(headers or {}) - return self.request('GET', path, headers=ws_headers, - sock=FakeWebSocket()) + sock = FakeWebSocket() + return await self.request('GET', path, headers=ws_headers, + sock=(sock, sock)) diff --git a/src/microdot/utemplate.py b/src/microdot/utemplate.py new file mode 100644 index 0000000..34694d9 --- /dev/null +++ b/src/microdot/utemplate.py @@ -0,0 +1,68 @@ +from utemplate import recompile + +_loader = None + + +def init_templates(template_dir='templates', loader_class=recompile.Loader): + """Initialize the templating subsystem. + + :param template_dir: the directory where templates are stored. This + argument is optional. The default is to load templates + from a *templates* subdirectory. + :param loader_class: the ``utemplate.Loader`` class to use when loading + templates. This argument is optional. The default is + the ``recompile.Loader`` class, which automatically + recompiles templates when they change. + """ + global _loader + _loader = loader_class(None, template_dir) + + +class Template: + """A template object. + + :param template: The filename of the template to render, relative to the + configured template directory. + """ + def __init__(self, template): + if _loader is None: # pragma: no cover + init_templates() + #: The name of the template + self.name = template + self.template = _loader.load(template) + + def generate(self, *args, **kwargs): + """Return a generator that renders the template in chunks, with the + given arguments.""" + return self.template(*args, **kwargs) + + def render(self, *args, **kwargs): + """Render the template with the given arguments and return it as a + string.""" + return ''.join(self.generate(*args, **kwargs)) + + def generate_async(self, *args, **kwargs): + """Return an asynchronous generator that renders the template in + chunks, using the given arguments.""" + class sync_to_async_iter(): + def __init__(self, iter): + self.iter = iter + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self.iter) + except StopIteration: + raise StopAsyncIteration + + return sync_to_async_iter(self.generate(*args, **kwargs)) + + async def render_async(self, *args, **kwargs): + """Render the template with the given arguments asynchronously and + return it as a string.""" + response = '' + async for chunk in self.generate_async(*args, **kwargs): + response += chunk + return response diff --git a/src/microdot_websocket.py b/src/microdot/websocket.py similarity index 71% rename from src/microdot_websocket.py rename to src/microdot/websocket.py index 4ca0486..40d6877 100644 --- a/src/microdot_websocket.py +++ b/src/microdot/websocket.py @@ -1,6 +1,7 @@ import binascii import hashlib from microdot import Response +from microdot.microdot import MUTED_SOCKET_ERRORS class WebSocket: @@ -15,33 +16,34 @@ class WebSocket: self.request = request self.closed = False - def handshake(self): + async def handshake(self): response = self._handshake_response() - self.request.sock.send(b'HTTP/1.1 101 Switching Protocols\r\n') - self.request.sock.send(b'Upgrade: websocket\r\n') - self.request.sock.send(b'Connection: Upgrade\r\n') - self.request.sock.send( + await self.request.sock[1].awrite( + b'HTTP/1.1 101 Switching Protocols\r\n') + await self.request.sock[1].awrite(b'Upgrade: websocket\r\n') + await self.request.sock[1].awrite(b'Connection: Upgrade\r\n') + await self.request.sock[1].awrite( b'Sec-WebSocket-Accept: ' + response + b'\r\n\r\n') - def receive(self): + async def receive(self): while True: - opcode, payload = self._read_frame() + opcode, payload = await self._read_frame() send_opcode, data = self._process_websocket_frame(opcode, payload) if send_opcode: # pragma: no cover - self.send(data, send_opcode) + await self.send(data, send_opcode) elif data: # pragma: no branch return data - def send(self, data, opcode=None): + async def send(self, data, opcode=None): frame = self._encode_websocket_frame( opcode or (self.TEXT if isinstance(data, str) else self.BINARY), data) - self.request.sock.send(frame) + await self.request.sock[1].awrite(frame) - def close(self): + async def close(self): if not self.closed: # pragma: no cover self.closed = True - self.send(b'', self.CLOSE) + await self.send(b'', self.CLOSE) def _handshake_response(self): connection = False @@ -109,23 +111,26 @@ class WebSocket: frame.extend(payload) return frame - def _read_frame(self): - header = self.request.sock.recv(2) + async def _read_frame(self): + header = await self.request.sock[0].read(2) if len(header) != 2: # pragma: no cover raise OSError(32, 'Websocket connection closed') fin, opcode, has_mask, length = self._parse_frame_header(header) - if length < 0: - length = self.request.sock.recv(-length) + if length == -2: + length = await self.request.sock[0].read(2) + length = int.from_bytes(length, 'big') + elif length == -8: + length = await self.request.sock[0].read(8) length = int.from_bytes(length, 'big') if has_mask: # pragma: no cover - mask = self.request.sock.recv(4) - payload = self.request.sock.recv(length) + mask = await self.request.sock[0].read(4) + payload = await self.request.sock[0].read(length) if has_mask: # pragma: no cover payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload)) return opcode, payload -def websocket_upgrade(request): +async def websocket_upgrade(request): """Upgrade a request handler to a websocket connection. This function can be called directly inside a route function to process a @@ -133,24 +138,37 @@ def websocket_upgrade(request): verified. The function returns the websocket object:: @app.route('/echo') - def echo(request): + async def echo(request): if not authenticate_user(request): abort(401) - ws = websocket_upgrade(request) + ws = await websocket_upgrade(request) while True: - message = ws.receive() - ws.send(message) + message = await ws.receive() + await ws.send(message) """ ws = WebSocket(request) - ws.handshake() + await ws.handshake() @request.after_request - def after_request(request, response): + async def after_request(request, response): return Response.already_handled return ws +def websocket_wrapper(f, upgrade_function): + async def wrapper(request, *args, **kwargs): + ws = await upgrade_function(request) + try: + await f(request, ws, *args, **kwargs) + await ws.close() # pragma: no cover + except OSError as exc: + if exc.errno not in MUTED_SOCKET_ERRORS: # pragma: no cover + raise + return '' + return wrapper + + def with_websocket(f): """Decorator to make a route a WebSocket endpoint. @@ -160,18 +178,9 @@ def with_websocket(f): @app.route('/echo') @with_websocket - def echo(request, ws): + async def echo(request, ws): while True: - message = ws.receive() - ws.send(message) + message = await ws.receive() + await ws.send(message) """ - def wrapper(request, *args, **kwargs): - ws = websocket_upgrade(request) - try: - f(request, ws, *args, **kwargs) - ws.close() # pragma: no cover - except OSError as exc: - if exc.errno not in [32, 54, 104]: # pragma: no cover - raise - return '' - return wrapper + return websocket_wrapper(f, websocket_upgrade) diff --git a/src/microdot/wsgi.py b/src/microdot/wsgi.py new file mode 100644 index 0000000..7f1e4e4 --- /dev/null +++ b/src/microdot/wsgi.py @@ -0,0 +1,154 @@ +import asyncio +import os +import signal +from microdot import * # noqa: F401, F403 +from microdot.microdot import Microdot as BaseMicrodot, Request, NoCaseDict, \ + MUTED_SOCKET_ERRORS +from microdot.websocket import WebSocket, websocket_upgrade, \ + with_websocket # noqa: F401 + + +class Microdot(BaseMicrodot): + def __init__(self): + super().__init__() + self.loop = asyncio.new_event_loop() + self.embedded_server = False + + def wsgi_app(self, environ, start_response): + """A WSGI application callable.""" + path = environ.get('SCRIPT_NAME', '') + environ.get('PATH_INFO', '') + if 'QUERY_STRING' in environ and environ['QUERY_STRING']: + path += '?' + environ['QUERY_STRING'] + headers = NoCaseDict() + content_length = 0 + for k, value in environ.items(): + if k.startswith('HTTP_'): + key = '-'.join([p.title() for p in k[5:].split('_')]) + headers[key] = value + elif k == 'CONTENT_TYPE': + headers['Content-Type'] = value + elif k == 'CONTENT_LENGTH': + headers['Content-Length'] = value + content_length = int(value) + + class sync_to_async_body_stream(): # pragma: no cover + def __init__(self, wsgi_input=None): + self.wsgi_input = wsgi_input + + async def read(self, n=-1): + return self.wsgi_input.read(n) + + async def readline(self): + return self.wsgi_input.readline() + + async def readexactly(self, n): + return self.wsgi_input.read(n) + + wsgi_input = environ.get('wsgi.input') + if content_length and content_length <= Request.max_body_length: + # the request came with a body that is within the allowed size + body = wsgi_input.read(content_length) + stream = None + sock = (None, None) + else: + body = b'' + if content_length: + # the request came with a body that is too large to fit in + # memory, so we stream it + stream = sync_to_async_body_stream(wsgi_input) + sock = (None, None) + else: + # the request did not declare a body size, so we connect the + # raw socket if available + stream = None + if 'gunicorn.socket' in environ: # pragma: no cover + reader, writer = self.loop.run_until_complete( + asyncio.open_connection( + sock=environ['gunicorn.socket'].dup())) + if not hasattr(writer, 'awrite'): # pragma: no cover + async def awrite(self, data): + self.write(data) + await self.drain() + + async def aclose(self): + self.close() + await self.wait_closed() + + from types import MethodType + writer.awrite = MethodType(awrite, writer) + writer.aclose = MethodType(aclose, writer) + sock = (reader, writer) + else: + sock = (None, None) + + req = Request( + self, + (environ['REMOTE_ADDR'], int(environ.get('REMOTE_PORT', '0'))), + environ['REQUEST_METHOD'], + path, + environ['SERVER_PROTOCOL'], + headers, + body=body, + stream=stream, + sock=sock) + req.environ = environ + + res = self.loop.run_until_complete(self.dispatch_request(req)) + res.complete() + if sock[1]: # pragma: no cover + try: + self.loop.run_until_complete(sock[1].aclose()) + except OSError as exc: # pragma: no cover + if exc.errno in MUTED_SOCKET_ERRORS: + pass + else: + raise + + reason = res.reason or ('OK' if res.status_code == 200 else 'N/A') + header_list = [] + for name, value in res.headers.items(): + if not isinstance(value, list): + header_list.append((name, value)) + else: + for v in value: + header_list.append((name, v)) + start_response(str(res.status_code) + ' ' + reason, header_list) + + class async_to_sync_iter(): + def __init__(self, iter, loop): + self.iter = iter.__aiter__() + self.loop = loop + + def __iter__(self): + return self + + def __next__(self): + try: + return self.loop.run_until_complete(self.iter.__anext__()) + except StopAsyncIteration: + raise StopIteration + + def close(self): # pragma: no cover + if hasattr(self.iter, 'aclose'): + self.loop.run_until_complete(self.iter.aclose()) + + return async_to_sync_iter(res.body_iter(), self.loop) + + def __call__(self, environ, start_response): + return self.wsgi_app(environ, start_response) + + def shutdown(self): + if self.embedded_server: # pragma: no cover + super().shutdown() + else: + pid = os.getpgrp() if hasattr(os, 'getpgrp') else os.getpid() + os.kill(pid, signal.SIGTERM) + + def run(self, host='0.0.0.0', port=5000, debug=False, + **options): # pragma: no cover + """Normally you would not start the server by invoking this method. + Instead, start your chosen WSGI web server and pass the ``Microdot`` + instance as the WSGI callable. + """ + self.embedded_server = True + super().run(host=host, port=port, debug=debug, **options) diff --git a/src/microdot_asgi_websocket.py b/src/microdot_asgi_websocket.py deleted file mode 100644 index 53576a9..0000000 --- a/src/microdot_asgi_websocket.py +++ /dev/null @@ -1,86 +0,0 @@ -from microdot_asyncio import Response, abort -from microdot_websocket import WebSocket as BaseWebSocket - - -class WebSocket(BaseWebSocket): - async def handshake(self): - connect = await self.request.sock[0]() - if connect['type'] != 'websocket.connect': - abort(400) - await self.request.sock[1]({'type': 'websocket.accept'}) - - async def receive(self): - message = await self.request.sock[0]() - if message['type'] == 'websocket.disconnect': - raise OSError(32, 'Websocket connection closed') - elif message['type'] != 'websocket.receive': - raise OSError(32, 'Websocket message type not supported') - return message.get('bytes', message.get('text')) - - async def send(self, data): - if isinstance(data, str): - await self.request.sock[1]( - {'type': 'websocket.send', 'text': data}) - else: - await self.request.sock[1]( - {'type': 'websocket.send', 'bytes': data}) - - async def close(self): - if not self.closed: - self.closed = True - try: - await self.request.sock[1]({'type': 'websocket.close'}) - except: # noqa E722 - pass - - -async def websocket_upgrade(request): - """Upgrade a request handler to a websocket connection. - - This function can be called directly inside a route function to process a - WebSocket upgrade handshake, for example after the user's credentials are - verified. The function returns the websocket object:: - - @app.route('/echo') - async def echo(request): - if not (await authenticate_user(request)): - abort(401) - ws = await websocket_upgrade(request) - while True: - message = await ws.receive() - await ws.send(message) - """ - ws = WebSocket(request) - await ws.handshake() - - @request.after_request - async def after_request(request, response): - return Response.already_handled - - return ws - - -def with_websocket(f): - """Decorator to make a route a WebSocket endpoint. - - This decorator is used to define a route that accepts websocket - connections. The route then receives a websocket object as a second - argument that it can use to send and receive messages:: - - @app.route('/echo') - @with_websocket - async def echo(request, ws): - while True: - message = await ws.receive() - await ws.send(message) - """ - async def wrapper(request, *args, **kwargs): - ws = await websocket_upgrade(request) - try: - await f(request, ws, *args, **kwargs) - except OSError as exc: - if exc.errno != 32 and exc.errno != 54: - raise - await ws.close() - return '' - return wrapper diff --git a/src/microdot_asyncio.py b/src/microdot_asyncio.py deleted file mode 100644 index 45c8c10..0000000 --- a/src/microdot_asyncio.py +++ /dev/null @@ -1,455 +0,0 @@ -""" -microdot_asyncio ----------------- - -The ``microdot_asyncio`` module defines a few classes that help implement -HTTP-based servers for MicroPython and standard Python that use ``asyncio`` -and coroutines. -""" -try: - import uasyncio as asyncio -except ImportError: - import asyncio - -try: - import uio as io -except ImportError: - import io - -from microdot import Microdot as BaseMicrodot -from microdot import mro -from microdot import NoCaseDict -from microdot import Request as BaseRequest -from microdot import Response as BaseResponse -from microdot import print_exception -from microdot import HTTPException -from microdot import MUTED_SOCKET_ERRORS - - -def _iscoroutine(coro): - return hasattr(coro, 'send') and hasattr(coro, 'throw') - - -class _AsyncBytesIO: - def __init__(self, data): - self.stream = io.BytesIO(data) - - async def read(self, n=-1): - return self.stream.read(n) - - async def readline(self): # pragma: no cover - return self.stream.readline() - - async def readexactly(self, n): # pragma: no cover - return self.stream.read(n) - - async def readuntil(self, separator=b'\n'): # pragma: no cover - return self.stream.readuntil(separator=separator) - - async def awrite(self, data): # pragma: no cover - return self.stream.write(data) - - async def aclose(self): # pragma: no cover - pass - - -class Request(BaseRequest): - @staticmethod - async def create(app, client_reader, client_writer, client_addr): - """Create a request object. - - :param app: The Microdot application instance. - :param client_reader: An input stream from where the request data can - be read. - :param client_writer: An output stream where the response data can be - written. - :param client_addr: The address of the client, as a tuple. - - This method is a coroutine. It returns a newly created ``Request`` - object. - """ - # request line - line = (await Request._safe_readline(client_reader)).strip().decode() - if not line: - return None - method, url, http_version = line.split() - http_version = http_version.split('/', 1)[1] - - # headers - headers = NoCaseDict() - content_length = 0 - while True: - line = (await Request._safe_readline( - client_reader)).strip().decode() - if line == '': - break - header, value = line.split(':', 1) - value = value.strip() - headers[header] = value - if header.lower() == 'content-length': - content_length = int(value) - - # body - body = b'' - if content_length and content_length <= Request.max_body_length: - body = await client_reader.readexactly(content_length) - stream = None - else: - body = b'' - stream = client_reader - - return Request(app, client_addr, method, url, http_version, headers, - body=body, stream=stream, - sock=(client_reader, client_writer)) - - @property - def stream(self): - if self._stream is None: - self._stream = _AsyncBytesIO(self._body) - return self._stream - - @staticmethod - async def _safe_readline(stream): - line = (await stream.readline()) - if len(line) > Request.max_readline: - raise ValueError('line too long') - return line - - -class Response(BaseResponse): - """An HTTP response class. - - :param body: The body of the response. If a dictionary or list is given, - a JSON formatter is used to generate the body. If a file-like - object or an async generator is given, a streaming response is - used. If a string is given, it is encoded from UTF-8. Else, - the body should be a byte sequence. - :param status_code: The numeric HTTP status code of the response. The - default is 200. - :param headers: A dictionary of headers to include in the response. - :param reason: A custom reason phrase to add after the status code. The - default is "OK" for responses with a 200 status code and - "N/A" for any other status codes. - """ - - async def write(self, stream): - self.complete() - - try: - # status code - reason = self.reason if self.reason is not None else \ - ('OK' if self.status_code == 200 else 'N/A') - await stream.awrite('HTTP/1.0 {status_code} {reason}\r\n'.format( - status_code=self.status_code, reason=reason).encode()) - - # headers - for header, value in self.headers.items(): - values = value if isinstance(value, list) else [value] - for value in values: - await stream.awrite('{header}: {value}\r\n'.format( - header=header, value=value).encode()) - await stream.awrite(b'\r\n') - - # body - if not self.is_head: - async for body in self.body_iter(): - if isinstance(body, str): # pragma: no cover - body = body.encode() - await stream.awrite(body) - except OSError as exc: # pragma: no cover - if exc.errno in MUTED_SOCKET_ERRORS or \ - exc.args[0] == 'Connection lost': - pass - else: - raise - - def body_iter(self): - if hasattr(self.body, '__anext__'): - # response body is an async generator - return self.body - - response = self - - class iter: - def __aiter__(self): - if response.body: - self.i = 0 # need to determine type of response.body - else: - self.i = -1 # no response body - return self - - async def __anext__(self): - if self.i == -1: - raise StopAsyncIteration - if self.i == 0: - if hasattr(response.body, 'read'): - self.i = 2 # response body is a file-like object - elif hasattr(response.body, '__next__'): - self.i = 1 # response body is a sync generator - return next(response.body) - else: - self.i = -1 # response body is a plain string - return response.body - elif self.i == 1: - try: - return next(response.body) - except StopIteration: - raise StopAsyncIteration - buf = response.body.read(response.send_file_buffer_size) - if _iscoroutine(buf): # pragma: no cover - buf = await buf - if len(buf) < response.send_file_buffer_size: - self.i = -1 - if hasattr(response.body, 'close'): # pragma: no cover - result = response.body.close() - if _iscoroutine(result): - await result - return buf - - return iter() - - -class Microdot(BaseMicrodot): - async def start_server(self, host='0.0.0.0', port=5000, debug=False, - ssl=None): - """Start the Microdot web server as a coroutine. This coroutine does - not normally return, as the server enters an endless listening loop. - The :func:`shutdown` function provides a method for terminating the - server gracefully. - - :param host: The hostname or IP address of the network interface that - will be listening for requests. A value of ``'0.0.0.0'`` - (the default) indicates that the server should listen for - requests on all the available interfaces, and a value of - ``127.0.0.1`` indicates that the server should listen - for requests only on the internal networking interface of - the host. - :param port: The port number to listen for requests. The default is - port 5000. - :param debug: If ``True``, the server logs debugging information. The - default is ``False``. - :param ssl: An ``SSLContext`` instance or ``None`` if the server should - not use TLS. The default is ``None``. - - This method is a coroutine. - - Example:: - - import asyncio - from microdot_asyncio import Microdot - - app = Microdot() - - @app.route('/') - async def index(request): - return 'Hello, world!' - - async def main(): - await app.start_server(debug=True) - - asyncio.run(main()) - """ - self.debug = debug - - async def serve(reader, writer): - if not hasattr(writer, 'awrite'): # pragma: no cover - # CPython provides the awrite and aclose methods in 3.8+ - async def awrite(self, data): - self.write(data) - await self.drain() - - async def aclose(self): - self.close() - await self.wait_closed() - - from types import MethodType - writer.awrite = MethodType(awrite, writer) - writer.aclose = MethodType(aclose, writer) - - await self.handle_request(reader, writer) - - if self.debug: # pragma: no cover - print('Starting async server on {host}:{port}...'.format( - host=host, port=port)) - - try: - self.server = await asyncio.start_server(serve, host, port, - ssl=ssl) - except TypeError: - self.server = await asyncio.start_server(serve, host, port) - - while True: - try: - if hasattr(self.server, 'serve_forever'): # pragma: no cover - try: - await self.server.serve_forever() - except asyncio.CancelledError: - pass - await self.server.wait_closed() - break - except AttributeError: # pragma: no cover - # the task hasn't been initialized in the server object yet - # wait a bit and try again - await asyncio.sleep(0.1) - - def run(self, host='0.0.0.0', port=5000, debug=False, ssl=None): - """Start the web server. This function does not normally return, as - the server enters an endless listening loop. The :func:`shutdown` - function provides a method for terminating the server gracefully. - - :param host: The hostname or IP address of the network interface that - will be listening for requests. A value of ``'0.0.0.0'`` - (the default) indicates that the server should listen for - requests on all the available interfaces, and a value of - ``127.0.0.1`` indicates that the server should listen - for requests only on the internal networking interface of - the host. - :param port: The port number to listen for requests. The default is - port 5000. - :param debug: If ``True``, the server logs debugging information. The - default is ``False``. - :param ssl: An ``SSLContext`` instance or ``None`` if the server should - not use TLS. The default is ``None``. - - Example:: - - from microdot_asyncio import Microdot - - app = Microdot() - - @app.route('/') - async def index(request): - return 'Hello, world!' - - app.run(debug=True) - """ - asyncio.run(self.start_server(host=host, port=port, debug=debug, - ssl=ssl)) - - def shutdown(self): - self.server.close() - - async def handle_request(self, reader, writer): - req = None - try: - req = await Request.create(self, reader, writer, - writer.get_extra_info('peername')) - except Exception as exc: # pragma: no cover - print_exception(exc) - - res = await self.dispatch_request(req) - if res != Response.already_handled: # pragma: no branch - await res.write(writer) - try: - await writer.aclose() - except OSError as exc: # pragma: no cover - if exc.errno in MUTED_SOCKET_ERRORS: - pass - else: - raise - if self.debug and req: # pragma: no cover - print('{method} {path} {status_code}'.format( - method=req.method, path=req.path, - status_code=res.status_code)) - - async def dispatch_request(self, req): - after_request_handled = False - if req: - if req.content_length > req.max_content_length: - if 413 in self.error_handlers: - res = await self._invoke_handler( - self.error_handlers[413], req) - else: - res = 'Payload too large', 413 - else: - f = self.find_route(req) - try: - res = None - if callable(f): - for handler in self.before_request_handlers: - res = await self._invoke_handler(handler, req) - if res: - break - if res is None: - res = await self._invoke_handler( - f, req, **req.url_args) - if isinstance(res, tuple): - body = res[0] - if isinstance(res[1], int): - status_code = res[1] - headers = res[2] if len(res) > 2 else {} - else: - status_code = 200 - headers = res[1] - res = Response(body, status_code, headers) - elif not isinstance(res, Response): - res = Response(res) - for handler in self.after_request_handlers: - res = await self._invoke_handler( - handler, req, res) or res - for handler in req.after_request_handlers: - res = await self._invoke_handler( - handler, req, res) or res - after_request_handled = True - elif isinstance(f, dict): - res = Response(headers=f) - elif f in self.error_handlers: - res = await self._invoke_handler( - self.error_handlers[f], req) - else: - res = 'Not found', f - except HTTPException as exc: - if exc.status_code in self.error_handlers: - res = self.error_handlers[exc.status_code](req) - else: - res = exc.reason, exc.status_code - except Exception as exc: - print_exception(exc) - exc_class = None - res = None - if exc.__class__ in self.error_handlers: - exc_class = exc.__class__ - else: - for c in mro(exc.__class__)[1:]: - if c in self.error_handlers: - exc_class = c - break - if exc_class: - try: - res = await self._invoke_handler( - self.error_handlers[exc_class], req, exc) - except Exception as exc2: # pragma: no cover - print_exception(exc2) - if res is None: - if 500 in self.error_handlers: - res = await self._invoke_handler( - self.error_handlers[500], req) - else: - res = 'Internal server error', 500 - else: - if 400 in self.error_handlers: - res = await self._invoke_handler(self.error_handlers[400], req) - else: - res = 'Bad request', 400 - if isinstance(res, tuple): - res = Response(*res) - elif not isinstance(res, Response): - res = Response(res) - if not after_request_handled: - for handler in self.after_error_request_handlers: - res = await self._invoke_handler( - handler, req, res) or res - res.is_head = (req and req.method == 'HEAD') - return res - - async def _invoke_handler(self, f_or_coro, *args, **kwargs): - ret = f_or_coro(*args, **kwargs) - if _iscoroutine(ret): - ret = await ret - return ret - - -abort = Microdot.abort -Response.already_handled = Response() -redirect = Response.redirect -send_file = Response.send_file diff --git a/src/microdot_asyncio_test_client.py b/src/microdot_asyncio_test_client.py deleted file mode 100644 index fc326e9..0000000 --- a/src/microdot_asyncio_test_client.py +++ /dev/null @@ -1,208 +0,0 @@ -from microdot_asyncio import Request, Response, _AsyncBytesIO -from microdot_test_client import TestClient as BaseTestClient, \ - TestResponse as BaseTestResponse -try: - from microdot_asyncio_websocket import WebSocket -except: # pragma: no cover # noqa: E722 - WebSocket = None - - -class TestResponse(BaseTestResponse): - """A response object issued by the Microdot test client.""" - - @classmethod - async def create(cls, res): - test_res = cls() - test_res._initialize_response(res) - await test_res._initialize_body(res) - test_res._process_text_body() - test_res._process_json_body() - return test_res - - async def _initialize_body(self, res): - self.body = b'' - async for body in res.body_iter(): # pragma: no branch - if isinstance(body, str): - body = body.encode() - self.body += body - - -class TestClient(BaseTestClient): - """A test client for Microdot's Asynchronous web server. - - :param app: The Microdot application instance. - :param cookies: A dictionary of cookies to use when sending requests to the - application. - - The following example shows how to create a test client for an application - and send a test request:: - - from microdot_asyncio import Microdot - - app = Microdot() - - @app.get('/') - async def index(): - return 'Hello, World!' - - async def test_hello_world(self): - client = TestClient(app) - res = await client.get('/') - assert res.status_code == 200 - assert res.text == 'Hello, World!' - """ - async def request(self, method, path, headers=None, body=None, sock=None): - headers = headers or {} - body, headers = self._process_body(body, headers) - cookies, headers = self._process_cookies(headers) - request_bytes = self._render_request(method, path, headers, body) - if sock: - reader = sock[0] - reader.buffer = request_bytes - writer = sock[1] - else: - reader = _AsyncBytesIO(request_bytes) - writer = _AsyncBytesIO(b'') - - req = await Request.create(self.app, reader, writer, - ('127.0.0.1', 1234)) - res = await self.app.dispatch_request(req) - if res == Response.already_handled: - return None - res.complete() - - self._update_cookies(res) - return await TestResponse.create(res) - - async def get(self, path, headers=None): - """Send a GET request to the application. - - :param path: The request URL. - :param headers: A dictionary of headers to send with the request. - - This method returns a - :class:`TestResponse ` object. - """ - return await self.request('GET', path, headers=headers) - - async def post(self, path, headers=None, body=None): - """Send a POST request to the application. - - :param path: The request URL. - :param headers: A dictionary of headers to send with the request. - :param body: The request body. If a dictionary or list is provided, - a JSON-encoded body will be sent. A string body is encoded - to bytes as UTF-8. A bytes body is sent as-is. - - This method returns a - :class:`TestResponse ` object. - """ - return await self.request('POST', path, headers=headers, body=body) - - async def put(self, path, headers=None, body=None): - """Send a PUT request to the application. - - :param path: The request URL. - :param headers: A dictionary of headers to send with the request. - :param body: The request body. If a dictionary or list is provided, - a JSON-encoded body will be sent. A string body is encoded - to bytes as UTF-8. A bytes body is sent as-is. - - This method returns a - :class:`TestResponse ` object. - """ - return await self.request('PUT', path, headers=headers, body=body) - - async def patch(self, path, headers=None, body=None): - """Send a PATCH request to the application. - - :param path: The request URL. - :param headers: A dictionary of headers to send with the request. - :param body: The request body. If a dictionary or list is provided, - a JSON-encoded body will be sent. A string body is encoded - to bytes as UTF-8. A bytes body is sent as-is. - - This method returns a - :class:`TestResponse ` object. - """ - return await self.request('PATCH', path, headers=headers, body=body) - - async def delete(self, path, headers=None): - """Send a DELETE request to the application. - - :param path: The request URL. - :param headers: A dictionary of headers to send with the request. - - This method returns a - :class:`TestResponse ` object. - """ - return await self.request('DELETE', path, headers=headers) - - async def websocket(self, path, client, headers=None): - """Send a websocket connection request to the application. - - :param path: The request URL. - :param client: A generator function that yields client messages. - :param headers: A dictionary of headers to send with the request. - """ - gen = client() - - class FakeWebSocket: - def __init__(self): - self.started = False - self.closed = False - self.buffer = b'' - - async def _next(self, data=None): - try: - data = (await gen.asend(data)) if hasattr(gen, 'asend') \ - else gen.send(data) - except (StopIteration, StopAsyncIteration): - if not self.closed: - self.closed = True - raise OSError(32, 'Websocket connection closed') - return # pragma: no cover - opcode = WebSocket.TEXT if isinstance(data, str) \ - else WebSocket.BINARY - return WebSocket._encode_websocket_frame(opcode, data) - - async def read(self, n): - if not self.buffer: - self.started = True - self.buffer = await self._next() - data = self.buffer[:n] - self.buffer = self.buffer[n:] - return data - - async def readexactly(self, n): # pragma: no cover - return await self.read(n) - - async def readline(self): - line = b'' - while True: - line += await self.read(1) - if line[-1] in [b'\n', 10]: - break - return line - - async def awrite(self, data): - if self.started: - h = WebSocket._parse_frame_header(data[0:2]) - if h[3] < 0: - data = data[2 - h[3]:] - else: - data = data[2:] - if h[1] == WebSocket.TEXT: - data = data.decode() - self.buffer = await self._next(data) - - ws_headers = { - 'Upgrade': 'websocket', - 'Connection': 'Upgrade', - 'Sec-WebSocket-Version': '13', - 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', - } - ws_headers.update(headers or {}) - sock = FakeWebSocket() - return await self.request('GET', path, headers=ws_headers, - sock=(sock, sock)) diff --git a/src/microdot_asyncio_websocket.py b/src/microdot_asyncio_websocket.py deleted file mode 100644 index 1e2b11a..0000000 --- a/src/microdot_asyncio_websocket.py +++ /dev/null @@ -1,103 +0,0 @@ -from microdot_asyncio import Response -from microdot_websocket import WebSocket as BaseWebSocket - - -class WebSocket(BaseWebSocket): - async def handshake(self): - response = self._handshake_response() - await self.request.sock[1].awrite( - b'HTTP/1.1 101 Switching Protocols\r\n') - await self.request.sock[1].awrite(b'Upgrade: websocket\r\n') - await self.request.sock[1].awrite(b'Connection: Upgrade\r\n') - await self.request.sock[1].awrite( - b'Sec-WebSocket-Accept: ' + response + b'\r\n\r\n') - - async def receive(self): - while True: - opcode, payload = await self._read_frame() - send_opcode, data = self._process_websocket_frame(opcode, payload) - if send_opcode: # pragma: no cover - await self.send(data, send_opcode) - elif data: # pragma: no branch - return data - - async def send(self, data, opcode=None): - frame = self._encode_websocket_frame( - opcode or (self.TEXT if isinstance(data, str) else self.BINARY), - data) - await self.request.sock[1].awrite(frame) - - async def close(self): - if not self.closed: # pragma: no cover - self.closed = True - await self.send(b'', self.CLOSE) - - async def _read_frame(self): - header = await self.request.sock[0].read(2) - if len(header) != 2: # pragma: no cover - raise OSError(32, 'Websocket connection closed') - fin, opcode, has_mask, length = self._parse_frame_header(header) - if length == -2: - length = await self.request.sock[0].read(2) - length = int.from_bytes(length, 'big') - elif length == -8: - length = await self.request.sock[0].read(8) - length = int.from_bytes(length, 'big') - if has_mask: # pragma: no cover - mask = await self.request.sock[0].read(4) - payload = await self.request.sock[0].read(length) - if has_mask: # pragma: no cover - payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload)) - return opcode, payload - - -async def websocket_upgrade(request): - """Upgrade a request handler to a websocket connection. - - This function can be called directly inside a route function to process a - WebSocket upgrade handshake, for example after the user's credentials are - verified. The function returns the websocket object:: - - @app.route('/echo') - async def echo(request): - if not authenticate_user(request): - abort(401) - ws = await websocket_upgrade(request) - while True: - message = await ws.receive() - await ws.send(message) - """ - ws = WebSocket(request) - await ws.handshake() - - @request.after_request - async def after_request(request, response): - return Response.already_handled - - return ws - - -def with_websocket(f): - """Decorator to make a route a WebSocket endpoint. - - This decorator is used to define a route that accepts websocket - connections. The route then receives a websocket object as a second - argument that it can use to send and receive messages:: - - @app.route('/echo') - @with_websocket - async def echo(request, ws): - while True: - message = await ws.receive() - await ws.send(message) - """ - async def wrapper(request, *args, **kwargs): - ws = await websocket_upgrade(request) - try: - await f(request, ws, *args, **kwargs) - await ws.close() # pragma: no cover - except OSError as exc: - if exc.errno not in [32, 54, 104]: # pragma: no cover - raise - return '' - return wrapper diff --git a/src/microdot_jinja.py b/src/microdot_jinja.py deleted file mode 100644 index 533ef1d..0000000 --- a/src/microdot_jinja.py +++ /dev/null @@ -1,33 +0,0 @@ -from jinja2 import Environment, FileSystemLoader, select_autoescape - -_jinja_env = None - - -def init_templates(template_dir='templates'): - """Initialize the templating subsystem. - - :param template_dir: the directory where templates are stored. This - argument is optional. The default is to load templates - from a *templates* subdirectory. - """ - global _jinja_env - _jinja_env = Environment( - loader=FileSystemLoader(template_dir), - autoescape=select_autoescape() - ) - - -def render_template(template, *args, **kwargs): - """Render a template. - - :param template: The filename of the template to render, relative to the - configured template directory. - :param args: Positional arguments to be passed to the render engine. - :param kwargs: Keyword arguments to be passed to the render engine. - - The return value is a string with the rendered template. - """ - if _jinja_env is None: # pragma: no cover - init_templates() - template = _jinja_env.get_template(template) - return template.render(*args, **kwargs) diff --git a/src/microdot_session.py b/src/microdot_session.py deleted file mode 100644 index 2778c0f..0000000 --- a/src/microdot_session.py +++ /dev/null @@ -1,98 +0,0 @@ -import jwt - -secret_key = None - - -def set_session_secret_key(key): - """Set the secret key for signing user sessions. - - :param key: The secret key, as a string or bytes object. - """ - global secret_key - secret_key = key - - -def get_session(request): - """Retrieve the user session. - - :param request: The client request. - - The return value is a dictionary with the data stored in the user's - session, or ``{}`` if the session data is not available or invalid. - """ - global secret_key - if not secret_key: - raise ValueError('The session secret key is not configured') - if hasattr(request.g, '_session'): - return request.g._session - session = request.cookies.get('session') - if session is None: - request.g._session = {} - return request.g._session - try: - session = jwt.decode(session, secret_key, algorithms=['HS256']) - except jwt.exceptions.PyJWTError: # pragma: no cover - request.g._session = {} - else: - request.g._session = session - return request.g._session - - -def update_session(request, session): - """Update the user session. - - :param request: The client request. - :param session: A dictionary with the update session data for the user. - - Calling this function adds a cookie with the updated session to the request - currently being processed. - """ - if not secret_key: - raise ValueError('The session secret key is not configured') - - encoded_session = jwt.encode(session, secret_key, algorithm='HS256') - - @request.after_request - def _update_session(request, response): - response.set_cookie('session', encoded_session, http_only=True) - return response - - -def delete_session(request): - """Remove the user session. - - :param request: The client request. - - Calling this function adds a cookie removal header to the request currently - being processed. - """ - @request.after_request - def _delete_session(request, response): - response.set_cookie('session', '', http_only=True, - expires='Thu, 01 Jan 1970 00:00:01 GMT') - return response - - -def with_session(f): - """Decorator that passes the user session to the route handler. - - The session dictionary is passed to the decorated function as an argument - after the request object. Example:: - - @app.route('/') - @with_session - def index(request, session): - return 'Hello, World!' - - Note that the decorator does not save the session. To update the session, - call the :func:`update_session ` function. - """ - def wrapper(request, *args, **kwargs): - return f(request, get_session(request), *args, **kwargs) - - for attr in ['__name__', '__doc__', '__module__', '__qualname__']: - try: - setattr(wrapper, attr, getattr(f, attr)) - except AttributeError: # pragma: no cover - pass - return wrapper diff --git a/src/microdot_ssl.py b/src/microdot_ssl.py deleted file mode 100644 index f0514f5..0000000 --- a/src/microdot_ssl.py +++ /dev/null @@ -1,61 +0,0 @@ -import ssl - - -def create_ssl_context(cert, key, **kwargs): - """Create an SSL context to wrap sockets with. - - :param cert: The certificate to use. If it is given as a string, it is - assumed to be a filename. If it is given as a bytes object, it - is assumed to be the certificate data. In both cases the data - is expected to be in PEM format for CPython and in DER format - for MicroPython. - :param key: The private key to use. If it is given as a string, it is - assumed to be a filename. If it is given as a bytes object, it - is assumed to be the private key data. in both cases the data - is expected to be in PEM format for CPython and in DER format - for MicroPython. - :param kwargs: Additional arguments to pass to the ``ssl.wrap_socket`` - function. - - Note: This function creates a fairly limited SSL context object to enable - the use of certificates under MicroPython. It is not intended to be used in - any other context, and in particular, it is not needed when using CPython - or any other Python implementation that has native support for - ``SSLContext`` objects. Once MicroPython implements ``SSLContext`` - natively, this function will be deprecated. - """ - if hasattr(ssl, 'SSLContext'): - ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER, **kwargs) - ctx.load_cert_chain(cert, key) - return ctx - - if isinstance(cert, str): - with open(cert, 'rb') as f: - cert = f.read() - if isinstance(key, str): - with open(key, 'rb') as f: - key = f.read() - - class FakeSSLSocket: - def __init__(self, sock, **kwargs): - self.sock = sock - self.kwargs = kwargs - - def accept(self): - client, addr = self.sock.accept() - return (ssl.wrap_socket(client, cert=cert, key=key, **self.kwargs), - addr) - - def close(self): - self.sock.close() - - class FakeSSLContext: - def __init__(self, **kwargs): - self.kwargs = kwargs - - def wrap_socket(self, sock, **kwargs): - all_kwargs = self.kwargs.copy() - all_kwargs.update(kwargs) - return FakeSSLSocket(sock, **all_kwargs) - - return FakeSSLContext(**kwargs) diff --git a/src/microdot_utemplate.py b/src/microdot_utemplate.py deleted file mode 100644 index ccef608..0000000 --- a/src/microdot_utemplate.py +++ /dev/null @@ -1,34 +0,0 @@ -from utemplate import recompile - -_loader = None - - -def init_templates(template_dir='templates', loader_class=recompile.Loader): - """Initialize the templating subsystem. - - :param template_dir: the directory where templates are stored. This - argument is optional. The default is to load templates - from a *templates* subdirectory. - :param loader_class: the ``utemplate.Loader`` class to use when loading - templates. This argument is optional. The default is - the ``recompile.Loader`` class, which automatically - recompiles templates when they change. - """ - global _loader - _loader = loader_class(None, template_dir) - - -def render_template(template, *args, **kwargs): - """Render a template. - - :param template: The filename of the template to render, relative to the - configured template directory. - :param args: Positional arguments to be passed to the render engine. - :param kwargs: Keyword arguments to be passed to the render engine. - - The return value is an iterator that returns sections of rendered template. - """ - if _loader is None: # pragma: no cover - init_templates() - render = _loader.load(template) - return render(*args, **kwargs) diff --git a/src/microdot_websocket_alt.py b/src/microdot_websocket_alt.py deleted file mode 100644 index 9bcdbfc..0000000 --- a/src/microdot_websocket_alt.py +++ /dev/null @@ -1,114 +0,0 @@ -import binascii -import hashlib -import select -import websocket as _websocket -from microdot import Response - - -class WebSocket: - CONT = 0 - TEXT = 1 - BINARY = 2 - CLOSE = 8 - PING = 9 - PONG = 10 - - def __init__(self, request): - self.request = request - self.poll = select.poll() - self.poll.register(self.request.sock, select.POLLIN) - self.ws = _websocket.websocket(self.request.sock, True) - self.request.sock.setblocking(False) - - def handshake(self): - response = self._handshake_response() - self.request.sock.write(b'HTTP/1.1 101 Switching Protocols\r\n') - self.request.sock.write(b'Upgrade: websocket\r\n') - self.request.sock.write(b'Connection: Upgrade\r\n') - self.request.sock.write( - b'Sec-WebSocket-Accept: ' + response + b'\r\n\r\n') - - def receive(self): - while True: - self.poll.poll() - data = self.ws.read() - if data: - try: - data = data.decode() - except ValueError: - pass - return data - - def send(self, data): - self.ws.write(data) - - def close(self): - self.poll.unregister(self.request.sock) - self.ws.close() - - def _handshake_response(self): - for header, value in self.request.headers.items(): - h = header.lower() - if h == 'connection' and not value.lower().startswith('upgrade'): - return self.request.app.abort(400) - elif h == 'upgrade' and not value.lower() == 'websocket': - return self.request.app.abort(400) - elif h == 'sec-websocket-key': - websocket_key = value - if not websocket_key: - return self.request.app.abort(400) - d = hashlib.sha1(websocket_key.encode()) - d.update(b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11') - return binascii.b2a_base64(d.digest())[:-1] - - -def websocket_upgrade(request): - """Upgrade a request handler to a websocket connection. - - This function can be called directly inside a route function to process a - WebSocket upgrade handshake, for example after the user's credentials are - verified. The function returns the websocket object:: - - @app.route('/echo') - def echo(request): - if not authenticate_user(request): - abort(401) - ws = websocket_upgrade(request) - while True: - message = ws.receive() - ws.send(message) - """ - ws = WebSocket(request) - ws.handshake() - - @request.after_request - def after_request(request, response): - return Response.already_handled - - return ws - - -def with_websocket(f): - """Decorator to make a route a WebSocket endpoint. - - This decorator is used to define a route that accepts websocket - connections. The route then receives a websocket object as a second - argument that it can use to send and receive messages:: - - @app.route('/echo') - @with_websocket - def echo(request, ws): - while True: - message = ws.receive() - ws.send(message) - """ - def wrapper(request, *args, **kwargs): - ws = websocket_upgrade(request) - try: - f(request, ws, *args, **kwargs) - except OSError as exc: - if exc.errno != 32 and exc.errno != 54: - raise - ws.close() - return '' - return wrapper diff --git a/src/microdot_wsgi.py b/src/microdot_wsgi.py deleted file mode 100644 index fb8991d..0000000 --- a/src/microdot_wsgi.py +++ /dev/null @@ -1,64 +0,0 @@ -import os -import signal -from microdot import * # noqa: F401, F403 -from microdot import Microdot as BaseMicrodot, Request, NoCaseDict - - -class Microdot(BaseMicrodot): - def __init__(self): - super().__init__() - self.embedded_server = False - - def wsgi_app(self, environ, start_response): - """A WSGI application callable.""" - path = environ.get('SCRIPT_NAME', '') + environ.get('PATH_INFO', '') - if 'QUERY_STRING' in environ and environ['QUERY_STRING']: - path += '?' + environ['QUERY_STRING'] - headers = NoCaseDict() - for k, v in environ.items(): - if k.startswith('HTTP_'): - h = '-'.join([p.title() for p in k[5:].split('_')]) - headers[h] = v - req = Request( - self, - (environ['REMOTE_ADDR'], int(environ.get('REMOTE_PORT', '0'))), - environ['REQUEST_METHOD'], - path, - environ['SERVER_PROTOCOL'], - headers, - stream=environ['wsgi.input'], - sock=environ.get('gunicorn.socket')) - req.environ = environ - - res = self.dispatch_request(req) - res.complete() - - reason = res.reason or ('OK' if res.status_code == 200 else 'N/A') - header_list = [] - for name, value in res.headers.items(): - if not isinstance(value, list): - header_list.append((name, value)) - else: - for v in value: - header_list.append((name, v)) - start_response(str(res.status_code) + ' ' + reason, header_list) - return res.body_iter() - - def __call__(self, environ, start_response): - return self.wsgi_app(environ, start_response) - - def shutdown(self): - if self.embedded_server: # pragma: no cover - super().shutdown() - else: - pid = os.getpgrp() if hasattr(os, 'getpgrp') else os.getpid() - os.kill(pid, signal.SIGTERM) - - def run(self, host='0.0.0.0', port=5000, debug=False, - **options): # pragma: no cover - """Normally you would not start the server by invoking this method. - Instead, start your chosen WSGI web server and pass the ``Microdot`` - instance as the WSGI callable. - """ - self.embedded_server = True - super().run(host=host, port=port, debug=debug, **options) diff --git a/tests/__init__.py b/tests/__init__.py index 1cfcf37..4f40481 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,14 +1,11 @@ -from .test_multidict import TestMultiDict -from .test_request import TestRequest -from .test_response import TestResponse -from .test_url_pattern import TestURLPattern -from .test_microdot import TestMicrodot -from .test_microdot_websocket import TestMicrodotWebSocket - -from .test_request_asyncio import TestRequestAsync -from .test_response_asyncio import TestResponseAsync -from .test_microdot_asyncio import TestMicrodotAsync -from .test_microdot_asyncio_websocket import TestMicrodotAsyncWebSocket -from .test_utemplate import TestUTemplate - -from .test_session import TestSession +from tests.test_microdot import * # noqa: F401, F403 +from tests.test_multidict import * # noqa: F401, F403 +from tests.test_request import * # noqa: F401, F403 +from tests.test_response import * # noqa: F401, F403 +from tests.test_urlencode import * # noqa: F401, F403 +from tests.test_url_pattern import * # noqa: F401, F403 +from tests.test_websocket import * # noqa: F401, F403 +from tests.test_sse import * # noqa: F401, F403 +from tests.test_cors import * # noqa: F401, F403 +from tests.test_utemplate import * # noqa: F401, F403 +from tests.test_session import * # noqa: F401, F403 diff --git a/tests/mock_asyncio.py b/tests/mock_asyncio.py deleted file mode 100644 index d2a1ddc..0000000 --- a/tests/mock_asyncio.py +++ /dev/null @@ -1,39 +0,0 @@ -try: - import uasyncio as asyncio -except ImportError: - import asyncio - -from tests import mock_socket - - -def get_event_loop(): - return asyncio.get_event_loop() - - -async def start_server(cb, host, port): - class MockServer: - def __init__(self): - self.closed = False - - async def run(self): - s = mock_socket.socket() - while not self.closed: - fd, addr = s.accept() - fd = mock_socket.FakeStreamAsync(fd) - await cb(fd, fd) - - def close(self): - self.closed = True - - async def wait_closed(self): - while not self.closed: - await asyncio.sleep(0.01) - - server = MockServer() - asyncio.get_event_loop().create_task(server.run()) - return server - - -def run(coro): - loop = asyncio.get_event_loop() - return loop.run_until_complete(coro) diff --git a/tests/test_microdot_asgi.py b/tests/test_asgi.py similarity index 88% rename from tests/test_microdot_asgi.py rename to tests/test_asgi.py index ae753cf..d2d2e04 100644 --- a/tests/test_microdot_asgi.py +++ b/tests/test_asgi.py @@ -1,23 +1,23 @@ -import unittest +import asyncio import sys +import unittest +from unittest import mock -try: - import asyncio -except ImportError: - pass - -try: - from unittest import mock -except ImportError: - mock = None - -from microdot_asgi import Microdot, Response -from tests import mock_asyncio +from microdot.asgi import Microdot, Response @unittest.skipIf(sys.implementation.name == 'micropython', 'not supported under MicroPython') -class TestMicrodotASGI(unittest.TestCase): +class TestASGI(unittest.TestCase): + @classmethod + def setUpClass(cls): + if hasattr(asyncio, 'set_event_loop'): + asyncio.set_event_loop(asyncio.new_event_loop()) + cls.loop = asyncio.get_event_loop() + + def _run(self, coro): + return self.loop.run_until_complete(coro) + def test_asgi_request_with_query_string(self): app = Microdot() @@ -98,7 +98,7 @@ class TestMicrodotASGI(unittest.TestCase): original_buffer_size = Response.send_file_buffer_size Response.send_file_buffer_size = 2 - mock_asyncio.run(app(scope, receive, send)) + self._run(app(scope, receive, send)) Response.send_file_buffer_size = original_buffer_size @@ -143,7 +143,7 @@ class TestMicrodotASGI(unittest.TestCase): async def send(packet): pass - mock_asyncio.run(app(scope, receive, send)) + self._run(app(scope, receive, send)) def test_shutdown(self): app = Microdot() @@ -166,7 +166,7 @@ class TestMicrodotASGI(unittest.TestCase): async def send(packet): pass - with mock.patch('microdot_asgi.os.kill') as kill: - mock_asyncio.run(app(scope, receive, send)) + with mock.patch('microdot.asgi.os.kill') as kill: + self._run(app(scope, receive, send)) kill.assert_called() diff --git a/tests/test_cors.py b/tests/test_cors.py index d13265b..136a2ce 100644 --- a/tests/test_cors.py +++ b/tests/test_cors.py @@ -1,10 +1,18 @@ +import asyncio import unittest from microdot import Microdot -from microdot_test_client import TestClient -from microdot_cors import CORS +from microdot.test_client import TestClient +from microdot.cors import CORS class TestCORS(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.loop = asyncio.new_event_loop() + + def _run(self, coro): + return self.loop.run_until_complete(coro) + def test_origin(self): app = Microdot() cors = CORS(allowed_origins=['https://example.com'], @@ -16,13 +24,14 @@ class TestCORS(unittest.TestCase): return 'foo' client = TestClient(app) - res = client.get('/') + res = self._run(client.get('/')) self.assertEqual(res.status_code, 200) self.assertFalse('Access-Control-Allow-Origin' in res.headers) self.assertFalse('Access-Control-Allow-Credentials' in res.headers) self.assertFalse('Vary' in res.headers) - res = client.get('/', headers={'Origin': 'https://example.com'}) + res = self._run(client.get( + '/', headers={'Origin': 'https://example.com'})) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Access-Control-Allow-Origin'], 'https://example.com') @@ -32,14 +41,15 @@ class TestCORS(unittest.TestCase): cors.allow_credentials = False - res = client.get('/foo', headers={'Origin': 'https://example.com'}) + res = self._run(client.get( + '/foo', headers={'Origin': 'https://example.com'})) self.assertEqual(res.status_code, 404) self.assertEqual(res.headers['Access-Control-Allow-Origin'], 'https://example.com') self.assertFalse('Access-Control-Allow-Credentials' in res.headers) self.assertEqual(res.headers['Vary'], 'Origin') - res = client.get('/', headers={'Origin': 'https://bad.com'}) + res = self._run(client.get('/', headers={'Origin': 'https://bad.com'})) self.assertEqual(res.status_code, 200) self.assertFalse('Access-Control-Allow-Origin' in res.headers) self.assertFalse('Access-Control-Allow-Credentials' in res.headers) @@ -58,14 +68,15 @@ class TestCORS(unittest.TestCase): return 'foo', {'Vary': 'X-Foo, X-Bar'} client = TestClient(app) - res = client.get('/') + res = self._run(client.get('/')) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Access-Control-Allow-Origin'], '*') self.assertFalse('Vary' in res.headers) self.assertEqual(res.headers['Access-Control-Expose-Headers'], 'X-Test, X-Test2') - res = client.get('/', headers={'Origin': 'https://example.com'}) + res = self._run(client.get( + '/', headers={'Origin': 'https://example.com'})) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Access-Control-Allow-Origin'], 'https://example.com') @@ -73,7 +84,8 @@ class TestCORS(unittest.TestCase): self.assertEqual(res.headers['Access-Control-Expose-Headers'], 'X-Test, X-Test2') - res = client.get('/bad', headers={'Origin': 'https://example.com'}) + res = self._run(client.get( + '/bad', headers={'Origin': 'https://example.com'})) self.assertEqual(res.status_code, 404) self.assertEqual(res.headers['Access-Control-Allow-Origin'], 'https://example.com') @@ -81,7 +93,8 @@ class TestCORS(unittest.TestCase): self.assertEqual(res.headers['Access-Control-Expose-Headers'], 'X-Test, X-Test2') - res = client.get('/foo', headers={'Origin': 'https://example.com'}) + res = self._run(client.get( + '/foo', headers={'Origin': 'https://example.com'})) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Vary'], 'X-Foo, X-Bar, Origin') @@ -94,10 +107,10 @@ class TestCORS(unittest.TestCase): return 'foo' client = TestClient(app) - res = client.request('OPTIONS', '/', headers={ + res = self._run(client.request('OPTIONS', '/', headers={ 'Origin': 'https://example.com', 'Access-Control-Request-Method': 'POST', - 'Access-Control-Request-Headers': 'X-Test, X-Test2'}) + 'Access-Control-Request-Headers': 'X-Test, X-Test2'})) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Access-Control-Allow-Origin'], 'https://example.com') @@ -106,8 +119,8 @@ class TestCORS(unittest.TestCase): self.assertEqual(res.headers['Access-Control-Allow-Headers'], 'X-Test, X-Test2') - res = client.request('OPTIONS', '/', headers={ - 'Origin': 'https://example.com'}) + res = self._run(client.request('OPTIONS', '/', headers={ + 'Origin': 'https://example.com'})) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Access-Control-Allow-Origin'], 'https://example.com') @@ -125,10 +138,10 @@ class TestCORS(unittest.TestCase): return 'foo' client = TestClient(app) - res = client.request('OPTIONS', '/', headers={ + res = self._run(client.request('OPTIONS', '/', headers={ 'Origin': 'https://example.com', 'Access-Control-Request-Method': 'POST', - 'Access-Control-Request-Headers': 'X-Test, X-Test2'}) + 'Access-Control-Request-Headers': 'X-Test, X-Test2'})) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Access-Control-Allow-Origin'], 'https://example.com') @@ -136,9 +149,9 @@ class TestCORS(unittest.TestCase): self.assertEqual(res.headers['Access-Control-Allow-Methods'], 'POST') self.assertEqual(res.headers['Access-Control-Allow-Headers'], 'X-Test') - res = client.request('OPTIONS', '/', headers={ + res = self._run(client.request('OPTIONS', '/', headers={ 'Origin': 'https://example.com', - 'Access-Control-Request-Method': 'GET'}) + 'Access-Control-Request-Method': 'GET'})) self.assertEqual(res.status_code, 200) self.assertFalse('Access-Control-Allow-Methods' in res.headers) self.assertFalse('Access-Control-Allow-Headers' in res.headers) @@ -152,7 +165,7 @@ class TestCORS(unittest.TestCase): return 'foo' client = TestClient(app) - res = client.get('/') + res = self._run(client.get('/')) self.assertEqual(res.status_code, 200) self.assertFalse('Access-Control-Allow-Origin' in res.headers) self.assertFalse('Vary' in res.headers) diff --git a/tests/test_end2end.py b/tests/test_end2end.py new file mode 100644 index 0000000..eafb6b0 --- /dev/null +++ b/tests/test_end2end.py @@ -0,0 +1,104 @@ +import asyncio +import sys +import time +import unittest +from microdot import Microdot + + +class TestEnd2End(unittest.TestCase): + async def request(self, url, method='GET'): + while True: + reader, writer = await asyncio.open_connection('localhost', 5678) + try: + writer.write(f'{method} {url} HTTP/1.0\r\n\r\n'.encode()) + break + except OSError: + # micropython's server sometimes needs a moment + writer.close() + await writer.wait_closed() + await asyncio.sleep(0.1) + await writer.drain() + + response = await reader.read() + writer.close() + await writer.wait_closed() + return response.decode().splitlines() + + def test_get(self): + app = Microdot() + + @app.route('/') + def index(request): + return 'Hello, World!' + + @app.route('/shutdown') + def shutdown(request): + app.shutdown() + return '' + + async def run(): + server = asyncio.create_task(app.start_server(host='127.0.0.1', + port=5678)) + await asyncio.sleep(0.1) + response = await self.request('/') + self.assertEqual(response[0], 'HTTP/1.0 200 OK') + self.assertEqual(response[-1], 'Hello, World!') + await self.request('/shutdown') + await server + + asyncio.run(run()) + + @unittest.skipIf(sys.implementation.name == 'micropython', + 'not supported under MicroPython') + def test_concurrent_requests(self): + app = Microdot() + counter = 0 + + @app.route('/async1') + async def async1(request): + nonlocal counter + counter += 1 + while counter < 4: + await asyncio.sleep(0.01) + return 'OK' + + @app.route('/async2') + async def async2(request): + nonlocal counter + counter += 1 + while counter < 4: + await asyncio.sleep(0.01) + return 'OK' + + @app.route('/sync1') + def sync1(request): + nonlocal counter + counter += 1 + while counter < 4: + time.sleep(0.01) + return 'OK' + + @app.route('/sync2') + def sync2(request): + nonlocal counter + counter += 1 + while counter < 4: + time.sleep(0.01) + return 'OK' + + @app.route('/shutdown') + def shutdown(request): + app.shutdown() + return '' + + async def run(): + server = asyncio.create_task(app.start_server(port=5678)) + await asyncio.sleep(0.1) + await asyncio.gather(self.request('/async1'), + self.request('/async2'), + self.request('/sync1'), + self.request('/sync2')) + await self.request('/shutdown') + await server + + asyncio.run(run()) diff --git a/tests/test_jinja.py b/tests/test_jinja.py index 1fca3b0..2d381fc 100644 --- a/tests/test_jinja.py +++ b/tests/test_jinja.py @@ -1,58 +1,81 @@ -try: - import uasyncio as asyncio -except ImportError: - import asyncio - +import asyncio import sys import unittest -from microdot import Microdot, Request -from microdot_asyncio import Microdot as MicrodotAsync, Request as RequestAsync -from microdot_jinja import render_template, init_templates -from tests.mock_socket import get_request_fd, get_async_request_fd +from microdot import Microdot +from microdot.jinja import Template, init_templates +from microdot.test_client import TestClient init_templates('tests/templates') -def _run(coro): - return asyncio.get_event_loop().run_until_complete(coro) - - @unittest.skipIf(sys.implementation.name == 'micropython', 'not supported under MicroPython') class TestJinja(unittest.TestCase): + @classmethod + def setUpClass(cls): + if hasattr(asyncio, 'set_event_loop'): + asyncio.set_event_loop(asyncio.new_event_loop()) + cls.loop = asyncio.get_event_loop() + + def _run(self, coro): + return self.loop.run_until_complete(coro) + def test_render_template(self): - s = render_template('hello.jinja.txt', name='foo') + s = Template('hello.jinja.txt').render(name='foo') self.assertEqual(s, 'Hello, foo!') def test_render_template_in_app(self): app = Microdot() @app.route('/') - def index(req): - return render_template('hello.jinja.txt', name='foo') + async def index(req): + return Template('hello.jinja.txt').render(name='foo') - req = Request.create(app, get_request_fd('GET', '/'), 'addr') - res = app.dispatch_request(req) + client = TestClient(app) + res = self._run(client.get('/')) self.assertEqual(res.status_code, 200) - self.assertEqual(list(res.body_iter()), [b'Hello, foo!']) + self.assertEqual(res.body, b'Hello, foo!') - def test_render_template_in_app_async(self): - app = MicrodotAsync() + def test_generate_template_in_app(self): + app = Microdot() @app.route('/') async def index(req): - return render_template('hello.jinja.txt', name='foo') + return Template('hello.jinja.txt').generate(name='foo') - req = _run(RequestAsync.create( - app, get_async_request_fd('GET', '/'), 'writer', 'addr')) - res = _run(app.dispatch_request(req)) + client = TestClient(app) + res = self._run(client.get('/')) self.assertEqual(res.status_code, 200) + self.assertEqual(res.body, b'Hello, foo!') - async def get_result(): - result = [] - async for chunk in res.body_iter(): - result.append(chunk) - return result + def test_render_async_template_in_app(self): + init_templates('tests/templates', enable_async=True) - result = _run(get_result()) - self.assertEqual(result, [b'Hello, foo!']) + app = Microdot() + + @app.route('/') + async def index(req): + return await Template('hello.jinja.txt').render_async(name='foo') + + client = TestClient(app) + res = self._run(client.get('/')) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.body, b'Hello, foo!') + + init_templates('tests/templates') + + def test_generate_async_template_in_app(self): + init_templates('tests/templates', enable_async=True) + + app = Microdot() + + @app.route('/') + async def index(req): + return Template('hello.jinja.txt').generate_async(name='foo') + + client = TestClient(app) + res = self._run(client.get('/')) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.body, b'Hello, foo!') + + init_templates('tests/templates') diff --git a/tests/test_microdot.py b/tests/test_microdot.py index ff4b278..f99d571 100644 --- a/tests/test_microdot.py +++ b/tests/test_microdot.py @@ -1,31 +1,18 @@ -import sys +import asyncio import unittest from microdot import Microdot, Response, abort -from microdot_test_client import TestClient -from tests import mock_socket +from microdot.test_client import TestClient class TestMicrodot(unittest.TestCase): - def _mock(self): - def mock_create_thread(f, *args, **kwargs): - f(*args, **kwargs) + @classmethod + def setUpClass(cls): + if hasattr(asyncio, 'set_event_loop'): + asyncio.set_event_loop(asyncio.new_event_loop()) + cls.loop = asyncio.get_event_loop() - self.original_socket = sys.modules['microdot'].socket - self.original_create_thread = sys.modules['microdot'].create_thread - sys.modules['microdot'].socket = mock_socket - sys.modules['microdot'].create_thread = mock_create_thread - - def _unmock(self): - sys.modules['microdot'].socket = self.original_socket - sys.modules['microdot'].create_thread = self.original_create_thread - - def _add_shutdown(self, app): - @app.route('/shutdown') - def shutdown(req): - app.shutdown() - return '' - - mock_socket.add_request('GET', '/shutdown') + def _run(self, coro): + return self.loop.run_until_complete(coro) def test_get_request(self): app = Microdot() @@ -34,8 +21,13 @@ class TestMicrodot(unittest.TestCase): def index(req): return 'foo' + @app.route('/async') + async def index2(req): + return 'foo-async' + client = TestClient(app) - res = client.get('/') + + res = self._run(client.get('/')) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') @@ -44,6 +36,15 @@ class TestMicrodot(unittest.TestCase): self.assertEqual(res.body, b'foo') self.assertEqual(res.json, None) + res = self._run(client.get('/async')) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers['Content-Type'], + 'text/plain; charset=UTF-8') + self.assertEqual(res.headers['Content-Length'], '9') + self.assertEqual(res.text, 'foo-async') + self.assertEqual(res.body, b'foo-async') + self.assertEqual(res.json, None) + def test_post_request(self): app = Microdot() @@ -55,78 +56,71 @@ class TestMicrodot(unittest.TestCase): def index_post(req): return Response('bar') + @app.route('/async', methods=['POST']) + async def index_post2(req): + return Response('bar-async') + client = TestClient(app) - res = client.post('/') + + res = self._run(client.post('/')) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') self.assertEqual(res.headers['Content-Length'], '3') self.assertEqual(res.text, 'bar') + self.assertEqual(res.body, b'bar') + self.assertEqual(res.json, None) + + res = self._run(client.post('/async')) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers['Content-Type'], + 'text/plain; charset=UTF-8') + self.assertEqual(res.headers['Content-Length'], '9') + self.assertEqual(res.text, 'bar-async') + self.assertEqual(res.body, b'bar-async') + self.assertEqual(res.json, None) def test_head_request(self): - self._mock() - app = Microdot() @app.route('/foo') def index(req): return 'foo' - mock_socket.clear_requests() - fd = mock_socket.add_request('HEAD', '/foo') - self._add_shutdown(app) - app.run() - - self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n')) - self.assertIn(b'Content-Length: 3\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', - fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\n')) - - self._unmock() + client = TestClient(app) + res = self._run(client.request('HEAD', '/foo')) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers['Content-Type'], + 'text/plain; charset=UTF-8') + self.assertEqual(res.headers['Content-Length'], '3') + self.assertIsNone(res.body) + self.assertIsNone(res.text) + self.assertIsNone(res.json) def test_options_request(self): app = Microdot() @app.route('/', methods=['GET', 'DELETE']) - def index(req): + async def index(req): return 'foo' @app.post('/') - def index_post(req): + async def index_post(req): return 'bar' @app.route('/foo', methods=['POST', 'PUT']) - def foo(req): + async def foo(req): return 'baz' client = TestClient(app) - res = client.request('OPTIONS', '/') + res = self._run(client.request('OPTIONS', '/')) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Allow'], 'GET, DELETE, POST, HEAD, OPTIONS') - res = client.request('OPTIONS', '/foo') + res = self._run(client.request('OPTIONS', '/foo')) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Allow'], 'POST, PUT, OPTIONS') - def test_empty_request(self): - self._mock() - - app = Microdot() - - mock_socket.clear_requests() - fd = mock_socket.FakeStream(b'\n') - mock_socket._requests.append(fd) - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 400 N/A\r\n')) - self.assertIn(b'Content-Length: 11\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', - fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\nBad request')) - - self._unmock() - def test_method_decorators(self): app = Microdot() @@ -135,7 +129,7 @@ class TestMicrodot(unittest.TestCase): return 'GET' @app.post('/post') - def post(req): + async def post(req): return 'POST' @app.put('/put') @@ -143,7 +137,7 @@ class TestMicrodot(unittest.TestCase): return 'PUT' @app.patch('/patch') - def patch(req): + async def patch(req): return 'PATCH' @app.delete('/delete') @@ -153,7 +147,8 @@ class TestMicrodot(unittest.TestCase): client = TestClient(app) methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] for method in methods: - res = getattr(client, method.lower())('/' + method.lower()) + res = self._run(getattr( + client, method.lower())('/' + method.lower())) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') @@ -167,7 +162,7 @@ class TestMicrodot(unittest.TestCase): return req.headers.get('X-Foo') client = TestClient(app) - res = client.get('/', headers={'X-Foo': 'bar'}) + res = self._run(client.get('/', headers={'X-Foo': 'bar'})) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') @@ -178,15 +173,19 @@ class TestMicrodot(unittest.TestCase): @app.route('/') def index(req): - return req.cookies['one'] + req.cookies['two'] + \ - req.cookies['three'] + res = Response( + req.cookies['one'] + req.cookies['two'] + req.cookies['three']) + res.set_cookie('four', '4') + res.delete_cookie('two', path='/') + return res client = TestClient(app, cookies={'one': '1', 'two': '2'}) - res = client.get('/', headers={'Cookie': 'three=3'}) + res = self._run(client.get('/', headers={'Cookie': 'three=3'})) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') self.assertEqual(res.text, '123') + self.assertEqual(client.cookies, {'one': '1', 'four': '4'}) def test_binary_payload(self): app = Microdot() @@ -196,7 +195,7 @@ class TestMicrodot(unittest.TestCase): return req.body client = TestClient(app) - res = client.post('/', body=b'foo') + res = self._run(client.post('/', body=b'foo')) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') @@ -216,13 +215,13 @@ class TestMicrodot(unittest.TestCase): client = TestClient(app) - res = client.post('/dict', body={'foo': 'bar'}) + res = self._run(client.post('/dict', body={'foo': 'bar'})) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') self.assertEqual(res.text, 'bar') - res = client.post('/list', body=['foo', 'bar']) + res = self._run(client.post('/list', body=['foo', 'bar'])) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') @@ -250,24 +249,24 @@ class TestMicrodot(unittest.TestCase): client = TestClient(app) - res = client.get('/body') + res = self._run(client.get('/body')) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') self.assertEqual(res.text, 'one') - res = client.get('/body-status') + res = self._run(client.get('/body-status')) self.assertEqual(res.status_code, 202) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') self.assertEqual(res.text, 'two') - res = client.get('/body-headers') + res = self._run(client.get('/body-headers')) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Content-Type'], 'text/html') self.assertEqual(res.text, '

three

') - res = client.get('/body-status-headers') + res = self._run(client.get('/body-status-headers')) self.assertEqual(res.status_code, 202) self.assertEqual(res.headers['Content-Type'], 'text/html; charset=UTF-8') @@ -280,7 +279,7 @@ class TestMicrodot(unittest.TestCase): def before_request(req): if req.path == '/bar': @req.after_request - def after_request(req, res): + async def after_request(req, res): res.headers['X-Two'] = '2' return res return 'bar', 202 @@ -291,7 +290,7 @@ class TestMicrodot(unittest.TestCase): res.headers['X-One'] = '1' @app.after_request - def after_request_two(req, res): + async def after_request_two(req, res): res.set_cookie('foo', 'bar') return res @@ -305,7 +304,7 @@ class TestMicrodot(unittest.TestCase): client = TestClient(app) - res = client.get('/bar') + res = self._run(client.get('/bar')) self.assertEqual(res.status_code, 202) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') @@ -315,7 +314,7 @@ class TestMicrodot(unittest.TestCase): self.assertEqual(res.text, 'bar') self.assertEqual(client.cookies['foo'], 'bar') - res = client.get('/baz') + res = self._run(client.get('/baz')) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') @@ -343,14 +342,14 @@ class TestMicrodot(unittest.TestCase): client = TestClient(app) - res = client.get('/foo') + res = self._run(client.get('/foo')) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') self.assertFalse('X-One' in res.headers) self.assertFalse('Set-Cookie' in res.headers) - res = client.get('/bar') + res = self._run(client.get('/bar')) self.assertEqual(res.status_code, 404) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') @@ -359,44 +358,22 @@ class TestMicrodot(unittest.TestCase): self.assertEqual(client.cookies['foo'], 'bar') def test_400(self): - self._mock() - app = Microdot() - mock_socket.clear_requests() - fd = mock_socket.FakeStream(b'\n') - mock_socket._requests.append(fd) - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 400 N/A\r\n')) - self.assertIn(b'Content-Length: 11\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', - fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\nBad request')) - - self._unmock() + res = self._run(app.dispatch_request(None)) + self.assertEqual(res.status_code, 400) + self.assertEqual(res.body, b'Bad request') def test_400_handler(self): - self._mock() - app = Microdot() @app.errorhandler(400) - def handle_400(req): + async def handle_400(req): return '400' - mock_socket.clear_requests() - fd = mock_socket.FakeStream(b'\n') - mock_socket._requests.append(fd) - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n')) - self.assertIn(b'Content-Length: 3\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', - fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\n400')) - - self._unmock() + res = self._run(app.dispatch_request(None)) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.body, b'400') def test_404(self): app = Microdot() @@ -406,7 +383,7 @@ class TestMicrodot(unittest.TestCase): return 'foo' client = TestClient(app) - res = client.post('/foo') + res = self._run(client.post('/foo')) self.assertEqual(res.status_code, 404) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') @@ -420,11 +397,11 @@ class TestMicrodot(unittest.TestCase): return 'foo' @app.errorhandler(404) - def handle_404(req): + async def handle_404(req): return '404' client = TestClient(app) - res = client.post('/foo') + res = self._run(client.post('/foo')) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') @@ -438,7 +415,7 @@ class TestMicrodot(unittest.TestCase): return 'foo' client = TestClient(app) - res = client.post('/foo') + res = self._run(client.post('/foo')) self.assertEqual(res.status_code, 405) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') @@ -452,11 +429,11 @@ class TestMicrodot(unittest.TestCase): return 'foo' @app.errorhandler(405) - def handle_405(req): + async def handle_405(req): return '405', 405 client = TestClient(app) - res = client.patch('/foo') + res = self._run(client.patch('/foo')) self.assertEqual(res.status_code, 405) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') @@ -465,12 +442,12 @@ class TestMicrodot(unittest.TestCase): def test_413(self): app = Microdot() - @app.post('/') + @app.route('/') def index(req): return 'foo' client = TestClient(app) - res = client.post('/foo', body='x' * 17000) + res = self._run(client.post('/foo', body='x' * 17000)) self.assertEqual(res.status_code, 413) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') @@ -484,11 +461,11 @@ class TestMicrodot(unittest.TestCase): return 'foo' @app.errorhandler(413) - def handle_413(req): + async def handle_413(req): return '413', 400 client = TestClient(app) - res = client.post('/foo', body='x' * 17000) + res = self._run(client.post('/foo', body='x' * 17000)) self.assertEqual(res.status_code, 400) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') @@ -502,7 +479,7 @@ class TestMicrodot(unittest.TestCase): return 1 / 0 client = TestClient(app) - res = client.get('/') + res = self._run(client.get('/')) self.assertEqual(res.status_code, 500) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') @@ -520,7 +497,7 @@ class TestMicrodot(unittest.TestCase): return '501', 501 client = TestClient(app) - res = client.get('/') + res = self._run(client.get('/')) self.assertEqual(res.status_code, 501) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') @@ -534,11 +511,11 @@ class TestMicrodot(unittest.TestCase): return 1 / 0 @app.errorhandler(ZeroDivisionError) - def handle_div_zero(req, exc): + async def handle_div_zero(req, exc): return '501', 501 client = TestClient(app) - res = client.get('/') + res = self._run(client.get('/')) self.assertEqual(res.status_code, 501) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') @@ -553,11 +530,11 @@ class TestMicrodot(unittest.TestCase): return foo[1] @app.errorhandler(LookupError) - def handle_lookup_error(req, exc): + async def handle_lookup_error(req, exc): return exc.__class__.__name__, 501 client = TestClient(app) - res = client.get('/') + res = self._run(client.get('/')) self.assertEqual(res.status_code, 501) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') @@ -572,15 +549,15 @@ class TestMicrodot(unittest.TestCase): return foo[1] @app.errorhandler(LookupError) - def handle_lookup_error(req, exc): + async def handle_lookup_error(req, exc): return 'LookupError', 501 @app.errorhandler(IndexError) - def handle_index_error(req, exc): + async def handle_index_error(req, exc): return 'IndexError', 501 client = TestClient(app) - res = client.get('/') + res = self._run(client.get('/')) self.assertEqual(res.status_code, 501) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') @@ -595,15 +572,15 @@ class TestMicrodot(unittest.TestCase): return foo[1] @app.errorhandler(Exception) - def handle_generic_exception(req, exc): + async def handle_generic_exception(req, exc): return 'Exception', 501 @app.errorhandler(LookupError) - def handle_lookup_error(req, exc): + async def handle_lookup_error(req, exc): return 'LookupError', 501 client = TestClient(app) - res = client.get('/') + res = self._run(client.get('/')) self.assertEqual(res.status_code, 501) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') @@ -618,11 +595,11 @@ class TestMicrodot(unittest.TestCase): return foo[1] @app.errorhandler(RuntimeError) - def handle_runtime_error(req, exc): + async def handle_runtime_error(req, exc): return 'RuntimeError', 501 client = TestClient(app) - res = client.get('/') + res = self._run(client.get('/')) self.assertEqual(res.status_code, 500) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') @@ -637,7 +614,7 @@ class TestMicrodot(unittest.TestCase): return 'foo' client = TestClient(app) - res = client.get('/') + res = self._run(client.get('/')) self.assertEqual(res.status_code, 406) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') @@ -652,11 +629,11 @@ class TestMicrodot(unittest.TestCase): return 'foo' @app.errorhandler(406) - def handle_406(req): + def handle_500(req): return '406', 406 client = TestClient(app) - res = client.get('/') + res = self._run(client.get('/')) self.assertEqual(res.status_code, 406) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') @@ -666,7 +643,7 @@ class TestMicrodot(unittest.TestCase): app = Microdot() @app.route('/dict') - def json_dict(req): + async def json_dict(req): return {'foo': 'bar'} @app.route('/list') @@ -675,13 +652,13 @@ class TestMicrodot(unittest.TestCase): client = TestClient(app) - res = client.get('/dict') + res = self._run(client.get('/dict')) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Content-Type'], 'application/json; charset=UTF-8') self.assertEqual(res.json, {'foo': 'bar'}) - res = client.get('/list') + res = self._run(client.get('/list')) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Content-Type'], 'application/json; charset=UTF-8') @@ -695,7 +672,7 @@ class TestMicrodot(unittest.TestCase): return b'\xff\xfe', {'Content-Type': 'application/octet-stream'} client = TestClient(app) - res = client.get('/bin') + res = self._run(client.get('/bin')) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Content-Type'], 'application/octet-stream') @@ -705,20 +682,38 @@ class TestMicrodot(unittest.TestCase): def test_streaming(self): app = Microdot() + done = False @app.route('/') def index(req): - def stream(): - yield 'foo' - yield b'bar' + class stream(): + def __init__(self): + self.i = 0 + self.data = ['foo', b'bar'] + + def __aiter__(self): + return self + + async def __anext__(self): + if self.i >= len(self.data): + raise StopAsyncIteration + data = self.data[self.i] + self.i += 1 + return data + + async def aclose(self): + nonlocal done + done = True + return stream() client = TestClient(app) - res = client.get('/') + res = self._run(client.get('/')) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') self.assertEqual(res.text, 'foobar') + self.assertEqual(done, True) def test_already_handled_response(self): app = Microdot() @@ -728,7 +723,7 @@ class TestMicrodot(unittest.TestCase): return Response.already_handled client = TestClient(app) - res = client.get('/') + res = self._run(client.get('/')) self.assertEqual(res, None) def test_mount(self): @@ -759,39 +754,14 @@ class TestMicrodot(unittest.TestCase): client = TestClient(app) - res = client.get('/app') + res = self._run(client.get('/app')) self.assertEqual(res.status_code, 404) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') self.assertEqual(res.text, '404:errorafter') - res = client.get('/sub/app') + res = self._run(client.get('/sub/app')) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Content-Type'], 'text/plain; charset=UTF-8') self.assertEqual(res.text, 'before:foo:after') - - def test_ssl(self): - self._mock() - - app = Microdot() - - @app.route('/foo') - def foo(req): - return 'bar' - - class FakeSSL: - def wrap_socket(self, sock, **kwargs): - return sock - - mock_socket.clear_requests() - fd = mock_socket.add_request('GET', '/foo') - self._add_shutdown(app) - app.run(ssl=FakeSSL()) - self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n')) - self.assertIn(b'Content-Length: 3\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', - fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\nbar')) - - self._unmock() diff --git a/tests/test_microdot_asyncio.py b/tests/test_microdot_asyncio.py deleted file mode 100644 index ff40515..0000000 --- a/tests/test_microdot_asyncio.py +++ /dev/null @@ -1,775 +0,0 @@ -try: - import uasyncio as asyncio -except ImportError: - import asyncio -import sys -import unittest -from microdot_asyncio import Microdot, Response, abort -from microdot_asyncio_test_client import TestClient -from tests import mock_asyncio, mock_socket - - -class TestMicrodotAsync(unittest.TestCase): - def setUp(self): - self._mock() - - def tearDown(self): - self._unmock() - - def _run(self, coro): - loop = asyncio.get_event_loop() - return loop.run_until_complete(coro) - - def _mock(self): - self.original_asyncio = sys.modules['microdot_asyncio'].asyncio - sys.modules['microdot_asyncio'].asyncio = mock_asyncio - - def _unmock(self): - sys.modules['microdot_asyncio'].asyncio = self.original_asyncio - - def _add_shutdown(self, app): - @app.route('/shutdown') - def shutdown(req): - app.shutdown() - return '' - - mock_socket.add_request('GET', '/shutdown') - - def test_get_request(self): - app = Microdot() - - @app.route('/') - def index(req): - return 'foo' - - @app.route('/async') - async def index2(req): - return 'foo-async' - - client = TestClient(app) - - res = self._run(client.get('/')) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.headers['Content-Length'], '3') - self.assertEqual(res.text, 'foo') - self.assertEqual(res.body, b'foo') - self.assertEqual(res.json, None) - - res = self._run(client.get('/async')) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.headers['Content-Length'], '9') - self.assertEqual(res.text, 'foo-async') - self.assertEqual(res.body, b'foo-async') - self.assertEqual(res.json, None) - - def test_post_request(self): - app = Microdot() - - @app.route('/') - def index(req): - return 'foo' - - @app.route('/', methods=['POST']) - def index_post(req): - return Response('bar') - - @app.route('/async', methods=['POST']) - async def index_post2(req): - return Response('bar-async') - - client = TestClient(app) - - res = self._run(client.post('/')) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.headers['Content-Length'], '3') - self.assertEqual(res.text, 'bar') - self.assertEqual(res.body, b'bar') - self.assertEqual(res.json, None) - - res = self._run(client.post('/async')) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.headers['Content-Length'], '9') - self.assertEqual(res.text, 'bar-async') - self.assertEqual(res.body, b'bar-async') - self.assertEqual(res.json, None) - - def test_head_request(self): - app = Microdot() - - @app.route('/foo') - def index(req): - return 'foo' - - mock_socket.clear_requests() - fd = mock_socket.add_request('HEAD', '/foo') - self._add_shutdown(app) - app.run() - - self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n')) - self.assertIn(b'Content-Length: 3\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', - fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\n')) - - def test_options_request(self): - app = Microdot() - - @app.route('/', methods=['GET', 'DELETE']) - async def index(req): - return 'foo' - - @app.post('/') - async def index_post(req): - return 'bar' - - @app.route('/foo', methods=['POST', 'PUT']) - async def foo(req): - return 'baz' - - client = TestClient(app) - res = self._run(client.request('OPTIONS', '/')) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Allow'], - 'GET, DELETE, POST, HEAD, OPTIONS') - res = self._run(client.request('OPTIONS', '/foo')) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Allow'], 'POST, PUT, OPTIONS') - - def test_empty_request(self): - app = Microdot() - - mock_socket.clear_requests() - fd = mock_socket.FakeStream(b'\n') - mock_socket._requests.append(fd) - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 400 N/A\r\n')) - self.assertIn(b'Content-Length: 11\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', - fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\nBad request')) - - def test_method_decorators(self): - app = Microdot() - - @app.get('/get') - def get(req): - return 'GET' - - @app.post('/post') - async def post(req): - return 'POST' - - @app.put('/put') - def put(req): - return 'PUT' - - @app.patch('/patch') - async def patch(req): - return 'PATCH' - - @app.delete('/delete') - def delete(req): - return 'DELETE' - - client = TestClient(app) - methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] - for method in methods: - res = self._run(getattr( - client, method.lower())('/' + method.lower())) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, method) - - def test_headers(self): - app = Microdot() - - @app.route('/') - def index(req): - return req.headers.get('X-Foo') - - client = TestClient(app) - res = self._run(client.get('/', headers={'X-Foo': 'bar'})) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, 'bar') - - def test_cookies(self): - app = Microdot() - - @app.route('/') - def index(req): - return req.cookies['one'] + req.cookies['two'] + \ - req.cookies['three'] - - client = TestClient(app, cookies={'one': '1', 'two': '2'}) - res = self._run(client.get('/', headers={'Cookie': 'three=3'})) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, '123') - - def test_binary_payload(self): - app = Microdot() - - @app.post('/') - def index(req): - return req.body - - client = TestClient(app) - res = self._run(client.post('/', body=b'foo')) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, 'foo') - - def test_json_payload(self): - app = Microdot() - - @app.post('/dict') - def json_dict(req): - print(req.headers) - return req.json.get('foo') - - @app.post('/list') - def json_list(req): - return req.json[0] - - client = TestClient(app) - - res = self._run(client.post('/dict', body={'foo': 'bar'})) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, 'bar') - - res = self._run(client.post('/list', body=['foo', 'bar'])) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, 'foo') - - def test_tuple_responses(self): - app = Microdot() - - @app.route('/body') - def one(req): - return 'one' - - @app.route('/body-status') - def two(req): - return 'two', 202 - - @app.route('/body-headers') - def three(req): - return '

three

', {'Content-Type': 'text/html'} - - @app.route('/body-status-headers') - def four(req): - return '

four

', 202, \ - {'Content-Type': 'text/html; charset=UTF-8'} - - client = TestClient(app) - - res = self._run(client.get('/body')) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, 'one') - - res = self._run(client.get('/body-status')) - self.assertEqual(res.status_code, 202) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, 'two') - - res = self._run(client.get('/body-headers')) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Content-Type'], 'text/html') - self.assertEqual(res.text, '

three

') - - res = self._run(client.get('/body-status-headers')) - self.assertEqual(res.status_code, 202) - self.assertEqual(res.headers['Content-Type'], - 'text/html; charset=UTF-8') - self.assertEqual(res.text, '

four

') - - def test_before_after_request(self): - app = Microdot() - - @app.before_request - def before_request(req): - if req.path == '/bar': - @req.after_request - async def after_request(req, res): - res.headers['X-Two'] = '2' - return res - return 'bar', 202 - req.g.message = 'baz' - - @app.after_request - def after_request_one(req, res): - res.headers['X-One'] = '1' - - @app.after_request - async def after_request_two(req, res): - res.set_cookie('foo', 'bar') - return res - - @app.route('/bar') - def bar(req): - return 'foo' - - @app.route('/baz') - def baz(req): - return req.g.message - - client = TestClient(app) - - res = self._run(client.get('/bar')) - self.assertEqual(res.status_code, 202) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.headers['Set-Cookie'], ['foo=bar']) - self.assertEqual(res.headers['X-One'], '1') - self.assertEqual(res.headers['X-Two'], '2') - self.assertEqual(res.text, 'bar') - self.assertEqual(client.cookies['foo'], 'bar') - - res = self._run(client.get('/baz')) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.headers['Set-Cookie'], ['foo=bar']) - self.assertEqual(res.headers['X-One'], '1') - self.assertFalse('X-Two' in res.headers) - self.assertEqual(res.headers['Content-Length'], '3') - self.assertEqual(res.text, 'baz') - - def test_after_error_request(self): - app = Microdot() - - @app.after_error_request - def after_error_request_one(req, res): - res.headers['X-One'] = '1' - - @app.after_error_request - def after_error_request_two(req, res): - res.set_cookie('foo', 'bar') - return res - - @app.route('/foo') - def foo(req): - return 'foo' - - client = TestClient(app) - - res = self._run(client.get('/foo')) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertFalse('X-One' in res.headers) - self.assertFalse('Set-Cookie' in res.headers) - - res = self._run(client.get('/bar')) - self.assertEqual(res.status_code, 404) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.headers['Set-Cookie'], ['foo=bar']) - self.assertEqual(res.headers['X-One'], '1') - self.assertEqual(client.cookies['foo'], 'bar') - - def test_400(self): - self._mock() - - app = Microdot() - - mock_socket.clear_requests() - fd = mock_socket.FakeStream(b'\n') - mock_socket._requests.append(fd) - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 400 N/A\r\n')) - self.assertIn(b'Content-Length: 11\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', - fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\nBad request')) - - self._unmock() - - def test_400_handler(self): - self._mock() - - app = Microdot() - - @app.errorhandler(400) - async def handle_404(req): - return '400' - - mock_socket.clear_requests() - fd = mock_socket.FakeStream(b'\n') - mock_socket._requests.append(fd) - self._add_shutdown(app) - app.run() - self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n')) - self.assertIn(b'Content-Length: 3\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', - fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\n400')) - - self._unmock() - - def test_404(self): - app = Microdot() - - @app.route('/') - def index(req): - return 'foo' - - client = TestClient(app) - res = self._run(client.post('/foo')) - self.assertEqual(res.status_code, 404) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, 'Not found') - - def test_404_handler(self): - app = Microdot() - - @app.route('/') - def index(req): - return 'foo' - - @app.errorhandler(404) - async def handle_404(req): - return '404' - - client = TestClient(app) - res = self._run(client.post('/foo')) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, '404') - - def test_405(self): - app = Microdot() - - @app.route('/foo') - def index(req): - return 'foo' - - client = TestClient(app) - res = self._run(client.post('/foo')) - self.assertEqual(res.status_code, 405) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, 'Not found') - - def test_405_handler(self): - app = Microdot() - - @app.route('/foo') - def index(req): - return 'foo' - - @app.errorhandler(405) - async def handle_405(req): - return '405', 405 - - client = TestClient(app) - res = self._run(client.patch('/foo')) - self.assertEqual(res.status_code, 405) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, '405') - - def test_413(self): - app = Microdot() - - @app.route('/') - def index(req): - return 'foo' - - client = TestClient(app) - res = self._run(client.post('/foo', body='x' * 17000)) - self.assertEqual(res.status_code, 413) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, 'Payload too large') - - def test_413_handler(self): - app = Microdot() - - @app.route('/') - def index(req): - return 'foo' - - @app.errorhandler(413) - async def handle_413(req): - return '413', 400 - - client = TestClient(app) - res = self._run(client.post('/foo', body='x' * 17000)) - self.assertEqual(res.status_code, 400) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, '413') - - def test_500(self): - app = Microdot() - - @app.route('/') - def index(req): - return 1 / 0 - - client = TestClient(app) - res = self._run(client.get('/')) - self.assertEqual(res.status_code, 500) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, 'Internal server error') - - def test_500_handler(self): - app = Microdot() - - @app.route('/') - def index(req): - return 1 / 0 - - @app.errorhandler(500) - def handle_500(req): - return '501', 501 - - client = TestClient(app) - res = self._run(client.get('/')) - self.assertEqual(res.status_code, 501) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, '501') - - def test_exception_handler(self): - app = Microdot() - - @app.route('/') - def index(req): - return 1 / 0 - - @app.errorhandler(ZeroDivisionError) - async def handle_div_zero(req, exc): - return '501', 501 - - client = TestClient(app) - res = self._run(client.get('/')) - self.assertEqual(res.status_code, 501) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, '501') - - def test_exception_handler_parent(self): - app = Microdot() - - @app.route('/') - def index(req): - foo = [] - return foo[1] - - @app.errorhandler(LookupError) - async def handle_lookup_error(req, exc): - return exc.__class__.__name__, 501 - - client = TestClient(app) - res = self._run(client.get('/')) - self.assertEqual(res.status_code, 501) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, 'IndexError') - - def test_exception_handler_redundant_parent(self): - app = Microdot() - - @app.route('/') - def index(req): - foo = [] - return foo[1] - - @app.errorhandler(LookupError) - async def handle_lookup_error(req, exc): - return 'LookupError', 501 - - @app.errorhandler(IndexError) - async def handle_index_error(req, exc): - return 'IndexError', 501 - - client = TestClient(app) - res = self._run(client.get('/')) - self.assertEqual(res.status_code, 501) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, 'IndexError') - - def test_exception_handler_multiple_parents(self): - app = Microdot() - - @app.route('/') - def index(req): - foo = [] - return foo[1] - - @app.errorhandler(Exception) - async def handle_generic_exception(req, exc): - return 'Exception', 501 - - @app.errorhandler(LookupError) - async def handle_lookup_error(req, exc): - return 'LookupError', 501 - - client = TestClient(app) - res = self._run(client.get('/')) - self.assertEqual(res.status_code, 501) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, 'LookupError') - - def test_exception_handler_no_viable_parents(self): - app = Microdot() - - @app.route('/') - def index(req): - foo = [] - return foo[1] - - @app.errorhandler(RuntimeError) - async def handle_runtime_error(req, exc): - return 'RuntimeError', 501 - - client = TestClient(app) - res = self._run(client.get('/')) - self.assertEqual(res.status_code, 500) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, 'Internal server error') - - def test_abort(self): - app = Microdot() - - @app.route('/') - def index(req): - abort(406, 'Not acceptable') - return 'foo' - - client = TestClient(app) - res = self._run(client.get('/')) - self.assertEqual(res.status_code, 406) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, 'Not acceptable') - - def test_abort_handler(self): - app = Microdot() - - @app.route('/') - def index(req): - abort(406) - return 'foo' - - @app.errorhandler(406) - def handle_500(req): - return '406', 406 - - client = TestClient(app) - res = self._run(client.get('/')) - self.assertEqual(res.status_code, 406) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, '406') - - def test_json_response(self): - app = Microdot() - - @app.route('/dict') - async def json_dict(req): - return {'foo': 'bar'} - - @app.route('/list') - def json_list(req): - return ['foo', 'bar'] - - client = TestClient(app) - - res = self._run(client.get('/dict')) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Content-Type'], - 'application/json; charset=UTF-8') - self.assertEqual(res.json, {'foo': 'bar'}) - - res = self._run(client.get('/list')) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Content-Type'], - 'application/json; charset=UTF-8') - self.assertEqual(res.json, ['foo', 'bar']) - - def test_binary_response(self): - app = Microdot() - - @app.route('/bin') - def index(req): - return b'\xff\xfe', {'Content-Type': 'application/octet-stream'} - - client = TestClient(app) - res = self._run(client.get('/bin')) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Content-Type'], - 'application/octet-stream') - self.assertEqual(res.text, None) - self.assertEqual(res.json, None) - self.assertEqual(res.body, b'\xff\xfe') - - def test_streaming(self): - app = Microdot() - - @app.route('/') - def index(req): - class stream(): - def __init__(self): - self.i = 0 - self.data = ['foo', b'bar'] - - def __aiter__(self): - return self - - async def __anext__(self): - if self.i >= len(self.data): - raise StopAsyncIteration - data = self.data[self.i] - self.i += 1 - return data - - return stream() - - client = TestClient(app) - res = self._run(client.get('/')) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Content-Type'], - 'text/plain; charset=UTF-8') - self.assertEqual(res.text, 'foobar') - - def test_already_handled_response(self): - app = Microdot() - - @app.route('/') - def index(req): - return Response.already_handled - - client = TestClient(app) - res = self._run(client.get('/')) - self.assertEqual(res, None) diff --git a/tests/test_microdot_asyncio_websocket.py b/tests/test_microdot_asyncio_websocket.py deleted file mode 100644 index 2f82acc..0000000 --- a/tests/test_microdot_asyncio_websocket.py +++ /dev/null @@ -1,71 +0,0 @@ -import sys -try: - import uasyncio as asyncio -except ImportError: - import asyncio -import unittest -from microdot_asyncio import Microdot -from microdot_asyncio_websocket import with_websocket -from microdot_asyncio_test_client import TestClient - - -class TestMicrodotAsyncWebSocket(unittest.TestCase): - def _run(self, coro): - loop = asyncio.get_event_loop() - return loop.run_until_complete(coro) - - def test_websocket_echo(self): - app = Microdot() - - @app.route('/echo') - @with_websocket - async def index(req, ws): - while True: - data = await ws.receive() - await ws.send(data) - - results = [] - - def ws(): - data = yield 'hello' - results.append(data) - data = yield b'bye' - results.append(data) - data = yield b'*' * 300 - results.append(data) - data = yield b'+' * 65537 - results.append(data) - - client = TestClient(app) - res = self._run(client.websocket('/echo', ws)) - self.assertIsNone(res) - self.assertEqual(results, ['hello', b'bye', b'*' * 300, b'+' * 65537]) - - @unittest.skipIf(sys.implementation.name == 'micropython', - 'no support for async generators in MicroPython') - def test_websocket_echo_async_client(self): - app = Microdot() - - @app.route('/echo') - @with_websocket - async def index(req, ws): - while True: - data = await ws.receive() - await ws.send(data) - - results = [] - - async def ws(): - data = yield 'hello' - results.append(data) - data = yield b'bye' - results.append(data) - data = yield b'*' * 300 - results.append(data) - data = yield b'+' * 65537 - results.append(data) - - client = TestClient(app) - res = self._run(client.websocket('/echo', ws)) - self.assertIsNone(res) - self.assertEqual(results, ['hello', b'bye', b'*' * 300, b'+' * 65537]) diff --git a/tests/test_microdot_websocket.py b/tests/test_microdot_websocket.py deleted file mode 100644 index 3a6fede..0000000 --- a/tests/test_microdot_websocket.py +++ /dev/null @@ -1,73 +0,0 @@ -import unittest -from microdot import Microdot -from microdot_websocket import with_websocket, WebSocket -from microdot_test_client import TestClient - - -class TestMicrodotWebSocket(unittest.TestCase): - def test_websocket_echo(self): - app = Microdot() - - @app.route('/echo') - @with_websocket - def index(req, ws): - while True: - data = ws.receive() - ws.send(data) - - results = [] - - def ws(): - data = yield 'hello' - results.append(data) - data = yield b'bye' - results.append(data) - data = yield b'*' * 300 - results.append(data) - data = yield b'+' * 65537 - results.append(data) - - client = TestClient(app) - res = client.websocket('/echo', ws) - self.assertIsNone(res) - self.assertEqual(results, ['hello', b'bye', b'*' * 300, b'+' * 65537]) - - def test_bad_websocket_request(self): - app = Microdot() - - @app.route('/echo') - @with_websocket - def index(req, ws): - return 'hello' - - client = TestClient(app) - res = client.get('/echo') - self.assertEqual(res.status_code, 400) - res = client.get('/echo', headers={'Connection': 'Upgrade'}) - self.assertEqual(res.status_code, 400) - res = client.get('/echo', headers={'Connection': 'foo'}) - self.assertEqual(res.status_code, 400) - res = client.get('/echo', headers={'Upgrade': 'websocket'}) - self.assertEqual(res.status_code, 400) - res = client.get('/echo', headers={'Upgrade': 'bar'}) - self.assertEqual(res.status_code, 400) - res = client.get('/echo', headers={'Connection': 'Upgrade', - 'Upgrade': 'websocket'}) - self.assertEqual(res.status_code, 400) - res = client.get('/echo', headers={'Sec-WebSocket-Key': 'xxx'}) - self.assertEqual(res.status_code, 400) - - def test_process_websocket_frame(self): - ws = WebSocket(None) - ws.closed = True - - self.assertEqual(ws._process_websocket_frame(WebSocket.TEXT, b'foo'), - (None, 'foo')) - self.assertEqual(ws._process_websocket_frame(WebSocket.BINARY, b'foo'), - (None, b'foo')) - self.assertRaises(OSError, ws._process_websocket_frame, - WebSocket.CLOSE, b'') - self.assertEqual(ws._process_websocket_frame(WebSocket.PING, b'foo'), - (WebSocket.PONG, b'foo')) - self.assertEqual(ws._process_websocket_frame(WebSocket.PONG, b'foo'), - (None, None)) diff --git a/tests/test_multidict.py b/tests/test_multidict.py index f7339dc..b527a38 100644 --- a/tests/test_multidict.py +++ b/tests/test_multidict.py @@ -1,5 +1,5 @@ import unittest -from microdot import MultiDict, NoCaseDict +from microdot.microdot import MultiDict, NoCaseDict class TestMultiDict(unittest.TestCase): diff --git a/tests/test_request.py b/tests/test_request.py index 89fb7dc..f9a7b59 100644 --- a/tests/test_request.py +++ b/tests/test_request.py @@ -1,12 +1,22 @@ +import asyncio import unittest -from microdot import Request, MultiDict -from tests.mock_socket import get_request_fd +from microdot.microdot import MultiDict, Request +from tests.mock_socket import get_async_request_fd class TestRequest(unittest.TestCase): + @classmethod + def setUpClass(cls): + if hasattr(asyncio, 'set_event_loop'): + asyncio.set_event_loop(asyncio.new_event_loop()) + cls.loop = asyncio.get_event_loop() + + def _run(self, coro): + return self.loop.run_until_complete(coro) + def test_create_request(self): - fd = get_request_fd('GET', '/foo') - req = Request.create('app', fd, 'addr') + fd = get_async_request_fd('GET', '/foo') + req = self._run(Request.create('app', fd, 'writer', 'addr')) self.assertEqual(req.app, 'app') self.assertEqual(req.client_addr, 'addr') self.assertEqual(req.method, 'GET') @@ -23,11 +33,11 @@ class TestRequest(unittest.TestCase): self.assertEqual(req.form, None) def test_headers(self): - fd = get_request_fd('GET', '/foo', headers={ + fd = get_async_request_fd('GET', '/foo', headers={ 'Content-Type': 'application/json', 'Cookie': 'foo=bar;abc=def', 'Content-Length': '3'}, body='aaa') - req = Request.create('app', fd, 'addr') + req = self._run(Request.create('app', fd, 'writer', 'addr')) self.assertEqual(req.headers, { 'Host': 'example.com:1234', 'Content-Type': 'application/json', @@ -39,93 +49,68 @@ class TestRequest(unittest.TestCase): self.assertEqual(req.body, b'aaa') def test_args(self): - fd = get_request_fd('GET', '/?foo=bar&abc=def&foo&x=%2f%%') - req = Request.create('app', fd, 'addr') + fd = get_async_request_fd('GET', '/?foo=bar&abc=def&foo&x=%2f%%') + req = self._run(Request.create('app', fd, 'writer', 'addr')) self.assertEqual(req.query_string, 'foo=bar&abc=def&foo&x=%2f%%') md = MultiDict({'foo': 'bar', 'abc': 'def', 'x': '/%%'}) md['foo'] = '' self.assertEqual(req.args, md) - def test_badly_formatted_args(self): - fd = get_request_fd('GET', '/?&foo=bar&abc=def&&&x=%2f%%') - req = Request.create('app', fd, 'addr') - self.assertEqual(req.query_string, '&foo=bar&abc=def&&&x=%2f%%') - self.assertEqual(req.args, MultiDict( - {'foo': 'bar', 'abc': 'def', 'x': '/%%'})) - def test_json(self): - fd = get_request_fd('GET', '/foo', headers={ + fd = get_async_request_fd('GET', '/foo', headers={ 'Content-Type': 'application/json'}, body='{"foo":"bar"}') - req = Request.create('app', fd, 'addr') + req = self._run(Request.create('app', fd, 'writer', 'addr')) json = req.json self.assertEqual(json, {'foo': 'bar'}) self.assertTrue(req.json is json) - fd = get_request_fd('GET', '/foo', headers={ + fd = get_async_request_fd('GET', '/foo', headers={ 'Content-Type': 'application/json'}, body='[1, "2"]') - req = Request.create('app', fd, 'addr') + req = self._run(Request.create('app', fd, 'writer', 'addr')) self.assertEqual(req.json, [1, '2']) - fd = get_request_fd('GET', '/foo', headers={ + fd = get_async_request_fd('GET', '/foo', headers={ 'Content-Type': 'application/xml'}, body='[1, "2"]') - req = Request.create('app', fd, 'addr') + req = self._run(Request.create('app', fd, 'writer', 'addr')) self.assertIsNone(req.json) def test_form(self): - fd = get_request_fd('GET', '/foo', headers={ + fd = get_async_request_fd('GET', '/foo', headers={ 'Content-Type': 'application/x-www-form-urlencoded'}, body='foo=bar&abc=def&x=%2f%%') - req = Request.create('app', fd, 'addr') + req = self._run(Request.create('app', fd, 'writer', 'addr')) form = req.form self.assertEqual(form, MultiDict( {'foo': 'bar', 'abc': 'def', 'x': '/%%'})) self.assertTrue(req.form is form) - fd = get_request_fd('GET', '/foo', headers={ - 'Content-Type': 'application/x-www-form-urlencoded'}, - body='') - req = Request.create('app', fd, 'addr') - form = req.form - self.assertEqual(form, MultiDict({})) - self.assertTrue(req.form is form) - - fd = get_request_fd('GET', '/foo', headers={ + fd = get_async_request_fd('GET', '/foo', headers={ 'Content-Type': 'application/json'}, body='foo=bar&abc=def&x=%2f%%') - req = Request.create('app', fd, 'addr') + req = self._run(Request.create('app', fd, 'writer', 'addr')) self.assertIsNone(req.form) def test_large_line(self): saved_max_readline = Request.max_readline Request.max_readline = 16 - fd = get_request_fd('GET', '/foo', headers={ + fd = get_async_request_fd('GET', '/foo', headers={ 'Content-Type': 'application/x-www-form-urlencoded'}, body='foo=bar&abc=def&x=y') with self.assertRaises(ValueError): - Request.create('app', fd, 'addr') + self._run(Request.create('app', fd, 'writer', 'addr')) Request.max_readline = saved_max_readline - def test_stream(self): - fd = get_request_fd('GET', '/foo', headers={ + def test_body_and_stream(self): + fd = get_async_request_fd('GET', '/foo', headers={ 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': '19'}, body='foo=bar&abc=def&x=y') - req = Request.create('app', fd, 'addr') - self.assertEqual(req.stream.read(), b'foo=bar&abc=def&x=y') - with self.assertRaises(RuntimeError): - req.body - - def test_body(self): - fd = get_request_fd('GET', '/foo', headers={ - 'Content-Type': 'application/x-www-form-urlencoded', - 'Content-Length': '19'}, - body='foo=bar&abc=def&x=y') - req = Request.create('app', fd, 'addr') + req = self._run(Request.create('app', fd, 'writer', 'addr')) self.assertEqual(req.body, b'foo=bar&abc=def&x=y') - with self.assertRaises(RuntimeError): - req.stream + data = self._run(req.stream.read()) + self.assertEqual(data, b'foo=bar&abc=def&x=y') def test_large_payload(self): saved_max_content_length = Request.max_content_length @@ -133,12 +118,14 @@ class TestRequest(unittest.TestCase): Request.max_content_length = 32 Request.max_body_length = 16 - fd = get_request_fd('GET', '/foo', headers={ - 'Content-Type': 'application/x-www-form-urlencoded'}, + fd = get_async_request_fd('GET', '/foo', headers={ + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': '19'}, body='foo=bar&abc=def&x=y') - req = Request.create('app', fd, 'addr') + req = self._run(Request.create('app', fd, 'writer', 'addr')) self.assertEqual(req.body, b'') - self.assertEqual(req.stream.read(), b'foo=bar&abc=def&x=y') + data = self._run(req.stream.read()) + self.assertEqual(data, b'foo=bar&abc=def&x=y') Request.max_content_length = saved_max_content_length Request.max_body_length = saved_max_body_length diff --git a/tests/test_request_asyncio.py b/tests/test_request_asyncio.py deleted file mode 100644 index 7b23815..0000000 --- a/tests/test_request_asyncio.py +++ /dev/null @@ -1,131 +0,0 @@ -try: - import uasyncio as asyncio -except ImportError: - import asyncio - -import unittest -from microdot import MultiDict -from microdot_asyncio import Request -from tests.mock_socket import get_async_request_fd - - -def _run(coro): - return asyncio.get_event_loop().run_until_complete(coro) - - -class TestRequestAsync(unittest.TestCase): - def test_create_request(self): - fd = get_async_request_fd('GET', '/foo') - req = _run(Request.create('app', fd, 'writer', 'addr')) - self.assertEqual(req.app, 'app') - self.assertEqual(req.client_addr, 'addr') - self.assertEqual(req.method, 'GET') - self.assertEqual(req.path, '/foo') - self.assertEqual(req.http_version, '1.0') - self.assertIsNone(req.query_string) - self.assertEqual(req.args, {}) - self.assertEqual(req.headers, {'Host': 'example.com:1234'}) - self.assertEqual(req.cookies, {}) - self.assertEqual(req.content_length, 0) - self.assertEqual(req.content_type, None) - self.assertEqual(req.body, b'') - self.assertEqual(req.json, None) - self.assertEqual(req.form, None) - - def test_headers(self): - fd = get_async_request_fd('GET', '/foo', headers={ - 'Content-Type': 'application/json', - 'Cookie': 'foo=bar;abc=def', - 'Content-Length': '3'}, body='aaa') - req = _run(Request.create('app', fd, 'writer', 'addr')) - self.assertEqual(req.headers, { - 'Host': 'example.com:1234', - 'Content-Type': 'application/json', - 'Cookie': 'foo=bar;abc=def', - 'Content-Length': '3'}) - self.assertEqual(req.content_type, 'application/json') - self.assertEqual(req.cookies, {'foo': 'bar', 'abc': 'def'}) - self.assertEqual(req.content_length, 3) - self.assertEqual(req.body, b'aaa') - - def test_args(self): - fd = get_async_request_fd('GET', '/?foo=bar&abc=def&foo&x=%2f%%') - req = _run(Request.create('app', fd, 'writer', 'addr')) - self.assertEqual(req.query_string, 'foo=bar&abc=def&foo&x=%2f%%') - md = MultiDict({'foo': 'bar', 'abc': 'def', 'x': '/%%'}) - md['foo'] = '' - self.assertEqual(req.args, md) - - def test_json(self): - fd = get_async_request_fd('GET', '/foo', headers={ - 'Content-Type': 'application/json'}, body='{"foo":"bar"}') - req = _run(Request.create('app', fd, 'writer', 'addr')) - json = req.json - self.assertEqual(json, {'foo': 'bar'}) - self.assertTrue(req.json is json) - - fd = get_async_request_fd('GET', '/foo', headers={ - 'Content-Type': 'application/json'}, body='[1, "2"]') - req = _run(Request.create('app', fd, 'writer', 'addr')) - self.assertEqual(req.json, [1, '2']) - - fd = get_async_request_fd('GET', '/foo', headers={ - 'Content-Type': 'application/xml'}, body='[1, "2"]') - req = _run(Request.create('app', fd, 'writer', 'addr')) - self.assertIsNone(req.json) - - def test_form(self): - fd = get_async_request_fd('GET', '/foo', headers={ - 'Content-Type': 'application/x-www-form-urlencoded'}, - body='foo=bar&abc=def&x=%2f%%') - req = _run(Request.create('app', fd, 'writer', 'addr')) - form = req.form - self.assertEqual(form, MultiDict( - {'foo': 'bar', 'abc': 'def', 'x': '/%%'})) - self.assertTrue(req.form is form) - - fd = get_async_request_fd('GET', '/foo', headers={ - 'Content-Type': 'application/json'}, - body='foo=bar&abc=def&x=%2f%%') - req = _run(Request.create('app', fd, 'writer', 'addr')) - self.assertIsNone(req.form) - - def test_large_line(self): - saved_max_readline = Request.max_readline - Request.max_readline = 16 - - fd = get_async_request_fd('GET', '/foo', headers={ - 'Content-Type': 'application/x-www-form-urlencoded'}, - body='foo=bar&abc=def&x=y') - with self.assertRaises(ValueError): - _run(Request.create('app', fd, 'writer', 'addr')) - - Request.max_readline = saved_max_readline - - def test_stream(self): - fd = get_async_request_fd('GET', '/foo', headers={ - 'Content-Type': 'application/x-www-form-urlencoded', - 'Content-Length': '19'}, - body='foo=bar&abc=def&x=y') - req = _run(Request.create('app', fd, 'writer', 'addr')) - self.assertEqual(req.body, b'foo=bar&abc=def&x=y') - data = _run(req.stream.read()) - self.assertEqual(data, b'foo=bar&abc=def&x=y') - - def test_large_payload(self): - saved_max_content_length = Request.max_content_length - saved_max_body_length = Request.max_body_length - Request.max_content_length = 32 - Request.max_body_length = 16 - - fd = get_async_request_fd('GET', '/foo', headers={ - 'Content-Type': 'application/x-www-form-urlencoded', - 'Content-Length': '19'}, - body='foo=bar&abc=def&x=y') - req = _run(Request.create('app', fd, 'writer', 'addr')) - self.assertEqual(req.body, b'') - data = _run(req.stream.read()) - self.assertEqual(data, b'foo=bar&abc=def&x=y') - - Request.max_content_length = saved_max_content_length - Request.max_body_length = saved_max_body_length diff --git a/tests/test_response.py b/tests/test_response.py index 6aa7fca..3780f92 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -1,67 +1,87 @@ +import asyncio from datetime import datetime - -try: - import uio as io -except ImportError: - import io - import unittest from microdot import Response +from tests.mock_socket import FakeStreamAsync class TestResponse(unittest.TestCase): + @classmethod + def setUpClass(cls): + if hasattr(asyncio, 'set_event_loop'): + asyncio.set_event_loop(asyncio.new_event_loop()) + cls.loop = asyncio.get_event_loop() + + def _run(self, coro): + return self.loop.run_until_complete(coro) + def test_create_from_string(self): res = Response('foo') self.assertEqual(res.status_code, 200) self.assertEqual(res.headers, {}) self.assertEqual(res.body, b'foo') - fd = io.BytesIO() - res.write(fd) - response = fd.getvalue() - self.assertIn(b'HTTP/1.0 200 OK\r\n', response) - self.assertIn(b'Content-Length: 3\r\n', response) - self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', response) - self.assertTrue(response.endswith(b'\r\n\r\nfoo')) + fd = FakeStreamAsync() + self._run(res.write(fd)) + self.assertIn(b'HTTP/1.0 200 OK\r\n', fd.response) + self.assertIn(b'Content-Length: 3\r\n', fd.response) + self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', + fd.response) + self.assertTrue(fd.response.endswith(b'\r\n\r\nfoo')) def test_create_from_string_with_content_length(self): res = Response('foo', headers={'Content-Length': '2'}) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers, {'Content-Length': '2'}) self.assertEqual(res.body, b'foo') - fd = io.BytesIO() - res.write(fd) - response = fd.getvalue() - self.assertIn(b'HTTP/1.0 200 OK\r\n', response) - self.assertIn(b'Content-Length: 2\r\n', response) - self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', response) - self.assertTrue(response.endswith(b'\r\n\r\nfoo')) + fd = FakeStreamAsync() + self._run(res.write(fd)) + self.assertIn(b'HTTP/1.0 200 OK\r\n', fd.response) + self.assertIn(b'Content-Length: 2\r\n', fd.response) + self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', + fd.response) + self.assertTrue(fd.response.endswith(b'\r\n\r\nfoo')) def test_create_from_bytes(self): res = Response(b'foo') self.assertEqual(res.status_code, 200) self.assertEqual(res.headers, {}) self.assertEqual(res.body, b'foo') - fd = io.BytesIO() - res.write(fd) - response = fd.getvalue() - self.assertIn(b'HTTP/1.0 200 OK\r\n', response) - self.assertIn(b'Content-Length: 3\r\n', response) - self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', response) - self.assertTrue(response.endswith(b'\r\n\r\nfoo')) + fd = FakeStreamAsync() + self._run(res.write(fd)) + self.assertIn(b'HTTP/1.0 200 OK\r\n', fd.response) + self.assertIn(b'Content-Length: 3\r\n', fd.response) + self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', + fd.response) + self.assertTrue(fd.response.endswith(b'\r\n\r\nfoo')) + + def test_create_from_head(self): + res = Response(b'foo') + res.is_head = True + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers, {}) + self.assertEqual(res.body, b'foo') + fd = FakeStreamAsync() + self._run(res.write(fd)) + self.assertIn(b'HTTP/1.0 200 OK\r\n', fd.response) + self.assertIn(b'Content-Length: 3\r\n', fd.response) + self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', + fd.response) + self.assertTrue(fd.response.endswith(b'\r\n\r\n')) + self.assertFalse(b'foo' in fd.response) def test_create_empty(self): res = Response(headers={'X-Foo': 'Bar'}) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers, {'X-Foo': 'Bar'}) self.assertEqual(res.body, b'') - fd = io.BytesIO() - res.write(fd) - response = fd.getvalue() - self.assertIn(b'HTTP/1.0 200 OK\r\n', response) - self.assertIn(b'X-Foo: Bar\r\n', response) - self.assertIn(b'Content-Length: 0\r\n', response) - self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', response) - self.assertTrue(response.endswith(b'\r\n\r\n')) + fd = FakeStreamAsync() + self._run(res.write(fd)) + self.assertIn(b'HTTP/1.0 200 OK\r\n', fd.response) + self.assertIn(b'X-Foo: Bar\r\n', fd.response) + self.assertIn(b'Content-Length: 0\r\n', fd.response) + self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', + fd.response) + self.assertTrue(fd.response.endswith(b'\r\n\r\n')) def test_create_json(self): res = Response({'foo': 'bar'}) @@ -69,40 +89,52 @@ class TestResponse(unittest.TestCase): self.assertEqual(res.headers, {'Content-Type': 'application/json; charset=UTF-8'}) self.assertEqual(res.body, b'{"foo": "bar"}') - fd = io.BytesIO() - res.write(fd) - response = fd.getvalue() - self.assertIn(b'HTTP/1.0 200 OK\r\n', response) - self.assertIn(b'Content-Length: 14\r\n', response) + fd = FakeStreamAsync() + self._run(res.write(fd)) + self.assertIn(b'HTTP/1.0 200 OK\r\n', fd.response) + self.assertIn(b'Content-Length: 14\r\n', fd.response) self.assertIn(b'Content-Type: application/json; charset=UTF-8\r\n', - response) - self.assertTrue(response.endswith(b'\r\n\r\n{"foo": "bar"}')) + fd.response) + self.assertTrue(fd.response.endswith(b'\r\n\r\n{"foo": "bar"}')) res = Response([1, '2']) self.assertEqual(res.status_code, 200) self.assertEqual(res.headers, {'Content-Type': 'application/json; charset=UTF-8'}) self.assertEqual(res.body, b'[1, "2"]') - fd = io.BytesIO() - res.write(fd) - response = fd.getvalue() - self.assertIn(b'HTTP/1.0 200 OK\r\n', response) - self.assertIn(b'Content-Length: 8\r\n', response) + fd = FakeStreamAsync() + self._run(res.write(fd)) + self.assertIn(b'HTTP/1.0 200 OK\r\n', fd.response) + self.assertIn(b'Content-Length: 8\r\n', fd.response) self.assertIn(b'Content-Type: application/json; charset=UTF-8\r\n', - response) - self.assertTrue(response.endswith(b'\r\n\r\n[1, "2"]')) + fd.response) + self.assertTrue(fd.response.endswith(b'\r\n\r\n[1, "2"]')) def test_create_from_none(self): res = Response(None) self.assertEqual(res.status_code, 204) self.assertEqual(res.body, b'') - fd = io.BytesIO() - res.write(fd) - response = fd.getvalue() - self.assertIn(b'HTTP/1.0 204 N/A\r\n', response) - self.assertIn(b'Content-Length: 0\r\n', response) - self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', response) - self.assertTrue(response.endswith(b'\r\n\r\n')) + fd = FakeStreamAsync() + self._run(res.write(fd)) + self.assertIn(b'HTTP/1.0 204 N/A\r\n', fd.response) + self.assertIn(b'Content-Length: 0\r\n', fd.response) + self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', + fd.response) + self.assertTrue(fd.response.endswith(b'\r\n\r\n')) + + def test_create_from_iterator(self): + def gen(): + yield 'foo' + yield 'bar' + + res = Response(gen()) + fd = FakeStreamAsync() + self._run(res.write(fd)) + print(fd.response) + self.assertIn(b'HTTP/1.0 200 OK\r\n', fd.response) + self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', + fd.response) + self.assertTrue(fd.response.endswith(b'\r\n\r\nfoobar')) def test_create_from_other(self): res = Response(123) @@ -134,10 +166,9 @@ class TestResponse(unittest.TestCase): self.assertEqual(res.headers, {}) self.assertEqual(res.reason, 'ALL GOOD!') self.assertEqual(res.body, b'foo') - fd = io.BytesIO() - res.write(fd) - response = fd.getvalue() - self.assertIn(b'HTTP/1.0 200 ALL GOOD!\r\n', response) + fd = FakeStreamAsync() + self._run(res.write(fd)) + self.assertIn(b'HTTP/1.0 200 ALL GOOD!\r\n', fd.response) def test_create_with_status_and_reason(self): res = Response('not found', 404, reason='NOT FOUND') @@ -145,15 +176,14 @@ class TestResponse(unittest.TestCase): self.assertEqual(res.headers, {}) self.assertEqual(res.reason, 'NOT FOUND') self.assertEqual(res.body, b'not found') - fd = io.BytesIO() - res.write(fd) - response = fd.getvalue() - self.assertIn(b'HTTP/1.0 404 NOT FOUND\r\n', response) + fd = FakeStreamAsync() + self._run(res.write(fd)) + self.assertIn(b'HTTP/1.0 404 NOT FOUND\r\n', fd.response) def test_cookies(self): res = Response('ok') res.set_cookie('foo1', 'bar1') - res.set_cookie('foo2', 'bar2', path='/') + res.set_cookie('foo2', 'bar2', path='/', partitioned=True) res.set_cookie('foo3', 'bar3', domain='example.com:1234') res.set_cookie('foo4', 'bar4', expires=datetime(2019, 11, 5, 2, 23, 54)) @@ -163,16 +193,18 @@ class TestResponse(unittest.TestCase): res.set_cookie('foo7', 'bar7', path='/foo', domain='example.com:1234', expires=datetime(2019, 11, 5, 2, 23, 54), max_age=123, secure=True, http_only=True) + res.delete_cookie('foo8', http_only=True) self.assertEqual(res.headers, {'Set-Cookie': [ 'foo1=bar1', - 'foo2=bar2; Path=/', + 'foo2=bar2; Path=/; Partitioned', 'foo3=bar3; Domain=example.com:1234', 'foo4=bar4; Expires=Tue, 05 Nov 2019 02:23:54 GMT', 'foo5=bar5; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=123', 'foo6=bar6; Secure; HttpOnly', 'foo7=bar7; Path=/foo; Domain=example.com:1234; ' 'Expires=Tue, 05 Nov 2019 02:23:54 GMT; Max-Age=123; Secure; ' - 'HttpOnly' + 'HttpOnly', + 'foo8=; Expires=Thu, 01 Jan 1970 00:00:01 GMT; HttpOnly', ]}) def test_redirect(self): @@ -188,36 +220,14 @@ class TestResponse(unittest.TestCase): Response.redirect('/foo\x0d\x0a\x0d\x0a

Foo

') def test_send_file(self): - files = [ - ('test.txt', 'text/plain'), - ('test.gif', 'image/gif'), - ('test.jpg', 'image/jpeg'), - ('test.png', 'image/png'), - ('test.html', 'text/html'), - ('test.css', 'text/css'), - ('test.js', 'application/javascript'), - ('test.json', 'application/json'), - ('test.bin', 'application/octet-stream'), - ] - for file, content_type in files: - res = Response.send_file('tests/files/' + file) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Content-Type'], content_type) - fd = io.BytesIO() - res.write(fd) - response = fd.getvalue() - self.assertEqual(response, ( - b'HTTP/1.0 200 OK\r\nContent-Type: ' + content_type.encode() - + b'\r\n\r\nfoo\n')) res = Response.send_file('tests/files/test.txt', content_type='text/html') self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Content-Type'], 'text/html') - fd = io.BytesIO() - res.write(fd) - response = fd.getvalue() + fd = FakeStreamAsync() + self._run(res.write(fd)) self.assertEqual( - response, + fd.response, b'HTTP/1.0 200 OK\r\nContent-Type: text/html\r\n\r\nfoo\n') def test_send_file_small_buffer(self): @@ -227,11 +237,10 @@ class TestResponse(unittest.TestCase): content_type='text/html') self.assertEqual(res.status_code, 200) self.assertEqual(res.headers['Content-Type'], 'text/html') - fd = io.BytesIO() - res.write(fd) - response = fd.getvalue() + fd = FakeStreamAsync() + self._run(res.write(fd)) self.assertEqual( - response, + fd.response, b'HTTP/1.0 200 OK\r\nContent-Type: text/html\r\n\r\nfoo\n') Response.send_file_buffer_size = original_buffer_size diff --git a/tests/test_response_asyncio.py b/tests/test_response_asyncio.py deleted file mode 100644 index 3ae77f4..0000000 --- a/tests/test_response_asyncio.py +++ /dev/null @@ -1,139 +0,0 @@ -try: - import uasyncio as asyncio -except ImportError: - import asyncio - -import unittest -from microdot_asyncio import Response -from tests.mock_socket import FakeStreamAsync - - -def _run(coro): - return asyncio.get_event_loop().run_until_complete(coro) - - -class TestResponseAsync(unittest.TestCase): - def test_create_from_string(self): - res = Response('foo') - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers, {}) - self.assertEqual(res.body, b'foo') - fd = FakeStreamAsync() - _run(res.write(fd)) - self.assertIn(b'HTTP/1.0 200 OK\r\n', fd.response) - self.assertIn(b'Content-Length: 3\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', - fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\nfoo')) - - def test_create_from_string_with_content_length(self): - res = Response('foo', headers={'Content-Length': '2'}) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers, {'Content-Length': '2'}) - self.assertEqual(res.body, b'foo') - fd = FakeStreamAsync() - _run(res.write(fd)) - self.assertIn(b'HTTP/1.0 200 OK\r\n', fd.response) - self.assertIn(b'Content-Length: 2\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', - fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\nfoo')) - - def test_create_from_bytes(self): - res = Response(b'foo') - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers, {}) - self.assertEqual(res.body, b'foo') - fd = FakeStreamAsync() - _run(res.write(fd)) - self.assertIn(b'HTTP/1.0 200 OK\r\n', fd.response) - self.assertIn(b'Content-Length: 3\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', - fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\nfoo')) - - def test_create_empty(self): - res = Response(headers={'X-Foo': 'Bar'}) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers, {'X-Foo': 'Bar'}) - self.assertEqual(res.body, b'') - fd = FakeStreamAsync() - _run(res.write(fd)) - self.assertIn(b'HTTP/1.0 200 OK\r\n', fd.response) - self.assertIn(b'X-Foo: Bar\r\n', fd.response) - self.assertIn(b'Content-Length: 0\r\n', fd.response) - self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', - fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\n')) - - def test_create_json(self): - res = Response({'foo': 'bar'}) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers, - {'Content-Type': 'application/json; charset=UTF-8'}) - self.assertEqual(res.body, b'{"foo": "bar"}') - fd = FakeStreamAsync() - _run(res.write(fd)) - self.assertIn(b'HTTP/1.0 200 OK\r\n', fd.response) - self.assertIn(b'Content-Length: 14\r\n', fd.response) - self.assertIn(b'Content-Type: application/json; charset=UTF-8\r\n', - fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\n{"foo": "bar"}')) - - res = Response([1, '2']) - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers, - {'Content-Type': 'application/json; charset=UTF-8'}) - self.assertEqual(res.body, b'[1, "2"]') - fd = FakeStreamAsync() - _run(res.write(fd)) - self.assertIn(b'HTTP/1.0 200 OK\r\n', fd.response) - self.assertIn(b'Content-Length: 8\r\n', fd.response) - self.assertIn(b'Content-Type: application/json; charset=UTF-8\r\n', - fd.response) - self.assertTrue(fd.response.endswith(b'\r\n\r\n[1, "2"]')) - - def test_create_with_reason(self): - res = Response('foo', reason='ALL GOOD!') - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers, {}) - self.assertEqual(res.reason, 'ALL GOOD!') - self.assertEqual(res.body, b'foo') - fd = FakeStreamAsync() - _run(res.write(fd)) - self.assertIn(b'HTTP/1.0 200 ALL GOOD!\r\n', fd.response) - - def test_create_with_status_and_reason(self): - res = Response('not found', 404, reason='NOT FOUND') - self.assertEqual(res.status_code, 404) - self.assertEqual(res.headers, {}) - self.assertEqual(res.reason, 'NOT FOUND') - self.assertEqual(res.body, b'not found') - fd = FakeStreamAsync() - _run(res.write(fd)) - self.assertIn(b'HTTP/1.0 404 NOT FOUND\r\n', fd.response) - - def test_send_file(self): - res = Response.send_file('tests/files/test.txt', - content_type='text/html') - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Content-Type'], 'text/html') - fd = FakeStreamAsync() - _run(res.write(fd)) - self.assertEqual( - fd.response, - b'HTTP/1.0 200 OK\r\nContent-Type: text/html\r\n\r\nfoo\n') - - def test_send_file_small_buffer(self): - original_buffer_size = Response.send_file_buffer_size - Response.send_file_buffer_size = 2 - res = Response.send_file('tests/files/test.txt', - content_type='text/html') - self.assertEqual(res.status_code, 200) - self.assertEqual(res.headers['Content-Type'], 'text/html') - fd = FakeStreamAsync() - _run(res.write(fd)) - self.assertEqual( - fd.response, - b'HTTP/1.0 200 OK\r\nContent-Type: text/html\r\n\r\nfoo\n') - Response.send_file_buffer_size = original_buffer_size diff --git a/tests/test_session.py b/tests/test_session.py index 9697670..aedb7b2 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1,79 +1,31 @@ -try: - import uasyncio as asyncio -except ImportError: - import asyncio +import asyncio import unittest from microdot import Microdot -from microdot_asyncio import Microdot as MicrodotAsync -from microdot_session import set_session_secret_key, get_session, \ - update_session, delete_session, with_session -from microdot_test_client import TestClient -from microdot_asyncio_test_client import TestClient as TestClientAsync +from microdot.session import Session, with_session +from microdot.test_client import TestClient -set_session_secret_key('top-secret!') +session_ext = Session(secret_key='top-secret!') class TestSession(unittest.TestCase): - def test_session(self): + @classmethod + def setUpClass(cls): + if hasattr(asyncio, 'set_event_loop'): + asyncio.set_event_loop(asyncio.new_event_loop()) + cls.loop = asyncio.get_event_loop() + + def _run(self, coro): + return self.loop.run_until_complete(coro) + + def test_session_async(self): app = Microdot() + session_ext.initialize(app, secret_key='some-other-secret') client = TestClient(app) - @app.get('/') - def index(req): - session = get_session(req) - session2 = get_session(req) - session2['foo'] = 'bar' - self.assertEqual(session['foo'], 'bar') - return str(session.get('name')) - - @app.get('/with') - @with_session - def session_context_manager(req, session): - return str(session.get('name')) - - @app.post('/set') - def set_session(req): - update_session(req, {'name': 'joe'}) - return 'OK' - - @app.post('/del') - def del_session(req): - delete_session(req) - return 'OK' - - res = client.get('/') - self.assertEqual(res.text, 'None') - res = client.get('/with') - self.assertEqual(res.text, 'None') - - res = client.post('/set') - self.assertEqual(res.text, 'OK') - - res = client.get('/') - self.assertEqual(res.text, 'joe') - res = client.get('/with') - self.assertEqual(res.text, 'joe') - - res = client.post('/del') - self.assertEqual(res.text, 'OK') - - res = client.get('/') - self.assertEqual(res.text, 'None') - res = client.get('/with') - self.assertEqual(res.text, 'None') - - def _run(self, coro): - loop = asyncio.get_event_loop() - return loop.run_until_complete(coro) - - def test_session_async(self): - app = MicrodotAsync() - client = TestClientAsync(app) - @app.get('/') async def index(req): - session = get_session(req) - session2 = get_session(req) + session = session_ext.get(req) + session2 = session_ext.get(req) session2['foo'] = 'bar' self.assertEqual(session['foo'], 'bar') return str(session.get('name')) @@ -84,13 +36,16 @@ class TestSession(unittest.TestCase): return str(session.get('name')) @app.post('/set') - async def set_session(req): - update_session(req, {'name': 'joe'}) + @with_session + async def save_session(req, session): + session['name'] = 'joe' + session.save() return 'OK' @app.post('/del') - async def del_session(req): - delete_session(req) + @with_session + async def delete_session(req, session): + session.delete() return 'OK' res = self._run(client.get('/')) @@ -115,17 +70,15 @@ class TestSession(unittest.TestCase): self.assertEqual(res.text, 'None') def test_session_no_secret_key(self): - set_session_secret_key(None) app = Microdot() + session_ext = Session(app) client = TestClient(app) @app.get('/') def index(req): - self.assertRaises(ValueError, get_session, req) - self.assertRaises(ValueError, update_session, req, {}) + self.assertRaises(ValueError, session_ext.get, req) + self.assertRaises(ValueError, session_ext.update, req, {}) return '' - res = client.get('/') + res = self._run(client.get('/')) self.assertEqual(res.status_code, 200) - - set_session_secret_key('top-secret!') diff --git a/tests/test_sse.py b/tests/test_sse.py new file mode 100644 index 0000000..edebf11 --- /dev/null +++ b/tests/test_sse.py @@ -0,0 +1,38 @@ +import asyncio +import unittest +from microdot import Microdot +from microdot.sse import with_sse +from microdot.test_client import TestClient + + +class TestWebSocket(unittest.TestCase): + @classmethod + def setUpClass(cls): + if hasattr(asyncio, 'set_event_loop'): + asyncio.set_event_loop(asyncio.new_event_loop()) + cls.loop = asyncio.get_event_loop() + + def _run(self, coro): + return self.loop.run_until_complete(coro) + + def test_sse(self): + app = Microdot() + + @app.route('/sse') + @with_sse + async def handle_sse(request, sse): + await sse.send('foo') + await sse.send('bar', event='test') + await sse.send({'foo': 'bar'}) + await sse.send([42, 'foo', 'bar']) + await sse.send(ValueError('foo')) + + client = TestClient(app) + response = self._run(client.get('/sse')) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers['Content-Type'], 'text/event-stream') + self.assertEqual(response.text, ('data: foo\n\n' + 'event: test\ndata: bar\n\n' + 'data: {"foo": "bar"}\n\n' + 'data: [42, "foo", "bar"]\n\n' + 'data: foo\n\n')) diff --git a/tests/test_url_pattern.py b/tests/test_url_pattern.py index 7ea6a30..8bdce01 100644 --- a/tests/test_url_pattern.py +++ b/tests/test_url_pattern.py @@ -1,5 +1,5 @@ import unittest -from microdot import URLPattern +from microdot.microdot import URLPattern class TestURLPattern(unittest.TestCase): diff --git a/tests/test_urlencode.py b/tests/test_urlencode.py index 353bd0c..db21d85 100644 --- a/tests/test_urlencode.py +++ b/tests/test_urlencode.py @@ -1,5 +1,5 @@ import unittest -from microdot import urlencode, urldecode_str, urldecode_bytes +from microdot.microdot import urlencode, urldecode_str, urldecode_bytes class TestURLEncode(unittest.TestCase): diff --git a/tests/test_utemplate.py b/tests/test_utemplate.py index 97108a2..5784a5e 100644 --- a/tests/test_utemplate.py +++ b/tests/test_utemplate.py @@ -1,55 +1,47 @@ -try: - import uasyncio as asyncio -except ImportError: - import asyncio - +import asyncio import unittest -from microdot import Microdot, Request -from microdot_asyncio import Microdot as MicrodotAsync, Request as RequestAsync -from microdot_utemplate import render_template, init_templates -from tests.mock_socket import get_request_fd, get_async_request_fd +from microdot import Microdot +from microdot.test_client import TestClient +from microdot.utemplate import Template, init_templates init_templates('tests/templates') -def _run(coro): - return asyncio.get_event_loop().run_until_complete(coro) - - class TestUTemplate(unittest.TestCase): + @classmethod + def setUpClass(cls): + if hasattr(asyncio, 'set_event_loop'): + asyncio.set_event_loop(asyncio.new_event_loop()) + cls.loop = asyncio.get_event_loop() + + def _run(self, coro): + return self.loop.run_until_complete(coro) + def test_render_template(self): - s = list(render_template('hello.utemplate.txt', name='foo')) - self.assertEqual(s, ['Hello, ', 'foo', '!\n']) + s = Template('hello.utemplate.txt').render(name='foo') + self.assertEqual(s, 'Hello, foo!\n') def test_render_template_in_app(self): app = Microdot() @app.route('/') - def index(req): - return render_template('hello.utemplate.txt', name='foo') + async def index(req): + return Template('hello.utemplate.txt').render(name='foo') - req = Request.create(app, get_request_fd('GET', '/'), 'addr') - res = app.dispatch_request(req) + client = TestClient(app) + res = self._run(client.get('/')) self.assertEqual(res.status_code, 200) - self.assertEqual(list(res.body_iter()), ['Hello, ', 'foo', '!\n']) + self.assertEqual(res.body, b'Hello, foo!\n') - def test_render_template_in_app_async(self): - app = MicrodotAsync() + def test_render_async_template_in_app(self): + app = Microdot() @app.route('/') async def index(req): - return render_template('hello.utemplate.txt', name='foo') + return await Template('hello.utemplate.txt').render_async( + name='foo') - req = _run(RequestAsync.create( - app, get_async_request_fd('GET', '/'), 'writer', 'addr')) - res = _run(app.dispatch_request(req)) + client = TestClient(app) + res = self._run(client.get('/')) self.assertEqual(res.status_code, 200) - - async def get_result(): - result = [] - async for chunk in res.body_iter(): - result.append(chunk) - return result - - result = _run(get_result()) - self.assertEqual(result, ['Hello, ', 'foo', '!\n']) + self.assertEqual(res.body, b'Hello, foo!\n') diff --git a/tests/test_websocket.py b/tests/test_websocket.py new file mode 100644 index 0000000..4d2a507 --- /dev/null +++ b/tests/test_websocket.py @@ -0,0 +1,114 @@ +import asyncio +import sys +import unittest +from microdot import Microdot +from microdot.websocket import with_websocket, WebSocket +from microdot.test_client import TestClient + + +class TestWebSocket(unittest.TestCase): + @classmethod + def setUpClass(cls): + if hasattr(asyncio, 'set_event_loop'): + asyncio.set_event_loop(asyncio.new_event_loop()) + cls.loop = asyncio.get_event_loop() + + def _run(self, coro): + return self.loop.run_until_complete(coro) + + def test_websocket_echo(self): + app = Microdot() + + @app.route('/echo') + @with_websocket + async def index(req, ws): + while True: + data = await ws.receive() + await ws.send(data) + + results = [] + + def ws(): + data = yield 'hello' + results.append(data) + data = yield b'bye' + results.append(data) + data = yield b'*' * 300 + results.append(data) + data = yield b'+' * 65537 + results.append(data) + + client = TestClient(app) + res = self._run(client.websocket('/echo', ws)) + self.assertIsNone(res) + self.assertEqual(results, ['hello', b'bye', b'*' * 300, b'+' * 65537]) + + @unittest.skipIf(sys.implementation.name == 'micropython', + 'no support for async generators in MicroPython') + def test_websocket_echo_async_client(self): + app = Microdot() + + @app.route('/echo') + @with_websocket + async def index(req, ws): + while True: + data = await ws.receive() + await ws.send(data) + + results = [] + + async def ws(): + data = yield 'hello' + results.append(data) + data = yield b'bye' + results.append(data) + data = yield b'*' * 300 + results.append(data) + data = yield b'+' * 65537 + results.append(data) + + client = TestClient(app) + res = self._run(client.websocket('/echo', ws)) + self.assertIsNone(res) + self.assertEqual(results, ['hello', b'bye', b'*' * 300, b'+' * 65537]) + + def test_bad_websocket_request(self): + app = Microdot() + + @app.route('/echo') + @with_websocket + def index(req, ws): + return 'hello' + + client = TestClient(app) + res = self._run(client.get('/echo')) + self.assertEqual(res.status_code, 400) + res = self._run(client.get('/echo', headers={'Connection': 'Upgrade'})) + self.assertEqual(res.status_code, 400) + res = self._run(client.get('/echo', headers={'Connection': 'foo'})) + self.assertEqual(res.status_code, 400) + res = self._run(client.get('/echo', headers={'Upgrade': 'websocket'})) + self.assertEqual(res.status_code, 400) + res = self._run(client.get('/echo', headers={'Upgrade': 'bar'})) + self.assertEqual(res.status_code, 400) + res = self._run(client.get('/echo', headers={'Connection': 'Upgrade', + 'Upgrade': 'websocket'})) + self.assertEqual(res.status_code, 400) + res = self._run(client.get( + '/echo', headers={'Sec-WebSocket-Key': 'xxx'})) + self.assertEqual(res.status_code, 400) + + def test_process_websocket_frame(self): + ws = WebSocket(None) + ws.closed = True + + self.assertEqual(ws._process_websocket_frame(WebSocket.TEXT, b'foo'), + (None, 'foo')) + self.assertEqual(ws._process_websocket_frame(WebSocket.BINARY, b'foo'), + (None, b'foo')) + self.assertRaises(OSError, ws._process_websocket_frame, + WebSocket.CLOSE, b'') + self.assertEqual(ws._process_websocket_frame(WebSocket.PING, b'foo'), + (WebSocket.PONG, b'foo')) + self.assertEqual(ws._process_websocket_frame(WebSocket.PONG, b'foo'), + (None, None)) diff --git a/tests/test_microdot_wsgi.py b/tests/test_wsgi.py similarity index 67% rename from tests/test_microdot_wsgi.py rename to tests/test_wsgi.py index 708861f..8102f14 100644 --- a/tests/test_microdot_wsgi.py +++ b/tests/test_wsgi.py @@ -1,23 +1,15 @@ -import unittest +import io import sys +import unittest +from unittest import mock -try: - import uio as io -except ImportError: - import io - -try: - from unittest import mock -except ImportError: - mock = None - -from microdot_wsgi import Microdot +from microdot.wsgi import Microdot, Request @unittest.skipIf(sys.implementation.name == 'micropython', 'not supported under MicroPython') -class TestMicrodotWSGI(unittest.TestCase): - def test_wsgi_request_with_query_string(self): +class TestWSGI(unittest.TestCase): + def test_request_with_query_string(self): app = Microdot() @app.post('/foo/bar') @@ -29,6 +21,8 @@ class TestMicrodotWSGI(unittest.TestCase): self.assertEqual(req.path, '/foo/bar') self.assertEqual(req.args, {'baz': ['1']}) self.assertEqual(req.cookies, {'session': 'xyz'}) + self.assertEqual(req.headers['Content-Length'], '4') + self.assertEqual(req.headers['Content-Type'], 'text/plain') self.assertEqual(req.body, b'body') return 'response' @@ -43,7 +37,8 @@ class TestMicrodotWSGI(unittest.TestCase): 'QUERY_STRING': 'baz=1', 'HTTP_AUTHORIZATION': 'Bearer 123', 'HTTP_COOKIE': 'session=xyz', - 'HTTP_CONTENT_LENGTH': '4', + 'CONTENT_LENGTH': '4', + 'CONTENT_TYPE': 'text/plain', 'REMOTE_ADDR': '1.2.3.4', 'REMOTE_PORT': '1234', 'REQUEST_METHOD': 'POST', @@ -62,9 +57,9 @@ class TestMicrodotWSGI(unittest.TestCase): self.assertIn(header, headers) r = app(environ, start_response) - self.assertEqual(next(r), b'response') + self.assertEqual(b''.join(r), b'response') - def test_wsgi_request_without_query_string(self): + def test_request_without_query_string(self): app = Microdot() @app.route('/foo/bar') @@ -88,6 +83,37 @@ class TestMicrodotWSGI(unittest.TestCase): app(environ, start_response) + def test_request_with_stream(self): + saved_max_body_length = Request.max_body_length + Request.max_body_length = 2 + + app = Microdot() + + @app.post('/foo/bar') + async def index(req): + self.assertEqual(req.body, b'') + self.assertEqual(await req.stream.read(), b'body') + return 'response' + + environ = { + 'SCRIPT_NAME': '/foo', + 'PATH_INFO': '/bar', + 'CONTENT_LENGTH': '4', + 'CONTENT_TYPE': 'text/plain', + 'REMOTE_ADDR': '1.2.3.4', + 'REMOTE_PORT': '1234', + 'REQUEST_METHOD': 'POST', + 'SERVER_PROTOCOL': 'HTTP/1.1', + 'wsgi.input': io.BytesIO(b'body'), + } + + def start_response(status, headers): + pass + + app(environ, start_response) + + Request.max_body_length = saved_max_body_length + def test_shutdown(self): app = Microdot() @@ -107,7 +133,7 @@ class TestMicrodotWSGI(unittest.TestCase): def start_response(status, headers): pass - with mock.patch('microdot_wsgi.os.kill') as kill: + with mock.patch('microdot.wsgi.os.kill') as kill: app(environ, start_response) kill.assert_called() diff --git a/tox.ini b/tox.ini index c85300e..f5350c4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist=flake8,py37,py38,py39,py310,py311,upy,benchmark +envlist=flake8,py38,py39,py310,py311,py312,upy,benchmark skipsdist=True skip_missing_interpreters=True @@ -10,12 +10,13 @@ python = 3.9: py39 3.10: py310 3.11: py311 + 3.12: py312 pypy3: pypy3 [testenv] commands= pip install -e . - pytest -p no:logging --cov=src --cov-config=.coveragerc --cov-branch --cov-report=term-missing --cov-report=xml + pytest -p no:logging --cov=src --cov-config=.coveragerc --cov-branch --cov-report=term-missing --cov-report=xml tests deps= pytest pytest-cov @@ -24,12 +25,6 @@ deps= setenv= PYTHONPATH=libs/common -[testenv:flake8] -deps= - flake8 -commands= - flake8 --ignore=W503 --exclude src/utemplate,tests/libs src tests examples - [testenv:upy] allowlist_externals=sh commands=sh -c "bin/micropython run_tests.py" @@ -55,3 +50,8 @@ commands= setenv= PATH={env:PATH}{:}../../bin +[testenv:flake8] +deps= + flake8 +commands= + flake8 --ignore=W503 --exclude examples/templates/utemplate/templates src tests examples