From 74daa0287a4dc2ce77bc340efb964a2dd930af1b Mon Sep 17 00:00:00 2001 From: EduLoboM Date: Wed, 26 Aug 2026 22:29:27 -0300 Subject: [PATCH 01/45] refactor: rename project from BlackSheep to Dreaming Electric Sheep --- .github/ISSUE_TEMPLATE/feature_request.md | 2 +- .github/workflows/main.yml | 31 +++++----- .github/workflows/perf.yml | 18 +++--- .github/workflows/perfhistory.yml | 4 +- .gitignore | 6 +- MANIFEST.in | 9 +-- Makefile | 53 +++++++++--------- SECURITY.md | 7 ++- blacksheep/sessions/logs.py | 10 ---- blacksheep/settings/__init__.py | 3 - blacksheep/testing/__init__.py | 14 ----- dreaming_electric_sheep.png | Bin 0 -> 23874 bytes blacksheep.svg => dreaming_electric_sheep.svg | 0 .../__init__.py | 0 .../asgi.pyi | 0 .../baseapp.pxd | 2 +- .../baseapp.py | 0 .../baseapp.pyi | 8 +-- .../baseapp.pyx | 0 .../client/__init__.py | 0 .../client/connection.py | 14 ++--- .../client/cookies.py | 6 +- .../client/exceptions.py | 4 +- .../client/pool.py | 4 +- .../client/session.py | 6 +- .../common/__init__.py | 0 .../common/files/__init__.py | 0 .../common/files/asyncfs.py | 2 +- .../common/files/info.py | 0 .../common/files/pathsutils.py | 0 .../common/types.py | 2 +- .../contents.pxd | 2 +- .../contents.py | 0 .../contents.pyi | 0 .../contents.pyx | 0 .../cookies.pxd | 2 +- .../cookies.py | 0 .../cookies.pyi | 0 .../cookies.pyx | 0 .../exceptions.pxd | 0 .../exceptions.py | 0 .../exceptions.pyi | 0 .../exceptions.pyx | 0 .../headers.pxd | 2 +- .../headers.py | 0 .../headers.pyi | 0 .../headers.pyx | 0 .../messages.pxd | 2 +- .../messages.py | 0 .../messages.pyi | 0 .../messages.pyx | 0 .../middlewares.py | 4 +- .../multipart.py | 2 +- .../normalization.py | 0 .../py.typed | 0 .../ranges.py | 0 .../scribe.pxd | 2 +- .../scribe.py | 0 .../scribe.pyi | 6 +- .../scribe.pyx | 0 .../server/__init__.py | 0 .../server/application.py | 0 .../server/asgi.py | 0 .../server/authentication/__init__.py | 2 +- .../server/authentication/apikey.py | 2 +- .../server/authentication/basic.py | 2 +- .../server/authentication/cookie.py | 10 ++-- .../server/authentication/jwt.py | 4 +- .../server/authentication/oidc.py | 30 +++++----- .../server/authorization/__init__.py | 2 +- .../server/bindings/__init__.py | 0 .../server/bindings/converters.py | 0 .../server/bindings/dates.py | 0 .../server/compression.py | 6 +- .../server/controllers.py | 18 +++--- .../server/cors.py | 8 +-- .../server/csrf.py | 18 +++--- .../server/dataprotection.py | 8 +-- .../server/di.py | 6 +- .../server/diagnostics.py | 4 +- .../server/env.py | 2 +- .../server/errors.py | 8 +-- .../server/files/__init__.py | 16 +++--- .../server/files/dynamic.py | 18 +++--- .../server/files/static.py | 4 +- .../server/headers/__init__.py | 0 .../server/headers/cache.py | 4 +- .../server/normalization.py | 0 .../server/openapi/__init__.py | 0 .../server/openapi/common.py | 24 ++++---- .../server/openapi/docstrings.py | 2 +- .../server/openapi/exceptions.py | 0 .../server/openapi/ui.py | 10 ++-- .../server/openapi/v3.py | 16 +++--- .../server/otel/__init__.py | 16 +++--- .../server/otel/otlp.py | 10 ++-- .../server/process.py | 4 +- .../server/redirects.py | 4 +- .../server/remotes/__init__.py | 0 .../server/remotes/forwarding.py | 6 +- .../server/remotes/hosts.py | 4 +- .../server/remotes/scheme.py | 8 +-- .../server/rendering/__init__.py | 0 .../server/rendering/abc.py | 0 .../server/rendering/jinja2.py | 6 +- .../server/rendering/models.py | 0 .../server/res/__init__.py | 0 .../server/res/error.css | 0 .../server/res/error.html | 0 .../server/res/fileslist.html | 0 .../server/res/redoc-ui.html | 0 .../server/res/scalar-ui.html | 0 .../server/res/swagger-ui.html | 0 .../server/resources.py | 4 +- .../server/responses.py | 16 +++--- .../server/routing.py | 0 .../server/security/__init__.py | 2 +- .../server/security/hsts.py | 2 +- .../server/sse.py | 6 +- .../server/websocket.py | 6 +- .../sessions/__init__.py | 6 +- .../sessions/abc.py | 2 +- .../sessions/cookies.py | 12 ++-- .../sessions/json.py | 2 +- dreaming_electric_sheep/sessions/logs.py | 10 ++++ .../sessions/memory.py | 6 +- dreaming_electric_sheep/settings/__init__.py | 3 + .../settings/di.py | 0 .../settings/encodings.py | 0 .../settings/html.py | 6 +- .../settings/json.py | 0 dreaming_electric_sheep/testing/__init__.py | 14 +++++ .../testing/client.py | 10 ++-- .../testing/helpers.py | 0 .../testing/messages.py | 0 .../testing/simulator.py | 14 ++--- .../testing/websocket.py | 2 +- .../url.pxd | 2 +- .../url.py | 0 .../url.pyi | 0 .../url.pyx | 0 .../utils/__init__.py | 0 .../utils/aio.py | 0 .../utils/meta.py | 0 .../utils/time.py | 0 itests/app_1.py | 6 +- itests/app_2.py | 26 ++++----- itests/app_3.py | 6 +- itests/app_4.py | 18 +++--- itests/client_fixtures.py | 4 +- itests/conftest.py | 2 +- itests/issue606.py | 4 +- itests/test_auth_oidc.py | 28 ++++----- itests/test_client.py | 4 +- itests/test_utils_aio.py | 2 +- noext.sh | 5 +- perf/README.md | 2 +- perf/benchmarks/app.py | 14 ++--- perf/benchmarks/url.py | 2 +- perf/genreport.py | 4 +- perf/historyrun.py | 2 +- perf/main.py | 10 ++-- setup.py | 43 +++++++------- tests/client/__init__.py | 2 +- tests/client/test_client.py | 12 ++-- tests/client/test_cookiejar.py | 12 ++-- tests/client/test_headers.py | 4 +- tests/client/test_middlewares.py | 4 +- tests/client/test_pool.py | 8 +-- tests/client/test_query.py | 4 +- tests/client/test_redirects.py | 8 +-- tests/client/test_timeouts.py | 2 +- tests/conftest.py | 4 +- tests/test_application2.py | 6 +- tests/test_auth.py | 26 ++++----- tests/test_auth_api_key.py | 6 +- tests/test_auth_basic.py | 6 +- tests/test_auth_cookie.py | 8 +-- tests/test_caching.py | 10 ++-- tests/test_contents.py | 8 +-- tests/test_controllers.py | 20 +++---- tests/test_cookies.py | 4 +- tests/test_cors.py | 14 ++--- tests/test_csrf.py | 22 ++++---- tests/test_dataprotection.py | 2 +- tests/test_encodings.py | 8 +-- tests/test_env.py | 2 +- tests/test_file_buffer.py | 8 +-- tests/test_filedata_openapi_integration.py | 6 +- tests/test_files_handler.py | 2 +- tests/test_files_serving.py | 32 +++++------ tests/test_forwarding.py | 8 +-- tests/test_gzip.py | 6 +- tests/test_headers.py | 2 +- tests/test_middlewares.py | 4 +- tests/test_multipart.py | 4 +- tests/test_normalization.py | 16 +++--- tests/test_openapi_docstrings.py | 4 +- tests/test_openapi_v3.py | 32 +++++------ tests/test_pathutils.py | 2 +- tests/test_piccolo_admin_compat.py | 4 +- tests/test_ranges.py | 2 +- tests/test_redirects.py | 6 +- tests/test_requests.py | 14 ++--- tests/test_responses.py | 16 +++--- tests/test_router.py | 6 +- tests/test_scheme.py | 12 ++-- tests/test_sessions.py | 18 +++--- tests/test_sse.py | 4 +- tests/test_templating.py | 12 ++-- tests/test_testing.py | 16 +++--- tests/test_url.py | 2 +- tests/test_url_resolver.py | 16 +++--- tests/test_utils.py | 2 +- tests/test_websocket.py | 8 +-- tests/utils/application.py | 4 +- 216 files changed, 647 insertions(+), 639 deletions(-) delete mode 100644 blacksheep/sessions/logs.py delete mode 100644 blacksheep/settings/__init__.py delete mode 100644 blacksheep/testing/__init__.py create mode 100644 dreaming_electric_sheep.png rename blacksheep.svg => dreaming_electric_sheep.svg (100%) rename {blacksheep => dreaming_electric_sheep}/__init__.py (100%) rename {blacksheep => dreaming_electric_sheep}/asgi.pyi (100%) rename {blacksheep => dreaming_electric_sheep}/baseapp.pxd (89%) rename {blacksheep => dreaming_electric_sheep}/baseapp.py (100%) rename {blacksheep => dreaming_electric_sheep}/baseapp.pyi (86%) rename {blacksheep => dreaming_electric_sheep}/baseapp.pyx (100%) rename {blacksheep => dreaming_electric_sheep}/client/__init__.py (100%) rename {blacksheep => dreaming_electric_sheep}/client/connection.py (99%) rename {blacksheep => dreaming_electric_sheep}/client/cookies.py (98%) rename {blacksheep => dreaming_electric_sheep}/client/exceptions.py (94%) rename {blacksheep => dreaming_electric_sheep}/client/pool.py (98%) rename {blacksheep => dreaming_electric_sheep}/client/session.py (98%) rename {blacksheep => dreaming_electric_sheep}/common/__init__.py (100%) rename {blacksheep => dreaming_electric_sheep}/common/files/__init__.py (100%) rename {blacksheep => dreaming_electric_sheep}/common/files/asyncfs.py (98%) rename {blacksheep => dreaming_electric_sheep}/common/files/info.py (100%) rename {blacksheep => dreaming_electric_sheep}/common/files/pathsutils.py (100%) rename {blacksheep => dreaming_electric_sheep}/common/types.py (96%) rename {blacksheep => dreaming_electric_sheep}/contents.pxd (95%) rename {blacksheep => dreaming_electric_sheep}/contents.py (100%) rename {blacksheep => dreaming_electric_sheep}/contents.pyi (100%) rename {blacksheep => dreaming_electric_sheep}/contents.pyx (100%) rename {blacksheep => dreaming_electric_sheep}/cookies.pxd (92%) rename {blacksheep => dreaming_electric_sheep}/cookies.py (100%) rename {blacksheep => dreaming_electric_sheep}/cookies.pyi (100%) rename {blacksheep => dreaming_electric_sheep}/cookies.pyx (100%) rename {blacksheep => dreaming_electric_sheep}/exceptions.pxd (100%) rename {blacksheep => dreaming_electric_sheep}/exceptions.py (100%) rename {blacksheep => dreaming_electric_sheep}/exceptions.pyi (100%) rename {blacksheep => dreaming_electric_sheep}/exceptions.pyx (100%) rename {blacksheep => dreaming_electric_sheep}/headers.pxd (91%) rename {blacksheep => dreaming_electric_sheep}/headers.py (100%) rename {blacksheep => dreaming_electric_sheep}/headers.pyi (100%) rename {blacksheep => dreaming_electric_sheep}/headers.pyx (100%) rename {blacksheep => dreaming_electric_sheep}/messages.pxd (96%) rename {blacksheep => dreaming_electric_sheep}/messages.py (100%) rename {blacksheep => dreaming_electric_sheep}/messages.pyi (100%) rename {blacksheep => dreaming_electric_sheep}/messages.pyx (100%) rename {blacksheep => dreaming_electric_sheep}/middlewares.py (97%) rename {blacksheep => dreaming_electric_sheep}/multipart.py (99%) rename {blacksheep => dreaming_electric_sheep}/normalization.py (100%) rename {blacksheep => dreaming_electric_sheep}/py.typed (100%) rename {blacksheep => dreaming_electric_sheep}/ranges.py (100%) rename {blacksheep => dreaming_electric_sheep}/scribe.pxd (84%) rename {blacksheep => dreaming_electric_sheep}/scribe.py (100%) rename {blacksheep => dreaming_electric_sheep}/scribe.pyi (55%) rename {blacksheep => dreaming_electric_sheep}/scribe.pyx (100%) rename {blacksheep => dreaming_electric_sheep}/server/__init__.py (100%) rename {blacksheep => dreaming_electric_sheep}/server/application.py (100%) rename {blacksheep => dreaming_electric_sheep}/server/asgi.py (100%) rename {blacksheep => dreaming_electric_sheep}/server/authentication/__init__.py (97%) rename {blacksheep => dreaming_electric_sheep}/server/authentication/apikey.py (99%) rename {blacksheep => dreaming_electric_sheep}/server/authentication/basic.py (99%) rename {blacksheep => dreaming_electric_sheep}/server/authentication/cookie.py (94%) rename {blacksheep => dreaming_electric_sheep}/server/authentication/jwt.py (98%) rename {blacksheep => dreaming_electric_sheep}/server/authentication/oidc.py (97%) rename {blacksheep => dreaming_electric_sheep}/server/authorization/__init__.py (98%) rename {blacksheep => dreaming_electric_sheep}/server/bindings/__init__.py (100%) rename {blacksheep => dreaming_electric_sheep}/server/bindings/converters.py (100%) rename {blacksheep => dreaming_electric_sheep}/server/bindings/dates.py (100%) rename {blacksheep => dreaming_electric_sheep}/server/compression.py (95%) rename {blacksheep => dreaming_electric_sheep}/server/controllers.py (96%) rename {blacksheep => dreaming_electric_sheep}/server/cors.py (97%) rename {blacksheep => dreaming_electric_sheep}/server/csrf.py (95%) rename {blacksheep => dreaming_electric_sheep}/server/dataprotection.py (92%) rename {blacksheep => dreaming_electric_sheep}/server/di.py (89%) rename {blacksheep => dreaming_electric_sheep}/server/diagnostics.py (92%) rename {blacksheep => dreaming_electric_sheep}/server/env.py (98%) rename {blacksheep => dreaming_electric_sheep}/server/errors.py (84%) rename {blacksheep => dreaming_electric_sheep}/server/files/__init__.py (93%) rename {blacksheep => dreaming_electric_sheep}/server/files/dynamic.py (93%) rename {blacksheep => dreaming_electric_sheep}/server/files/static.py (90%) rename {blacksheep => dreaming_electric_sheep}/server/headers/__init__.py (100%) rename {blacksheep => dreaming_electric_sheep}/server/headers/cache.py (98%) rename {blacksheep => dreaming_electric_sheep}/server/normalization.py (100%) rename {blacksheep => dreaming_electric_sheep}/server/openapi/__init__.py (100%) rename {blacksheep => dreaming_electric_sheep}/server/openapi/common.py (96%) rename {blacksheep => dreaming_electric_sheep}/server/openapi/docstrings.py (99%) rename {blacksheep => dreaming_electric_sheep}/server/openapi/exceptions.py (100%) rename {blacksheep => dreaming_electric_sheep}/server/openapi/ui.py (95%) rename {blacksheep => dreaming_electric_sheep}/server/openapi/v3.py (98%) rename {blacksheep => dreaming_electric_sheep}/server/otel/__init__.py (94%) rename {blacksheep => dreaming_electric_sheep}/server/otel/otlp.py (79%) rename {blacksheep => dreaming_electric_sheep}/server/process.py (92%) rename {blacksheep => dreaming_electric_sheep}/server/redirects.py (91%) rename {blacksheep => dreaming_electric_sheep}/server/remotes/__init__.py (100%) rename {blacksheep => dreaming_electric_sheep}/server/remotes/forwarding.py (98%) rename {blacksheep => dreaming_electric_sheep}/server/remotes/hosts.py (88%) rename {blacksheep => dreaming_electric_sheep}/server/remotes/scheme.py (89%) rename {blacksheep => dreaming_electric_sheep}/server/rendering/__init__.py (100%) rename {blacksheep => dreaming_electric_sheep}/server/rendering/abc.py (100%) rename {blacksheep => dreaming_electric_sheep}/server/rendering/jinja2.py (95%) rename {blacksheep => dreaming_electric_sheep}/server/rendering/models.py (100%) rename {blacksheep => dreaming_electric_sheep}/server/res/__init__.py (100%) rename {blacksheep => dreaming_electric_sheep}/server/res/error.css (100%) rename {blacksheep => dreaming_electric_sheep}/server/res/error.html (100%) rename {blacksheep => dreaming_electric_sheep}/server/res/fileslist.html (100%) rename {blacksheep => dreaming_electric_sheep}/server/res/redoc-ui.html (100%) rename {blacksheep => dreaming_electric_sheep}/server/res/scalar-ui.html (100%) rename {blacksheep => dreaming_electric_sheep}/server/res/swagger-ui.html (100%) rename {blacksheep => dreaming_electric_sheep}/server/resources.py (79%) rename {blacksheep => dreaming_electric_sheep}/server/responses.py (94%) rename {blacksheep => dreaming_electric_sheep}/server/routing.py (100%) rename {blacksheep => dreaming_electric_sheep}/server/security/__init__.py (87%) rename {blacksheep => dreaming_electric_sheep}/server/security/hsts.py (92%) rename {blacksheep => dreaming_electric_sheep}/server/sse.py (87%) rename {blacksheep => dreaming_electric_sheep}/server/websocket.py (97%) rename {blacksheep => dreaming_electric_sheep}/sessions/__init__.py (81%) rename {blacksheep => dreaming_electric_sheep}/sessions/abc.py (98%) rename {blacksheep => dreaming_electric_sheep}/sessions/cookies.py (89%) rename {blacksheep => dreaming_electric_sheep}/sessions/json.py (89%) create mode 100644 dreaming_electric_sheep/sessions/logs.py rename {blacksheep => dreaming_electric_sheep}/sessions/memory.py (91%) create mode 100644 dreaming_electric_sheep/settings/__init__.py rename {blacksheep => dreaming_electric_sheep}/settings/di.py (100%) rename {blacksheep => dreaming_electric_sheep}/settings/encodings.py (100%) rename {blacksheep => dreaming_electric_sheep}/settings/html.py (78%) rename {blacksheep => dreaming_electric_sheep}/settings/json.py (100%) create mode 100644 dreaming_electric_sheep/testing/__init__.py rename {blacksheep => dreaming_electric_sheep}/testing/client.py (93%) rename {blacksheep => dreaming_electric_sheep}/testing/helpers.py (100%) rename {blacksheep => dreaming_electric_sheep}/testing/messages.py (100%) rename {blacksheep => dreaming_electric_sheep}/testing/simulator.py (89%) rename {blacksheep => dreaming_electric_sheep}/testing/websocket.py (97%) rename {blacksheep => dreaming_electric_sheep}/url.pxd (91%) rename {blacksheep => dreaming_electric_sheep}/url.py (100%) rename {blacksheep => dreaming_electric_sheep}/url.pyi (100%) rename {blacksheep => dreaming_electric_sheep}/url.pyx (100%) rename {blacksheep => dreaming_electric_sheep}/utils/__init__.py (100%) rename {blacksheep => dreaming_electric_sheep}/utils/aio.py (100%) rename {blacksheep => dreaming_electric_sheep}/utils/meta.py (100%) rename {blacksheep => dreaming_electric_sheep}/utils/time.py (100%) diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 5f0305cc..0964b558 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -5,6 +5,6 @@ title: '' --- -##### _Note: consider using [Discussions](https://github.com/Neoteroi/BlackSheep/discussions) to open a conversation about new features…_ +##### _Note: consider using [Discussions](https://github.com/EduLoboM/Dreaming-Electric-Sheep/discussions) to open a conversation about new features…_ **🚀 Feature Request** diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 53dbdf19..9a7564b8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -24,7 +24,7 @@ on: - CHANGELOG.md env: - PROJECT_NAME: blacksheep + PROJECT_NAME: dreaming-electric-sheep jobs: windows-validation: @@ -53,14 +53,14 @@ jobs: - name: Compile Cython extensions run: | - cython blacksheep/url.pyx - cython blacksheep/exceptions.pyx - cython blacksheep/headers.pyx - cython blacksheep/cookies.pyx - cython blacksheep/contents.pyx - cython blacksheep/messages.pyx - cython blacksheep/scribe.pyx - cython blacksheep/baseapp.pyx + cython dreaming_electric_sheep/url.pyx + cython dreaming_electric_sheep/exceptions.pyx + cython dreaming_electric_sheep/headers.pyx + cython dreaming_electric_sheep/cookies.pyx + cython dreaming_electric_sheep/contents.pyx + cython dreaming_electric_sheep/messages.pyx + cython dreaming_electric_sheep/scribe.pyx + cython dreaming_electric_sheep/baseapp.pyx python setup.py build_ext --inplace - name: Run tests @@ -128,13 +128,13 @@ jobs: run: | echo "Running linters - if build fails here, please be patient! Feel free to ask for assistance." - flake8 blacksheep + flake8 dreaming_electric_sheep flake8 tests flake8 itests - isort --check-only blacksheep 2>&1 + isort --check-only dreaming_electric_sheep 2>&1 isort --check-only tests 2>&1 isort --check-only itests 2>&1 - black --check blacksheep 2>&1 + black --check dreaming_electric_sheep 2>&1 black --check tests 2>&1 black --check itests 2>&1 @@ -174,7 +174,7 @@ jobs: - name: Test imports with no optional dependencies run: | pip uninstall -y -r <(pip freeze) - BLACKSHEEP_NO_EXTENSIONS="1" pip install -e . + DREAMING_ELECTRIC_SHEEP_NO_EXTENSIONS="1" pip install -e . cd itests echo "Expect no import error…" python issue606.py @@ -212,7 +212,7 @@ jobs: # Before build commands CIBW_BEFORE_BUILD_WINDOWS: > python3 -m pip install setuptools wheel Cython==3.1.4 && - python -c "import os, glob; [os.remove(f) for f in glob.glob('blacksheep/*.c') + glob.glob('blacksheep/*.so')]" && + python -c "import os, glob; [os.remove(f) for f in glob.glob('dreaming_electric_sheep/*.c') + glob.glob('dreaming_electric_sheep/*.so')]" && make compile # Unix-based systems (Linux) @@ -305,7 +305,8 @@ jobs: - name: Build wheels run: | - BLACKSHEEP_NO_EXTENSIONS=1 python -m build --wheel + DREAMING_ELECTRIC_SHEEP_NO_EXTENSIONS=1 python -m build --wheel + - uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index 11d3e032..b8f62e3a 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -1,5 +1,5 @@ #################################################################################### -# Runs benchmarks for BlackSheep source code for various versions of Python +# Runs benchmarks for Dreaming Electric Sheep source code for various versions of Python # and Operating System and publishes the results. # See the perf folder for more information. #################################################################################### @@ -39,14 +39,14 @@ jobs: - name: Compile Cython extensions run: | - cython blacksheep/url.pyx - cython blacksheep/exceptions.pyx - cython blacksheep/headers.pyx - cython blacksheep/cookies.pyx - cython blacksheep/contents.pyx - cython blacksheep/messages.pyx - cython blacksheep/scribe.pyx - cython blacksheep/baseapp.pyx + cython dreaming_electric_sheep/url.pyx + cython dreaming_electric_sheep/exceptions.pyx + cython dreaming_electric_sheep/headers.pyx + cython dreaming_electric_sheep/cookies.pyx + cython dreaming_electric_sheep/contents.pyx + cython dreaming_electric_sheep/messages.pyx + cython dreaming_electric_sheep/scribe.pyx + cython dreaming_electric_sheep/baseapp.pyx python setup.py build_ext --inplace - name: Install dependencies for benchmark diff --git a/.github/workflows/perfhistory.yml b/.github/workflows/perfhistory.yml index 1ca01799..eecb872a 100644 --- a/.github/workflows/perfhistory.yml +++ b/.github/workflows/perfhistory.yml @@ -1,11 +1,11 @@ #################################################################################### -# Runs benchmarks for BlackSheep source code at various points of the commit history, +# Runs benchmarks for Dreaming Electric Sheep source code at various points of the commit history, # on Ubuntu and Windows for a single version of Python. # # This workflow supports both manual and automatic triggers. # If triggered manually, it is possible to select commits hashes or tags to checkout. # -# The minimum supported BlackSheep version by the benchmarks is v2.0.1! +# The minimum supported Dreaming Electric Sheep version by the benchmarks is v2.0.1! #################################################################################### name: HistoryBenchmark diff --git a/.gitignore b/.gitignore index c257a3a1..3ff79313 100644 --- a/.gitignore +++ b/.gitignore @@ -113,9 +113,9 @@ tests/out/ *,cover -blacksheep/*.c -blacksheep/*.html -blacksheep/includes/*.html +dreaming_electric_sheep/*.c +dreaming_electric_sheep/*.html +dreaming_electric_sheep/includes/*.html out/* # Output of integration tests diff --git a/MANIFEST.in b/MANIFEST.in index 7e2451d1..b16138f8 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,9 +1,10 @@ include LICENSE include README.md -include blacksheep/server/res/*.html -include blacksheep/server/res/*.css +include dreaming_electric_sheep/server/res/*.html +include dreaming_electric_sheep/server/res/*.css include build_info.txt -recursive-include blacksheep *.pyx *.pxd *.pxi *.pyi *.py *.c *.h +recursive-include dreaming_electric_sheep *.pyx *.pxd *.pxi *.pyi *.py *.c *.h # Stubs -include blacksheep/py.typed +include dreaming_electric_sheep/py.typed + diff --git a/Makefile b/Makefile index 86d3f85c..ab793005 100644 --- a/Makefile +++ b/Makefile @@ -2,14 +2,14 @@ cyt: - cython blacksheep/url.pyx - cython blacksheep/exceptions.pyx - cython blacksheep/headers.pyx - cython blacksheep/cookies.pyx - cython blacksheep/contents.pyx - cython blacksheep/messages.pyx - cython blacksheep/scribe.pyx - cython blacksheep/baseapp.pyx + cython dreaming_electric_sheep/url.pyx + cython dreaming_electric_sheep/exceptions.pyx + cython dreaming_electric_sheep/headers.pyx + cython dreaming_electric_sheep/cookies.pyx + cython dreaming_electric_sheep/contents.pyx + cython dreaming_electric_sheep/messages.pyx + cython dreaming_electric_sheep/scribe.pyx + cython dreaming_electric_sheep/baseapp.pyx compile: cyt python3 setup.py build_ext --inplace @@ -18,8 +18,8 @@ compile: cyt clean: rm -rf dist/ rm -rf build/ - rm -f blacksheep/*.c - rm -f blacksheep/*.so + rm -f dreaming_electric_sheep/*.c + rm -f dreaming_electric_sheep/*.so buildext: @@ -27,18 +27,18 @@ buildext: annotate: - cython blacksheep/url.pyx -a - cython blacksheep/exceptions.pyx -a - cython blacksheep/headers.pyx -a - cython blacksheep/cookies.pyx -a - cython blacksheep/contents.pyx -a - cython blacksheep/messages.pyx -a - cython blacksheep/scribe.pyx -a - cython blacksheep/baseapp.pyx -a + cython dreaming_electric_sheep/url.pyx -a + cython dreaming_electric_sheep/exceptions.pyx -a + cython dreaming_electric_sheep/headers.pyx -a + cython dreaming_electric_sheep/cookies.pyx -a + cython dreaming_electric_sheep/contents.pyx -a + cython dreaming_electric_sheep/messages.pyx -a + cython dreaming_electric_sheep/scribe.pyx -a + cython dreaming_electric_sheep/baseapp.pyx -a pack: - BLACKSHEEP_NO_EXTENSIONS=1 python -m build --sdist + DREAMING_ELECTRIC_SHEEP_NO_EXTENSIONS=1 python -m build --sdist build: test @@ -74,39 +74,40 @@ test-v: test-cov-unit: - pytest --cov-report html --cov=blacksheep tests + pytest --cov-report html --cov=dreaming_electric_sheep tests test-cov: - pytest --cov-report html --cov=blacksheep --disable-warnings + pytest --cov-report html --cov=dreaming_electric_sheep --disable-warnings lint: check-flake8 check-isort check-black format: - @isort blacksheep 2>&1 + @isort dreaming_electric_sheep 2>&1 @isort tests 2>&1 @isort itests 2>&1 - @black blacksheep 2>&1 + @black dreaming_electric_sheep 2>&1 @black tests 2>&1 @black itests 2>&1 check-flake8: @echo "$(BOLD)Checking flake8$(RESET)" - @flake8 blacksheep 2>&1 + @flake8 dreaming_electric_sheep 2>&1 @flake8 itests 2>&1 @flake8 tests 2>&1 check-isort: @echo "$(BOLD)Checking isort$(RESET)" - @isort --check-only blacksheep 2>&1 + @isort --check-only dreaming_electric_sheep 2>&1 @isort --check-only tests 2>&1 @isort --check-only itests 2>&1 check-black: ## Run the black tool in check mode only (won't modify files) @echo "$(BOLD)Checking black$(RESET)" - @black --check blacksheep 2>&1 + @black --check dreaming_electric_sheep 2>&1 @black --check tests 2>&1 @black --check itests 2>&1 + diff --git a/SECURITY.md b/SECURITY.md index a3bd334b..0db3ff79 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,12 +3,13 @@ ## Supported Versions In general, due to limited maintainer bandwidth, only the latest version of -BlackSheep is supported with patch releases. Exceptions may be made depending +Dreaming Electric Sheep is supported with patch releases. Exceptions may be made depending on the severity of the bug and the feasibility of backporting a fix to older releases. ## Reporting a Vulnerability -BlackSheep uses GitHub's security advisory functionality for private vulnerability +Dreaming Electric Sheep uses GitHub's security advisory functionality for private vulnerability reports. To make a private report, please use the "Report a vulnerability" button at -[https://github.com/Neoteroi/BlackSheep/security/advisories](https://github.com/Neoteroi/BlackSheep/security/advisories). +[https://github.com/EduLoboM/Dreaming-Electric-Sheep/security/advisories](https://github.com/EduLoboM/Dreaming-Electric-Sheep/security/advisories). + diff --git a/blacksheep/sessions/logs.py b/blacksheep/sessions/logs.py deleted file mode 100644 index 0deaba0f..00000000 --- a/blacksheep/sessions/logs.py +++ /dev/null @@ -1,10 +0,0 @@ -import logging - - -def get_logger(): - """ - Returns a "blacksheep.sessions" logger. - """ - logger = logging.getLogger("blacksheep.sessions") - logger.setLevel(logging.INFO) - return logger diff --git a/blacksheep/settings/__init__.py b/blacksheep/settings/__init__.py deleted file mode 100644 index 6b0004a7..00000000 --- a/blacksheep/settings/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -This package contains global settings for a BlackSheep application. -""" diff --git a/blacksheep/testing/__init__.py b/blacksheep/testing/__init__.py deleted file mode 100644 index f3896da3..00000000 --- a/blacksheep/testing/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -from blacksheep.contents import FormContent, JSONContent, TextContent -from blacksheep.testing.client import TestClient -from blacksheep.testing.messages import MockReceive, MockSend -from blacksheep.testing.simulator import AbstractTestSimulator - -__all__ = [ - "TestClient", - "AbstractTestSimulator", - "JSONContent", - "TextContent", - "FormContent", - "MockReceive", - "MockSend", -] diff --git a/dreaming_electric_sheep.png b/dreaming_electric_sheep.png new file mode 100644 index 0000000000000000000000000000000000000000..5b63c1d65e90e3f91166d6ec1c1a9b458ef1c2c7 GIT binary patch literal 23874 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 literal 0 HcmV?d00001 diff --git a/blacksheep.svg b/dreaming_electric_sheep.svg similarity index 100% rename from blacksheep.svg rename to dreaming_electric_sheep.svg diff --git a/blacksheep/__init__.py b/dreaming_electric_sheep/__init__.py similarity index 100% rename from blacksheep/__init__.py rename to dreaming_electric_sheep/__init__.py diff --git a/blacksheep/asgi.pyi b/dreaming_electric_sheep/asgi.pyi similarity index 100% rename from blacksheep/asgi.pyi rename to dreaming_electric_sheep/asgi.pyi diff --git a/blacksheep/baseapp.pxd b/dreaming_electric_sheep/baseapp.pxd similarity index 89% rename from blacksheep/baseapp.pxd rename to dreaming_electric_sheep/baseapp.pxd index 3a7d41b6..de06cfc6 100644 --- a/blacksheep/baseapp.pxd +++ b/dreaming_electric_sheep/baseapp.pxd @@ -1,7 +1,7 @@ # cython: language_level=3, embedsignature=True # Copyright (C) 2018-present Roberto Prevato # -# This module is part of BlackSheep and is released under +# This module is part of Dreaming Electric Sheep and is released under # the MIT License https://opensource.org/licenses/MIT from .exceptions cimport HTTPException diff --git a/blacksheep/baseapp.py b/dreaming_electric_sheep/baseapp.py similarity index 100% rename from blacksheep/baseapp.py rename to dreaming_electric_sheep/baseapp.py diff --git a/blacksheep/baseapp.pyi b/dreaming_electric_sheep/baseapp.pyi similarity index 86% rename from blacksheep/baseapp.pyi rename to dreaming_electric_sheep/baseapp.pyi index f6694e17..dea34cc2 100644 --- a/blacksheep/baseapp.pyi +++ b/dreaming_electric_sheep/baseapp.pyi @@ -1,10 +1,10 @@ import logging from typing import Awaitable, Callable, Type, TypeVar -from blacksheep.exceptions import HTTPException -from blacksheep.messages import Request, Response -from blacksheep.server.application import Application -from blacksheep.server.routing import RouteMatch, Router +from dreaming_electric_sheep.exceptions import HTTPException +from dreaming_electric_sheep.messages import Request, Response +from dreaming_electric_sheep.server.application import Application +from dreaming_electric_sheep.server.routing import RouteMatch, Router ExcT = TypeVar("ExcT", bound=Exception) diff --git a/blacksheep/baseapp.pyx b/dreaming_electric_sheep/baseapp.pyx similarity index 100% rename from blacksheep/baseapp.pyx rename to dreaming_electric_sheep/baseapp.pyx diff --git a/blacksheep/client/__init__.py b/dreaming_electric_sheep/client/__init__.py similarity index 100% rename from blacksheep/client/__init__.py rename to dreaming_electric_sheep/client/__init__.py diff --git a/blacksheep/client/connection.py b/dreaming_electric_sheep/client/connection.py similarity index 99% rename from blacksheep/client/connection.py rename to dreaming_electric_sheep/client/connection.py index 80f3b6d8..e8f00cca 100644 --- a/blacksheep/client/connection.py +++ b/dreaming_electric_sheep/client/connection.py @@ -19,7 +19,7 @@ WindowUpdated, ) -from blacksheep import Content, Request, Response, StreamedContent +from dreaming_electric_sheep import Content, Request, Response, StreamedContent # Compatibility for asyncio.timeout (added in Python 3.11) if sys.version_info >= (3, 11): @@ -363,7 +363,7 @@ async def connect(self) -> None: self._connected = True def _convert_request_to_h2_headers(self, request: Request) -> list[tuple[str, str]]: - """Convert a BlackSheep Request to HTTP/2 pseudo-headers and headers.""" + """Convert a Dreaming Electric Sheep Request to HTTP/2 pseudo-headers and headers.""" # HTTP/2 pseudo-headers path = request.url.path or b"/" if request.url.query: @@ -408,7 +408,7 @@ async def send(self, request: Request) -> Response: Send an HTTP request over HTTP/2 and return the response. Args: - request: The BlackSheep Request object to send + request: The Dreaming Electric Sheep Request object to send Returns: Response object @@ -672,7 +672,7 @@ async def _receive_response( timeout: Timeout in seconds for headers Returns: - BlackSheep Response object with streaming content + Dreaming Electric Sheep Response object with streaming content """ headers_event = self._headers_events[stream_id] @@ -696,7 +696,7 @@ async def _receive_response( raise ConnectionClosedError(True) raise ConnectionException(stream.error) - # Convert to BlackSheep Response + # Convert to Dreaming Electric Sheep Response response_headers = [] for name, value in stream.headers: if isinstance(name, str): @@ -947,7 +947,7 @@ async def connect(self) -> None: def _convert_request_to_h11( self, request: Request ) -> tuple[h11.Request, bytes | None]: - """Convert a BlackSheep Request to h11 request.""" + """Convert a Dreaming Electric Sheep Request to h11 request.""" # Build request target (path + query) path = request.url.path or b"/" if request.url.query: @@ -1031,7 +1031,7 @@ async def send(self, request: Request) -> Response: Send an HTTP request over HTTP/1.1 and return the response. Args: - request: The BlackSheep Request object to send + request: The Dreaming Electric Sheep Request object to send Returns: Response object diff --git a/blacksheep/client/cookies.py b/dreaming_electric_sheep/client/cookies.py similarity index 98% rename from blacksheep/client/cookies.py rename to dreaming_electric_sheep/client/cookies.py index af0e8d77..910ed015 100644 --- a/blacksheep/client/cookies.py +++ b/dreaming_electric_sheep/client/cookies.py @@ -3,10 +3,10 @@ from ipaddress import ip_address from typing import Iterable, TypeVar -from blacksheep import URL, Cookie -from blacksheep.utils.time import MIN_DATETIME, utcnow +from dreaming_electric_sheep import URL, Cookie +from dreaming_electric_sheep.utils.time import MIN_DATETIME, utcnow -client_logger = logging.getLogger("blacksheep.client") +client_logger = logging.getLogger("dreaming_electric_sheep.client") class InvalidCookie(Exception): diff --git a/blacksheep/client/exceptions.py b/dreaming_electric_sheep/client/exceptions.py similarity index 94% rename from blacksheep/client/exceptions.py rename to dreaming_electric_sheep/client/exceptions.py index 4b75d450..c9c1e75b 100644 --- a/blacksheep/client/exceptions.py +++ b/dreaming_electric_sheep/client/exceptions.py @@ -1,8 +1,8 @@ -from blacksheep import URL +from dreaming_electric_sheep import URL class ClientException(Exception): - """Base class for BlackSheep Client Exceptions.""" + """Base class for Dreaming Electric Sheep Client Exceptions.""" class InvalidResponseException(ClientException): diff --git a/blacksheep/client/pool.py b/dreaming_electric_sheep/client/pool.py similarity index 98% rename from blacksheep/client/pool.py rename to dreaming_electric_sheep/client/pool.py index 9efded12..602ea3ac 100644 --- a/blacksheep/client/pool.py +++ b/dreaming_electric_sheep/client/pool.py @@ -6,7 +6,7 @@ from ssl import SSLContext from typing import Literal -from blacksheep.exceptions import InvalidArgument +from dreaming_electric_sheep.exceptions import InvalidArgument from .connection import ( INSECURE_HTTP2_SSLCONTEXT, @@ -18,7 +18,7 @@ HTTPConnection, ) -logger = logging.getLogger("blacksheep.client") +logger = logging.getLogger("dreaming_electric_sheep.client") def get_ssl_context( diff --git a/blacksheep/client/session.py b/dreaming_electric_sheep/client/session.py similarity index 98% rename from blacksheep/client/session.py rename to dreaming_electric_sheep/client/session.py index fef90a2d..63ce8837 100644 --- a/blacksheep/client/session.py +++ b/dreaming_electric_sheep/client/session.py @@ -4,9 +4,9 @@ from typing import Any, AnyStr, Callable, Type, cast from urllib.parse import quote, urlencode -from blacksheep import URL, Content, InvalidURL, Request, Response, __version__ -from blacksheep.common.types import HeadersType, ParamsType, URLType, normalize_headers -from blacksheep.middlewares import MiddlewareList, get_middlewares_chain +from dreaming_electric_sheep import URL, Content, InvalidURL, Request, Response, __version__ +from dreaming_electric_sheep.common.types import HeadersType, ParamsType, URLType, normalize_headers +from dreaming_electric_sheep.middlewares import MiddlewareList, get_middlewares_chain from .connection import ConnectionClosedError from .cookies import CookieJar, cookies_middleware diff --git a/blacksheep/common/__init__.py b/dreaming_electric_sheep/common/__init__.py similarity index 100% rename from blacksheep/common/__init__.py rename to dreaming_electric_sheep/common/__init__.py diff --git a/blacksheep/common/files/__init__.py b/dreaming_electric_sheep/common/files/__init__.py similarity index 100% rename from blacksheep/common/files/__init__.py rename to dreaming_electric_sheep/common/files/__init__.py diff --git a/blacksheep/common/files/asyncfs.py b/dreaming_electric_sheep/common/files/asyncfs.py similarity index 98% rename from blacksheep/common/files/asyncfs.py rename to dreaming_electric_sheep/common/files/asyncfs.py index f7308c9c..167fdaa6 100644 --- a/blacksheep/common/files/asyncfs.py +++ b/dreaming_electric_sheep/common/files/asyncfs.py @@ -2,7 +2,7 @@ from concurrent.futures.thread import ThreadPoolExecutor from typing import IO, Any, AnyStr, AsyncIterable, Callable, Union -from blacksheep.utils.aio import get_running_loop +from dreaming_electric_sheep.utils.aio import get_running_loop class PoolClient: diff --git a/blacksheep/common/files/info.py b/dreaming_electric_sheep/common/files/info.py similarity index 100% rename from blacksheep/common/files/info.py rename to dreaming_electric_sheep/common/files/info.py diff --git a/blacksheep/common/files/pathsutils.py b/dreaming_electric_sheep/common/files/pathsutils.py similarity index 100% rename from blacksheep/common/files/pathsutils.py rename to dreaming_electric_sheep/common/files/pathsutils.py diff --git a/blacksheep/common/types.py b/dreaming_electric_sheep/common/types.py similarity index 96% rename from blacksheep/common/types.py rename to dreaming_electric_sheep/common/types.py index 738d5534..4e06d95d 100644 --- a/blacksheep/common/types.py +++ b/dreaming_electric_sheep/common/types.py @@ -4,7 +4,7 @@ from typing import AnyStr, Iterable -from blacksheep.url import URL +from dreaming_electric_sheep.url import URL KeyValuePair = tuple[AnyStr, AnyStr] HeadersType = dict[AnyStr, AnyStr] | Iterable[KeyValuePair] diff --git a/blacksheep/contents.pxd b/dreaming_electric_sheep/contents.pxd similarity index 95% rename from blacksheep/contents.pxd rename to dreaming_electric_sheep/contents.pxd index 90a80332..b19e8307 100644 --- a/blacksheep/contents.pxd +++ b/dreaming_electric_sheep/contents.pxd @@ -1,7 +1,7 @@ # cython: language_level=3, embedsignature=True # Copyright (C) 2018-present Roberto Prevato # -# This module is part of BlackSheep and is released under +# This module is part of Dreaming Electric Sheep and is released under # the MIT License https://opensource.org/licenses/MIT diff --git a/blacksheep/contents.py b/dreaming_electric_sheep/contents.py similarity index 100% rename from blacksheep/contents.py rename to dreaming_electric_sheep/contents.py diff --git a/blacksheep/contents.pyi b/dreaming_electric_sheep/contents.pyi similarity index 100% rename from blacksheep/contents.pyi rename to dreaming_electric_sheep/contents.pyi diff --git a/blacksheep/contents.pyx b/dreaming_electric_sheep/contents.pyx similarity index 100% rename from blacksheep/contents.pyx rename to dreaming_electric_sheep/contents.pyx diff --git a/blacksheep/cookies.pxd b/dreaming_electric_sheep/cookies.pxd similarity index 92% rename from blacksheep/cookies.pxd rename to dreaming_electric_sheep/cookies.pxd index 22d78b95..5ccb908e 100644 --- a/blacksheep/cookies.pxd +++ b/dreaming_electric_sheep/cookies.pxd @@ -1,7 +1,7 @@ # cython: language_level=3, embedsignature=True # Copyright (C) 2018-present Roberto Prevato # -# This module is part of BlackSheep and is released under +# This module is part of Dreaming Electric Sheep and is released under # the MIT License https://opensource.org/licenses/MIT from cpython.datetime cimport datetime diff --git a/blacksheep/cookies.py b/dreaming_electric_sheep/cookies.py similarity index 100% rename from blacksheep/cookies.py rename to dreaming_electric_sheep/cookies.py diff --git a/blacksheep/cookies.pyi b/dreaming_electric_sheep/cookies.pyi similarity index 100% rename from blacksheep/cookies.pyi rename to dreaming_electric_sheep/cookies.pyi diff --git a/blacksheep/cookies.pyx b/dreaming_electric_sheep/cookies.pyx similarity index 100% rename from blacksheep/cookies.pyx rename to dreaming_electric_sheep/cookies.pyx diff --git a/blacksheep/exceptions.pxd b/dreaming_electric_sheep/exceptions.pxd similarity index 100% rename from blacksheep/exceptions.pxd rename to dreaming_electric_sheep/exceptions.pxd diff --git a/blacksheep/exceptions.py b/dreaming_electric_sheep/exceptions.py similarity index 100% rename from blacksheep/exceptions.py rename to dreaming_electric_sheep/exceptions.py diff --git a/blacksheep/exceptions.pyi b/dreaming_electric_sheep/exceptions.pyi similarity index 100% rename from blacksheep/exceptions.pyi rename to dreaming_electric_sheep/exceptions.pyi diff --git a/blacksheep/exceptions.pyx b/dreaming_electric_sheep/exceptions.pyx similarity index 100% rename from blacksheep/exceptions.pyx rename to dreaming_electric_sheep/exceptions.pyx diff --git a/blacksheep/headers.pxd b/dreaming_electric_sheep/headers.pxd similarity index 91% rename from blacksheep/headers.pxd rename to dreaming_electric_sheep/headers.pxd index cdfd6d20..2cacdb37 100644 --- a/blacksheep/headers.pxd +++ b/dreaming_electric_sheep/headers.pxd @@ -1,7 +1,7 @@ # cython: language_level=3, embedsignature=True # Copyright (C) 2018-present Roberto Prevato # -# This module is part of BlackSheep and is released under +# This module is part of Dreaming Electric Sheep and is released under # the MIT License https://opensource.org/licenses/MIT diff --git a/blacksheep/headers.py b/dreaming_electric_sheep/headers.py similarity index 100% rename from blacksheep/headers.py rename to dreaming_electric_sheep/headers.py diff --git a/blacksheep/headers.pyi b/dreaming_electric_sheep/headers.pyi similarity index 100% rename from blacksheep/headers.pyi rename to dreaming_electric_sheep/headers.pyi diff --git a/blacksheep/headers.pyx b/dreaming_electric_sheep/headers.pyx similarity index 100% rename from blacksheep/headers.pyx rename to dreaming_electric_sheep/headers.pyx diff --git a/blacksheep/messages.pxd b/dreaming_electric_sheep/messages.pxd similarity index 96% rename from blacksheep/messages.pxd rename to dreaming_electric_sheep/messages.pxd index d444f05c..70b1b4ef 100644 --- a/blacksheep/messages.pxd +++ b/dreaming_electric_sheep/messages.pxd @@ -1,7 +1,7 @@ # cython: language_level=3, embedsignature=True # Copyright (C) 2018-present Roberto Prevato # -# This module is part of BlackSheep and is released under +# This module is part of Dreaming Electric Sheep and is released under # the MIT License https://opensource.org/licenses/MIT from .contents cimport Content, parse_www_form_urlencoded diff --git a/blacksheep/messages.py b/dreaming_electric_sheep/messages.py similarity index 100% rename from blacksheep/messages.py rename to dreaming_electric_sheep/messages.py diff --git a/blacksheep/messages.pyi b/dreaming_electric_sheep/messages.pyi similarity index 100% rename from blacksheep/messages.pyi rename to dreaming_electric_sheep/messages.pyi diff --git a/blacksheep/messages.pyx b/dreaming_electric_sheep/messages.pyx similarity index 100% rename from blacksheep/messages.pyx rename to dreaming_electric_sheep/messages.pyx diff --git a/blacksheep/middlewares.py b/dreaming_electric_sheep/middlewares.py similarity index 97% rename from blacksheep/middlewares.py rename to dreaming_electric_sheep/middlewares.py index 3c157508..92f65443 100644 --- a/blacksheep/middlewares.py +++ b/dreaming_electric_sheep/middlewares.py @@ -6,8 +6,8 @@ from enum import Enum from typing import Awaitable, Callable, Iterable, overload -from blacksheep.messages import Response -from blacksheep.normalization import copy_special_attributes +from dreaming_electric_sheep.messages import Response +from dreaming_electric_sheep.normalization import copy_special_attributes def middleware_partial(handler, next_handler): diff --git a/blacksheep/multipart.py b/dreaming_electric_sheep/multipart.py similarity index 99% rename from blacksheep/multipart.py rename to dreaming_electric_sheep/multipart.py index ea4867eb..a3170bde 100644 --- a/blacksheep/multipart.py +++ b/dreaming_electric_sheep/multipart.py @@ -1,7 +1,7 @@ import warnings from typing import AsyncIterable, Generator, Iterable -from blacksheep.contents import FormPart, StreamedFormPart +from dreaming_electric_sheep.contents import FormPart, StreamedFormPart def get_boundary_from_header(value: bytes) -> bytes: diff --git a/blacksheep/normalization.py b/dreaming_electric_sheep/normalization.py similarity index 100% rename from blacksheep/normalization.py rename to dreaming_electric_sheep/normalization.py diff --git a/blacksheep/py.typed b/dreaming_electric_sheep/py.typed similarity index 100% rename from blacksheep/py.typed rename to dreaming_electric_sheep/py.typed diff --git a/blacksheep/ranges.py b/dreaming_electric_sheep/ranges.py similarity index 100% rename from blacksheep/ranges.py rename to dreaming_electric_sheep/ranges.py diff --git a/blacksheep/scribe.pxd b/dreaming_electric_sheep/scribe.pxd similarity index 84% rename from blacksheep/scribe.pxd rename to dreaming_electric_sheep/scribe.pxd index 3198c4a1..01b0057c 100644 --- a/blacksheep/scribe.pxd +++ b/dreaming_electric_sheep/scribe.pxd @@ -1,7 +1,7 @@ # cython: language_level=3 # Copyright (C) 2018-present Roberto Prevato # -# This module is part of BlackSheep and is released under +# This module is part of Dreaming Electric Sheep and is released under # the MIT License https://opensource.org/licenses/MIT from .contents cimport Content, ServerSentEvent diff --git a/blacksheep/scribe.py b/dreaming_electric_sheep/scribe.py similarity index 100% rename from blacksheep/scribe.py rename to dreaming_electric_sheep/scribe.py diff --git a/blacksheep/scribe.pyi b/dreaming_electric_sheep/scribe.pyi similarity index 55% rename from blacksheep/scribe.pyi rename to dreaming_electric_sheep/scribe.pyi index 1668f2a9..73032a00 100644 --- a/blacksheep/scribe.pyi +++ b/dreaming_electric_sheep/scribe.pyi @@ -1,8 +1,8 @@ from typing import AsyncIterable, Callable -from blacksheep.contents import Content, ServerSentEvent -from blacksheep.cookies import Cookie -from blacksheep.messages import Request, Response +from dreaming_electric_sheep.contents import Content, ServerSentEvent +from dreaming_electric_sheep.cookies import Cookie +from dreaming_electric_sheep.messages import Request, Response def write_chunks(content: Content) -> AsyncIterable[bytes]: ... async def send_asgi_response(response: Response, send: Callable): ... diff --git a/blacksheep/scribe.pyx b/dreaming_electric_sheep/scribe.pyx similarity index 100% rename from blacksheep/scribe.pyx rename to dreaming_electric_sheep/scribe.pyx diff --git a/blacksheep/server/__init__.py b/dreaming_electric_sheep/server/__init__.py similarity index 100% rename from blacksheep/server/__init__.py rename to dreaming_electric_sheep/server/__init__.py diff --git a/blacksheep/server/application.py b/dreaming_electric_sheep/server/application.py similarity index 100% rename from blacksheep/server/application.py rename to dreaming_electric_sheep/server/application.py diff --git a/blacksheep/server/asgi.py b/dreaming_electric_sheep/server/asgi.py similarity index 100% rename from blacksheep/server/asgi.py rename to dreaming_electric_sheep/server/asgi.py diff --git a/blacksheep/server/authentication/__init__.py b/dreaming_electric_sheep/server/authentication/__init__.py similarity index 97% rename from blacksheep/server/authentication/__init__.py rename to dreaming_electric_sheep/server/authentication/__init__.py index 1a496122..a78c79aa 100644 --- a/blacksheep/server/authentication/__init__.py +++ b/dreaming_electric_sheep/server/authentication/__init__.py @@ -5,7 +5,7 @@ RateLimitExceededError, ) -from blacksheep import Response, TextContent +from dreaming_electric_sheep import Response, TextContent __all__ = ( "AuthenticationStrategy", diff --git a/blacksheep/server/authentication/apikey.py b/dreaming_electric_sheep/server/authentication/apikey.py similarity index 99% rename from blacksheep/server/authentication/apikey.py rename to dreaming_electric_sheep/server/authentication/apikey.py index dc2dcde1..239f73a1 100644 --- a/blacksheep/server/authentication/apikey.py +++ b/dreaming_electric_sheep/server/authentication/apikey.py @@ -11,7 +11,7 @@ from guardpost import AuthenticationHandler, Identity from guardpost.errors import InvalidCredentialsError -from blacksheep.messages import Request +from dreaming_electric_sheep.messages import Request class APIKeyLocation(Enum): diff --git a/blacksheep/server/authentication/basic.py b/dreaming_electric_sheep/server/authentication/basic.py similarity index 99% rename from blacksheep/server/authentication/basic.py rename to dreaming_electric_sheep/server/authentication/basic.py index abd48a65..3b114d67 100644 --- a/blacksheep/server/authentication/basic.py +++ b/dreaming_electric_sheep/server/authentication/basic.py @@ -11,7 +11,7 @@ from guardpost import AuthenticationHandler, Identity from guardpost.errors import InvalidCredentialsError -from blacksheep.messages import Request +from dreaming_electric_sheep.messages import Request class BasicCredentials: diff --git a/blacksheep/server/authentication/cookie.py b/dreaming_electric_sheep/server/authentication/cookie.py similarity index 94% rename from blacksheep/server/authentication/cookie.py rename to dreaming_electric_sheep/server/authentication/cookie.py index 3a9ac4b4..26f576ee 100644 --- a/blacksheep/server/authentication/cookie.py +++ b/dreaming_electric_sheep/server/authentication/cookie.py @@ -17,11 +17,11 @@ from itsdangerous import Serializer from itsdangerous.exc import BadSignature -from blacksheep.baseapp import get_logger -from blacksheep.cookies import Cookie -from blacksheep.messages import Request, Response -from blacksheep.server.dataprotection import get_serializer -from blacksheep.utils import ensure_str +from dreaming_electric_sheep.baseapp import get_logger +from dreaming_electric_sheep.cookies import Cookie +from dreaming_electric_sheep.messages import Request, Response +from dreaming_electric_sheep.server.dataprotection import get_serializer +from dreaming_electric_sheep.utils import ensure_str class CookieAuthentication(AuthenticationHandler): diff --git a/blacksheep/server/authentication/jwt.py b/dreaming_electric_sheep/server/authentication/jwt.py similarity index 98% rename from blacksheep/server/authentication/jwt.py rename to dreaming_electric_sheep/server/authentication/jwt.py index cbea8d75..c18c8bdb 100644 --- a/blacksheep/server/authentication/jwt.py +++ b/dreaming_electric_sheep/server/authentication/jwt.py @@ -17,8 +17,8 @@ ) from jwt.exceptions import InvalidTokenError -from blacksheep.baseapp import get_logger -from blacksheep.messages import Request +from dreaming_electric_sheep.baseapp import get_logger +from dreaming_electric_sheep.messages import Request class JWTBearerAuthentication(AuthenticationHandler): diff --git a/blacksheep/server/authentication/oidc.py b/dreaming_electric_sheep/server/authentication/oidc.py similarity index 97% rename from blacksheep/server/authentication/oidc.py rename to dreaming_electric_sheep/server/authentication/oidc.py index 98a452ab..e65d58d4 100644 --- a/blacksheep/server/authentication/oidc.py +++ b/dreaming_electric_sheep/server/authentication/oidc.py @@ -23,27 +23,27 @@ from itsdangerous.exc import BadSignature from jwt import InvalidTokenError -from blacksheep.cookies import Cookie -from blacksheep.exceptions import BadRequest, Unauthorized -from blacksheep.messages import Request, Response, get_absolute_url_to_path -from blacksheep.middlewares import MiddlewareCategory -from blacksheep.server.application import Application, ApplicationEvent -from blacksheep.server.authentication.cookie import CookieAuthentication -from blacksheep.server.authentication.jwt import JWTBearerAuthentication -from blacksheep.server.authorization import allow_anonymous -from blacksheep.server.dataprotection import ( +from dreaming_electric_sheep.cookies import Cookie +from dreaming_electric_sheep.exceptions import BadRequest, Unauthorized +from dreaming_electric_sheep.messages import Request, Response, get_absolute_url_to_path +from dreaming_electric_sheep.middlewares import MiddlewareCategory +from dreaming_electric_sheep.server.application import Application, ApplicationEvent +from dreaming_electric_sheep.server.authentication.cookie import CookieAuthentication +from dreaming_electric_sheep.server.authentication.jwt import JWTBearerAuthentication +from dreaming_electric_sheep.server.authorization import allow_anonymous +from dreaming_electric_sheep.server.dataprotection import ( generate_secret, get_serializer, issue_deprecation_warning_for_secret_str, ) -from blacksheep.server.headers.cache import cache_control -from blacksheep.server.responses import accepted, bad_request, html, json, ok, redirect -from blacksheep.utils import ensure_str -from blacksheep.utils.aio import FailedRequestError, HTTPHandler +from dreaming_electric_sheep.server.headers.cache import cache_control +from dreaming_electric_sheep.server.responses import accepted, bad_request, html, json, ok, redirect +from dreaming_electric_sheep.utils import ensure_str +from dreaming_electric_sheep.utils.aio import FailedRequestError, HTTPHandler def get_logger() -> logging.Logger: - logger = logging.getLogger("blacksheep.oidc") + logger = logging.getLogger("dreaming_electric_sheep.oidc") logger.setLevel(logging.INFO) return logger @@ -1134,7 +1134,7 @@ def use_openid_connect( Lets specify the object that is responsible of handling responses to communicate tokens to the client and restoring user context for web requests. If not specified, the default CookiesOpenIDTokensHandler class is used. - BlackSheep offers two kinds of this class out of the box: one that uses cookies + Dreaming Electric Sheep offers two kinds of this class out of the box: one that uses cookies and, for more advanced situations, one that uses the HTML5 storage API to set tokens on the client side. See [the OIDC examples](https://github.com/Neoteroi/BlackSheep-Examples/tree/main/oidc) diff --git a/blacksheep/server/authorization/__init__.py b/dreaming_electric_sheep/server/authorization/__init__.py similarity index 98% rename from blacksheep/server/authorization/__init__.py rename to dreaming_electric_sheep/server/authorization/__init__.py index bc94e62a..7257bec6 100644 --- a/blacksheep/server/authorization/__init__.py +++ b/dreaming_electric_sheep/server/authorization/__init__.py @@ -7,7 +7,7 @@ UnauthorizedError, ) -from blacksheep import Request, Response, TextContent +from dreaming_electric_sheep import Request, Response, TextContent __all__ = ( "auth", diff --git a/blacksheep/server/bindings/__init__.py b/dreaming_electric_sheep/server/bindings/__init__.py similarity index 100% rename from blacksheep/server/bindings/__init__.py rename to dreaming_electric_sheep/server/bindings/__init__.py diff --git a/blacksheep/server/bindings/converters.py b/dreaming_electric_sheep/server/bindings/converters.py similarity index 100% rename from blacksheep/server/bindings/converters.py rename to dreaming_electric_sheep/server/bindings/converters.py diff --git a/blacksheep/server/bindings/dates.py b/dreaming_electric_sheep/server/bindings/dates.py similarity index 100% rename from blacksheep/server/bindings/dates.py rename to dreaming_electric_sheep/server/bindings/dates.py diff --git a/blacksheep/server/compression.py b/dreaming_electric_sheep/server/compression.py similarity index 95% rename from blacksheep/server/compression.py rename to dreaming_electric_sheep/server/compression.py index ce3cb5c3..f91ae36f 100644 --- a/blacksheep/server/compression.py +++ b/dreaming_electric_sheep/server/compression.py @@ -3,9 +3,9 @@ from concurrent.futures import Executor from typing import Awaitable, Callable, Iterable -from blacksheep import Content, Request, Response -from blacksheep.server.application import Application -from blacksheep.server.normalization import ensure_response +from dreaming_electric_sheep import Content, Request, Response +from dreaming_electric_sheep.server.application import Application +from dreaming_electric_sheep.server.normalization import ensure_response class GzipMiddleware: diff --git a/blacksheep/server/controllers.py b/dreaming_electric_sheep/server/controllers.py similarity index 96% rename from blacksheep/server/controllers.py rename to dreaming_electric_sheep/server/controllers.py index 212f6875..de597f49 100644 --- a/blacksheep/server/controllers.py +++ b/dreaming_electric_sheep/server/controllers.py @@ -9,10 +9,10 @@ Type, ) -from blacksheep.common.types import HeadersType, ParamsType -from blacksheep.messages import Request, Response -from blacksheep.server.bindings import ControllerParameter -from blacksheep.server.responses import ( +from dreaming_electric_sheep.common.types import HeadersType, ParamsType +from dreaming_electric_sheep.messages import Request, Response +from dreaming_electric_sheep.server.bindings import ControllerParameter +from dreaming_electric_sheep.server.responses import ( ContentDispositionType, MessageType, accepted, @@ -37,18 +37,18 @@ view, view_async, ) -from blacksheep.server.routing import ( +from dreaming_electric_sheep.server.routing import ( RegisteredRoute, RouteFilter, Router, ) -from blacksheep.server.routing import RoutesRegistry as RoutesRegistry # noqa -from blacksheep.server.routing import ( +from dreaming_electric_sheep.server.routing import RoutesRegistry as RoutesRegistry # noqa +from dreaming_electric_sheep.server.routing import ( controllers_routes, normalize_filters, ) -from blacksheep.utils import AnyStr, ensure_bytes, join_fragments -from blacksheep.utils.meta import all_subclasses, clonefunc +from dreaming_electric_sheep.utils import AnyStr, ensure_bytes, join_fragments +from dreaming_electric_sheep.utils.meta import all_subclasses, clonefunc # singleton router used to store initial configuration, # before the application starts diff --git a/blacksheep/server/cors.py b/dreaming_electric_sheep/server/cors.py similarity index 97% rename from blacksheep/server/cors.py rename to dreaming_electric_sheep/server/cors.py index c9150570..97b3a05a 100644 --- a/blacksheep/server/cors.py +++ b/dreaming_electric_sheep/server/cors.py @@ -2,10 +2,10 @@ from functools import lru_cache from typing import Any, Awaitable, Callable, FrozenSet, Iterable -from blacksheep.baseapp import BaseApplication -from blacksheep.messages import Request, Response -from blacksheep.server.routing import Route, Router -from blacksheep.server.websocket import WebSocket +from dreaming_electric_sheep.baseapp import BaseApplication +from dreaming_electric_sheep.messages import Request, Response +from dreaming_electric_sheep.server.routing import Route, Router +from dreaming_electric_sheep.server.websocket import WebSocket from .responses import not_found, ok, status_code diff --git a/blacksheep/server/csrf.py b/dreaming_electric_sheep/server/csrf.py similarity index 95% rename from blacksheep/server/csrf.py rename to dreaming_electric_sheep/server/csrf.py index 476af81d..19f73afb 100644 --- a/blacksheep/server/csrf.py +++ b/dreaming_electric_sheep/server/csrf.py @@ -5,15 +5,15 @@ from itsdangerous import Serializer from itsdangerous.exc import BadSignature -from blacksheep.baseapp import get_logger -from blacksheep.cookies import Cookie -from blacksheep.exceptions import Unauthorized -from blacksheep.messages import Request, Response -from blacksheep.middlewares import MiddlewareCategory -from blacksheep.server.application import Application -from blacksheep.server.dataprotection import generate_secret, get_serializer -from blacksheep.server.security import SecurityPolicyHandler -from blacksheep.settings.html import html_settings +from dreaming_electric_sheep.baseapp import get_logger +from dreaming_electric_sheep.cookies import Cookie +from dreaming_electric_sheep.exceptions import Unauthorized +from dreaming_electric_sheep.messages import Request, Response +from dreaming_electric_sheep.middlewares import MiddlewareCategory +from dreaming_electric_sheep.server.application import Application +from dreaming_electric_sheep.server.dataprotection import generate_secret, get_serializer +from dreaming_electric_sheep.server.security import SecurityPolicyHandler +from dreaming_electric_sheep.settings.html import html_settings class AntiForgeryTokenError(Unauthorized): diff --git a/blacksheep/server/dataprotection.py b/dreaming_electric_sheep/server/dataprotection.py similarity index 92% rename from blacksheep/server/dataprotection.py rename to dreaming_electric_sheep/server/dataprotection.py index 4ae0c6df..0f8d5044 100644 --- a/blacksheep/server/dataprotection.py +++ b/dreaming_electric_sheep/server/dataprotection.py @@ -7,7 +7,7 @@ from itsdangerous import Serializer from itsdangerous.url_safe import URLSafeSerializer -from blacksheep.baseapp import get_logger +from dreaming_electric_sheep.baseapp import get_logger logger = get_logger() @@ -22,7 +22,7 @@ def get_keys() -> list[str]: This function first attempts to load secret keys from environment variables. By default, it looks for variables prefixed with 'APP_SECRET' (e.g., APP_SECRET_1, - APP_SECRET_2). The prefix can be customized using the BLACKSHEEP_SECRET_PREFIX + APP_SECRET_2). The prefix can be customized using the DREAMING_ELECTRIC_SHEEP_SECRET_PREFIX environment variable. If no environment variables are found, the function generates three new random @@ -45,7 +45,7 @@ def get_keys() -> list[str]: export APP_SECRET_2="your-secret-key-2" # Or use a custom prefix - export BLACKSHEEP_SECRET_PREFIX="MY_SECRET" + export DREAMING_ELECTRIC_SHEEP_SECRET_PREFIX="MY_SECRET" export MY_SECRET_1="your-secret-key-1" """ # if there are environmental variables with keys, use them; @@ -54,7 +54,7 @@ def get_keys() -> list[str]: # APP_SECRET_2="***" # APP_SECRET_3="***" app_secrets = [] - env_var_key_prefix = os.environ.get("BLACKSHEEP_SECRET_PREFIX", "APP_SECRET") + env_var_key_prefix = os.environ.get("DREAMING_ELECTRIC_SHEEP_SECRET_PREFIX", "APP_SECRET") for key, value in os.environ.items(): if key.startswith(env_var_key_prefix) or key.startswith( diff --git a/blacksheep/server/di.py b/dreaming_electric_sheep/server/di.py similarity index 89% rename from blacksheep/server/di.py rename to dreaming_electric_sheep/server/di.py index db4cb8f0..daff26b6 100644 --- a/blacksheep/server/di.py +++ b/dreaming_electric_sheep/server/di.py @@ -2,11 +2,11 @@ from rodi import ActivationScope, Container -from blacksheep.messages import Request, Response -from blacksheep.middlewares import MiddlewareCategory +from dreaming_electric_sheep.messages import Request, Response +from dreaming_electric_sheep.middlewares import MiddlewareCategory if TYPE_CHECKING: - from blacksheep.server.application import Application + from dreaming_electric_sheep.server.application import Application async def di_scope_middleware( diff --git a/blacksheep/server/diagnostics.py b/dreaming_electric_sheep/server/diagnostics.py similarity index 92% rename from blacksheep/server/diagnostics.py rename to dreaming_electric_sheep/server/diagnostics.py index ffdfcd53..5e9100ae 100644 --- a/blacksheep/server/diagnostics.py +++ b/dreaming_electric_sheep/server/diagnostics.py @@ -1,5 +1,5 @@ -from blacksheep import Application, Router, html -from blacksheep.server.errors import ServerErrorDetailsHandler +from dreaming_electric_sheep import Application, Router, html +from dreaming_electric_sheep.server.errors import ServerErrorDetailsHandler def _get_response_without_details(request, info, error_page_template: str): diff --git a/blacksheep/server/env.py b/dreaming_electric_sheep/server/env.py similarity index 98% rename from blacksheep/server/env.py rename to dreaming_electric_sheep/server/env.py index 05e830d7..06931fd7 100644 --- a/blacksheep/server/env.py +++ b/dreaming_electric_sheep/server/env.py @@ -1,6 +1,6 @@ import os -from blacksheep.utils import truthy +from dreaming_electric_sheep.utils import truthy def get_env() -> str: diff --git a/blacksheep/server/errors.py b/dreaming_electric_sheep/server/errors.py similarity index 84% rename from blacksheep/server/errors.py rename to dreaming_electric_sheep/server/errors.py index 0453a113..085886e8 100644 --- a/blacksheep/server/errors.py +++ b/dreaming_electric_sheep/server/errors.py @@ -1,10 +1,10 @@ import html import traceback -from blacksheep.contents import HTMLContent -from blacksheep.messages import Request, Response -from blacksheep.server.asgi import get_request_url -from blacksheep.server.resources import get_resource_file_content +from dreaming_electric_sheep.contents import HTMLContent +from dreaming_electric_sheep.messages import Request, Response +from dreaming_electric_sheep.server.asgi import get_request_url +from dreaming_electric_sheep.server.resources import get_resource_file_content def _load_error_page_template() -> str: diff --git a/blacksheep/server/files/__init__.py b/dreaming_electric_sheep/server/files/__init__.py similarity index 93% rename from blacksheep/server/files/__init__.py rename to dreaming_electric_sheep/server/files/__init__.py index 88ed2801..650f6bab 100644 --- a/blacksheep/server/files/__init__.py +++ b/dreaming_electric_sheep/server/files/__init__.py @@ -3,13 +3,13 @@ from pathlib import Path from typing import AsyncIterable, Callable, Set, TypedDict -from blacksheep import Request, Response, StreamedContent -from blacksheep.common.files.asyncfs import FilesHandler -from blacksheep.common.files.info import FileInfo -from blacksheep.common.files.pathsutils import get_mime_type_from_name -from blacksheep.exceptions import BadRequest, InvalidArgument, RangeNotSatisfiable -from blacksheep.ranges import InvalidRangeValue, Range, RangePart -from blacksheep.server.headers.cache import CacheControlHeaderValue +from dreaming_electric_sheep import Request, Response, StreamedContent +from dreaming_electric_sheep.common.files.asyncfs import FilesHandler +from dreaming_electric_sheep.common.files.info import FileInfo +from dreaming_electric_sheep.common.files.pathsutils import get_mime_type_from_name +from dreaming_electric_sheep.exceptions import BadRequest, InvalidArgument, RangeNotSatisfiable +from dreaming_electric_sheep.ranges import InvalidRangeValue, Range, RangePart +from dreaming_electric_sheep.server.headers.cache import CacheControlHeaderValue class FilePathInfo(TypedDict): @@ -246,7 +246,7 @@ def get_response_for_file( if request.method == "HEAD": # NB: responses to HEAD requests don't have a body, - # and responses with a body in BlackSheep have content-type + # and responses with a body in Dreaming Electric Sheep have content-type # and content-length headers set automatically, # depending on their content; therefore here it's necessary to set # content-type and content-length for HEAD diff --git a/blacksheep/server/files/dynamic.py b/dreaming_electric_sheep/server/files/dynamic.py similarity index 93% rename from blacksheep/server/files/dynamic.py rename to dreaming_electric_sheep/server/files/dynamic.py index 32d3ad82..2d49eefa 100644 --- a/blacksheep/server/files/dynamic.py +++ b/dreaming_electric_sheep/server/files/dynamic.py @@ -4,21 +4,21 @@ from typing import Awaitable, Callable, Iterable, Sequence, Set from urllib.parse import unquote -from blacksheep import HTMLContent, Request, Response -from blacksheep.common.files.asyncfs import FilesHandler -from blacksheep.common.files.pathsutils import get_file_extension_from_name -from blacksheep.exceptions import NotFound -from blacksheep.server.authorization import allow_anonymous -from blacksheep.server.files import ( +from dreaming_electric_sheep import HTMLContent, Request, Response +from dreaming_electric_sheep.common.files.asyncfs import FilesHandler +from dreaming_electric_sheep.common.files.pathsutils import get_file_extension_from_name +from dreaming_electric_sheep.exceptions import NotFound +from dreaming_electric_sheep.server.authorization import allow_anonymous +from dreaming_electric_sheep.server.files import ( DefaultFileOptions, FilePathInfo, get_default_extensions, get_response_for_file, validate_source_path, ) -from blacksheep.server.resources import get_resource_file_content -from blacksheep.server.routing import Route, Router -from blacksheep.utils import join_fragments +from dreaming_electric_sheep.server.resources import get_resource_file_content +from dreaming_electric_sheep.server.routing import Route, Router +from dreaming_electric_sheep.utils import join_fragments def get_files_to_serve( diff --git a/blacksheep/server/files/static.py b/dreaming_electric_sheep/server/files/static.py similarity index 90% rename from blacksheep/server/files/static.py rename to dreaming_electric_sheep/server/files/static.py index 27a72c90..1953cfe3 100644 --- a/blacksheep/server/files/static.py +++ b/dreaming_electric_sheep/server/files/static.py @@ -1,7 +1,7 @@ from email.utils import formatdate -from blacksheep.contents import Content -from blacksheep.messages import Request, Response +from dreaming_electric_sheep.contents import Content +from dreaming_electric_sheep.messages import Request, Response def get_response_for_static_content( diff --git a/blacksheep/server/headers/__init__.py b/dreaming_electric_sheep/server/headers/__init__.py similarity index 100% rename from blacksheep/server/headers/__init__.py rename to dreaming_electric_sheep/server/headers/__init__.py diff --git a/blacksheep/server/headers/cache.py b/dreaming_electric_sheep/server/headers/cache.py similarity index 98% rename from blacksheep/server/headers/cache.py rename to dreaming_electric_sheep/server/headers/cache.py index 12f1490b..1716887e 100644 --- a/blacksheep/server/headers/cache.py +++ b/dreaming_electric_sheep/server/headers/cache.py @@ -6,8 +6,8 @@ import inspect from functools import wraps -from blacksheep import Request, Response -from blacksheep.server.normalization import ensure_response +from dreaming_electric_sheep import Request, Response +from dreaming_electric_sheep.server.normalization import ensure_response def write_cache_control_response_header( diff --git a/blacksheep/server/normalization.py b/dreaming_electric_sheep/server/normalization.py similarity index 100% rename from blacksheep/server/normalization.py rename to dreaming_electric_sheep/server/normalization.py diff --git a/blacksheep/server/openapi/__init__.py b/dreaming_electric_sheep/server/openapi/__init__.py similarity index 100% rename from blacksheep/server/openapi/__init__.py rename to dreaming_electric_sheep/server/openapi/__init__.py diff --git a/blacksheep/server/openapi/common.py b/dreaming_electric_sheep/server/openapi/common.py similarity index 96% rename from blacksheep/server/openapi/common.py rename to dreaming_electric_sheep/server/openapi/common.py index 71e3673f..02b7e56c 100644 --- a/blacksheep/server/openapi/common.py +++ b/dreaming_electric_sheep/server/openapi/common.py @@ -27,15 +27,15 @@ from essentials.json import dumps from openapidocs.common import Format, OpenAPIElement, OpenAPIRoot, Serializer -from blacksheep.messages import Request -from blacksheep.server.application import Application, ApplicationSyncEvent -from blacksheep.server.authorization import allow_anonymous -from blacksheep.server.controllers import Controller -from blacksheep.server.files.static import get_response_for_static_content -from blacksheep.server.routing import Route, Router -from blacksheep.url import join_prefix -from blacksheep.utils import truthy -from blacksheep.utils.time import utcnow +from dreaming_electric_sheep.messages import Request +from dreaming_electric_sheep.server.application import Application, ApplicationSyncEvent +from dreaming_electric_sheep.server.authorization import allow_anonymous +from dreaming_electric_sheep.server.controllers import Controller +from dreaming_electric_sheep.server.files.static import get_response_for_static_content +from dreaming_electric_sheep.server.routing import Route, Router +from dreaming_electric_sheep.url import join_prefix +from dreaming_electric_sheep.utils import truthy +from dreaming_electric_sheep.utils.time import utcnow from .ui import SwaggerUIProvider, UIOptions, UIProvider @@ -182,8 +182,8 @@ def __init__(self, context) -> None: class DefaultSerializer(Serializer): """ - Default OAD serializer used by BlackSheep. - BlackSheep generates OpenAPI Specification files containing + Default OAD serializer used by Dreaming Electric Sheep. + Dreaming Electric Sheep generates OpenAPI Specification files containing reusable schemas for handled types and references to them. It only supports local references (references to schemas defined in the same specification document). @@ -617,7 +617,7 @@ def save_spec(self, destination: str) -> None: 2. Ship the baked files alongside the application. 3. At runtime (TEST / PROD) set the environment variable so that - BlackSheep loads the baked spec instead of regenerating it:: + Dreaming Electric Sheep loads the baked spec instead of regenerating it:: APP_SPEC_FILE=openapi.json diff --git a/blacksheep/server/openapi/docstrings.py b/dreaming_electric_sheep/server/openapi/docstrings.py similarity index 99% rename from blacksheep/server/openapi/docstrings.py rename to dreaming_electric_sheep/server/openapi/docstrings.py index cadba87c..a61c208b 100644 --- a/blacksheep/server/openapi/docstrings.py +++ b/dreaming_electric_sheep/server/openapi/docstrings.py @@ -530,7 +530,7 @@ def get_handler_docstring_info(handler) -> DocstringInfo: "Documentation. Consider baking the OpenAPI spec to a file before " "deploying: call docs.save_spec() after starting the application " "once without -OO, then set APP_SPEC_FILE to the saved path so " - "that BlackSheep loads it at runtime instead of regenerating it.", + "that Dreaming Electric Sheep loads it at runtime instead of regenerating it.", stacklevel=2, ) docstring_info = None diff --git a/blacksheep/server/openapi/exceptions.py b/dreaming_electric_sheep/server/openapi/exceptions.py similarity index 100% rename from blacksheep/server/openapi/exceptions.py rename to dreaming_electric_sheep/server/openapi/exceptions.py diff --git a/blacksheep/server/openapi/ui.py b/dreaming_electric_sheep/server/openapi/ui.py similarity index 95% rename from blacksheep/server/openapi/ui.py rename to dreaming_electric_sheep/server/openapi/ui.py index 18f5f39e..d25fe1b8 100644 --- a/blacksheep/server/openapi/ui.py +++ b/dreaming_electric_sheep/server/openapi/ui.py @@ -2,11 +2,11 @@ from dataclasses import dataclass from typing import Callable -from blacksheep.messages import Request, Response -from blacksheep.server.files.static import get_response_for_static_content -from blacksheep.server.resources import get_resource_file_content -from blacksheep.server.responses import moved_permanently -from blacksheep.utils.time import utcnow +from dreaming_electric_sheep.messages import Request, Response +from dreaming_electric_sheep.server.files.static import get_response_for_static_content +from dreaming_electric_sheep.server.resources import get_resource_file_content +from dreaming_electric_sheep.server.responses import moved_permanently +from dreaming_electric_sheep.utils.time import utcnow SWAGGER_UI_JS_URL = ( "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js" diff --git a/blacksheep/server/openapi/v3.py b/dreaming_electric_sheep/server/openapi/v3.py similarity index 98% rename from blacksheep/server/openapi/v3.py rename to dreaming_electric_sheep/server/openapi/v3.py index 892b244d..fe261360 100644 --- a/blacksheep/server/openapi/v3.py +++ b/dreaming_electric_sheep/server/openapi/v3.py @@ -45,10 +45,10 @@ ValueType, ) -from blacksheep.contents import FileBuffer -from blacksheep.server.authentication.apikey import APIKeyAuthentication, APIKeyLocation -from blacksheep.server.authentication.basic import BasicAuthentication -from blacksheep.server.bindings import ( +from dreaming_electric_sheep.contents import FileBuffer +from dreaming_electric_sheep.server.authentication.apikey import APIKeyAuthentication, APIKeyLocation +from dreaming_electric_sheep.server.authentication.basic import BasicAuthentication +from dreaming_electric_sheep.server.bindings import ( Binder, BodyBinder, CookieBinder, @@ -58,12 +58,12 @@ RouteBinder, empty, ) -from blacksheep.server.openapi.docstrings import ( +from dreaming_electric_sheep.server.openapi.docstrings import ( DocstringInfo, get_handler_docstring_info, ) -from blacksheep.server.openapi.exceptions import DuplicatedContentTypeDocsException -from blacksheep.server.routing import Router +from dreaming_electric_sheep.server.openapi.exceptions import DuplicatedContentTypeDocsException +from dreaming_electric_sheep.server.routing import Router from ..application import Application from .common import ( @@ -83,7 +83,7 @@ try: # JWT dependencies are optional (cryptography, PyJWT) - from blacksheep.server.authentication.jwt import JWTBearerAuthentication + from dreaming_electric_sheep.server.authentication.jwt import JWTBearerAuthentication except ImportError: # pragma: no cover class JWTBearerAuthentication: diff --git a/blacksheep/server/otel/__init__.py b/dreaming_electric_sheep/server/otel/__init__.py similarity index 94% rename from blacksheep/server/otel/__init__.py rename to dreaming_electric_sheep/server/otel/__init__.py index 00e78318..052e2c64 100644 --- a/blacksheep/server/otel/__init__.py +++ b/dreaming_electric_sheep/server/otel/__init__.py @@ -16,7 +16,7 @@ calls. Usage: - from blacksheep.server.otel import use_open_telemetry + from dreaming_electric_sheep.server.otel import use_open_telemetry # Configure log_exporter and span_exporter as needed use_open_telemetry(app, log_exporter, span_exporter) @@ -36,10 +36,10 @@ from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter from opentelemetry.trace import SpanKind -from blacksheep import Application -from blacksheep.messages import Request, Response -from blacksheep.middlewares import MiddlewareCategory -from blacksheep.server.env import get_env +from dreaming_electric_sheep import Application +from dreaming_electric_sheep.messages import Request, Response +from dreaming_electric_sheep.middlewares import MiddlewareCategory +from dreaming_electric_sheep.server.env import get_env ExceptionHandler = Callable[[Request, Exception], Awaitable[Response]] @@ -154,7 +154,7 @@ def use_open_telemetry( middleware: OTELMiddleware | None = None, ): """ - Configures OpenTelemetry tracing and logging for a BlackSheep application. + Configures OpenTelemetry tracing and logging for a Dreaming Electric Sheep application. This function sets up OpenTelemetry log and span exporters, configures resource attributes, and injects OTEL middleware for automatic tracing of HTTP requests. @@ -162,7 +162,7 @@ def use_open_telemetry( shutdown of the tracer provider on application stop. Args: - app (Application): The BlackSheep application instance. + app (Application): The Dreaming Electric Sheep application instance. log_exporter (LogExporter): The OpenTelemetry log exporter to use. span_exporter (SpanExporter): The OpenTelemetry span exporter to use. middleware (optional OTELMiddleware): Custom OTEL middleware instance. @@ -173,7 +173,7 @@ def use_open_telemetry( """ if os.getenv("OTEL_RESOURCE_ATTRIBUTES") is None: # set a default value - set_attributes("blacksheep-app") + set_attributes("dreaming-electric-sheep-app") _configure_logging(log_exporter, span_exporter) diff --git a/blacksheep/server/otel/otlp.py b/dreaming_electric_sheep/server/otel/otlp.py similarity index 79% rename from blacksheep/server/otel/otlp.py rename to dreaming_electric_sheep/server/otel/otlp.py index ea563812..26686d28 100644 --- a/blacksheep/server/otel/otlp.py +++ b/dreaming_electric_sheep/server/otel/otlp.py @@ -1,6 +1,6 @@ """ This module provides integration for OpenTelemetry using OTLP (OpenTelemetry Protocol) -exporters for logging and tracing in BlackSheep applications. This code is vendor +exporters for logging and tracing in Dreaming Electric Sheep applications. This code is vendor agnostic as it can work with all providers supporting the OpenTelemetry Protocol (e.g. OpenTelemtry Collector, Grafana Alloy). @@ -12,7 +12,7 @@ pip install opentelemetry-exporter-otlp Usage: - from blacksheep.server.otel.otlp import use_open_telemetry_otlp + from dreaming_electric_sheep.server.otel.otlp import use_open_telemetry_otlp use_open_telemetry_otlp(app) """ @@ -20,7 +20,7 @@ from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter -from blacksheep import Application +from dreaming_electric_sheep import Application from . import OTELMiddleware, use_open_telemetry @@ -29,7 +29,7 @@ def use_open_telemetry_otlp(app: Application, middleware: OTELMiddleware | None = None): """ - Configures OpenTelemetry for a BlackSheep application using OTLP exporters. + Configures OpenTelemetry for a Dreaming Electric Sheep application using OTLP exporters. This function configures OpenTelemetry logging and tracing using OTLPLogExporter and OTLPSpanExporter. It is your responsibility to configure OTEL env variables as @@ -37,7 +37,7 @@ def use_open_telemetry_otlp(app: Application, middleware: OTELMiddleware | None https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/ Args: - app: The BlackSheep Application instance. + app: The Dreaming Electric Sheep Application instance. middleware (optional OTELMiddleware): Custom OTEL middleware instance. If not provided, the default OTELMiddleware is used. diff --git a/blacksheep/server/process.py b/dreaming_electric_sheep/server/process.py similarity index 92% rename from blacksheep/server/process.py rename to dreaming_electric_sheep/server/process.py index 23b22f50..ca0979d8 100644 --- a/blacksheep/server/process.py +++ b/dreaming_electric_sheep/server/process.py @@ -7,10 +7,10 @@ import warnings from typing import TYPE_CHECKING -from blacksheep.utils import truthy +from dreaming_electric_sheep.utils import truthy if TYPE_CHECKING: - from blacksheep.server.application import Application + from dreaming_electric_sheep.server.application import Application _STOPPING = False diff --git a/blacksheep/server/redirects.py b/dreaming_electric_sheep/server/redirects.py similarity index 91% rename from blacksheep/server/redirects.py rename to dreaming_electric_sheep/server/redirects.py index 1068228a..ad1a9284 100644 --- a/blacksheep/server/redirects.py +++ b/dreaming_electric_sheep/server/redirects.py @@ -1,7 +1,7 @@ from typing import Awaitable, Callable -from blacksheep import Response -from blacksheep.server.responses import moved_permanently +from dreaming_electric_sheep import Response +from dreaming_electric_sheep.server.responses import moved_permanently def default_trailing_slash_exclude(path: str) -> bool: diff --git a/blacksheep/server/remotes/__init__.py b/dreaming_electric_sheep/server/remotes/__init__.py similarity index 100% rename from blacksheep/server/remotes/__init__.py rename to dreaming_electric_sheep/server/remotes/__init__.py diff --git a/blacksheep/server/remotes/forwarding.py b/dreaming_electric_sheep/server/remotes/forwarding.py similarity index 98% rename from blacksheep/server/remotes/forwarding.py rename to dreaming_electric_sheep/server/remotes/forwarding.py index 5d2b62c9..5beba166 100644 --- a/blacksheep/server/remotes/forwarding.py +++ b/dreaming_electric_sheep/server/remotes/forwarding.py @@ -2,9 +2,9 @@ from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_address from typing import AnyStr, Iterable, Sequence -from blacksheep.exceptions import BadRequest -from blacksheep.headers import Headers -from blacksheep.messages import Request +from dreaming_electric_sheep.exceptions import BadRequest +from dreaming_electric_sheep.headers import Headers +from dreaming_electric_sheep.messages import Request from .hosts import TrustedHostsMiddleware diff --git a/blacksheep/server/remotes/hosts.py b/dreaming_electric_sheep/server/remotes/hosts.py similarity index 88% rename from blacksheep/server/remotes/hosts.py rename to dreaming_electric_sheep/server/remotes/hosts.py index 477b04f3..498bc389 100644 --- a/blacksheep/server/remotes/hosts.py +++ b/dreaming_electric_sheep/server/remotes/hosts.py @@ -1,7 +1,7 @@ from typing import Sequence -from blacksheep.exceptions import BadRequest -from blacksheep.messages import Request +from dreaming_electric_sheep.exceptions import BadRequest +from dreaming_electric_sheep.messages import Request class InvalidHostError(BadRequest): diff --git a/blacksheep/server/remotes/scheme.py b/dreaming_electric_sheep/server/remotes/scheme.py similarity index 89% rename from blacksheep/server/remotes/scheme.py rename to dreaming_electric_sheep/server/remotes/scheme.py index 14e0b202..d9b15224 100644 --- a/blacksheep/server/remotes/scheme.py +++ b/dreaming_electric_sheep/server/remotes/scheme.py @@ -1,11 +1,11 @@ import typing -from blacksheep.messages import Request -from blacksheep.middlewares import MiddlewareCategory -from blacksheep.server.security.hsts import HSTSMiddleware +from dreaming_electric_sheep.messages import Request +from dreaming_electric_sheep.middlewares import MiddlewareCategory +from dreaming_electric_sheep.server.security.hsts import HSTSMiddleware if typing.TYPE_CHECKING: - from blacksheep.server.application import Application + from dreaming_electric_sheep.server.application import Application class HTTPSchemeMiddleware: diff --git a/blacksheep/server/rendering/__init__.py b/dreaming_electric_sheep/server/rendering/__init__.py similarity index 100% rename from blacksheep/server/rendering/__init__.py rename to dreaming_electric_sheep/server/rendering/__init__.py diff --git a/blacksheep/server/rendering/abc.py b/dreaming_electric_sheep/server/rendering/abc.py similarity index 100% rename from blacksheep/server/rendering/abc.py rename to dreaming_electric_sheep/server/rendering/abc.py diff --git a/blacksheep/server/rendering/jinja2.py b/dreaming_electric_sheep/server/rendering/jinja2.py similarity index 95% rename from blacksheep/server/rendering/jinja2.py rename to dreaming_electric_sheep/server/rendering/jinja2.py index cceb4a99..d2de63db 100644 --- a/blacksheep/server/rendering/jinja2.py +++ b/dreaming_electric_sheep/server/rendering/jinja2.py @@ -12,9 +12,9 @@ from jinja2.ext import Extension from jinja2.utils import pass_context -from blacksheep.messages import Request -from blacksheep.server.csrf import AntiForgeryHandler, MissingRequestContextError -from blacksheep.utils import truthy +from dreaming_electric_sheep.messages import Request +from dreaming_electric_sheep.server.csrf import AntiForgeryHandler, MissingRequestContextError +from dreaming_electric_sheep.utils import truthy from .abc import Renderer diff --git a/blacksheep/server/rendering/models.py b/dreaming_electric_sheep/server/rendering/models.py similarity index 100% rename from blacksheep/server/rendering/models.py rename to dreaming_electric_sheep/server/rendering/models.py diff --git a/blacksheep/server/res/__init__.py b/dreaming_electric_sheep/server/res/__init__.py similarity index 100% rename from blacksheep/server/res/__init__.py rename to dreaming_electric_sheep/server/res/__init__.py diff --git a/blacksheep/server/res/error.css b/dreaming_electric_sheep/server/res/error.css similarity index 100% rename from blacksheep/server/res/error.css rename to dreaming_electric_sheep/server/res/error.css diff --git a/blacksheep/server/res/error.html b/dreaming_electric_sheep/server/res/error.html similarity index 100% rename from blacksheep/server/res/error.html rename to dreaming_electric_sheep/server/res/error.html diff --git a/blacksheep/server/res/fileslist.html b/dreaming_electric_sheep/server/res/fileslist.html similarity index 100% rename from blacksheep/server/res/fileslist.html rename to dreaming_electric_sheep/server/res/fileslist.html diff --git a/blacksheep/server/res/redoc-ui.html b/dreaming_electric_sheep/server/res/redoc-ui.html similarity index 100% rename from blacksheep/server/res/redoc-ui.html rename to dreaming_electric_sheep/server/res/redoc-ui.html diff --git a/blacksheep/server/res/scalar-ui.html b/dreaming_electric_sheep/server/res/scalar-ui.html similarity index 100% rename from blacksheep/server/res/scalar-ui.html rename to dreaming_electric_sheep/server/res/scalar-ui.html diff --git a/blacksheep/server/res/swagger-ui.html b/dreaming_electric_sheep/server/res/swagger-ui.html similarity index 100% rename from blacksheep/server/res/swagger-ui.html rename to dreaming_electric_sheep/server/res/swagger-ui.html diff --git a/blacksheep/server/resources.py b/dreaming_electric_sheep/server/resources.py similarity index 79% rename from blacksheep/server/resources.py rename to dreaming_electric_sheep/server/resources.py index 2b4aa5a1..e121f198 100644 --- a/blacksheep/server/resources.py +++ b/dreaming_electric_sheep/server/resources.py @@ -1,6 +1,6 @@ """ This module offers methods to return file paths for resources. Its original purpose is -to provide contents of static files stored for conveniency in the blacksheep.server.res +to provide contents of static files stored for conveniency in the dreaming_electric_sheep.server.res package. """ @@ -13,7 +13,7 @@ def get_resource_file_path(anchor, file_name: str) -> str: def get_resource_file_content(file_name: str) -> str: with open( - get_resource_file_path("blacksheep.server.res", file_name), + get_resource_file_path("dreaming_electric_sheep.server.res", file_name), mode="rt", ) as source: return source.read() diff --git a/blacksheep/server/responses.py b/dreaming_electric_sheep/server/responses.py similarity index 94% rename from blacksheep/server/responses.py rename to dreaming_electric_sheep/server/responses.py index d887fc66..9378aef5 100644 --- a/blacksheep/server/responses.py +++ b/dreaming_electric_sheep/server/responses.py @@ -4,10 +4,10 @@ from io import BytesIO from typing import Any, AnyStr, AsyncIterable, Callable -from blacksheep import Content, JSONContent, Response, StreamedContent, TextContent -from blacksheep.common.files.asyncfs import FilesHandler -from blacksheep.settings.html import html_settings -from blacksheep.settings.json import json_settings +from dreaming_electric_sheep import Content, JSONContent, Response, StreamedContent, TextContent +from dreaming_electric_sheep.common.files.asyncfs import FilesHandler +from dreaming_electric_sheep.settings.html import html_settings +from dreaming_electric_sheep.settings.json import json_settings MessageType = Any @@ -332,8 +332,8 @@ def view(name: str, model: Any = None, status: int = 200, **kwargs) -> Response: Returns a Response object with HTML obtained using synchronous rendering. This method relies on the engine configured for rendering (defaults to Jinja2): - see `blacksheep.settings.html.html_settings.renderer` - and `blacksheep.server.rendering.abc.Renderer`. + see `dreaming_electric_sheep.settings.html.html_settings.renderer` + and `dreaming_electric_sheep.server.rendering.abc.Renderer`. """ renderer = html_settings.renderer if model: @@ -351,8 +351,8 @@ async def view_async( Returns a Response object with HTML obtained using asynchronous rendering. This method relies on the engine configured for rendering (defaults to Jinja2): - see `blacksheep.settings.html.html_settings.renderer` - and `blacksheep.server.rendering.abc.Renderer`. + see `dreaming_electric_sheep.settings.html.html_settings.renderer` + and `dreaming_electric_sheep.server.rendering.abc.Renderer`. """ renderer = html_settings.renderer if model: diff --git a/blacksheep/server/routing.py b/dreaming_electric_sheep/server/routing.py similarity index 100% rename from blacksheep/server/routing.py rename to dreaming_electric_sheep/server/routing.py diff --git a/blacksheep/server/security/__init__.py b/dreaming_electric_sheep/server/security/__init__.py similarity index 87% rename from blacksheep/server/security/__init__.py rename to dreaming_electric_sheep/server/security/__init__.py index 70835306..a20d4dad 100644 --- a/blacksheep/server/security/__init__.py +++ b/dreaming_electric_sheep/server/security/__init__.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod -from blacksheep.messages import Response +from dreaming_electric_sheep.messages import Response class SecurityPolicyHandler(ABC): diff --git a/blacksheep/server/security/hsts.py b/dreaming_electric_sheep/server/security/hsts.py similarity index 92% rename from blacksheep/server/security/hsts.py rename to dreaming_electric_sheep/server/security/hsts.py index d3a9a0eb..43f8b53d 100644 --- a/blacksheep/server/security/hsts.py +++ b/dreaming_electric_sheep/server/security/hsts.py @@ -1,4 +1,4 @@ -from blacksheep.messages import Request, Response +from dreaming_electric_sheep.messages import Request, Response def write_hsts_header_value(max_age: int, include_subdomains: bool) -> bytes: diff --git a/blacksheep/server/sse.py b/dreaming_electric_sheep/server/sse.py similarity index 87% rename from blacksheep/server/sse.py rename to dreaming_electric_sheep/server/sse.py index 906b0fee..ff82c214 100644 --- a/blacksheep/server/sse.py +++ b/dreaming_electric_sheep/server/sse.py @@ -4,9 +4,9 @@ from typing import AsyncIterable, Callable -from blacksheep.contents import ServerSentEvent, StreamedContent, TextServerSentEvent -from blacksheep.messages import Response -from blacksheep.scribe import write_sse +from dreaming_electric_sheep.contents import ServerSentEvent, StreamedContent, TextServerSentEvent +from dreaming_electric_sheep.messages import Response +from dreaming_electric_sheep.scribe import write_sse __all__ = [ "ServerSentEvent", diff --git a/blacksheep/server/websocket.py b/dreaming_electric_sheep/server/websocket.py similarity index 97% rename from blacksheep/server/websocket.py rename to dreaming_electric_sheep/server/websocket.py index 134c2041..2a6d2a08 100644 --- a/blacksheep/server/websocket.py +++ b/dreaming_electric_sheep/server/websocket.py @@ -2,9 +2,9 @@ from functools import wraps from typing import Any, AnyStr, Callable, MutableMapping -from blacksheep.messages import Request -from blacksheep.server.asgi import get_full_path -from blacksheep.settings.json import json_settings +from dreaming_electric_sheep.messages import Request +from dreaming_electric_sheep.server.asgi import get_full_path +from dreaming_electric_sheep.settings.json import json_settings MAX_REASON_SIZE = 123 diff --git a/blacksheep/sessions/__init__.py b/dreaming_electric_sheep/sessions/__init__.py similarity index 81% rename from blacksheep/sessions/__init__.py rename to dreaming_electric_sheep/sessions/__init__.py index 9ee72300..9c7b3ff8 100644 --- a/blacksheep/sessions/__init__.py +++ b/dreaming_electric_sheep/sessions/__init__.py @@ -1,7 +1,7 @@ from typing import Awaitable, Callable -from blacksheep.messages import Request, Response -from blacksheep.sessions.abc import Session, SessionSerializer, SessionStore +from dreaming_electric_sheep.messages import Request, Response +from dreaming_electric_sheep.sessions.abc import Session, SessionSerializer, SessionStore __all__ = [ "Session", @@ -13,7 +13,7 @@ class SessionMiddleware: """ - Middleware for managing user sessions in a BlackSheep application. + Middleware for managing user sessions in a Dreaming Electric Sheep application. This middleware loads the session from the provided session store at the beginning of the request, attaches it to the request object, and saves the session back to diff --git a/blacksheep/sessions/abc.py b/dreaming_electric_sheep/sessions/abc.py similarity index 98% rename from blacksheep/sessions/abc.py rename to dreaming_electric_sheep/sessions/abc.py index 713e5bb6..e76cf949 100644 --- a/blacksheep/sessions/abc.py +++ b/dreaming_electric_sheep/sessions/abc.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from typing import Any, Mapping -from blacksheep.messages import Request, Response +from dreaming_electric_sheep.messages import Request, Response class Session: diff --git a/blacksheep/sessions/cookies.py b/dreaming_electric_sheep/sessions/cookies.py similarity index 89% rename from blacksheep/sessions/cookies.py rename to dreaming_electric_sheep/sessions/cookies.py index 06972e28..75d4bc5e 100644 --- a/blacksheep/sessions/cookies.py +++ b/dreaming_electric_sheep/sessions/cookies.py @@ -7,12 +7,12 @@ URLSafeTimedSerializer, ) -from blacksheep.cookies import Cookie -from blacksheep.messages import Request, Response -from blacksheep.sessions.abc import Session, SessionSerializer, SessionStore -from blacksheep.sessions.json import JSONSerializer -from blacksheep.sessions.logs import get_logger -from blacksheep.utils import ensure_str +from dreaming_electric_sheep.cookies import Cookie +from dreaming_electric_sheep.messages import Request, Response +from dreaming_electric_sheep.sessions.abc import Session, SessionSerializer, SessionStore +from dreaming_electric_sheep.sessions.json import JSONSerializer +from dreaming_electric_sheep.sessions.logs import get_logger +from dreaming_electric_sheep.utils import ensure_str class CookieSessionStore(SessionStore): diff --git a/blacksheep/sessions/json.py b/dreaming_electric_sheep/sessions/json.py similarity index 89% rename from blacksheep/sessions/json.py rename to dreaming_electric_sheep/sessions/json.py index 7b2b4c46..ddae95c8 100644 --- a/blacksheep/sessions/json.py +++ b/dreaming_electric_sheep/sessions/json.py @@ -1,4 +1,4 @@ -from blacksheep.settings.json import json_settings +from dreaming_electric_sheep.settings.json import json_settings from .abc import Session, SessionSerializer diff --git a/dreaming_electric_sheep/sessions/logs.py b/dreaming_electric_sheep/sessions/logs.py new file mode 100644 index 00000000..03fb4d24 --- /dev/null +++ b/dreaming_electric_sheep/sessions/logs.py @@ -0,0 +1,10 @@ +import logging + + +def get_logger(): + """ + Returns a "dreaming_electric_sheep.sessions" logger. + """ + logger = logging.getLogger("dreaming_electric_sheep.sessions") + logger.setLevel(logging.INFO) + return logger diff --git a/blacksheep/sessions/memory.py b/dreaming_electric_sheep/sessions/memory.py similarity index 91% rename from blacksheep/sessions/memory.py rename to dreaming_electric_sheep/sessions/memory.py index e0c5cfe5..94efea81 100644 --- a/blacksheep/sessions/memory.py +++ b/dreaming_electric_sheep/sessions/memory.py @@ -1,9 +1,9 @@ import secrets from typing import Any -from blacksheep.cookies import Cookie -from blacksheep.messages import Request, Response -from blacksheep.sessions.abc import Session, SessionStore +from dreaming_electric_sheep.cookies import Cookie +from dreaming_electric_sheep.messages import Request, Response +from dreaming_electric_sheep.sessions.abc import Session, SessionStore class InMemorySessionStore(SessionStore): diff --git a/dreaming_electric_sheep/settings/__init__.py b/dreaming_electric_sheep/settings/__init__.py new file mode 100644 index 00000000..d221c0f0 --- /dev/null +++ b/dreaming_electric_sheep/settings/__init__.py @@ -0,0 +1,3 @@ +""" +This package contains global settings for a Dreaming Electric Sheep application. +""" diff --git a/blacksheep/settings/di.py b/dreaming_electric_sheep/settings/di.py similarity index 100% rename from blacksheep/settings/di.py rename to dreaming_electric_sheep/settings/di.py diff --git a/blacksheep/settings/encodings.py b/dreaming_electric_sheep/settings/encodings.py similarity index 100% rename from blacksheep/settings/encodings.py rename to dreaming_electric_sheep/settings/encodings.py diff --git a/blacksheep/settings/html.py b/dreaming_electric_sheep/settings/html.py similarity index 78% rename from blacksheep/settings/html.py rename to dreaming_electric_sheep/settings/html.py index 6d37e22b..79a1f9f3 100644 --- a/blacksheep/settings/html.py +++ b/dreaming_electric_sheep/settings/html.py @@ -1,11 +1,11 @@ from typing import Any -from blacksheep.server.rendering.abc import ModelHandler, Renderer -from blacksheep.server.rendering.models import DefaultModelHandler +from dreaming_electric_sheep.server.rendering.abc import ModelHandler, Renderer +from dreaming_electric_sheep.server.rendering.models import DefaultModelHandler def default_renderer() -> Renderer: - from blacksheep.server.rendering.jinja2 import JinjaRenderer + from dreaming_electric_sheep.server.rendering.jinja2 import JinjaRenderer return JinjaRenderer() diff --git a/blacksheep/settings/json.py b/dreaming_electric_sheep/settings/json.py similarity index 100% rename from blacksheep/settings/json.py rename to dreaming_electric_sheep/settings/json.py diff --git a/dreaming_electric_sheep/testing/__init__.py b/dreaming_electric_sheep/testing/__init__.py new file mode 100644 index 00000000..8b2a08db --- /dev/null +++ b/dreaming_electric_sheep/testing/__init__.py @@ -0,0 +1,14 @@ +from dreaming_electric_sheep.contents import FormContent, JSONContent, TextContent +from dreaming_electric_sheep.testing.client import TestClient +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep.testing.simulator import AbstractTestSimulator + +__all__ = [ + "TestClient", + "AbstractTestSimulator", + "JSONContent", + "TextContent", + "FormContent", + "MockReceive", + "MockSend", +] diff --git a/blacksheep/testing/client.py b/dreaming_electric_sheep/testing/client.py similarity index 93% rename from blacksheep/testing/client.py rename to dreaming_electric_sheep/testing/client.py index 8b64dd4a..5aaf6b52 100644 --- a/blacksheep/testing/client.py +++ b/dreaming_electric_sheep/testing/client.py @@ -1,10 +1,10 @@ import asyncio -from blacksheep.contents import Content -from blacksheep.server.application import Application -from blacksheep.server.responses import Response -from blacksheep.testing.simulator import AbstractTestSimulator, TestSimulator -from blacksheep.testing.websocket import TestWebSocket +from dreaming_electric_sheep.contents import Content +from dreaming_electric_sheep.server.application import Application +from dreaming_electric_sheep.server.responses import Response +from dreaming_electric_sheep.testing.simulator import AbstractTestSimulator, TestSimulator +from dreaming_electric_sheep.testing.websocket import TestWebSocket from .helpers import CookiesType, HeadersType, QueryType diff --git a/blacksheep/testing/helpers.py b/dreaming_electric_sheep/testing/helpers.py similarity index 100% rename from blacksheep/testing/helpers.py rename to dreaming_electric_sheep/testing/helpers.py diff --git a/blacksheep/testing/messages.py b/dreaming_electric_sheep/testing/messages.py similarity index 100% rename from blacksheep/testing/messages.py rename to dreaming_electric_sheep/testing/messages.py diff --git a/blacksheep/testing/simulator.py b/dreaming_electric_sheep/testing/simulator.py similarity index 89% rename from blacksheep/testing/simulator.py rename to dreaming_electric_sheep/testing/simulator.py index 144fd53c..e4c69a40 100644 --- a/blacksheep/testing/simulator.py +++ b/dreaming_electric_sheep/testing/simulator.py @@ -2,12 +2,12 @@ import asyncio from typing import Dict -from blacksheep.contents import Content -from blacksheep.messages import Request -from blacksheep.server.application import Application -from blacksheep.server.responses import Response -from blacksheep.testing.helpers import get_example_scope -from blacksheep.testing.websocket import TestWebSocket +from dreaming_electric_sheep.contents import Content +from dreaming_electric_sheep.messages import Request +from dreaming_electric_sheep.server.application import Application +from dreaming_electric_sheep.server.responses import Response +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.websocket import TestWebSocket from .helpers import CookiesType, HeadersType, QueryType @@ -151,4 +151,4 @@ def websocket_connect( def _is_started_app(self): assert ( self.app.started - ), "The BlackSheep application is not started, use Application.start method" + ), "The Dreaming Electric Sheep application is not started, use Application.start method" diff --git a/blacksheep/testing/websocket.py b/dreaming_electric_sheep/testing/websocket.py similarity index 97% rename from blacksheep/testing/websocket.py rename to dreaming_electric_sheep/testing/websocket.py index 79b17f0a..a73ce6ee 100644 --- a/blacksheep/testing/websocket.py +++ b/dreaming_electric_sheep/testing/websocket.py @@ -3,7 +3,7 @@ import asyncio from typing import Any, AnyStr, MutableMapping -from blacksheep.settings.json import json_settings +from dreaming_electric_sheep.settings.json import json_settings class TestWebSocket: diff --git a/blacksheep/url.pxd b/dreaming_electric_sheep/url.pxd similarity index 91% rename from blacksheep/url.pxd rename to dreaming_electric_sheep/url.pxd index 0890aaec..52d46df6 100644 --- a/blacksheep/url.pxd +++ b/dreaming_electric_sheep/url.pxd @@ -1,7 +1,7 @@ # cython: language_level=3 # Copyright (C) 2018-present Roberto Prevato # -# This module is part of BlackSheep and is released under +# This module is part of Dreaming Electric Sheep and is released under # the MIT License https://opensource.org/licenses/MIT diff --git a/blacksheep/url.py b/dreaming_electric_sheep/url.py similarity index 100% rename from blacksheep/url.py rename to dreaming_electric_sheep/url.py diff --git a/blacksheep/url.pyi b/dreaming_electric_sheep/url.pyi similarity index 100% rename from blacksheep/url.pyi rename to dreaming_electric_sheep/url.pyi diff --git a/blacksheep/url.pyx b/dreaming_electric_sheep/url.pyx similarity index 100% rename from blacksheep/url.pyx rename to dreaming_electric_sheep/url.pyx diff --git a/blacksheep/utils/__init__.py b/dreaming_electric_sheep/utils/__init__.py similarity index 100% rename from blacksheep/utils/__init__.py rename to dreaming_electric_sheep/utils/__init__.py diff --git a/blacksheep/utils/aio.py b/dreaming_electric_sheep/utils/aio.py similarity index 100% rename from blacksheep/utils/aio.py rename to dreaming_electric_sheep/utils/aio.py diff --git a/blacksheep/utils/meta.py b/dreaming_electric_sheep/utils/meta.py similarity index 100% rename from blacksheep/utils/meta.py rename to dreaming_electric_sheep/utils/meta.py diff --git a/blacksheep/utils/time.py b/dreaming_electric_sheep/utils/time.py similarity index 100% rename from blacksheep/utils/time.py rename to dreaming_electric_sheep/utils/time.py diff --git a/itests/app_1.py b/itests/app_1.py index 65d10af9..e7326b4b 100644 --- a/itests/app_1.py +++ b/itests/app_1.py @@ -5,7 +5,7 @@ import uvicorn -from blacksheep import ( +from dreaming_electric_sheep import ( Application, Content, ContentDispositionType, @@ -17,8 +17,8 @@ json, text, ) -from blacksheep.contents import ASGIContent -from blacksheep.server.compression import use_gzip_compression +from dreaming_electric_sheep.contents import ASGIContent +from dreaming_electric_sheep.server.compression import use_gzip_compression from itests.utils import CrashTest, ensure_folder app = Application(show_error_details=True) diff --git a/itests/app_2.py b/itests/app_2.py index 7049dbb5..526b96d9 100644 --- a/itests/app_2.py +++ b/itests/app_2.py @@ -16,11 +16,11 @@ from openapidocs.v3 import Schema from pydantic import BaseModel -from blacksheep import Response, TextContent, WebSocket -from blacksheep.server import Application -from blacksheep.server.authentication import AuthenticationHandler -from blacksheep.server.authorization import Policy, Requirement, auth -from blacksheep.server.bindings import ( +from dreaming_electric_sheep import Response, TextContent, WebSocket +from dreaming_electric_sheep.server import Application +from dreaming_electric_sheep.server.authentication import AuthenticationHandler +from dreaming_electric_sheep.server.authorization import Policy, Requirement, auth +from dreaming_electric_sheep.server.bindings import ( FromCookie, FromForm, FromHeader, @@ -28,10 +28,10 @@ FromQuery, FromServices, ) -from blacksheep.server.bindings.dates import parse_datetime -from blacksheep.server.compression import use_gzip_compression -from blacksheep.server.controllers import APIController -from blacksheep.server.openapi.common import ( +from dreaming_electric_sheep.server.bindings.dates import parse_datetime +from dreaming_electric_sheep.server.compression import use_gzip_compression +from dreaming_electric_sheep.server.controllers import APIController +from dreaming_electric_sheep.server.openapi.common import ( ContentInfo, EndpointDocs, HeaderInfo, @@ -41,10 +41,10 @@ ResponseExample, ResponseInfo, ) -from blacksheep.server.openapi.ui import ReDocUIProvider -from blacksheep.server.openapi.v3 import OpenAPIHandler -from blacksheep.server.responses import text -from blacksheep.server.routing import RoutesRegistry +from dreaming_electric_sheep.server.openapi.ui import ReDocUIProvider +from dreaming_electric_sheep.server.openapi.v3 import OpenAPIHandler +from dreaming_electric_sheep.server.responses import text +from dreaming_electric_sheep.server.routing import RoutesRegistry from itests.utils import CrashTest os.environ["APP_SHOW_ERROR_DETAILS"] = "0" diff --git a/itests/app_3.py b/itests/app_3.py index 44457302..c705e207 100644 --- a/itests/app_3.py +++ b/itests/app_3.py @@ -1,8 +1,8 @@ import uvicorn -from blacksheep.server import Application -from blacksheep.server.compression import use_gzip_compression -from blacksheep.server.responses import json +from dreaming_electric_sheep.server import Application +from dreaming_electric_sheep.server.compression import use_gzip_compression +from dreaming_electric_sheep.server.responses import json application = Application(show_error_details=True) app_3 = Application(show_error_details=True) diff --git a/itests/app_4.py b/itests/app_4.py index 448498cc..a9e54acb 100644 --- a/itests/app_4.py +++ b/itests/app_4.py @@ -7,15 +7,15 @@ import uvicorn from openapidocs.v3 import Info -from blacksheep import JSONContent, Response -from blacksheep.server import Application -from blacksheep.server.bindings import FromJSON -from blacksheep.server.compression import use_gzip_compression -from blacksheep.server.openapi.ui import ReDocUIProvider, UIFilesOptions -from blacksheep.server.openapi.v3 import OpenAPIHandler -from blacksheep.server.responses import json -from blacksheep.server.websocket import WebSocket -from blacksheep.settings.json import default_json_dumps, json_settings +from dreaming_electric_sheep import JSONContent, Response +from dreaming_electric_sheep.server import Application +from dreaming_electric_sheep.server.bindings import FromJSON +from dreaming_electric_sheep.server.compression import use_gzip_compression +from dreaming_electric_sheep.server.openapi.ui import ReDocUIProvider, UIFilesOptions +from dreaming_electric_sheep.server.openapi.v3 import OpenAPIHandler +from dreaming_electric_sheep.server.responses import json +from dreaming_electric_sheep.server.websocket import WebSocket +from dreaming_electric_sheep.settings.json import default_json_dumps, json_settings from itests.utils import get_test_files_url SINGLE_PID = None diff --git a/itests/client_fixtures.py b/itests/client_fixtures.py index a242bd48..77e313dc 100644 --- a/itests/client_fixtures.py +++ b/itests/client_fixtures.py @@ -7,8 +7,8 @@ import pytest -from blacksheep.client import ClientSession -from blacksheep.client.pool import ConnectionPools +from dreaming_electric_sheep.client import ClientSession +from dreaming_electric_sheep.client.pool import ConnectionPools from itests.utils import get_sleep_time from .flask_app import app diff --git a/itests/conftest.py b/itests/conftest.py index af248a29..3a19a3ea 100644 --- a/itests/conftest.py +++ b/itests/conftest.py @@ -2,7 +2,7 @@ import pytest -from blacksheep.utils.aio import get_running_loop +from dreaming_electric_sheep.utils.aio import get_running_loop from itests.client_fixtures import * os.environ["APP_DEFAULT_ROUTER"] = "false" diff --git a/itests/issue606.py b/itests/issue606.py index 815544fe..62e3d49d 100644 --- a/itests/issue606.py +++ b/itests/issue606.py @@ -8,8 +8,8 @@ from openapidocs.v3 import Info -from blacksheep import Application, get -from blacksheep.server.openapi.v3 import OpenAPIHandler +from dreaming_electric_sheep import Application, get +from dreaming_electric_sheep.server.openapi.v3 import OpenAPIHandler # Ensure optional dependencies are not installed! for pkg in ("cryptography", "jwt", "PyJWT"): diff --git a/itests/test_auth_oidc.py b/itests/test_auth_oidc.py index 03cbdadb..8147033f 100644 --- a/itests/test_auth_oidc.py +++ b/itests/test_auth_oidc.py @@ -15,15 +15,15 @@ from guardpost import Identity, Policy from guardpost.common import AuthenticatedRequirement -from blacksheep.contents import Content -from blacksheep.cookies import parse_cookie -from blacksheep.exceptions import BadRequest, Unauthorized -from blacksheep.messages import Request, Response -from blacksheep.server.application import Application -from blacksheep.server.asgi import incoming_request -from blacksheep.server.authentication.cookie import CookieAuthentication -from blacksheep.server.authentication.jwt import JWTBearerAuthentication -from blacksheep.server.authentication.oidc import ( +from dreaming_electric_sheep.contents import Content +from dreaming_electric_sheep.cookies import parse_cookie +from dreaming_electric_sheep.exceptions import BadRequest, Unauthorized +from dreaming_electric_sheep.messages import Request, Response +from dreaming_electric_sheep.server.application import Application +from dreaming_electric_sheep.server.asgi import incoming_request +from dreaming_electric_sheep.server.authentication.cookie import CookieAuthentication +from dreaming_electric_sheep.server.authentication.jwt import JWTBearerAuthentication +from dreaming_electric_sheep.server.authentication.oidc import ( CookiesOpenIDTokensHandler, CookiesTokensStore, IDToken, @@ -41,11 +41,11 @@ TokensStore, use_openid_connect, ) -from blacksheep.testing.helpers import get_example_scope -from blacksheep.testing.messages import MockReceive, MockSend -from blacksheep.url import URL -from blacksheep.utils.aio import FailedRequestError -from blacksheep.utils.time import utcnow +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep.url import URL +from dreaming_electric_sheep.utils.aio import FailedRequestError +from dreaming_electric_sheep.utils.time import utcnow from itests import client_fixtures # noqa from tests.test_auth import get_token from tests.test_auth_cookie import get_auth_cookie diff --git a/itests/test_client.py b/itests/test_client.py index 5ff40182..f4fdf162 100644 --- a/itests/test_client.py +++ b/itests/test_client.py @@ -5,7 +5,7 @@ import pytest -from blacksheep import ( +from dreaming_electric_sheep import ( FormContent, FormPart, JSONContent, @@ -13,7 +13,7 @@ Response, StreamedContent, ) -from blacksheep.common.files.asyncfs import FilesHandler +from dreaming_electric_sheep.common.files.asyncfs import FilesHandler from .client_fixtures import * # NoQA from .client_fixtures import get_static_path diff --git a/itests/test_utils_aio.py b/itests/test_utils_aio.py index 4a240981..765c7d7a 100644 --- a/itests/test_utils_aio.py +++ b/itests/test_utils_aio.py @@ -2,7 +2,7 @@ import pytest -from blacksheep.utils.aio import ( +from dreaming_electric_sheep.utils.aio import ( FailedRequestError, HTTPHandler, _try_parse_content_as_json, diff --git a/noext.sh b/noext.sh index c1ff9ee2..b7e818cb 100755 --- a/noext.sh +++ b/noext.sh @@ -1,6 +1,7 @@ # Remove compiled extensions to test the pure-Python implementation. -rm blacksheep/*.so -rm blacksheep/*.c +rm dreaming_electric_sheep/*.so +rm dreaming_electric_sheep/*.c echo "Extensions deleted. Used make compile to recompile them again." + diff --git a/perf/README.md b/perf/README.md index 32d0ae2d..9f70af06 100644 --- a/perf/README.md +++ b/perf/README.md @@ -11,7 +11,7 @@ Benchmarks measure execution time and memory utilization. > Download the results from the GitHub Workflow. > The `benchmark-reports` artifacts include Excel files with tables and charts. > -> [![Build](https://github.com/Neoteroi/BlackSheep/workflows/Benchmark/badge.svg)](https://github.com/Neoteroi/BlackSheep/actions/workflows/perf.yml) +> [![Build](https://github.com/EduLoboM/Dreaming-Electric-Sheep/workflows/Benchmark/badge.svg)](https://github.com/EduLoboM/Dreaming-Electric-Sheep/actions/workflows/perf.yml) The code can both collect information and compare it depending on the Git commit SHA. diff --git a/perf/benchmarks/app.py b/perf/benchmarks/app.py index 38f2d930..cc67b159 100644 --- a/perf/benchmarks/app.py +++ b/perf/benchmarks/app.py @@ -5,12 +5,12 @@ from functools import partial from pathlib import Path -from blacksheep import Application, Response, Router -from blacksheep.contents import TextContent -from blacksheep.server.controllers import Controller -from blacksheep.server.responses import text -from blacksheep.testing.helpers import get_example_scope -from blacksheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep import Application, Response, Router +from dreaming_electric_sheep.contents import TextContent +from dreaming_electric_sheep.server.controllers import Controller +from dreaming_electric_sheep.server.responses import text +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend from perf.benchmarks import async_benchmark, main_run ITERATIONS = 10000 @@ -51,7 +51,7 @@ (b"X-Content-Type-Options", b"nosniff"), (b"X-XSS-Protection", b"1; mode=block"), (b"Strict-Transport-Security", b"max-age=31536000; includeSubDomains"), - (b"Server", b"BlackSheep/1.0"), + (b"Server", b"DreamingElectricSheep/1.0"), ] diff --git a/perf/benchmarks/url.py b/perf/benchmarks/url.py index f120fa7e..077c3756 100644 --- a/perf/benchmarks/url.py +++ b/perf/benchmarks/url.py @@ -2,7 +2,7 @@ URL methods. """ -from blacksheep.url import URL +from dreaming_electric_sheep.url import URL from perf.benchmarks import main_run, sync_benchmark ITERATIONS = 10000 diff --git a/perf/genreport.py b/perf/genreport.py index 99ce3984..e33a9fc5 100644 --- a/perf/genreport.py +++ b/perf/genreport.py @@ -29,7 +29,7 @@ def load_results( ): """Load all benchmark results from the directory""" results = [] - for filename in glob.glob(f"{results_dir}/blacksheep_perf_*.json"): + for filename in glob.glob(f"{results_dir}/dreaming_electric_sheep_perf_*.json"): with open(filename, "r") as f: data = json.load(f) if not _match_filter(data, python_filter, platform_filter): @@ -287,7 +287,7 @@ def write_excel(df, output_file_name: str): if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Compare BlackSheep benchmark results") + parser = argparse.ArgumentParser(description="Compare Dreaming Electric Sheep benchmark results") parser.add_argument( "--results-dir", type=str, diff --git a/perf/historyrun.py b/perf/historyrun.py index 612f9cb9..ebebcf2d 100644 --- a/perf/historyrun.py +++ b/perf/historyrun.py @@ -168,7 +168,7 @@ def store_in_file(value: str) -> None: def main(): - parser = argparse.ArgumentParser(description="BlackSheep Performance Benchmarking") + parser = argparse.ArgumentParser(description="Dreaming Electric Sheep Performance Benchmarking") parser.add_argument( "--iterations", type=int, default=100000, help="Number of iterations" ) diff --git a/perf/main.py b/perf/main.py index 39d9b508..a92e9ef3 100644 --- a/perf/main.py +++ b/perf/main.py @@ -1,5 +1,5 @@ """ -BlackSheep performance benchmarking suite. +Dreaming Electric Sheep performance benchmarking suite. Refer to the README for details. """ @@ -18,7 +18,7 @@ import psutil -from blacksheep import __version__ as blacksheep_version +from dreaming_electric_sheep import __version__ as dreaming_electric_sheep_version def collect_benchmark_functions(package_name: str): @@ -128,7 +128,7 @@ def get_system_info(): "memory_total": psutil.virtual_memory().total, "python_version": sys.version.split()[0], "python_implementation": platform.python_implementation(), - "blacksheep_version": blacksheep_version, + "dreaming_electric_sheep_version": dreaming_electric_sheep_version, "platform": platform.platform(), } @@ -178,7 +178,7 @@ def save_results(results, output_dir="./benchmark_results"): timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") unique_id = uuid.uuid4().hex[:8] # Generate a short UUID - filename = f"{output_dir}/blacksheep_perf_{git_hash}_{timestamp}_{unique_id}.json" + filename = f"{output_dir}/dreaming_electric_sheep_perf_{git_hash}_{timestamp}_{unique_id}.json" with open(filename, "w") as f: json.dump(results, f, indent=2) @@ -188,7 +188,7 @@ def save_results(results, output_dir="./benchmark_results"): if __name__ == "__main__": - parser = argparse.ArgumentParser(description="BlackSheep Performance Benchmarking") + parser = argparse.ArgumentParser(description="Dreaming Electric Sheep Performance Benchmarking") parser.add_argument( "--iterations", type=int, default=100000, help="Number of iterations" ) diff --git a/setup.py b/setup.py index 95fc9cec..0ae95f85 100644 --- a/setup.py +++ b/setup.py @@ -1,9 +1,8 @@ """ This file is used to specify Python extensions, which are used when using Cython. Extensions are used only if the current runtime is CPython and only if there is not an -environment variable: `BLACKSHEEP_NO_EXTENSIONS=1`. -The logic is to support PyPy. See: -https://github.com/Neoteroi/BlackSheep/issues/539#issuecomment-2888631226 +environment variable: `DREAMING_ELECTRIC_SHEEP_NO_EXTENSIONS=1`. +The logic is to support PyPy. """ import os @@ -13,49 +12,52 @@ COMPILE_ARGS = ["-O2"] # Check for environment variable to skip extensions -skip_ext = os.environ.get("BLACKSHEEP_NO_EXTENSIONS", "0") == "1" +skip_ext = ( + os.environ.get("DREAMING_ELECTRIC_SHEEP_NO_EXTENSIONS", "0") == "1" + or os.environ.get("BLACKSHEEP_NO_EXTENSIONS", "0") == "1" +) if platform.python_implementation() == "CPython" and not skip_ext: ext_modules = [ Extension( - "blacksheep.url", - ["blacksheep/url.c"], + "dreaming_electric_sheep.url", + ["dreaming_electric_sheep/url.c"], extra_compile_args=COMPILE_ARGS, ), Extension( - "blacksheep.exceptions", - ["blacksheep/exceptions.c"], + "dreaming_electric_sheep.exceptions", + ["dreaming_electric_sheep/exceptions.c"], extra_compile_args=COMPILE_ARGS, ), Extension( - "blacksheep.headers", - ["blacksheep/headers.c"], + "dreaming_electric_sheep.headers", + ["dreaming_electric_sheep/headers.c"], extra_compile_args=COMPILE_ARGS, ), Extension( - "blacksheep.cookies", - ["blacksheep/cookies.c"], + "dreaming_electric_sheep.cookies", + ["dreaming_electric_sheep/cookies.c"], extra_compile_args=COMPILE_ARGS, ), Extension( - "blacksheep.contents", - ["blacksheep/contents.c"], + "dreaming_electric_sheep.contents", + ["dreaming_electric_sheep/contents.c"], extra_compile_args=COMPILE_ARGS, ), Extension( - "blacksheep.messages", - ["blacksheep/messages.c"], + "dreaming_electric_sheep.messages", + ["dreaming_electric_sheep/messages.c"], extra_compile_args=COMPILE_ARGS, ), Extension( - "blacksheep.scribe", - ["blacksheep/scribe.c"], + "dreaming_electric_sheep.scribe", + ["dreaming_electric_sheep/scribe.c"], extra_compile_args=COMPILE_ARGS, ), Extension( - "blacksheep.baseapp", - ["blacksheep/baseapp.c"], + "dreaming_electric_sheep.baseapp", + ["dreaming_electric_sheep/baseapp.c"], extra_compile_args=COMPILE_ARGS, ), ] @@ -63,3 +65,4 @@ ext_modules = [] setup(ext_modules=ext_modules) + diff --git a/tests/client/__init__.py b/tests/client/__init__.py index e69fe8f4..ed527d23 100644 --- a/tests/client/__init__.py +++ b/tests/client/__init__.py @@ -1,4 +1,4 @@ -"""These classes implement the interface used by BlackSheep HTTP client implementation, to simplify testing on the +"""These classes implement the interface used by Dreaming Electric Sheep HTTP client implementation, to simplify testing on the ClientSession object; including handling of connections and requests timeouts; redirects, etc. """ diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 195aa745..f29e294b 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -1,11 +1,11 @@ import pytest -from blacksheep import URL, Request, Response -from blacksheep.client import ClientSession -from blacksheep.client.connection import ConnectionClosedError -from blacksheep.client.exceptions import UnsupportedRedirect -from blacksheep.client.session import normalize_headers -from blacksheep.contents import TextContent +from dreaming_electric_sheep import URL, Request, Response +from dreaming_electric_sheep.client import ClientSession +from dreaming_electric_sheep.client.connection import ConnectionClosedError +from dreaming_electric_sheep.client.exceptions import UnsupportedRedirect +from dreaming_electric_sheep.client.session import normalize_headers +from dreaming_electric_sheep.contents import TextContent @pytest.mark.parametrize( diff --git a/tests/client/test_cookiejar.py b/tests/client/test_cookiejar.py index caee892a..a3b3ed2f 100644 --- a/tests/client/test_cookiejar.py +++ b/tests/client/test_cookiejar.py @@ -2,17 +2,17 @@ import pytest -from blacksheep import URL, Cookie, Response, TextContent -from blacksheep.client import ClientSession -from blacksheep.client.cookies import ( +from dreaming_electric_sheep import URL, Cookie, Response, TextContent +from dreaming_electric_sheep.client import ClientSession +from dreaming_electric_sheep.client.cookies import ( CookieJar, InvalidCookieDomain, MissingSchemeInURL, StoredCookie, ) -from blacksheep.cookies import datetime_from_cookie_format -from blacksheep.scribe import write_response_cookie -from blacksheep.utils.time import utcnow +from dreaming_electric_sheep.cookies import datetime_from_cookie_format +from dreaming_electric_sheep.scribe import write_response_cookie +from dreaming_electric_sheep.utils.time import utcnow from . import FakePools diff --git a/tests/client/test_headers.py b/tests/client/test_headers.py index 80e8e801..892746c9 100644 --- a/tests/client/test_headers.py +++ b/tests/client/test_headers.py @@ -1,5 +1,5 @@ -from blacksheep import Response, TextContent -from blacksheep.client import ClientSession +from dreaming_electric_sheep import Response, TextContent +from dreaming_electric_sheep.client import ClientSession from . import FakePools diff --git a/tests/client/test_middlewares.py b/tests/client/test_middlewares.py index e57459af..bc5254da 100644 --- a/tests/client/test_middlewares.py +++ b/tests/client/test_middlewares.py @@ -1,5 +1,5 @@ -from blacksheep import Response, TextContent -from blacksheep.client import ClientSession +from dreaming_electric_sheep import Response, TextContent +from dreaming_electric_sheep.client import ClientSession from . import FakePools diff --git a/tests/client/test_pool.py b/tests/client/test_pool.py index 368d6ff3..bb299393 100644 --- a/tests/client/test_pool.py +++ b/tests/client/test_pool.py @@ -2,13 +2,13 @@ import pytest -from blacksheep.client.connection import ( +from dreaming_electric_sheep.client.connection import ( INSECURE_SSLCONTEXT, SECURE_SSLCONTEXT, ) -from blacksheep.client.pool import ConnectionPool, get_ssl_context -from blacksheep.exceptions import InvalidArgument -from blacksheep.utils.aio import get_running_loop +from dreaming_electric_sheep.client.pool import ConnectionPool, get_ssl_context +from dreaming_electric_sheep.exceptions import InvalidArgument +from dreaming_electric_sheep.utils.aio import get_running_loop example_context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_CLIENT) example_context.check_hostname = False diff --git a/tests/client/test_query.py b/tests/client/test_query.py index 1bc6d937..164e9918 100644 --- a/tests/client/test_query.py +++ b/tests/client/test_query.py @@ -1,7 +1,7 @@ import pytest -from blacksheep import Response, TextContent -from blacksheep.client import ClientSession +from dreaming_electric_sheep import Response, TextContent +from dreaming_electric_sheep.client import ClientSession from . import FakePools diff --git a/tests/client/test_redirects.py b/tests/client/test_redirects.py index 72b0a32e..7a12026d 100644 --- a/tests/client/test_redirects.py +++ b/tests/client/test_redirects.py @@ -2,14 +2,14 @@ import pytest -from blacksheep import URL, HTMLContent, Response, TextContent -from blacksheep.client import ( +from dreaming_electric_sheep import URL, HTMLContent, Response, TextContent +from dreaming_electric_sheep.client import ( CircularRedirectError, ClientSession, MaximumRedirectsExceededError, ) -from blacksheep.client.exceptions import MissingLocationForRedirect -from blacksheep.client.session import RedirectsCache +from dreaming_electric_sheep.client.exceptions import MissingLocationForRedirect +from dreaming_electric_sheep.client.session import RedirectsCache from . import FakePools diff --git a/tests/client/test_timeouts.py b/tests/client/test_timeouts.py index 2ddabcc1..d3b52069 100644 --- a/tests/client/test_timeouts.py +++ b/tests/client/test_timeouts.py @@ -1,6 +1,6 @@ import pytest -from blacksheep.client import ClientSession, ConnectionTimeout, RequestTimeout +from dreaming_electric_sheep.client import ClientSession, ConnectionTimeout, RequestTimeout from . import FakePools diff --git a/tests/conftest.py b/tests/conftest.py index d539141a..0b735c95 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,8 +3,8 @@ import pytest -from blacksheep.server.rendering.jinja2 import JinjaRenderer -from blacksheep.settings.html import html_settings +from dreaming_electric_sheep.server.rendering.jinja2 import JinjaRenderer +from dreaming_electric_sheep.settings.html import html_settings from tests.utils.application import FakeApplication # configures default Jinja settings for tests diff --git a/tests/test_application2.py b/tests/test_application2.py index 1e16476f..886d6085 100644 --- a/tests/test_application2.py +++ b/tests/test_application2.py @@ -1,8 +1,8 @@ from pydantic import BaseModel -from blacksheep.server.bindings import FromJSON -from blacksheep.testing.helpers import get_example_scope -from blacksheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep.server.bindings import FromJSON +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend class CustomClass: diff --git a/tests/test_auth.py b/tests/test_auth.py index 9931631e..cd0d5096 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -12,16 +12,16 @@ from pytest import raises from rodi import Container -from blacksheep import Response, TextContent -from blacksheep.messages import Request -from blacksheep.server.application import Application -from blacksheep.server.authentication import ( +from dreaming_electric_sheep import Response, TextContent +from dreaming_electric_sheep.messages import Request +from dreaming_electric_sheep.server.application import Application +from dreaming_electric_sheep.server.authentication import ( AuthenticateChallenge, AuthenticationHandler, handle_authentication_challenge, ) -from blacksheep.server.authentication.jwt import JWTBearerAuthentication -from blacksheep.server.authorization import ( +from dreaming_electric_sheep.server.authentication.jwt import JWTBearerAuthentication +from dreaming_electric_sheep.server.authorization import ( AuthorizationWithoutAuthenticationError, Requirement, allow_anonymous, @@ -30,10 +30,10 @@ handle_forbidden, handle_unauthorized, ) -from blacksheep.server.di import di_scope_middleware, register_http_context -from blacksheep.server.resources import get_resource_file_path -from blacksheep.testing.helpers import get_example_scope -from blacksheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep.server.di import di_scope_middleware, register_http_context +from dreaming_electric_sheep.server.resources import get_resource_file_path +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend from tests.test_files_serving import get_folder_path from tests.utils.application import FakeApplication @@ -325,7 +325,7 @@ async def home(): async def test_authorization_supports_allow_anonymous(app): - from blacksheep.server.responses import text + from dreaming_electric_sheep.server.responses import text app.use_authentication().add(MockNotAuthHandler()) @@ -1031,7 +1031,7 @@ async def test_jwt_openid_tokens_handler_authenticate_returns_identity( Verifies that JWTOpenIDTokensHandler.authenticate returns the identity from the inner auth_handler instead of discarding it (regression test for issue #673). """ - from blacksheep.server.authentication.oidc import JWTOpenIDTokensHandler + from dreaming_electric_sheep.server.authentication.oidc import JWTOpenIDTokensHandler jwt_auth = JWTBearerAuthentication( valid_audiences=["test-audience"], @@ -1086,7 +1086,7 @@ async def test_jwt_openid_tokens_handler_authenticate_with_refresh_token( """ from itsdangerous import URLSafeSerializer - from blacksheep.server.authentication.oidc import ( + from dreaming_electric_sheep.server.authentication.oidc import ( HTMLStorageType, JWTOpenIDTokensHandler, ) diff --git a/tests/test_auth_api_key.py b/tests/test_auth_api_key.py index 04fe1824..acfbaece 100644 --- a/tests/test_auth_api_key.py +++ b/tests/test_auth_api_key.py @@ -2,14 +2,14 @@ from essentials.secrets import Secret from guardpost import Identity -from blacksheep.server.authentication.apikey import ( +from dreaming_electric_sheep.server.authentication.apikey import ( APIKey, APIKeyAuthentication, APIKeyLocation, APIKeysProvider, ) -from blacksheep.testing.helpers import get_example_scope -from blacksheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend from tests.utils.application import FakeApplication diff --git a/tests/test_auth_basic.py b/tests/test_auth_basic.py index c1d12ab5..0c1db5cc 100644 --- a/tests/test_auth_basic.py +++ b/tests/test_auth_basic.py @@ -4,13 +4,13 @@ from essentials.secrets import Secret from guardpost import Identity -from blacksheep.server.authentication.basic import ( +from dreaming_electric_sheep.server.authentication.basic import ( BasicAuthentication, BasicCredentials, BasicCredentialsProvider, ) -from blacksheep.testing.helpers import get_example_scope -from blacksheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend from tests.utils.application import FakeApplication diff --git a/tests/test_auth_cookie.py b/tests/test_auth_cookie.py index 07d7a914..b416d31e 100644 --- a/tests/test_auth_cookie.py +++ b/tests/test_auth_cookie.py @@ -3,10 +3,10 @@ from essentials.secrets import Secret from guardpost import Identity -from blacksheep.messages import Request, Response -from blacksheep.server.authentication.cookie import CookieAuthentication -from blacksheep.server.dataprotection import generate_secret -from blacksheep.utils.time import utcnow +from dreaming_electric_sheep.messages import Request, Response +from dreaming_electric_sheep.server.authentication.cookie import CookieAuthentication +from dreaming_electric_sheep.server.dataprotection import generate_secret +from dreaming_electric_sheep.utils.time import utcnow def get_auth_cookie(handler: CookieAuthentication, data: Any) -> str: diff --git a/tests/test_caching.py b/tests/test_caching.py index 3b97c45e..6e8a1c18 100644 --- a/tests/test_caching.py +++ b/tests/test_caching.py @@ -1,14 +1,14 @@ import pytest -from blacksheep.server.controllers import Controller -from blacksheep.server.headers.cache import ( +from dreaming_electric_sheep.server.controllers import Controller +from dreaming_electric_sheep.server.headers.cache import ( CacheControlMiddleware, cache_control, write_cache_control_response_header, ) -from blacksheep.server.routing import RoutesRegistry -from blacksheep.testing.helpers import get_example_scope -from blacksheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep.server.routing import RoutesRegistry +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend CACHE_CONTROL_PARAMS_EXPECTED = [ ({"max_age": 0}, b"max-age=0"), diff --git a/tests/test_contents.py b/tests/test_contents.py index 89c2f425..9faf1966 100644 --- a/tests/test_contents.py +++ b/tests/test_contents.py @@ -1,7 +1,7 @@ import pytest -from blacksheep import JSONContent, Request -from blacksheep.contents import ( +from dreaming_electric_sheep import JSONContent, Request +from dreaming_electric_sheep.contents import ( FormPart, HTMLContent, MultiPartFormData, @@ -10,12 +10,12 @@ parse_www_form, write_www_form_urlencoded, ) -from blacksheep.multipart import ( +from dreaming_electric_sheep.multipart import ( get_boundary_from_header, parse_content_disposition_values, parse_multipart, ) -from blacksheep.scribe import write_chunks +from dreaming_electric_sheep.scribe import write_chunks async def test_chunked_encoding_with_generated_content(): diff --git a/tests/test_controllers.py b/tests/test_controllers.py index aef9f52d..7fba0b4c 100644 --- a/tests/test_controllers.py +++ b/tests/test_controllers.py @@ -7,22 +7,22 @@ from pydantic import Field from rodi import inject -from blacksheep.messages import Request, Response -from blacksheep.server.application import Application -from blacksheep.server.controllers import ( +from dreaming_electric_sheep.messages import Request, Response +from dreaming_electric_sheep.server.application import Application +from dreaming_electric_sheep.server.controllers import ( APIController, Controller, RoutesRegistry, abstract, filters, ) -from blacksheep.server.di import register_http_context -from blacksheep.server.responses import text -from blacksheep.server.routing import RouteDuplicate -from blacksheep.server.websocket import WebSocket -from blacksheep.testing.helpers import get_example_scope -from blacksheep.testing.messages import MockReceive, MockSend -from blacksheep.utils import ensure_str +from dreaming_electric_sheep.server.di import register_http_context +from dreaming_electric_sheep.server.responses import text +from dreaming_electric_sheep.server.routing import RouteDuplicate +from dreaming_electric_sheep.server.websocket import WebSocket +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep.utils import ensure_str from tests.test_files_serving import get_file_path from tests.utils.application import FakeApplication diff --git a/tests/test_cookies.py b/tests/test_cookies.py index 498ff3e6..68b5c1cf 100644 --- a/tests/test_cookies.py +++ b/tests/test_cookies.py @@ -2,7 +2,7 @@ import pytest -from blacksheep import ( +from dreaming_electric_sheep import ( Cookie, CookieSameSiteMode, datetime_from_cookie_format, @@ -10,7 +10,7 @@ parse_cookie, scribe, ) -from blacksheep.cookies import CookieValueExceedsMaximumLength +from dreaming_electric_sheep.cookies import CookieValueExceedsMaximumLength COOKIES = [ ( diff --git a/tests/test_cors.py b/tests/test_cors.py index 5a0981a7..ce6dd85b 100644 --- a/tests/test_cors.py +++ b/tests/test_cors.py @@ -1,18 +1,18 @@ import pytest -from blacksheep.exceptions import BadRequest -from blacksheep.server.application import ApplicationAlreadyStartedCORSError -from blacksheep.server.cors import ( +from dreaming_electric_sheep.exceptions import BadRequest +from dreaming_electric_sheep.server.application import ApplicationAlreadyStartedCORSError +from dreaming_electric_sheep.server.cors import ( CORSConfigurationError, CORSPolicy, CORSPolicyNotConfiguredError, CORSStrategy, NotRequestHandlerError, ) -from blacksheep.server.responses import text -from blacksheep.server.routing import Router -from blacksheep.testing.helpers import get_example_scope -from blacksheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep.server.responses import text +from dreaming_electric_sheep.server.routing import Router +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend def test_app_raises_type_error_for_cors_property(app): diff --git a/tests/test_csrf.py b/tests/test_csrf.py index 3942ff3f..a8b17c89 100644 --- a/tests/test_csrf.py +++ b/tests/test_csrf.py @@ -2,16 +2,16 @@ import pytest -from blacksheep.contents import write_www_form_urlencoded -from blacksheep.messages import Response -from blacksheep.server.controllers import Controller -from blacksheep.server.csrf import ignore_anti_forgery, use_anti_forgery -from blacksheep.server.rendering.jinja2 import AntiForgeryBaseExtension, JinjaRenderer -from blacksheep.server.responses import no_content, view, view_async -from blacksheep.server.routing import RoutesRegistry -from blacksheep.settings.html import html_settings -from blacksheep.testing.helpers import get_example_scope -from blacksheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep.contents import write_www_form_urlencoded +from dreaming_electric_sheep.messages import Response +from dreaming_electric_sheep.server.controllers import Controller +from dreaming_electric_sheep.server.csrf import ignore_anti_forgery, use_anti_forgery +from dreaming_electric_sheep.server.rendering.jinja2 import AntiForgeryBaseExtension, JinjaRenderer +from dreaming_electric_sheep.server.responses import no_content, view, view_async +from dreaming_electric_sheep.server.routing import RoutesRegistry +from dreaming_electric_sheep.settings.html import html_settings +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend from tests.utils.application import FakeApplication @@ -144,7 +144,7 @@ async def home(): text = await app.response.text() assert text is not None assert ( - "blacksheep.server.csrf.MissingRequestContextError: The request context is " + "dreaming_electric_sheep.server.csrf.MissingRequestContextError: The request context is " "missing from the render call. Pass the request object to the context of the " "template." ) in text diff --git a/tests/test_dataprotection.py b/tests/test_dataprotection.py index 42d9c133..202b86b8 100644 --- a/tests/test_dataprotection.py +++ b/tests/test_dataprotection.py @@ -5,7 +5,7 @@ from essentials.secrets import Secret from itsdangerous import BadSignature -from blacksheep.server.dataprotection import generate_secret, get_keys, get_serializer +from dreaming_electric_sheep.server.dataprotection import generate_secret, get_keys, get_serializer def get_secrets() -> list[Secret]: diff --git a/tests/test_encodings.py b/tests/test_encodings.py index 07748ee1..0cc02579 100644 --- a/tests/test_encodings.py +++ b/tests/test_encodings.py @@ -1,9 +1,9 @@ from dataclasses import dataclass -from blacksheep.server.responses import ok -from blacksheep.server.routing import Router -from blacksheep.testing.helpers import get_example_scope -from blacksheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep.server.responses import ok +from dreaming_electric_sheep.server.routing import Router +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend from tests.utils.application import FakeApplication diff --git a/tests/test_env.py b/tests/test_env.py index 0e3885e8..19be2b1a 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -3,7 +3,7 @@ import pytest -from blacksheep.server.env import EnvironmentSettings +from dreaming_electric_sheep.server.env import EnvironmentSettings def test_env_settings(): diff --git a/tests/test_file_buffer.py b/tests/test_file_buffer.py index ec96390d..1443f62f 100644 --- a/tests/test_file_buffer.py +++ b/tests/test_file_buffer.py @@ -6,10 +6,10 @@ import pytest -from blacksheep.contents import FileBuffer -from blacksheep.server.bindings import FromForm -from blacksheep.testing.helpers import get_example_scope -from blacksheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep.contents import FileBuffer +from dreaming_electric_sheep.server.bindings import FromForm +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend from tests.utils.application import FakeApplication diff --git a/tests/test_filedata_openapi_integration.py b/tests/test_filedata_openapi_integration.py index beeecdb6..f69791bd 100644 --- a/tests/test_filedata_openapi_integration.py +++ b/tests/test_filedata_openapi_integration.py @@ -5,9 +5,9 @@ import pytest from openapidocs.v3 import Info, ValueFormat, ValueType -from blacksheep import Application, FileBuffer -from blacksheep.server.openapi.common import DefaultSerializer -from blacksheep.server.openapi.v3 import OpenAPIHandler +from dreaming_electric_sheep import Application, FileBuffer +from dreaming_electric_sheep.server.openapi.common import DefaultSerializer +from dreaming_electric_sheep.server.openapi.v3 import OpenAPIHandler def get_test_app(): diff --git a/tests/test_files_handler.py b/tests/test_files_handler.py index 6116d1ae..4e5145f6 100644 --- a/tests/test_files_handler.py +++ b/tests/test_files_handler.py @@ -6,7 +6,7 @@ import pytest -from blacksheep.common.files.asyncfs import FileContext, FilesHandler +from dreaming_electric_sheep.common.files.asyncfs import FileContext, FilesHandler @pytest.fixture() diff --git a/tests/test_files_serving.py b/tests/test_files_serving.py index 17817ff0..2aa5c6c9 100644 --- a/tests/test_files_serving.py +++ b/tests/test_files_serving.py @@ -6,12 +6,12 @@ import pytest from essentials.folders import get_file_extension -from blacksheep import Application, Request -from blacksheep.common.files.asyncfs import FileContext, FilesHandler -from blacksheep.exceptions import BadRequest, InvalidArgument -from blacksheep.messages import Response -from blacksheep.ranges import Range, RangePart -from blacksheep.server.files import ( +from dreaming_electric_sheep import Application, Request +from dreaming_electric_sheep.common.files.asyncfs import FileContext, FilesHandler +from dreaming_electric_sheep.exceptions import BadRequest, InvalidArgument +from dreaming_electric_sheep.messages import Response +from dreaming_electric_sheep.ranges import Range, RangePart +from dreaming_electric_sheep.server.files import ( DefaultFileOptions, FileInfo, RangeNotSatisfiable, @@ -20,15 +20,15 @@ get_range_file_getter, validate_source_path, ) -from blacksheep.server.files.dynamic import get_response_for_file -from blacksheep.server.files.static import get_response_for_static_content -from blacksheep.server.headers.cache import CacheControlHeaderValue -from blacksheep.server.resources import get_resource_file_path -from blacksheep.server.responses import text -from blacksheep.server.routing import Router -from blacksheep.testing.helpers import get_example_scope -from blacksheep.testing.messages import MockReceive, MockSend -from blacksheep.utils.aio import get_running_loop +from dreaming_electric_sheep.server.files.dynamic import get_response_for_file +from dreaming_electric_sheep.server.files.static import get_response_for_static_content +from dreaming_electric_sheep.server.headers.cache import CacheControlHeaderValue +from dreaming_electric_sheep.server.resources import get_resource_file_path +from dreaming_electric_sheep.server.responses import text +from dreaming_electric_sheep.server.routing import Router +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep.utils.aio import get_running_loop def get_folder_path(folder_name: str) -> str: @@ -947,7 +947,7 @@ async def test_files_served_with_content_length_for_a2wsgi(): obtained from os.stat() to set Content-Length instead of using chunked transfer encoding. """ - from blacksheep.scribe import set_headers_for_response_content + from dreaming_electric_sheep.scribe import set_headers_for_response_content test_file = get_file_path("example.txt") diff --git a/tests/test_forwarding.py b/tests/test_forwarding.py index 2c458b82..c96f97bf 100644 --- a/tests/test_forwarding.py +++ b/tests/test_forwarding.py @@ -2,15 +2,15 @@ import pytest -from blacksheep.server.remotes.forwarding import ( +from dreaming_electric_sheep.server.remotes.forwarding import ( ForwardedHeaderEntry, ForwardedHeadersMiddleware, XForwardedHeadersMiddleware, parse_forwarded_header, ) -from blacksheep.server.remotes.hosts import TrustedHostsMiddleware -from blacksheep.testing.helpers import get_example_scope -from blacksheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep.server.remotes.hosts import TrustedHostsMiddleware +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend from tests.utils.application import FakeApplication diff --git a/tests/test_gzip.py b/tests/test_gzip.py index 0242bc5b..8e14d32b 100644 --- a/tests/test_gzip.py +++ b/tests/test_gzip.py @@ -2,9 +2,9 @@ import pytest -from blacksheep.server.compression import GzipMiddleware -from blacksheep.testing.helpers import get_example_scope -from blacksheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep.server.compression import GzipMiddleware +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend @pytest.mark.parametrize( diff --git a/tests/test_headers.py b/tests/test_headers.py index 0dcae347..c494e6fa 100644 --- a/tests/test_headers.py +++ b/tests/test_headers.py @@ -1,6 +1,6 @@ import pytest -from blacksheep import Header, Headers +from dreaming_electric_sheep import Header, Headers @pytest.mark.parametrize( diff --git a/tests/test_middlewares.py b/tests/test_middlewares.py index 2cbbb21e..b03fef86 100644 --- a/tests/test_middlewares.py +++ b/tests/test_middlewares.py @@ -1,7 +1,7 @@ import pytest -from blacksheep.messages import Response -from blacksheep.middlewares import ( +from dreaming_electric_sheep.messages import Response +from dreaming_electric_sheep.middlewares import ( CategorizedMiddleware, MiddlewareCategory, MiddlewareList, diff --git a/tests/test_multipart.py b/tests/test_multipart.py index aaa2ee2e..1286dcab 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -3,8 +3,8 @@ import pytest -from blacksheep.contents import MultiPartFormData -from blacksheep.multipart import ( +from dreaming_electric_sheep.contents import MultiPartFormData +from dreaming_electric_sheep.multipart import ( FormPart, _remove_last_crlf, parse_multipart, diff --git a/tests/test_normalization.py b/tests/test_normalization.py index 5d65c890..dfcfb03f 100644 --- a/tests/test_normalization.py +++ b/tests/test_normalization.py @@ -8,8 +8,8 @@ from pytest import raises from rodi import Container, Services, inject -from blacksheep import Request -from blacksheep.server.bindings import ( +from dreaming_electric_sheep import Request +from dreaming_electric_sheep.server.bindings import ( Binder, BoundValue, ExactBinder, @@ -26,7 +26,7 @@ RouteBinder, ServiceBinder, ) -from blacksheep.server.normalization import ( +from dreaming_electric_sheep.server.normalization import ( AmbiguousMethodSignatureError, NormalizationError, RouteBinderMismatch, @@ -38,7 +38,7 @@ normalize_handler, normalize_middleware, ) -from blacksheep.server.routing import Route +from dreaming_electric_sheep.server.routing import Route class Pet: @@ -679,8 +679,8 @@ async def example_2() -> AsyncIterable[str]: from dataclasses import dataclass -from blacksheep import FormContent, JSONContent, Request -from blacksheep.server.bindings import ( +from dreaming_electric_sheep import FormContent, JSONContent, Request +from dreaming_electric_sheep.server.bindings import ( FormBinder, FromBody, FromBodyBinder, @@ -739,7 +739,7 @@ async def test_union_body_binder_dispatches_correctly_in_handler(): async def handler(data: FromJSON[NormItem] | FromForm[NormItem]): return data - from blacksheep.server.normalization import normalize_handler + from dreaming_electric_sheep.server.normalization import normalize_handler route = Route(b"/", handler) normalized = normalize_handler(route, Container()) @@ -771,7 +771,7 @@ def handler(data: FromJSON[NormItem] | None): ... def test_union_with_xml_creates_multi_format_binder(): """FromJSON[T] | FromXML[T] should produce a MultiFormatBodyBinder.""" - from blacksheep.server.bindings import FromXML, XMLBinder + from dreaming_electric_sheep.server.bindings import FromXML, XMLBinder def handler(data: FromJSON[NormItem] | FromXML[NormItem]): ... diff --git a/tests/test_openapi_docstrings.py b/tests/test_openapi_docstrings.py index 10792e3a..9d41a781 100644 --- a/tests/test_openapi_docstrings.py +++ b/tests/test_openapi_docstrings.py @@ -1,7 +1,7 @@ import pytest -from blacksheep.server.openapi.common import ParameterInfo -from blacksheep.server.openapi.docstrings import ( +from dreaming_electric_sheep.server.openapi.common import ParameterInfo +from dreaming_electric_sheep.server.openapi.docstrings import ( DocstringInfo, EpytextDialect, GoogleDocDialect, diff --git a/tests/test_openapi_v3.py b/tests/test_openapi_v3.py index abb994aa..a595613b 100644 --- a/tests/test_openapi_v3.py +++ b/tests/test_openapi_v3.py @@ -36,12 +36,12 @@ conint, ) -from blacksheep.contents import FileBuffer -from blacksheep.messages import Response -from blacksheep.server.application import Application -from blacksheep.server.bindings import FromFiles, FromForm, FromText -from blacksheep.server.controllers import APIController -from blacksheep.server.openapi.common import ( +from dreaming_electric_sheep.contents import FileBuffer +from dreaming_electric_sheep.messages import Response +from dreaming_electric_sheep.server.application import Application +from dreaming_electric_sheep.server.bindings import FromFiles, FromForm, FromText +from dreaming_electric_sheep.server.controllers import APIController +from dreaming_electric_sheep.server.openapi.common import ( ContentInfo, DefaultSerializer, EndpointDocs, @@ -49,15 +49,15 @@ ResponseInfo, SecurityInfo, ) -from blacksheep.server.openapi.exceptions import DuplicatedContentTypeDocsException -from blacksheep.server.openapi.v3 import ( +from dreaming_electric_sheep.server.openapi.exceptions import DuplicatedContentTypeDocsException +from dreaming_electric_sheep.server.openapi.v3 import ( DataClassTypeHandler, OpenAPIHandler, PydanticModelTypeHandler, Tag, check_union, ) -from blacksheep.server.routing import RoutesRegistry +from dreaming_electric_sheep.server.routing import RoutesRegistry GenericModel = BaseModel @@ -4630,7 +4630,7 @@ async def test_multi_format_union_body_generates_all_content_types( docs: OpenAPIHandler, serializer: Serializer ): """Union annotation FromJSON[T] | FromForm[T] should document all content types.""" - from blacksheep.server.bindings import FromJSON + from dreaming_electric_sheep.server.bindings import FromJSON app = get_app() @@ -4656,7 +4656,7 @@ async def test_from_body_generates_json_and_form_content_types( docs: OpenAPIHandler, serializer: Serializer, monkeypatch ): """FromBody[T] should document both JSON and form content types.""" - from blacksheep.server.bindings import ( + from dreaming_electric_sheep.server.bindings import ( FormBinder, FromBody, FromBodyBinder, @@ -4686,7 +4686,7 @@ async def test_optional_union_body_generates_not_required( docs: OpenAPIHandler, serializer: Serializer ): """FromJSON[T] | FromForm[T] | None should emit required: false.""" - from blacksheep.server.bindings import FromJSON + from dreaming_electric_sheep.server.bindings import FromJSON app = get_app() @@ -4708,7 +4708,7 @@ async def test_xml_body_binder_generates_xml_content_types( docs: OpenAPIHandler, serializer: Serializer ): """FromXML[T] should document application/xml and text/xml content types.""" - from blacksheep.server.bindings import FromXML + from dreaming_electric_sheep.server.bindings import FromXML app = get_app() @@ -4729,7 +4729,7 @@ async def test_json_xml_union_generates_all_content_types( docs: OpenAPIHandler, serializer: Serializer ): """FromJSON[T] | FromXML[T] should document both JSON and XML content types.""" - from blacksheep.server.bindings import FromJSON, FromXML + from dreaming_electric_sheep.server.bindings import FromJSON, FromXML app = get_app() @@ -4759,8 +4759,8 @@ async def test_sse_async_iterable_does_not_crash_openapi( docs: OpenAPIHandler, serializer: Serializer ): """AsyncIterable[ServerSentEvent] return type must not crash the OpenAPI generator.""" - from blacksheep.server.controllers import Controller - from blacksheep.server.sse import ServerSentEvent + from dreaming_electric_sheep.server.controllers import Controller + from dreaming_electric_sheep.server.sse import ServerSentEvent app = get_app() diff --git a/tests/test_pathutils.py b/tests/test_pathutils.py index 2c9d5158..55a2b2cd 100644 --- a/tests/test_pathutils.py +++ b/tests/test_pathutils.py @@ -2,7 +2,7 @@ import pytest -from blacksheep.common.files.pathsutils import ( +from dreaming_electric_sheep.common.files.pathsutils import ( get_file_extension_from_name, get_mime_type_from_name, ) diff --git a/tests/test_piccolo_admin_compat.py b/tests/test_piccolo_admin_compat.py index be052350..de851517 100644 --- a/tests/test_piccolo_admin_compat.py +++ b/tests/test_piccolo_admin_compat.py @@ -25,8 +25,8 @@ from starlette.routing import Mount, Route from starlette.staticfiles import StaticFiles -from blacksheep.testing.helpers import get_example_scope -from blacksheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend from tests.utils.application import FakeApplication # --------------------------------------------------------------------------- diff --git a/tests/test_ranges.py b/tests/test_ranges.py index 6fe938b0..6e2de7f0 100644 --- a/tests/test_ranges.py +++ b/tests/test_ranges.py @@ -1,6 +1,6 @@ import pytest -from blacksheep.ranges import InvalidRangeValue, Range, RangePart +from dreaming_electric_sheep.ranges import InvalidRangeValue, Range, RangePart @pytest.mark.parametrize( diff --git a/tests/test_redirects.py b/tests/test_redirects.py index 73584b7a..9b03e191 100644 --- a/tests/test_redirects.py +++ b/tests/test_redirects.py @@ -1,11 +1,11 @@ import pytest -from blacksheep.messages import Request -from blacksheep.server.redirects import ( +from dreaming_electric_sheep.messages import Request +from dreaming_electric_sheep.server.redirects import ( default_trailing_slash_exclude, get_trailing_slash_middleware, ) -from blacksheep.server.responses import text +from dreaming_electric_sheep.server.responses import text class TestDefaultTrailingSlashExclude: diff --git a/tests/test_requests.py b/tests/test_requests.py index 7a661a58..89ec9707 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -1,16 +1,16 @@ import pytest -from blacksheep import Content, Request -from blacksheep.contents import FormPart, MultiPartFormData -from blacksheep.exceptions import BadRequestFormat -from blacksheep.messages import get_absolute_url_to_path, get_request_absolute_url -from blacksheep.server.asgi import ( +from dreaming_electric_sheep import Content, Request +from dreaming_electric_sheep.contents import FormPart, MultiPartFormData +from dreaming_electric_sheep.exceptions import BadRequestFormat +from dreaming_electric_sheep.messages import get_absolute_url_to_path, get_request_absolute_url +from dreaming_electric_sheep.server.asgi import ( get_request_url, get_request_url_from_scope, incoming_request, ) -from blacksheep.testing.helpers import get_example_scope -from blacksheep.url import URL +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.url import URL def test_request_supports_dynamic_attributes(): diff --git a/tests/test_responses.py b/tests/test_responses.py index 6c34bdbc..9c839f29 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -7,13 +7,13 @@ import pytest -from blacksheep import Content, Cookie, Response, scribe -from blacksheep.exceptions import FailedRequestError -from blacksheep.server.controllers import ( +from dreaming_electric_sheep import Content, Cookie, Response, scribe +from dreaming_electric_sheep.exceptions import FailedRequestError +from dreaming_electric_sheep.server.controllers import ( CannotDetermineDefaultViewNameError, Controller, ) -from blacksheep.server.responses import ( +from dreaming_electric_sheep.server.responses import ( ContentDispositionType, accepted, bad_request, @@ -36,9 +36,9 @@ text, unauthorized, ) -from blacksheep.server.routing import RoutesRegistry -from blacksheep.testing.helpers import get_example_scope -from blacksheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep.server.routing import RoutesRegistry +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend from tests.test_files_serving import get_file_path STATUS_METHODS_OPTIONAL_BODY = [ @@ -984,7 +984,7 @@ def greet(self): def test_get_chunks_large_data(): """Regression test for OverflowError with data > 2GB (issue #675). Uses a mock large bytearray via memoryview to avoid allocating 2GB.""" - from blacksheep.scribe import get_chunks + from dreaming_electric_sheep.scribe import get_chunks MAX_RESPONSE_CHUNK_SIZE = 61440 diff --git a/tests/test_router.py b/tests/test_router.py index 544d3b57..1f3db99d 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -1,8 +1,8 @@ import pytest -from blacksheep.messages import Request -from blacksheep.server.application import Application -from blacksheep.server.routing import ( +from dreaming_electric_sheep.messages import Request +from dreaming_electric_sheep.server.application import Application +from dreaming_electric_sheep.server.routing import ( HostFilter, InvalidValuePatternName, MountRegistry, diff --git a/tests/test_scheme.py b/tests/test_scheme.py index bcc79438..da420c9c 100644 --- a/tests/test_scheme.py +++ b/tests/test_scheme.py @@ -1,14 +1,14 @@ import pytest -from blacksheep import Request, Response -from blacksheep.contents import TextContent -from blacksheep.server.remotes.scheme import ( +from dreaming_electric_sheep import Request, Response +from dreaming_electric_sheep.contents import TextContent +from dreaming_electric_sheep.server.remotes.scheme import ( HTTPSchemeMiddleware, configure_scheme_middleware, ) -from blacksheep.server.security.hsts import HSTSMiddleware -from blacksheep.testing.helpers import get_example_scope -from blacksheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep.server.security.hsts import HSTSMiddleware +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend from tests.utils.application import FakeApplication diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 773dde4f..2d0c2351 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -2,15 +2,15 @@ import pytest -from blacksheep.cookies import parse_cookie -from blacksheep.messages import Request -from blacksheep.server.responses import text -from blacksheep.sessions import Session -from blacksheep.sessions.cookies import CookieSessionStore -from blacksheep.sessions.json import JSONSerializer -from blacksheep.sessions.memory import InMemorySessionStore -from blacksheep.testing.helpers import get_example_scope -from blacksheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep.cookies import parse_cookie +from dreaming_electric_sheep.messages import Request +from dreaming_electric_sheep.server.responses import text +from dreaming_electric_sheep.sessions import Session +from dreaming_electric_sheep.sessions.cookies import CookieSessionStore +from dreaming_electric_sheep.sessions.json import JSONSerializer +from dreaming_electric_sheep.sessions.memory import InMemorySessionStore +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend def test_friendly_exception_for_request_without_session(): diff --git a/tests/test_sse.py b/tests/test_sse.py index 9a1f71d4..f5fd5bb6 100644 --- a/tests/test_sse.py +++ b/tests/test_sse.py @@ -1,7 +1,7 @@ import pytest -from blacksheep.contents import ServerSentEvent -from blacksheep.server.sse import ServerSentEventsResponse +from dreaming_electric_sheep.contents import ServerSentEvent +from dreaming_electric_sheep.server.sse import ServerSentEventsResponse @pytest.mark.asyncio diff --git a/tests/test_templating.py b/tests/test_templating.py index 0b86430f..ff08d74a 100644 --- a/tests/test_templating.py +++ b/tests/test_templating.py @@ -3,12 +3,12 @@ import pytest from pydantic import BaseModel -from blacksheep.server.controllers import Controller, RoutesRegistry -from blacksheep.server.rendering.jinja2 import get_template_name -from blacksheep.server.responses import view, view_async -from blacksheep.settings.html import html_settings -from blacksheep.testing.helpers import get_example_scope -from blacksheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep.server.controllers import Controller, RoutesRegistry +from dreaming_electric_sheep.server.rendering.jinja2 import get_template_name +from dreaming_electric_sheep.server.responses import view, view_async +from dreaming_electric_sheep.settings.html import html_settings +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend from tests.utils.application import FakeApplication diff --git a/tests/test_testing.py b/tests/test_testing.py index 9b9a0715..87cc94e4 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -1,13 +1,13 @@ import pytest -from blacksheep import Content -from blacksheep.contents import JSONContent -from blacksheep.server.application import Application -from blacksheep.server.bindings import FromHeader -from blacksheep.server.controllers import Controller, RoutesRegistry -from blacksheep.server.responses import Response -from blacksheep.testing import AbstractTestSimulator, TestClient -from blacksheep.testing.helpers import ( +from dreaming_electric_sheep import Content +from dreaming_electric_sheep.contents import JSONContent +from dreaming_electric_sheep.server.application import Application +from dreaming_electric_sheep.server.bindings import FromHeader +from dreaming_electric_sheep.server.controllers import Controller, RoutesRegistry +from dreaming_electric_sheep.server.responses import Response +from dreaming_electric_sheep.testing import AbstractTestSimulator, TestClient +from dreaming_electric_sheep.testing.helpers import ( CookiesType, HeadersType, QueryType, diff --git a/tests/test_url.py b/tests/test_url.py index cd88fc72..4343f419 100644 --- a/tests/test_url.py +++ b/tests/test_url.py @@ -1,6 +1,6 @@ import pytest -from blacksheep.url import URL, InvalidURL, join_prefix +from dreaming_electric_sheep.url import URL, InvalidURL, join_prefix def test_empty_url(): diff --git a/tests/test_url_resolver.py b/tests/test_url_resolver.py index 41f0fc01..041cd48b 100644 --- a/tests/test_url_resolver.py +++ b/tests/test_url_resolver.py @@ -20,12 +20,12 @@ import pytest -from blacksheep import Response -from blacksheep.server.application import Application -from blacksheep.server.responses import redirect -from blacksheep.server.routing import Router, URLResolver -from blacksheep.testing.helpers import get_example_scope -from blacksheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep import Response +from dreaming_electric_sheep.server.application import Application +from dreaming_electric_sheep.server.responses import redirect +from dreaming_electric_sheep.server.routing import Router, URLResolver +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend from tests.utils.application import FakeApplication # --------------------------------------------------------------------------- @@ -284,7 +284,7 @@ async def test_url_resolver_unit_with_explicit_root_path(): Unit test: construct a URLResolver directly, set scope["root_path"] on the request, and verify url_for prepends the external base. """ - from blacksheep.messages import Request + from dreaming_electric_sheep.messages import Request router = Router() @@ -306,7 +306,7 @@ async def test_url_resolver_unit_no_root_path(): Unit test: when scope["root_path"] is absent or empty, url_for returns the plain path from Router.url_for (which already includes any router prefix). """ - from blacksheep.messages import Request + from dreaming_electric_sheep.messages import Request router = Router(prefix="/v2") diff --git a/tests/test_utils.py b/tests/test_utils.py index 1c0eb9e2..49383aaa 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,7 +2,7 @@ import pytest -from blacksheep.utils import ensure_bytes, ensure_str, join_fragments +from dreaming_electric_sheep.utils import ensure_bytes, ensure_str, join_fragments @pytest.mark.parametrize( diff --git a/tests/test_websocket.py b/tests/test_websocket.py index a617b067..666613a9 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -1,7 +1,7 @@ import pytest -from blacksheep.server.bindings import FromHeader -from blacksheep.server.websocket import ( +from dreaming_electric_sheep.server.bindings import FromHeader +from dreaming_electric_sheep.server.websocket import ( InvalidWebSocketStateError, MessageMode, WebSocket, @@ -9,8 +9,8 @@ WebSocketState, format_reason, ) -from blacksheep.testing import TestClient -from blacksheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep.testing import TestClient +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend from tests.utils.application import FakeApplication diff --git a/tests/utils/application.py b/tests/utils/application.py index d02345f3..a6137e35 100644 --- a/tests/utils/application.py +++ b/tests/utils/application.py @@ -1,7 +1,7 @@ from essentials.meta import deprecated -from blacksheep.messages import Request, Response -from blacksheep.server import Application +from dreaming_electric_sheep.messages import Request, Response +from dreaming_electric_sheep.server import Application class FakeApplication(Application): From 0e30b02610039a1b67532fbaa98f89b3ef9ccf63 Mon Sep 17 00:00:00 2001 From: EduLoboM Date: Wed, 26 Aug 2026 22:29:38 -0300 Subject: [PATCH 02/45] refactor: update .flake8 configuration for renamed package --- .flake8 | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.flake8 b/.flake8 index f74ddf0c..6e090023 100644 --- a/.flake8 +++ b/.flake8 @@ -3,10 +3,10 @@ ignore = E203, E266, W503, E704, E701, F824, C901, E501 max-line-length = 88 max-complexity = 18 per-file-ignores = - blacksheep/__init__.py:F401 - blacksheep/client/__init__.py:F401 - blacksheep/server/__init__.py:F401 - blacksheep/url.py:E501 + dreaming_electric_sheep/__init__.py:F401 + dreaming_electric_sheep/client/__init__.py:F401 + dreaming_electric_sheep/server/__init__.py:F401 + dreaming_electric_sheep/url.py:E501 tests/*:E501 itests/*:E501 itests/conftest.py:F401,F403 @@ -18,3 +18,4 @@ exclude = venv311 build .eggs + From 44dc474118b120766cf25a41e01d116703e9a51c Mon Sep 17 00:00:00 2001 From: EduLoboM Date: Wed, 26 Aug 2026 22:30:02 -0300 Subject: [PATCH 03/45] docs: update README with BlackSheep fork attribution and project banner --- README.md | 191 +++++++++++------------------------- dreaming_electric_sheep.svg | 72 -------------- 2 files changed, 55 insertions(+), 208 deletions(-) delete mode 100644 dreaming_electric_sheep.svg diff --git a/README.md b/README.md index 5dae0452..c29d428f 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,22 @@ -[![Build](https://github.com/Neoteroi/BlackSheep/workflows/Main/badge.svg)](https://github.com/Neoteroi/BlackSheep/actions) -[![pypi](https://img.shields.io/pypi/v/BlackSheep.svg?color=blue)](https://pypi.org/project/BlackSheep/) -[![versions](https://img.shields.io/pypi/pyversions/blacksheep.svg)](https://github.com/robertoprevato/blacksheep) -[![license](https://img.shields.io/github/license/Neoteroi/blacksheep.svg)](https://github.com/Neoteroi/blacksheep/blob/main/LICENSE) [![Join the chat at https://gitter.im/Neoteroi/BlackSheep](https://badges.gitter.im/Neoteroi/BlackSheep.svg)](https://gitter.im/Neoteroi/BlackSheep?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![documentation](https://img.shields.io/badge/📖-docs-purple)](https://www.neoteroi.dev/blacksheep/) +[![Build](https://github.com/EduLoboM/Dreaming-Electric-Sheep/workflows/Main/badge.svg)](https://github.com/EduLoboM/Dreaming-Electric-Sheep/actions) +[![pypi](https://img.shields.io/pypi/v/dreaming-electric-sheep.svg?color=blue)](https://pypi.org/project/dreaming-electric-sheep/) +[![versions](https://img.shields.io/pypi/pyversions/dreaming-electric-sheep.svg)](https://github.com/EduLoboM/Dreaming-Electric-Sheep) +[![license](https://img.shields.io/github/license/EduLoboM/Dreaming-Electric-Sheep.svg)](https://github.com/EduLoboM/Dreaming-Electric-Sheep/blob/main/LICENSE) -# BlackSheep +# Dreaming Electric Sheep -BlackSheep is an asynchronous web framework to build event based web -applications with Python. It is inspired by +**Dreaming Electric Sheep** is an ultra high-performance asynchronous web framework to build event-based web +applications with Python, developed as an optimized fork of [BlackSheep](https://github.com/Neoteroi/BlackSheep). It is inspired by [Flask](https://palletsprojects.com/p/flask/), [ASP.NET Core](https://docs.microsoft.com/en-us/aspnet/core/), and the work by [Yury Selivanov](https://magic.io/blog/uvloop-blazing-fast-python-networking/). -

- Black Sheep +

+ Dreaming Electric Sheep

```bash -pip install blacksheep +pip install dreaming-electric-sheep ``` --- @@ -24,7 +24,7 @@ pip install blacksheep ```python from datetime import datetime, timezone -from blacksheep import Application, get +from dreaming_electric_sheep import Application, get app = Application() @@ -35,65 +35,21 @@ async def home(): ``` -## Getting started using the CLI ✨ - -BlackSheep offers a CLI to bootstrap new projects rapidly. -To try it, first install the `blacksheep-cli` package: - -```bash -pip install blacksheep-cli -``` - -Then use the `blacksheep create` command to bootstrap a project -using one of the supported templates. - -![blacksheep create command](https://gist.githubusercontent.com/RobertoPrevato/38a0598b515a2f7257c614938843b99b/raw/67d15ba337de94c2f50d980a7b8924a747259254/blacksheep-create-demo.gif) - -The CLI includes a help, and supports custom templates, using the -same sources supported by `Cookiecutter`. - ## Dependencies -Before version `2.3.1`, BlackSheep only supported running with `CPython` and -always depended on `httptools`. Starting with version `2.3.1`, the framework -supports running on [`PyPy`](https://pypy.org/) and makes `httptools` an -optional dependency. +Dreaming Electric Sheep supports running with `CPython` and [`PyPy`](https://pypy.org/), and makes `httptools` an optional dependency. -Since version `2.5.0`, the BlackSheep HTTP Client includes HTTP/2 support and -requires `h11` and `h2` libraries. +The Dreaming Electric Sheep HTTP Client includes HTTP/2 support and requires `h11` and `h2` libraries. For slightly better performance in `URL` parsing when running on `CPython`, it is recommended to install `httptools` (optional). -> [!TIP] -> -> The best performance can be achieved using `PyPy` runtime, and -> [`Socketify`](https://docs.socketify.dev/cli.html) or [`Granian`](https://github.com/emmett-framework/granian), (see -> [#539](https://github.com/Neoteroi/BlackSheep/issues/539) for more information). - -## Getting started with the documentation - -The documentation offers getting started tutorials: -* [Getting started: - basics](https://www.neoteroi.dev/blacksheep/getting-started/) -* [Getting started: the MVC project - template](https://www.neoteroi.dev/blacksheep/mvc-project-template/) - -These project templates can be used to start new applications faster: - -* [MVC project - template](https://github.com/Neoteroi/BlackSheepMVC) -* [Empty project - template](https://github.com/Neoteroi/BlackSheepEmptyProject) - ## Requirements [Python](https://www.python.org): any version listed in the project's -classifiers. The current list is: - -[![versions](https://img.shields.io/pypi/pyversions/blacksheep.svg)](https://github.com/robertoprevato/blacksheep) +classifiers. -BlackSheep belongs to the category of +Dreaming Electric Sheep belongs to the category of [ASGI](https://asgi.readthedocs.io/en/latest/) web frameworks, so it requires an ASGI HTTP server to run, such as [uvicorn](https://www.uvicorn.org/), [hypercorn](https://pgjones.gitlab.io/hypercorn/) or @@ -101,14 +57,14 @@ an ASGI HTTP server to run, such as [uvicorn](https://www.uvicorn.org/), For example, to use it with uvicorn: ```bash -$ pip install uvicorn +pip install uvicorn ``` To run an application like in the example above, use the methods provided by the ASGI HTTP Server: ```bash -# if the BlackSheep app is defined in a file `server.py` +# if the Dreaming Electric Sheep app is defined in a file `server.py` $ uvicorn server:app ``` @@ -118,14 +74,13 @@ To run for production, refer to the documentation of the chosen ASGI server ## Automatic bindings and dependency injection -BlackSheep supports automatic binding of values for request handlers, by type -annotation or by conventions. See [more -here](https://www.neoteroi.dev/blacksheep/requests/). +Dreaming Electric Sheep supports automatic binding of values for request handlers, by type +annotation or by conventions. ```python from dataclasses import dataclass -from blacksheep import Application, FromJSON, FromQuery, get, post +from dreaming_electric_sheep import Application, FromJSON, FromQuery, get, post app = Application() @@ -176,22 +131,11 @@ def get_products2( ``` -It also supports [dependency -injection](https://www.neoteroi.dev/blacksheep/dependency-injection/), a -feature that provides a consistent and clean way to use dependencies in request -handlers. - -## Generation of OpenAPI Documentation - -[Generation of OpenAPI Documentation](https://www.neoteroi.dev/blacksheep/openapi/). +It also supports dependency injection, a feature that provides a consistent and clean way to use dependencies in request handlers. ## Strategies to handle authentication and authorization -BlackSheep implements strategies to handle authentication and authorization. -These features are documented here: - -* [Authentication](https://www.neoteroi.dev/blacksheep/authentication/) -* [Authorization](https://www.neoteroi.dev/blacksheep/authorization/) +Dreaming Electric Sheep implements strategies to handle authentication and authorization: ```python app.use_authentication()\ @@ -214,79 +158,61 @@ async def only_for_authenticated_users(): ... ``` -BlackSheep provides: +Dreaming Electric Sheep provides: -* [Built-in support for **OpenID Connect** authentication](https://www.neoteroi.dev/blacksheep/authentication/#oidc) -* [Built-in support for **JWT Bearer** authentication](https://www.neoteroi.dev/blacksheep/authentication/#jwt-bearer) +* Built-in support for **OpenID Connect** authentication +* Built-in support for **JWT Bearer** authentication Meaning that it is easy to integrate with services such as: + * [Auth0](https://auth0.com) * [Microsoft Entra ID](https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id) * [Azure Active Directory B2C](https://docs.microsoft.com/en-us/azure/active-directory-b2c/overview) * [Okta](https://www.okta.com) -Since version `2.4.2`, it also offers built-in support for **Basic authentication**, +It also offers built-in support for **Basic authentication**, **API Key authentication**, **JWT Bearer authentication using symmetric encryption**, and automatic generation of OpenAPI Documentation for security schemes when using built-in classes for authentication. It supports defining custom authentication handlers and custom mappers for OpenAPI Documentation. -Refer to the documentation and to [BlackSheep-Examples](https://github.com/Neoteroi/BlackSheep-Examples) -for more details and examples. - ## Web framework features -* [ASGI compatibility](https://www.neoteroi.dev/blacksheep/asgi/) -* [Routing](https://www.neoteroi.dev/blacksheep/routing/) -* Request handlers can be [defined as - functions](https://www.neoteroi.dev/blacksheep/request-handlers/), or [class - methods](https://www.neoteroi.dev/blacksheep/controllers/) -* [Middlewares](https://www.neoteroi.dev/blacksheep/middlewares/) -* [WebSocket](https://www.neoteroi.dev/blacksheep/websocket/) -* [Server-Sent Events (SSE)](https://www.neoteroi.dev/blacksheep/server-sent-events/) -* [Built-in support for dependency - injection](https://www.neoteroi.dev/blacksheep/dependency-injection/) -* [Support for automatic binding of route and query parameters to request - handlers methods - calls](https://www.neoteroi.dev/blacksheep/getting-started/#handling-route-parameters) -* [Strategy to handle - exceptions](https://www.neoteroi.dev/blacksheep/application/#configuring-exceptions-handlers) -* [Strategy to handle authentication and - authorization](https://www.neoteroi.dev/blacksheep/authentication/) -* [Built-in support for OpenID Connect authentication using OIDC - discovery](https://www.neoteroi.dev/blacksheep/authentication/#oidc) -* [Built-in support for JWT Bearer authentication using OIDC discovery and - other sources of - JWKS](https://www.neoteroi.dev/blacksheep/authentication/#jwt-bearer) -* [Handlers - normalization](https://www.neoteroi.dev/blacksheep/request-handlers/) -* [Serving static - files](https://www.neoteroi.dev/blacksheep/static-files/) -* [Integration with - Jinja2](https://www.neoteroi.dev/blacksheep/templating/) -* [Support for serving SPAs that use HTML5 History API for client side - routing](https://www.neoteroi.dev/blacksheep/static-files/#how-to-serve-spas-that-use-html5-history-api) -* [Support for automatic generation of OpenAPI - Documentation](https://www.neoteroi.dev/blacksheep/openapi/) -* [Strategy to handle CORS settings](https://www.neoteroi.dev/blacksheep/cors/) -* [Sessions](https://www.neoteroi.dev/blacksheep/sessions/) -* Support for automatic binding of `dataclasses` and - [`Pydantic`](https://pydantic-docs.helpmanual.io) models to handle the - request body payload expected by request handlers -* [`TestClient` class to simplify testing of applications](https://www.neoteroi.dev/blacksheep/testing/) -* [Anti Forgery validation](https://www.neoteroi.dev/blacksheep/anti-request-forgery) to protect against Cross-Site Request Forgery (XSRF/CSRF) attacks +* ASGI compatibility +* Routing +* Request handlers can be defined as functions or class methods +* Middlewares +* WebSocket +* Server-Sent Events (SSE) +* Built-in support for dependency injection +* Support for automatic binding of route and query parameters to request handler method calls +* Strategy to handle exceptions +* Strategy to handle authentication and authorization +* Built-in support for OpenID Connect authentication using OIDC discovery +* Built-in support for JWT Bearer authentication using OIDC discovery and other sources of JWKS +* Handlers normalization +* Serving static files +* Integration with Jinja2 +* Support for serving SPAs that use HTML5 History API for client side routing +* Support for automatic generation of OpenAPI Documentation +* Strategy to handle CORS settings +* Sessions +* Support for automatic binding of `dataclasses` and [`Pydantic`](https://pydantic-docs.helpmanual.io) models to handle the request body payload expected by request handlers +* `TestClient` class to simplify testing of applications +* Anti Forgery validation to protect against Cross-Site Request Forgery (XSRF/CSRF) attacks ## Client features -BlackSheep includes an HTTP Client with native HTTP/2 support (since version `2.5.0`). +Dreaming Electric Sheep includes an HTTP Client with native HTTP/2 support. The client automatically detects and uses HTTP/2 when the server supports it, with seamless fallback to HTTP/1.1. **Example:** + ```python import asyncio -from blacksheep.client import ClientSession +from dreaming_electric_sheep.client import ClientSession async def client_example(): @@ -301,11 +227,9 @@ asyncio.run(client_example()) > [!IMPORTANT] > -> Starting from version `2.3.1`, BlackSheep supports [PyPy](https://pypy.org/) -> (`PyPy 3.11`). The HTTP client requires `h11` and `h2` libraries. Version `2.5.0` -> added native HTTP/2 support via the `h2` library. The `httptools` library is +> Dreaming Electric Sheep supports [PyPy](https://pypy.org/) (`PyPy 3.11`). The HTTP client requires `h11` and `h2` libraries. The `httptools` library is > optional and only provides better URL parsing performance on CPython. These -> dependencies affect only the `blacksheep.client` namespace. +> dependencies affect only the `dreaming_electric_sheep.client` namespace. ## Supported platforms and runtimes @@ -315,11 +239,6 @@ asyncio.run(client_example()) * Windows. * macOS. -## Documentation - -Please refer to the [documentation website](https://www.neoteroi.dev/blacksheep/). - ## Branches -The _main_ branch contains the currently developed version, which is version 2. The _v1_ branch contains version 1 of the web framework, for bugs fixes -and maintenance. +The _main_ branch contains the currently developed version. diff --git a/dreaming_electric_sheep.svg b/dreaming_electric_sheep.svg deleted file mode 100644 index fdf7c750..00000000 --- a/dreaming_electric_sheep.svg +++ /dev/null @@ -1,72 +0,0 @@ - - - - - BlackSheep web framework logo - - - - image/svg+xml - - BlackSheep web framework logo - 2018-11-25 - - - Roberto Prevato - - - - - Roberto Prevato - - - Logo for BlackSheep and BlueSheep web frameworks by Roberto Prevato - - - - - - - From 2f16eea1465db7d60fef54ec62cfdc4856f9b6d9 Mon Sep 17 00:00:00 2001 From: EduLoboM Date: Wed, 26 Aug 2026 22:30:13 -0300 Subject: [PATCH 04/45] feat(exceptions): implement RFC 9110 HTTP 422 UnprocessableEntity exception --- dreaming_electric_sheep/baseapp.py | 34 ++++++++++++++++++++++---- dreaming_electric_sheep/baseapp.pyx | 21 ++++++++++++++-- dreaming_electric_sheep/exceptions.pxd | 4 +++ dreaming_electric_sheep/exceptions.py | 6 +++++ dreaming_electric_sheep/exceptions.pyi | 5 ++++ dreaming_electric_sheep/exceptions.pyx | 7 ++++++ 6 files changed, 70 insertions(+), 7 deletions(-) diff --git a/dreaming_electric_sheep/baseapp.py b/dreaming_electric_sheep/baseapp.py index d8fcc59c..5b6c5887 100644 --- a/dreaming_electric_sheep/baseapp.py +++ b/dreaming_electric_sheep/baseapp.py @@ -4,11 +4,18 @@ import typing from collections import UserDict -from blacksheep.server.errors import ServerErrorDetailsHandler -from blacksheep.server.routing import Router +import msgspec + +from dreaming_electric_sheep.server.errors import ServerErrorDetailsHandler +from dreaming_electric_sheep.server.routing import Router from .contents import Content, TextContent -from .exceptions import HTTPException, InternalServerError, InvalidExceptionHandler +from .exceptions import ( + HTTPException, + InternalServerError, + InvalidExceptionHandler, + UnprocessableEntity, +) from .messages import Response from .utils import get_class_instance_hierarchy @@ -57,6 +64,18 @@ async def handle_bad_request(app, request, http_exception) -> Response: return Response(400, content=TextContent(f"Bad Request: {str(http_exception)}")) +async def handle_unprocessable_entity(app, request, http_exception) -> Response: + """Default 422 Unprocessable Entity handler with structured msgspec error details.""" + details = getattr(http_exception, "details", None) + if details is not None: + if isinstance(details, (dict, list)): + body = msgspec.json.encode(details) + else: + body = msgspec.json.encode({"error": str(details), "type": "validation_error"}) + return Response(422, content=Content(b"application/json", body)) + return Response(422, content=TextContent(f"Unprocessable Entity: {str(http_exception)}")) + + async def _default_pydantic_validation_error_handler(app, request, error) -> Response: return Response( 400, content=Content(b"application/json", error.json(indent=4).encode("utf-8")) @@ -71,7 +90,7 @@ async def common_http_exception_handler(app, request, http_exception) -> Respons def get_logger() -> logging.Logger: - logger = logging.getLogger("blacksheep.server") + logger = logging.getLogger("dreaming_electric_sheep.server") logger.setLevel(logging.INFO) return logger @@ -88,7 +107,12 @@ def __init__(self, show_error_details, router): def init_exceptions_handlers(self) -> ExceptionHandlersDict: default_handlers = ExceptionHandlersDict( - {404: handle_not_found, 400: handle_bad_request} + { + 404: handle_not_found, + 400: handle_bad_request, + 422: handle_unprocessable_entity, + UnprocessableEntity: handle_unprocessable_entity, + } ) if ValidationError is not None: default_handlers[ValidationError] = ( diff --git a/dreaming_electric_sheep/baseapp.pyx b/dreaming_electric_sheep/baseapp.pyx index 99f7ddde..6a191a5f 100644 --- a/dreaming_electric_sheep/baseapp.pyx +++ b/dreaming_electric_sheep/baseapp.pyx @@ -9,9 +9,11 @@ from .exceptions cimport ( HTTPException, InternalServerError, InvalidExceptionHandler, + UnprocessableEntity, ) from .messages cimport Request, Response +import msgspec from .utils import get_class_instance_hierarchy # Better support for Pydantic @@ -55,6 +57,19 @@ async def handle_bad_request(app, Request request, HTTPException http_exception) return Response(400, content=TextContent(f'Bad Request: {str(http_exception)}')) +async def handle_unprocessable_entity(app, Request request, HTTPException http_exception): + """Default 422 Unprocessable Entity handler with structured msgspec error details.""" + cdef object details = getattr(http_exception, "details", None) + cdef bytes body + if details is not None: + if isinstance(details, (dict, list)): + body = msgspec.json.encode(details) + else: + body = msgspec.json.encode({"error": str(details), "type": "validation_error"}) + return Response(422, content=Content(b"application/json", body)) + return Response(422, content=TextContent(f'Unprocessable Entity: {str(http_exception)}')) + + async def _default_pydantic_validation_error_handler(app, Request request, Exception error): return Response(400, content=Content(b"application/json", error.json(indent=4).encode("utf-8"))) @@ -64,7 +79,7 @@ async def common_http_exception_handler(app, Request request, HTTPException http def get_logger(): - logger = logging.getLogger("blacksheep.server") + logger = logging.getLogger("dreaming_electric_sheep.server") logger.setLevel(logging.INFO) return logger @@ -80,7 +95,9 @@ cdef class BaseApplication: def init_exceptions_handlers(self): default_handlers = ExceptionHandlersDict({ 404: handle_not_found, - 400: handle_bad_request + 400: handle_bad_request, + 422: handle_unprocessable_entity, + UnprocessableEntity: handle_unprocessable_entity, }) if ValidationError is not None: default_handlers[ValidationError] = _default_pydantic_validation_error_handler diff --git a/dreaming_electric_sheep/exceptions.pxd b/dreaming_electric_sheep/exceptions.pxd index 662c9cf3..0e6a1adc 100644 --- a/dreaming_electric_sheep/exceptions.pxd +++ b/dreaming_electric_sheep/exceptions.pxd @@ -37,6 +37,10 @@ cdef class UnsupportedMediaType(HTTPException): pass +cdef class UnprocessableEntity(HTTPException): + cdef public object details + + cdef class FailedRequestError(HTTPException): cdef public str data diff --git a/dreaming_electric_sheep/exceptions.py b/dreaming_electric_sheep/exceptions.py index c4241ae1..c52a8b71 100644 --- a/dreaming_electric_sheep/exceptions.py +++ b/dreaming_electric_sheep/exceptions.py @@ -56,6 +56,12 @@ def __init__(self, message=None): super().__init__(415, message or "Unsupported media type") +class UnprocessableEntity(HTTPException): + def __init__(self, message=None, details=None): + super().__init__(422, message or "Unprocessable entity") + self.details = details + + class RangeNotSatisfiable(HTTPException): def __init__(self): super().__init__(416, "Range not satisfiable") diff --git a/dreaming_electric_sheep/exceptions.pyi b/dreaming_electric_sheep/exceptions.pyi index b3e36639..95e153ef 100644 --- a/dreaming_electric_sheep/exceptions.pyi +++ b/dreaming_electric_sheep/exceptions.pyi @@ -33,6 +33,11 @@ class RangeNotSatisfiable(HTTPException): def __init__(self, message: str = "Range Not Satisfiable"): super().__init__(416, message) +class UnprocessableEntity(HTTPException): + def __init__(self, message: str = "Unprocessable Entity", details: object = None): + super().__init__(422, message) + self.details = details + class NotFound(HTTPException): def __init__(self): super().__init__(404) diff --git a/dreaming_electric_sheep/exceptions.pyx b/dreaming_electric_sheep/exceptions.pyx index 738f3635..b4546b89 100644 --- a/dreaming_electric_sheep/exceptions.pyx +++ b/dreaming_electric_sheep/exceptions.pyx @@ -64,6 +64,13 @@ cdef class UnsupportedMediaType(HTTPException): super().__init__(415, message or "Unsupported media type") +cdef class UnprocessableEntity(HTTPException): + + def __init__(self, message=None, object details=None): + super().__init__(422, message or "Unprocessable entity") + self.details = details + + cdef class RangeNotSatisfiable(HTTPException): def __init__(self): From ea7b4aac8ff32c4e72725db9881369c71be10e6c Mon Sep 17 00:00:00 2001 From: EduLoboM Date: Wed, 26 Aug 2026 22:30:21 -0300 Subject: [PATCH 05/45] feat(binding): implement zero-copy socket ingestion and pre-compiled msgspec decoders --- dreaming_electric_sheep/contents.py | 26 +- dreaming_electric_sheep/contents.pyx | 23 +- dreaming_electric_sheep/server/asgi.py | 10 +- .../server/bindings/__init__.py | 227 ++++++++++++++++-- .../server/bindings/converters.py | 32 ++- .../server/normalization.py | 27 ++- dreaming_electric_sheep/server/routing.py | 16 +- dreaming_electric_sheep/settings/json.py | 4 + pyproject.toml | 20 +- requirements.txt | 1 + tests/test_application.py | 51 ++-- tests/test_bindings.py | 33 ++- 12 files changed, 358 insertions(+), 112 deletions(-) diff --git a/dreaming_electric_sheep/contents.py b/dreaming_electric_sheep/contents.py index 30ee08db..6add2b3a 100644 --- a/dreaming_electric_sheep/contents.py +++ b/dreaming_electric_sheep/contents.py @@ -7,11 +7,11 @@ from typing import IO, Any, AsyncIterable, AsyncIterator, Iterable, cast from urllib.parse import parse_qsl, quote_plus -from blacksheep.common.files.pathsutils import get_mime_type_from_name -from blacksheep.exceptions import MessageAborted -from blacksheep.settings.json import json_settings +from dreaming_electric_sheep.common.files.pathsutils import get_mime_type_from_name +from dreaming_electric_sheep.exceptions import MessageAborted +from dreaming_electric_sheep.settings.json import json_settings -logger = logging.getLogger("blacksheep.server") +logger = logging.getLogger("dreaming_electric_sheep.server") def ensure_in_cwd(path: str) -> None: @@ -180,7 +180,21 @@ async def read(self): """ if self.body is not None: return self.body - value = bytearray() + + if self.receive is None: + return b"" + + message = await self.receive() + if message.get("type") == "http.disconnect": + raise MessageAborted() + + chunk = message.get("body", b"") + if not message.get("more_body"): + self.body = chunk + self.length = len(chunk) + return self.body + + value = bytearray(chunk) while True: if self.receive is None: break # disposed @@ -781,7 +795,7 @@ def __init__(self, parts: list[FormPart]): async def _generate_multipart_chunks(self) -> AsyncIterator[bytes]: """Generate multipart/form-data content in chunks.""" # Import the escape function from multipart module - from blacksheep.multipart import _escape_quoted_string + from dreaming_electric_sheep.multipart import _escape_quoted_string for part in self.parts: # Build headers as a single chunk diff --git a/dreaming_electric_sheep/contents.pyx b/dreaming_electric_sheep/contents.pyx index 0f8f7052..7144cbd7 100644 --- a/dreaming_electric_sheep/contents.pyx +++ b/dreaming_electric_sheep/contents.pyx @@ -9,11 +9,11 @@ from inspect import isasyncgenfunction from tempfile import SpooledTemporaryFile from urllib.parse import parse_qsl, quote_plus -from blacksheep.settings.json import json_settings +from dreaming_electric_sheep.settings.json import json_settings from .exceptions cimport MessageAborted -logger = logging.getLogger("blacksheep.server") +logger = logging.getLogger("dreaming_electric_sheep.server") def ensure_in_cwd(path: str) -> None: @@ -123,8 +123,21 @@ cdef class ASGIContent(Content): async def read(self): if self.body is not None: return self.body - value = bytearray() + if self.receive is None: + return b'' + + message = await self.receive() + if message.get('type') == 'http.disconnect': + raise MessageAborted() + + chunk = message.get('body', b'') + if not message.get('more_body'): + self.body = chunk + self.length = len(chunk) + return self.body + + value = bytearray(chunk) while True: if self.receive is None: break # disposed @@ -388,7 +401,7 @@ cdef class FormPart: if content_type is None: # Try obtaining mime type from the file name - from blacksheep.common.files.pathsutils import get_mime_type_from_name + from dreaming_electric_sheep.common.files.pathsutils import get_mime_type_from_name content_type = get_mime_type_from_name(file_path) if content_type: @@ -694,7 +707,7 @@ cdef class MultiPartFormData(StreamedContent): async def _generate_multipart_chunks(self) -> AsyncIterator[bytes]: """Generate multipart/form-data content in chunks.""" # Import the escape function from multipart module - from blacksheep.multipart import _escape_quoted_string + from dreaming_electric_sheep.multipart import _escape_quoted_string for part in self.parts: # Build headers as a single chunk diff --git a/dreaming_electric_sheep/server/asgi.py b/dreaming_electric_sheep/server/asgi.py index df161057..7dabdd3b 100644 --- a/dreaming_electric_sheep/server/asgi.py +++ b/dreaming_electric_sheep/server/asgi.py @@ -1,5 +1,5 @@ -from blacksheep.contents import ASGIContent -from blacksheep.messages import Request +from dreaming_electric_sheep.contents import ASGIContent +from dreaming_electric_sheep.messages import Request def get_request_url_from_scope( @@ -15,7 +15,7 @@ def get_request_url_from_scope( Do not use this method for logic that must generate full request URL, since it doesn't handle Forward and X-Forwarded* headers - use instead: - > from blacksheep.messages import get_absolute_url_to_path, get_request_absolute_url + > from dreaming_electric_sheep.messages import get_absolute_url_to_path, get_request_absolute_url """ try: path = scope["path"] @@ -58,7 +58,7 @@ def get_request_url(request: Request) -> str: Do not use this method for logic that must generate full request URL, since it doesn't handle Forward and X-Forwarded* headers - use instead: - > from blacksheep.messages import get_absolute_url_to_path, get_request_absolute_url + > from dreaming_electric_sheep.messages import get_absolute_url_to_path, get_request_absolute_url """ return get_request_url_from_scope(request.scope) @@ -67,7 +67,7 @@ def incoming_request(scope, receive=None) -> Request: """ Function used to simulate incoming requests from an ASGI scope. This function is intentionally not used in - `blacksheep.server.application.Application`. + `dreaming_electric_sheep.server.application.Application`. """ request = Request.incoming( scope["method"], diff --git a/dreaming_electric_sheep/server/bindings/__init__.py b/dreaming_electric_sheep/server/bindings/__init__.py index 67bac957..ad251a01 100644 --- a/dreaming_electric_sheep/server/bindings/__init__.py +++ b/dreaming_electric_sheep/server/bindings/__init__.py @@ -10,6 +10,7 @@ from abc import abstractmethod from collections.abc import Iterable as IterableAbc +from dataclasses import is_dataclass from functools import partial from typing import ( Any, @@ -23,16 +24,30 @@ TypeVar, ) +from uuid import UUID + +import msgspec from guardpost import Identity from rodi import CannotResolveTypeException, ContainerProtocol -from blacksheep.contents import FormPart -from blacksheep.exceptions import BadRequest, UnsupportedMediaType -from blacksheep.messages import Request -from blacksheep.server.bindings.converters import class_converters, converters -from blacksheep.server.routing import Router, URLResolver -from blacksheep.server.websocket import WebSocket -from blacksheep.url import URL +try: + from pydantic import BaseModel +except ImportError: + BaseModel = () # type: ignore + +from dreaming_electric_sheep.contents import FormPart +from dreaming_electric_sheep.exceptions import ( + BadRequest, + BadRequestFormat, + UnprocessableEntity, + UnsupportedMediaType, +) +from dreaming_electric_sheep.messages import Request +from dreaming_electric_sheep.server.bindings.converters import class_converters, converters +from dreaming_electric_sheep.server.routing import Router, URLResolver +from dreaming_electric_sheep.server.websocket import WebSocket +from dreaming_electric_sheep.settings.json import json_settings +from dreaming_electric_sheep.url import URL T = TypeVar("T") TypeOrName = Type | str @@ -206,7 +221,7 @@ class FromXML(BoundValue[T]): Requires ``defusedxml`` for safe parsing against common XML attacks (XXE, entity expansion, DTD injection). Install it with:: - pip install blacksheep[xml] + pip install dreaming-electric-sheep[xml] The root element tag is ignored; its child elements are mapped to model fields by name. All standard field-type coercions (int, float, datetime, UUID …) apply via @@ -476,7 +491,7 @@ def _get_default_converter_for_iterable(self, expected_type): item_type = self.generic_iterable_annotation_item_type(expected_type) if isinstance(item_type, ForwardRef): # pragma: no cover - from blacksheep.server.normalization import ( + from dreaming_electric_sheep.server.normalization import ( UnsupportedForwardRefInSignatureError, ) @@ -552,10 +567,89 @@ def parse_value(self, data: dict): class JSONBinder(BodyBinder): - """Extracts a model from JSON content""" + """Extracts a model from JSON content using pre-compiled msgspec decoders.""" handle = FromJSON + def __init__( + self, + expected_type: Type, + name: str = "", + implicit: bool = False, + required: bool = False, + default: Any = empty, + converter: Callable | None = None, + dec_hook: Callable[[type, Any], Any] | None = None, + ): + super().__init__(expected_type, name, implicit, required, converter) + self.default = default + self.dec_hook = dec_hook + self.decoder = self._build_decoder( + expected_type, dec_hook, converter is not None and converter != self.get_default_binder_for_body(expected_type) + ) + + @property + def converter(self): + return getattr(self, "_converter", None) + + @converter.setter + def converter(self, value): + self._converter = value + if hasattr(self, "expected_type"): + has_custom = value is not None and value != self.get_default_binder_for_body(self.expected_type) + self.decoder = self._build_decoder(self.expected_type, getattr(self, "dec_hook", None), has_custom) + + def _build_decoder( + self, + expected_type: Type, + custom_dec_hook: Callable | None = None, + has_custom_converter: bool = False, + ): + if has_custom_converter: + target_type = Any + else: + target_type = expected_type + if target_type in (None, empty): + target_type = dict + + def composite_dec_hook(tp, obj): + if custom_dec_hook is not None: + try: + res = custom_dec_hook(tp, obj) + if res is not None: + return res + except Exception: + pass + + if isinstance(tp, type): + if issubclass(tp, UUID) and isinstance(obj, str): + return UUID(obj) + try: + from pydantic import BaseModel + + if issubclass(tp, BaseModel): + return tp.model_validate(obj) + except (ImportError, TypeError): + pass + if hasattr(tp, "convert") and callable(getattr(tp, "convert")): + return tp.convert(obj) + if hasattr(tp, "__dataclass_fields__") or issubclass(tp, msgspec.Struct): + return obj + try: + conv = get_default_class_converter(tp) + return conv(obj) + except (TypeError, ValueError): + raise + except Exception: + pass + + return obj + + try: + return msgspec.json.Decoder(target_type, dec_hook=composite_dec_hook) + except Exception: + return msgspec.json.Decoder(dec_hook=composite_dec_hook) + @property def content_type(self) -> str: return "application/json" @@ -564,7 +658,110 @@ def matches_content_type(self, request: Request) -> bool: return request.declares_json() async def read_data(self, request: Request) -> Any: - return await request.json() + return await request.read_raw() + + async def get_value(self, request: Request) -> T | None: + if request.method not in self._excluded_methods and self.matches_content_type( + request + ): + raw_data = await request.read_raw() + + if not raw_data: + if not self.required or self.default is not empty: + return self.default if self.default is not empty else None + raise MissingBodyError() + + charset = request.charset or "utf8" + if charset.lower() not in ("utf-8", "utf8", "ascii"): + try: + text = raw_data.decode(charset) + raw_data = text.encode("utf-8") + except UnicodeDecodeError as decode_error: + raise BadRequest( + f"Unicode decode error. Cannot decode the request content using: {decode_error.encoding}. " + "Ensure the request content is encoded using the encoding declared in the Content-Type request header." + ) + + if json_settings.has_custom_loads: + try: + text = raw_data.decode(charset) + data = json_settings.loads(text) + if self.converter and self.converter != self.get_default_binder_for_body(self.expected_type): + return self.parse_value(data) + return data + except (ValueError, TypeError) as err: + raise BadRequestFormat(f"Cannot parse content as JSON: {err}", err) + + try: + data = self.decoder.decode(raw_data) + if self.converter and self.converter != self.get_default_binder_for_body(self.expected_type): + return self.parse_value(data) + return data + except UnicodeDecodeError as decode_error: + charset_name = (request.charset or decode_error.encoding or "utf-8").lower() + raise BadRequest( + f"Unicode decode error. Cannot decode the request content using: {charset_name}. " + "Ensure the request content is encoded using the encoding declared in the Content-Type request header." + ) + except msgspec.ValidationError as validation_error: + err_str = str(validation_error) + if "codec can't decode" in err_str or "UnicodeDecodeError" in err_str: + charset_name = (request.charset or "utf-8").lower() + raise BadRequest( + f"Unicode decode error. Cannot decode the request content using: {charset_name}. " + "Ensure the request content is encoded using the encoding declared in the Content-Type request header." + ) + tp = self.expected_type + if tp not in (None, empty): + try: + raw_obj = msgspec.json.decode(raw_data) + if self.converter and self.converter != self.get_default_binder_for_body(tp): + return self.parse_value(raw_obj) + conv = get_default_class_converter(tp) + val = conv(raw_obj) + return val + except (MissingBodyError,): + raise + except Exception: + pass + raise UnprocessableEntity( + f"Validation error: {validation_error}", + details=str(validation_error), + ) + except msgspec.DecodeError as decode_error: + err_str = str(decode_error) + if "codec can't decode" in err_str or "UnicodeDecodeError" in err_str: + charset_name = (request.charset or "utf-8").lower() + raise BadRequest( + f"Unicode decode error. Cannot decode the request content using: {charset_name}. " + "Ensure the request content is encoded using the encoding declared in the Content-Type request header." + ) + content_type = request.content_type() + if content_type and b"json" in content_type: + raise BadRequestFormat( + f"Declared Content-Type is {content_type.decode()} but " + f"the content cannot be parsed as JSON: {decode_error}", + decode_error, + ) + raise BadRequestFormat( + f"Cannot parse content as JSON: {decode_error}", decode_error + ) + except (TypeError, ValueError) as value_error: + raise UnprocessableEntity( + f"Validation error: {value_error}", + details=str(value_error), + ) + + if self.required: + if self.default is not empty: + return None + + if not request.has_body(): + raise MissingBodyError() + + raise InvalidRequestBody("Expected request content") + + return None JsonBinder = JSONBinder @@ -648,7 +845,7 @@ class XMLBinder(BodyBinder): Uses ``defusedxml`` to protect against XXE injection, entity expansion (billion laughs), and DTD-based attacks. Install the extra with:: - pip install blacksheep[xml] + pip install dreaming-electric-sheep[xml] The root element tag is ignored; its direct children are mapped to model fields by name. The same type-coercion converters used by the JSON and form binders apply, @@ -681,7 +878,7 @@ def _parse_xml(content: bytes) -> dict: except ImportError as exc: raise ImportError( "defusedxml is required for safe XML parsing. " - "Install it with: pip install blacksheep[xml]" + "Install it with: pip install dreaming-electric-sheep[xml]" ) from exc try: @@ -968,7 +1165,7 @@ class ControllerBinder(ServiceBinder): Binder used to activate an instance of Controller. This binder is applied automatically by the application object at startup, as type annotation, for handlers configured on classes - inheriting `blacksheep.server.Controller`. + inheriting `dreaming_electric_sheep.server.Controller`. If used manually, it causes several controllers to be instantiated and injected into request handlers. @@ -1085,7 +1282,7 @@ def __init__(self, router: Router, implicit: bool = True): @classmethod def from_alias(cls, services: ContainerProtocol) -> "URLResolverBinder": - from blacksheep.server import Application + from dreaming_electric_sheep.server import Application app: Application = services.resolve(Application) return cls(app.router) diff --git a/dreaming_electric_sheep/server/bindings/converters.py b/dreaming_electric_sheep/server/bindings/converters.py index 702cbf65..fc0445b9 100644 --- a/dreaming_electric_sheep/server/bindings/converters.py +++ b/dreaming_electric_sheep/server/bindings/converters.py @@ -27,10 +27,10 @@ from urllib.parse import unquote from uuid import UUID -from blacksheep.contents import FileBuffer, FormPart -from blacksheep.exceptions import BadRequest -from blacksheep.server.bindings.dates import parse_datetime -from blacksheep.utils import ensure_str +from dreaming_electric_sheep.contents import FileBuffer, FormPart +from dreaming_electric_sheep.exceptions import BadRequest +from dreaming_electric_sheep.server.bindings.dates import parse_datetime +from dreaming_electric_sheep.utils import ensure_str try: # Supported only in Python >= 3.11 @@ -88,8 +88,10 @@ def __init__(self, obj_type) -> None: def can_convert(self, expected_type) -> bool: return expected_type is self._handled_type - def convert(self, value, expected_type) -> Any: - return self._handled_type(value) if value is not None else None + def convert(self, value, expected_type=None) -> Any: + if value is None or isinstance(value, self._handled_type): + return value + return self._handled_type(value) class BoolConverter(TypeConverter): @@ -196,7 +198,9 @@ def __init__(self, parser_fn: Callable[[str], datetime] = parse_datetime) -> Non def can_convert(self, expected_type) -> bool: return expected_type is datetime - def convert(self, value, expected_type) -> Any: + def convert(self, value, expected_type=None) -> Any: + if isinstance(value, datetime): + return value return self.parser_fn(value) if value else None @@ -208,7 +212,9 @@ def __init__(self, parser_fn: Callable[[str], datetime] = parse_datetime) -> Non def can_convert(self, expected_type) -> bool: return expected_type is date - def convert(self, value, expected_type) -> Any: + def convert(self, value, expected_type=None) -> Any: + if isinstance(value, date): + return value return self.parser_fn(value).date() if value else None @@ -405,13 +411,19 @@ def _from_dict(self, cls, data: dict | list): ] else: # return data as-is (let if fail downstream if it must) - return data + try: + return cls(data) + except Exception: + return data if _dict_converter.can_convert(cls): return _dict_converter.convert(data, cls) if not isinstance(data, dict): - return data + try: + return cls(data) + except Exception: + return data # Handle dataclasses if _is_dataclass(cls): diff --git a/dreaming_electric_sheep/server/normalization.py b/dreaming_electric_sheep/server/normalization.py index 747aa245..f136947a 100644 --- a/dreaming_electric_sheep/server/normalization.py +++ b/dreaming_electric_sheep/server/normalization.py @@ -20,12 +20,12 @@ from guardpost import Identity from rodi import ContainerProtocol -from blacksheep.messages import Request, Response -from blacksheep.normalization import copy_special_attributes -from blacksheep.server import responses -from blacksheep.server.routing import Route -from blacksheep.server.sse import ServerSentEvent, ServerSentEventsResponse -from blacksheep.server.websocket import WebSocket +from dreaming_electric_sheep.messages import Request, Response +from dreaming_electric_sheep.normalization import copy_special_attributes +from dreaming_electric_sheep.server import responses +from dreaming_electric_sheep.server.routing import Route +from dreaming_electric_sheep.server.sse import ServerSentEvent, ServerSentEventsResponse +from dreaming_electric_sheep.server.websocket import WebSocket from .bindings import ( Binder, @@ -111,7 +111,7 @@ def ensure_response(result) -> Response: """ When a request handler returns a result that is not an instance of Response, this method normalizes the output of the method to be either `None`. or an instance - of `blacksheep.messages.Response` class. + of `dreaming_electric_sheep.messages.Response` class. Use this method in custom decorators for request handlers. """ @@ -434,7 +434,11 @@ def _get_parameter_binder( binder.expected_type = expected_type binder.parameter_name = parameter_name else: - binder = binder_type(expected_type, parameter_name, False) + dec_hook = getattr(route, "dec_hook", None) if route else None + if issubclass(binder_type, JSONBinder): + binder = binder_type(expected_type, parameter_name, False, dec_hook=dec_hook) + else: + binder = binder_type(expected_type, parameter_name, False) binder.required = not is_optional if is_root_optional: @@ -471,7 +475,8 @@ def _get_parameter_binder( ) # 6. from json body (last default) - return JSONBinder(annotation, name, True, required=not is_root_optional) + dec_hook = getattr(route, "dec_hook", None) if route else None + return JSONBinder(annotation, name, True, required=not is_root_optional, dec_hook=dec_hook) def get_parameter_binder( @@ -765,7 +770,7 @@ def normalize_handler( ) -> Callable[[Request], Awaitable[Response]]: """ Root function used to normalize a request handler. The objective of this function is - to improve the developer experience, so developers using BlackSheep have more + to improve the developer experience, so developers using Dreaming Electric Sheep have more options when defining request handlers. When a request handler already has the right signature, it is kept as-is (this @@ -810,7 +815,7 @@ def normalize_handler( normalized = get_sync_wrapper(services, route, method, params, params_len) # Normalize output. WebSocket handlers must be excluded here because their - # response is not handled writing a BlackSheep Response object. + # response is not handled writing a Dreaming Electric Sheep Response object. if ( return_type is _empty or return_type is not Response ) and http_method != "GET_WS": diff --git a/dreaming_electric_sheep/server/routing.py b/dreaming_electric_sheep/server/routing.py index f5d384ff..c1363d62 100644 --- a/dreaming_electric_sheep/server/routing.py +++ b/dreaming_electric_sheep/server/routing.py @@ -14,16 +14,16 @@ ) from urllib.parse import unquote -from blacksheep.common import extend -from blacksheep.common.types import ( +from dreaming_electric_sheep.common import extend +from dreaming_electric_sheep.common.types import ( HeadersType, ParamsType, normalize_headers, normalize_params, ) -from blacksheep.messages import Request -from blacksheep.server.env import get_global_route_prefix -from blacksheep.utils import ensure_bytes, ensure_str +from dreaming_electric_sheep.messages import Request +from dreaming_electric_sheep.server.env import get_global_route_prefix +from dreaming_electric_sheep.utils import ensure_bytes, ensure_str class RouteMethod: @@ -242,6 +242,8 @@ class Route: "pattern", "param_names", "_rx", + "dec_hook", + "enc_hook", ) pattern: bytes @@ -267,6 +269,8 @@ def __init__( rx, param_names = self._get_regex_for_pattern(raw_pattern) self._rx = rx self.param_names = [name.decode("utf8") for name in param_names] + self.dec_hook = None + self.enc_hook = None @property def rx(self) -> re.Pattern: @@ -1173,7 +1177,7 @@ def validate_router(app): # This is the same application! This can happen when imported dynamically # by uvicorn reload feature, when uvicorn is started programmatically. # See https://github.com/Neoteroi/BlackSheep/issues/438 - logging.getLogger("blacksheep.server").warning( + logging.getLogger("dreaming_electric_sheep.server").warning( "The application was reloaded, resetting its router." ) app_router.reset() diff --git a/dreaming_electric_sheep/settings/json.py b/dreaming_electric_sheep/settings/json.py index 41e61be6..6c4911e1 100644 --- a/dreaming_electric_sheep/settings/json.py +++ b/dreaming_electric_sheep/settings/json.py @@ -28,6 +28,10 @@ def use( self._dumps = dumps self._pretty_dumps = pretty_dumps + @property + def has_custom_loads(self) -> bool: + return self._loads is not json.loads + def loads(self, text: str) -> Any: return self._loads(text) diff --git a/pyproject.toml b/pyproject.toml index d513aacf..2e9f2b04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools"] build-backend = "setuptools.build_meta" [project] -name = "blacksheep" +name = "dreaming-electric-sheep" dynamic = ["version"] authors = [{ name = "Roberto Prevato", email = "roberto.prevato@gmail.com" }] description = "Fast web framework for Python asyncio" @@ -26,7 +26,7 @@ classifiers = [ "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development :: Libraries :: Python Modules", ] -keywords = ["blacksheep", "web framework", "asyncio"] +keywords = ["dreaming-electric-sheep", "dreaming electric sheep", "web framework", "asyncio"] dependencies = [ "certifi>=2025.4.26", "guardpost>=1.0.4", @@ -37,15 +37,16 @@ dependencies = [ "h11>=0.14.0,<1.0.0", "h2>=4.0.0,<5.0.0", "httptools>=0.7.1; platform_python_implementation != 'PyPy'", + "msgspec>=0.18.0", "async-timeout>=4.0.0; python_version < '3.11'", ] [tool.setuptools.packages.find] where = ["."] -include = ["blacksheep*"] +include = ["dreaming_electric_sheep*"] [tool.setuptools.dynamic] -version = { attr = "blacksheep.__version__" } +version = { attr = "dreaming_electric_sheep.__version__" } [project.optional-dependencies] jinja = ["Jinja2~=3.1.6"] @@ -59,11 +60,12 @@ full = [ ] [project.urls] -Homepage = "https://github.com/Neoteroi/BlackSheep" -Documentation = "https://www.neoteroi.dev/blacksheep/" -Issues = "https://github.com/Neoteroi/BlackSheep/issues" -Repository = "https://github.com/Neoteroi/BlackSheep" -Changelog = "https://github.com/Neoteroi/BlackSheep/blob/main/CHANGELOG.md" +Homepage = "https://github.com/EduLoboM/Dreaming-Electric-Sheep" +Documentation = "https://github.com/EduLoboM/Dreaming-Electric-Sheep#readme" +Issues = "https://github.com/EduLoboM/Dreaming-Electric-Sheep/issues" +Repository = "https://github.com/EduLoboM/Dreaming-Electric-Sheep" +Changelog = "https://github.com/EduLoboM/Dreaming-Electric-Sheep/blob/main/CHANGELOG.md" [tool.setuptools] include-package-data = true + diff --git a/requirements.txt b/requirements.txt index 84d646c1..97907aaa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,6 +11,7 @@ Jinja2~=3.1.6 PyJWT==2.12.0 websockets~=15.0.1 httptools==0.6.4 +msgspec>=0.18.0 async-timeout>=4.0.0; python_version < '3.11' # For tests: Cython==3.1.4 diff --git a/tests/test_application.py b/tests/test_application.py index 41952cb9..70487925 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -18,22 +18,22 @@ from pydantic import BaseModel, Field, ValidationError from rodi import Container, inject -from blacksheep import ( +from dreaming_electric_sheep import ( HTTPException, JSONContent, Request, Response, TextContent, ) -from blacksheep.contents import FileBuffer, FormPart -from blacksheep.exceptions import ( +from dreaming_electric_sheep.contents import FileBuffer, FormPart +from dreaming_electric_sheep.exceptions import ( Conflict, InternalServerError, InvalidExceptionHandler, NotFound, ) -from blacksheep.server.application import Application, ApplicationSyncEvent -from blacksheep.server.bindings import ( +from dreaming_electric_sheep.server.application import Application, ApplicationSyncEvent +from dreaming_electric_sheep.server.bindings import ( ClientInfo, FromBytes, FromCookie, @@ -48,16 +48,16 @@ RequestUser, ServerInfo, ) -from blacksheep.server.di import di_scope_middleware -from blacksheep.server.normalization import ensure_response -from blacksheep.server.openapi.v3 import OpenAPIHandler -from blacksheep.server.resources import get_resource_file_path -from blacksheep.server.responses import status_code, text -from blacksheep.server.routing import Router, SharedRouterError -from blacksheep.server.security.hsts import HSTSMiddleware -from blacksheep.server.sse import ServerSentEvent, TextServerSentEvent -from blacksheep.testing.helpers import get_example_scope -from blacksheep.testing.messages import MockReceive, MockSend +from dreaming_electric_sheep.server.di import di_scope_middleware +from dreaming_electric_sheep.server.normalization import ensure_response +from dreaming_electric_sheep.server.openapi.v3 import OpenAPIHandler +from dreaming_electric_sheep.server.resources import get_resource_file_path +from dreaming_electric_sheep.server.responses import status_code, text +from dreaming_electric_sheep.server.routing import Router, SharedRouterError +from dreaming_electric_sheep.server.security.hsts import HSTSMiddleware +from dreaming_electric_sheep.server.sse import ServerSentEvent, TextServerSentEvent +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend from tests.utils.application import FakeApplication from tests.utils.folder import ensure_folder @@ -1894,11 +1894,7 @@ async def home(item: FromJSON[Item]): ... MockReceive([b'{"a":"Hello","b":"World"}']), MockSend(), ) - assert app.response.status == 400 - assert ( - b"Bad Request: invalid parameter in request payload" - in app.response.content.body - ) + assert app.response.status == 422 async def test_handler_json_response_implicit(app): @@ -1980,11 +1976,7 @@ async def home(item: FromJSON[Foo]): ... MockReceive([b'{"item":{"a":"Hello","b":"World"}}']), MockSend(), ) - assert app.response.status == 400 - assert ( - b"Bad Request: invalid parameter in request payload." - in app.response.content.body - ) + assert app.response.status == 422 async def test_handler_from_json_parameter_missing_property_array(app): @@ -2002,11 +1994,7 @@ async def home(item: FromJSON[list[Item]]): ... MockReceive([b'[{"a":"Hello","b":"World"}]']), MockSend(), ) - assert app.response.status == 400 - assert ( - b"Bad Request: invalid parameter in request payload." - in app.response.content.body - ) + assert app.response.status == 422 async def test_handler_from_json_parameter_handles_request_without_body(app): @@ -3976,8 +3964,7 @@ async def create_cat(data: CreateCatInput): ... ) response = app.response - assert response.status == 400 - assert response.content.body.decode() == expected_error + assert response.status == 422 async def test_app_fallback_route(app): diff --git a/tests/test_bindings.py b/tests/test_bindings.py index a1a6e45b..66bcf97e 100644 --- a/tests/test_bindings.py +++ b/tests/test_bindings.py @@ -10,8 +10,15 @@ from pytest import raises from rodi import Container -from blacksheep import FormContent, FormPart, JSONContent, MultiPartFormData, Request -from blacksheep.server.bindings import ( +from dreaming_electric_sheep import ( + FormContent, + FormPart, + JSONContent, + MultiPartFormData, + Request, + UnprocessableEntity, +) +from dreaming_electric_sheep.server.bindings import ( BadRequest, Binder, BinderAlreadyDefinedException, @@ -36,7 +43,7 @@ TypeAliasAlreadyDefinedException, get_binder_by_type, ) -from blacksheep.url import URL +from dreaming_electric_sheep.url import URL JSONContentType = (b"Content-Type", b"application/json") @@ -325,7 +332,7 @@ async def test_from_body_json_binding_invalid_input(): parameter = JSONBinder(ExampleOne) - with raises(BadRequest): + with raises((BadRequest, UnprocessableEntity)): await parameter.get_value(request) @@ -1253,8 +1260,8 @@ async def test_from_body_json_binding_empty_dict(expected_type): # region MultiFormatBodyBinder tests -from blacksheep.exceptions import UnsupportedMediaType as UnsupportedMediaTypeExc -from blacksheep.server.bindings import FormBinder, MultiFormatBodyBinder +from dreaming_electric_sheep.exceptions import UnsupportedMediaType as UnsupportedMediaTypeExc +from dreaming_electric_sheep.server.bindings import FormBinder, MultiFormatBodyBinder @dataclass @@ -1312,7 +1319,7 @@ async def test_multi_format_binder_raises_415_for_unsupported_content_type(): "body", required=True, ) - from blacksheep.contents import Content + from dreaming_electric_sheep.contents import Content request = Request( "POST", b"/", [(b"content-type", b"application/xml")] @@ -1332,7 +1339,7 @@ async def test_multi_format_binder_returns_none_when_optional_and_no_match(): "body", required=False, ) - from blacksheep.contents import Content + from dreaming_electric_sheep.contents import Content request = Request( "POST", b"/", [(b"content-type", b"application/xml")] @@ -1374,8 +1381,8 @@ def test_multi_format_binder_content_type_combines_inner(): # region XMLBinder tests -from blacksheep.contents import Content -from blacksheep.server.bindings import FromXML, XMLBinder +from dreaming_electric_sheep.contents import Content +from dreaming_electric_sheep.server.bindings import FromXML, XMLBinder XML_ITEM = b"hello7" XML_NESTED = b"1" @@ -1472,7 +1479,7 @@ def test_xml_binder_rejects_billion_laughs(): def test_element_to_dict_handles_attributes(): import xml.etree.ElementTree as ET - from blacksheep.server.bindings import _element_to_dict + from dreaming_electric_sheep.server.bindings import _element_to_dict root = ET.fromstring(XML_ATTR) d = _element_to_dict(root) @@ -1483,7 +1490,7 @@ def test_element_to_dict_handles_attributes(): def test_element_to_dict_handles_nested(): import xml.etree.ElementTree as ET - from blacksheep.server.bindings import _element_to_dict + from dreaming_electric_sheep.server.bindings import _element_to_dict root = ET.fromstring(XML_NESTED) d = _element_to_dict(root) @@ -1494,7 +1501,7 @@ def test_element_to_dict_handles_nested(): def test_element_to_dict_collects_repeated_tags_as_list(): import xml.etree.ElementTree as ET - from blacksheep.server.bindings import _element_to_dict + from dreaming_electric_sheep.server.bindings import _element_to_dict root = ET.fromstring(XML_LIST) d = _element_to_dict(root) From e551476867cbbaa945269eb48846ff8b4eb3b402 Mon Sep 17 00:00:00 2001 From: EduLoboM Date: Wed, 26 Aug 2026 22:30:34 -0300 Subject: [PATCH 06/45] feat(memory): add frozen Struct base class, GC tuning and buffer detachment --- dreaming_electric_sheep/__init__.py | 2 + dreaming_electric_sheep/messages.py | 89 ++++- dreaming_electric_sheep/messages.pyi | 5 +- dreaming_electric_sheep/messages.pyx | 95 +++-- dreaming_electric_sheep/server/application.py | 83 ++-- dreaming_electric_sheep/structures.py | 37 ++ tests/test_msgspec_binding.py | 357 ++++++++++++++++++ 7 files changed, 594 insertions(+), 74 deletions(-) create mode 100644 dreaming_electric_sheep/structures.py create mode 100644 tests/test_msgspec_binding.py diff --git a/dreaming_electric_sheep/__init__.py b/dreaming_electric_sheep/__init__.py index aa0d0fc3..27b6e320 100644 --- a/dreaming_electric_sheep/__init__.py +++ b/dreaming_electric_sheep/__init__.py @@ -22,6 +22,7 @@ from .cookies import datetime_to_cookie_format as datetime_to_cookie_format from .cookies import parse_cookie as parse_cookie from .exceptions import HTTPException as HTTPException +from .exceptions import UnprocessableEntity as UnprocessableEntity from .headers import Header as Header from .headers import Headers as Headers from .messages import Message as Message @@ -86,5 +87,6 @@ from .server.websocket import WebSocketDisconnectError as WebSocketDisconnectError from .server.websocket import WebSocketError as WebSocketError from .server.websocket import WebSocketState as WebSocketState +from .structures import Struct as Struct from .url import URL as URL from .url import InvalidURL as InvalidURL diff --git a/dreaming_electric_sheep/messages.py b/dreaming_electric_sheep/messages.py index 19624888..13f94c18 100644 --- a/dreaming_electric_sheep/messages.py +++ b/dreaming_electric_sheep/messages.py @@ -8,16 +8,17 @@ from typing import TYPE_CHECKING, Any, TypeAlias from urllib.parse import parse_qs, quote, unquote, urlencode +import msgspec from guardpost import Identity -from blacksheep.multipart import ( +from dreaming_electric_sheep.multipart import ( get_boundary_from_header, parse_multipart_async, simplify_multipart_data, ) -from blacksheep.settings.encodings import encodings_settings -from blacksheep.settings.json import json_settings -from blacksheep.utils.time import utcnow +from dreaming_electric_sheep.settings.encodings import encodings_settings +from dreaming_electric_sheep.settings.json import json_settings +from dreaming_electric_sheep.utils.time import utcnow from .contents import ( ASGIContent, @@ -34,7 +35,7 @@ from .url import URL, build_absolute_url if TYPE_CHECKING: - from blacksheep.sessions import Session + from dreaming_electric_sheep.sessions import Session _charset_rx = re.compile(rb"charset=([\w\-]+)", re.I) @@ -215,6 +216,40 @@ async def read(self) -> bytes | None: return await self.content.read() return None + async def read_raw(self): + """ + Reads and returns the raw binary body without string conversion, + implementing Python's buffer protocol (Py_buffer compatible: bytes/bytearray). + """ + if hasattr(self, "content") and self.content: + return await self.content.read() + return b"" + + async def read_detached(self) -> bytes: + """ + Reads and returns an independent copy (bytes) of the body buffer, + guaranteeing safe usage across background tasks (e.g. asyncio.create_task) + without risk of use-after-free or socket buffer recycling. + """ + raw = await self.read_raw() + if raw is None: + return b"" + return bytes(raw) + + def detach_raw(self) -> bytes: + """ + Synchronously returns an independent copy (bytes) of already-read body buffer, + guaranteeing safe usage across background tasks without holding socket buffer pointers. + """ + if hasattr(self, "content") and self.content: + data = getattr(self.content, "_data", None) + if data is not None: + return bytes(data) + body = getattr(self.content, "body", None) + if body is not None: + return bytes(body) + return b"" + async def stream(self): if ( hasattr(self, "content") @@ -393,24 +428,40 @@ async def files(self, name: str | bytes | None = None) -> list[FormPart]: return [part for part in data if isinstance(part, FormPart) and part.file_name] async def json( - self, loads=json_settings.loads + self, loads=None ) -> dict[str, Any] | list[Any] | None: if not self.declares_json(): return None - text = await self.text() - if text is None or text == "": + raw = await self.read_raw() + if raw is None or len(raw) == 0: return None - try: - return loads(text) - except JSONDecodeError as decode_error: - content_type = self.content_type() - if content_type and b"json" in content_type: - raise BadRequestFormat( - f"Declared Content-Type is {content_type.decode()} but " - f"the content cannot be parsed as JSON.", - decode_error, - ) - raise BadRequestFormat("Cannot parse content as JSON", decode_error) + if loads is None and json_settings.has_custom_loads: + loads = json_settings.loads + if loads is None: + try: + return msgspec.json.decode(raw) + except msgspec.DecodeError as decode_error: + content_type = self.content_type() + if content_type and b"json" in content_type: + raise BadRequestFormat( + f"Declared Content-Type is {content_type.decode()} but " + f"the content cannot be parsed as JSON.", + decode_error, + ) + raise BadRequestFormat("Cannot parse content as JSON", decode_error) + else: + try: + text = raw.decode(self.charset) if isinstance(raw, (bytes, bytearray, memoryview)) else raw + return loads(text) + except (JSONDecodeError, UnicodeDecodeError, ValueError) as decode_error: + content_type = self.content_type() + if content_type and b"json" in content_type: + raise BadRequestFormat( + f"Declared Content-Type is {content_type.decode()} but " + f"the content cannot be parsed as JSON.", + decode_error, + ) + raise BadRequestFormat("Cannot parse content as JSON", decode_error) def has_body(self) -> bool: content = getattr(self, "content", None) diff --git a/dreaming_electric_sheep/messages.pyi b/dreaming_electric_sheep/messages.pyi index 26b22723..e01b9925 100644 --- a/dreaming_electric_sheep/messages.pyi +++ b/dreaming_electric_sheep/messages.pyi @@ -23,6 +23,9 @@ class Message: def add_header(self, name: bytes, value: bytes) -> None: ... def set_header(self, key: bytes, value: bytes) -> None: ... async def read(self) -> bytes | None: ... + async def read_raw(self) -> bytes: ... + async def read_detached(self) -> bytes: ... + def detach_raw(self) -> bytes: ... async def stream(self) -> Generator[bytes, None, None]: ... async def text(self) -> str: ... async def form( @@ -34,7 +37,7 @@ class Message: def declares_json(self) -> bool: ... def declares_xml(self) -> bool: ... async def files(self, name: str | None = None) -> list[FormPart]: ... - async def json(self, loads: Callable[[str], Any] = json_settings.loads) -> Any: ... + async def json(self, loads: Callable[[str], Any] | None = None) -> Any: ... def has_body(self) -> bool: ... @property def charset(self) -> str: ... diff --git a/dreaming_electric_sheep/messages.pyx b/dreaming_electric_sheep/messages.pyx index 91791ed6..b0ce68c8 100644 --- a/dreaming_electric_sheep/messages.pyx +++ b/dreaming_electric_sheep/messages.pyx @@ -5,12 +5,13 @@ from datetime import datetime, timedelta from json.decoder import JSONDecodeError from urllib.parse import parse_qs, quote, unquote, urlencode +import msgspec from guardpost import Identity -from blacksheep.sessions import Session -from blacksheep.settings.encodings import encodings_settings -from blacksheep.settings.json import json_settings -from blacksheep.utils.time import utcnow +from dreaming_electric_sheep.sessions import Session +from dreaming_electric_sheep.settings.encodings import encodings_settings +from dreaming_electric_sheep.settings.json import json_settings +from dreaming_electric_sheep.utils.time import utcnow from .contents cimport ( ASGIContent, @@ -226,6 +227,40 @@ cdef class Message: return await self.content.read() return None + async def read_raw(self): + """ + Reads and returns the raw binary body without string conversion, + implementing Python's buffer protocol (Py_buffer compatible: bytes/bytearray). + """ + if self.content: + return await self.content.read() + return b'' + + async def read_detached(self): + """ + Reads and returns an independent copy (bytes) of the body buffer, + guaranteeing safe usage across background tasks (e.g. asyncio.create_task) + without risk of use-after-free or socket buffer recycling. + """ + raw = await self.read_raw() + if raw is None: + return b"" + return bytes(raw) + + def detach_raw(self): + """ + Synchronously returns an independent copy (bytes) of already-read body buffer, + guaranteeing safe usage across background tasks without holding socket buffer pointers. + """ + if self.content: + data = getattr(self.content, "_data", None) + if data is not None: + return bytes(data) + body = getattr(self.content, "body", None) + if body is not None: + return bytes(body) + return b"" + async def stream(self): if self.content: async for chunk in self.content.stream(): @@ -365,7 +400,7 @@ cdef class Message: # Extract boundary from Content-Type header # e.g., "multipart/form-data; boundary=----WebKitFormBoundary..." - from blacksheep.multipart import get_boundary_from_header, parse_multipart_async + from dreaming_electric_sheep.multipart import get_boundary_from_header, parse_multipart_async try: boundary = get_boundary_from_header(content_type_value) except (ValueError, IndexError): @@ -401,30 +436,46 @@ cdef class Message: return [part for part in data if part.file_name and part.name == name] return [part for part in data if part.file_name] - async def json(self, loads=json_settings.loads): + async def json(self, loads=None): if not self.declares_json(): return None - text = await self.text() - - if text is None or text == "": + raw = await self.read_raw() + if raw is None or len(raw) == 0: return None - try: - return loads(text) - except JSONDecodeError as decode_error: - content_type = self.content_type() - if content_type and b'json' in content_type: - # NB: content type could also be "application/problem+json"; - # so we don't check for application/json in this case + if loads is None and json_settings.has_custom_loads: + loads = json_settings.loads + + if loads is None: + try: + return msgspec.json.decode(raw) + except msgspec.DecodeError as decode_error: + content_type = self.content_type() + if content_type and b'json' in content_type: + raise BadRequestFormat( + f'Declared Content-Type is {content_type.decode()} but ' + f'the content cannot be parsed as JSON.', decode_error + ) raise BadRequestFormat( - f'Declared Content-Type is {content_type.decode()} but ' - f'the content cannot be parsed as JSON.', decode_error + f'Cannot parse content as JSON', + decode_error + ) + else: + try: + text = raw.decode(self.charset) if isinstance(raw, (bytes, bytearray, memoryview)) else raw + return loads(text) + except (JSONDecodeError, UnicodeDecodeError, ValueError) as decode_error: + content_type = self.content_type() + if content_type and b'json' in content_type: + raise BadRequestFormat( + f'Declared Content-Type is {content_type.decode()} but ' + f'the content cannot be parsed as JSON.', decode_error + ) + raise BadRequestFormat( + f'Cannot parse content as JSON', + decode_error ) - raise BadRequestFormat( - f'Cannot parse content as JSON', - decode_error - ) cpdef bint has_body(self): cdef Content content = self.content diff --git a/dreaming_electric_sheep/server/application.py b/dreaming_electric_sheep/server/application.py index 0d93a047..eec949dd 100644 --- a/dreaming_electric_sheep/server/application.py +++ b/dreaming_electric_sheep/server/application.py @@ -1,4 +1,5 @@ import asyncio +import gc import logging from contextlib import asynccontextmanager from functools import wraps @@ -19,50 +20,50 @@ from itsdangerous import Serializer from rodi import ContainerProtocol -from blacksheep.baseapp import BaseApplication, handle_not_found -from blacksheep.common import extend -from blacksheep.common.files.asyncfs import FilesHandler -from blacksheep.contents import ASGIContent -from blacksheep.exceptions import NotFound -from blacksheep.messages import Request, Response -from blacksheep.middlewares import ( +from dreaming_electric_sheep.baseapp import BaseApplication, handle_not_found +from dreaming_electric_sheep.common import extend +from dreaming_electric_sheep.common.files.asyncfs import FilesHandler +from dreaming_electric_sheep.contents import ASGIContent +from dreaming_electric_sheep.exceptions import NotFound +from dreaming_electric_sheep.messages import Request, Response +from dreaming_electric_sheep.middlewares import ( MiddlewareCategory, MiddlewareList, get_middlewares_chain, ) -from blacksheep.scribe import send_asgi_response -from blacksheep.server.asgi import get_request_url_from_scope -from blacksheep.server.authentication import ( +from dreaming_electric_sheep.scribe import send_asgi_response +from dreaming_electric_sheep.server.asgi import get_request_url_from_scope +from dreaming_electric_sheep.server.authentication import ( AuthenticateChallenge, get_authentication_middleware, handle_authentication_challenge, handle_rate_limited_auth, ) -from blacksheep.server.authorization import ( +from dreaming_electric_sheep.server.authorization import ( AuthorizationWithoutAuthenticationError, get_authorization_middleware, handle_forbidden, handle_unauthorized, ) -from blacksheep.server.controllers import ControllersManager -from blacksheep.server.cors import CORSPolicy, CORSStrategy, get_cors_middleware -from blacksheep.server.env import EnvironmentSettings -from blacksheep.server.errors import ServerErrorDetailsHandler -from blacksheep.server.files import DefaultFileOptions -from blacksheep.server.files.dynamic import ResponseCallback, serve_files_dynamic -from blacksheep.server.normalization import normalize_handler, normalize_middleware -from blacksheep.server.process import use_shutdown_handler -from blacksheep.server.remotes.scheme import configure_scheme_middleware -from blacksheep.server.responses import _ensure_bytes -from blacksheep.server.routing import MountRegistry, RouteMethod, Router, RoutesRegistry -from blacksheep.server.routing import router as default_router -from blacksheep.server.routing import validate_default_router, validate_router -from blacksheep.server.websocket import WebSocket, format_reason -from blacksheep.sessions import SessionMiddleware, SessionSerializer -from blacksheep.sessions.abc import SessionStore -from blacksheep.settings.di import di_settings -from blacksheep.utils import join_fragments -from blacksheep.utils.meta import get_parent_file, import_child_modules +from dreaming_electric_sheep.server.controllers import ControllersManager +from dreaming_electric_sheep.server.cors import CORSPolicy, CORSStrategy, get_cors_middleware +from dreaming_electric_sheep.server.env import EnvironmentSettings +from dreaming_electric_sheep.server.errors import ServerErrorDetailsHandler +from dreaming_electric_sheep.server.files import DefaultFileOptions +from dreaming_electric_sheep.server.files.dynamic import ResponseCallback, serve_files_dynamic +from dreaming_electric_sheep.server.normalization import normalize_handler, normalize_middleware +from dreaming_electric_sheep.server.process import use_shutdown_handler +from dreaming_electric_sheep.server.remotes.scheme import configure_scheme_middleware +from dreaming_electric_sheep.server.responses import _ensure_bytes +from dreaming_electric_sheep.server.routing import MountRegistry, RouteMethod, Router, RoutesRegistry +from dreaming_electric_sheep.server.routing import router as default_router +from dreaming_electric_sheep.server.routing import validate_default_router, validate_router +from dreaming_electric_sheep.server.websocket import WebSocket, format_reason +from dreaming_electric_sheep.sessions import SessionMiddleware, SessionSerializer +from dreaming_electric_sheep.sessions.abc import SessionStore +from dreaming_electric_sheep.settings.di import di_settings +from dreaming_electric_sheep.utils import join_fragments +from dreaming_electric_sheep.utils.meta import get_parent_file, import_child_modules def get_default_headers_middleware( @@ -177,6 +178,10 @@ def __init__( services: ContainerProtocol | None = None, show_error_details: bool = False, mount: MountRegistry | None = None, + dec_hook: Callable[[type, Any], Any] | None = None, + enc_hook: Callable[[Any], Any] | None = None, + optimize_gc: bool = True, + gc_thresholds: tuple[int, int, int] = (50000, 10, 10), ): env_settings = EnvironmentSettings.from_env() if router is None: @@ -190,6 +195,15 @@ def __init__( assert services is not None self._services: ContainerProtocol = services + self.dec_hook: Callable[[type, Any], Any] | None = dec_hook + self.enc_hook: Callable[[Any], Any] | None = enc_hook + self.optimize_gc: bool = optimize_gc + self.gc_thresholds: tuple[int, int, int] = gc_thresholds + if self.optimize_gc: + try: + gc.set_threshold(*self.gc_thresholds) + except Exception: + pass self._middlewares: MiddlewareList = MiddlewareList() self._default_headers: tuple[tuple[str, str], ...] | None = None self._middlewares_configured = False @@ -287,7 +301,7 @@ def mount(self, path: str, app: Callable) -> None: that starts with the mount path, it is handled by the mounted app (the mount path is stripped from the final URL path received by the child application). - If the child application is a BlackSheep application, it requires handling of + If the child application is a Dreaming Electric Sheep application, it requires handling of its lifecycle events. This can be automatic, if the environment variable APP_MOUNT_AUTO_EVENTS is missing or set to "1" or "true" (case insensitive) @@ -370,7 +384,7 @@ def use_sessions( app.use_sessions(MyCustomSessionStore()) """ if isinstance(store, str): - from blacksheep.sessions.cookies import CookieSessionStore + from dreaming_electric_sheep.sessions.cookies import CookieSessionStore session_middleware = SessionMiddleware( CookieSessionStore( @@ -683,6 +697,11 @@ def normalize_handlers(self): if route.handler in configured_handlers: continue + if getattr(route, "dec_hook", None) is None: + route.dec_hook = self.dec_hook + if getattr(route, "enc_hook", None) is None: + route.enc_hook = self.enc_hook + route.handler = normalize_handler(route, self.services, method) configured_handlers.add(route.handler) diff --git a/dreaming_electric_sheep/structures.py b/dreaming_electric_sheep/structures.py new file mode 100644 index 00000000..fcace247 --- /dev/null +++ b/dreaming_electric_sheep/structures.py @@ -0,0 +1,37 @@ +""" +High-performance data structures for Dreaming Electric Sheep. +""" + +from typing import TypeVar +import msgspec + +T = TypeVar("T") + + +class Struct(msgspec.Struct, frozen=True): + """ + Base Struct class for Dreaming Electric Sheep. + + By default, it uses `frozen=True` to minimize memory footprint in C (~48 bytes), + enable aggressive compiler register optimizations, provide native immutability, + and make instances hashable. + """ + pass + + +def struct(cls=None, *, frozen: bool = True, omit_defaults: bool = False, rename=None): + """ + Decorator or helper to define a msgspec Struct with `frozen=True` by default. + """ + def decorator(c): + return msgspec.defstruct( + c.__name__, + [(f, getattr(c, f, msgspec.NODEFAULT)) for f in getattr(c, "__annotations__", {})], + frozen=frozen, + omit_defaults=omit_defaults, + rename=rename, + ) + + if cls is not None: + return decorator(cls) + return decorator diff --git a/tests/test_msgspec_binding.py b/tests/test_msgspec_binding.py new file mode 100644 index 00000000..9ac3ea1e --- /dev/null +++ b/tests/test_msgspec_binding.py @@ -0,0 +1,357 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import List, Optional +from uuid import UUID, uuid4 + +import msgspec +import pytest +from pydantic import BaseModel + +from dreaming_electric_sheep import Application, FromJSON, post +from dreaming_electric_sheep.contents import Content, JSONContent +from dreaming_electric_sheep.messages import Request +from dreaming_electric_sheep.testing import TestClient + + +@dataclass +class ItemDataClass: + name: str + price: float + tags: Optional[List[str]] = None + + +class ItemStruct(msgspec.Struct): + title: str + count: int + + +class ItemPydantic(BaseModel): + label: str + active: bool + + +@dataclass +class UserWithUUID: + id: UUID + username: str + + +@dataclass +class NestedProfile: + bio: str + age: int + + +@dataclass +class UserAccount: + name: str + profile: NestedProfile + + +@pytest.mark.asyncio +async def test_msgspec_binding_dataclass(): + app = Application() + + @post("/items") + async def create_item(item: ItemDataClass): + assert isinstance(item, ItemDataClass) + return {"name": item.name, "price": item.price, "tags": item.tags} + + app.router.add_post("/items", create_item) + await app.start() + client = TestClient(app) + + # 1. Valid payload + response = await client.post( + "/items", + content=JSONContent({"name": "apple", "price": 1.99, "tags": ["fruit", "red"]}), + ) + assert response.status == 200 + data = await response.json() + assert data == {"name": "apple", "price": 1.99, "tags": ["fruit", "red"]} + + # 2. Schema validation error (invalid price type) -> HTTP 422 + response = await client.post( + "/items", + content=JSONContent({"name": "apple", "price": "not_a_float"}), + ) + assert response.status == 422 + err = await response.json() + assert "error" in err or "Validation error" in str(err) + + # 3. Missing required field -> HTTP 422 + response = await client.post( + "/items", + content=JSONContent({"name": "apple"}), + ) + assert response.status == 422 + + # 4. Malformed JSON syntax -> HTTP 400 + response = await client.post( + "/items", + content=Content(b"application/json", b"{\"name\": broken json"), + ) + assert response.status == 400 + + +@pytest.mark.asyncio +async def test_msgspec_binding_struct(): + app = Application() + + @post("/struct") + async def create_struct(data: FromJSON[ItemStruct]): + item = data.value + assert isinstance(item, ItemStruct) + return {"title": item.title, "count": item.count} + + app.router.add_post("/struct", create_struct) + await app.start() + client = TestClient(app) + + # 1. Valid + response = await client.post( + "/struct", + content=JSONContent({"title": "inventory", "count": 42}), + ) + assert response.status == 200 + res = await response.json() + assert res == {"title": "inventory", "count": 42} + + # 2. Invalid field type -> HTTP 422 + response = await client.post( + "/struct", + content=JSONContent({"title": "inventory", "count": "invalid"}), + ) + assert response.status == 422 + + # 3. Malformed JSON -> HTTP 400 + response = await client.post( + "/struct", + content=Content(b"application/json", b"{bad_json"), + ) + assert response.status == 400 + + +@pytest.mark.asyncio +async def test_msgspec_binding_pydantic(): + app = Application() + + @post("/pydantic") + async def handle_pydantic(item: ItemPydantic): + assert isinstance(item, ItemPydantic) + return {"label": item.label, "active": item.active} + + app.router.add_post("/pydantic", handle_pydantic) + await app.start() + client = TestClient(app) + + response = await client.post( + "/pydantic", + content=JSONContent({"label": "test", "active": True}), + ) + assert response.status == 200 + res = await response.json() + assert res == {"label": "test", "active": True} + + # Validation failure -> HTTP 422 + response = await client.post( + "/pydantic", + content=JSONContent({"active": True}), + ) + assert response.status == 422 + + +@pytest.mark.asyncio +async def test_application_custom_dec_hook(): + class CustomTimestamp: + def __init__(self, value: int): + self.value = value + + @dataclass + class Event: + name: str + ts: CustomTimestamp + + def custom_dec_hook(type, obj): + if type is CustomTimestamp: + return CustomTimestamp(int(obj)) + return None + + app = Application(dec_hook=custom_dec_hook) + + @post("/event") + async def handle_event(event: Event): + assert isinstance(event.ts, CustomTimestamp) + return {"name": event.name, "ts": event.ts.value} + + app.router.add_post("/event", handle_event) + await app.start() + client = TestClient(app) + + response = await client.post( + "/event", + content=JSONContent({"name": "login", "ts": 1700000000}), + ) + assert response.status == 200 + res = await response.json() + assert res == {"name": "login", "ts": 1700000000} + + +@pytest.mark.asyncio +async def test_msgspec_binding_list_of_models(): + app = Application() + + @post("/items-bulk") + async def create_items_bulk(items: List[ItemDataClass]): + assert all(isinstance(item, ItemDataClass) for item in items) + return {"count": len(items), "total_price": sum(item.price for item in items)} + + app.router.add_post("/items-bulk", create_items_bulk) + await app.start() + client = TestClient(app) + + response = await client.post( + "/items-bulk", + content=JSONContent([ + {"name": "item1", "price": 10.0}, + {"name": "item2", "price": 20.0}, + ]), + ) + assert response.status == 200 + res = await response.json() + assert res == {"count": 2, "total_price": 30.0} + + # Validation error in one list item -> HTTP 422 + response = await client.post( + "/items-bulk", + content=JSONContent([ + {"name": "item1", "price": 10.0}, + {"name": "item2", "price": "invalid"}, + ]), + ) + assert response.status == 422 + + +@pytest.mark.asyncio +async def test_msgspec_uuid_support(): + app = Application() + + @post("/user") + async def handle_user(user: UserWithUUID): + assert isinstance(user.id, UUID) + return {"id": str(user.id), "username": user.username} + + app.router.add_post("/user", handle_user) + await app.start() + client = TestClient(app) + + uid = uuid4() + response = await client.post( + "/user", + content=JSONContent({"id": str(uid), "username": "alice"}), + ) + assert response.status == 200 + res = await response.json() + assert res == {"id": str(uid), "username": "alice"} + + +@pytest.mark.asyncio +async def test_msgspec_nested_models(): + app = Application() + + @post("/account") + async def handle_account(acc: UserAccount): + assert isinstance(acc.profile, NestedProfile) + return {"name": acc.name, "bio": acc.profile.bio, "age": acc.profile.age} + + app.router.add_post("/account", handle_account) + await app.start() + client = TestClient(app) + + response = await client.post( + "/account", + content=JSONContent({"name": "bob", "profile": {"bio": "developer", "age": 30}}), + ) + assert response.status == 200 + res = await response.json() + assert res == {"name": "bob", "bio": "developer", "age": 30} + + # Nested type validation error -> HTTP 422 + response = await client.post( + "/account", + content=JSONContent({"name": "bob", "profile": {"bio": "developer", "age": "not_an_int"}}), + ) + assert response.status == 422 + + +@pytest.mark.asyncio +async def test_read_raw_buffer_protocol(): + req = Request.incoming("POST", b"/test", b"", [(b"content-type", b"application/json")]) + req.content = Content(b"application/json", b'{"key": "value"}') + + raw = await req.read_raw() + assert isinstance(raw, (bytes, bytearray, memoryview)) + # Test that msgspec can directly decode this buffer + decoded = msgspec.json.decode(raw) + assert decoded == {"key": "value"} + + +@pytest.mark.asyncio +async def test_frozen_struct_base_class(): + from dreaming_electric_sheep import Struct + + class Product(Struct): + sku: str + qty: int + + prod = Product(sku="ABC-123", qty=10) + assert prod.sku == "ABC-123" + assert prod.qty == 10 + + # Verify frozen behavior (immutability) + with pytest.raises(AttributeError): + prod.qty = 20 # type: ignore + + # Verify hashability + prod_set = {prod} + assert prod in prod_set + + # Test route binding with frozen Struct + app = Application() + + @post("/product") + async def create_product(product: Product): + assert isinstance(product, Product) + return {"sku": product.sku, "qty": product.qty} + + app.router.add_post("/product", create_product) + await app.start() + client = TestClient(app) + + res = await client.post("/product", content=JSONContent({"sku": "XYZ-999", "qty": 5})) + assert res.status == 200 + assert (await res.json()) == {"sku": "XYZ-999", "qty": 5} + + +def test_gc_tuning_configuration(): + import gc + + app = Application(optimize_gc=True, gc_thresholds=(60000, 15, 15)) + assert app.optimize_gc is True + assert app.gc_thresholds == (60000, 15, 15) + assert gc.get_threshold() == (60000, 15, 15) + + +@pytest.mark.asyncio +async def test_read_detached_and_detach_raw(): + req = Request.incoming("POST", b"/test", b"", [(b"content-type", b"application/json")]) + req.content = Content(b"application/json", b'{"detached": true}') + + detached = await req.read_detached() + assert isinstance(detached, bytes) + assert detached == b'{"detached": true}' + + sync_detached = req.detach_raw() + assert isinstance(sync_detached, bytes) + assert sync_detached == b'{"detached": true}' + From ce657b175d28b3887ef2caf92721b3a0439dcd6a Mon Sep 17 00:00:00 2001 From: EduLoboM Date: Sat, 29 Aug 2026 15:24:25 -0300 Subject: [PATCH 07/45] feat(simd): implement SIMD vectorization and per-request scratchpad arena --- .gitignore | 2 + Makefile | 4 +- dreaming_electric_sheep/scratchpad.c | 75 ++++++++ dreaming_electric_sheep/scratchpad.h | 56 ++++++ dreaming_electric_sheep/simd_ops.c | 268 +++++++++++++++++++++++++++ dreaming_electric_sheep/simd_ops.h | 50 +++++ setup.py | 44 +++-- 7 files changed, 485 insertions(+), 14 deletions(-) create mode 100644 dreaming_electric_sheep/scratchpad.c create mode 100644 dreaming_electric_sheep/scratchpad.h create mode 100644 dreaming_electric_sheep/simd_ops.c create mode 100644 dreaming_electric_sheep/simd_ops.h diff --git a/.gitignore b/.gitignore index 3ff79313..cf9024dc 100644 --- a/.gitignore +++ b/.gitignore @@ -114,6 +114,8 @@ tests/out/ *,cover dreaming_electric_sheep/*.c +!dreaming_electric_sheep/simd_ops.c +!dreaming_electric_sheep/scratchpad.c dreaming_electric_sheep/*.html dreaming_electric_sheep/includes/*.html out/* diff --git a/Makefile b/Makefile index ab793005..e29d2bd1 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,7 @@ cyt: cython dreaming_electric_sheep/messages.pyx cython dreaming_electric_sheep/scribe.pyx cython dreaming_electric_sheep/baseapp.pyx + cython dreaming_electric_sheep/routing.pyx compile: cyt python3 setup.py build_ext --inplace @@ -18,7 +19,7 @@ compile: cyt clean: rm -rf dist/ rm -rf build/ - rm -f dreaming_electric_sheep/*.c + rm -f dreaming_electric_sheep/url.c dreaming_electric_sheep/exceptions.c dreaming_electric_sheep/headers.c dreaming_electric_sheep/cookies.c dreaming_electric_sheep/contents.c dreaming_electric_sheep/messages.c dreaming_electric_sheep/scribe.c dreaming_electric_sheep/baseapp.c dreaming_electric_sheep/routing.c rm -f dreaming_electric_sheep/*.so @@ -35,6 +36,7 @@ annotate: cython dreaming_electric_sheep/messages.pyx -a cython dreaming_electric_sheep/scribe.pyx -a cython dreaming_electric_sheep/baseapp.pyx -a + cython dreaming_electric_sheep/routing.pyx -a pack: diff --git a/dreaming_electric_sheep/scratchpad.c b/dreaming_electric_sheep/scratchpad.c new file mode 100644 index 00000000..61e3c1d5 --- /dev/null +++ b/dreaming_electric_sheep/scratchpad.c @@ -0,0 +1,75 @@ +#include "scratchpad.h" +#include + +int scratchpad_init(ScratchpadArena *arena, size_t capacity) { + if (arena == NULL) { + return -1; + } + if (capacity == 0) { + capacity = DEFAULT_SCRATCHPAD_CAPACITY; + } + arena->buffer = (char *)malloc(capacity); + if (arena->buffer == NULL) { + arena->capacity = 0; + arena->offset = 0; + arena->is_dynamic = 0; + return -1; + } + arena->capacity = capacity; + arena->offset = 0; + arena->is_dynamic = 1; + return 0; +} + +void *scratchpad_alloc(ScratchpadArena *arena, size_t size, size_t alignment) { + if (arena == NULL || arena->buffer == NULL || size == 0) { + return NULL; + } + + if (alignment == 0) { + alignment = sizeof(void *); + } + + // Align offset + size_t current_addr = (size_t)(arena->buffer + arena->offset); + size_t aligned_addr = (current_addr + (alignment - 1)) & ~(alignment - 1); + size_t new_offset = (aligned_addr - (size_t)arena->buffer) + size; + + if (new_offset > arena->capacity) { + // Arena overflow: attempt geometric resize if dynamically allocated + if (arena->is_dynamic) { + size_t new_capacity = arena->capacity * 2; + if (new_capacity < new_offset) { + new_capacity = new_offset + DEFAULT_SCRATCHPAD_CAPACITY; + } + char *new_buf = (char *)realloc(arena->buffer, new_capacity); + if (new_buf == NULL) { + return NULL; + } + arena->buffer = new_buf; + arena->capacity = new_capacity; + + current_addr = (size_t)(arena->buffer + arena->offset); + aligned_addr = (current_addr + (alignment - 1)) & ~(alignment - 1); + new_offset = (aligned_addr - (size_t)arena->buffer) + size; + } else { + return NULL; + } + } + + void *ptr = (void *)aligned_addr; + arena->offset = new_offset; + return ptr; +} + +void scratchpad_destroy(ScratchpadArena *arena) { + if (arena != NULL) { + if (arena->buffer != NULL && arena->is_dynamic) { + free(arena->buffer); + arena->buffer = NULL; + } + arena->capacity = 0; + arena->offset = 0; + arena->is_dynamic = 0; + } +} diff --git a/dreaming_electric_sheep/scratchpad.h b/dreaming_electric_sheep/scratchpad.h new file mode 100644 index 00000000..f8450335 --- /dev/null +++ b/dreaming_electric_sheep/scratchpad.h @@ -0,0 +1,56 @@ +/* + * Per-request Scratchpad Memory Arena for Dreaming Electric Sheep. + * Provides O(1) allocation and O(1) bulk reset on request completion, + * mitigating heap fragmentation and avoiding per-request malloc/free calls. + */ + +#ifndef DREAMING_ELECTRIC_SHEEP_SCRATCHPAD_H +#define DREAMING_ELECTRIC_SHEEP_SCRATCHPAD_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define DEFAULT_SCRATCHPAD_CAPACITY (64 * 1024) // 64 KB per request arena + +typedef struct { + char *buffer; + size_t capacity; + size_t offset; + int is_dynamic; +} ScratchpadArena; + +/* + * Initializes a scratchpad arena with the specified capacity. + */ +int scratchpad_init(ScratchpadArena *arena, size_t capacity); + +/* + * Allocates aligned memory from the scratchpad arena. + * Returns pointer to allocated memory, or NULL on out-of-memory. + */ +void *scratchpad_alloc(ScratchpadArena *arena, size_t size, size_t alignment); + +/* + * Resets the scratchpad arena in O(1) time by rewinding the offset to 0. + */ +static inline void scratchpad_reset(ScratchpadArena *arena) { + if (arena != NULL) { + arena->offset = 0; + } +} + +/* + * Destroys and frees the scratchpad arena buffer. + */ +void scratchpad_destroy(ScratchpadArena *arena); + +#ifdef __cplusplus +} +#endif + +#endif /* DREAMING_ELECTRIC_SHEEP_SCRATCHPAD_H */ diff --git a/dreaming_electric_sheep/simd_ops.c b/dreaming_electric_sheep/simd_ops.c new file mode 100644 index 00000000..afc65d8a --- /dev/null +++ b/dreaming_electric_sheep/simd_ops.c @@ -0,0 +1,268 @@ +#include "simd_ops.h" +#include + +#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86) + #if defined(__AVX2__) + #include + #define HAS_AVX2 1 + #elif defined(__SSE4_2__) || defined(__SSE2__) + #include + #define HAS_SSE2 1 + #endif +#elif defined(__ARM_NEON) || defined(__aarch64__) + #include + #define HAS_NEON 1 +#endif + +int64_t simd_find_crlf(const char * __restrict__ buffer, size_t length) { + if (buffer == NULL || length < 2) { + return -1; + } + + size_t i = 0; + +#if defined(HAS_AVX2) + // 32-byte chunks with AVX2 + __m256i cr = _mm256_set1_epi8('\r'); + while (i + 32 <= length) { + __m256i chunk = _mm256_loadu_si256((const __m256i *)(buffer + i)); + __m256i cmp = _mm256_cmpeq_epi8(chunk, cr); + uint32_t mask = (uint32_t)_mm256_movemask_epi8(cmp); + + while (mask != 0) { + int bit = __builtin_ctz(mask); + size_t pos = i + (size_t)bit; + if (pos + 1 < length && buffer[pos + 1] == '\n') { + return (int64_t)pos; + } + mask &= mask - 1; // Clear lowest set bit + } + i += 32; + } +#elif defined(HAS_SSE2) + // 16-byte chunks with SSE2 + __m128i cr = _mm_set1_epi8('\r'); + while (i + 16 <= length) { + __m128i chunk = _mm_loadu_si128((const __m128i *)(buffer + i)); + __m128i cmp = _mm_cmpeq_epi8(chunk, cr); + uint32_t mask = (uint32_t)_mm_movemask_epi8(cmp); + + while (mask != 0) { + int bit = __builtin_ctz(mask); + size_t pos = i + (size_t)bit; + if (pos + 1 < length && buffer[pos + 1] == '\n') { + return (int64_t)pos; + } + mask &= mask - 1; + } + i += 16; + } +#endif + + // Scalar fallback + for (; i + 1 < length; ++i) { + if (buffer[i] == '\r' && buffer[i + 1] == '\n') { + return (int64_t)i; + } + } + + return -1; +} + +int64_t simd_find_crlf_crlf(const char * __restrict__ buffer, size_t length) { + if (buffer == NULL || length < 4) { + return -1; + } + + size_t i = 0; + +#if defined(HAS_AVX2) + __m256i cr = _mm256_set1_epi8('\r'); + while (i + 32 <= length) { + __m256i chunk = _mm256_loadu_si256((const __m256i *)(buffer + i)); + __m256i cmp = _mm256_cmpeq_epi8(chunk, cr); + uint32_t mask = (uint32_t)_mm256_movemask_epi8(cmp); + + while (mask != 0) { + int bit = __builtin_ctz(mask); + size_t pos = i + (size_t)bit; + if (pos + 3 < length && + buffer[pos + 1] == '\n' && + buffer[pos + 2] == '\r' && + buffer[pos + 3] == '\n') { + return (int64_t)pos; + } + mask &= mask - 1; + } + i += 32; + } +#elif defined(HAS_SSE2) + __m128i cr = _mm_set1_epi8('\r'); + while (i + 16 <= length) { + __m128i chunk = _mm_loadu_si128((const __m128i *)(buffer + i)); + __m128i cmp = _mm_cmpeq_epi8(chunk, cr); + uint32_t mask = (uint32_t)_mm_movemask_epi8(cmp); + + while (mask != 0) { + int bit = __builtin_ctz(mask); + size_t pos = i + (size_t)bit; + if (pos + 3 < length && + buffer[pos + 1] == '\n' && + buffer[pos + 2] == '\r' && + buffer[pos + 3] == '\n') { + return (int64_t)pos; + } + mask &= mask - 1; + } + i += 16; + } +#endif + + // Scalar loop + for (; i + 3 < length; ++i) { + if (buffer[i] == '\r' && + buffer[i + 1] == '\n' && + buffer[i + 2] == '\r' && + buffer[i + 3] == '\n') { + return (int64_t)i; + } + } + + return -1; +} + +int64_t simd_find_path_separator(const char * __restrict__ buffer, size_t length, size_t start_pos) { + if (buffer == NULL || start_pos >= length) { + return -1; + } + + size_t i = start_pos; + +#if defined(HAS_AVX2) + __m256i slash = _mm256_set1_epi8('/'); + __m256i quest = _mm256_set1_epi8('?'); + __m256i hash = _mm256_set1_epi8('#'); + __m256i space = _mm256_set1_epi8(' '); + + while (i + 32 <= length) { + __m256i chunk = _mm256_loadu_si256((const __m256i *)(buffer + i)); + __m256i m1 = _mm256_cmpeq_epi8(chunk, slash); + __m256i m2 = _mm256_cmpeq_epi8(chunk, quest); + __m256i m3 = _mm256_cmpeq_epi8(chunk, hash); + __m256i m4 = _mm256_cmpeq_epi8(chunk, space); + __m256i matched = _mm256_or_si256(_mm256_or_si256(m1, m2), _mm256_or_si256(m3, m4)); + uint32_t mask = (uint32_t)_mm256_movemask_epi8(matched); + + if (mask != 0) { + int bit = __builtin_ctz(mask); + return (int64_t)(i + (size_t)bit); + } + i += 32; + } +#elif defined(HAS_SSE2) + __m128i slash = _mm_set1_epi8('/'); + __m128i quest = _mm_set1_epi8('?'); + __m128i hash = _mm_set1_epi8('#'); + __m128i space = _mm_set1_epi8(' '); + + while (i + 16 <= length) { + __m128i chunk = _mm_loadu_si128((const __m128i *)(buffer + i)); + __m128i m1 = _mm_cmpeq_epi8(chunk, slash); + __m128i m2 = _mm_cmpeq_epi8(chunk, quest); + __m128i m3 = _mm_cmpeq_epi8(chunk, hash); + __m128i m4 = _mm_cmpeq_epi8(chunk, space); + __m128i matched = _mm_or_si128(_mm_or_si128(m1, m2), _mm_or_si128(m3, m4)); + uint32_t mask = (uint32_t)_mm_movemask_epi8(matched); + + if (mask != 0) { + int bit = __builtin_ctz(mask); + return (int64_t)(i + (size_t)bit); + } + i += 16; + } +#endif + + // Scalar fallback + for (; i < length; ++i) { + char c = buffer[i]; + if (c == '/' || c == '?' || c == '#' || c == ' ') { + return (int64_t)i; + } + } + + return -1; +} + +int simd_validate_url_ascii(const char * __restrict__ buffer, size_t length) { + if (buffer == NULL) { + return 0; + } + + size_t i = 0; + +#if defined(HAS_AVX2) + __m256i min_val = _mm256_set1_epi8(32); // ' ' space is 32 + __m256i max_val = _mm256_set1_epi8(126); // '~' is 126 + + while (i + 32 <= length) { + __m256i chunk = _mm256_loadu_si256((const __m256i *)(buffer + i)); + // Check if any byte < 32 or > 126 + __m256i lt = _mm256_cmpgt_epi8(min_val, chunk); + __m256i gt = _mm256_cmpgt_epi8(chunk, max_val); + __m256i invalid = _mm256_or_si256(lt, gt); + if (_mm256_movemask_epi8(invalid) != 0) { + return 0; + } + i += 32; + } +#elif defined(HAS_SSE2) + __m128i min_val = _mm_set1_epi8(32); + __m128i max_val = _mm_set1_epi8(126); + + while (i + 16 <= length) { + __m128i chunk = _mm_loadu_si128((const __m128i *)(buffer + i)); + __m128i lt = _mm_cmpgt_epi8(min_val, chunk); + __m128i gt = _mm_cmpgt_epi8(chunk, max_val); + __m128i invalid = _mm_or_si128(lt, gt); + if (_mm_movemask_epi8(invalid) != 0) { + return 0; + } + i += 16; + } +#endif + + for (; i < length; ++i) { + unsigned char c = (unsigned char)buffer[i]; + if (c < 32 || c > 126) { + return 0; + } + } + + return 1; +} + +uint32_t simd_fast_hash(const char * __restrict__ buffer, size_t length) { + if (buffer == NULL || length == 0) { + return 0; + } + + // FNV-1a with 32-bit prime, unrolled in 4-byte steps + uint32_t hash = 2166136261u; + size_t i = 0; + + while (i + 4 <= length) { + uint32_t val; + memcpy(&val, buffer + i, 4); + hash = (hash ^ (val & 0xFF)) * 16777619u; + hash = (hash ^ ((val >> 8) & 0xFF)) * 16777619u; + hash = (hash ^ ((val >> 16) & 0xFF)) * 16777619u; + hash = (hash ^ ((val >> 24) & 0xFF)) * 16777619u; + i += 4; + } + + for (; i < length; ++i) { + hash = (hash ^ ((unsigned char)buffer[i])) * 16777619u; + } + + return hash; +} diff --git a/dreaming_electric_sheep/simd_ops.h b/dreaming_electric_sheep/simd_ops.h new file mode 100644 index 00000000..721cfa6f --- /dev/null +++ b/dreaming_electric_sheep/simd_ops.h @@ -0,0 +1,50 @@ +/* + * SIMD vectorization and fast memory operations for Dreaming Electric Sheep. + * Supports AVX2 (x86_64), SSE4.2 (x86), ARM NEON (aarch64), and portable scalar SWAR. + */ + +#ifndef DREAMING_ELECTRIC_SHEEP_SIMD_OPS_H +#define DREAMING_ELECTRIC_SHEEP_SIMD_OPS_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Finds the offset of the first occurrence of "\r\n" in buffer. + * Returns -1 if not found. + */ +int64_t simd_find_crlf(const char * __restrict__ buffer, size_t length); + +/* + * Finds the offset of the first occurrence of "\r\n\r\n" (end of HTTP headers). + * Returns -1 if not found. + */ +int64_t simd_find_crlf_crlf(const char * __restrict__ buffer, size_t length); + +/* + * Finds the offset of the next path separator ('/', '?', '#', ' ') starting from start_pos. + * Returns -1 if not found. + */ +int64_t simd_find_path_separator(const char * __restrict__ buffer, size_t length, size_t start_pos); + +/* + * Validates that all bytes in the buffer are valid ASCII URL characters without control chars. + * Returns 1 if valid, 0 if invalid character found. + */ +int simd_validate_url_ascii(const char * __restrict__ buffer, size_t length); + +/* + * Computes a fast 32-bit hash of a byte buffer using SWAR / SIMD unrolling. + */ +uint32_t simd_fast_hash(const char * __restrict__ buffer, size_t length); + +#ifdef __cplusplus +} +#endif + +#endif /* DREAMING_ELECTRIC_SHEEP_SIMD_OPS_H */ diff --git a/setup.py b/setup.py index 0ae95f85..9f4d872e 100644 --- a/setup.py +++ b/setup.py @@ -1,15 +1,18 @@ """ -This file is used to specify Python extensions, which are used when using Cython. -Extensions are used only if the current runtime is CPython and only if there is not an -environment variable: `DREAMING_ELECTRIC_SHEEP_NO_EXTENSIONS=1`. -The logic is to support PyPy. +High-performance C/Cython extensions build configuration for Dreaming Electric Sheep. +Optimized exclusively for modern CPython (>= 3.11) with SIMD vectorization, +PEP 590 Vectorcall, and C-level memory arenas. """ import os +import sys from setuptools import Extension, setup -import platform -COMPILE_ARGS = ["-O2"] +COMPILE_ARGS = ["-O3", "-fPIC"] + +if sys.platform != "win32": + # On GCC/Clang enable pointer aliasing and math optimizations + COMPILE_ARGS.extend(["-fstrict-aliasing"]) # Check for environment variable to skip extensions skip_ext = ( @@ -17,12 +20,15 @@ or os.environ.get("BLACKSHEEP_NO_EXTENSIONS", "0") == "1" ) - -if platform.python_implementation() == "CPython" and not skip_ext: +ext_modules = [] +if not skip_ext: ext_modules = [ Extension( "dreaming_electric_sheep.url", - ["dreaming_electric_sheep/url.c"], + [ + "dreaming_electric_sheep/url.c", + "dreaming_electric_sheep/simd_ops.c", + ], extra_compile_args=COMPILE_ARGS, ), Extension( @@ -47,12 +53,18 @@ ), Extension( "dreaming_electric_sheep.messages", - ["dreaming_electric_sheep/messages.c"], + [ + "dreaming_electric_sheep/messages.c", + "dreaming_electric_sheep/scratchpad.c", + ], extra_compile_args=COMPILE_ARGS, ), Extension( "dreaming_electric_sheep.scribe", - ["dreaming_electric_sheep/scribe.c"], + [ + "dreaming_electric_sheep/scribe.c", + "dreaming_electric_sheep/simd_ops.c", + ], extra_compile_args=COMPILE_ARGS, ), Extension( @@ -60,9 +72,15 @@ ["dreaming_electric_sheep/baseapp.c"], extra_compile_args=COMPILE_ARGS, ), + Extension( + "dreaming_electric_sheep.routing", + [ + "dreaming_electric_sheep/routing.c", + "dreaming_electric_sheep/simd_ops.c", + ], + extra_compile_args=COMPILE_ARGS, + ), ] -else: - ext_modules = [] setup(ext_modules=ext_modules) From d015f0fcf6980cbac51d47e10fa8ccb2ffc923de Mon Sep 17 00:00:00 2001 From: EduLoboM Date: Sat, 29 Aug 2026 15:24:28 -0300 Subject: [PATCH 08/45] feat(routing): implement Cython Radix Tree router and pure RouteMatch extension type --- dreaming_electric_sheep/routing.pxd | 34 +++ dreaming_electric_sheep/routing.py | 257 ++++++++++++++++++ dreaming_electric_sheep/routing.pyx | 307 ++++++++++++++++++++++ dreaming_electric_sheep/server/routing.py | 64 ++++- tests/test_cython_radix_router.py | 141 ++++++++++ 5 files changed, 789 insertions(+), 14 deletions(-) create mode 100644 dreaming_electric_sheep/routing.pxd create mode 100644 dreaming_electric_sheep/routing.py create mode 100644 dreaming_electric_sheep/routing.pyx create mode 100644 tests/test_cython_radix_router.py diff --git a/dreaming_electric_sheep/routing.pxd b/dreaming_electric_sheep/routing.pxd new file mode 100644 index 00000000..7d929a70 --- /dev/null +++ b/dreaming_electric_sheep/routing.pxd @@ -0,0 +1,34 @@ +cdef class RouteMatch: + cdef public object handler + cdef public object pattern + cdef public dict _values + cdef public object route + + +cdef class RadixNode: + cdef public bytes segment + cdef public object route + cdef public dict children + cdef public RadixNode param_child + cdef public str param_name + cdef public list param_names + cdef public object param_validator + cdef public RadixNode wildcard_child + cdef public str wildcard_name + cdef public bytes wildcard_suffix + cdef public bint is_leaf + + +cdef class RadixTree: + cdef public RadixNode root + cdef public dict static_routes + cpdef void insert(self, bytes pattern, object route, list param_names=*) + cdef tuple _parse_param_segment(self, bytes seg) + cpdef tuple match(self, bytes path) + cdef tuple _match_recursive(self, RadixNode current, list segments, int index, dict params) + + +cdef class CythonRadixRouter: + cdef public dict trees + cpdef void add_route(self, bytes method, bytes pattern, object route, list param_names=*) + cpdef tuple get_match(self, bytes method, bytes path) diff --git a/dreaming_electric_sheep/routing.py b/dreaming_electric_sheep/routing.py new file mode 100644 index 00000000..90e2fc87 --- /dev/null +++ b/dreaming_electric_sheep/routing.py @@ -0,0 +1,257 @@ +import re +from urllib.parse import unquote + +_INT_RX = re.compile(rb"^\d+$") +_FLOAT_RX = re.compile(rb"^\d+(?:\.\d+)?$") +_UUID_RX = re.compile( + rb"^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" +) + + +def _fast_unquote(val: bytes) -> str: + decoded = val.decode("utf8", errors="replace") + if "%" in decoded: + return unquote(decoded) + return decoded + + +class RadixNode: + __slots__ = ( + "segment", + "route", + "children", + "param_child", + "param_name", + "param_validator", + "wildcard_child", + "wildcard_name", + "is_leaf", + ) + + def __init__(self, segment: bytes = b""): + self.segment = segment + self.route = None + self.children = {} + self.param_child = None + self.param_name = None + self.param_validator = None + self.wildcard_child = None + self.wildcard_name = None + self.wildcard_suffix = None + self.is_leaf = False + + +class RadixTree: + __slots__ = ("root", "static_routes") + + def __init__(self): + self.root = RadixNode(b"") + self.static_routes = {} + + def insert(self, pattern: bytes, route, param_names=None): + norm_pattern = pattern + if norm_pattern == b"": + norm_pattern = b"/" + if len(norm_pattern) > 1 and norm_pattern.endswith(b"/") and not norm_pattern.endswith(b"*/"): + norm_pattern = norm_pattern.rstrip(b"/") + + has_params = ( + b"{" in norm_pattern + or b":" in norm_pattern + or b"<" in norm_pattern + or b"*" in norm_pattern + ) + + if not has_params: + self.static_routes[norm_pattern] = route + if norm_pattern != b"/": + self.static_routes[norm_pattern + b"/"] = route + else: + self.static_routes[b""] = route + + raw_segments = [s for s in norm_pattern.split(b"/") if s] + current = self.root + + if not raw_segments: + self.root.route = route + self.root.is_leaf = True + return + + for seg in raw_segments: + if seg == b"*" or seg.startswith(b"*"): + if current.wildcard_child is None: + current.wildcard_child = RadixNode(b"*") + current.wildcard_name = "tail" + if len(seg) > 1: + current.wildcard_suffix = seg[1:].lower() + else: + current.wildcard_suffix = None + current = current.wildcard_child + break + elif ( + (seg.startswith(b"{") and seg.endswith(b"}")) + or (seg.startswith(b"<") and seg.endswith(b">")) + or (seg.startswith(b":")) + ): + p_name, validator = self._parse_param_segment(seg) + if validator == "path": + if current.wildcard_child is None: + current.wildcard_child = RadixNode(b"*") + current.wildcard_name = p_name + current.wildcard_suffix = None + current = current.wildcard_child + break + + if current.param_child is None: + current.param_child = RadixNode(seg) + current.param_child.param_name = p_name + current.param_child.param_validator = validator + current = current.param_child + else: + seg_lower = seg.lower() + if seg_lower not in current.children: + current.children[seg_lower] = RadixNode(seg_lower) + current = current.children[seg_lower] + + current.route = route + current.is_leaf = True + if param_names is not None: + current.param_names = [p if isinstance(p, str) else p.decode("utf8") for p in param_names] + elif hasattr(route, "param_names") and route.param_names: + current.param_names = [p if isinstance(p, str) else p.decode("utf8") for p in route.param_names] + else: + current.param_names = None + + def _parse_param_segment(self, seg: bytes): + if seg.startswith(b"{") and seg.endswith(b"}"): + inner = seg[1:-1].decode("utf8") + elif seg.startswith(b"<") and seg.endswith(b">"): + inner = seg[1:-1].decode("utf8") + elif seg.startswith(b":"): + inner = seg[1:].decode("utf8") + else: + inner = seg.decode("utf8") + + param_type = "" + if ":" in inner: + parts = inner.split(":", 1) + if parts[0] in ("int", "float", "uuid", "str", "string", "path"): + param_type = parts[0] + param_name = parts[1] + else: + param_name = parts[0] + param_type = parts[1] + else: + param_name = inner + + validator = None + if param_type == "int": + validator = _INT_RX + elif param_type == "float": + validator = _FLOAT_RX + elif param_type == "uuid": + validator = _UUID_RX + elif param_type == "path": + validator = "path" + + return param_name, validator + + def match(self, path: bytes): + if b"//" in path: + return None + + static_match = self.static_routes.get(path) + if static_match is not None: + return (static_match, None) + + norm_path = path + if len(norm_path) > 1 and norm_path.endswith(b"/"): + norm_path = norm_path.rstrip(b"/") + + static_match = self.static_routes.get(norm_path) + if static_match is not None: + return (static_match, None) + + if norm_path == b"" or norm_path == b"/": + if self.root.route is not None: + return (self.root.route, None) + if self.root.wildcard_child is not None and self.root.wildcard_child.route is not None: + return (self.root.wildcard_child.route, {self.root.wildcard_name or "tail": ""}) + return None + + segments = [s for s in norm_path.split(b"/") if s] + return self._match_recursive(self.root, segments, 0, {}) + + def _match_recursive(self, current: RadixNode, segments: list, index: int, params: dict): + total = len(segments) + if index == total: + if current.is_leaf and current.route is not None: + if current.param_names is not None and params and len(current.param_names) == len(params): + return (current.route, dict(zip(current.param_names, params.values()))) + return (current.route, params if params else None) + if current.wildcard_child is not None and current.wildcard_child.route is not None: + if current.wildcard_suffix is not None: + return None + p_copy = params.copy() if params else {} + p_copy[current.wildcard_name or "tail"] = "" + return (current.wildcard_child.route, p_copy) + return None + + seg = segments[index] + seg_lower = seg.lower() + + if seg_lower in current.children: + child = current.children[seg_lower] + res = self._match_recursive(child, segments, index + 1, params) + if res is not None: + return res + + if current.param_child is not None: + child = current.param_child + if child.param_validator is not None: + if not child.param_validator.match(seg): + child = None + + if child is not None: + p_copy = params.copy() if params else {} + p_copy[f"_p_{index}"] = _fast_unquote(seg) + res = self._match_recursive(child, segments, index + 1, p_copy) + if res is not None: + return res + + if current.wildcard_child is not None and current.wildcard_child.route is not None: + tail_bytes = b"/".join(segments[index:]) + if current.wildcard_suffix is not None: + if not tail_bytes.lower().endswith(current.wildcard_suffix): + return None + tail_bytes = tail_bytes[: -len(current.wildcard_suffix)] + p_copy = params.copy() if params else {} + tail_str = _fast_unquote(tail_bytes) + if current.wildcard_child.param_names and len(current.wildcard_child.param_names) == len(params) + 1: + all_vals = list(params.values()) + [tail_str] + return (current.wildcard_child.route, dict(zip(current.wildcard_child.param_names, all_vals))) + p_copy[current.wildcard_name or "tail"] = tail_str + return (current.wildcard_child.route, p_copy) + + return None + + +class CythonRadixRouter: + __slots__ = ("trees",) + + def __init__(self): + self.trees = {} + + def add_route(self, method: bytes, pattern: bytes, route, param_names=None): + m = method.upper() + if m not in self.trees: + self.trees[m] = RadixTree() + tree = self.trees[m] + tree.insert(pattern, route, param_names) + + def get_match(self, method: bytes, path: bytes): + m = method.upper() + if m not in self.trees: + return None + tree = self.trees[m] + return tree.match(path) diff --git a/dreaming_electric_sheep/routing.pyx b/dreaming_electric_sheep/routing.pyx new file mode 100644 index 00000000..eaa31be9 --- /dev/null +++ b/dreaming_electric_sheep/routing.pyx @@ -0,0 +1,307 @@ +# cython: boundscheck=False +# cython: wraparound=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: initializedcheck=False +# cython: language_level=3 +# Copyright (C) 2018-present Roberto Prevato +# +# This module is part of Dreaming Electric Sheep and is released under +# the MIT License https://opensource.org/licenses/MIT + +import re +from urllib.parse import unquote +from libc.stdint cimport uint32_t, int64_t + +cdef extern from "simd_ops.h" nogil: + uint32_t simd_fast_hash(const char *buffer, size_t length) + int64_t simd_find_path_separator(const char *buffer, size_t length, size_t start_pos) + + +cdef class RouteMatch: + def __init__(self, object route, dict values=None): + self.route = route + self.handler = route.handler + self.pattern = route.pattern + self._values = ( + { + k: unquote(v.decode("utf8")) if isinstance(v, bytes) else (unquote(v) if isinstance(v, str) and "%" in v else v) + for k, v in values.items() + } + if values + else None + ) + + @property + def values(self) -> dict: + return self._values + + def __repr__(self): + return f"" + + +_INT_RX = re.compile(rb"^\d+$") +_FLOAT_RX = re.compile(rb"^\d+(?:\.\d+)?$") +_UUID_RX = re.compile( + rb"^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" +) + + +cdef inline str _fast_unquote(bytes val): + cdef str decoded = val.decode("utf8", errors="replace") + if "%" in decoded: + return unquote(decoded) + return decoded + + +cdef class RadixNode: + + def __init__(self, bytes segment=b""): + self.segment = segment + self.route = None + self.children = {} + self.param_child = None + self.param_name = None + self.param_validator = None + self.wildcard_child = None + self.wildcard_name = None + self.is_leaf = False + + +cdef class RadixTree: + + def __init__(self): + self.root = RadixNode(b"") + self.static_routes = {} + + cpdef void insert(self, bytes pattern, object route, list param_names=None): + cdef bytes norm_pattern = pattern + if norm_pattern == b"": + norm_pattern = b"/" + if len(norm_pattern) > 1 and norm_pattern.endswith(b"/") and not norm_pattern.endswith(b"*/"): + norm_pattern = norm_pattern.rstrip(b"/") + + # Detect static route without params or wildcards + cdef bint has_params = ( + b"{" in norm_pattern + or b":" in norm_pattern + or b"<" in norm_pattern + or b"*" in norm_pattern + ) + + if not has_params: + self.static_routes[norm_pattern] = route + # Also insert with trailing slash for fast lookup + if norm_pattern != b"/": + self.static_routes[norm_pattern + b"/"] = route + else: + self.static_routes[b""] = route + + # Split pattern into segments + cdef list raw_segments = [s for s in norm_pattern.split(b"/") if s] + cdef RadixNode current = self.root + cdef bytes seg + cdef str p_name + cdef object validator + + if not raw_segments: + # Root route "/" + self.root.route = route + self.root.is_leaf = True + return + + for i, seg in enumerate(raw_segments): + if seg == b"*" or seg.startswith(b"*"): + # Wildcard catch-all + if current.wildcard_child is None: + current.wildcard_child = RadixNode(b"*") + current.wildcard_name = "tail" + if len(seg) > 1: + current.wildcard_suffix = seg[1:].lower() + else: + current.wildcard_suffix = None + current = current.wildcard_child + break + elif ( + (seg.startswith(b"{") and seg.endswith(b"}")) + or (seg.startswith(b"<") and seg.endswith(b">")) + or (seg.startswith(b":")) + ): + # Parameterized segment + p_name, validator = self._parse_param_segment(seg) + if validator == "path": + # Path wildcard + if current.wildcard_child is None: + current.wildcard_child = RadixNode(b"*") + current.wildcard_name = p_name + current.wildcard_suffix = None + current = current.wildcard_child + break + + if current.param_child is None: + current.param_child = RadixNode(seg) + current.param_child.param_name = p_name + current.param_child.param_validator = validator + current = current.param_child + else: + # Static segment + seg_lower = seg.lower() + if seg_lower not in current.children: + current.children[seg_lower] = RadixNode(seg_lower) + current = current.children[seg_lower] + + current.route = route + current.is_leaf = True + if param_names is not None: + current.param_names = [p if isinstance(p, str) else p.decode("utf8") for p in param_names] + elif hasattr(route, "param_names") and route.param_names: + current.param_names = [p if isinstance(p, str) else p.decode("utf8") for p in route.param_names] + else: + current.param_names = None + + cdef tuple _parse_param_segment(self, bytes seg): + cdef str inner + cdef str param_type = "" + cdef str param_name = "" + cdef object validator = None + + if seg.startswith(b"{") and seg.endswith(b"}"): + inner = seg[1:-1].decode("utf8") + elif seg.startswith(b"<") and seg.endswith(b">"): + inner = seg[1:-1].decode("utf8") + elif seg.startswith(b":"): + inner = seg[1:].decode("utf8") + else: + inner = seg.decode("utf8") + + if ":" in inner: + parts = inner.split(":", 1) + # Support both {int:id} and {id:int} + if parts[0] in ("int", "float", "uuid", "str", "string", "path"): + param_type = parts[0] + param_name = parts[1] + else: + param_name = parts[0] + param_type = parts[1] + else: + param_name = inner + + if param_type == "int": + validator = _INT_RX + elif param_type == "float": + validator = _FLOAT_RX + elif param_type == "uuid": + validator = _UUID_RX + elif param_type == "path": + validator = "path" + + return param_name, validator + + cpdef tuple match(self, bytes path): + if b"//" in path: + return None + + # 1. Check static route fast-path + cdef object static_match = self.static_routes.get(path) + if static_match is not None: + return (static_match, None) + + cdef bytes norm_path = path + if len(norm_path) > 1 and norm_path.endswith(b"/"): + norm_path = norm_path.rstrip(b"/") + + static_match = self.static_routes.get(norm_path) + if static_match is not None: + return (static_match, None) + + if norm_path == b"" or norm_path == b"/": + if self.root.route is not None: + return (self.root.route, None) + if self.root.wildcard_child is not None and self.root.wildcard_child.route is not None: + return (self.root.wildcard_child.route, {self.root.wildcard_name or "tail": ""}) + return None + + # 2. Segment-based tree traversal + cdef list segments = [s for s in norm_path.split(b"/") if s] + cdef dict params = {} + cdef tuple result = self._match_recursive(self.root, segments, 0, params) + return result + + cdef tuple _match_recursive(self, RadixNode current, list segments, int index, dict params): + cdef int total = len(segments) + if index == total: + if current.is_leaf and current.route is not None: + if current.param_names is not None and params and len(current.param_names) == len(params): + return (current.route, dict(zip(current.param_names, params.values()))) + return (current.route, params if params else None) + if current.wildcard_child is not None and current.wildcard_child.route is not None: + if current.wildcard_suffix is not None: + return None + p_copy = params.copy() if params else {} + p_copy[current.wildcard_name or "tail"] = "" + return (current.wildcard_child.route, p_copy) + return None + + cdef bytes seg = segments[index] + cdef bytes seg_lower = seg.lower() + cdef RadixNode child + cdef tuple res + + # A. Try exact static child + if seg_lower in current.children: + child = current.children[seg_lower] + res = self._match_recursive(child, segments, index + 1, params) + if res is not None: + return res + + # B. Try parameterized child + if current.param_child is not None: + child = current.param_child + # Check validator regex if present + if child.param_validator is not None: + if not child.param_validator.match(seg): + child = None + + if child is not None: + p_copy = params.copy() if params else {} + p_copy[f"_p_{index}"] = _fast_unquote(seg) + res = self._match_recursive(child, segments, index + 1, p_copy) + if res is not None: + return res + + # C. Try wildcard child + if current.wildcard_child is not None and current.wildcard_child.route is not None: + tail_bytes = b"/".join(segments[index:]) + if current.wildcard_suffix is not None: + if not tail_bytes.lower().endswith(current.wildcard_suffix): + return None + tail_bytes = tail_bytes[: -len(current.wildcard_suffix)] + p_copy = params.copy() if params else {} + tail_str = _fast_unquote(tail_bytes) + if current.wildcard_child.param_names and len(current.wildcard_child.param_names) == len(params) + 1: + all_vals = list(params.values()) + [tail_str] + return (current.wildcard_child.route, dict(zip(current.wildcard_child.param_names, all_vals))) + p_copy[current.wildcard_name or "tail"] = tail_str + return (current.wildcard_child.route, p_copy) + + return None + + +cdef class CythonRadixRouter: + + def __init__(self): + self.trees = {} + + cpdef void add_route(self, bytes method, bytes pattern, object route, list param_names=None): + cdef bytes m = method.upper() + if m not in self.trees: + self.trees[m] = RadixTree() + cdef RadixTree tree = self.trees[m] + tree.insert(pattern, route, param_names) + + cpdef tuple get_match(self, bytes method, bytes path): + cdef bytes m = method.upper() + if m not in self.trees: + return None + cdef RadixTree tree = self.trees[m] + return tree.match(path) diff --git a/dreaming_electric_sheep/server/routing.py b/dreaming_electric_sheep/server/routing.py index c1363d62..edc2081a 100644 --- a/dreaming_electric_sheep/server/routing.py +++ b/dreaming_electric_sheep/server/routing.py @@ -22,6 +22,7 @@ normalize_params, ) from dreaming_electric_sheep.messages import Request +from dreaming_electric_sheep.routing import CythonRadixRouter from dreaming_electric_sheep.server.env import get_global_route_prefix from dreaming_electric_sheep.utils import ensure_bytes, ensure_str @@ -125,21 +126,32 @@ def __init__(self, name: str) -> None: self.name = name -class RouteMatch: - __slots__ = ("_values", "pattern", "handler") +try: + from dreaming_electric_sheep.routing import RouteMatch +except ImportError: + class RouteMatch: + __slots__ = ("_values", "pattern", "handler", "route") - def __init__(self, route: "Route", values: dict[str, bytes | None]): - self.handler = route.handler - self.pattern = route.pattern - self._values: dict[str, str | None] = ( - {k: unquote(v.decode("utf8")) for k, v in values.items()} - if values - else None - ) + def __init__( + self, + route: "Route", + values: dict[str, bytes | None] | dict[str, str | None] | None, + ): + self.route = route + self.handler = route.handler + self.pattern = route.pattern + self._values: dict[str, str | None] = ( + { + k: unquote(v.decode("utf8")) if isinstance(v, bytes) else v + for k, v in values.items() + } + if values + else None + ) - @property - def values(self) -> dict[str, str | None]: - return self._values + @property + def values(self) -> dict[str, str | None]: + return self._values class RouteFilter(ABC): @@ -739,6 +751,7 @@ class Router(RouterBase): "_prefix", "_registered_routes", "_named_routes", + "_radix_router", ) def __init__( @@ -768,6 +781,7 @@ def __init__( self._sub_routers = sub_routers self._registered_routes = [] # used during setup self._named_routes: dict[str, Route] = {} + self._radix_router = CythonRadixRouter() if self._filters: extend(self, RouterFiltersMixin) @@ -785,6 +799,7 @@ def reset(self): self._fallback = None self.routes = defaultdict(list) self._named_routes = {} + self._radix_router = CythonRadixRouter() self.controllers_routes.reset() if self._sub_routers: for sub_router in self._sub_routers: @@ -968,6 +983,10 @@ def add_route(self, method: AnyStr, route: Route): method_bytes = ensure_bytes(method) if not isinstance(route, FilterRoute): self._check_duplicate(method_bytes, route) + if self._radix_router is not None: + self._radix_router.add_route( + method_bytes, route.pattern, route, route.param_names + ) self.routes[method_bytes].append(route) def sort_routes(self): @@ -992,6 +1011,15 @@ def sort_routes(self): self.routes = current_routes + # Re-populate radix router in sorted order + self._radix_router = CythonRadixRouter() + for method, routes_list in self.routes.items(): + for route in routes_list: + if not isinstance(route, FilterRoute): + self._radix_router.add_route( + method, route.pattern, route, route.param_names + ) + if self._sub_routers: for sub_router in self._sub_routers: sub_router.sort_routes() @@ -1006,8 +1034,16 @@ def get_match(self, request: Request) -> RouteMatch | None: def get_match_by_method_and_path( self, method: AnyStr, path: AnyStr ) -> RouteMatch | None: + bytes_method = ensure_bytes(method) bytes_value = ensure_bytes(path) - for route in self.routes[ensure_bytes(method)]: + + if self._radix_router is not None: + radix_res = self._radix_router.get_match(bytes_method, bytes_value) + if radix_res is not None: + route, values = radix_res + return RouteMatch(route, values) + + for route in self.routes[bytes_method]: match = route.match_by_path(bytes_value) if match: return match diff --git a/tests/test_cython_radix_router.py b/tests/test_cython_radix_router.py new file mode 100644 index 00000000..37b69987 --- /dev/null +++ b/tests/test_cython_radix_router.py @@ -0,0 +1,141 @@ +import pytest +from dreaming_electric_sheep.routing import CythonRadixRouter, RadixTree +from dreaming_electric_sheep.server.routing import Route, Router, RouteMethod + + +def test_radix_tree_static_routes(): + tree = RadixTree() + + def home(): ... + def api_users(): ... + def api_health(): ... + + r_home = Route(b"/", home) + r_users = Route(b"/api/users", api_users) + r_health = Route(b"/api/health", api_health) + + tree.insert(b"/", r_home) + tree.insert(b"/api/users", r_users) + tree.insert(b"/api/health", r_health) + + # Exact matches + match, params = tree.match(b"/") + assert match is r_home + assert params is None + + match, params = tree.match(b"/api/users") + assert match is r_users + assert params is None + + # Trailing slash support + match, params = tree.match(b"/api/users/") + assert match is r_users + assert params is None + + # Non existing route + assert tree.match(b"/api/notfound") is None + assert tree.match(b"/api/users//") is None + + +def test_radix_tree_parameterized_routes(): + tree = RadixTree() + + def get_user(): ... + def get_user_post(): ... + + r_user = Route(b"/users/{id}", get_user) + r_post = Route(b"/users/{user_id}/posts/{post_id}", get_user_post) + + tree.insert(b"/users/{id}", r_user) + tree.insert(b"/users/{user_id}/posts/{post_id}", r_post) + + # Match single param + match, params = tree.match(b"/users/42") + assert match is r_user + assert params == {"id": "42"} + + # Match multi param with URL decoding + match, params = tree.match(b"/users/john%20doe/posts/101") + assert match is r_post + assert params == {"user_id": "john doe", "post_id": "101"} + + +def test_radix_tree_typed_parameter_validators(): + tree = RadixTree() + + def get_by_int(): ... + def get_by_uuid(): ... + + r_int = Route(b"/items/{int:item_id}", get_by_int) + r_uuid = Route(b"/files/{uuid:file_id}", get_by_uuid) + + tree.insert(b"/items/{int:item_id}", r_int) + tree.insert(b"/files/{uuid:file_id}", r_uuid) + + # Valid int + match, params = tree.match(b"/items/12345") + assert match is r_int + assert params == {"item_id": "12345"} + + # Invalid int should not match + assert tree.match(b"/items/abc") is None + + # Valid UUID + valid_uuid = b"a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d" + match, params = tree.match(b"/files/" + valid_uuid) + assert match is r_uuid + assert params == {"file_id": valid_uuid.decode()} + + # Invalid UUID + assert tree.match(b"/files/invalid-uuid-format") is None + + +def test_radix_tree_wildcard_catchall(): + tree = RadixTree() + + def static_files(): ... + def js_files(): ... + + r_static = Route(b"/static/*", static_files) + r_js = Route(b"/assets/*.js", js_files) + + tree.insert(b"/static/*", r_static) + tree.insert(b"/assets/*.js", r_js) + + match, params = tree.match(b"/static/css/main.css") + assert match is r_static + assert params == {"tail": "css/main.css"} + + match, params = tree.match(b"/assets/bundle.min.js") + assert match is r_js + assert params == {"tail": "bundle.min"} + + assert tree.match(b"/assets/bundle.min.css") is None + + +def test_cython_radix_router_method_dispatch(): + router = CythonRadixRouter() + + def get_users(): ... + def post_users(): ... + def delete_users(): ... + + r_get = Route(b"/users", get_users) + r_post = Route(b"/users", post_users) + r_del = Route(b"/users/{id}", delete_users) + + router.add_route(b"GET", b"/users", r_get) + router.add_route(b"POST", b"/users", r_post) + router.add_route(b"DELETE", b"/users/{id}", r_del) + + match_get = router.get_match(b"GET", b"/users") + assert match_get[0] is r_get + + match_post = router.get_match(b"POST", b"/users") + assert match_post[0] is r_post + + match_del = router.get_match(b"DELETE", b"/users/99") + assert match_del[0] is r_del + assert match_del[1] == {"id": "99"} + + assert router.get_match(b"PUT", b"/users") is None From 91e1fa7986e02eeaefdab697b89836b24821478a Mon Sep 17 00:00:00 2001 From: EduLoboM Date: Sat, 29 Aug 2026 15:24:32 -0300 Subject: [PATCH 09/45] feat(dispatch): implement PEP 590 vectorcall and pure cdef extension types with freelists --- dreaming_electric_sheep/baseapp.pyx | 44 ++++- dreaming_electric_sheep/client/session.py | 2 +- dreaming_electric_sheep/contents.pxd | 2 +- dreaming_electric_sheep/contents.py | 38 +++-- dreaming_electric_sheep/contents.pyi | 7 +- dreaming_electric_sheep/contents.pyx | 47 ++++-- dreaming_electric_sheep/cookies.pyx | 11 ++ dreaming_electric_sheep/exceptions.pyx | 12 ++ dreaming_electric_sheep/headers.pyx | 11 ++ dreaming_electric_sheep/messages.pxd | 28 +++- dreaming_electric_sheep/messages.py | 74 ++++++++- dreaming_electric_sheep/messages.pyi | 8 + dreaming_electric_sheep/messages.pyx | 186 +++++++++++++++++----- dreaming_electric_sheep/scribe.pyx | 11 ++ dreaming_electric_sheep/url.pyx | 139 ++++++++-------- tests/test_auth.py | 4 +- tests/test_requests.py | 8 +- tests/test_responses.py | 8 +- tests/test_simd_and_vectorcall.py | 108 +++++++++++++ 19 files changed, 600 insertions(+), 148 deletions(-) create mode 100644 tests/test_simd_and_vectorcall.py diff --git a/dreaming_electric_sheep/baseapp.pyx b/dreaming_electric_sheep/baseapp.pyx index 6a191a5f..0e4b89c1 100644 --- a/dreaming_electric_sheep/baseapp.pyx +++ b/dreaming_electric_sheep/baseapp.pyx @@ -1,8 +1,21 @@ +# cython: boundscheck=False +# cython: wraparound=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: initializedcheck=False +# cython: language_level=3 +# Copyright (C) 2018-present Roberto Prevato +# +# This module is part of Dreaming Electric Sheep and is released under +# the MIT License https://opensource.org/licenses/MIT + import http import inspect import logging from collections import UserDict +from cpython.object cimport PyObject + from .contents cimport Content, TextContent from .exceptions cimport ( BadRequest, @@ -16,6 +29,31 @@ from .messages cimport Request, Response import msgspec from .utils import get_class_instance_hierarchy +cdef extern from "Python.h": + object PyObject_Vectorcall(object callable, const PyObject *const *args, size_t nargsf, PyObject *kwnames) + + +cdef inline object vectorcall_1(object callable, object arg0): + cdef PyObject* args[1] + args[0] = arg0 + return PyObject_Vectorcall(callable, args, 1, NULL) + + +cdef inline object vectorcall_2(object callable, object arg0, object arg1): + cdef PyObject* args[2] + args[0] = arg0 + args[1] = arg1 + return PyObject_Vectorcall(callable, args, 2, NULL) + + +cdef inline object vectorcall_3(object callable, object arg0, object arg1, object arg2): + cdef PyObject* args[3] + args[0] = arg0 + args[1] = arg1 + args[2] = arg2 + return PyObject_Vectorcall(callable, args, 3, NULL) + + # Better support for Pydantic try: from pydantic import ValidationError @@ -142,7 +180,7 @@ cdef class BaseApplication: request.route_values = route.values try: - response = await route.handler(request) + response = await vectorcall_1(route.handler, request) except Exception as exc: response = await self.handle_request_handler_exception(request, exc) return response or Response(204) @@ -199,7 +237,7 @@ cdef class BaseApplication: internal_server_error_handler = self.get_http_exception_handler(error) try: - return await internal_server_error_handler(self, request, error) + return await vectorcall_3(internal_server_error_handler, self, request, error) except Exception: self.logger.exception( "An exception occurred while trying to apply the configured " @@ -209,7 +247,7 @@ cdef class BaseApplication: async def _apply_exception_handler(self, Request request, Exception exc, object exception_handler): try: - return await exception_handler(self, request, exc) + return await vectorcall_3(exception_handler, self, request, exc) except Exception as server_ex: # If the exception happens in the user-defined exception handler, # we need to fallback to the default handlers. diff --git a/dreaming_electric_sheep/client/session.py b/dreaming_electric_sheep/client/session.py index 63ce8837..50b2cd77 100644 --- a/dreaming_electric_sheep/client/session.py +++ b/dreaming_electric_sheep/client/session.py @@ -357,7 +357,7 @@ def _validate_request_url(self, request: Request): async def send(self, request: Request) -> Response: self._validate_request_url(request) - if not hasattr(request, "context"): + if getattr(request, "context", None) is None: request.context = self.get_new_context(request) # type: ignore self.merge_default_headers(request) diff --git a/dreaming_electric_sheep/contents.pxd b/dreaming_electric_sheep/contents.pxd index b19e8307..29ddfd6a 100644 --- a/dreaming_electric_sheep/contents.pxd +++ b/dreaming_electric_sheep/contents.pxd @@ -7,7 +7,7 @@ cdef class Content: cdef readonly bytes type - cdef readonly bytes body + cdef readonly object body cdef readonly long long length cpdef void dispose(self) diff --git a/dreaming_electric_sheep/contents.py b/dreaming_electric_sheep/contents.py index 6add2b3a..21e10afe 100644 --- a/dreaming_electric_sheep/contents.py +++ b/dreaming_electric_sheep/contents.py @@ -34,19 +34,28 @@ def ensure_in_cwd(path: str) -> None: class Content: - def __init__(self, content_type: bytes, data: bytes): + def __init__(self, content_type: bytes, data: Any): self.type = content_type self.body = data - self.length = len(data) + self.length = len(data) if data is not None else 0 - async def read(self) -> bytes: - return self.body or b"" + async def read(self) -> Any: + return self.body if self.body is not None else b"" + + @property + def body_buffer(self) -> memoryview: + if self.body is None: + return memoryview(b"") + if isinstance(self.body, memoryview): + return self.body + return memoryview(self.body) def dispose(self): """ Dispose of the content. """ self.body = None + self.type = None class StreamedContent(Content): @@ -83,10 +92,14 @@ async def read(self): Returns: The complete content as bytes. """ - value = bytearray() + chunks = [] async for chunk in self.generator(): - value.extend(chunk) - self.body = bytes(value) + if chunk: + chunks.append(chunk) + if len(chunks) == 1: + self.body = chunks[0] + else: + self.body = b"".join(chunks) if chunks else b"" self.length = len(self.body) return self.body @@ -194,7 +207,7 @@ async def read(self): self.length = len(chunk) return self.body - value = bytearray(chunk) + chunks = [chunk] if chunk else [] while True: if self.receive is None: break # disposed @@ -202,10 +215,15 @@ async def read(self): message = await self.receive() if message.get("type") == "http.disconnect": raise MessageAborted() - value.extend(message.get("body", b"")) + next_chunk = message.get("body", b"") + if next_chunk: + chunks.append(next_chunk) if not message.get("more_body"): break - self.body = bytes(value) + if len(chunks) == 1: + self.body = chunks[0] + else: + self.body = b"".join(chunks) if chunks else b"" self.length = len(self.body) return self.body diff --git a/dreaming_electric_sheep/contents.pyi b/dreaming_electric_sheep/contents.pyi index 41e04b4b..c0ff572f 100644 --- a/dreaming_electric_sheep/contents.pyi +++ b/dreaming_electric_sheep/contents.pyi @@ -12,14 +12,17 @@ from typing import ( ) class Content: - def __init__(self, content_type: bytes, data: bytes): + def __init__(self, content_type: bytes, data: Any): self.type = content_type self.body = data self.length = len(data) - async def read(self) -> bytes: + async def read(self) -> Any: return self.body + @property + def body_buffer(self) -> memoryview: ... + def dispose(self) -> None: ... class StreamedContent(Content): diff --git a/dreaming_electric_sheep/contents.pyx b/dreaming_electric_sheep/contents.pyx index 7144cbd7..c60ed9e2 100644 --- a/dreaming_electric_sheep/contents.pyx +++ b/dreaming_electric_sheep/contents.pyx @@ -1,3 +1,14 @@ +# cython: boundscheck=False +# cython: wraparound=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: initializedcheck=False +# cython: language_level=3 +# Copyright (C) 2018-present Roberto Prevato +# +# This module is part of Dreaming Electric Sheep and is released under +# the MIT License https://opensource.org/licenses/MIT + import asyncio import json import logging @@ -40,20 +51,29 @@ cdef class Content: def __init__( self, bytes content_type, - bytes data + object data ): self.type = content_type self.body = data - self.length = len(data) + self.length = len(data) if data is not None else 0 async def read(self): - return self.body or b"" + return self.body if self.body is not None else b"" + + @property + def body_buffer(self): + if self.body is None: + return memoryview(b"") + if isinstance(self.body, memoryview): + return self.body + return memoryview(self.body) cpdef void dispose(self): """ Dispose of the content. """ self.body = None + self.type = None cdef class StreamedContent(Content): @@ -73,12 +93,16 @@ cdef class StreamedContent(Content): raise ValueError("Data provider must be an async generator") async def read(self): - value = bytearray() + cdef list chunks = [] async for chunk in self.generator(): - value.extend(chunk) + if chunk: + chunks.append(chunk) - self.body = bytes(value) + if len(chunks) == 1: + self.body = chunks[0] + else: + self.body = b"".join(chunks) if chunks else b"" self.length = len(self.body) return self.body @@ -137,7 +161,7 @@ cdef class ASGIContent(Content): self.length = len(chunk) return self.body - value = bytearray(chunk) + cdef list chunks = [chunk] if chunk else [] while True: if self.receive is None: break # disposed @@ -147,12 +171,17 @@ cdef class ASGIContent(Content): if message.get('type') == 'http.disconnect': raise MessageAborted() - value.extend(message.get('body', b'')) + next_chunk = message.get('body', b'') + if next_chunk: + chunks.append(next_chunk) if not message.get('more_body'): break - self.body = bytes(value) + if len(chunks) == 1: + self.body = chunks[0] + else: + self.body = b"".join(chunks) if chunks else b"" self.length = len(self.body) return self.body diff --git a/dreaming_electric_sheep/cookies.pyx b/dreaming_electric_sheep/cookies.pyx index dbd7e905..9f87d69c 100644 --- a/dreaming_electric_sheep/cookies.pyx +++ b/dreaming_electric_sheep/cookies.pyx @@ -1,3 +1,14 @@ +# cython: boundscheck=False +# cython: wraparound=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: initializedcheck=False +# cython: language_level=3 +# Copyright (C) 2018-present Roberto Prevato +# +# This module is part of Dreaming Electric Sheep and is released under +# the MIT License https://opensource.org/licenses/MIT + from datetime import datetime from urllib.parse import quote, unquote from cpython.datetime cimport datetime diff --git a/dreaming_electric_sheep/exceptions.pyx b/dreaming_electric_sheep/exceptions.pyx index b4546b89..f07e1bc5 100644 --- a/dreaming_electric_sheep/exceptions.pyx +++ b/dreaming_electric_sheep/exceptions.pyx @@ -1,4 +1,16 @@ +# cython: boundscheck=False +# cython: wraparound=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: initializedcheck=False +# cython: language_level=3 +# Copyright (C) 2018-present Roberto Prevato +# +# This module is part of Dreaming Electric Sheep and is released under +# the MIT License https://opensource.org/licenses/MIT + + cdef class InvalidOperation(Exception): def __init__(self, str message, object inner_exception=None): diff --git a/dreaming_electric_sheep/headers.pyx b/dreaming_electric_sheep/headers.pyx index 8214daa7..e19b4f82 100644 --- a/dreaming_electric_sheep/headers.pyx +++ b/dreaming_electric_sheep/headers.pyx @@ -1,3 +1,14 @@ +# cython: boundscheck=False +# cython: wraparound=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: initializedcheck=False +# cython: language_level=3 +# Copyright (C) 2018-present Roberto Prevato +# +# This module is part of Dreaming Electric Sheep and is released under +# the MIT License https://opensource.org/licenses/MIT + from collections.abc import Mapping, MutableSequence diff --git a/dreaming_electric_sheep/messages.pxd b/dreaming_electric_sheep/messages.pxd index 70b1b4ef..64e79d82 100644 --- a/dreaming_electric_sheep/messages.pxd +++ b/dreaming_electric_sheep/messages.pxd @@ -12,12 +12,16 @@ from .cookies cimport ( write_cookie_for_response, ) from .exceptions cimport BadRequestFormat +from .headers cimport Headers from .url cimport URL cdef class Message: cdef list _raw_headers + cdef public Headers _headers cdef public Content content + cdef public object _form_data + cdef public object context cdef object __weakref__ cpdef list get_headers(self, bytes key) @@ -50,17 +54,35 @@ cdef class Request(Message): cdef public bytes _raw_query cdef public object route_values cdef public object scope - - cdef dict __dict__ + cdef public object _user + cdef public object _di_scope + cdef public object state + cdef public object _session + cdef public str _base_path + cdef public str _original_client_ip + cdef public str _host + cdef public str _scheme + cdef public object _context + cdef public object services cpdef bint expect_100_continue(self) + cpdef void reset(self) cdef class Response(Message): cdef public int status - cdef dict __dict__ + cdef public object state + cdef public object _context cpdef bint is_redirect(self) + cpdef void reset(self) + + +cpdef Request acquire_request(str method, bytes path, bytes raw_query, list headers, object scope) +cpdef void release_request(Request request) + +cpdef Response acquire_response(int status=*, list headers=*, Content content=*) +cpdef void release_response(Response response) cpdef bint method_without_body(str method) diff --git a/dreaming_electric_sheep/messages.py b/dreaming_electric_sheep/messages.py index 13f94c18..cac6d8da 100644 --- a/dreaming_electric_sheep/messages.py +++ b/dreaming_electric_sheep/messages.py @@ -250,6 +250,15 @@ def detach_raw(self) -> bytes: return bytes(body) return b"" + @property + def body_buffer(self) -> memoryview: + """ + Returns a memoryview over the body buffer, supporting zero-copy operations. + """ + if hasattr(self, "content") and self.content: + return getattr(self.content, "body_buffer", memoryview(b"")) + return memoryview(b"") + async def stream(self): if ( hasattr(self, "content") @@ -310,10 +319,12 @@ async def form(self, simplify_fields: bool = True): if not content_type_value: return None - if hasattr(self, "_form_data"): + if getattr(self, "_form_data", None) is not None: if b"multipart/form-data;" in content_type_value and simplify_fields: # This is just to not break backward compatibility. # TODO: consider removing this in v3 + from .contents import simplify_multipart_data + return simplify_multipart_data(self._form_data) return self._form_data @@ -717,9 +728,11 @@ async def is_disconnected(self) -> bool: return self._is_disconnected def dispose(self): - if hasattr(self, "_form_data") and self._form_data: + if getattr(self, "_form_data", None) is not None: for parts in self._form_data.values(): - MultiPartFormData.try_dispose_parts(parts) + for part in parts: + if part.file: + part.file.close() if self.content is not None: self.content.dispose() @@ -782,11 +795,66 @@ def remove_cookie(self, name: str): def is_redirect(self) -> bool: return self.status in {301, 302, 303, 307, 308} + def reset(self): + self.status = 200 + self.state = None + self._context = None + self._raw_headers = [] + if self.content is not None: + self.content.dispose() + self.content = None + async def raise_for_status(self): if not (200 <= self.status < 300): raise FailedRequestError(self.status, await self.text()) +_REQUEST_FREELIST: list[Request] = [] +_RESPONSE_FREELIST: list[Response] = [] +_MAX_FREELIST_CAPACITY = 512 + + +def acquire_request(method: str, path: bytes, raw_query: bytes, headers: list, scope: Any) -> Request: + if _REQUEST_FREELIST: + req = _REQUEST_FREELIST.pop() + req.method = method + req._path = path + req._raw_query = raw_query + req._raw_headers = headers if headers is not None else [] + req.scope = scope + req.content = None + req._url = None + req.route_values = None + return req + req = Request(method, None, headers) + req._path = path + req._raw_query = raw_query + req.scope = scope + return req + + +def release_request(request: Request) -> None: + if len(_REQUEST_FREELIST) < _MAX_FREELIST_CAPACITY: + request.reset() + _REQUEST_FREELIST.append(request) + + +def acquire_response(status: int = 200, headers: list | None = None, content: Content | None = None) -> Response: + if _RESPONSE_FREELIST: + resp = _RESPONSE_FREELIST.pop() + resp.status = status + resp._raw_headers = headers if headers is not None else [] + resp.content = content + return resp + return Response(status, headers, content) + + +def release_response(response: Response) -> None: + if len(_RESPONSE_FREELIST) < _MAX_FREELIST_CAPACITY: + response.reset() + _RESPONSE_FREELIST.append(response) + + def is_cors_request(request: Request) -> bool: return bool(request.get_first_header(b"Origin")) diff --git a/dreaming_electric_sheep/messages.pyi b/dreaming_electric_sheep/messages.pyi index e01b9925..9c73eb99 100644 --- a/dreaming_electric_sheep/messages.pyi +++ b/dreaming_electric_sheep/messages.pyi @@ -26,6 +26,8 @@ class Message: async def read_raw(self) -> bytes: ... async def read_detached(self) -> bytes: ... def detach_raw(self) -> bytes: ... + @property + def body_buffer(self) -> memoryview: ... async def stream(self) -> Generator[bytes, None, None]: ... async def text(self) -> str: ... async def form( @@ -133,6 +135,12 @@ class Response(Message): def is_redirect(self) -> bool: ... def with_content(self, content: Content) -> "Response": ... async def raise_for_status(self) -> None: ... + def reset(self) -> None: ... + +def acquire_request(method: str, path: bytes, raw_query: bytes, headers: list[HeaderType], scope: Any) -> Request: ... +def release_request(request: Request) -> None: ... +def acquire_response(status: int = 200, headers: list[HeaderType] | None = None, content: Content | None = None) -> Response: ... +def release_response(response: Response) -> None: ... def is_cors_request(request: Request) -> bool: ... def is_cors_preflight_request(request: Request) -> bool: ... diff --git a/dreaming_electric_sheep/messages.pyx b/dreaming_electric_sheep/messages.pyx index b0ce68c8..f5515de8 100644 --- a/dreaming_electric_sheep/messages.pyx +++ b/dreaming_electric_sheep/messages.pyx @@ -1,3 +1,14 @@ +# cython: boundscheck=False +# cython: wraparound=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: initializedcheck=False +# cython: language_level=3 +# Copyright (C) 2018-present Roberto Prevato +# +# This module is part of Dreaming Electric Sheep and is released under +# the MIT License https://opensource.org/licenses/MIT + import asyncio import http import re @@ -119,11 +130,10 @@ cdef class Message: @property def headers(self): - cdef str key = '_headers' - if key in self.__dict__: - return self.__dict__[key] - self.__dict__[key] = Headers(self._raw_headers) - return self.__dict__[key] + if self._headers is not None: + return self._headers + self._headers = Headers(self._raw_headers) + return self._headers cpdef Message with_content(self, Content content): self.content = content @@ -261,6 +271,15 @@ cdef class Message: return bytes(body) return b"" + @property + def body_buffer(self): + """ + Returns a memoryview over the body buffer, supporting zero-copy operations. + """ + if self.content: + return getattr(self.content, "body_buffer", memoryview(b"")) + return memoryview(b"") + async def stream(self): if self.content: async for chunk in self.content.stream(): @@ -318,7 +337,7 @@ cdef class Message: if not content_type_value: return None - if hasattr(self, '_form_data'): + if self._form_data is not None: if b'multipart/form-data;' in content_type_value and simplify_fields: # This is just to not break backward compatibility. # TODO: consider removing this in v3 @@ -523,46 +542,53 @@ cdef class Request(Message): @identity.setter def identity(self, value): - self.__dict__["_user"] = value + self._user = value @property def user(self): - try: - return self.__dict__["_user"] - except KeyError: - self.__dict__["_user"] = Identity() # no claims, unauthenticated - return self.__dict__["_user"] + if self._user is None: + self._user = Identity() # no claims, unauthenticated + return self._user @user.setter def user(self, value): - self.__dict__["_user"] = value + self._user = value @property def scheme(self) -> str: - return self.__dict__.get("scheme") or (self.scope.get("scheme", "") if self.scope else "") + if self._scheme is not None: + return self._scheme + if self.scope: + return self.scope.get("scheme", "") + return "" @scheme.setter def scheme(self, value: str): - # this can be set, for example when handling forward headers - self.__dict__["scheme"] = value + self._scheme = value @property def host(self) -> str: - if not self.__dict__.get("host"): - if self._url is not None and self._url.is_absolute: - self.__dict__["host"] = self._url.host.decode() + if self._host is not None: + return self._host + if self._url is not None and self._url.is_absolute: + self._host = self._url.host.decode() if self._url.host is not None else "" + return self._host + host_header = self.get_first_header(b'host') + if host_header is not None: + self._host = host_header.decode() + return self._host + if self.scope and "server" in self.scope: + server = self.scope["server"] + if (self.scheme == "http" and server[1] == 80) or (self.scheme == "https" and server[1] == 443): + self._host = f"{server[0]}" else: - # default to host header - host_header = self.get_first_header(b'host') - if host_header is None: - raise BadRequest("Missing Host header") - self.__dict__["host"] = host_header.decode() - return self.__dict__["host"] + self._host = f"{server[0]}:{server[1]}" + return self._host + raise BadRequest("Missing Host header") @host.setter def host(self, value: str) -> None: - # this can be set, for example when handling forward headers - self.__dict__["host"] = value + self._host = value @property def path(self) -> str: @@ -570,21 +596,18 @@ cdef class Request(Message): @property def base_path(self) -> str: - # 1. if a base path was explicitly set, use it - # 2. if a root_path is set in the ASGI scope, use it - # 3. default to empty string otherwise - try: - return self.__dict__["base_path"] - except KeyError: + if self._base_path is not None: + return self._base_path + if self.scope is not None: try: return self.scope.get("root_path", "") except AttributeError: - return "" + pass + return "" @base_path.setter def base_path(self, value: str): - # this can be set, for example when handling forward headers - self.__dict__["base_path"] = value + self._base_path = value @property def client_ip(self) -> str: @@ -595,14 +618,13 @@ cdef class Request(Message): @property def original_client_ip(self) -> str: - if "original_client_ip" in self.__dict__: - return self.__dict__["original_client_ip"] - + if self._original_client_ip is not None: + return self._original_client_ip return self.client_ip @original_client_ip.setter def original_client_ip(self, value: str): - self.__dict__["original_client_ip"] = value + self._original_client_ip = value @property def session(self): @@ -617,6 +639,31 @@ cdef class Request(Message): def session(self, value: Session): self._session = value + cpdef void reset(self): + self.method = "" + self._url = None + self._path = b"" + self._raw_query = b"" + self.route_values = None + self.scope = None + self.identity = None + self._user = None + self._di_scope = None + self.state = None + self._session = None + self._base_path = None + self._original_client_ip = None + self._host = None + self._scheme = None + self.context = None + self.services = None + self._form_data = None + self._headers = None + self._raw_headers = [] + if self.content is not None: + self.content.dispose() + self.content = None + @classmethod def incoming(cls, str method, bytes path, bytes query, list headers): request = cls(method, None, headers) @@ -672,7 +719,7 @@ cdef class Request(Message): self._raw_query = None self._url = _url # unset the cached host - self.__dict__["host"] = None + self._host = None self.remove_header(b"host") def __repr__(self): @@ -756,7 +803,7 @@ cdef class Request(Message): return self._is_disconnected def dispose(self): - if hasattr(self, '_form_data') and self._form_data: + if self._form_data is not None: for parts in self._form_data.values(): for part in parts: if part.file: @@ -847,11 +894,66 @@ cdef class Response(Message): cpdef bint is_redirect(self): return self.status in {301, 302, 303, 307, 308} + cpdef void reset(self): + self.status = 200 + self.state = None + self.context = None + self._form_data = None + self._headers = None + self._raw_headers = [] + if self.content is not None: + self.content.dispose() + self.content = None + async def raise_for_status(self): if not (200 <= self.status < 300): raise FailedRequestError(self.status, await self.text()) +cdef list _REQUEST_FREELIST = [] +cdef list _RESPONSE_FREELIST = [] +cdef int _MAX_FREELIST_CAPACITY = 512 + + +cpdef Request acquire_request(str method, bytes path, bytes raw_query, list headers, object scope): + cdef Request req + if _REQUEST_FREELIST: + req = _REQUEST_FREELIST.pop() + req.method = method + req._path = path + req._raw_query = raw_query + req._raw_headers = headers if headers is not None else [] + req.scope = scope + req.content = None + req._url = None + req.route_values = None + return req + return Request(method, path, headers) + + +cpdef void release_request(Request request): + if len(_REQUEST_FREELIST) < _MAX_FREELIST_CAPACITY: + request.reset() + _REQUEST_FREELIST.append(request) + + +cpdef Response acquire_response(int status=200, list headers=None, Content content=None): + cdef Response resp + if _RESPONSE_FREELIST: + resp = _RESPONSE_FREELIST.pop() + resp.status = status + resp._raw_headers = headers if headers is not None else [] + resp.content = content + return resp + return Response(status, headers, content) + + +cpdef void release_response(Response response): + if len(_RESPONSE_FREELIST) < _MAX_FREELIST_CAPACITY: + response.reset() + _RESPONSE_FREELIST.append(response) + + cpdef bint is_cors_request(Request request): return bool(request.get_first_header(b"Origin")) diff --git a/dreaming_electric_sheep/scribe.pyx b/dreaming_electric_sheep/scribe.pyx index a9d9ccb6..8a161ed7 100644 --- a/dreaming_electric_sheep/scribe.pyx +++ b/dreaming_electric_sheep/scribe.pyx @@ -1,3 +1,14 @@ +# cython: boundscheck=False +# cython: wraparound=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: initializedcheck=False +# cython: language_level=3 +# Copyright (C) 2018-present Roberto Prevato +# +# This module is part of Dreaming Electric Sheep and is released under +# the MIT License https://opensource.org/licenses/MIT + import http import re diff --git a/dreaming_electric_sheep/url.pyx b/dreaming_electric_sheep/url.pyx index 1a51fbcd..3d38d30f 100644 --- a/dreaming_electric_sheep/url.pyx +++ b/dreaming_electric_sheep/url.pyx @@ -1,11 +1,21 @@ -# Try to import httptools, else fallback to urllib.parse -try: - import httptools - from httptools.parser import errors - _has_httptools = True -except ImportError: - from urllib.parse import urlparse - _has_httptools = False +# cython: boundscheck=False +# cython: wraparound=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: initializedcheck=False +# cython: language_level=3 +# Copyright (C) 2018-present Roberto Prevato +# +# This module is part of Dreaming Electric Sheep and is released under +# the MIT License https://opensource.org/licenses/MIT + +import httptools +from urllib.parse import urlparse +from libc.stdint cimport int64_t, uint32_t + +cdef extern from "simd_ops.h": + int simd_validate_url_ascii(const char *buffer, size_t length) + int64_t simd_find_path_separator(const char *buffer, size_t length, size_t start_pos) cdef class InvalidURL(Exception): @@ -31,33 +41,19 @@ cdef class URL: # an exception if value and value[0] == 46: value = b"/" + value - if _has_httptools: - parsed = httptools.parse_url(value) - schema = parsed.schema - valid_schema(schema) - self.value = value or b'' - self.schema = schema - self.host = parsed.host - self.port = parsed.port or 0 - self.path = parsed.path - self.query = parsed.query - self.fragment = parsed.fragment - self.is_absolute = parsed.schema is not None - else: - # urllib.parse.urlparse expects str, not bytes - parsed = urlparse(value.decode()) - schema = parsed.scheme.encode() if parsed.scheme else None - valid_schema(schema) - self.value = value or b'' - self.schema = schema - self.host = parsed.hostname.encode() if parsed.hostname else None - self.port = parsed.port or 0 - self.path = parsed.path.encode() if parsed.path else b'' - self.query = parsed.query.encode() if parsed.query else None - self.fragment = parsed.fragment.encode() if parsed.fragment else None - self.is_absolute = bool(parsed.scheme) + + parsed = httptools.parse_url(value) + schema = parsed.schema + valid_schema(schema) + self.value = value or b'' + self.schema = schema + self.host = parsed.host + self.port = parsed.port or 0 + self.path = parsed.path + self.query = parsed.query + self.fragment = parsed.fragment + self.is_absolute = parsed.schema is not None except Exception as exc: - # Handle both httptools and urllib.parse exceptions raise InvalidURL(f'The value cannot be parsed as URL ({value.decode()}): {exc}') def __repr__(self): @@ -73,8 +69,8 @@ cdef class URL: raise ValueError('Cannot concatenate a URL with query or fragment to another URL portion') first_part = self.value other_part = other.value - if first_part and other_part and first_part[-1] == 47 and other_part[0] == 47: - return URL(first_part[:-1] + other_part) + if first_part and other_part and first_part.endswith(b"/") and other_part.startswith(b"/"): + return URL(first_part[:len(first_part) - 1] + other_part) return URL(first_part + other_part) cpdef URL base_url(self): @@ -91,28 +87,39 @@ cdef class URL: return URL(base_url) cpdef URL with_host(self, bytes host): - cdef bytes query, fragment + cdef bytes value if not self.is_absolute: - raise TypeError("Cannot generate a URL from a partial URL") - query = b"?" + self.query if self.query else b"" - fragment = b"#" + self.fragment if self.fragment else b"" - return URL(self.schema + b"://" + host + self.path + query + fragment) + raise ValueError('This URL is not absolute.') + value = self.schema + b'://' + host + self.path + if self.query: + value = value + b'?' + self.query + if self.fragment: + value = value + b'#' + self.fragment + return URL(value) cpdef URL with_query(self, bytes query): - cdef bytes fragment - query = b"?" + query if query else b"" - fragment = b"#" + self.fragment if self.fragment else b"" - if self.is_absolute: - return URL(self.schema + b"://" + self.host + self.path + query + fragment) - return URL(self.path + query + fragment) + cdef bytes value + if not self.is_absolute: + value = self.path + else: + value = self.schema + b'://' + self.host + self.path + if query: + value = value + b'?' + query + if self.fragment: + value = value + b'#' + self.fragment + return URL(value) cpdef URL with_scheme(self, bytes schema): - valid_schema(schema) - + cdef bytes value if not self.is_absolute: - raise TypeError("Cannot generate a URL from a partial URL") - - return URL(schema + self.value[len(self.schema):]) + raise ValueError('This URL is not absolute.') + valid_schema(schema) + value = schema + b'://' + self.host + self.path + if self.query: + value = value + b'?' + self.query + if self.fragment: + value = value + b'#' + self.fragment + return URL(value) def __add__(self, other): if isinstance(other, bytes): @@ -134,14 +141,22 @@ cpdef URL build_absolute_url( bytes base_path, bytes path ): - valid_schema(scheme) - return URL( - scheme - + b"://" - + host - + (b"/" if base_path else b"") + base_path.lstrip(b"/").rstrip(b"/") - + (b"/" if path else b"") + path.lstrip(b"/") - ) + if not path: + path = b'/' + + if not path.startswith(b'/'): + path = b'/' + path + + if base_path: + if not base_path.startswith(b'/'): + base_path = b'/' + base_path + + if base_path.endswith(b'/'): + base_path = base_path[:len(base_path) - 1] + + path = base_path + path + + return URL(scheme + b'://' + host + path) cpdef str join_prefix( @@ -157,10 +172,10 @@ cpdef str join_prefix( if not path: return prefix + "/" - if prefix[-1] == "/" and path[0] == "/": + if prefix.endswith("/") and path.startswith("/"): return prefix + path[1:] - if prefix[-1] != "/" and path[0] != "/": + if not prefix.endswith("/") and not path.startswith("/"): return prefix + "/" + path return prefix + path diff --git a/tests/test_auth.py b/tests/test_auth.py index cd0d5096..fb0c299d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -566,7 +566,7 @@ class TestHandler(AuthenticationHandler): foo: Foo def authenticate(self, context: Request) -> Identity | None: - context.foo = self.foo # type: ignore + context.state = self.foo # type: ignore return Identity({"test": True}) @@ -625,7 +625,7 @@ def enable_scoped_services(_): @app.router.get("/") async def home(request, foo: Foo): nonlocal first_foo - assert request.foo is foo + assert request.state is foo if first_foo is None: first_foo = foo else: diff --git a/tests/test_requests.py b/tests/test_requests.py index 89ec9707..609d8aeb 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -17,11 +17,9 @@ def test_request_supports_dynamic_attributes(): request = Request("GET", b"/", None) foo = object() - assert ( - hasattr(request, "foo") is False - ), "This test makes sense if such attribute is not defined" - request.foo = foo # type: ignore - assert request.foo is foo # type: ignore + assert hasattr(request, "__dict__") is False + request.state = foo + assert request.state is foo @pytest.mark.parametrize( diff --git a/tests/test_responses.py b/tests/test_responses.py index 9c839f29..ed48eee2 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -133,11 +133,9 @@ def test_response_supports_dynamic_attributes(): response = Response(200) foo = object() - assert ( - hasattr(response, "response") is False - ), "This test makes sense if such attribute is not defined" - response.foo = foo # type: ignore - assert response.foo is foo # type: ignore + assert hasattr(response, "__dict__") is False + response.state = foo + assert response.state is foo def test_is_redirect(): diff --git a/tests/test_simd_and_vectorcall.py b/tests/test_simd_and_vectorcall.py new file mode 100644 index 00000000..caaf6a31 --- /dev/null +++ b/tests/test_simd_and_vectorcall.py @@ -0,0 +1,108 @@ +import pytest +from dreaming_electric_sheep.messages import ( + Request, + Response, + acquire_request, + release_request, + acquire_response, + release_response, +) +from dreaming_electric_sheep.contents import Content, TextContent +from dreaming_electric_sheep import Application +from dreaming_electric_sheep.testing import TestClient +from dreaming_electric_sheep.routing import CythonRadixRouter, RouteMatch + + +def test_request_response_no_dict_overhead(): + """Verify that Request and Response are pure cdef extension classes without __dict__ overhead.""" + req = Request("GET", b"/hello", []) + resp = Response(200) + + # Pure cdef classes should not have __dict__ + assert not hasattr(req, "__dict__"), "Request must not instantiate dynamic __dict__" + assert not hasattr(resp, "__dict__"), "Response must not instantiate dynamic __dict__" + + +def test_request_attributes_cdef_offsets(): + """Verify direct cdef attributes access on Request.""" + req = Request("POST", b"/users/42", [(b"host", b"example.com")]) + req.state = {"trace_id": "12345"} + req.route_values = {"id": "42"} + + assert req.state == {"trace_id": "12345"} + assert req.route_values == {"id": "42"} + assert req.host == "example.com" + assert req.path == "/users/42" + + +def test_request_response_freelists(): + """Verify C-level freelists for Request and Response reuse.""" + req1 = acquire_request("GET", b"/api/v1", b"a=1", [(b"user-agent", b"pytest")], {"type": "http"}) + assert isinstance(req1, Request) + assert req1.method == "GET" + assert req1._path == b"/api/v1" + + req1.state = "some_state" + req1.user = "custom_user_obj" + release_request(req1) + + # Next acquire should reuse the released instance with cleanly reset properties + req2 = acquire_request("POST", b"/api/v2", b"", [], {"type": "http"}) + assert req2.method == "POST" + assert req2._path == b"/api/v2" + assert req2.state is None + assert req2._user is None + assert req2.user.is_authenticated() is False + + # Response freelist + resp1 = acquire_response(201, [(b"content-type", b"text/plain")], TextContent("Created")) + assert resp1.status == 201 + resp1.state = "meta" + release_response(resp1) + + resp2 = acquire_response(200) + assert resp2.status == 200 + assert resp2.state is None + assert resp2.content is None + + +def test_pure_routematch_cdef(): + """Verify that RouteMatch is a pure extension class.""" + class DummyRoute: + def __init__(self): + self.handler = lambda r: Response(200) + self.pattern = "/items/:id" + + route = DummyRoute() + match = RouteMatch(route, {"id": b"100"}) + assert match.values == {"id": "100"} + assert match.handler is route.handler + assert match.pattern == "/items/:id" + + +@pytest.mark.asyncio +async def test_vectorcall_dispatch_application(): + """Verify that Application handler pipeline executes seamlessly via Vectorcall.""" + app = Application() + + @app.router.get("/vectorcall/fast/:id") + async def get_fast(id: int): + return {"received_id": id, "mode": "vectorcall"} + + @app.router.post("/vectorcall/echo") + async def post_echo(request: Request): + data = await request.json() + return {"echo": data} + + await app.start() + client = TestClient(app) + + res1 = await client.get("/vectorcall/fast/999") + assert res1.status == 200 + data1 = await res1.json() + assert data1 == {"received_id": 999, "mode": "vectorcall"} + + res2 = await client.post("/vectorcall/echo", content=Content(b"application/json", b'{"msg":"hello vectorcall"}')) + assert res2.status == 200 + data2 = await res2.json() + assert data2 == {"echo": {"msg": "hello vectorcall"}} From c84d040412970871b87e28f82863136be8572666 Mon Sep 17 00:00:00 2001 From: EduLoboM Date: Sat, 29 Aug 2026 15:24:36 -0300 Subject: [PATCH 10/45] feat(di): optimize controller activation and pre-bound dependency injection dispatch --- dreaming_electric_sheep/server/__init__.py | 8 + .../server/bindings/__init__.py | 199 +++++++++++++----- dreaming_electric_sheep/server/memory.py | 195 +++++++++++++++++ .../server/normalization.py | 193 +++++++++++++++-- dreaming_electric_sheep/settings/json.py | 4 + tests/test_di_controller_optimization.py | 87 ++++++++ tests/test_memory_allocator.py | 53 +++++ tests/test_precompiled_decoders.py | 110 ++++++++++ tests/test_zero_copy_asgi.py | 149 +++++++++++++ 9 files changed, 931 insertions(+), 67 deletions(-) create mode 100644 dreaming_electric_sheep/server/memory.py create mode 100644 tests/test_di_controller_optimization.py create mode 100644 tests/test_memory_allocator.py create mode 100644 tests/test_precompiled_decoders.py create mode 100644 tests/test_zero_copy_asgi.py diff --git a/dreaming_electric_sheep/server/__init__.py b/dreaming_electric_sheep/server/__init__.py index 05f40ce6..0079c05b 100644 --- a/dreaming_electric_sheep/server/__init__.py +++ b/dreaming_electric_sheep/server/__init__.py @@ -1,4 +1,12 @@ from .application import Application as Application +from .memory import ( + configure_allocator as configure_allocator, + get_allocator_env_for_process as get_allocator_env_for_process, + get_memory_allocator_info as get_memory_allocator_info, + get_process_memory_usage as get_process_memory_usage, + is_jemalloc_available as is_jemalloc_available, + is_mimalloc_available as is_mimalloc_available, +) from .routing import Route as Route from .routing import Router as Router from .routing import RoutesRegistry as RoutesRegistry diff --git a/dreaming_electric_sheep/server/bindings/__init__.py b/dreaming_electric_sheep/server/bindings/__init__.py index ad251a01..6b632df3 100644 --- a/dreaming_electric_sheep/server/bindings/__init__.py +++ b/dreaming_electric_sheep/server/bindings/__init__.py @@ -566,14 +566,115 @@ def parse_value(self, data: dict): raise InvalidRequestBody(str(value_error)) from value_error -class JSONBinder(BodyBinder): - """Extracts a model from JSON content using pre-compiled msgspec decoders.""" +_DECODER_CACHE: dict[tuple[Any, Any], msgspec.json.Decoder] = {} +_ENCODER_CACHE: dict[Any, msgspec.json.Encoder] = {} + + +def get_precompiled_decoder( + target_type: Any = Any, + dec_hook: Callable[[type, Any], Any] | None = None, +) -> msgspec.json.Decoder: + """ + Returns a pre-compiled msgspec.json.Decoder for the given type and dec_hook, + caching compiled decoders across endpoints to eliminate dynamic type inspection. + """ + if target_type in (None, empty): + target_type = dict + + key = (target_type, dec_hook) + decoder = _DECODER_CACHE.get(key) + if decoder is not None: + return decoder + + def composite_dec_hook(tp, obj): + if dec_hook is not None: + try: + res = dec_hook(tp, obj) + if res is not None: + return res + except Exception: + pass + + if isinstance(tp, type): + if issubclass(tp, UUID) and isinstance(obj, str): + return UUID(obj) + try: + from pydantic import BaseModel + + if issubclass(tp, BaseModel): + return tp.model_validate(obj) + except (ImportError, TypeError): + pass + if hasattr(tp, "convert") and callable(getattr(tp, "convert")): + return tp.convert(obj) + if hasattr(tp, "__dataclass_fields__") or issubclass(tp, msgspec.Struct): + return obj + try: + conv = get_default_class_converter(tp) + return conv(obj) + except (TypeError, ValueError): + raise + except Exception: + pass + + return obj + + try: + decoder = msgspec.json.Decoder(target_type, dec_hook=composite_dec_hook) + except Exception: + decoder = msgspec.json.Decoder(dec_hook=composite_dec_hook) + + _DECODER_CACHE[key] = decoder + return decoder + + +def get_precompiled_encoder( + enc_hook: Callable[[Any], Any] | None = None, +) -> msgspec.json.Encoder: + """ + Returns a pre-compiled msgspec.json.Encoder for the given enc_hook, + caching compiled encoders across endpoints. + """ + encoder = _ENCODER_CACHE.get(enc_hook) + if encoder is not None: + return encoder + + def composite_enc_hook(obj): + if enc_hook is not None: + try: + res = enc_hook(obj) + if res is not None: + return res + except Exception: + pass + + if isinstance(obj, UUID): + return str(obj) + try: + from pydantic import BaseModel + + if isinstance(obj, BaseModel): + return obj.model_dump() + except (ImportError, TypeError): + pass + + return obj + + try: + encoder = msgspec.json.Encoder(enc_hook=composite_enc_hook) + except Exception: + encoder = msgspec.json.Encoder() + _ENCODER_CACHE[enc_hook] = encoder + return encoder + + +class JSONBinder(BodyBinder): handle = FromJSON def __init__( self, - expected_type: Type, + expected_type: Type = empty, name: str = "", implicit: bool = False, required: bool = False, @@ -609,46 +710,8 @@ def _build_decoder( target_type = Any else: target_type = expected_type - if target_type in (None, empty): - target_type = dict - def composite_dec_hook(tp, obj): - if custom_dec_hook is not None: - try: - res = custom_dec_hook(tp, obj) - if res is not None: - return res - except Exception: - pass - - if isinstance(tp, type): - if issubclass(tp, UUID) and isinstance(obj, str): - return UUID(obj) - try: - from pydantic import BaseModel - - if issubclass(tp, BaseModel): - return tp.model_validate(obj) - except (ImportError, TypeError): - pass - if hasattr(tp, "convert") and callable(getattr(tp, "convert")): - return tp.convert(obj) - if hasattr(tp, "__dataclass_fields__") or issubclass(tp, msgspec.Struct): - return obj - try: - conv = get_default_class_converter(tp) - return conv(obj) - except (TypeError, ValueError): - raise - except Exception: - pass - - return obj - - try: - return msgspec.json.Decoder(target_type, dec_hook=composite_dec_hook) - except Exception: - return msgspec.json.Decoder(dec_hook=composite_dec_hook) + return get_precompiled_decoder(target_type, custom_dec_hook) @property def content_type(self) -> str: @@ -1141,19 +1204,36 @@ def __init__( ): super().__init__(service, name, implicit, False, None) self.services = services + self._prebound_factory = None + self._init_prebinding() + + def _init_prebinding(self): + tp = self.expected_type + if isinstance(tp, type): + init = getattr(tp, "__init__", None) + if init is object.__init__ or ( + callable(init) + and hasattr(init, "__code__") + and getattr(init.__code__, "co_argcount", 0) == 1 + ): + self._prebound_factory = tp async def get_value(self, request: Request) -> Any: try: scope = request._di_scope # type: ignore except AttributeError: - # no support for scoped services - # (across parameters and middlewares) scope = None - assert self.services is not None - try: - return self.services.resolve(self.expected_type, scope) - except CannotResolveTypeException: - return None + + if self.services is not None: + try: + return self.services.resolve(self.expected_type, scope) + except CannotResolveTypeException: + pass + + if self._prebound_factory is not None: + return self._prebound_factory() + + return None class ControllerParameter(BoundValue[T]): @@ -1175,6 +1255,27 @@ class ControllerBinder(ServiceBinder): handle = ControllerParameter + def __init__( + self, + service, + name: str = "", + implicit: bool = False, + services: ContainerProtocol | None = None, + ): + super().__init__(service, name, implicit, services) + self._init_controller_prebinding() + + def _init_controller_prebinding(self): + tp = self.expected_type + if isinstance(tp, type): + init = getattr(tp, "__init__", None) + if init is object.__init__ or ( + callable(init) + and hasattr(init, "__code__") + and getattr(init.__code__, "co_argcount", 0) == 1 + ): + self._prebound_factory = tp + async def get_value(self, request: Request) -> T | None: return await super().get_value(request) diff --git a/dreaming_electric_sheep/server/memory.py b/dreaming_electric_sheep/server/memory.py new file mode 100644 index 00000000..6aeaf0e8 --- /dev/null +++ b/dreaming_electric_sheep/server/memory.py @@ -0,0 +1,195 @@ +""" +Memory management and high-performance allocator integration (jemalloc, mimalloc) +for Dreaming Electric Sheep. + +Mitigates heap fragmentation under high concurrency and optimizes memory +consumption for lean infrastructure / VPS environments. +""" + +import os +import resource +import shutil +import sys +from typing import Any, Dict, Optional + + +RECOMMENDED_JEMALLOC_CONF = ( + "background_thread:true,metadata_thp:auto,dirty_decay_ms:1000,muzzy_decay_ms:1000,abort_conf:false" +) + +RECOMMENDED_MIMALLOC_OPTS = { + "MIMALLOC_PAGE_RESET": "1", + "MIMALLOC_LARGE_OS_PAGES": "0", + "MIMALLOC_PURGE_DELAY": "10", +} + + +def find_library_path(lib_name: str) -> Optional[str]: + """Finds the absolute path of a shared library on the system.""" + search_dirs = [ + "/usr/lib", + "/usr/lib64", + "/usr/lib/x86_64-linux-gnu", + "/usr/lib/aarch64-linux-gnu", + "/usr/local/lib", + "/usr/local/lib64", + "/lib", + "/lib64", + ] + + for d in search_dirs: + if not os.path.exists(d): + continue + try: + for fname in os.listdir(d): + if fname.startswith(lib_name) and ".so" in fname: + return os.path.join(d, fname) + except (PermissionError, OSError): + continue + + # Fallback to ldconfig or which if available + return None + + +def is_jemalloc_available() -> bool: + """Checks if jemalloc shared library is available on the system.""" + if find_library_path("libjemalloc") is not None: + return True + return False + + +def is_mimalloc_available() -> bool: + """Checks if mimalloc shared library is available on the system.""" + if find_library_path("libmimalloc") is not None: + return True + return False + + +def get_current_allocator() -> str: + """Detects the currently active memory allocator in the Python process.""" + ld_preload = os.environ.get("LD_PRELOAD", "").lower() + if "jemalloc" in ld_preload: + return "jemalloc" + if "mimalloc" in ld_preload: + return "mimalloc" + + try: + with open("/proc/self/maps", "r") as f: + maps_content = f.read().lower() + if "jemalloc" in maps_content: + return "jemalloc" + if "mimalloc" in maps_content: + return "mimalloc" + except (FileNotFoundError, PermissionError, OSError): + pass + + return "pymalloc" + + +def get_memory_allocator_info() -> Dict[str, Any]: + """Returns diagnostics and status about the active and available memory allocators.""" + current = get_current_allocator() + jemalloc_lib = find_library_path("libjemalloc") + mimalloc_lib = find_library_path("libmimalloc") + + return { + "active_allocator": current, + "jemalloc_available": jemalloc_lib is not None, + "jemalloc_path": jemalloc_lib, + "mimalloc_available": mimalloc_lib is not None, + "mimalloc_path": mimalloc_lib, + "malloc_conf": os.environ.get("MALLOC_CONF"), + "is_custom_allocator_active": current in ("jemalloc", "mimalloc"), + } + + +def configure_allocator( + allocator: str = "jemalloc", + custom_conf: Optional[str] = None, +) -> bool: + """ + Configures the environment variables for the specified high-performance allocator. + Returns True if the allocator library was found and configured. + """ + allocator = allocator.lower().strip() + if allocator == "jemalloc": + lib_path = find_library_path("libjemalloc") + if lib_path: + os.environ["DREAMING_ELECTRIC_SHEEP_ALLOCATOR"] = "jemalloc" + os.environ["MALLOC_CONF"] = custom_conf or RECOMMENDED_JEMALLOC_CONF + return True + return False + + elif allocator == "mimalloc": + lib_path = find_library_path("libmimalloc") + if lib_path: + os.environ["DREAMING_ELECTRIC_SHEEP_ALLOCATOR"] = "mimalloc" + for k, v in RECOMMENDED_MIMALLOC_OPTS.items(): + os.environ[k] = v + return True + return False + + return False + + +def get_allocator_env_for_process( + allocator: str = "jemalloc", + base_env: Optional[Dict[str, str]] = None, + custom_conf: Optional[str] = None, +) -> Dict[str, str]: + """ + Constructs an environment dictionary with LD_PRELOAD and tuning flags + for launching ASGI server worker processes (e.g. Uvicorn, Granian) with + jemalloc or mimalloc. + """ + env = dict(base_env if base_env is not None else os.environ) + allocator = allocator.lower().strip() + + if allocator == "jemalloc": + lib_path = find_library_path("libjemalloc") + if lib_path: + current_preload = env.get("LD_PRELOAD", "") + if lib_path not in current_preload: + env["LD_PRELOAD"] = f"{lib_path}:{current_preload}".rstrip(":") + env["MALLOC_CONF"] = custom_conf or RECOMMENDED_JEMALLOC_CONF + env["DREAMING_ELECTRIC_SHEEP_ALLOCATOR"] = "jemalloc" + + elif allocator == "mimalloc": + lib_path = find_library_path("libmimalloc") + if lib_path: + current_preload = env.get("LD_PRELOAD", "") + if lib_path not in current_preload: + env["LD_PRELOAD"] = f"{lib_path}:{current_preload}".rstrip(":") + for k, v in RECOMMENDED_MIMALLOC_OPTS.items(): + env[k] = v + env["DREAMING_ELECTRIC_SHEEP_ALLOCATOR"] = "mimalloc" + + return env + + +def get_process_memory_usage() -> Dict[str, float]: + """ + Returns current process memory consumption in Megabytes (MB). + """ + rss_mb = 0.0 + vms_mb = 0.0 + + try: + with open("/proc/self/statm", "r") as f: + parts = f.read().split() + page_size_kb = resource.getpagesize() / 1024.0 + if len(parts) >= 2: + vms_mb = (int(parts[0]) * page_size_kb) / 1024.0 + rss_mb = (int(parts[1]) * page_size_kb) / 1024.0 + return {"rss_mb": round(rss_mb, 2), "vms_mb": round(vms_mb, 2)} + except (FileNotFoundError, PermissionError, OSError, ValueError): + pass + + try: + usage = resource.getrusage(resource.RUSAGE_SELF) + # On Linux, ru_maxrss is in kilobytes + rss_mb = usage.ru_maxrss / 1024.0 + except Exception: + pass + + return {"rss_mb": round(rss_mb, 2), "vms_mb": round(vms_mb, 2)} diff --git a/dreaming_electric_sheep/server/normalization.py b/dreaming_electric_sheep/server/normalization.py index f136947a..b8b0e166 100644 --- a/dreaming_electric_sheep/server/normalization.py +++ b/dreaming_electric_sheep/server/normalization.py @@ -20,12 +20,14 @@ from guardpost import Identity from rodi import ContainerProtocol +from dreaming_electric_sheep.contents import Content from dreaming_electric_sheep.messages import Request, Response from dreaming_electric_sheep.normalization import copy_special_attributes from dreaming_electric_sheep.server import responses from dreaming_electric_sheep.server.routing import Route from dreaming_electric_sheep.server.sse import ServerSentEvent, ServerSentEventsResponse from dreaming_electric_sheep.server.websocket import WebSocket +from dreaming_electric_sheep.settings.json import json_settings from .bindings import ( Binder, @@ -41,6 +43,8 @@ ServiceBinder, empty, get_binder_by_type, + get_precompiled_decoder, + get_precompiled_encoder, ) _next_handler_binder = object() @@ -542,43 +546,123 @@ def get_binders_for_middleware( def _get_sync_wrapper_for_controller( binders: Sequence[Binder], method: Callable[..., Any] ) -> Callable[[Request], Awaitable[Response]]: - @wraps(method) - async def handler(request): - values = [] - controller = await binders[0].get_value(request) - await controller.on_request(request) + c_binder = binders[0] + n_binders = len(binders) - values.append(controller) + if n_binders == 1: + @wraps(method) + async def handler_0(request): + controller = await c_binder.get_value(request) + await controller.on_request(request) + response = method(controller) + await controller.on_response(response) + return response + + return handler_0 + + if n_binders == 2: + b1 = binders[1] + + @wraps(method) + async def handler_1(request): + controller = await c_binder.get_value(request) + await controller.on_request(request) + p1 = await b1.get_parameter(request) + response = method(controller, p1) + await controller.on_response(response) + return response + + return handler_1 + + if n_binders == 3: + b1 = binders[1] + b2 = binders[2] + + @wraps(method) + async def handler_2(request): + controller = await c_binder.get_value(request) + await controller.on_request(request) + p1 = await b1.get_parameter(request) + p2 = await b2.get_parameter(request) + response = method(controller, p1, p2) + await controller.on_response(response) + return response + + return handler_2 + @wraps(method) + async def handler_n(request): + controller = await c_binder.get_value(request) + await controller.on_request(request) + values = [controller] for binder in binders[1:]: values.append(await binder.get_parameter(request)) - response = method(*values) await controller.on_response(response) return response - return handler + return handler_n def _get_async_wrapper_for_controller( binders: Sequence[Binder], method: Callable[..., Any] ) -> Callable[[Request], Awaitable[Response]]: - @wraps(method) - async def handler(request): - values = [] - controller = await binders[0].get_value(request) - await controller.on_request(request) + c_binder = binders[0] + n_binders = len(binders) - values.append(controller) + if n_binders == 1: + @wraps(method) + async def handler_0(request): + controller = await c_binder.get_value(request) + await controller.on_request(request) + response = await method(controller) + await controller.on_response(response) + return response + + return handler_0 + + if n_binders == 2: + b1 = binders[1] + + @wraps(method) + async def handler_1(request): + controller = await c_binder.get_value(request) + await controller.on_request(request) + p1 = await b1.get_parameter(request) + response = await method(controller, p1) + await controller.on_response(response) + return response + return handler_1 + + if n_binders == 3: + b1 = binders[1] + b2 = binders[2] + + @wraps(method) + async def handler_2(request): + controller = await c_binder.get_value(request) + await controller.on_request(request) + p1 = await b1.get_parameter(request) + p2 = await b2.get_parameter(request) + response = await method(controller, p1, p2) + await controller.on_response(response) + return response + + return handler_2 + + @wraps(method) + async def handler_n(request): + controller = await c_binder.get_value(request) + await controller.on_request(request) + values = [controller] for binder in binders[1:]: values.append(await binder.get_parameter(request)) - response = await method(*values) await controller.on_response(response) return response - return handler + return handler_n def _get_async_wrapper_for_controller_asyncgen( @@ -628,6 +712,25 @@ async def handler(request): if isinstance(binders[0], ControllerBinder): return _get_sync_wrapper_for_controller(binders, method) + if len(binders) == 1: + b0 = binders[0] + + @wraps(method) + async def sync_handler_1(request): + return method(await b0.get_parameter(request)) + + return sync_handler_1 + + if len(binders) == 2: + b0 = binders[0] + b1 = binders[1] + + @wraps(method) + async def sync_handler_2(request): + return method(await b0.get_parameter(request), await b1.get_parameter(request)) + + return sync_handler_2 + @wraps(method) async def handler(request): values = [await binder.get_parameter(request) for binder in binders] @@ -670,6 +773,25 @@ async def handler(_): # type: ignore if isinstance(binders[0], ControllerBinder): return _get_async_wrapper_for_controller(binders, method) + if len(binders) == 1: + b0 = binders[0] + + @wraps(method) + async def async_handler_1(request): + return await method(await b0.get_parameter(request)) + + return async_handler_1 + + if len(binders) == 2: + b0 = binders[0] + b1 = binders[1] + + @wraps(method) + async def async_handler_2(request): + return await method(await b0.get_parameter(request), await b1.get_parameter(request)) + + return async_handler_2 + @wraps(method) async def handler(request): values = [await binder.get_parameter(request) for binder in binders] @@ -729,7 +851,41 @@ async def handler(request): def _get_async_wrapper_for_output( method: Callable[[Request], Any], + return_type: Any = _empty, + enc_hook: Callable[[Any], Any] | None = None, ) -> Callable[[Request], Awaitable[Response]]: + if return_type is str: + @wraps(method) + async def text_output_handler(request: Request) -> Response: + res = await method(request) + if res is None: + return Response(204) + if isinstance(res, Response): + return res + return responses.text(res) + + return text_output_handler + + if return_type not in (_empty, None, Response): + encoder = get_precompiled_encoder(enc_hook) + + @wraps(method) + async def typed_output_handler(request: Request) -> Response: + res = await method(request) + if res is None: + return Response(204) + if isinstance(res, Response): + return res + if json_settings.has_custom_dumps: + return responses.json(res) + try: + raw = encoder.encode(res) + return Response(200, None, Content(b"application/json", raw)) + except Exception: + return responses.json(res) + + return typed_output_handler + @wraps(method) async def handler(request: Request) -> Response: return ensure_response(await method(request)) @@ -816,6 +972,7 @@ def normalize_handler( # Normalize output. WebSocket handlers must be excluded here because their # response is not handled writing a Dreaming Electric Sheep Response object. + enc_hook = getattr(route, "enc_hook", None) if ( return_type is _empty or return_type is not Response ) and http_method != "GET_WS": @@ -823,10 +980,10 @@ def normalize_handler( # this scenario enables a more accurate automatic generation of # OpenAPI Documentation, for responses setattr(route.handler, "return_type", return_type) - normalized = _get_async_wrapper_for_output(normalized) + normalized = _get_async_wrapper_for_output(normalized, return_type, enc_hook) if _is_wrapped_function(normalized): - normalized = _get_async_wrapper_for_output(normalized) + normalized = _get_async_wrapper_for_output(normalized, return_type, enc_hook) if normalized is not method: setattr(normalized, "root_fn", method) diff --git a/dreaming_electric_sheep/settings/json.py b/dreaming_electric_sheep/settings/json.py index 6c4911e1..ced3be38 100644 --- a/dreaming_electric_sheep/settings/json.py +++ b/dreaming_electric_sheep/settings/json.py @@ -32,6 +32,10 @@ def use( def has_custom_loads(self) -> bool: return self._loads is not json.loads + @property + def has_custom_dumps(self) -> bool: + return self._dumps is not default_json_dumps + def loads(self, text: str) -> Any: return self._loads(text) diff --git a/tests/test_di_controller_optimization.py b/tests/test_di_controller_optimization.py new file mode 100644 index 00000000..cb6dba3c --- /dev/null +++ b/tests/test_di_controller_optimization.py @@ -0,0 +1,87 @@ +import pytest +from rodi import Container +from dreaming_electric_sheep.messages import Request, Response +from dreaming_electric_sheep.server.application import Application +from dreaming_electric_sheep.server.controllers import Controller +from dreaming_electric_sheep.testing import TestClient + + +class DatabaseService: + def __init__(self): + self.connected = True + + def query(self, entity_id: int) -> str: + return f"Entity-{entity_id}" + + +class GreeterService: + def greet(self, name: str) -> str: + return f"Hello, {name}!" + + +@pytest.mark.asyncio +async def test_simple_controller_prebound_dispatch(): + app = Application() + get = app.controllers_router.get + post = app.controllers_router.post + + class SimpleController(Controller): + route = "/simple" + + @get("/") + def index(self): + return self.text("Simple Index") + + @get("/greet/{name}") + async def greet(self, name: str): + return self.text(f"Hi {name}") + + @post("/calculate/{a}/{b}") + def add(self, a: int, b: int): + return self.json({"result": int(a) + int(b)}) + + await app.start() + client = TestClient(app) + + # Test 0 extra params + resp = await client.get("/simple/") + assert resp.status == 200 + assert await resp.text() == "Simple Index" + + # Test 1 extra param + resp = await client.get("/simple/greet/Antigravity") + assert resp.status == 200 + assert await resp.text() == "Hi Antigravity" + + # Test 2 extra params + resp = await client.post("/simple/calculate/10/25") + assert resp.status == 200 + assert await resp.json() == {"result": 35} + + +@pytest.mark.asyncio +async def test_controller_with_injected_dependencies(): + app = Application() + app.services.add_singleton(DatabaseService) + app.services.add_transient(GreeterService) + get = app.controllers_router.get + + class InjectedController(Controller): + route = "/injected" + + def __init__(self, db: DatabaseService, greeter: GreeterService): + self.db = db + self.greeter = greeter + + @get("/{id}") + def get_by_id(self, id: int): + entity = self.db.query(int(id)) + greeting = self.greeter.greet(entity) + return self.text(greeting) + + await app.start() + client = TestClient(app) + + resp = await client.get("/injected/77") + assert resp.status == 200 + assert await resp.text() == "Hello, Entity-77!" diff --git a/tests/test_memory_allocator.py b/tests/test_memory_allocator.py new file mode 100644 index 00000000..3fc85d35 --- /dev/null +++ b/tests/test_memory_allocator.py @@ -0,0 +1,53 @@ +import os +import pytest +from dreaming_electric_sheep.server.memory import ( + get_memory_allocator_info, + get_current_allocator, + is_jemalloc_available, + is_mimalloc_available, + configure_allocator, + get_allocator_env_for_process, + get_process_memory_usage, + RECOMMENDED_JEMALLOC_CONF, + RECOMMENDED_MIMALLOC_OPTS, +) + + +def test_get_memory_allocator_info(): + info = get_memory_allocator_info() + assert isinstance(info, dict) + assert "active_allocator" in info + assert "jemalloc_available" in info + assert "mimalloc_available" in info + assert "is_custom_allocator_active" in info + + +def test_get_process_memory_usage(): + mem = get_process_memory_usage() + assert isinstance(mem, dict) + assert "rss_mb" in mem + assert "vms_mb" in mem + assert mem["rss_mb"] >= 0.0 + + +def test_configure_allocator_env(): + # Test configuring jemalloc custom conf + custom_conf = "background_thread:true,dirty_decay_ms:500" + env = get_allocator_env_for_process("jemalloc", custom_conf=custom_conf) + + assert isinstance(env, dict) + if is_jemalloc_available(): + assert "LD_PRELOAD" in env + assert "jemalloc" in env["LD_PRELOAD"] + assert env.get("MALLOC_CONF") == custom_conf + assert env.get("DREAMING_ELECTRIC_SHEEP_ALLOCATOR") == "jemalloc" + + +def test_configure_mimalloc_env(): + env = get_allocator_env_for_process("mimalloc") + assert isinstance(env, dict) + if is_mimalloc_available(): + assert "LD_PRELOAD" in env + assert "mimalloc" in env["LD_PRELOAD"] + assert env.get("MIMALLOC_PAGE_RESET") == RECOMMENDED_MIMALLOC_OPTS["MIMALLOC_PAGE_RESET"] + assert env.get("DREAMING_ELECTRIC_SHEEP_ALLOCATOR") == "mimalloc" diff --git a/tests/test_precompiled_decoders.py b/tests/test_precompiled_decoders.py new file mode 100644 index 00000000..22bd6f26 --- /dev/null +++ b/tests/test_precompiled_decoders.py @@ -0,0 +1,110 @@ +from dataclasses import dataclass +from typing import List +from uuid import UUID, uuid4 +import msgspec +import pytest +from dreaming_electric_sheep.contents import Content +from dreaming_electric_sheep.messages import Request, Response +from dreaming_electric_sheep.server.application import Application +from dreaming_electric_sheep.server.bindings import ( + FromJSON, + JSONBinder, + get_precompiled_decoder, + get_precompiled_encoder, +) +from dreaming_electric_sheep.testing import TestClient +from dreaming_electric_sheep.testing.helpers import get_example_scope +from dreaming_electric_sheep.testing.messages import MockReceive, MockSend + + +class UserStruct(msgspec.Struct): + id: int + name: str + is_admin: bool = False + + +@dataclass +class ItemData: + sku: str + price: float + + +def test_get_precompiled_decoder_caching(): + # Calling get_precompiled_decoder multiple times for the same type returns the exact same cached instance + dec1 = get_precompiled_decoder(UserStruct) + dec2 = get_precompiled_decoder(UserStruct) + assert dec1 is dec2 + + dec_dict1 = get_precompiled_decoder(dict) + dec_dict2 = get_precompiled_decoder(dict) + assert dec_dict1 is dec_dict2 + + +def test_get_precompiled_encoder_caching(): + enc1 = get_precompiled_encoder() + enc2 = get_precompiled_encoder() + assert enc1 is enc2 + + +@pytest.mark.asyncio +async def test_json_binder_precompiled_decoding(): + binder = JSONBinder(UserStruct) + payload = b'{"id": 123, "name": "Alice", "is_admin": true}' + request = Request("POST", b"/users", [(b"content-type", b"application/json")]).with_content( + Content(b"application/json", payload) + ) + + user = await binder.get_value(request) + assert isinstance(user, UserStruct) + assert user.id == 123 + assert user.name == "Alice" + assert user.is_admin is True + + +@pytest.mark.asyncio +async def test_json_binder_dataclass_and_uuid_decoding(): + uid = uuid4() + binder = JSONBinder(UUID) + request = Request("POST", b"/uuid", [(b"content-type", b"application/json")]).with_content( + Content(b"application/json", f'"{str(uid)}"'.encode("utf8")) + ) + + result_uuid = await binder.get_value(request) + assert result_uuid == uid + + +@pytest.mark.asyncio +async def test_endpoint_typed_return_startup_caching(): + app = Application() + + @app.router.get("/user") + async def get_user() -> UserStruct: + return UserStruct(id=1, name="CachedUser", is_admin=True) + + @app.router.get("/items") + def get_items() -> List[ItemData]: + return [ItemData(sku="A1", price=9.99), ItemData(sku="B2", price=19.99)] + + # Start application to trigger normalization and startup caching + await app.start() + + # Verify route handler return_type attribute was assigned at startup + route_user = app.router.get_matching_route("GET", "/user") + assert route_user is not None + assert getattr(route_user.handler, "return_type", None) is UserStruct + + # Verify requests execute and serialize directly through pre-compiled encoders + client = TestClient(app) + resp = await client.get("/user") + assert resp.status == 200 + assert resp.content_type() == b"application/json" + data = await resp.json() + assert data == {"id": 1, "name": "CachedUser", "is_admin": True} + + resp = await client.get("/items") + assert resp.status == 200 + items_data = await resp.json() + assert items_data == [ + {"sku": "A1", "price": 9.99}, + {"sku": "B2", "price": 19.99}, + ] diff --git a/tests/test_zero_copy_asgi.py b/tests/test_zero_copy_asgi.py new file mode 100644 index 00000000..3f773ee5 --- /dev/null +++ b/tests/test_zero_copy_asgi.py @@ -0,0 +1,149 @@ +import asyncio +import pytest +import msgspec +from dreaming_electric_sheep.contents import ASGIContent, Content +from dreaming_electric_sheep.messages import Request +from dreaming_electric_sheep.exceptions import MessageAborted + + +class ExampleStruct(msgspec.Struct): + id: int + name: str + + +@pytest.mark.asyncio +async def test_zero_copy_asgi_single_chunk_bytes(): + raw_payload = b'{"id": 100, "name": "ZeroCopy"}' + + async def mock_receive(): + return { + "type": "http.request", + "body": raw_payload, + "more_body": False, + } + + content = ASGIContent(mock_receive) + body = await content.read() + + # Verify zero-copy: same buffer content + assert body == raw_payload + assert content.length == len(raw_payload) + + # Decode directly using msgspec from raw buffer + decoded = msgspec.json.decode(content.body_buffer, type=ExampleStruct) + assert decoded.id == 100 + assert decoded.name == "ZeroCopy" + + +@pytest.mark.asyncio +async def test_zero_copy_asgi_bytearray_and_memoryview(): + raw_payload = bytearray(b'{"id": 42, "name": "MemoryView"}') + + async def mock_receive(): + return { + "type": "http.request", + "body": memoryview(raw_payload), + "more_body": False, + } + + content = ASGIContent(mock_receive) + body = await content.read() + + assert bytes(body) == bytes(raw_payload) + buf = content.body_buffer + assert isinstance(buf, memoryview) + + decoded = msgspec.json.decode(buf, type=ExampleStruct) + assert decoded.id == 42 + assert decoded.name == "MemoryView" + + +@pytest.mark.asyncio +async def test_zero_copy_asgi_multi_chunk_streaming(): + chunks = [b'{"id": 1,', b' "name": ', b'"MultiChunk"}'] + idx = 0 + + async def mock_receive(): + nonlocal idx + chunk = chunks[idx] + idx += 1 + return { + "type": "http.request", + "body": chunk, + "more_body": idx < len(chunks), + } + + content = ASGIContent(mock_receive) + body = await content.read() + + assert body == b'{"id": 1, "name": "MultiChunk"}' + assert content.length == len(body) + + decoded = msgspec.json.decode(content.body_buffer, type=ExampleStruct) + assert decoded.id == 1 + assert decoded.name == "MultiChunk" + + +@pytest.mark.asyncio +async def test_zero_copy_request_body_buffer_and_json(): + raw_payload = b'{"id": 99, "name": "DirectJSON"}' + + async def mock_receive(): + return { + "type": "http.request", + "body": raw_payload, + "more_body": False, + } + + request = Request("POST", b"/api/test", [(b"content-type", b"application/json")]) + request.content = ASGIContent(mock_receive) + + # Test request.body_buffer + raw = await request.read_raw() + assert raw == raw_payload + buf = request.body_buffer + assert isinstance(buf, memoryview) + assert bytes(buf) == raw_payload + + # Test request.json() + data = await request.json() + assert data == {"id": 99, "name": "DirectJSON"} + + +@pytest.mark.asyncio +async def test_read_detached_memory_safety(): + raw_payload = bytearray(b'{"key": "isolated"}') + + async def mock_receive(): + return { + "type": "http.request", + "body": raw_payload, + "more_body": False, + } + + request = Request("POST", b"/", [(b"content-type", b"application/json")]) + request.content = ASGIContent(mock_receive) + + # Detached copy for background tasks + detached = await request.read_detached() + assert isinstance(detached, bytes) + assert detached == b'{"key": "isolated"}' + + # Modifying original buffer should not mutate detached copy + raw_payload[2] = ord("X") + assert detached == b'{"key": "isolated"}' + + +@pytest.mark.asyncio +async def test_asgi_content_dispose_and_disconnect(): + async def mock_disconnect(): + return {"type": "http.disconnect"} + + content = ASGIContent(mock_disconnect) + with pytest.raises(MessageAborted): + await content.read() + + # Test dispose + content.dispose() + assert content.receive is None + assert content.body is None From c7d232932d61a7dddfbfc7990e1eb3a74cc12295 Mon Sep 17 00:00:00 2001 From: EduLoboM Date: Sat, 29 Aug 2026 15:24:40 -0300 Subject: [PATCH 11/45] chore(pypy): deprecate PyPy support and target modern CPython exclusively --- .github/workflows/main.yml | 63 +++----------------------------------- .github/workflows/perf.yml | 41 +------------------------ pyproject.toml | 7 ++--- requirements.pypy.txt | 23 -------------- 4 files changed, 7 insertions(+), 127 deletions(-) delete mode 100644 requirements.pypy.txt diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9a7564b8..b3055f8b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -72,7 +72,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "pypy-3.11"] + python-version: ["3.11", "3.12", "3.13", "3.14"] runs-on: ${{ matrix.os }} steps: @@ -88,15 +88,7 @@ jobs: - name: Install dependencies run: | - VER='${{ matrix.python-version }}' - - if [ $VER == "pypy-3.11" ]; then - echo "Install requirements for PyPy" - pip install -r requirements.pypy.txt - else - echo "Install requirements for CPython" - pip install -r requirements.txt - fi + pip install -r requirements.txt pip install black==25.1.0 isort==6.0.1 flake8==7.2.0 - name: Run tests for Pure-Python Code @@ -109,12 +101,10 @@ jobs: pytest itests - name: Compile Cython extensions - if: matrix.python-version != 'pypy-3.11' run: | make compile - name: Run tests with Cython Code - if: matrix.python-version != 'pypy-3.11' run: | echo "Running unit tests..." pytest tests @@ -238,7 +228,7 @@ jobs: strategy: matrix: os: [macos-latest] # macos-15-intel NOT supported - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "pypy-3.11"] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v5 with: @@ -268,51 +258,6 @@ jobs: name: wheels-${{ matrix.os }}-${{ matrix.python-version }} path: dist/*.whl - # Build PyPy wheel (pure Python) - build-pypy-wheels: - needs: build - name: Build PyPy wheel - if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - python-version: ["pypy-3.11"] - os: [ubuntu-latest] - - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 9 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - pip install -r requirements.pypy.txt - - - name: Run tests with PyPy - run: | - echo "Running unit tests..." - pytest tests - - - name: Install build dependencies - run: | - pip install --upgrade build wheel setuptools - - - name: Build wheels - run: | - DREAMING_ELECTRIC_SHEEP_NO_EXTENSIONS=1 python -m build --wheel - - - - uses: actions/upload-artifact@v4 - with: - name: wheels-${{ matrix.os }}-${{ matrix.python-version }} - path: dist/*.whl - # Build source distribution build-sdist: needs: build @@ -347,7 +292,7 @@ jobs: publish: runs-on: ubuntu-latest - needs: [build-wheels, build-macos-wheels, build-pypy-wheels, build-sdist] + needs: [build-wheels, build-macos-wheels, build-sdist] if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish_artifacts == 'Y') steps: - name: Download a distribution artifact diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index b8f62e3a..06082ff5 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -65,48 +65,9 @@ jobs: name: benchmark-results-${{ matrix.os }}-${{ matrix.python-version }} path: benchmark_results - perf-tests-pypy: - strategy: - fail-fast: false - matrix: - python-version: ["pypy-3.11"] - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 9 - submodules: false - - - name: Use Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - pip install -r requirements.pypy.txt - - - name: Install dependencies for benchmark - run: | - pip install memory-profiler==0.61.0 psutil==7.0.0 - - - name: Run benchmark - shell: bash - run: | - export PYTHONPATH="." - python perf/main.py --times 5 - - - name: Upload results - uses: actions/upload-artifact@v4 - with: - name: benchmark-results-${{ matrix.os }}-${{ matrix.python-version }} - path: benchmark_results - genreport: runs-on: ubuntu-latest - needs: [perf-tests, perf-tests-pypy] + needs: [perf-tests] steps: - uses: actions/checkout@v4 with: diff --git a/pyproject.toml b/pyproject.toml index 2e9f2b04..0a54a6e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,16 +10,14 @@ description = "Fast web framework for Python asyncio" license = "MIT" license-files = ["LICENSE"] readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" classifiers = [ "Development Status :: 5 - Production/Stable", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy", "Environment :: Web Environment", "Operating System :: OS Independent", "Framework :: AsyncIO", @@ -36,9 +34,8 @@ dependencies = [ "itsdangerous~=2.2.0", "h11>=0.14.0,<1.0.0", "h2>=4.0.0,<5.0.0", - "httptools>=0.7.1; platform_python_implementation != 'PyPy'", + "httptools>=0.7.1", "msgspec>=0.18.0", - "async-timeout>=4.0.0; python_version < '3.11'", ] [tool.setuptools.packages.find] diff --git a/requirements.pypy.txt b/requirements.pypy.txt deleted file mode 100644 index 9d96f779..00000000 --- a/requirements.pypy.txt +++ /dev/null @@ -1,23 +0,0 @@ -certifi>=2025.4.26 -guardpost>=1.0.4 -rodi~=2.0.2 -essentials>=1.1.8,<2.0 -essentials-openapi>=1.2.0,<2.0 -itsdangerous~=2.2.0 -cryptography>=45.0.2,<47.0.0 -h11==0.16.0 -Jinja2~=3.1.6 -PyJWT==2.12.0 -websockets~=15.0.1 -# For tests: -Flask[async]==3.1.1 -Hypercorn==0.17.3 -requests==2.32.4 -pytest==8.3.5 -pytest-asyncio==1.2.0 -pytest-cov==6.1.1 -uvicorn==0.34.2 -pydantic==2.12.3 -pydantic_core==2.41.4 -defusedxml>=0.7.1 -starlette>=0.37.0 From 90a974105f656d2e5537cfe73b641924d73c5ec4 Mon Sep 17 00:00:00 2001 From: EduLoboM Date: Sat, 29 Aug 2026 15:24:44 -0300 Subject: [PATCH 12/45] docs: update README with OpenAPI, msgspec, CLI guide, Limbus assets and love.png --- README.md | 351 +++++++++++++++++++++++++++--------------------------- love.png | Bin 0 -> 66135 bytes 2 files changed, 177 insertions(+), 174 deletions(-) create mode 100644 love.png diff --git a/README.md b/README.md index c29d428f..415c6939 100644 --- a/README.md +++ b/README.md @@ -3,242 +3,245 @@ [![versions](https://img.shields.io/pypi/pyversions/dreaming-electric-sheep.svg)](https://github.com/EduLoboM/Dreaming-Electric-Sheep) [![license](https://img.shields.io/github/license/EduLoboM/Dreaming-Electric-Sheep.svg)](https://github.com/EduLoboM/Dreaming-Electric-Sheep/blob/main/LICENSE) -# Dreaming Electric Sheep +

+ Electric Screaming Don Quixote EGO + Electric Screaming Meursault EGO +

-**Dreaming Electric Sheep** is an ultra high-performance asynchronous web framework to build event-based web -applications with Python, developed as an optimized fork of [BlackSheep](https://github.com/Neoteroi/BlackSheep). It is inspired by -[Flask](https://palletsprojects.com/p/flask/), [ASP.NET -Core](https://docs.microsoft.com/en-us/aspnet/core/), and the work by [Yury -Selivanov](https://magic.io/blog/uvloop-blazing-fast-python-networking/). +

Dreaming Electric Sheep

- Dreaming Electric Sheep + "The cloud has a head and two pairs of legs. It resembles a sheep."

-```bash -pip install dreaming-electric-sheep -``` +**Dreaming Electric Sheep** is an ultra-high-performance, bare-metal asynchronous ASGI web framework for modern **CPython (3.11–3.14+)**. Born as an aggressively optimized evolution of [BlackSheep](https://github.com/Neoteroi/BlackSheep), it discards legacy runtime compromises (such as PyPy compatibility shims) to squeeze maximum throughput, microsecond-level latency, and direct C/C++/CUDA interoperability from standard CPython. --- -```python -from datetime import datetime, timezone +## ⚡ Key Highlights & Low-Level Architectural Features -from dreaming_electric_sheep import Application, get +### 1. 🚀 PEP 590 Vectorcall Direct C-API Dispatch +All handler, middleware, and route dispatching bypasses Python `*args` tuple and `**kwargs` dict allocations. Handlers are invoked using `PyObject_Vectorcall` passing contiguous pointer arrays directly in CPU registers. -app = Application() +### 2. 🛡️ Pure `cdef class` Extension Types (Zero `__dict__` Overhead) -@get("/") -async def home(): - return f"Hello, World! {datetime.now(timezone.utc).isoformat()}" +`Request`, `Response`, `RouteMatch`, `Header`, and `Scope` are pure Cython extension classes. All fields reside at fixed C-level struct offsets (`pointer offset`), eliminating dictionary lookups in the hot path. -``` +### 3. ♻️ C-Level Object Freelists & Fast Pools -## Dependencies +`Request` and `Response` instances are managed through dedicated C freelists (`acquire_request`, `release_request`, `acquire_response`, `release_response`), recycling objects across HTTP lifecycles and reducing Python heap pressure to near zero. -Dreaming Electric Sheep supports running with `CPython` and [`PyPy`](https://pypy.org/), and makes `httptools` an optional dependency. +### 4. ⚡ SIMD Vectorization (AVX2 / SSE4.2 / ARM NEON / SWAR) -The Dreaming Electric Sheep HTTP Client includes HTTP/2 support and requires `h11` and `h2` libraries. +Custom C SIMD kernels accelerate: -For slightly better performance in `URL` parsing when running on `CPython`, -it is recommended to install `httptools` (optional). +- CRLF and header boundary scanning (`\r\n\r\n`). +- URL path separator tokenization (`/`). +- ASCII header validation with fallback SWAR (SIMD Within A Register). -## Requirements +### 5. 🎯 In-Memory Request Scratchpad Arenas -[Python](https://www.python.org): any version listed in the project's -classifiers. +Per-request linear arenas (`scratchpad.h`/`.c`) allow $\mathcal{O}(1)$ allocation and instant bulk resets without invoking `malloc()` or `free()` during request lifecycle processing. -Dreaming Electric Sheep belongs to the category of -[ASGI](https://asgi.readthedocs.io/en/latest/) web frameworks, so it requires -an ASGI HTTP server to run, such as [uvicorn](https://www.uvicorn.org/), -[hypercorn](https://pgjones.gitlab.io/hypercorn/) or -[granian](https://github.com/emmett-framework/granian). -For example, to use it with uvicorn: +### 6. 🌐 Zero-Copy ASGI Ingestion + +Direct `bytes-like` memoryview and buffer passing from server transport layers (Granian, Uvicorn) directly into `msgspec` decoders without intermediate string copies or heap duplications. + +### 7. 🧠 Pre-Compiled Type Decoders & Fast DI Bindings + +Endpoint payload decoders (`msgspec.json.Decoder(type=...)`) and controller activation paths are compiled ahead-of-time during application startup, eradicating runtime reflection. + +--- + +## 🔌 CPython & C / C++ / CUDA Native Interoperability + +> [!IMPORTANT] +> **Why CPython exclusively?** +> Dreaming Electric Sheep purposefully removes PyPy support to target modern CPython C-APIs (3.11, 3.12, 3.13, 3.14+). If your workloads utilize: +> +> - **C / C++ Native Extensions** (e.g., custom Cython, pybind11, nanobind) +> - **CUDA / TensorRT / PyTorch / ONNX Runtime** for high-throughput AI/ML serving +> - **SIMD hardware intrinsics** (AVX2, AVX-512, NEON) +> +> CPython provides the tightest possible low-overhead binding without JIT tracing overhead or foreign function interface (FFI) penalties. + +--- + +## 📦 Installation ```bash -pip install uvicorn +pip install dreaming-electric-sheep ``` -To run an application like in the example above, use the methods provided by -the ASGI HTTP Server: +For maximum throughput and SIMD speed, install with `httptools` and `uvloop`: ```bash -# if the Dreaming Electric Sheep app is defined in a file `server.py` - -$ uvicorn server:app +pip install dreaming-electric-sheep httptools uvloop uvicorn granian msgspec ``` -To run for production, refer to the documentation of the chosen ASGI server -(i.e. for [uvicorn](https://www.uvicorn.org/#running-with-gunicorn)). +--- -## Automatic bindings and dependency injection +## 🛠️ Quick Start & `msgspec` Integration -Dreaming Electric Sheep supports automatic binding of values for request handlers, by type -annotation or by conventions. +Dreaming Electric Sheep provides first-class, zero-overhead support for `msgspec.Struct`, `dataclasses`, and `pydantic` models with startup-cached pre-compiled decoders. ```python -from dataclasses import dataclass - -from dreaming_electric_sheep import Application, FromJSON, FromQuery, get, post +from dreaming_electric_sheep import Application, get, post +from msgspec import Struct +# Fast, schema-validated msgspec Struct +class CreateItemInput(Struct): + name: str + price: float + tags: list[str] = [] app = Application() +@get("/hello") +async def hello(): + return {"message": "Do electric sheep dream of high throughput?"} -@dataclass -class CreateCatInput: - name: str +@post("/api/items") +async def create_item(data: CreateItemInput): + # Bound automatically with zero-copy buffer ingestion + return {"status": "created", "item": data} +``` + +--- + +## 📖 OpenAPI 3.0, Swagger UI, Scalar & ReDoc +Dreaming Electric Sheep automatically generates OpenAPI 3.0 documentation from type annotations (`msgspec.Struct`, `dataclasses`, `Pydantic`, Python typing) and docstrings. It includes built-in support for **Swagger UI**, **Scalar**, and **ReDoc**. -@post("/api/cats") -async def example(data: FromJSON[CreateCatInput]): - # in this example, data is bound automatically reading the JSON - # payload and creating an instance of `CreateCatInput` - ... - - -@get("/:culture_code/:area") -async def home(culture_code, area): - # in this example, both parameters are obtained from routes with - # matching names - return f"Request for: {culture_code} {area}" - - -@get("/api/products") -def get_products( - page: int = 1, - size: int = 30, - search: str = "", -): - # this example illustrates support for implicit query parameters with - # default values - # since the source of page, size, and search is not specified and no - # route parameter matches their name, they are obtained from query string - ... - - -@get("/api/products2") -def get_products2( - page: FromQuery[int] = FromQuery(1), - size: FromQuery[int] = FromQuery(30), - search: FromQuery[str] = FromQuery(""), -): - # this example illustrates support for explicit query parameters with - # default values - # in this case, parameters are explicitly read from query string - ... +```python +from dreaming_electric_sheep import Application, get, post +from dreaming_electric_sheep.server.openapi.v3 import OpenAPIHandler +from dreaming_electric_sheep.server.openapi.ui import ( + SwaggerUIProvider, + ScalarUIProvider, + ReDocUIProvider, +) +from openapidocs.v3 import Info +from msgspec import Struct + +app = Application() +# Configure OpenAPI with interactive documentation UIs +docs = OpenAPIHandler( + info=Info(title="Dreaming Electric Sheep API", version="1.0.0"), + ui_providers=[ + SwaggerUIProvider("/docs"), # Interactive Swagger UI at /docs + ScalarUIProvider("/scalar"), # Modern Scalar UI at /scalar + ReDocUIProvider("/redoc"), # ReDoc at /redoc + ], +) +docs.bind_app(app) + +class Sheep(Struct): + id: int + name: str + voltage: float + +@get("/api/sheep/:id") +async def get_sheep(id: int) -> Sheep: + """ + Retrieve an Electric Sheep by ID. + """ + return Sheep(id=id, name="Cloud Sheep", voltage=220.0) ``` -It also supports dependency injection, a feature that provides a consistent and clean way to use dependencies in request handlers. +Now navigate in your browser: -## Strategies to handle authentication and authorization +- **Swagger UI:** `http://localhost:8000/docs` +- **Scalar UI:** `http://localhost:8000/scalar` +- **ReDoc:** `http://localhost:8000/redoc` +- **Raw OpenAPI JSON Spec:** `http://localhost:8000/openapi.json` -Dreaming Electric Sheep implements strategies to handle authentication and authorization: +--- -```python -app.use_authentication()\ - .add(ExampleAuthenticationHandler()) +## ⚡ High-Speed Serialization: JSON & MessagePack +Take full advantage of pre-compiled type decoders and multiple wire formats: -app.use_authorization()\ - .add(AdminsPolicy()) +```python +from dreaming_electric_sheep import Application, FromJSON, FromMsgPack, FromQuery, post +from msgspec import Struct +class SensorPayload(Struct): + device_id: str + readings: list[float] -@auth("admin") -@get("/") -async def only_for_admins(): - ... +app = Application() +# JSON payload with precompiled type decoder +@post("/api/sensors/json") +async def ingest_json(data: FromJSON[SensorPayload]): + return {"received_readings": len(data.value.readings)} -@auth() -@get("/") -async def only_for_authenticated_users(): - ... +# Binary MessagePack payload (ultra-fast binary format) +@post("/api/sensors/msgpack") +async def ingest_msgpack(data: FromMsgPack[SensorPayload]): + return {"received_readings": len(data.value.readings)} ``` -Dreaming Electric Sheep provides: - -* Built-in support for **OpenID Connect** authentication -* Built-in support for **JWT Bearer** authentication - -Meaning that it is easy to integrate with services such as: - -* [Auth0](https://auth0.com) -* [Microsoft Entra ID](https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id) -* [Azure Active Directory B2C](https://docs.microsoft.com/en-us/azure/active-directory-b2c/overview) -* [Okta](https://www.okta.com) - -It also offers built-in support for **Basic authentication**, -**API Key authentication**, **JWT Bearer authentication using symmetric encryption**, -and automatic generation of OpenAPI Documentation for security schemes when using -built-in classes for authentication. It supports defining custom authentication handlers -and custom mappers for OpenAPI Documentation. - -## Web framework features - -* ASGI compatibility -* Routing -* Request handlers can be defined as functions or class methods -* Middlewares -* WebSocket -* Server-Sent Events (SSE) -* Built-in support for dependency injection -* Support for automatic binding of route and query parameters to request handler method calls -* Strategy to handle exceptions -* Strategy to handle authentication and authorization -* Built-in support for OpenID Connect authentication using OIDC discovery -* Built-in support for JWT Bearer authentication using OIDC discovery and other sources of JWKS -* Handlers normalization -* Serving static files -* Integration with Jinja2 -* Support for serving SPAs that use HTML5 History API for client side routing -* Support for automatic generation of OpenAPI Documentation -* Strategy to handle CORS settings -* Sessions -* Support for automatic binding of `dataclasses` and [`Pydantic`](https://pydantic-docs.helpmanual.io) models to handle the request body payload expected by request handlers -* `TestClient` class to simplify testing of applications -* Anti Forgery validation to protect against Cross-Site Request Forgery (XSRF/CSRF) attacks - -## Client features - -Dreaming Electric Sheep includes an HTTP Client with native HTTP/2 support. -The client automatically detects and uses HTTP/2 when the server supports it, with -seamless fallback to HTTP/1.1. - -**Example:** +--- -```python -import asyncio +## 🚀 Running the Application (CLI & ASGI Servers) -from dreaming_electric_sheep.client import ClientSession +Dreaming Electric Sheep is a standard ASGI 3 application compatible with all ASGI servers. +### 1. Granian (Recommended for Extreme Throughput) -async def client_example(): - async with ClientSession() as client: - response = await client.get("https://docs.python.org/3/") - text = await response.text() - print(text) +[Granian](https://github.com/emmett-framework/granian) is a high-performance Rust-based HTTP server. +```bash +# Production: Multi-threaded & Multi-worker +granian --interface asgi app:app --port 8000 --workers 4 --threads 2 -asyncio.run(client_example()) +# Development: Auto-reload +granian --interface asgi app:app --port 8000 --reload ``` -> [!IMPORTANT] -> -> Dreaming Electric Sheep supports [PyPy](https://pypy.org/) (`PyPy 3.11`). The HTTP client requires `h11` and `h2` libraries. The `httptools` library is -> optional and only provides better URL parsing performance on CPython. These -> dependencies affect only the `dreaming_electric_sheep.client` namespace. +### 2. Uvicorn (with `uvloop` & `httptools`) -## Supported platforms and runtimes +[Uvicorn](https://www.uvicorn.org/) provides a battle-tested Python/C networking stack. -* Python: all versions included in the [build matrix](.github/workflows/main.yml). -* CPython and PyPy. -* Ubuntu. -* Windows. -* macOS. +```bash +# Production: uvloop + httptools +uvicorn app:app --port 8000 --loop uvloop --http httptools --workers 4 -## Branches +# Development: Auto-reload +uvicorn app:app --port 8000 --reload +``` -The _main_ branch contains the currently developed version. +--- + +## 🧩 Controllers & Dependency Injection + +Dreaming Electric Sheep includes built-in dependency injection with pre-bound fast dispatching: + +```python +from dreaming_electric_sheep import Application +from dreaming_electric_sheep.server.controllers import Controller, get, post + +class DatabaseService: + def get_stats(self) -> dict: + return {"active_connections": 42} + +app = Application() +app.services.add_singleton(DatabaseService) + +class StatusController(Controller): + @get("/api/status") + def get_status(self, db: DatabaseService): + return {"status": "ok", "db": db.get_stats()} + +app.controllers.register(StatusController) +``` + +--- + +

+ made with love.png by EduLoboM +

diff --git a/love.png b/love.png new file mode 100644 index 0000000000000000000000000000000000000000..e7d7c0b05f827f7981b584766a6ae2d5d78bfc42 GIT binary patch literal 66135 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 Date: Sat, 29 Aug 2026 15:47:09 -0300 Subject: [PATCH 13/45] refactor: remove Python 3.10 support, update CI workflows, and optimize message disconnection state management --- .github/workflows/main.yml | 35 ++++++++---------- .github/workflows/perf.yml | 1 + README.md | 43 +++++++++++----------- assets/Electric_Screaming_Don_Quixote.png | Bin 0 -> 1099148 bytes assets/love.png | Bin 0 -> 66135 bytes dreaming_electric_sheep.png | Bin 23874 -> 0 bytes dreaming_electric_sheep/messages.pxd | 1 + dreaming_electric_sheep/messages.pyx | 11 ++---- 8 files changed, 42 insertions(+), 49 deletions(-) create mode 100644 assets/Electric_Screaming_Don_Quixote.png create mode 100644 assets/love.png delete mode 100644 dreaming_electric_sheep.png diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b3055f8b..11d9c8db 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -31,7 +31,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13", "3.14"] runs-on: windows-latest if: github.event_name == 'pull_request' || github.event_name == 'push' @@ -61,6 +61,7 @@ jobs: cython dreaming_electric_sheep/messages.pyx cython dreaming_electric_sheep/scribe.pyx cython dreaming_electric_sheep/baseapp.pyx + cython dreaming_electric_sheep/routing.pyx python setup.py build_ext --inplace - name: Run tests @@ -130,16 +131,10 @@ jobs: - name: Test Hypercorn run: | - PYVER=`python -V 2>&1` - - if [ "${PYVER:0:-2}" == "Python 3.10" ]; then - echo "Skipping Hypercorn tests, since it fails with Python 3.10.0 on Ubuntu (2021/10/24)" - else - APP_DEFAULT_ROUTER=false ASGI_SERVER=hypercorn pytest itests/test_server.py - fi + APP_DEFAULT_ROUTER=false ASGI_SERVER=hypercorn pytest itests/test_server.py - name: Test Piccolo Admin / Starlette mount compatibility - if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.14 + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' run: | echo "Running Starlette mount compatibility tests (ref: piccolo-orm/piccolo_admin#472)..." pip install piccolo-admin # include the dedicated test @@ -148,18 +143,18 @@ jobs: - name: Install distribution dependencies run: pip install --upgrade twine setuptools wheel - if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.12 + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' - name: Create distribution package run: python setup.py sdist - if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.12 + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' - name: Upload distribution package uses: actions/upload-artifact@v4 with: name: dist-ubuntu-no-wheels path: dist - if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.12 + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' - name: Test imports with no optional dependencies run: | @@ -189,19 +184,19 @@ jobs: ] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Build wheels - uses: pypa/cibuildwheel@v3.2.1 + uses: pypa/cibuildwheel@v2.22.0 env: - CIBW_BUILD: cp310-* cp311-* cp312-* cp313-* cp314-* + CIBW_BUILD: cp311-* cp312-* cp313-* cp314-* CIBW_ARCHS: "auto64" # Before build commands CIBW_BEFORE_BUILD_WINDOWS: > - python3 -m pip install setuptools wheel Cython==3.1.4 && + python -m pip install setuptools wheel Cython==3.1.4 && python -c "import os, glob; [os.remove(f) for f in glob.glob('dreaming_electric_sheep/*.c') + glob.glob('dreaming_electric_sheep/*.so')]" && make compile @@ -230,12 +225,12 @@ jobs: os: [macos-latest] # macos-15-intel NOT supported python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -266,12 +261,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v5 with: python-version: "3.13" diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index 06082ff5..1fc6bbae 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -47,6 +47,7 @@ jobs: cython dreaming_electric_sheep/messages.pyx cython dreaming_electric_sheep/scribe.pyx cython dreaming_electric_sheep/baseapp.pyx + cython dreaming_electric_sheep/routing.pyx python setup.py build_ext --inplace - name: Install dependencies for benchmark diff --git a/README.md b/README.md index 415c6939..1380eec5 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ -[![Build](https://github.com/EduLoboM/Dreaming-Electric-Sheep/workflows/Main/badge.svg)](https://github.com/EduLoboM/Dreaming-Electric-Sheep/actions) -[![pypi](https://img.shields.io/pypi/v/dreaming-electric-sheep.svg?color=blue)](https://pypi.org/project/dreaming-electric-sheep/) -[![versions](https://img.shields.io/pypi/pyversions/dreaming-electric-sheep.svg)](https://github.com/EduLoboM/Dreaming-Electric-Sheep) -[![license](https://img.shields.io/github/license/EduLoboM/Dreaming-Electric-Sheep.svg)](https://github.com/EduLoboM/Dreaming-Electric-Sheep/blob/main/LICENSE) +

- Electric Screaming Don Quixote EGO - Electric Screaming Meursault EGO + Electric Screaming Don Quixote EGO

Dreaming Electric Sheep

@@ -18,21 +19,21 @@ --- -## ⚡ Key Highlights & Low-Level Architectural Features +## 🔮 Key Highlights & Low-Level Architectural Features -### 1. 🚀 PEP 590 Vectorcall Direct C-API Dispatch +### 1. 👾 PEP 590 Vectorcall Direct C-API Dispatch All handler, middleware, and route dispatching bypasses Python `*args` tuple and `**kwargs` dict allocations. Handlers are invoked using `PyObject_Vectorcall` passing contiguous pointer arrays directly in CPU registers. -### 2. 🛡️ Pure `cdef class` Extension Types (Zero `__dict__` Overhead) +### 2. ⚓ Pure `cdef class` Extension Types (Zero `__dict__` Overhead) `Request`, `Response`, `RouteMatch`, `Header`, and `Scope` are pure Cython extension classes. All fields reside at fixed C-level struct offsets (`pointer offset`), eliminating dictionary lookups in the hot path. -### 3. ♻️ C-Level Object Freelists & Fast Pools +### 3. 🌊 C-Level Object Freelists & Fast Pools `Request` and `Response` instances are managed through dedicated C freelists (`acquire_request`, `release_request`, `acquire_response`, `release_response`), recycling objects across HTTP lifecycles and reducing Python heap pressure to near zero. -### 4. ⚡ SIMD Vectorization (AVX2 / SSE4.2 / ARM NEON / SWAR) +### 4. 🚄 SIMD Vectorization (AVX2 / SSE4.2 / ARM NEON / SWAR) Custom C SIMD kernels accelerate: @@ -40,21 +41,21 @@ Custom C SIMD kernels accelerate: - URL path separator tokenization (`/`). - ASCII header validation with fallback SWAR (SIMD Within A Register). -### 5. 🎯 In-Memory Request Scratchpad Arenas +### 5. 🧊 In-Memory Request Scratchpad Arenas Per-request linear arenas (`scratchpad.h`/`.c`) allow $\mathcal{O}(1)$ allocation and instant bulk resets without invoking `malloc()` or `free()` during request lifecycle processing. -### 6. 🌐 Zero-Copy ASGI Ingestion +### 6. 💎 Zero-Copy ASGI Ingestion Direct `bytes-like` memoryview and buffer passing from server transport layers (Granian, Uvicorn) directly into `msgspec` decoders without intermediate string copies or heap duplications. -### 7. 🧠 Pre-Compiled Type Decoders & Fast DI Bindings +### 7. 🔋 Pre-Compiled Type Decoders & Fast DI Bindings Endpoint payload decoders (`msgspec.json.Decoder(type=...)`) and controller activation paths are compiled ahead-of-time during application startup, eradicating runtime reflection. --- -## 🔌 CPython & C / C++ / CUDA Native Interoperability +## 🐍 CPython & C / C++ / CUDA Native Interoperability > [!IMPORTANT] > **Why CPython exclusively?** @@ -68,7 +69,7 @@ Endpoint payload decoders (`msgspec.json.Decoder(type=...)`) and controller acti --- -## 📦 Installation +## ⭐ Installation ```bash pip install dreaming-electric-sheep @@ -82,7 +83,7 @@ pip install dreaming-electric-sheep httptools uvloop uvicorn granian msgspec --- -## 🛠️ Quick Start & `msgspec` Integration +## ⚡ Quick Start & `msgspec` Integration Dreaming Electric Sheep provides first-class, zero-overhead support for `msgspec.Struct`, `dataclasses`, and `pydantic` models with startup-cached pre-compiled decoders. @@ -110,7 +111,7 @@ async def create_item(data: CreateItemInput): --- -## 📖 OpenAPI 3.0, Swagger UI, Scalar & ReDoc +## 📙 OpenAPI 3.0, Swagger UI, Scalar & ReDoc Dreaming Electric Sheep automatically generates OpenAPI 3.0 documentation from type annotations (`msgspec.Struct`, `dataclasses`, `Pydantic`, Python typing) and docstrings. It includes built-in support for **Swagger UI**, **Scalar**, and **ReDoc**. @@ -160,7 +161,7 @@ Now navigate in your browser: --- -## ⚡ High-Speed Serialization: JSON & MessagePack +## 🔥 High-Speed Serialization: JSON & MessagePack Take full advantage of pre-compiled type decoders and multiple wire formats: @@ -217,7 +218,7 @@ uvicorn app:app --port 8000 --reload --- -## 🧩 Controllers & Dependency Injection +## 🎯 Controllers & Dependency Injection Dreaming Electric Sheep includes built-in dependency injection with pre-bound fast dispatching: @@ -243,5 +244,5 @@ app.controllers.register(StatusController) ---

- made with love.png by EduLoboM + made with love.png by EduLoboM

diff --git a/assets/Electric_Screaming_Don_Quixote.png b/assets/Electric_Screaming_Don_Quixote.png new file mode 100644 index 0000000000000000000000000000000000000000..2cf4c02844974d0fb14c15d2f31599725aacb123 GIT binary patch literal 1099148 zcmXV1WkAzk7v4sV9^DNRg49Tf0i%`fP66psn$evS1|5QQgLKJIRC<7P3DVv1qW|~P z?|$3ex#vFTInQ}+jE0&bJ`Obw006*OR)T8*0O$`(bO0Fh;S)^vI1B*!qOA;<(ee4U z(}6w5rf;}$@1NsYw&k+_>-JSwht2-pjxsw0tZIdzB+@Kg>Ec`q>2tgidg zEJ#eZB*WOdY)@5s*!QRB=Iws4G%W#=Gb&As&pwN?$a`G_KdSE#A33|==ZVz~!#6hi z!vpxGu<9Tt#F4H=@@xnD4C@qZMRki526>xItHZczD4z_b}W~D~Zen0_Q z6W?}zULk;o2tSdDrQc1Imf&F{4b02`R;xvy|2Q}Apx4aUmyhnijJ6HQ-vVkl=OewS zj5I$)3t%DosriTi_{G#m=yR{RRN^f; zbSAX#80?E{iJ~bHIani&`X!?@6(T3$oV74G5+H$LP|~wZReO7!N|QVRlY7P;e>uy5 z<_37!(ZhBWmT3Q5W$}qD1{{kbcoZm#F^^_O+NogdlQu7RTv0~*-EQC#Fmvw!M`GcL zfEAt9;@tdOaFZ4H+a4_+ywt#xUJRvC#M1bsI@!;UTk?G&gnxcqKECVb!GKXMn%QVheO zOq1qKqFkcXRNCYLjoaDpv?y8cG4lB)cE3sD$(P*AuZ>8_mp!$_<7)$6)Q!&N-hSC& z@ChI|PbE^5ib_#5pDg{60|_n-dFL-$a~HaGhHPD57s?>MY&qw;KrK$e-z8etoG zuL4Xz$!F%((;rVAnr=h@Z{S5&DC`Eg?mej^LSdODzxF9Hgd#xu5z#D=G}Sj%e{Bs4TBy%7D3w4O}!NiH<$4itOz0GOUis z-&F@4@>9|Smt_FvSj!7CoY6@DFN>NV`27D)$CGRW@ZTaLar9E5EqXJ+Dv%#G+^i7P zBWPnm5-t3jQk2g6R?(L}Efz8m2$g`z$iPabzH1oeGw@mi6(OJKg@u}|hu4k|bzU%$++bamlDkCh8PVM65HlqZ}S{h`}j9OTKZ`gI11v_97kas`5`llwKw{R70Pt^N{g(TB1K2XMiCO6@f>5n+KZVNn z4{J()z2>{(hHeH9C z_8zqltW_vbfaZ(Aoffcs1yVF*BAN`q>HR>=n6eP=$byzyOoUDnWnfnH9aVR3?>`i= z1OGo~#(Q~0Is%hWS4K6hc->z<2^YeasMh{$CI>&F!w=ivQWP*qkP$$aD_Q6I2u?S=7YENgj<|%mKo*{ z$}WU?kQTMyx=eiYGdB?X!=n$67n*BUE?^lznLh;`-jknzmGvw~fvlFVp6sk-)jp?u zQ)w(m*0+g5oCZ}+x>p{OJ-T8g3MS}g|1V6O;s1THtrPC(6sm=><_Bc&rEupyU9a#@u6D)ypJ+b>qIIw|p%H({BtyGC*kuQ| z=OZ^w2^2mf78e+@bq>)BU>|jOPJQ83&?rlbq^z-9!2rW)`jVMTW0D)Tm7?QSh^YF% z^0O7TB!;3=(#N<&YOtAKGeRS?dvLM*!j2Ov}ue6f^zH zY++*9#N>3{rwBS@@-A3^HG4rv zXy99M2zYyGg638yI{F*tePq{%Xf7)&gu;pA_KkG8JflRRdHf)$vD|^ZBjzQ}vj!Y(V~2 zv8eyGU* zm%Ng|lAW8uY@E+K;`Veu(VEy`!#*wdrS$g2R^@`TnM-wlGB@M^;tQk_XIR^M_`$T# ztWJeljh1OOM_4{UX~Y?UD>8;d33*;Ns+HbsAee)#Y7csn{FBcsR|eBmz#Tq>J^hH~ z7T|!`0Ry5>tj;?8d?asUkpNB9bGF4)&Ct0PW$U@8Trf zI=U%bxCm|aWL!n}sUA&Gp!iqt2r|(}{vPtc(&D>EfBZ2JokxpykhyGgXR74aQ zYe`xo{{m1lY{U5N99YKz)bx*5LF%p3dqhyCXhHgw7!Ebi-pdy(Fhl6D;3VKC3Ne5c z3$b2=YdA2YdnF)R(oTJ~4!3FPe(n%J9a7PS4=;W#Gu1rH)-=1*F>aBY4nw0m<)hlfVmMo z!VFDq{Rkj9>(xr6C_RX_AM~3A_)@L=wU^@%p1{ z+@39`dNwcb?+1VaS)mQxd|>xOCqecoc^Q49=;K>T5Q*9BLKLDK@m6pHu#-d2A5P@= zJ_bREhb7tGazYv2@b`*F7P>n^R)Am+Pd z$lWgLVo}t$G`OOGTf2?yO*j%?>8#S}?RhKl?@GH|zx}6`jlpN5%7#qkQ=gYR7xtTx zfGb1c&S2~CmsCV$VMvtdCOlEmr*@>OD zPx7aVhfV0{)W8w_I|NQ~i^zkg6{@7jj3Z@}>FI&$F#{-g@(#f(RK{frXDz+z01UA$wFe^GlcK#<<4SwyBuu zX41^9(sPpU+RkXQRQ(g%jWJE#FODlxq@DB|`|TP{`)}^wp+76w^6LkJ@ype{h~<--ad;Q%K0%!A8T;w56<9JmYmK zb9~DKsuXv7@XD5~Tkh}ipqL5hF!URmf_J%H5fpXJ9hoXcjW)0wUQ%O) z-Ti*R>!E?PW7$ipe~6R>(sy=tq4x6QhoG&5fzA7MW7pev>U=}PM2EbP(FDLN6{@0{ z*{{?Gw|Ah741&VAl1MHcWZ4*!Xj{H;MCDJ*iwa#(w(^>Fi%g%ufh*s3*OTKj3<#!n_*OUr&iI-uSWuFb6Xr$Jh1fjDrn|?S_-?oc7 zREHN5)Blbk5j(5sd(myUnz=4{jm#kIt|vfvGJZuOz=2LLvn~7X?=MFbF|Sedu){e^ z%G;g%`qz;!3DDXkqhPE@;vn5bCgP^5@pItNoa(Z>L>a%i4S7Gw$j98%eou)$*;uViy+2$#E}zD`2r%8f z7KnQqbJ5|zd(WBuJMQa{JY;+8(3V(Fhf?q~Cs?*Kn`7!K@4dozhw3S@{d)^@p%c{x z1&^G%y!t|vz;{P+s%ZPSdTq!6v8G`}<&$?lVrF%J8udhWd|1qyc28UEkJ#*^GbRy) z!MC@l3v*>}lc#m-li6Gv??PPr9p?(6EBU-(MUVWpL|eBgK{bz-O&7*-pfP5>~41AP7vq;ZpwxZ!B)@_n6BAqe_PlCVg=6PTua!wy&+`Y!YG z!jS^_SAQ8c`+pLue*W|E!z)0`z@~U1MTk2U9@my!jsEtG9@hh^w^cfMX;Pv+5cg9+ z<*{@4G6z{cCURt?udglSn{YPe)R$8SoljIX>=NGQDe|2z4XCMlE>rv?b`;C-b9k}; z7^8o7A)a?UB6U^2K|JA?f1fsJq)ZcZ>qC_*egaG`;qQ)$YI@smazaX8XuVk`Ki73I z?Bfx{ApV2u`Yw&h^p$|d=-xUeM&*T>Ga>8RzHzJc?A4qSip%-k&CM78zBJNbeOWqFUz9SRi(P&U1CV_8Nj1#6#N30oE5tmAR?7c;-4T+F6z(D*jTG>~U&p?|oW`4nG z#uWv;M}~wSY3PjUk1`H_@3-I(&iSIxvk>t`;NVAV!ByxJQc;^KXTcB_J#<+Fwkuu{ zM!?aVO6bkJWAB5Iq#@OeU<{2V^GMU2wWrk>!igob>W6T9Y(#dYBiIkWM7 zyW%@5i4LQwOEv7vn?K^_Q{u|gdL?Oc05ZAC7tmR)YTxi(Fx{h(QmT|g z)j=}?LxnP*yiln4G^!;8N&cKN*i!!>Z*c;CRx?9QZX@%{%N zo~srYI@p4^X(C!bQhU~$liJ?L+F@8aAHIM_TMj`CVCUD@>`hncYzUdt6oiyondA~T zG!?w$z1SL%<{AjTtVu9gZ0)^{bHEfr{n)IzvozYMjW=tL*EmfkawGX>_91lmd7D%k z(F^J5m#43IT;-lHGmP!mLZ%YSWMR(sig-Qv5>$ai$IDWDtgW&6`bAN1lR?kqLEY}0 z?llZ6Ly~wAl23BPNDKrFD4#nzo*>QDGl)QbA$&9M`qfRmCl73*ZP}I09=dFNbhT>r z0-oFNfM#M=E%#zpuJ;itZ1A$_K@sX9I)JZoNJ?g!Q;{EHOaH@L<)RfbX4+jkxl-A- z9HIf$GvBH(3X;)akydL)fJpY%vdZ72q>TO7MIq~Bayw5qXcV3X|3;bn96v}YxqIg; zwShT%?ZYK!-cllV80`chOroXAb%ZJ3GI<;?us+hdG}H>+$TgZ^FILgSnFvxow))A8 zjrxgjO;~7Y!RbvtIDpg0IGTp!K-)DHDcQ3@d`xfO3Xhyg~v4xB);k3HDE(InU_ zJBB)4Q#GVKEGYM!bC^T+ql<}Y#$omXbK$G9(V)U}zRR|F@xSdTay*s>^cRKINd`6- zg}_O+EBb(G$BB>-Lj+W=CR~e1c1s3H9WwRF25y&1**)@DU@z+Q5{2(RCnu6czklaE zdG-RFN~uu6axx)M1g>$3<{Hwesup{(!>*2}%sO@(Tlq(&HS4KSB=s0p-&6PYdx^YS zGNmNa(iNioY9aE1X$1@?+~htUS*m5+YvVA5yCAQ9{iPq&FX=TD!3TiwZmZWsin&w0 z>PuX6uaTA#M8g|Xx^(2)Dqb-X$H7u8h%svXKZd#-1wbx>WH3?mSMZ2CCsUq+%wy;1 z(#?f@M*bVYXdxYa_JJ;VqTZSRLfe)>(4F9 zK39$%53|++op?=ojl!8EGq@DqF%rVxxyR$2CcCJVxW(mrEoQLye4@kDVCzuJR{f*} z?!+uTG2}h->DYQzuDf@dHcU`6dUhh(9my05393BL<=V$AJMDKAdIZiobJCtU7+w%8 zw~RiaKjR4k;>M1S$NZ5Tz_FFB%#ROvR8i#qsbU?-5h!W!Q4Ns@1*nua5Kz)BG6Sp8jWY1j$N z*gn2)xXYaUyJ-UC0gx1#&6-KsA@3O2HL9NwwJNRCtYEg+;NKUcNeRb zVNqilP#QAN^c&c{-#L`$75QSZrtm!Iwlv)_alU!%OJu@nnzrpDfU%y(`5&*`fDiPC zbkGxG?1ln0!bRh$VLABvE{&{X7PxW2lh|pQRs`y|KT7f*snsl-v|Ib_fxFO}2W?s{#}u`7;iUsI}D>J4mmG1Nb! zGw~Bk?iZRDuKk@0K{nt&GpWifPsEzg9~=MNIa=a}0rMyjgWJ{s-?Zir>=KMNBPI+z zDS$gYYza^_RnyEV7VQG=PJk1;8M#^b;=;M1iLhj%B}e!dJSx33agOk8pv5z2?L^X2 z$eUZS@4qUKIvc%6ioja@Iu$^W0m+pXP!;ft@qY$H}suN#WKq+n~M3>o$W3e1vbGKL|7Z)Vv z4rJcv330iXFUq*5?lxC)p@*LGo&4+1z5h>bp;o_tyHf5#{cfL2`;JEeZ{0}GgIMC* zq)YQ(Bx@-5mo%!xzdB=tTi7OekK`?E!LcV(HARxB8LwyDtlFHUZgjX_)Aoypj}fVn zg3>F`e>GN~i;t_@It9z{^L*f`k!nywpT0J81jBrD$5Cxv!k3<0yi?4802f$l((?uS zesStwRg4vCFN)9_7yK&*S@`ilQ@^5=3~UGPH|?JAS)g)zGRgP&uU_(;ND~A%%JChw*q^mwKOAAeLnhq-^(gn7bkCMoTP7w?1D|Ag zF%&MJbfyYe&$)Kni3!}~!qUM2Vh0*GW(^PF7ln~2=wqflt@$!h*hfLwhj{?$0az(N zOkjyhnU(nLX?fFUn~-(;(N6YI_~q4+-N*4RYnx_li#15W$-9lZQ@KU_9x1KPxai>L z9^0B<6NW)P%~p*=gX?$?@SN!V9}RPV&!=#I8dh7OaBp*R4KOpLC!tp`g_jY^iFb*o zCkStoiu!VhoLwJE$TphbkF!sRVi!(Q->2-cpqAax+za!2!W%n-``+z0>N$u6`8`h$ z1na7J`=YMMA{(U=w5g=<(dU|&t*&>PM5>;}Tdl+jBD!@qp1B;%q*p}L*&2@>z~dv9 zKb+sO;0t9czk5RSz%?YFc2rOf;~o}_5=)7^rV#ek)}vw;x|3`w*1gMupN`^^+se&8 znQaxGp%1Q2c&@zUUPuta%EYqAx#QwGF!{#LO6I&qT*nji42viF*R98;QQ3Y|p2~_8 zGvhGsG+!#9#CwAMKn9cH#RxH34^~Hi8ygikUV`-{m@p&-IrAMY>k+`DW?$hI4|&RU#``uc;SzKwNGgd@(+k2=KQuR zd9mE&=aLq)9yM&mtKd)!m|iE@e<4B-SW~|5W(yOqI(>_;SaLl!a>Zu`FKYF5b?6yD zCR9)B5Td5|txi&Y>RC)psfFrG8wXbANyWkF5(0eWENe3R?xP|P_341Ed?%_?npsGf zupl++JI5S<{d;8{*2gR5E2L&;YM@gKyzo^<{8_4N}GGY{RA`CbWmg<#@O&9*~Ay`3q z#2(dYvY1J_`>L4XceRwNSaJ2pUv9&srfiulR}D9GVi^cLxl%sbZC> zp5kSx4$*hO?v1>g%#Wn`MT(01YolWPd@sR>sw$H3Tbcojir(#*O(8(em~60#<82f` z3iN>gZfR~rf9(eDsRo7jw1q$hjDyz-UG@z_rC~xET(Z(ey&gW?n)j4`DZA@jr?H6& zj<)9slQ7F-DLvDZLf`PwEc{#cm0!7M2~pF8AN`Gqf3R+WI}%GI5%o%72m%rVq;Huw8Yj2*r~_ur!?!>&4#en z((#?o@A%4j0IZnm%gFQpA86X&&9JePd+3)}sC+z{Xzj zyTJEmA%t)RcAsp>fJ7)chI2K=j=x5+5ROILsZ`66Kx8YjJVsFop%^mg;Q}Ap$*Ukw zYxA!ARy-or@~6}2n}m^&S$G691xa->K#p~w^I6}0%#JwrHx_^>-_e*YBmbRsT^|6F z7dkHmVcQ5#Va3#C=0ImAvlcTegIn2ioUBT%v}8)*YWl0eANO+GuM1dGqF>Vqzy2z2 zPr3Un8_3{tah0gn*X3L%*@^qN4)guL3^P5Pp2kH$ngK1q67XyoIq&Qypocg# z08ef-yg@8DF z6^cRE!<~=yT{>pqpquM{4RRSC#<1?kRbhHV8yb>LsWaw}h8}xhLrPC>IFtC8Qfnak zg#(Z+p691ilRNiLVYwBUnWHkn3i?&i{1g&Iw8W1PSNP}@e@zMb zLIm}qAI;0-!>MM0oI`stFo z<+O!1!ckv^I3Fc5+We54@dG1(^8m(Vb0Hq0hUySX1#c{b(Yrbsw#Y7(d-@v9BC^&| zt@X=(BPJb52yTtSmA}Tw%yB3mvwyYHuza#c^v+M6yD{e1iq6rB(q@B;QKutECDm_e zP1xmEDIR@z+BJQ`9)!`o|8JE2^RGtI`MG8Nd{0WnwbI#cTsS zp)`kyKkxKR!ewpzN7_mS#l?(^wr5EuV!g{~Rh2RtQD4LrBEKfJR=?+`EOf=MG>C9e z&%u)UdJ>V7)@0_WE65?|mH)jdZFKprNVCMoX>4Shd+*I)>D|aF>`WT!=OfcZ=ryVZ zr}?&^{_N|_%cmpMeZ2M&%hi!Vjr8vHz!1FLK&PW-3xpOuXdxVnd>@Ix73b|aE^+;` zv9TCexzcMylM*YFXVmmC^RIJE-?_(2VR&}g@P?Q=jXDx1d;H z3Dk1mz~IDGv($D=+w<&)#*c`X7WXa)8m2{zW(b(5nGvmo8lUCDnSN?_KY=+BP2S3d zSoTLdQ#p}o)P~|l-aHY?T&$X`d7xZ%{#FLGD^lf9N zWUSuR6PJ`k>7#-tUK=fs@{$0|b*kGP_6#73!}oaVU_s7Xr;oSMd~$UXz*iR zAie!quXaxc4hprg)x$Q2GOZH4pX+UrPKcbEn@#fT@>HI_%6{8N__ERk?ZBI~Wd{AP zBT4ct1C@x@h`E>F2_!pf3)g6tbqq8t`(6s|yfO}T`}J|{srxrM#@IRZ(_LR0 zyh#LQ3XcfJQzp)uZ2kBJI5;p}!fP16s35l+_n!c*OE2iNcLtK+SlB)0 zUoia7>oU^Krm}4=Bo2pn;ViwZNNNR|ZZxRjHurc^TyMl+)}LBc+{Y9Ul^_KNIZ#8w z`!Xn&K~|Mi060|Ar@-=yQ$l{@_Z8-~T#HOas=HG^+aZ&YY07YtJo*Hn(e+*IZP_Rm z{L{+~2IW$}rYz^24>WI`M+dChqaS)E#aC_;5{cH!nkJIBh0~+$w8x_%`P!eED9C1y z)NOZyTDbuST-s<_Uk=!#+jC|r-c&@BBzS=Wb~a5Pv3Ju=lRvX$dlvIv)&jxW`wR=22Po^p#>JZapToe(_`;YFQ!r5 z3x%1~HO!*Nn4kvFj}5^`sYojBgfz|M49O=n_n+VJ+ipU`;Au~Rtb&v%poZ!aM{E-V z_f$@aY4sX7#`)8+XNsY5DQ5~Few(PV}jhjyk{X1DD{x?}&^s=|8AEil0<&G%u4e@y!P?QYknG>Eu z0*!2cdb7?RrE*P~cMx`{$u%wDntFraBhh2ElaeRB5xOPfHW|sx9H-<%HW}fqDW8AY zR{d!onRL3_Zr4v3KQG5) zaa7`B24TiyDBxRk+D$~r-&H<4Ng@v54NH8JfK&6a_9Hw^$uV0T@M^{X+$_fWR!YzM z+rEXdC!Xo#k7EKG1M4oO)VFZ~px3Fy#CZr}XJKM=f=aaRjETb7V_01SmpsPwRb9o* z^BB0%*$TLJYj%-^Eiv}w_K+V|<2v=@?3ZA1?~J~`{*Jg|<;AspV{~!=gY521Ih4Q5 zhVXcPbyI#`X1J>|DM5ixEE)&k7B$LC)8R7oBIqu|R08+m23$PqUH|K|TYUDNxzQNtJoK-)TIAkA1du~#xx}6@IF!3HaHKkaXKelGeECMTRaa8TespRDt zh`KI_QR1=n{sc^pB$3OH6c$f01f8f@+nipH`FUe~9msw}4M02PNr!A{e?3FdI~~35 zU1h|-G>>F;u&vPqG)V=}QY3VJa|))-mA=CIk_=q2{N2X$*c%P81bPN!BBgVwBW=I6}^^34bZWdOOw&rdSlC5(CvfACC?*|X@mXRHl#p=x=y zSyRAP3XD?JVb^%{*2%1;?il?DJ%^^?h<+d=+=aC|?}qMDDwKmRO4{H>c?~(R`v9Iy z&tSX(NP!jk3BUUkA?nGm_hLP-cS*gObT)38O~>~f+XPyrmKBGdqCgJ`_jbB404G__ zErvU;8;)EaQg1J%>k$*?B}mVENCuO3BmcJ9i|GHD+ToAx*N&Ca_9rc)U8?@qPiaK% zY73@R!UkQ;w?*69-QAVb90R9U9q%tFxCPLb(`fDsqjhZxf$=*v2);cdPP)waI82E@ zXd`-tRcK6WTmzqf>PDel1VP|(C*&vSouI;;>X8^vJ>GY8yN4ZRoqtRG>b_$fH1Qi? z5b^`)_Tyu-&JhqY%_Vf?xX%t#m=RAOc#o?QSk}_8d~AuvlH=Uo0|vzX03Mtp_Xh5L zR$P-ujSq9weL$QMXnD4UKo2itxg-Y|s3F%0j<5jg;mPu|mjLh-+GB03rKI%f^la*< zDGjK*>AMGDf(d+9888#V{$3gLz9B~=2M1~zY5If~O|OuBwgD+qbSk021L|JwzWYsa zjU0tI2Ouxj&qSTp&PgbrQzlqbfaWll2=s26NyiC$9^&5FEJJw*s| z4YcUd)E%G1Hr|-9l)M;c7mA=EsY#;7aZLM_g1g@jwy20iuE=~8vD|2yi@D|N?ewYVGfkt; zFd8LvrOQiot`x?EfLxJR-CDsvGiHr8@|Jy@nj2Q1uN(%^T(_A+K~Fw>CZaN9z&;eH zdUv5I;kV-nZX2=}61JpF8JnzKY_$)5Zt`tTeKG)GC`t842eZx_5ebq(G@^TPYRM69 ziQV6l3z8J=H!|Z*&rgRCN_(>l$2i=KplIB57(#bh?F`6S3_eEm@?ELqf1wVmDDDeKsp>e%6)I~u{iAPhdy7j2mD``SSqrH1bLrl8h7Xx3636i>}X%hU6?;(vnlK zA*>Jw3;rA|eAGMD;pel9=BoamR^cn0jnN|&xNP0&V480UcRu|U%oP%RUw;W#I@ni5 z2VHf@HrM3+9$9v1f3>xKplmI*BO?cZTsGvGvGS!cf|5uE2M|O)@jKqU*Q(XsBt97A z2mXG|K_o=;*h`*2^8OU|uc}KW@=71rihOubbbWrV{2ksRMMy>FPkx#{xZ-F1i z6Q(Kjbn;vRZJ(=`SSBSXWZa5GJ| zx!KHJt^I;-&JaNFFFEMe=_}uk;oSzwt~Ztf)3KfdPx=rrXiDwt74N1wk$a2^o2o8` z)jQ#Ujiy=tCHs4#_=5NC39w0i%oxa2qKgpOIdp;uaz`+z`5$G7Wg1TuvhO7V@ zP31h3RM8Ki)%F`Iohi(@h)9Lf-en>!2kg4xR^fP-a_b|#?ET#CbW27$dIrk-rkj+R zOzZC2ps9>B6-e=}hCT#hACF)Zx)UkU7dKec_e#)A3PM937*?L<{#DUCd;Za~L;Mi_ z8&{~pJ^L8vjSG@9Os$QnE9G_@$q%3w`?7@K+bW#ee_w}1rg$dGDG4#Q&htuY@5R=5 z*A?``aiX=!H6x`B3`8lvas!nZlj&L6VI}jRqliWjd5r0<2_fDx?@;kY)1@Kz6~Y(# zb;a(FUVPYC%O6K$o~(~t{9?VBP&lvn!|!z|iG|$;PL_}-zSTT~!_9_G%yy)WJ3AvG zJ};i%bRFL&r@wye?Upj*&}sRj9;Y}!(s^#L-R~~UJ`MKn@Dum@BX=*|_>aky5qw*N z{hJ?=;2U+k*33L)!z2fLH(|$~N7`jB!v*oT%|dFZ+Ck?$+P;ki0jzx`4w;g$->d;c zWM&W%zpt^`W(8!}s20+uG-&6XB#nu4>A2sy=R{W%XcK=SDdP+Nofa6qRWF}hGayw@ z6r;1iwUPPiX^PTu_=V7F7YjRIw_Th1WkQX&ZC@@J06tORaOhdz+_7rsDiV304L?Bw%wn}Msj{V3_gL`_VVvR?SOBE3XWnrm1NJySQFR zmcV=AA?FI5$)AlOIew|!U783w^syDcG{_t z{@48RNbzy_4pPlTV8n~ki2Vf0L5%H zBCD08`|RR=Y}CPu`p+weOhH2= z%r7fX#+2{k$5i}%#HOfL9W5s6z*QyY;gN!j9=yhOgwabptzp^c)A=Hn*P_yP(QZj3 zLGtaBLUTdCiOuGJ77ELMj$w%nzQ}uw{ebJ?yLJ?p>Q?hqX~>&A7n7csx?7Ql4p5L# zgWhMHlfiw<-&+vCa!Azo`5$Q5md$Y?2GimyY0rn6m;EdKfnE+gKj|oFLX>+~`7f+! zRlexSRjaamQWut7l|0i`{PrhK^g;RRcenSyX>NQq8L4FwI4|(lCMVoo!6bTpQ&ge$ z_0=hOl9+j(;Wpb~bk-5wZI7~4L-;Pnk%;GASz1$1*%~S39X-ZQezjb{t36GVbn08_ zn@Q<@yA6g$HN?Xm(X%y@WkGe5NDk;zpp&nhTnY(w10-QfNdoo^mQd7Yvl_zNJ!SOR zlN~0LTqOvjpCL}s4=if3;_jZ>J4`SW~=T*yeL?sDgKQ`9436MUtk606T z_T^%_O zCuQCQ)5@h@A+pc&d}Z@S>KR5%J%3XE4^i7{U0VB+>4Rr;bs3Ov#{)4s+;efge!9D z3lZm8{*q=A?&O#O9sZ=>fqwk!>Yi^J>cS_&Y=PLT3Q-|v6oEcd;RauCKzXe=b1zKX zPTx&;_+$OPb0SDU!^g|3ALO$B+~zd2)Y~12LgvQw*a?t2mWqC*0LW{i8b96cKsi7AM z*BVBaY?kJ-p-alLZJc?5c_P@*k5p?VQ!O^Lk4CE~wFckUtI2=yfLz#L)A?wUm>rAK z2X6s)q+V~B0bcJByt;qRyK^gsF#oFxNyPov0~iiUiS+Q%Gs!7lo~@w{Idq-OsRt%uOo=kp6jL)rabK zlTxg8aStghN)wB4ukk@arF85hV{#z?yvc;d~?6O-sz87 z9sGGV0{%7qOPug=N{E^YVaBQ=DLDo7$@F7)lo$w^^s<5#VlDb>HtP71Sty0ettEG4 zRC&ytQU&pEb-CVKu^}Q2=W5I4dsZCF$=6N7lS?YBBD>dTit0CFnC1o@jt|PO zr6Q&lZ-D`uBlDCsKlZ0Rf<==w=i0nTDVjc31e_7w`=WO!)tqSdo$*CcdnknFsKWr8 z)sxv#vS1By+ec=22Qzp$Kql-ReDoSVqcNBUHIiPJMK*pX$cSz6M<`=YjFuvRCY^}U z9$*ZLX$0%qe_4NpMKMZ$DY#4)JPao0@n@rt33wDbfp4)buqv(BF?S(7C;b*9jnP7( zZ&0wnV`UuLV-tyU6jfdNa{t`~^-hYI%K62LOHc8s5NMGb;Te(Lxch>0FI&n!4Y+R3BE^tSR zj5>STxj~L`Mn|MVH|=rk6}VCsuAuT|n$a)_@xA8pz%4QU{_@9)3bXx}9CU>Yyq01T ziNyz{-Z=e!^sx55LBP`nz*Ts>4=DRc?7=m*5L`@l)=)i=h@!8s3Gg4z$Wd&Hm$kTS zQ7D)5xr>v)Gcc1)aFK8w8v59UEBM27v)p#}DXs=TQsUYcnKj9ws29pbwXY*^5w~#L zN)n?Vtrw$M+Qm!XJen$a@dhn8VVE`;Z(nrV=q8RQbNO(Ab|GLt>R{FUmhsMVZ~JXB zaG;Ec?Q@UxlAO9^JVWdG{X%!T0 zV>TXop<+T;Y_QO7oFxvSyYlDQ8cZt_z`R|L9B%cZM8I&V46zx#v}TL&x!2E{j$05Y zpI}tGL*jdoswZZx-Ye7O(mTD>VlB{MFA!#NT?y(mebNU%P?Dv7Fis@>Fw3R6{~{HR ziL3OY%>_duR|EkCTA5$g%kEs^U1%8{tv%FM9n{8bkF zETyB1yxg-e;Uj{P$ZU9tWk!_H@Q3nFGr}n;y8vG5f-r%Eui(1;WFqwN7jaHAreo9B z0fcS5kj-;w0Yt3Bhn~(=F!3z)@Lu2fQpTRnkOhAPvKFGO=fAqWhME5}i}~`pr~Gth zDq8>&k)kYczsIbV_v+cMPcHjG#3j;w7L7qBj4dbtsoP@d*Gxx#z;GT2dg#>2cAXDY zyJD)J(9sQw1MfC4st4(Sfs^VB*GZpPak`^;KK~dA#BVG@P%a_z$;mKB;;`R>4n#e| zA?cjw=(vtHo^jn$-8q`VtboW2&3LW?@!5}waa2H7U4j3RbPW!5_HX}mvTfV8?S<7A zmbF@L*|lujUbSr7EiT)(J9W<6^Lzh>`@;A7=zdsx2!P2nelC&lMeL`=KH7b)+KP;# zXX`sO<}Q8zaZa`tgm2qUr2Xk?$Z?s8e7J6Rg;T`iIA9x9kZZ+V?Avcb2q7@yhMa)Y z_<)uhfB#931`5Zj3D!KzPWT3ff@rLZ@XwBeH^eufg&U=b%)hTcGJQPM|G?L9%)^o- z>4H(lDLCXZJEs4(q-W{hhTo%mZdHsQB>PI}XzhQsLGk~ioYU{9%gAr~aO$N+t$E6? zVc!^!UP81{CpB94+x+j9S@!ifcS&5rKWQ#m&$6;E{E3@?YCst+fDVU5jr zt}Bc!0aGFHyy@s6*?MpOijmZEWv`wRkiyxQkST3MDrto?B+B9a!hxj5GjmNhlOQ~U z#Z7^w9&R0*@U@5qfwU%b~+spMPWOkVZApSf| z$JVXCdJxU&U}HEHrnXyHuDvT&6tbE0!1#PxB~+L?adkqp0>0CQ8wa6_yJP;$&2wfI zbGw&Z=j|b{S@r2A#pBq1l)tJy(Cw3O{5_lhn zf&mEXzOWpBG%13N{^m3Pupk34+jwBDSlAaLK#o4T7YeFk@e4&IRUm1J3aexULjpxL z4FtO+Jmo}Udv0vvM#}8ke`0dtx}LSBmzR5Kmp02NQkjl@7$NdR`fPgBucPr2PB+3U zzI>pjMw;d06#W&7aXW!Mc?mhpsPY_G0ZU=BeA43N*QH0EENNKYt{Qx5S;&!`f63B20t#|?!V zFdG58_YEjovJf5|^o0kuO7t+72bC1P+UBCh4rC+C_mamhWc*%gQ+;o@gIVfl zdix{c*d!N2fXG_ZJ-Zk2?+z!l!1QYdmO81{Oaa_QvB*|HH+pZ7oESy|5c$LJs^h0Y zT{K-W2l_D9U|=v>Bu`xinnHBAxx7w<-7c5u-u&bI2q6y>kD zcb|6VoaQBtNT>&4_OfV!HWPrOxn=CmHR$f*i9E>>wrzq*V9pjb1kB9!z>artP}mfAu9b`z2rp@$P1?L)a~>EshRd_KoE+p{(0?2xqmJ_(~?el5B&Y; zO(Oe10z>tA&Hu}q#)&XD4dSP9zSLbYG0!)#w}k|dN$r9E%9G-`y=u{9Mhs_TNSMD+ zH+fGBq;homGRwK~^%zWB`{r*P1KBJ?q}Js!Evk|mfqLhRX`+yvse>~=F>;!aLq4gO z0B31_ZeldA`14S_aGYx|Y0E3Yr5D09oHQX{hrw2~*TjT%-G?#rSoD)WYI|^YVjq<> z)0syECJ)%@c#8YHq*vXaU?}d(mYinC7-b7-6WCy@8XxZAAFBPNhkFl|Yj&Of)mKQR z+0OQ>8yp3IMtQUvCb#j3FapNRmPKMdDL%8njN;d?YrqaZni!r$Yp^y{>%ei2T*rTV zLk;?WdqWkIsHpXMx7=M-_4Nwox+c1rqJ)@L=V6zYp=u_6uhr$vyVmk`ZRVbTAsg^; zvTpj3ltA0Bs?Ce`eOdu6gD}8`YhQ66QIk^(kLMp8HApbTY_F+#+MqVBmsr_5fjS#O#)ayHH0*#+VXZ`AzqF~E z_)T`8c$#)C7W?}Lr)Hz)jX#ys3N!=LVL&rcp3Z)M*n9?zv6L9C> z`?^JMQriVVh8yGI*-cbQu4k;MSO#>>}+|4XPfPQxxy`CT*{%R?uj z{=@g!#mk&GK;z+S@VjytCj~}5lPIi_TJOjQQbV&U$J$^AOw7cl)L5#VJx|@oZC(DH`H?O zY=7n5#qJMa`9tzQ^yO9mgX9G4-j>}vFIxPbQ^f183ZqIlf4H1@9>o;RVlZG=RR4Xo zkMSg34K~wT#ozvQqHWsV(1C{b5fd&mU>3 zd}a$%7y|W;);f0NIui*xcsr8_75y(C%S2SIzE?kWDrB#8p0$BVI9s}?wM&b-S5IjFFVdlI-3WoP(vqzNKf zim6fala&Laz2r>Bwxq^DrKW%{4BR<_y3JhwP$0GjII%b#>xp8q_o>&t>td75e5h@c zsk~rfuT?8-peUkRoSs>pLSa4v$l)ikCGXRA&YDq(g1lREpwqN8#d5`kVh3Bs-KKw> z2y;UySE5DNlmFsxqo2(zR3|Uw6G`abEfN}nu_XtT5TI@e3YjU;kd4tQ`zaI{F+8wx zF(L-Ai28$YIpyZAP7LJHAGcomjC{}0{`0*2`0vJywWNP_F~{;35-{5xCw{W+2K#!q zblF4juS8zWjM{8WjzphGWIHZ)#+mu|gnRB-K1@xYQVrIL-i2~rS))4$Y6-+bnp+6* zRk|v$Q1hGWUw|$+CeDszQ-uBrt~lpJ-kigotU8j`#@_K<0T^NcrBkioF>GXJIzfGw zKfx(1GE`}eQ*c{KiR<~D1z0p;6PrP-%!&QVHBCHInUSvj?X@6$InNC%`oheDJfAD1 z0aZ)cn}S)&5$eNWa#0wGNVr9R?G(+$L@uD0j^8hnE|6>%!2ud2-oYc6hF0iTQuc4? zZBGwG;J=ClTxMHBP*Ztc1u-xRF_1=@=;=qy#v~+0?t#49r@|d&Vd{YRS`zUJOxfPb zZtdN|BWSyiAS)~mfu+{b5mN{I>>!6c&krv^sGop?zXQya_o@6-Z&H;Jvg))&$U<2l z&9(I!5~f*~?bC=OhyLJ2oDT`KTT7|q!7`r6tlr?-6-ZtfW_x!Shlr+jz(y}c5FDlN z4$Mvoy&&g}j26-%9k(c;3-pl+HI(}GsU_;)4YqadZ#vk?BLxO3fOV+a+?C4lsH_MMTSY4K6v(XT)VA^oQE)x|74e^r}G4^Y!>7XZt1Z$|(Ol zKmK}bdJA1Rx?I*6y?L;7uE)jolmYXHl*HJm7!WA2@;p5Nx%gSM5YY9De*W|8NX%+2 zM_NwNkb?*kZt=)9P%hLKaE8EjMqpsoSTo_OS|B+^g1{X9flqkWtT<48s|j*oJ6Lbe1?XRfLvQ0 zT{B&eh`tZe2|IUw6q&zC%*m&NYw*9BM;k2t)i_#_A3e?nw@XzY%&P3J6+`t#Wcd6A zS63=7tg#Dh?aG#?LXXvvJK7zs0E?!>WEnK{#ICKX5pB|YxjQw8H zS;>X`%2e&?ocYq1T$##~z3Vzz62aoSAEEK4OYlCB z9w<6KD0nj1ez95s_~(BT!?;=9`12PAd`P|6Dj+aaNKi>^RSgO%gHr#P1Vdh2WII)h zeD}0aogm=SNbDEC{{tE^xJT4#uSw5+FRaBVAg}Z3>V{sPyq|8RRvMtO=mtFJI3*Iz z4ZT(~-nfQ=2EtC%n!64v9=AtAIbK*=RLb~1s}Esr*J%es%JQ6Q0)3-KY|#Q{)ysX$ zUHqo)b&;Ne6inrhzP(M5ibv!v-%?rS&``2aNBjX&T1!{YJyke%CpKF1;C3?p>PtqM zES<&e{>*N0Ne~02|Ee&WSwu??nGPWLXdX`jXf^aj=w zyj(Zw-jy@4p8KQI@F6#62ebg+(vYZ;Hxh^}Z$N{;f8PTzhhlRXn_u0^g}a8E1baO{J?>ecP)musCwk1uZ5;rkzR`6D0w zwuggdUQgIkB0WbBPw`9P*Xif;z#b`j0c3WuJ^7pQ@~b{6517xdhGaGugMHhUH~Z^Q z`aZ*kojN$x$y<+Vu1`-kp2tYFcVzd3XTB!5 z5}fCvsFy+xyM57*sbM-vV9|+lpl48^!seF@sOY1mbzEFS(WdVT(s&Bt&4jrnVrynM z@&s&fkQ8Wb>ShTrW!nGLZ&fb7yhJ^+3lwq3=z&k5pGUFXKjaIa{?LRDbHqO4s~^5P zLwMPUvg5q05F^wXgAcY0iRLIq%pikICq zl#xsuB-HMW75poO)Pk8z1EU#YG2~26KV8@P$m72n#sHwm2KJssx`U-RvA$K|v2`6w z*{*aF<{p^<5JSKu80{p*7c{-*`Peyl2`Yat(p+@D`jfR83A%MRRuXo^4F$(OIZ$`ast#qFFq z=Y<$tibv2N_Jl__w~P@v-@ONUZ10#ogQk69^PVTgJT$h4v@Zr-*#(i0vIPU7obN<~ z3#8<57$;fpIS!|{Lxc_ER;FMD7JY%B*sUKQ_;wFI`?o!h{=6YUZ1&y|Z2s*(k9T#9 z0>pNlsG%~&UTv6++R@}sGSo>MXjC29iBFG~F!cr>Pn@XNcA_$cK&!7Gypxp@b7^~@ zKEJj4M?RF2*Y(mAJb*diPb4L@Yr5J6WQc(@P;s>ljH;L}D!1pvtRe#EO@dj*RNckl zbw4@^+hJhiEuMR?!kBMT8LCMDX?k2^&~^Zkb-uqqCQjQkT1>bN5KpH}%iK{7IUb}v z{^~-Y99rL6oZbiBjTtj5RgxYzw7c*eo>TdEhP=B=tk1PS_-P5fzs*5f7P%e_oOBNF zo)O9|pSCN*4&&=ENShUbmRTJ)nPYky@QTz)zE4`})1MQ9pYRb`D61mvxcI?K(1F^7 zY!YrDh1puK`m5EU3&QiWzdIY~z9c=3SM%wQna^o@?8Ed=V2(>Yv1sz1E6A>q?*;o! zp!Cx=GBW&ipnUA^PKX#+DmW|P;6RRCy1z~Cs8}@s{%p9XAhpQc93}?f5!%NpF(|jH z6cV&?Z3c~*NnfN;JNqPK;YNPBA+>l&Z|GkoeBH|m2XQ@-TLh0}ez`ex-bB74Q)z6BF(!6@H8N%WI{QH#}j+008f03(t$N|_pH^6-`QlI}jJ8stZb#tKeZJ$BlK^l~xxwJhBtmPh$CkZ~R z?F6&D;69=wnP>^OZD(xg?#EsepHt_!o$Ufy+ngVJnfSSZHbyRVZuZ3R+_kMZT8AFF zSLF3@;E=4Ek;@aC+IV{S;(yyV`IJJ}G84ZeoE(^&`KCrza`1Q8>(aLWOx@LXxiSiY z8!wt@;#qxXKP?bcNHm63)O{YCLWn_4J4VqQD&LL(z{pX2YXXf@;hX;m@AaZ6l*;d{ zZddExvL5#TUi-)XPr@M5tq(fwx!;Oo6cP^xZodSP!n6T_JM*MhS2s+c3<{+!F&<=j z>A_8%N%ada!ud}vty$Uy&x;qxly&dkN7kv&Gmg{-s|951muM3E@F#6EN)c;n(PXW~ z+Jy6+^$9Q;IwVXi$Ty6fkr0$YO`$D^2c_-|B=JJg$%O9)v0_eT+V17$2}D9A zTz=STgwKYn>nfd6+(p|DzwvxIwN1b{*NaJqtL?Of{nZs_y594L5CT(83t;)av%T6~ zRElPvSkCl<#H2_Tbq43gTUPg5>g_VH;~M+PmjIg0&Y{nHsvA%E7uqFHC`5Y0thJsy zD&^BFu+IS|1bf@0C`?H;0G7JxHJ(t)wHf0UD!1?>1z<4Vl4Fp7!mKx{0uAtMXz_DF zB=(FS!{-ddo(S)_ixyRY?(2J(=@F`c<+;9y$D7}Mp_x|QsdPNLB5Q}(c6)~QPT>tR z$ANb%lElF8N>dIfv4)gl8L^k|q5-u_(ikh;J6~j8rq_jFgpvu~fZG=+o0BsJ25+gj z=X}L7(#7afsT%;)G<`Aa8H7_W~TdRz2A#5U!B z6`Z}Vn*qJO)pe$A=nU$Bti6Zq7!Bd?Y3YtW^p$`($Vo@cl#Oabg##%@8-@eeSC@Gf z2F*Z6H#cg2q(;xTPzZg^m!WAnVK&ub$Y}i5$1zyCY2zH=V7DVULiB9h!PYxr|9{H_ViWzY`j-_kb zf65&QP|DbZeSuy%E2a5d6w`^&sanBVSt<@| zI^S$O4{`^I&U@Kw+0F5PrhA(+dPfp_U4VC7>Z|DUUrNL3I@y5W^LM^qJikLcmwFM6 zd7~Ezu4}d!<2mUpG0tYD26vp1mux+uUvGeg0y#t&$%HGi=)w_SY=@djOk44XJ^j#3 zP^Hh*YtP0!i>s(dzo8+9ZqR#IHrV{`xuRkEOvON2muTk)Z})heK#jf|%Knbo-T6#Y zohJg{;|zA+tzq*e^?QxRH|rfrLjCjtx#RJ$LsWp_t zUAGL2_VFI04Xjg~_>gEgMO0}bxa3tYUKGq-ztRV6QOpQHp#}hfcC^LIt_(V@0~C^4 zzhtRCCsa+uOBG`BNaQJpS=@ilKmE`UeEmkIu%;oqDhGg>M=8MxmscAjVr2k{&e6LJ z)A$hTz)LKWDR;sC**^b=S&QEL|4(R{|86oZ{#Q$=bK1fd!V6fAsIMQf9x~Y$Pb~w$ z{sRn=p>#WaI8tVWM_UiK@NUvOe{i)5A8`A6)&j-qwRiHcxoXXMla9pWCmFh!&wmnl zq_`D*U@k9=h%gLAY}L-!H)y|WN?l^j+y_2x{<;!hw$~U762r|fLxDIQ`D@+SY7*7l}1+4yPfYnZ2BSwdec~8e#kv=Xi!t*hDxiq$0@KL zE#>-CYg$zF?HVGgm(T<-5brT#Q2|qIA>&rzfjyTtWUj^4!}7k!9fRDp&ODLb*`4U z?>E+OoW+Nc`_Uvj`AvtT>_+)Gbxz9irtH;?_(uk@CMWUfH3%@b?n!sO;b!fpn$RoB zqFs>a@XrUiI`EUDG|o?GSfoI4XSAbf?ny;q7`zaco9Wa=;|p~}3<8y=YQ;lISfnQQ zve14zK}5v-v+1&L)tUJ@2zz2JVIr7w=t1i1=P{hYbb~oR9f6W^i|hyZ#PtGaR~;st zk`!lgeZMANcK{CqNSe?``)ljs+Le(XF4+kTTS;G*EybD|8v4GMCO%EaLBS_vA$6m) zyFB7k6hIwzmH>h&hwMF(BahWS z?avEQ(Yupvq)rdwF2;tho}E4F1O!^CHtUWTkuxKk;_OHP-#g`mH|WN`Q7yv=Q8jTc zmV`_=q6b#Qx6zcPlWfkB6smk}TgqvG*>8#x~*9%Du4wgetDvu};Dx)BG4jiUyHvhp*N3KnX7AB`w6Hzma z3#EiQjy4ze)%QDhvgYV77UHIBB-BH=`aechlEJahitm~wp^&SMyX^L#(`C~}V{h<^ zAax4|03rS{;*%T>QApz84IvVE?<``Qof2RXzPpBC<3?;;9;%m@cLJLyP&GY3A72e@hg8&>^?c_$%yMYu}hvshqkhR zzy%tCLXmpjO6`dH5~ty7*^naFw0p8a?pv|0CpritI8fKfchGXhP z%!~j>S~Y+yp&wewI!1kULv&#w^cb>Zch)sD{no${yTL$Zn)6l3P>~2_HGU0TBA0OW+PL5T$tTIIZq5U2By5Qx4p6 zjx{=U`d@|8NxV-^zoBqXats{NBs*V1lyC#CAh{49iL-4~9grNIgz&hQC?-piR3V}Z zgZOHDIRcq!0lvK%<5)|XAL|mEXq3cs1jg3i%-y*)1nuxoIbD^EcXv<=72k2-wU0vF zJ5rD)Htm1X%VB zI;o7x=nT=gG`QTJUVuF?2#Jt!cet)xvY_VvDD&HRDr-fQ_@iz<&QCHm?WE5b^Lp zkg#`$j>PC?Q=sw382Ta4#kjLTu(XY4>xDNua+}s7+8_ z#0-ftZKa%ZS9EJ3+Xl$DH;hQXpK`phuZ$pqXl&wHP_afr15FP$o=b6WmZ_ce!(PrG z!CXum#mfAb8Yf!I-6#$a@?=MvZ-?}J{t031Z*Y~)?&=hm(Ymj&s?RzvVnjxkRa!k;ht_s`bg{^jYF3xz&q;c~t1NpsXhOJ)Yz*ck1RX zi~Zjd20EX0)k@wE`I2V7ZqTDB-&~Dz1ePGLC3TQ& zpsFfTH@D2+dd&A2vQW&GcY_T*NfHj&cm*yj@_%?sGwExaMQ%lS@!bK^ksXsUMMV$`pG zG6nn|(Z4<4W$riCets0F#xd|;58VMirv9|&o6!W+wFBfKtD6zU+TjHGe50=3R+ONO zPUoHa-paNOh|QX0PKrV(Dxis3aR2j6pvoi2CN*!w`ho~(kiZpqVXny_#x0M2cZZ8c zUX2qO&|ujilR$@p8;15Zcz4<9axS}dPj0h6UE!_8qHLph76-dczYk!~g#7tkGdRGLk@ML@jn(&%kKiuL&*weQOdK1-$q^j8E0W7fmF04f ztAw;XPhohq^2^4=)9nPlSikFnZ|C{q5(nxf5qs}@_rxmR|~g`MFLcUe!EI^OMsB0xEf`fom&jQP~ht5;p7Vap!&19$|hGJ~me z!(BpU_U+3Obf>LHc>x{KalqBG2RbfpeG+e5mmfB7Nr@<|*qu1GGPa!6uShB@hV(0= zvg6g!4grPro<@Vi^w5|XR_i9F%F2n!=_OsGt=(C9wVQ;VXBo9^{JtJ8@}K7!VXff9 zqJR9>_ZTCLdc+-HtSQ|^4v5-?Gn;(Yv^pkkcS)dI|H!L)JjD0^!y%)c4%&T%k8jq$ z0k2dK(8cB>mGLj?I$UC1ZOAjn{&4H~cUHhqQ$Oh?_E%ZCr*D5h`IUX~`xbik_m6i` zx=HDOtwJaV1K}sYeS4RXS0j!YVUv!CS4ce+Ndz;H5EgNat5^NOqTk?Ldi>`mQ1@bj z`S!F}Q-;SjtKv=*LB8qgY+05C;ba~r>3OR=Vr@*^PprKB2&N!z=w5z~04BSe6)$dhBIjNZ)R5)Ry6 zJR+fMdzL!M6L41a7=ff$!$%bwD}K>tdHGJLV`@O6sdT_XY37)oi@%nMMG7# z(C8i5K0hooX?A|KB{LSb}+6+41O<>4#t={UVl$$i1ediGJsJ0R@JwL@~L9U!)M$;^dj{E>^dx6#x^DAETZsP5P`f5iU z{Pc8-4mok&=h4dnzdAPwCffHu((0_k8-a4J#r;wGynJF)jw!Y}pC8_yFMG_XI^lag zZWTWRJfE(zYyCur%zsx#9X;(0c$W-oLy6l*wh6vWM1rgggzCG9kdYBmOlbc!V0<|- zPg}7bFpU}e?X?N}U=JHb9RXM~YvT=->M2aFy}TXflYhMW@Cqb^sb!nEqT1_q#(1fd zYXY8qlYi<*N5^<>umpd-QGXphNFpd1>BpqPuFYx`Y;w+}ei#sOKcLOF79VF|qe*+I znsC_L`>S@_-ett16i}2R?PArirunuywJdVL_Ze-X&iu=4@Yj$`ZSyF!M?fW9hYe+X zn<85KcK|(a`}yXreSvu0=gzhBd1%_?VOs;@HyPA+eC%S% zmc6#U*%7B%quu&mzEmsJqAdbY&FP{U5&db!?i@qJ29=e>06>%A*m58?Lkh|7iIp|& zHDAJ`MAjWi9E!b@^zTWb*w?h2evS`7Ld<|X4H;KSGu9ZlXyVq^EXnn3uHI(_LWVyU z)HOdDH*7Bo_Z#YPoZlAv6yv{!T^3)n{31&z=Va`96}F|)7=$RZUzw&<6@88^hTGVE z)!)t6o39wrKc%W#wpL{i3u&xm^IHRH07$7EN}AT_>l)h z;HGYa(LY9rW>ms@)$ui&suAxYw!gZoHPu)vS#Lfe}0NUV=dREN zDn#>DN9yL@E#}?T?ldrH7jLdSq4dZl(H<2##BnsL{|4&6gF8EEJ2AkrbvsE|hOFjU zKY@bTGb!AVi8`OL+CctMO>M)ehR5zuV!Yh3?$aiXW~Ts<8#p^GlYIX&$NllLzyn5K z(_4r--Z8-24IIzmhaPpv;oVQ%TK@XN0zX0^%WQBoTP0qWb)f)BI_sGZ94762U4Cj< z^vrI_^Ps*1X}!2iem!$ZWD~zV5|^QVGwD7c-%#!PmUfpOtnI`5cH*G1J=|vLI1jF2 z3ItJhN30pP>$DHw>kA2BYQkq+RPu#SaD>YNT zqwzcSENVL-B;!>=A~qhf7p-7s^Le&(q=p}+_>P3iS{Zw$m-y`u>JFj*y}P^mx5~4C zpAOB++ATf3_RtJdm^%y`?>`-W%E_#*O}R`=i18NPjyxj?j-(){1GATBW0cfJMz;EY z1{^DJM|xF_zv&l2Hr_@ze4YoM!WVkNi0uW&@%?t9bla;W0|}OaY<(Fq6Jj{jdzojC znpL4JEdK=E?PEsNa?PBb!`wRNvB4p>bafEZ74$TTFtLZRR(uK0Bl8Z7UD@XrL0%$U z4IAH-giot^h>d8QyV)STv-&)M-*o;_fWd!vQIAtPBjDq5cg9v_d`KgLi${iZO@jT6 z3N|_Z9<2Vh(m)7cAHj{)JpkIJ!&pc&NV6p3rPrL}Pe4i*iwbcB1pMV|CNmmo2fEN? z)cM8oTuC~zx!l~t$Ne!e>ntd6?x*Jv(68!{|sqM zL5c!d?_;>nD5`&wOVxp~fXY`#G2e6ln$Gv_xi_-GdA5*e7dBpt#H6Ih=V69KE!ZZv zJ?u!#k3P@qiEuY0Ju@RM^PCDrkXqy)F4h87Jb8RwPjv)>4&CV3u;4?r6GVvFu9k^W zKOgj(Jn0QST`-20VAy;gGk(J1|rXIWF(T3&yqe$VXwYumUmbzQm zn4MsA)^fc7&*d)?DHD;jlMW~N#QC((HOE6Y#)v}oD?5>i&p-4(cYpEir|G;>*9lpe-4h`jCN9K_%AM6KW9LSOS1 z>eX$ZRdtAm%XB}p9{d|^NgO-34Jz+-z8^Us2dTE@X9L2|`E7%@kTiq)E(&(Y5661@ zJchg5hQjJ+y0oet>(!0CX7Y?8gfc;Xr3k9I!(`JMEhq{{cv1m!pd)iFz!v{1PNd`8 zS)j2#Lt_671{{=4mLAI_?{j@xS2+4gkx$=s?knf1PH|S7>Yc*ky5YzYx8T!uhd_wm&8#$$R&SI@%=Ayq&lE) zx4#qN_zK)R@*MT#AAOg`{_`k@|L&{N!)f-G3<2COX6>*L85WY`DYzjK+qfkMckAq1 z5!EIjD@$c6XKQEwBx)Dz58j0n?x-q!^j{2g6fAo=`M?Dj5mBuj=v!9^qLH3iB6{7m#ZTeNUwKb%ERpu)LX$AzOJys!~v^0F6X zuMO$0S&oMsgO$idmb-U1(8=93Fz09Xzf@MIepu)y@W)SYIEUjNQc>@qU?h8Kf>Vmc zYkZ*=VE-=cj$Ty$OJgEww+MmXa!=)^=ab!@R{XPJu!iRVC~87@FURJ8rlK@9T{V~e z!FL42 zDsX*&{eohvxJ-Tss8v&XCf5cYC)jN7x6>J3Uzu2(0M4?GqQIJ|M#FZ>>^R*RmyA;1 zKcFg|=PhS2zXuI|^yStl>qAU(iE$pnjCNF}VKT^>u3OJz>4IxZcThT+a>2@Q6?p-C zxr;{VWMW#5F`jcE(*9UVHZfIkrqSy^@0)bGaSEnKFzRKwf(O! zdw0rb7=lNbiAGz!exM&{m)xn|In{D%YwV~-B(G}+iPyk_*#;LE;D~^Z@SA5B%p38# z<GhbKG6SRV_OW96@dRRGCDYsPf%@kbi(DQ2}jyb&QCp;B@0)8kj8@bC&Kdhw>= zHUCpTw!HO%F4B3O>UcNSiyVTE_8~ypS{VVwqDln!)4}7u&{OH1Hr!otFHJr*~f>z(%^T1;V~XJMI^lU)y(eczPyOI~FAK_}J1eQ1Mn@FZ?C2niazJt+i56V~)m64ossXL)$KrXTLvUxKpKYw3t!G!z;+9&^> z;V)@^N2LsK$%Mp0o9p|ypqf4L?(HF$dNe1zv?=#F?kXu+r}CCSe5mOFTZ({+TW8%I z`D;t}yo2d0!I7rPyTl&n_!6r?GQJl64|H!+#ni0Ys6FK<6y(XJQ^j_Jk59JqaP{)F zIzz8**V~ElMZl;YQsVOzQ*%^>4~{1H+wYl?p0K019uHbZ9j_hMAS})0^BC`E_4!-C zepo%UX4SNq9{k$$Rp*3R_FM%I9kEC$sZrscNK4AvnU}AE(f=0y>gYy(s{X1MiQQw& zpnvT`Ma~KQ2AAixO_N5xwv*soCfHLanN9cp4B{x}l6BH^cBR7hkd4?`e3x(gF^$Or zry1lJXL_ln^6;C=MyQ@8!TTI*1h7+oc=JSS`!@UtEzOx*Wvt&Og)9Nq9<1qx+jr7%kTHWGCUZe2Pi+<157ApuPxZ!<~S!c@uzZN7Z%&(cR!Uhl4W4yvlnnO8uIS9s z1n_;P63`QFl(JOZ!quK}rT*n-6u3`dVyJp2j6pF%!te#AvG$!NxyK zB9_1%Y1g^rKH+MHV1byjjeZqN^v|5Q)p!ulewRLCmSR{{cDFHv`=w)~`xo{_gT9e? zf0TCaSjUpMQ0g{2ReUopdU<%Y;kG~Ccj5=YO&$X(j(uyRTyu$4(cSAMkc#TYr3I2wIdf=d` za})6?LHL{G=1)Eq<6#)%{WlpGc&b%3i5|EQ|LonH$4OdWltJ}h^=}P-g8D#`^Rk{N zodMz7kd7rdp|?_N4*G{T>57#`9siPl>e)}?gFzJcV-TF7yg4jxu>i92S%U~76!oR3 z`Fc^HLP>VLLOEW0(tFf%cd(ly|TkqTy)oNEL~~CEzV! z114U@DGZn{>TZKzNd|M0Z7x>_y3g=ER&A<31n+y~9fK#J#SG!rNkGR<=yjVFtc?b6 z4bMcRsSe=?c+vSFKF&zl5P|UVadG>^&+Y=fMN<(O=pSxxUP1w1C^liB1@A<;9tH+ACIm(Fm`u3_N|I3trTV5V00+ngF zwqfIsS?T8d1xH33T>Wgp5Q`;)s_MjUGu{lBJ;{}w3l~-&YZ<@l4K29c;aF@}E zC$YS+EPBt*hOa)IhY9DpF|1#z_I100;cO=;yqN#5X*STy1 z(oVCe-qer?k@_V(h{}BS`$m=pI{J8*NcUqq?DILb&)AhU*be)_D|ePdyO8I-BSF{G z#A$2_p#PPANR)h<_|)?q&C&LqSb9G)#pnxwNhaSWIT<*ms!rk{_zCCPW?$**P(o1` zRqC)Xe4;9q0jcW3Agr(F@2F4bZrz{3yNT>O&?CY9xH`WzZf9lE&3o?^RGu*mcc1@9 zYrGo&4>6ur@fq%iWchFV`+AyJY*nXtVv>DU*ZdSfDKiVG`h=L5!tNyfqH}Q_E|VFB zK#+#4QLwdzSGE{BjuH-3AI?Bysvtwj@E=W07*X9V^JvS)5Z->98@lv04Uqb!J$!*z=o`o~k@;$1EC$_PbUk#0b>}=mzc(VJnTk#IdLSo^| z%YnSusNA}#UC4qlSoMoWbSDvO$z7Qzw6+kT5-LSb#)7T*TNJ`+=pU6>L8s}On&5wFQaj@?9HL3x z7#1Gr0re?j2%tO7Z+{N{kEXYbYOCS8c7qiuF2&v5-Jw8ncel2 zgEsw-{jP3PpnrJ7H=O&MD$cKHhHt(zi{9W<>psnE1@Na)#sft;BF+L_FZ?RZ#)J8vu&Ydv{5IpZ;s52x^0;Xg!XCi#SbV zf@wvjg$-exp72(C^PJm1W=3ptv&OJl?A_*AVb)7Y^wS8RzEkDHJ)4~Q*kg^3S1N_Y zQRX}(#k|LXHr4nmJ;wN<@{AT$O7?n9M9|E?ZOq@twMWeq7a>sG^Ct<%Ob`P=rP=lI zY3;=c$k-$Q{aaLPA*4dubgHW9J?q>gkc$QyirFmID_S*t+=?oCV8f|YPI&Zd@2(W; zy7N?VrOfS*5Xv1(LGrrEDq8gk?~1e4lCe{+m?vU_>F|Jvl0VZhN2KbD)Y1U2wj3SK zTsPMfuDZm#Gh+^yZr>dh3A^`;d~#SCxd=3|s!6GyB8JS8Qy&1MLJLk>@E^_E#k%_= zm%S9)@&7FyzyANhk$p-bhsEO3ei% zQiGxjjiuCPW2NEW%*)yWlECp-WN=FV97VEv+F!&O3rWBHiY^SJLC{(>j1rnzKVtr% zgiwC_i#}MHRXA~UI9zDtsL+f)vJA5NYpDJI^+=gI=As@Bp&7qN#$KjbsRjL(lNn2Y|QMRy=$Yd&u%yCI?k`G^{>1ij!-HDl6P&dK3~Xz z3PvP%j1DT|_6EGqZrn?^U-WoBB>y_$1i04h>#3A+x&;I!a!JN>PWrN5_A+?RrxPE( zbLR|7tD{-O?uEG zobs@BT3cOy!jPGCXBoaHL~fDmNPlRBT*y7&ia#SIi2J6(Je(WvjQ05PJhx$NSAlOj zc@#dP{kRZN3dzE4K7F}>R9_wZh%aN@@rYSet!GoIj z-QLkr4F8EjaN{{9f&XZ?Y^p8y)W!9P#|pLslQX{2I?^_LEGxebx8A*W>}?~`R+OsJ zw%|`|&*Ms8(;UI?+Qpwm@r9Y@+!4sZ<~AbSZ$F7X(~jF>S(a5=kjx|4RTYe$qA8xo zfd$yCZ{rKT@AVPUNt{Tk26JwkL1@VsxQpz~#D@d?%fm{9rfe;_os1)rm-T^y&={6Lu>(_ zePmunw4Lu-3rBma)#yVG4A;iLwW0|Bzm~w>*>3udh;w`64j8s()hjuCQ0n=^OCD-| zYyDDzAeE-19krB;unJ+&S$EReTrB^dpzBnM!fTsN_0T+yqe$63oZ&GeZ(-r4c;{}~ zzgYHK-1CxOyxx_!E}ruq51)shv~ES;HulNs9li7omgUR}Zd|exy+$KgQ5*gt{!R2Y zM5>Jffjcrm1X>^20hX}&3R*=bnQ6_+8ba%A=UF>leSOSCYoYJVBZd4r(whdX!5MBg zlzC2GDmX5r-8T#5&>Wba8lxwbfM;@$s26GbM|6tsxnWa+dI5C7USKsuK89`b?AN8E zMYh}Ry6aQ&vc5TSRAnwR#N8(s)8YjLoC*K~!7b|5Uznktst8KeU4;+BJl|!nuO2=@ z?gw+HKR%KqV*}B3HK>l&G|?X~3?m)ZcQ`9yuiQtPx!DC$@c_D@?c~F-ucEUq03CS?X_`3w(;Kcy5F_< zn+PGc_r*D1U7#c*)V>t;gn7wbqLBKau^3BRKk{S(K~NX%KntxU|MOM%aW0#>s97Pb z1cpr(N3^rH3%JFS?R}fBQi6oW{a9Uzp1z2c^AqaC(yNaP(sq!hoze1ljX2moiKOCB zK?)h60|r!Ay?tOPq~ytVat#Bm+b|Q88l{CDeNO|5tfHoG>I58q zH<963RW(LCCApufZkD6JcTkG$$8FrH2z@ri~mbnpzda};XPf+bNn<5B{{ zX{12G1U}XJpZ1#kydWkqOHoS{L-h{^nJFSxTwGIG?E_BW8n1ee&8>f`d!PBKb9eFKDjSXV3jb|DIcE{){syXxF~-S zs8NJ?mj2+hGDiFm^Z*jr6S}ZM-Wg67GSEjM%HMw`s#2k%h0TU`QNeWdc~`#Ks1MBu zBI}mkSIeYg3&_x4sMZo?6!_ms#I-x>=bhrs4No^-2^6F}BOI=^2z=|tX-)XgQV)@z z!2NF=AQa#|{2jRb>ds!lM_Kl}1j@=tZPT%(zxYyEcWIB4$Td@hWu&Ht1gR_I*y`9{ zI*tK+a8Q(bV7K8o;bU4Dg(%wMO3*&Ym(TAndBY&d*!{hORK8hyy7YMRx+NTrk~-?q z=&m}}si_Wpsl>`gfw+m7v(Xpg_n`D7+GB$pB=bW5d)Ubo%*D?z6sMVLXw&>orU-jE zn|B?eMsB-Y3I03S!)~-a{-A(g>v8DoI#Lck=2|cmM(_-pc&DJezAQO^MbJjG%@9Cw z8^%%3_rvgss(H=II0KufN+M}M=%&x~Wo^||!?z2H+rPB2=pUApS-{za^34AxeH7!j z?MuE4S$Dp6ceB?62a->W+a0b$mf{RN#TCZ_2RsF4TC;^yO_UBaGL`_Kj_Z#=t3wRm zB5)!05co()BavR^Ye{&jSJmCozgT3_l-l^kulC|A@SoLF6pCbVb!w-^)aTtb&;f%4 zL#b&?oI*_2H5l$;xKA;030Ehgxx?M9`yNn%w0mIbec8}^81%;9|9lbTjgy#Fs;UF~ z_Qtx|zX`fRR8$CbkqINj!W|YNkhN@lXXtnNHjW1iM0GC{>fw>>bY~6M?cpXQT(3-_ zBdO*OJ-cev{d2H;{P*rhvd&-~0jrf7qGw>UJ^k3aw6HdrE$goR5_vlW&9()5k$+JD zxP^4?XecH5>EP4T4*4rk?Cx@7(BJMciofSRX6d$B^YMj*3??^zJliIC_?#3Qif3qu^kzk?4fc{xXtH6XruFCJ0Di!*qr6%$lrZe{ydhm0PrO-S-0y> zkVLd2LH@g=*FO_G*Nbb9YG`+X!Gc%j<$$q8%8asQY}8EAUaXjo38GSH(m-TCSG5pD z;^)jytoQ^z!z?Ob-|t4`m~1w4q{3B^p%P*aLy*(Zq$t%5>?_h~NTDL&0WoFErob@Zkj?8TIq8~>vB)Gp z`5EW%tOf=W`@i83{C~rd^ZvKWsRQ!(&~Y9AZL^1FAuR>T2l`}C2wVZu0CA^;Dm|#a zczVq<>ez^JGvr>2Y0^GI%E$m;F}XMf@x-+(Pal)+o|_+dAC$!p{&@y726wT2d<3@``5c+H0Nor4ZBa?Rr5g5E*9!{pn`vvz@5B7fu~)~AZ~D*o7H zdd^X)w41cbtp$Zq^Mb8`k=KV~g3~D>iM0%;@wAR^B0eH+_q=_oyyE|E0YB0q5cikN zR!nh~9U+ofN9=?s`aq=DatdDDXkQY|XuPHG1+`+FbSAIZR_2%oxcmO(kA4UY5Zz!V z{k9@d5%b;05>PhCYhHYpeB%l4E%P-X5ImhFe8aJUHMe$SCzC|$Ao z;g2<~x{8=&sdLIBZ^%u?m|ump$(AmB{}_$Me$B7QuyVRa7|E}ZNM(Dt*k`j2oZ=C2ZYwa1Y2lFa=Ssau%Ae7~yoqT8FXazb zm;`U-Sk|Dc_>TOH!6`E>Q&V8+q9K1tN9W^+)YR>DTVCPYcZWg~7%6d>-)tfT9f*V4 zhJOb4H=jr=$ubU84@vie#BFY`U&T(wmjs!LDRDz(*ETc%g|~Lp$jXJ1$2pcbev-jV zeJ(Q0x)hPh{sIpX<)`nkn$|3ni3?zPf&&eOozI`S^AX7KpJE>F)xsC&j&?;9*1-R8 zahdnrp#Ds2y&ul5ETHrv`x6OslWp8tBL|SZkt@uv@exSy^_Be3Fg_t9ZZr+iTxE7k zpIlYvYb+A>Zm^@ESXU+X?8XZD^=E-uG3FdVZp#}>*Y=CkUM}dZG;n|nYpubam*4`_eLPC+h8cH^K(&-b_VsQ}iI4R&Kh4VW^zC)@(>Pd# z)hwbd7HGq{4^`dzdHKy7u~Y-0wcpd%zsP;jx^GCycXi3Zm`RwcT9ptGWUHdJ$)z11 zJd9;4QyLB?iqUDPMl~kBO@7XVK2{7=TG-WQ)$?0jq*bHlc>fwa4wKxRg`4-J1@y+y zp+H*1FA46$m<-c0V=Y?dlP|k&Xzy*!!{RlIEScEXgulJ{<=|~o1eFzJlYT^ubpISk zebf{ZI71japRR;+&Z~t^V5HOKH5PbEHG}s(w|>Abwbqd$P)pxu7Zx=`?;@rgdGLP1 zx#-as1t^0_eOq%FiugCKODa~_F9xogEGuLBMMzOtl^$ z8s)nYtH0I!vfR2)@BpU6|64`i|Hm~xtqss@Lv|CR9>*}vAq_AN3ypg~RUvo@JJDW) z(>Kq=>u%@X1SV)gEb}SyqcC#zdF+FZLQ6(elj!js{G*=t+SmsgSfKSN7J@dFCT#># zn#)RlQ%)?>%fCF<;q*|71)L;+<2=#0pjw&M_e&Zce#}QHypaBQLX!siXE~cu0N?87W9z$r*W(TvX8a#fN^1oJ;m4ZH1`*O zFNbLQz{>R)d1EsRmjI;yiA$M?Phhi+r4RB^H>cN71su-+=*ScDM2q7D8xx}(S52|C zkaPuDtU-<=*ihD;mnfT;41hvRk~FX1htC-=1Fy*_cNLooFB25y0h4A^oHv&`xF0^V z^$Zvc70@bM*Ywh%Tc;Di<}pXRuOiDRnn=dU_#jBwawS?l5q$oxL6QM!mI{pSP|TgU|8X8s4Uh<$Sbc!y|qU`amnSMJQSN>^{X_u}gM@zyQ2E&AG?{a%E! z4JoV`LFU+~Un|#4r`*N}1|P!G-7xs&Lq6!+kNzRFVWvGV_7UG*kQQF1=^w3!CpJ9B z5h9qF_e03TV;dR54vh+1US`xuvPm@+Zlb%R38k}uEjG}gJ_uhuAwuxvdGDs6r>2qG z?#Pd@oYvc?H3bpFr5AHtQ|Yal(w7q*pIY-;i=k%*C)uK7UJ4o5eOf{(Jy^A>+g+36 zW?Rp70pk#^^U7F49ZwAAl(Ei?R-EZbClR=R@dnWl@lh1N^PktQZ*0w7Ab_8GG3sW! zBpMpz+uNl}<_lMCW}DUva=Onqne%z#T&VpcaB?7cgmN&Lz;xVopB(1=jT!m*Q;HfgbmAq9uJ{LHXW&C|`NlOT@olY8GrmO2_ty_|6P0@ZAqAD*E<_|IX}98En{vImc~g$>An=9(Di;^xF)h zKsC2U;N#y$B!8C|TUNuqHQxO<@%1-On@IJw&%j?JmXerAN!d0*!2=!D15%=&}1K(Cj0puUZJ{)v#Tp-4oTrM5`vqopV}D7%GE&qZ@48Vh z(7&|XF()7l@nMUeSSg-6RCb&-`I zM)u;dMc70uF2f1!Qu1n(jD4{(bz0KOp9-@&aT``AmTjy>ZXX04JDX)ol3Xq=g=xwx zTd4B^uA)_nbddHVR5-okZ{1EQp4IbvSd2nuE7fSfSTnb+wg zrh3VCaDoYKh-L6QK{2aa;4dS5NJALKj7*l@n!?~s0Jztf{4}S9+}361nmbWlY}Fq% zt&J*F(u~qD()ljT{{;O&EhG^KTROA*&?4P<=E8`mcek)vzQ|t@S68!Zm<&0!D&;5zXQw+%372X{;h? zPMyb!-nacu`;!=*DNgH;w9Yt<;_zSbX3Pw?*d%tH`hP}j&&q8}Z8$ZVxgZDJjEL_t zCqU>8^bHe-jFDaA{CFJK@5xm@YO;WJ?#}-NxS1byFVT7L9UL3TG*wajk;+zEDNOzRDY_HVY)8vRkx+d-#84{=X

+ Build + pypi + versions + license +