From a39fd7b822132539109976880da3b276b13d80cd Mon Sep 17 00:00:00 2001 From: Livio Ribeiro Date: Thu, 8 Feb 2024 13:48:17 -0300 Subject: [PATCH] Feature/extensions (#38) * add support for extensions * change jinja support to extension --- .github/workflows/test.yml | 32 ++- docs/extensions/jinja.md | 63 +++++ docs/extensions/overview.md | 38 ++- docs/extensions/sqlalchemy.md | 8 +- docs/templates.md | 79 +++--- {src/selva/contrib => examples}/__init__.py | 0 .../configuration/settings.yaml | 2 + examples/database/requirements.txt | 2 +- examples/htmx/configuration/settings.yaml | 2 + examples/sqlalchemy/application.py | 30 --- .../sqlalchemy/application}/__init__.py | 0 examples/sqlalchemy/application/controller.py | 28 +++ examples/sqlalchemy/application/model.py | 15 ++ examples/sqlalchemy/application/service.py | 43 ++++ .../sqlalchemy/configuration/settings.yaml | 3 + examples/templates/{ => jinja}/application.py | 0 .../{ => jinja}/configuration/settings.yaml | 4 + .../resources/templates/index.html | 2 +- mkdocs.yml | 1 + pyproject.toml | 25 +- src/selva/_util/import_item.py | 27 ++- src/selva/_util/pydantic/__init__.py | 3 + src/selva/configuration/__init__.py | 3 + src/selva/configuration/defaults.py | 8 +- src/selva/configuration/settings.py | 3 +- src/selva/di/__init__.py | 4 + src/selva/di/hook.py | 37 --- .../data/sqlalchemy => ext}/__init__.py | 0 .../selva/ext/data}/__init__.py | 0 .../data/sqlalchemy/__init__.py} | 8 +- .../data/sqlalchemy/service.py | 2 +- .../data/sqlalchemy/settings.py | 3 +- .../selva/ext/templates}/__init__.py | 0 src/selva/ext/templates/jinja/__init__.py | 21 ++ src/selva/ext/templates/jinja/service.py | 65 +++++ src/selva/ext/templates/jinja/settings.py | 33 +++ src/selva/web/__init__.py | 3 + src/selva/web/application.py | 33 +-- src/selva/web/converter/from_request_impl.py | 2 - .../{templates/template.py => templates.py} | 0 src/selva/web/templates/__init__.py | 1 - src/selva/web/templates/jinja.py | 102 -------- tests/configuration/test_environment.py | 2 - tests/configuration/test_settings.py | 3 +- tests/contrib/data/sqlalchemy/test_service.py | 226 ------------------ tests/di/{fixtures.py => conftest.py} | 0 tests/di/test_create.py | 2 - tests/di/test_define_instance.py | 2 - tests/di/test_dependency_complex_graph.py | 2 - tests/di/test_dependency_loop.py | 2 - tests/di/test_dependency_options.py | 2 - tests/di/test_generics.py | 2 - tests/di/test_interceptor.py | 2 - tests/di/test_iter_service.py | 2 - tests/di/test_lifecycle_callbacks.py | 2 - tests/di/test_named_dependencies.py | 2 - tests/di/test_non_injectable.py | 2 - tests/di/test_register_decorated_class.py | 2 - tests/di/test_scan_package.py | 2 - tests/di/test_service_class.py | 2 - tests/di/test_service_function.py | 2 - tests/di/test_service_generator.py | 4 +- tests/di/test_service_generator_async.py | 4 +- tests/di/test_unknown_service.py | 2 - .../data/sqlalchemy => ext}/__init__.py | 0 tests/ext/data/__init__.py | 0 tests/ext/data/sqlalchemy/__init__.py | 0 .../data/sqlalchemy/application.py | 0 .../ext/data/sqlalchemy/application_named.py | 21 ++ .../data/sqlalchemy/test_application.py | 18 +- tests/ext/data/sqlalchemy/test_service.py | 128 ++++++++++ .../data/sqlalchemy/test_service_postgres.py | 132 ++++++++++ .../data/sqlalchemy/test_settings.py | 2 +- tests/ext/register_extension/__init__.py | 0 tests/ext/register_extension/application.py | 1 + tests/ext/register_extension/extension.py | 2 + .../ext/register_extension/test_extension.py | 19 ++ tests/ext/templates/__init__.py | 0 tests/ext/templates/jinja/__init__.py | 0 .../templates/jinja}/application.py | 0 .../templates/jinja}/template.html | 0 .../templates/jinja}/test_jinja.py | 2 +- .../templates/jinja}/test_jinja_render.py | 6 +- .../templates/jinja}/test_jinja_response.py | 5 +- tests/util/pydantic/test_dotted_path.py | 6 +- tests/web/application/test_application.py | 4 +- tests/web/application/test_middleware.py | 6 +- tests/web/converter/test_from_request.py | 2 +- tests/web/exception_handler/__init__.py | 0 tests/web/exception_handler/application.py | 21 ++ .../test_exception_handler.py | 23 ++ .../{test_excetion.py => test_exception.py} | 0 92 files changed, 838 insertions(+), 566 deletions(-) create mode 100644 docs/extensions/jinja.md rename {src/selva/contrib => examples}/__init__.py (100%) create mode 100644 examples/background_tasks/configuration/settings.yaml create mode 100644 examples/htmx/configuration/settings.yaml delete mode 100644 examples/sqlalchemy/application.py rename {src/selva/contrib/data => examples/sqlalchemy/application}/__init__.py (100%) create mode 100644 examples/sqlalchemy/application/controller.py create mode 100644 examples/sqlalchemy/application/model.py create mode 100644 examples/sqlalchemy/application/service.py rename examples/templates/{ => jinja}/application.py (100%) rename examples/templates/{ => jinja}/configuration/settings.yaml (57%) rename examples/templates/{ => jinja}/resources/templates/index.html (90%) delete mode 100644 src/selva/di/hook.py rename src/selva/{contrib/data/sqlalchemy => ext}/__init__.py (100%) rename {tests/contrib => src/selva/ext/data}/__init__.py (100%) rename src/selva/{contrib/data/sqlalchemy/hook.py => ext/data/sqlalchemy/__init__.py} (76%) rename src/selva/{contrib => ext}/data/sqlalchemy/service.py (93%) rename src/selva/{contrib => ext}/data/sqlalchemy/settings.py (97%) rename {tests/contrib/data => src/selva/ext/templates}/__init__.py (100%) create mode 100644 src/selva/ext/templates/jinja/__init__.py create mode 100644 src/selva/ext/templates/jinja/service.py create mode 100644 src/selva/ext/templates/jinja/settings.py rename src/selva/web/{templates/template.py => templates.py} (100%) delete mode 100644 src/selva/web/templates/__init__.py delete mode 100644 src/selva/web/templates/jinja.py delete mode 100644 tests/contrib/data/sqlalchemy/test_service.py rename tests/di/{fixtures.py => conftest.py} (100%) rename tests/{contrib/data/sqlalchemy => ext}/__init__.py (100%) create mode 100644 tests/ext/data/__init__.py create mode 100644 tests/ext/data/sqlalchemy/__init__.py rename tests/{contrib => ext}/data/sqlalchemy/application.py (100%) create mode 100644 tests/ext/data/sqlalchemy/application_named.py rename tests/{contrib => ext}/data/sqlalchemy/test_application.py (52%) create mode 100644 tests/ext/data/sqlalchemy/test_service.py create mode 100644 tests/ext/data/sqlalchemy/test_service_postgres.py rename tests/{contrib => ext}/data/sqlalchemy/test_settings.py (88%) create mode 100644 tests/ext/register_extension/__init__.py create mode 100644 tests/ext/register_extension/application.py create mode 100644 tests/ext/register_extension/extension.py create mode 100644 tests/ext/register_extension/test_extension.py create mode 100644 tests/ext/templates/__init__.py create mode 100644 tests/ext/templates/jinja/__init__.py rename tests/{web/templates => ext/templates/jinja}/application.py (100%) rename tests/{web/templates => ext/templates/jinja}/template.html (100%) rename tests/{web/templates => ext/templates/jinja}/test_jinja.py (97%) rename tests/{web/templates => ext/templates/jinja}/test_jinja_render.py (79%) rename tests/{web/templates => ext/templates/jinja}/test_jinja_response.py (91%) create mode 100644 tests/web/exception_handler/__init__.py create mode 100644 tests/web/exception_handler/application.py create mode 100644 tests/web/exception_handler/test_exception_handler.py rename tests/web/{test_excetion.py => test_exception.py} (100%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b809d56..f3725bc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,21 +9,35 @@ on: - pyproject.toml - 'src/**' - 'tests/**' + pull_request: + branches: + - main + +env: + POSTGRES_DB: test_db jobs: test: runs-on: ubuntu-latest strategy: matrix: - version: - - "3.11" - - "3.12" + version: ["3.11", "3.12"] + container: python:${{ matrix.version }} + services: + postgres: + image: postgres:16.1-alpine + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.version }} - - run: pip install pipx - - run: pipx install poetry + - run: pip install pipx && pipx install poetry + - run: pip install asyncpg - run: poetry install --with test --extras jinja --extras sqlalchemy --no-interaction - - run: poetry run pytest + - env: + POSTGRES_URL: postgresql+asyncpg://postgres:postgres@postgres:5342/${{ POSTGRES_DB }} + run: poetry run pytest diff --git a/docs/extensions/jinja.md b/docs/extensions/jinja.md new file mode 100644 index 0000000..d271db6 --- /dev/null +++ b/docs/extensions/jinja.md @@ -0,0 +1,63 @@ +# Jinja + +This extension offers support for Jinja templates. + +## Usage + +To use jinja templates, first install the `jinja` extra: + +```shell +pip install selva[jinja] +``` + +Then activate the extension: + +=== "configuration/settings.yaml" + ```yaml + extensions: + - selva.ext.templates.jinja + ``` + +## Configuration + +Jinja can be configured through the `settings.yaml`. For example, to activate extensions: + +=== "configuration/settings.yaml" + + ```yaml + templates: + jinja: + extensions: + - jinja2.ext.i18n + - jinja2.ext.debug + ``` + +Full list of settings: + +```yaml +templates: + jinja: + block_start_string: "" + block_end_string: "" + variable_start_string: "" + variable_end_string: "" + comment_start_string: "" + comment_end_string: "" + line_statement_prefix: "" + line_comment_prefix: "" + trim_blocks: true + lstrip_blocks: true + newline_sequence: "\n" # or "\r\n" or "\r" + keep_trailing_newline: true + extensions: + - extension1 + - extensions2 + optimized: true + undefined: "" # dotted path to python class + finalize: "" # dotted path to python function + autoescape: "" # dotted path to python function + loader: "" # dotted path to python object + cache_size: 1 + auto_reload: true + bytecode_cache: "" # dotted path to python object +``` \ No newline at end of file diff --git a/docs/extensions/overview.md b/docs/extensions/overview.md index d1e4755..905cd46 100644 --- a/docs/extensions/overview.md +++ b/docs/extensions/overview.md @@ -1,7 +1,39 @@ # Overview -Selva provides extensions to integrate other libraries and funcionality +Extensions are python packages that provide additional functionality or integrate +external libraries into the framework. -Current provided extensions are: +Current builtin extensions are: -- [SQLAlchemy](../extensions/sqlalchemy.md) \ No newline at end of file +- [SQLAlchemy](./sqlalchemy.md) +- [Jinja](./jinja.md) + +## Activating extensions + +Extensions need to be activated in `settings.yaml`, in the `extensions` property: + +```yaml +extensions: +- selva.ext.data.sqlalchemy +- selva.ext.templates.jinja +``` + +## Creating extensions + +An extension is a python package or module that contains a function named `selva_extension` +with arguments `selva.di.Container` and `selva.configuration.Settings`. It is called +during the startup phase of the application and may also be a coroutine. + +```python +from selva.configuration import Settings +from selva.di import Container + +# (1) +def selva_extension(container: Container, settings: Settings): + pass +``` + +1. `selva_extension` can also be `async`. + +The function can then access values in the settings object, register new services, +retrieve the router service to register new routes, etc. diff --git a/docs/extensions/sqlalchemy.md b/docs/extensions/sqlalchemy.md index 9721a14..d1137c0 100644 --- a/docs/extensions/sqlalchemy.md +++ b/docs/extensions/sqlalchemy.md @@ -10,7 +10,7 @@ injection context. Install SQLAlchemy python package and the database driver: ```shell -pip install sqlalchemy aiosqlite aiomysql psycopg oracledb +pip install selva[sqlalchemy] aiosqlite aiomysql psycopg oracledb ``` Define the configuration properties for the database: @@ -32,8 +32,8 @@ Define the configuration properties for the database: url: "oracle+oracledb_async://user:pass@localhost/?service_name=XEPDB1" ``` - 1. "default" connection will be registered without name - 2. Will be registered as "postgres" + 1. "default" connection will be registered without a name + 2. Will be registered with the name "postgres" === "application/service.py" ```python @@ -111,6 +111,8 @@ data: default: url: "" options: # (1) + connect_args: + arg: value echo: false echo_pool: false enable_from_linting: false diff --git a/docs/templates.md b/docs/templates.md index c8b16b1..daa68d6 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -1,12 +1,12 @@ -Selva offers support for Jinja templates. +# Templates -To use templates, first install the `jinja` extra: +Selva offers support for rendering html responses from templates. -```shell -pip install selva[jinja] -``` +## Usage + +First install the [Jinja extension](./extensions/jinja.md). -Template files are located in the `resources/templates directory`: +By default, template files are located in the `resources/templates directory`: ``` project/ @@ -67,40 +67,45 @@ the result. ## Configuration -Jinja can be configured through the `settings.yaml`. For exmaple, to activate extensions: +Selva offers configuration options for templates. ```yaml templates: - jinja: - extensions: - - jinja2.ext.i18n - - jinja2.ext.debug + backend: "jinja" # (1) + paths: # (2) + ["resources/templates"] ``` -Full settings list: +1. If there are more extensions that provide templates, the backend property can + be used to choose which one to use. +2. Paths that will be used to look for templates. -```yaml -templates: - jinja: - block_start_string: - block_end_string: - variable_start_string: - variable_end_string: - comment_start_string: - comment_end_string: - line_statement_prefix: - line_comment_prefix: - trim_blocks: - lstrip_blocks: - newline_sequence: - keep_trailing_newline: - extensions: - optimized: - undefined: - finalize: - autoescape: - loader: - cache_size: - auto_reload: - bytecode_cache: -``` \ No newline at end of file +## Extensions providing templates + +If you are writing an extension that provides a `selva.web.templates.Template` implementation, +make sure to check whether the value of configuration property `templates.backend` +matches with your extension's `__package__` or no other implementation has been +registered yet. + +For example, the function `selva_extension` in your extension could be implemented +like the following: + +=== "my/extension/__init__.py" + + ```python + from selva.configuration.settings import Settings + from selva.di.container import Container + from selva.web.templates import Template + + + def selva_extension(container: Container, settings: Settings): + backend = settings.templates.backend + + if backend and backend != __package__: + return + + if not backend and container.has(Template): + return + + # ... + ``` \ No newline at end of file diff --git a/src/selva/contrib/__init__.py b/examples/__init__.py similarity index 100% rename from src/selva/contrib/__init__.py rename to examples/__init__.py diff --git a/examples/background_tasks/configuration/settings.yaml b/examples/background_tasks/configuration/settings.yaml new file mode 100644 index 0000000..983208f --- /dev/null +++ b/examples/background_tasks/configuration/settings.yaml @@ -0,0 +1,2 @@ +logging: + root: INFO \ No newline at end of file diff --git a/examples/database/requirements.txt b/examples/database/requirements.txt index 0f3a881..b565245 100644 --- a/examples/database/requirements.txt +++ b/examples/database/requirements.txt @@ -1 +1 @@ -databases[aiosqlite]==0.6.0 +databases[aiosqlite]==0.8.0 diff --git a/examples/htmx/configuration/settings.yaml b/examples/htmx/configuration/settings.yaml new file mode 100644 index 0000000..63602c6 --- /dev/null +++ b/examples/htmx/configuration/settings.yaml @@ -0,0 +1,2 @@ +extensions: + - selva.ext.templates.jinja \ No newline at end of file diff --git a/examples/sqlalchemy/application.py b/examples/sqlalchemy/application.py deleted file mode 100644 index 01ac46e..0000000 --- a/examples/sqlalchemy/application.py +++ /dev/null @@ -1,30 +0,0 @@ -from typing import Annotated - -from sqlalchemy import text -from sqlalchemy.ext.asyncio import async_sessionmaker - -from asgikit.responses import respond_json -from selva.di import Inject -from selva.web import controller, get - - -@controller -class Controller: - sessionmaker: Annotated[async_sessionmaker, Inject] - other_sessionmaker: Annotated[async_sessionmaker, Inject(name="other")] - - @get - async def index(self, request): - async with self.sessionmaker() as session: - result = await session.execute(text("SELECT sqlite_version()")) - sqlite_version = result.scalar() - - await respond_json(request.response, {"sqlite_version": sqlite_version}) - - @get("other") - async def other(self, request): - async with self.other_sessionmaker() as session: - result = await session.stream_scalars(text("SELECT version()")) - postgres_version = await result.first() - - await respond_json(request.response, {"postgres_version": postgres_version}) diff --git a/src/selva/contrib/data/__init__.py b/examples/sqlalchemy/application/__init__.py similarity index 100% rename from src/selva/contrib/data/__init__.py rename to examples/sqlalchemy/application/__init__.py diff --git a/examples/sqlalchemy/application/controller.py b/examples/sqlalchemy/application/controller.py new file mode 100644 index 0000000..cf9f4e8 --- /dev/null +++ b/examples/sqlalchemy/application/controller.py @@ -0,0 +1,28 @@ +from typing import Annotated + +from asgikit.responses import respond_json +from selva.di import Inject +from selva.web import controller, get + +from .service import DefautDBService, OtherDBService + + +@controller +class Controller: + default_db_service: Annotated[DefautDBService, Inject] + other_db_service: Annotated[OtherDBService, Inject] + + @get + async def index(self, request): + db_version = await self.default_db_service.db_version() + model = await self.default_db_service.get_model() + dto = { + "id": model.id, + "name": model.name, + } + await respond_json(request.response, {"db_version": db_version, "model": dto}) + + @get("other") + async def other(self, request): + db_version = await self.other_db_service.db_version() + await respond_json(request.response, {"db_version": db_version}) diff --git a/examples/sqlalchemy/application/model.py b/examples/sqlalchemy/application/model.py new file mode 100644 index 0000000..b99bad1 --- /dev/null +++ b/examples/sqlalchemy/application/model.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, Integer, String +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass + + +class MyModel(Base): + __tablename__ = 'my_model' + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(length=100)) + + def __repr__(self): + return f"" diff --git a/examples/sqlalchemy/application/service.py b/examples/sqlalchemy/application/service.py new file mode 100644 index 0000000..bbf687d --- /dev/null +++ b/examples/sqlalchemy/application/service.py @@ -0,0 +1,43 @@ +from typing import Annotated + +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncEngine + +from selva.di import service, Inject + +from .model import Base, MyModel + + +@service +class DefautDBService: + engine: Annotated[AsyncEngine, Inject] + sessionmaker: Annotated[async_sessionmaker, Inject] + + async def initialize(self): + async with self.engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async with self.sessionmaker() as session: + my_model = MyModel(name="MyModel") + session.add(my_model) + await session.commit() + + async def db_version(self) -> str: + async with self.sessionmaker() as session: + result = await session.execute(text("SELECT sqlite_version()")) + return result.scalar() + + async def get_model(self) -> MyModel: + async with self.sessionmaker() as session: + result = await session.execute(select(MyModel)) + return result.scalar() + + +@service +class OtherDBService: + sessionmaker: Annotated[async_sessionmaker, Inject(name="other")] + + async def db_version(self) -> str: + async with self.sessionmaker() as session: + result = await session.execute(text("SELECT version()")) + return result.scalar() diff --git a/examples/sqlalchemy/configuration/settings.yaml b/examples/sqlalchemy/configuration/settings.yaml index 1c472cf..af95c99 100644 --- a/examples/sqlalchemy/configuration/settings.yaml +++ b/examples/sqlalchemy/configuration/settings.yaml @@ -1,3 +1,6 @@ +extensions: + - selva.ext.data.sqlalchemy + data: sqlalchemy: default: diff --git a/examples/templates/application.py b/examples/templates/jinja/application.py similarity index 100% rename from examples/templates/application.py rename to examples/templates/jinja/application.py diff --git a/examples/templates/configuration/settings.yaml b/examples/templates/jinja/configuration/settings.yaml similarity index 57% rename from examples/templates/configuration/settings.yaml rename to examples/templates/jinja/configuration/settings.yaml index 259117a..2c513e1 100644 --- a/examples/templates/configuration/settings.yaml +++ b/examples/templates/jinja/configuration/settings.yaml @@ -1,5 +1,9 @@ +extensions: +- selva.ext.templates.jinja + templates: jinja: + optimized: true extensions: - jinja2.ext.i18n - jinja2.ext.debug \ No newline at end of file diff --git a/examples/templates/resources/templates/index.html b/examples/templates/jinja/resources/templates/index.html similarity index 90% rename from examples/templates/resources/templates/index.html rename to examples/templates/jinja/resources/templates/index.html index 8fc293d..caa1546 100644 --- a/examples/templates/resources/templates/index.html +++ b/examples/templates/jinja/resources/templates/index.html @@ -7,7 +7,7 @@

{{ heading }}

-    {% debug %}
+    {%- debug %}
 
\ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 07392e9..0fe5b1c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -52,3 +52,4 @@ nav: - Extensions: - extensions/overview.md - extensions/sqlalchemy.md + - extensions/jinja.md diff --git a/pyproject.toml b/pyproject.toml index 5115633..a301404 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,23 +50,24 @@ uvicorn = { version = "^0.23", extras = ["standard"] } optional = true [tool.poetry.group.test.dependencies] -pytest = "^7.4" -pytest-asyncio = "^0.21" -pytest-cov = "^4.1" -coverage = { version = "^7.3", extras = ["toml"] } -httpx = "^0.25" -aiosqlite = "0.19" +pytest = "^8" +# TODO: remove "allow-prereleases" once pytest-asyncio supports pytest 8 +pytest-asyncio = { version = "^0.23", allow-prereleases = true } +pytest-cov = "^4" +coverage = { version = "^7", extras = ["toml"] } +httpx = "^0.26" +aiosqlite = "^0.19" [tool.poetry.group.lint] optional = true [tool.poetry.group.lint.dependencies] pylint = "^3.0" -black = "^23.10" -isort = "^5.12" -flake8 = "^6.1" -mypy = "^1.6" -ruff = "^0.1" +black = "^24.1" +isort = "^5.13" +flake8 = "^7.0" +mypy = "^1.8" +ruff = "^0.2" [tool.poetry.group.docs] optional = true @@ -91,7 +92,7 @@ ignore_missing_imports = true [tool.black] line-length = 88 -target-version = ['py310'] +target-version = ["py311", "py312"] extend-exclude = "^/tests/configuration/invalid_settings/configuration/settings\\.py" [build-system] diff --git a/src/selva/_util/import_item.py b/src/selva/_util/import_item.py index 9252478..fe12164 100644 --- a/src/selva/_util/import_item.py +++ b/src/selva/_util/import_item.py @@ -11,15 +11,18 @@ def import_item(name: str): :return: The imported module or item within the module """ - match name.rsplit(".", 1): - case [module_name, item_name]: - module = import_module(module_name) - if item := getattr(module, item_name, None): - return item - - raise ImportError( - f"module '{module.__name__}' does not have item '{item_name}'" - ) - - case _: - return import_module(name) + try: + return import_module(name) + except ImportError as err: + match name.rsplit(".", 1): + case [module_name, item_name]: + module = import_module(module_name) + if item := getattr(module, item_name, None): + return item + + raise ImportError( + f"module '{module.__name__}' does not have item '{item_name}'" + ) + + case _: + raise err diff --git a/src/selva/_util/pydantic/__init__.py b/src/selva/_util/pydantic/__init__.py index 4d21713..6436aa3 100644 --- a/src/selva/_util/pydantic/__init__.py +++ b/src/selva/_util/pydantic/__init__.py @@ -1 +1,4 @@ +# flake8: noqa: F401 +# ruff: noqa: F401 + from .dotted_path import DottedPath diff --git a/src/selva/configuration/__init__.py b/src/selva/configuration/__init__.py index b2063ce..d6e45e6 100644 --- a/src/selva/configuration/__init__.py +++ b/src/selva/configuration/__init__.py @@ -1 +1,4 @@ +# flake8: noqa: F401 +# ruff: noqa: F401 + from selva.configuration.settings import Settings diff --git a/src/selva/configuration/defaults.py b/src/selva/configuration/defaults.py index e228733..047405e 100644 --- a/src/selva/configuration/defaults.py +++ b/src/selva/configuration/defaults.py @@ -1,6 +1,6 @@ default_settings = { "application": "application", - "modules": [], + "extensions": [], "middleware": [], "logging": { "setup": "selva.logging.setup.setup_logger", @@ -10,9 +10,9 @@ "disable": [], }, "templates": { - "jinja": { - "path": "resources/templates", - }, + "backend": None, + "paths": ["resources/templates"], + "jinja": {}, }, "data": {"sqlalchemy": {}}, } diff --git a/src/selva/configuration/settings.py b/src/selva/configuration/settings.py index 7e13346..949e8a7 100644 --- a/src/selva/configuration/settings.py +++ b/src/selva/configuration/settings.py @@ -117,8 +117,7 @@ def get_settings_for_profile(profile: str = None) -> dict[str, Any]: settings_file_path = settings_file_path.absolute() try: - # settings_yaml = settings_file_path.read_text("utf-8") - logger.debug("settings loaded from {}", settings_file_path) + logger.info("settings loaded from {}", settings_file_path) yaml = YAML(typ="safe") return yaml.load(settings_file_path) or {} except FileNotFoundError: diff --git a/src/selva/di/__init__.py b/src/selva/di/__init__.py index 3a0bedd..50a16f7 100644 --- a/src/selva/di/__init__.py +++ b/src/selva/di/__init__.py @@ -1,2 +1,6 @@ +# flake8: noqa: F401 +# ruff: noqa: F401 + +from selva.di.container import Container from selva.di.decorator import service from selva.di.inject import Inject diff --git a/src/selva/di/hook.py b/src/selva/di/hook.py deleted file mode 100644 index b6b4f77..0000000 --- a/src/selva/di/hook.py +++ /dev/null @@ -1,37 +0,0 @@ -import inspect -from collections.abc import Callable - -from selva._util.package_scan import scan_packages -from selva.configuration.settings import Settings -from selva.di.container import Container - -__all__ = ("hook", "run_hooks") - -DI_ATTRIBUTE_HOOK = "__selva_di_hook__" - - -def hook(callback: Callable[[Container, Settings], None] | None) -> Callable: - """Marks a function as a hook to the dependency injection container. - - Hook functions are called when the applications start, passing the dependency - injection container and the application settings as parameters. - """ - - def inner(inner_callback): - assert inspect.isfunction(inner_callback), "callback must be a function" - - sig = inspect.signature(inner_callback) - assert len(sig.parameters) == 2, "callback must have 2 arguments" - - setattr(inner_callback, DI_ATTRIBUTE_HOOK, True) - return inner_callback - - return inner(callback) if callback else inner - - -async def run_hooks(packages, container: Container, settings: Settings): - hooks = scan_packages(packages, lambda x: getattr(x, DI_ATTRIBUTE_HOOK, False)) - for hook_func in hooks: - maybe_awaitable = hook_func(container, settings) - if inspect.isawaitable(maybe_awaitable): - await maybe_awaitable diff --git a/src/selva/contrib/data/sqlalchemy/__init__.py b/src/selva/ext/__init__.py similarity index 100% rename from src/selva/contrib/data/sqlalchemy/__init__.py rename to src/selva/ext/__init__.py diff --git a/tests/contrib/__init__.py b/src/selva/ext/data/__init__.py similarity index 100% rename from tests/contrib/__init__.py rename to src/selva/ext/data/__init__.py diff --git a/src/selva/contrib/data/sqlalchemy/hook.py b/src/selva/ext/data/sqlalchemy/__init__.py similarity index 76% rename from src/selva/contrib/data/sqlalchemy/hook.py rename to src/selva/ext/data/sqlalchemy/__init__.py index 1789250..f6dd427 100644 --- a/src/selva/contrib/data/sqlalchemy/hook.py +++ b/src/selva/ext/data/sqlalchemy/__init__.py @@ -1,16 +1,14 @@ from importlib.util import find_spec from selva.configuration.settings import Settings -from selva.contrib.data.sqlalchemy.service import ( +from selva.di.container import Container +from selva.ext.data.sqlalchemy.service import ( make_engine_service, make_sessionmaker_service, ) -from selva.di.container import Container -from selva.di.hook import hook -@hook -def sqlalchemy_hook(container: Container, settings: Settings): +def selva_extension(container: Container, settings: Settings): if find_spec("sqlalchemy") is None: return diff --git a/src/selva/contrib/data/sqlalchemy/service.py b/src/selva/ext/data/sqlalchemy/service.py similarity index 93% rename from src/selva/contrib/data/sqlalchemy/service.py rename to src/selva/ext/data/sqlalchemy/service.py index 1431c9c..e46aafc 100644 --- a/src/selva/contrib/data/sqlalchemy/service.py +++ b/src/selva/ext/data/sqlalchemy/service.py @@ -3,8 +3,8 @@ from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine from selva.configuration.settings import Settings -from selva.contrib.data.sqlalchemy.settings import SqlAlchemySettings from selva.di import Inject +from selva.ext.data.sqlalchemy.settings import SqlAlchemySettings def make_engine_service(name: str): diff --git a/src/selva/contrib/data/sqlalchemy/settings.py b/src/selva/ext/data/sqlalchemy/settings.py similarity index 97% rename from src/selva/contrib/data/sqlalchemy/settings.py rename to src/selva/ext/data/sqlalchemy/settings.py index 7b23393..4e1a2be 100644 --- a/src/selva/contrib/data/sqlalchemy/settings.py +++ b/src/selva/ext/data/sqlalchemy/settings.py @@ -1,6 +1,6 @@ from collections.abc import Callable from types import ModuleType -from typing import Annotated, Literal, Self +from typing import Annotated, Any, Literal, Self from pydantic import BaseModel, Field, model_validator from sqlalchemy import URL, make_url @@ -30,6 +30,7 @@ class SqlAlchemyOptions(BaseModel): Defined in https://docs.sqlalchemy.org/en/20/core/engines.html#sqlalchemy.create_engine """ + connect_args: Annotated[dict[str, Any], Field(default=None)] echo: Annotated[bool, Field(default=None)] echo_pool: Annotated[bool, Field(default=None)] enable_from_linting: Annotated[bool, Field(default=None)] diff --git a/tests/contrib/data/__init__.py b/src/selva/ext/templates/__init__.py similarity index 100% rename from tests/contrib/data/__init__.py rename to src/selva/ext/templates/__init__.py diff --git a/src/selva/ext/templates/jinja/__init__.py b/src/selva/ext/templates/jinja/__init__.py new file mode 100644 index 0000000..fd5b14b --- /dev/null +++ b/src/selva/ext/templates/jinja/__init__.py @@ -0,0 +1,21 @@ +from selva.configuration.settings import Settings +from selva.di.container import Container +from selva.web.templates import Template + + +async def selva_extension(container: Container, settings: Settings): + backend = settings.templates.backend + + if backend and backend != "jinja": + return + + if not backend and (current := await container.get(Template, optional=True)): + cls = current.__class__ + current_class = f"{cls.__module__}.{cls.__qualname__}" + + raise ValueError( + f"Template backend already registered with '{current_class}'. " + "Please define `templates.backend` property." + ) + + container.scan(f"{__package__}.service") diff --git a/src/selva/ext/templates/jinja/service.py b/src/selva/ext/templates/jinja/service.py new file mode 100644 index 0000000..2d63881 --- /dev/null +++ b/src/selva/ext/templates/jinja/service.py @@ -0,0 +1,65 @@ +from http import HTTPStatus +from pathlib import Path +from typing import Annotated + +from asgikit.responses import Response, respond_stream, respond_text +from jinja2 import Environment, FileSystemLoader, select_autoescape + +from selva.configuration import Settings +from selva.di import Inject, service +from selva.ext.templates.jinja.settings import JinjaTemplateSettings +from selva.web.templates import Template + + +@service(provides=Template) +class JinjaTemplate(Template): + settings: Annotated[Settings, Inject] + environment: Environment + + def initialize(self): + jinja_settings = JinjaTemplateSettings.model_validate( + self.settings.templates.jinja + ) + + kwargs = jinja_settings.model_dump(exclude_unset=True) + + if "loader" not in kwargs: + templates_path = [Path(p).absolute() for p in self.settings.templates.paths] + kwargs["loader"] = FileSystemLoader(templates_path) + + if "autoescape" not in kwargs: + kwargs["autoescape"] = select_autoescape() + + self.environment = Environment(enable_async=True, **kwargs) + + async def respond( + self, + response: Response, + template_name: str, + context: dict, + *, + status: HTTPStatus = HTTPStatus.OK, + content_type: str = None, + stream: bool = False, + ): + if content_type: + response.content_type = content_type + elif not response.content_type: + response.content_type = "text/html" + + template = self.environment.get_template(template_name) + + if stream: + render_stream = template.generate_async(context) + await respond_stream(response, render_stream, status=status) + else: + rendered = await template.render_async(context) + await respond_text(response, rendered, status=status) + + async def render(self, template_name: str, context: dict) -> str: + template = self.environment.get_template(template_name) + return await template.render_async(context) + + async def render_str(self, source: str, context: dict) -> str: + template = self.environment.from_string(source) + return await template.render_async(context) diff --git a/src/selva/ext/templates/jinja/settings.py b/src/selva/ext/templates/jinja/settings.py new file mode 100644 index 0000000..dba1ba6 --- /dev/null +++ b/src/selva/ext/templates/jinja/settings.py @@ -0,0 +1,33 @@ +from collections.abc import Callable +from typing import Annotated, Literal, Type + +from jinja2 import BaseLoader, BytecodeCache, Undefined +from pydantic import BaseModel, ConfigDict, Field + +from selva._util.pydantic import DottedPath + + +class JinjaTemplateSettings(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + block_start_string: Annotated[str, Field(default=None)] + block_end_string: Annotated[str, Field(default=None)] + variable_start_string: Annotated[str, Field(default=None)] + variable_end_string: Annotated[str, Field(default=None)] + comment_start_string: Annotated[str, Field(default=None)] + comment_end_string: Annotated[str, Field(default=None)] + line_statement_prefix: Annotated[str, Field(default=None)] + line_comment_prefix: Annotated[str, Field(default=None)] + trim_blocks: Annotated[bool, Field(default=None)] + lstrip_blocks: Annotated[bool, Field(default=None)] + newline_sequence: Annotated[Literal["\n", "\r\n", "\r"], Field(default=None)] + keep_trailing_newline: Annotated[bool, Field(default=None)] + extensions: Annotated[list[str], Field(default=None)] + optimized: Annotated[bool, Field(default=None)] + undefined: Annotated[DottedPath[Type[Undefined]], Field(default=None)] + finalize: Annotated[DottedPath[Callable[..., None]], Field(default=None)] + autoescape: Annotated[bool | DottedPath[Callable[[str], bool]], Field(default=None)] + loader: Annotated[DottedPath[BaseLoader], Field(default=None)] + cache_size: Annotated[int, Field(default=None)] + auto_reload: Annotated[bool, Field(default=None)] + bytecode_cache: Annotated[DottedPath[BytecodeCache], Field(default=None)] diff --git a/src/selva/web/__init__.py b/src/selva/web/__init__.py index 3ccbe12..ff231b4 100644 --- a/src/selva/web/__init__.py +++ b/src/selva/web/__init__.py @@ -1,3 +1,6 @@ +# flake8: noqa: F401 +# ruff: noqa: F401 + from selva.web.application import Selva from selva.web.converter.param_extractor_impl import ( FromCookie, diff --git a/src/selva/web/application.py b/src/selva/web/application.py index 908aeaa..c5cde4c 100644 --- a/src/selva/web/application.py +++ b/src/selva/web/application.py @@ -1,9 +1,7 @@ import functools import inspect import typing -from copy import copy from http import HTTPStatus -from importlib.util import find_spec from typing import Any from uuid import uuid4 @@ -19,7 +17,6 @@ from selva.configuration.settings import Settings from selva.di.container import Container from selva.di.decorator import DI_ATTRIBUTE_SERVICE -from selva.di.hook import run_hooks from selva.web.converter import ( from_request_impl, param_converter_impl, @@ -71,11 +68,6 @@ def __init__(self, settings: Settings): self.handler = self._process_request - self.modules = copy(self.settings.modules) + ["selva.contrib"] - - if find_spec("jinja2") is not None: - self.modules.append("selva.web.templates") - self._register_modules() setup_logger = import_item(self.settings.logging.setup) @@ -101,15 +93,26 @@ def _register_modules(self): ) self.di.scan(self.settings.application) - self.di.scan(*self.modules) for _iface, impl, _name in self.di.iter_all_services(): if _is_controller(impl): self.router.route(impl) - async def _run_hooks(self): - modules = [self.settings.application] + self.modules - await run_hooks(modules, self.di, self.settings) + async def _initialize_extensions(self): + for extension_name in self.settings.extensions: + try: + extension_module = import_item(extension_name) + except ImportError: + raise TypeError(f"Extension '{extension_name}' not found") + + try: + extension_init = getattr(extension_module, "selva_extension") + except AttributeError: + raise TypeError( + f"Extension '{extension_name}' is missing the 'selva_extension()' function" + ) + + await maybe_async(extension_init, self.di, self.settings) async def _initialize_middleware(self): middleware = self.settings.middleware @@ -131,7 +134,7 @@ async def _initialize_middleware(self): self.handler = chain async def _lifespan_startup(self): - await self._run_hooks() + await self._initialize_extensions() await self._initialize_middleware() async def _lifespan_shutdown(self): @@ -181,8 +184,8 @@ async def _handle_request(self, scope, receive, send): ): logger.trace( "Handling exception with handler {}.{}", - handler.__module__, - handler.__qualname__, + handler.__class__.__module__, + handler.__class__.__qualname__, ) await handler.handle_exception(request, err) else: diff --git a/src/selva/web/converter/from_request_impl.py b/src/selva/web/converter/from_request_impl.py index 0e135d2..b5d38db 100644 --- a/src/selva/web/converter/from_request_impl.py +++ b/src/selva/web/converter/from_request_impl.py @@ -19,7 +19,6 @@ async def from_request( _metadata=None, ) -> PydanticModel: if request.method not in (HTTPMethod.POST, HTTPMethod.PUT, HTTPMethod.PATCH): - # TODO: improve error raise TypeError( "Pydantic model parameter on method that does not accept body" ) @@ -48,7 +47,6 @@ async def from_request( _metadata=None, ) -> list[PydanticModel]: if request.method not in (HTTPMethod.POST, HTTPMethod.PUT, HTTPMethod.PATCH): - # TODO: improve error raise TypeError("Pydantic parameter on method that does not accept body") if "application/json" in request.content_type: diff --git a/src/selva/web/templates/template.py b/src/selva/web/templates.py similarity index 100% rename from src/selva/web/templates/template.py rename to src/selva/web/templates.py diff --git a/src/selva/web/templates/__init__.py b/src/selva/web/templates/__init__.py deleted file mode 100644 index d5acbf7..0000000 --- a/src/selva/web/templates/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from selva.web.templates.template import Template diff --git a/src/selva/web/templates/jinja.py b/src/selva/web/templates/jinja.py deleted file mode 100644 index 764c3b8..0000000 --- a/src/selva/web/templates/jinja.py +++ /dev/null @@ -1,102 +0,0 @@ -from collections.abc import Callable -from http import HTTPStatus -from pathlib import Path -from typing import Annotated, Literal, Type, TypeVar - -from asgikit.responses import Response, respond_stream, respond_text -from jinja2 import ( - BaseLoader, - BytecodeCache, - Environment, - FileSystemLoader, - Undefined, - select_autoescape, -) -from pydantic import BaseModel, ConfigDict, Field - -from selva._util.pydantic import DottedPath -from selva.configuration import Settings -from selva.di import Inject, service -from selva.web.templates.template import Template - -T = TypeVar("T") - - -class JinjaTemplateSettings(BaseModel): - model_config = ConfigDict(arbitrary_types_allowed=True) - - block_start_string: Annotated[str, Field(default=None)] - block_end_string: Annotated[str, Field(default=None)] - variable_start_string: Annotated[str, Field(default=None)] - variable_end_string: Annotated[str, Field(default=None)] - comment_start_string: Annotated[str, Field(default=None)] - comment_end_string: Annotated[str, Field(default=None)] - line_statement_prefix: Annotated[str, Field(default=None)] - line_comment_prefix: Annotated[str, Field(default=None)] - trim_blocks: Annotated[bool, Field(default=None)] - lstrip_blocks: Annotated[bool, Field(default=None)] - newline_sequence: Annotated[Literal["\n", "\r\n", "\r"], Field(default=None)] - keep_trailing_newline: Annotated[bool, Field(default=None)] - extensions: Annotated[list[str], Field(default=None)] - optimized: Annotated[bool, Field(default=None)] - undefined: Annotated[DottedPath[Type[Undefined]], Field(default=None)] - finalize: Annotated[DottedPath[Callable[..., None]], Field(default=None)] - autoescape: Annotated[bool | DottedPath[Callable[[str], bool]], Field(default=None)] - loader: Annotated[BaseLoader, Field(default=None)] - cache_size: Annotated[int, Field(default=None)] - auto_reload: Annotated[bool, Field(default=None)] - bytecode_cache: Annotated[DottedPath[BytecodeCache], Field(default=None)] - - -@service(provides=Template) -class JinjaTemplate(Template): - settings: Annotated[Settings, Inject] - environment: Environment - - def initialize(self): - jinja_settings = JinjaTemplateSettings.model_validate( - self.settings.templates.jinja - ) - - kwargs = jinja_settings.model_dump(exclude_none=True) - - if "loader" not in kwargs: - templates_path = Path(self.settings.templates.jinja.path).absolute() - kwargs["loader"] = FileSystemLoader(templates_path) - - if "autoescape" not in kwargs: - kwargs["autoescape"] = select_autoescape() - - self.environment = Environment(enable_async=True, **kwargs) - - async def respond( - self, - response: Response, - template_name: str, - context: dict, - *, - status: HTTPStatus = HTTPStatus.OK, - content_type: str = None, - stream: bool = False, - ): - if content_type: - response.content_type = content_type - elif not response.content_type: - response.content_type = "text/html" - - template = self.environment.get_template(template_name) - - if stream: - render_stream = template.generate_async(context) - await respond_stream(response, render_stream, status=status) - else: - rendered = await template.render_async(context) - await respond_text(response, rendered, status=status) - - async def render(self, template_name: str, context: dict) -> str: - template = self.environment.get_template(template_name) - return await template.render_async(context) - - async def render_str(self, source: str, context: dict) -> str: - template = self.environment.from_string(source) - return await template.render_async(context) diff --git a/tests/configuration/test_environment.py b/tests/configuration/test_environment.py index 7c180fd..5bebb74 100644 --- a/tests/configuration/test_environment.py +++ b/tests/configuration/test_environment.py @@ -1,5 +1,3 @@ -import pytest - from selva.configuration.environment import ( parse_settings_from_env, replace_variables_recursive, diff --git a/tests/configuration/test_settings.py b/tests/configuration/test_settings.py index 8c9a376..08d519e 100644 --- a/tests/configuration/test_settings.py +++ b/tests/configuration/test_settings.py @@ -1,5 +1,4 @@ import logging -import os from pathlib import Path import pytest @@ -259,7 +258,7 @@ def test_non_existent_env_var_should_fail(monkeypatch): with pytest.raises( ValueError, - match=f"DOES_NOT_EXIST environment variable is not defined and does not contain a default value", + match="DOES_NOT_EXIST environment variable is not defined and does not contain a default value", ): _get_settings_nocache() diff --git a/tests/contrib/data/sqlalchemy/test_service.py b/tests/contrib/data/sqlalchemy/test_service.py deleted file mode 100644 index 88a8c00..0000000 --- a/tests/contrib/data/sqlalchemy/test_service.py +++ /dev/null @@ -1,226 +0,0 @@ -from importlib.util import find_spec - -import pytest -from sqlalchemy import text - -from selva.configuration.defaults import default_settings -from selva.configuration.settings import Settings -from selva.contrib.data.sqlalchemy.service import ( - make_engine_service, - make_sessionmaker_service, -) - - -async def _test_engine_service(settings: Settings): - engine_service = make_engine_service("default")(settings) - engine = await anext(engine_service) - - async with engine.connect() as conn: - result = await conn.execute(text("select 1")) - assert result.scalar() == 1 - - await engine.dispose() - - -async def test_make_engine_service_with_url(): - settings = Settings( - default_settings - | { - "data": { - "sqlalchemy": { - "default": { - "url": "sqlite+aiosqlite:///:memory:", - }, - }, - }, - } - ) - - await _test_engine_service(settings) - - -async def test_make_engine_service_with_url_components(): - settings = Settings( - default_settings - | { - "data": { - "sqlalchemy": { - "default": { - "drivername": "sqlite+aiosqlite", - "database": ":memory:", - }, - }, - }, - } - ) - - await _test_engine_service(settings) - - -async def test_make_engine_service_with_options(): - settings = Settings( - default_settings - | { - "data": { - "sqlalchemy": { - "default": { - "url": "sqlite+aiosqlite:///:memory:", - "options": { - "echo": True, - "echo_pool": True, - }, - }, - }, - }, - } - ) - - engine_service = make_engine_service("default")(settings) - engine = await anext(engine_service) - assert engine.echo is True - assert engine.pool.echo is True - - -async def test_make_engine_service_with_execution_options(): - settings = Settings( - default_settings - | { - "data": { - "sqlalchemy": { - "default": { - "url": "sqlite+aiosqlite:///:memory:", - "options": { - "execution_options": { - "isolation_level": "READ UNCOMMITTED" - }, - }, - }, - }, - }, - } - ) - - engine_service = make_engine_service("default") - engine = await anext(engine_service(settings)) - sessionmaker_service = make_sessionmaker_service("default") - sessionmaker = await sessionmaker_service(engine) - - async with sessionmaker() as session: - result = await session.execute(text("select 1")) - assert result.context.execution_options["isolation_level"] == "READ UNCOMMITTED" - - -@pytest.mark.skipif(find_spec("psycopg") is None, reason="psycopg not present") -async def test_postgres_make_engine_service_with_url(): - settings = Settings( - default_settings - | { - "data": { - "sqlalchemy": { - "default": { - "url": "postgresql+psycopg://postgres:postgres@localhost:5432/postgres", - }, - }, - }, - } - ) - - await _test_engine_service(settings) - - -@pytest.mark.skipif(find_spec("psycopg") is None, reason="psycopg not present") -async def test_postgres_make_engine_service_with_url_username_password(): - settings = Settings( - default_settings - | { - "data": { - "sqlalchemy": { - "default": { - "url": "postgresql+psycopg://localhost:5432/postgres", - "username": "postgres", - "password": "postgres", - }, - }, - }, - } - ) - - await _test_engine_service(settings) - - -@pytest.mark.skipif(find_spec("psycopg") is None, reason="psycopg not present") -async def test_postgres_make_engine_service_with_url_components(): - settings = Settings( - default_settings - | { - "data": { - "sqlalchemy": { - "default": { - "drivername": "postgresql+psycopg", - "host": "localhost", - "port": 5432, - "database": "postgres", - "username": "postgres", - "password": "postgres", - }, - }, - }, - } - ) - - await _test_engine_service(settings) - - -@pytest.mark.skipif(find_spec("psycopg") is None, reason="psycopg not present") -async def test_postgres_make_engine_service_with_options(): - settings = Settings( - default_settings - | { - "data": { - "sqlalchemy": { - "default": { - "url": "postgresql+psycopg://postgres:postgres@localhost:5432/postgres", - "options": { - "echo": True, - "echo_pool": True, - }, - }, - }, - }, - } - ) - - engine_service = make_engine_service("default")(settings) - engine = await anext(engine_service) - assert engine.echo is True - assert engine.pool.echo is True - - -@pytest.mark.skipif(find_spec("psycopg") is None, reason="psycopg not present") -async def test_postgres_make_engine_service_with_execution_options(): - settings = Settings( - default_settings - | { - "data": { - "sqlalchemy": { - "default": { - "url": "postgresql+psycopg://postgres:postgres@localhost:5432/postgres", - "options": { - "execution_options": { - "isolation_level": "READ UNCOMMITTED" - }, - }, - }, - }, - }, - } - ) - - engine_service = make_engine_service("default") - engine = await anext(engine_service(settings)) - sessionmaker_service = make_sessionmaker_service("default") - sessionmaker = await sessionmaker_service(engine) - - async with sessionmaker() as session: - result = await session.execute(text("select 1")) - assert result.context.execution_options["isolation_level"] == "READ UNCOMMITTED" diff --git a/tests/di/fixtures.py b/tests/di/conftest.py similarity index 100% rename from tests/di/fixtures.py rename to tests/di/conftest.py diff --git a/tests/di/test_create.py b/tests/di/test_create.py index 7da93ce..2bae5fc 100644 --- a/tests/di/test_create.py +++ b/tests/di/test_create.py @@ -3,8 +3,6 @@ from selva.di.container import Container from selva.di.inject import Inject -from .fixtures import ioc - class Service1: pass diff --git a/tests/di/test_define_instance.py b/tests/di/test_define_instance.py index b3110c3..d8cdca8 100644 --- a/tests/di/test_define_instance.py +++ b/tests/di/test_define_instance.py @@ -1,7 +1,5 @@ from selva.di.container import Container -from .fixtures import ioc - class Service: pass diff --git a/tests/di/test_dependency_complex_graph.py b/tests/di/test_dependency_complex_graph.py index e1bf0f9..a5a0786 100644 --- a/tests/di/test_dependency_complex_graph.py +++ b/tests/di/test_dependency_complex_graph.py @@ -4,8 +4,6 @@ from selva.di.decorator import service from selva.di.inject import Inject -from .fixtures import ioc - @service class Service1: diff --git a/tests/di/test_dependency_loop.py b/tests/di/test_dependency_loop.py index 0251674..1f66b20 100644 --- a/tests/di/test_dependency_loop.py +++ b/tests/di/test_dependency_loop.py @@ -3,8 +3,6 @@ from selva.di.container import Container from selva.di.inject import Inject -from .fixtures import ioc - class Service1: service2: Annotated["Service2", Inject] diff --git a/tests/di/test_dependency_options.py b/tests/di/test_dependency_options.py index ec96130..2a1c03a 100644 --- a/tests/di/test_dependency_options.py +++ b/tests/di/test_dependency_options.py @@ -3,8 +3,6 @@ from selva.di.container import Container from selva.di.inject import Inject -from .fixtures import ioc - class DependentService: pass diff --git a/tests/di/test_generics.py b/tests/di/test_generics.py index 0b83c68..9b00759 100644 --- a/tests/di/test_generics.py +++ b/tests/di/test_generics.py @@ -6,8 +6,6 @@ from selva.di.error import TypeVarInGenericServiceError from selva.di.inject import Inject -from .fixtures import ioc - T = TypeVar("T") diff --git a/tests/di/test_interceptor.py b/tests/di/test_interceptor.py index ec13554..8cf33e5 100644 --- a/tests/di/test_interceptor.py +++ b/tests/di/test_interceptor.py @@ -2,8 +2,6 @@ from selva.di.container import Container -from .fixtures import ioc - class TestInterceptor: async def intercept(self, instance: Any, _service_type: type): diff --git a/tests/di/test_iter_service.py b/tests/di/test_iter_service.py index f35f374..b08daed 100644 --- a/tests/di/test_iter_service.py +++ b/tests/di/test_iter_service.py @@ -3,8 +3,6 @@ from selva.di.container import Container from selva.di.error import ServiceNotFoundError -from .fixtures import ioc - class Interface: pass diff --git a/tests/di/test_lifecycle_callbacks.py b/tests/di/test_lifecycle_callbacks.py index 533897a..67c7b21 100644 --- a/tests/di/test_lifecycle_callbacks.py +++ b/tests/di/test_lifecycle_callbacks.py @@ -3,8 +3,6 @@ from selva.di.container import Container from selva.di.inject import Inject -from .fixtures import ioc - class Dependency: pass diff --git a/tests/di/test_named_dependencies.py b/tests/di/test_named_dependencies.py index 2cc4346..1ec3d2d 100644 --- a/tests/di/test_named_dependencies.py +++ b/tests/di/test_named_dependencies.py @@ -6,8 +6,6 @@ from selva.di.error import ServiceAlreadyRegisteredError, ServiceNotFoundError from selva.di.inject import Inject -from .fixtures import ioc - class DependentService: pass diff --git a/tests/di/test_non_injectable.py b/tests/di/test_non_injectable.py index 871842e..00bb00d 100644 --- a/tests/di/test_non_injectable.py +++ b/tests/di/test_non_injectable.py @@ -3,8 +3,6 @@ from selva.di.container import Container from selva.di.error import NonInjectableTypeError -from .fixtures import ioc - def test_non_injectable_type_should_fail(ioc: Container): obj = () diff --git a/tests/di/test_register_decorated_class.py b/tests/di/test_register_decorated_class.py index a629984..141a8aa 100644 --- a/tests/di/test_register_decorated_class.py +++ b/tests/di/test_register_decorated_class.py @@ -1,8 +1,6 @@ from selva.di.container import Container from selva.di.decorator import service -from .fixtures import ioc - @service class Service: diff --git a/tests/di/test_scan_package.py b/tests/di/test_scan_package.py index d169a96..c5b2428 100644 --- a/tests/di/test_scan_package.py +++ b/tests/di/test_scan_package.py @@ -1,7 +1,5 @@ from selva.di.container import Container -from .fixtures import ioc - async def test_scan_package_by_module(ioc: Container): from .services import scan_package as module diff --git a/tests/di/test_service_class.py b/tests/di/test_service_class.py index 92b68e6..f344463 100644 --- a/tests/di/test_service_class.py +++ b/tests/di/test_service_class.py @@ -6,8 +6,6 @@ from selva.di.error import ServiceAlreadyRegisteredError from selva.di.inject import Inject -from .fixtures import ioc - class Service1: pass diff --git a/tests/di/test_service_function.py b/tests/di/test_service_function.py index 477ecbe..1b52ba7 100644 --- a/tests/di/test_service_function.py +++ b/tests/di/test_service_function.py @@ -3,8 +3,6 @@ from selva.di.container import Container from selva.di.error import FactoryMissingReturnTypeError, ServiceAlreadyRegisteredError -from .fixtures import ioc - class Service1: pass diff --git a/tests/di/test_service_generator.py b/tests/di/test_service_generator.py index 9f273ce..0a189cd 100644 --- a/tests/di/test_service_generator.py +++ b/tests/di/test_service_generator.py @@ -1,9 +1,7 @@ import pytest from selva.di.container import Container -from selva.di.error import FactoryMissingReturnTypeError, ServiceAlreadyRegisteredError - -from .fixtures import ioc +from selva.di.error import FactoryMissingReturnTypeError class Service1: diff --git a/tests/di/test_service_generator_async.py b/tests/di/test_service_generator_async.py index d42116e..543a81f 100644 --- a/tests/di/test_service_generator_async.py +++ b/tests/di/test_service_generator_async.py @@ -1,9 +1,7 @@ import pytest from selva.di.container import Container -from selva.di.error import FactoryMissingReturnTypeError, ServiceAlreadyRegisteredError - -from .fixtures import ioc +from selva.di.error import FactoryMissingReturnTypeError class Service1: diff --git a/tests/di/test_unknown_service.py b/tests/di/test_unknown_service.py index 741f893..62829b4 100644 --- a/tests/di/test_unknown_service.py +++ b/tests/di/test_unknown_service.py @@ -3,8 +3,6 @@ from selva.di.container import Container from selva.di.error import ServiceNotFoundError -from .fixtures import ioc - class Service: pass diff --git a/tests/contrib/data/sqlalchemy/__init__.py b/tests/ext/__init__.py similarity index 100% rename from tests/contrib/data/sqlalchemy/__init__.py rename to tests/ext/__init__.py diff --git a/tests/ext/data/__init__.py b/tests/ext/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ext/data/sqlalchemy/__init__.py b/tests/ext/data/sqlalchemy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/contrib/data/sqlalchemy/application.py b/tests/ext/data/sqlalchemy/application.py similarity index 100% rename from tests/contrib/data/sqlalchemy/application.py rename to tests/ext/data/sqlalchemy/application.py diff --git a/tests/ext/data/sqlalchemy/application_named.py b/tests/ext/data/sqlalchemy/application_named.py new file mode 100644 index 0000000..8add2cb --- /dev/null +++ b/tests/ext/data/sqlalchemy/application_named.py @@ -0,0 +1,21 @@ +from typing import Annotated + +from asgikit.responses import respond_text +from sqlalchemy import text +from sqlalchemy.ext.asyncio import async_sessionmaker + +from selva.di import Inject +from selva.web import controller, get + + +@controller +class Controller: + sessionmaker: Annotated[async_sessionmaker, Inject(name="other")] + + @get + async def index(self, request): + async with self.sessionmaker() as session: + result = await session.execute(text("select sqlite_version()")) + version = result.first()[0] + + await respond_text(request.response, version) diff --git a/tests/contrib/data/sqlalchemy/test_application.py b/tests/ext/data/sqlalchemy/test_application.py similarity index 52% rename from tests/contrib/data/sqlalchemy/test_application.py rename to tests/ext/data/sqlalchemy/test_application.py index b6a869b..8c82320 100644 --- a/tests/contrib/data/sqlalchemy/test_application.py +++ b/tests/ext/data/sqlalchemy/test_application.py @@ -1,5 +1,6 @@ from http import HTTPStatus +import pytest from httpx import AsyncClient from selva.configuration.defaults import default_settings @@ -7,14 +8,21 @@ from selva.web.application import Selva -async def test_application(): +@pytest.mark.parametrize( + "application,database", + [ + ("application", "default"), + ("application_named", "other"), + ], + ids=["default", "named"], +) +async def test_application(application: str, database: str): settings = Settings( default_settings | { - "application": "tests.contrib.data.sqlalchemy.application", - "data": { - "sqlalchemy": {"default": {"url": "sqlite+aiosqlite:///:memory:"}} - }, + "application": f"{__package__}.{application}", + "extensions": ["selva.ext.data.sqlalchemy"], + "data": {"sqlalchemy": {database: {"url": "sqlite+aiosqlite:///:memory:"}}}, } ) diff --git a/tests/ext/data/sqlalchemy/test_service.py b/tests/ext/data/sqlalchemy/test_service.py new file mode 100644 index 0000000..bf0433c --- /dev/null +++ b/tests/ext/data/sqlalchemy/test_service.py @@ -0,0 +1,128 @@ +from sqlalchemy import text + +from selva.configuration.defaults import default_settings +from selva.configuration.settings import Settings +from selva.ext.data.sqlalchemy.service import ( + make_engine_service, + make_sessionmaker_service, +) + + +async def _test_engine_service(settings: Settings): + engine_service = make_engine_service("default")(settings) + async for engine in engine_service: + async with engine.connect() as conn: + result = await conn.execute(text("select 1")) + assert result.scalar() == 1 + + await engine.dispose() + + +async def test_make_engine_service_with_url(): + settings = Settings( + default_settings + | { + "data": { + "sqlalchemy": { + "default": { + "url": "sqlite+aiosqlite:///:memory:", + }, + }, + }, + } + ) + + await _test_engine_service(settings) + + +async def test_make_engine_service_with_url_components(): + settings = Settings( + default_settings + | { + "data": { + "sqlalchemy": { + "default": { + "drivername": "sqlite+aiosqlite", + "database": ":memory:", + }, + }, + }, + } + ) + + await _test_engine_service(settings) + + +async def test_make_engine_service_with_options(): + settings = Settings( + default_settings + | { + "data": { + "sqlalchemy": { + "default": { + "url": "sqlite+aiosqlite:///:memory:", + "options": { + "echo": True, + "echo_pool": True, + }, + }, + }, + }, + } + ) + + engine_service = make_engine_service("default")(settings) + async for engine in engine_service: + assert engine.echo is True + assert engine.pool.echo is True + + +async def test_make_engine_service_with_execution_options(): + settings = Settings( + default_settings + | { + "data": { + "sqlalchemy": { + "default": { + "url": "sqlite+aiosqlite:///:memory:", + "options": { + "execution_options": { + "isolation_level": "READ UNCOMMITTED" + }, + }, + }, + }, + }, + } + ) + + engine_service = make_engine_service("default") + async for engine in engine_service(settings): + sessionmaker_service = make_sessionmaker_service("default") + sessionmaker = await sessionmaker_service(engine) + + async with sessionmaker() as session: + result = await session.execute(text("select 1")) + assert ( + result.context.execution_options["isolation_level"] + == "READ UNCOMMITTED" + ) + + +async def test_make_engine_service_alternative_name(): + settings = Settings( + default_settings + | { + "data": { + "sqlalchemy": { + "other": { + "url": "sqlite+aiosqlite:///:memory:", + }, + }, + }, + } + ) + + engine_service = make_engine_service("other")(settings) + async for engine in engine_service: + assert engine is not None diff --git a/tests/ext/data/sqlalchemy/test_service_postgres.py b/tests/ext/data/sqlalchemy/test_service_postgres.py new file mode 100644 index 0000000..ef7226f --- /dev/null +++ b/tests/ext/data/sqlalchemy/test_service_postgres.py @@ -0,0 +1,132 @@ +import os +from importlib.util import find_spec + +import pytest +from sqlalchemy import make_url, text + +from selva.configuration.defaults import default_settings +from selva.configuration.settings import Settings +from selva.ext.data.sqlalchemy.service import ( + make_engine_service, + make_sessionmaker_service, +) + +from .test_service import _test_engine_service + +POSTGRES_URL = os.getenv("POSTGRES_URL") + +pytestmark = [ + pytest.mark.skipif(POSTGRES_URL is None, reason="POSTGRES_URL not defined"), + pytest.mark.skipif(find_spec("asyncpg") is None, reason="asyncpg not present"), +] + +SA_DB_URL = make_url(POSTGRES_URL) if POSTGRES_URL else None + + +async def test_make_engine_service_with_url(): + settings = Settings( + default_settings + | { + "data": { + "sqlalchemy": { + "default": { + "url": POSTGRES_URL, + }, + }, + }, + } + ) + + await _test_engine_service(settings) + + +async def test_make_engine_service_with_url_username_password(): + settings = Settings( + default_settings + | { + "data": { + "sqlalchemy": { + "default": { + "url": f"postgresql+asyncpg://{SA_DB_URL.host}:{SA_DB_URL.port}/{SA_DB_URL.database}", + "username": SA_DB_URL.username, + "password": SA_DB_URL.password, + }, + }, + }, + } + ) + + await _test_engine_service(settings) + + +async def test_make_engine_service_with_url_components(): + settings = Settings( + default_settings + | { + "data": { + "sqlalchemy": { + "default": { + "drivername": "postgresql+asyncpg", + "host": SA_DB_URL.host, + "port": SA_DB_URL.port, + "database": SA_DB_URL.database, + "username": SA_DB_URL.username, + "password": SA_DB_URL.password, + }, + }, + }, + } + ) + + await _test_engine_service(settings) + + +async def test_make_engine_service_with_options(): + settings = Settings( + default_settings + | { + "data": { + "sqlalchemy": { + "default": { + "url": POSTGRES_URL, + "options": { + "echo": True, + "echo_pool": True, + }, + }, + }, + }, + } + ) + + engine_service = make_engine_service("default")(settings) + async for engine in engine_service: + assert engine.echo is True + assert engine.pool.echo is True + + +async def test_make_engine_service_with_execution_options(): + settings = Settings( + default_settings + | { + "data": { + "sqlalchemy": { + "default": { + "url": POSTGRES_URL, + "options": { + "execution_options": {"isolation_level": "READ COMMITTED"}, + }, + }, + }, + }, + } + ) + + engine_service = make_engine_service("default") + engine = await anext(engine_service(settings)) + sessionmaker_service = make_sessionmaker_service("default") + sessionmaker = await sessionmaker_service(engine) + + async with sessionmaker() as session: + result = await session.execute(text("select 1")) + assert result.context.execution_options["isolation_level"] == "READ COMMITTED" diff --git a/tests/contrib/data/sqlalchemy/test_settings.py b/tests/ext/data/sqlalchemy/test_settings.py similarity index 88% rename from tests/contrib/data/sqlalchemy/test_settings.py rename to tests/ext/data/sqlalchemy/test_settings.py index 8980cf6..e66a711 100644 --- a/tests/contrib/data/sqlalchemy/test_settings.py +++ b/tests/ext/data/sqlalchemy/test_settings.py @@ -1,6 +1,6 @@ import pytest -from selva.contrib.data.sqlalchemy.settings import SqlAlchemySettings +from selva.ext.data.sqlalchemy.settings import SqlAlchemySettings @pytest.mark.parametrize( diff --git a/tests/ext/register_extension/__init__.py b/tests/ext/register_extension/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ext/register_extension/application.py b/tests/ext/register_extension/application.py new file mode 100644 index 0000000..e9daedf --- /dev/null +++ b/tests/ext/register_extension/application.py @@ -0,0 +1 @@ +# Just an emtpy module to be loaded diff --git a/tests/ext/register_extension/extension.py b/tests/ext/register_extension/extension.py new file mode 100644 index 0000000..33db4ca --- /dev/null +++ b/tests/ext/register_extension/extension.py @@ -0,0 +1,2 @@ +def selva_extension(_container, settings): + setattr(settings, "tested", True) diff --git a/tests/ext/register_extension/test_extension.py b/tests/ext/register_extension/test_extension.py new file mode 100644 index 0000000..16a04b8 --- /dev/null +++ b/tests/ext/register_extension/test_extension.py @@ -0,0 +1,19 @@ +from selva.configuration.defaults import default_settings +from selva.configuration.settings import Settings +from selva.web.application import Selva + + +async def test_extension(): + settings = Settings( + default_settings + | { + "application": "tests.ext.register_extension.application", + "extensions": ["tests.ext.register_extension.extension"], + } + ) + + app = Selva(settings) + + await app._lifespan_startup() + + assert settings.tested diff --git a/tests/ext/templates/__init__.py b/tests/ext/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ext/templates/jinja/__init__.py b/tests/ext/templates/jinja/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/web/templates/application.py b/tests/ext/templates/jinja/application.py similarity index 100% rename from tests/web/templates/application.py rename to tests/ext/templates/jinja/application.py diff --git a/tests/web/templates/template.html b/tests/ext/templates/jinja/template.html similarity index 100% rename from tests/web/templates/template.html rename to tests/ext/templates/jinja/template.html diff --git a/tests/web/templates/test_jinja.py b/tests/ext/templates/jinja/test_jinja.py similarity index 97% rename from tests/web/templates/test_jinja.py rename to tests/ext/templates/jinja/test_jinja.py index 6d1a123..c90d6dc 100644 --- a/tests/web/templates/test_jinja.py +++ b/tests/ext/templates/jinja/test_jinja.py @@ -2,7 +2,7 @@ import pytest from pydantic import ValidationError -from selva.web.templates.jinja import JinjaTemplateSettings +from selva.ext.templates.jinja.settings import JinjaTemplateSettings class MyUndefined(jinja2.Undefined): diff --git a/tests/web/templates/test_jinja_render.py b/tests/ext/templates/jinja/test_jinja_render.py similarity index 79% rename from tests/web/templates/test_jinja_render.py rename to tests/ext/templates/jinja/test_jinja_render.py index 8bd039b..76743d0 100644 --- a/tests/web/templates/test_jinja_render.py +++ b/tests/ext/templates/jinja/test_jinja_render.py @@ -2,12 +2,14 @@ from selva.configuration.defaults import default_settings from selva.configuration.settings import Settings -from selva.web.templates.jinja import JinjaTemplate +from selva.ext.templates.jinja.service import JinjaTemplate async def test_render_template(): path = str(Path(__file__).parent.absolute()) - settings = Settings(default_settings | {"templates": {"jinja": {"path": path}}}) + settings = Settings( + default_settings | {"templates": {"paths": [path], "jinja": {}}} + ) template = JinjaTemplate(settings) template.initialize() diff --git a/tests/web/templates/test_jinja_response.py b/tests/ext/templates/jinja/test_jinja_response.py similarity index 91% rename from tests/web/templates/test_jinja_response.py rename to tests/ext/templates/jinja/test_jinja_response.py index a5dbb6e..2d13532 100644 --- a/tests/web/templates/test_jinja_response.py +++ b/tests/ext/templates/jinja/test_jinja_response.py @@ -10,8 +10,9 @@ settings = Settings( default_settings | { - "application": "tests.web.templates.application", - "templates": {"jinja": {"path": path}}, + "application": f"{__package__}.application", + "extensions": ["selva.ext.templates.jinja"], + "templates": default_settings["templates"] | {"paths": [path]}, } ) diff --git a/tests/util/pydantic/test_dotted_path.py b/tests/util/pydantic/test_dotted_path.py index 561a737..9b73c73 100644 --- a/tests/util/pydantic/test_dotted_path.py +++ b/tests/util/pydantic/test_dotted_path.py @@ -8,15 +8,15 @@ class Item: pass -class TestModel(BaseModel): +class MyModel(BaseModel): item: DottedPath def test_dotted_path(): - result = TestModel.model_validate({"item": f"{Item.__module__}.{Item.__qualname__}"}) + result = MyModel.model_validate({"item": f"{Item.__module__}.{Item.__qualname__}"}) assert result.item is Item def test_invalid_dotted_path(): with pytest.raises(ValueError): - TestModel.model_validate({"item": "invalid.dotted.path"}) + MyModel.model_validate({"item": "invalid.dotted.path"}) diff --git a/tests/web/application/test_application.py b/tests/web/application/test_application.py index 28a6d79..e8f6ca4 100644 --- a/tests/web/application/test_application.py +++ b/tests/web/application/test_application.py @@ -9,7 +9,7 @@ async def test_application(): settings = Settings( - default_settings | {"application": "tests.web.application.application"} + default_settings | {"application": f"{__package__}.application"} ) app = Selva(settings) @@ -20,7 +20,7 @@ async def test_application(): async def test_not_found(): settings = Settings( - default_settings | {"application": "tests.web.application.application"} + default_settings | {"application": f"{__package__}.application"} ) app = Selva(settings) diff --git a/tests/web/application/test_middleware.py b/tests/web/application/test_middleware.py index f16281a..09006e1 100644 --- a/tests/web/application/test_middleware.py +++ b/tests/web/application/test_middleware.py @@ -9,7 +9,7 @@ @service -class TestMiddleware(Middleware): +class MyMiddleware(Middleware): async def __call__(self, call, request): send = request.asgi.send @@ -26,8 +26,8 @@ async def test_middleware(): settings = Settings( default_settings | { - "application": "tests.web.application.application", - "middleware": ["tests.web.application.test_middleware.TestMiddleware"], + "application": f"{__package__}.application", + "middleware": [f"{__package__}.test_middleware.MyMiddleware"], } ) app = Selva(settings) diff --git a/tests/web/converter/test_from_request.py b/tests/web/converter/test_from_request.py index ffd6dd8..ddca374 100644 --- a/tests/web/converter/test_from_request.py +++ b/tests/web/converter/test_from_request.py @@ -55,6 +55,6 @@ async def receive(): result = await converter.from_request(context, list[Model], "name", None) - assert type(result) == list + assert isinstance(result, list) assert result[0].field == "value1" assert result[1].field == "value2" diff --git a/tests/web/exception_handler/__init__.py b/tests/web/exception_handler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/web/exception_handler/application.py b/tests/web/exception_handler/application.py new file mode 100644 index 0000000..912137b --- /dev/null +++ b/tests/web/exception_handler/application.py @@ -0,0 +1,21 @@ +from asgikit.responses import respond_json + +from selva.web import controller, get +from selva.web.exception_handler import exception_handler + + +class MyException(Exception): + pass + + +@exception_handler(MyException) +class MyExceptionHandler: + async def handle_exception(self, request, exc): + await respond_json(request.response, {"exception": exc.__class__.__name__}) + + +@controller +class Controller: + @get + async def index(self, request): + raise MyException() diff --git a/tests/web/exception_handler/test_exception_handler.py b/tests/web/exception_handler/test_exception_handler.py new file mode 100644 index 0000000..6153dc2 --- /dev/null +++ b/tests/web/exception_handler/test_exception_handler.py @@ -0,0 +1,23 @@ +from httpx import AsyncClient + +from selva.configuration import Settings +from selva.configuration.defaults import default_settings +from selva.web.application import Selva + +from .application import MyException + + +async def test_exception_handler(): + settings = Settings( + default_settings + | { + "application": f"{__package__}.application", + } + ) + + app = Selva(settings) + await app._lifespan_startup() + + client = AsyncClient(app=app) + response = await client.get("http://localhost:8000/") + assert response.json() == {"exception": MyException.__name__} diff --git a/tests/web/test_excetion.py b/tests/web/test_exception.py similarity index 100% rename from tests/web/test_excetion.py rename to tests/web/test_exception.py