From 8f5a1dd9034a74ffa2b4490ea0efa7096ed8f971 Mon Sep 17 00:00:00 2001 From: Ezeudoh Tochukwu Date: Tue, 30 Dec 2025 08:12:05 +0100 Subject: [PATCH 1/7] fixed keyerror when resolving api versioning --- ellar/core/conf/app_settings_models.py | 58 ++++++++++++++++++++++++++ ellar/core/conf/config.py | 2 +- ellar/core/routing/base.py | 2 +- ellar/core/routing/mount.py | 2 +- 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/ellar/core/conf/app_settings_models.py b/ellar/core/conf/app_settings_models.py index 7cbd5ccc..c4154fdc 100644 --- a/ellar/core/conf/app_settings_models.py +++ b/ellar/core/conf/app_settings_models.py @@ -1,3 +1,4 @@ +import secrets import typing as t from ellar.common import EllarInterceptor, GuardCanActivate @@ -253,3 +254,60 @@ def pre_cache_validate(cls, value: t.Dict) -> t.Any: ConfigSchema.model_rebuild() + + +def default_config_settings(): + from ellar.common import JSONResponse + from ellar.core.versioning import DefaultAPIVersioning + from ellar.pydantic import ENCODERS_BY_TYPE as encoders_by_type + + return { + "DEBUG": False, + "DEFAULT_JSON_CLASS": JSONResponse, + "SECRET_KEY": secrets.token_hex(16), + # injector auto_bind = True allows you to resolve types that are not registered on the container + # For more info, read: https://injector.readthedocs.io/en/latest/index.html + "INJECTOR_AUTO_BIND": False, + # jinja Environment options + # https://jinja.palletsprojects.com/en/3.0.x/api/#high-level-api + "JINJA_TEMPLATES_OPTIONS": {}, + # Injects context to jinja templating context values + "TEMPLATES_CONTEXT_PROCESSORS": [ + "ellar.core.templating.context_processors:request_context", + "ellar.core.templating.context_processors:user", + "ellar.core.templating.context_processors:request_state", + ], + # Application route versioning scheme + "VERSIONING_SCHEME": DefaultAPIVersioning(), + # Enable or Disable Application Router route searching by appending backslash + "REDIRECT_SLASHES": False, + # Define references to static folders in python packages. + # eg STATIC_FOLDER_PACKAGES = [('boostrap4', 'statics')] + "STATIC_FOLDER_PACKAGES": [], + # Define references to static folders defined within the project + "STATIC_DIRECTORIES": [], + # static route path + "STATIC_MOUNT_PATH": "/static", + "CORS_ALLOW_ORIGINS": ["*"], + "CORS_ALLOW_METHODS": ["*"], + "CORS_ALLOW_HEADERS": ["*"], + "ALLOWED_HOSTS": ["*"], + # Application middlewares + "MIDDLEWARE": [ + "ellar.core.middleware.trusted_host:trusted_host_middleware", + "ellar.core.middleware.cors:cors_middleware", + "ellar.core.middleware.errors:server_error_middleware", + "ellar.core.middleware.versioning:versioning_middleware", + "ellar.auth.middleware.session:session_middleware", + "ellar.auth.middleware.auth:identity_middleware", + "ellar.core.middleware.exceptions:exception_middleware", + ], + # A dictionary mapping either integer status codes, + # or exception class types onto callables which handle the exceptions. + # Exception handler callables should be of the form + # `handler(context:IExecutionContext, exc: Exception) -> response` + # and may be either standard functions, or async functions. + "EXCEPTION_HANDLERS": ["ellar.core.exceptions:error_404_handler"], + # Object Serializer custom encoders + "SERIALIZER_CUSTOM_ENCODER": encoders_by_type, + } diff --git a/ellar/core/conf/config.py b/ellar/core/conf/config.py index cbdfb89c..f1024726 100644 --- a/ellar/core/conf/config.py +++ b/ellar/core/conf/config.py @@ -22,7 +22,7 @@ class Config(ConfigDefaultTypesMixin): def __init__( self, - config_module: t.Optional[str] = None, + config_module: t.Optional[t.Union[str, dict]] = None, config_prefix: t.Optional[str] = None, **mapping: t.Any, ): diff --git a/ellar/core/routing/base.py b/ellar/core/routing/base.py index b1f58b0f..f75a0b5d 100644 --- a/ellar/core/routing/base.py +++ b/ellar/core/routing/base.py @@ -120,7 +120,7 @@ def matches(self, scope: TScope) -> t.Tuple[Match, TScope]: if match[0] is Match.FULL: version_scheme_resolver: "BaseAPIVersioningResolver" = t.cast( "BaseAPIVersioningResolver", - scope[constants.SCOPE_API_VERSIONING_RESOLVER], + scope.get(constants.SCOPE_API_VERSIONING_RESOLVER), ) if not version_scheme_resolver.can_activate( route_versions=self.allowed_version diff --git a/ellar/core/routing/mount.py b/ellar/core/routing/mount.py index 2da90ce7..36b3867c 100644 --- a/ellar/core/routing/mount.py +++ b/ellar/core/routing/mount.py @@ -116,7 +116,7 @@ def router_default_decorator(func: ASGIApp) -> ASGIApp: @functools.wraps(func) async def _wrap(scope: TScope, receive: TReceive, send: TSend) -> None: version_scheme_resolver: "BaseAPIVersioningResolver" = t.cast( - "BaseAPIVersioningResolver", scope[SCOPE_API_VERSIONING_RESOLVER] + "BaseAPIVersioningResolver", scope.get(SCOPE_API_VERSIONING_RESOLVER) ) if version_scheme_resolver and version_scheme_resolver.matched_any_route: version_scheme_resolver.raise_exception() From 98aeb1fa2574e72e704c34c64585187c6dfd9b53 Mon Sep 17 00:00:00 2001 From: Ezeudoh Tochukwu Date: Tue, 30 Dec 2025 08:19:22 +0100 Subject: [PATCH 2/7] Added docs for default config --- docs/techniques/configurations.md | 88 +++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/docs/techniques/configurations.md b/docs/techniques/configurations.md index 2d7e54bf..7e145500 100644 --- a/docs/techniques/configurations.md +++ b/docs/techniques/configurations.md @@ -304,3 +304,91 @@ application = AppFactory.create_from_app_module( ) ) ``` + +## **Complete Configuration Example** + +Below is a complete configuration example showing all available configuration options with their default values: + +```python +import typing as t + +from ellar.common import IExceptionHandler, JSONResponse +from ellar.core import ConfigDefaultTypesMixin +from ellar.core.versioning import BaseAPIVersioning, DefaultAPIVersioning +from ellar.pydantic import ENCODERS_BY_TYPE as encoders_by_type +from starlette.middleware import Middleware +from starlette.requests import Request + + +class BaseConfig(ConfigDefaultTypesMixin): + DEBUG: bool = False + + DEFAULT_JSON_CLASS: t.Type[JSONResponse] = JSONResponse + SECRET_KEY: str = "your-secret-key-here" + + # injector auto_bind = True allows you to resolve types that are not registered on the container + # For more info, read: https://injector.readthedocs.io/en/latest/index.html + INJECTOR_AUTO_BIND = False + + # jinja Environment options + # https://jinja.palletsprojects.com/en/3.0.x/api/#high-level-api + JINJA_TEMPLATES_OPTIONS: t.Dict[str, t.Any] = {} + + # Injects context to jinja templating context values + TEMPLATES_CONTEXT_PROCESSORS: t.List[ + t.Union[str, t.Callable[[t.Union[Request]], t.Dict[str, t.Any]]] + ] = [ + "ellar.core.templating.context_processors:request_context", + "ellar.core.templating.context_processors:user", + "ellar.core.templating.context_processors:request_state", + ] + + # Application route versioning scheme + VERSIONING_SCHEME: BaseAPIVersioning = DefaultAPIVersioning() + + # Enable or Disable Application Router route searching by appending backslash + REDIRECT_SLASHES: bool = False + + # Define references to static folders in python packages. + # eg STATIC_FOLDER_PACKAGES = [('boostrap4', 'statics')] + STATIC_FOLDER_PACKAGES: t.Optional[t.List[t.Union[str, t.Tuple[str, str]]]] = [] + + # Define references to static folders defined within the project + STATIC_DIRECTORIES: t.Optional[t.List[t.Union[str, t.Any]]] = [] + + # static route path + STATIC_MOUNT_PATH: str = "/static" + + CORS_ALLOW_ORIGINS: t.List[str] = ["*"] + CORS_ALLOW_METHODS: t.List[str] = ["*"] + CORS_ALLOW_HEADERS: t.List[str] = ["*"] + ALLOWED_HOSTS: t.List[str] = ["*"] + + # Application middlewares + MIDDLEWARE: t.List[t.Union[str, Middleware]] = [ + "ellar.core.middleware.trusted_host:trusted_host_middleware", + "ellar.core.middleware.cors:cors_middleware", + "ellar.core.middleware.errors:server_error_middleware", + "ellar.core.middleware.versioning:versioning_middleware", + "ellar.auth.middleware.session:session_middleware", + "ellar.auth.middleware.auth:identity_middleware", + "ellar.core.middleware.exceptions:exception_middleware", + ] + + # A dictionary mapping either integer status codes, + # or exception class types onto callables which handle the exceptions. + # Exception handler callables should be of the form + # `handler(context:IExecutionContext, exc: Exception) -> response` + # and may be either standard functions, or async functions. + EXCEPTION_HANDLERS: t.List[t.Union[str, IExceptionHandler]] = [ + "ellar.core.exceptions:error_404_handler" + ] + + # Object Serializer custom encoders + SERIALIZER_CUSTOM_ENCODER: t.Dict[t.Any, t.Callable[[t.Any], t.Any]] = ( + encoders_by_type + ) +``` + +!!! tip + You can copy this configuration as a starting point and modify only the values you need to change for your application. From 75869adfaf16931d32cff6fdd27419ecb3290a1f Mon Sep 17 00:00:00 2001 From: Ezeudoh Tochukwu Date: Tue, 30 Dec 2025 08:20:29 +0100 Subject: [PATCH 3/7] removed coded default configs --- ellar/core/conf/app_settings_models.py | 58 -------------------------- 1 file changed, 58 deletions(-) diff --git a/ellar/core/conf/app_settings_models.py b/ellar/core/conf/app_settings_models.py index c4154fdc..7cbd5ccc 100644 --- a/ellar/core/conf/app_settings_models.py +++ b/ellar/core/conf/app_settings_models.py @@ -1,4 +1,3 @@ -import secrets import typing as t from ellar.common import EllarInterceptor, GuardCanActivate @@ -254,60 +253,3 @@ def pre_cache_validate(cls, value: t.Dict) -> t.Any: ConfigSchema.model_rebuild() - - -def default_config_settings(): - from ellar.common import JSONResponse - from ellar.core.versioning import DefaultAPIVersioning - from ellar.pydantic import ENCODERS_BY_TYPE as encoders_by_type - - return { - "DEBUG": False, - "DEFAULT_JSON_CLASS": JSONResponse, - "SECRET_KEY": secrets.token_hex(16), - # injector auto_bind = True allows you to resolve types that are not registered on the container - # For more info, read: https://injector.readthedocs.io/en/latest/index.html - "INJECTOR_AUTO_BIND": False, - # jinja Environment options - # https://jinja.palletsprojects.com/en/3.0.x/api/#high-level-api - "JINJA_TEMPLATES_OPTIONS": {}, - # Injects context to jinja templating context values - "TEMPLATES_CONTEXT_PROCESSORS": [ - "ellar.core.templating.context_processors:request_context", - "ellar.core.templating.context_processors:user", - "ellar.core.templating.context_processors:request_state", - ], - # Application route versioning scheme - "VERSIONING_SCHEME": DefaultAPIVersioning(), - # Enable or Disable Application Router route searching by appending backslash - "REDIRECT_SLASHES": False, - # Define references to static folders in python packages. - # eg STATIC_FOLDER_PACKAGES = [('boostrap4', 'statics')] - "STATIC_FOLDER_PACKAGES": [], - # Define references to static folders defined within the project - "STATIC_DIRECTORIES": [], - # static route path - "STATIC_MOUNT_PATH": "/static", - "CORS_ALLOW_ORIGINS": ["*"], - "CORS_ALLOW_METHODS": ["*"], - "CORS_ALLOW_HEADERS": ["*"], - "ALLOWED_HOSTS": ["*"], - # Application middlewares - "MIDDLEWARE": [ - "ellar.core.middleware.trusted_host:trusted_host_middleware", - "ellar.core.middleware.cors:cors_middleware", - "ellar.core.middleware.errors:server_error_middleware", - "ellar.core.middleware.versioning:versioning_middleware", - "ellar.auth.middleware.session:session_middleware", - "ellar.auth.middleware.auth:identity_middleware", - "ellar.core.middleware.exceptions:exception_middleware", - ], - # A dictionary mapping either integer status codes, - # or exception class types onto callables which handle the exceptions. - # Exception handler callables should be of the form - # `handler(context:IExecutionContext, exc: Exception) -> response` - # and may be either standard functions, or async functions. - "EXCEPTION_HANDLERS": ["ellar.core.exceptions:error_404_handler"], - # Object Serializer custom encoders - "SERIALIZER_CUSTOM_ENCODER": encoders_by_type, - } From 8d3d8716999c7af1ef03b4ff4996e0402303b2f5 Mon Sep 17 00:00:00 2001 From: Ezeudoh Tochukwu Date: Thu, 1 Jan 2026 09:07:58 +0100 Subject: [PATCH 4/7] fixing ci tests --- ellar/socket_io/testing/module.py | 42 ++++++++++++++++++++++++++ tests/conftest.py | 16 ++++++++++ tests/test_socket_io/test_operation.py | 38 ++++++++++++----------- 3 files changed, 78 insertions(+), 18 deletions(-) diff --git a/ellar/socket_io/testing/module.py b/ellar/socket_io/testing/module.py index bf507564..37405f54 100644 --- a/ellar/socket_io/testing/module.py +++ b/ellar/socket_io/testing/module.py @@ -1,9 +1,17 @@ import typing as t from contextlib import asynccontextmanager +from pathlib import Path import socketio from ellar.testing.module import Test, TestingModule from ellar.testing.uvicorn_server import EllarUvicornServer +from starlette.routing import Host, Mount + +if t.TYPE_CHECKING: # pragma: no cover + from ellar.common import ControllerBase, GuardCanActivate, ModuleRouter + from ellar.core import ModuleBase + from ellar.core.routing import EllarControllerMount + from ellar.di import ProviderConfig class RunWithServerContext: @@ -62,3 +70,37 @@ async def run_with_server( class TestGateway(Test): TESTING_MODULE = SocketIOTestingModule + + @classmethod + def create_test_module( + cls, + controllers: t.Sequence[t.Union[t.Type["ControllerBase"], t.Type]] = (), + routers: t.Sequence[ + t.Union["ModuleRouter", "EllarControllerMount", Mount, Host, t.Callable] + ] = (), + providers: t.Sequence[t.Union[t.Type, "ProviderConfig"]] = (), + template_folder: t.Optional[str] = "templates", + base_directory: t.Optional[t.Union[Path, str]] = None, + static_folder: str = "static", + modules: t.Sequence[t.Union[t.Type, t.Any]] = (), + application_module: t.Optional[t.Union[t.Type["ModuleBase"], str]] = None, + global_guards: t.Optional[ + t.List[t.Union[t.Type["GuardCanActivate"], "GuardCanActivate"]] + ] = None, + config_module: t.Optional[t.Union[str, t.Dict]] = None, + ) -> SocketIOTestingModule: + return t.cast( + SocketIOTestingModule, + super().create_test_module( + controllers=controllers, + routers=routers, + providers=providers, + template_folder=template_folder, + base_directory=base_directory, + static_folder=static_folder, + modules=modules, + application_module=application_module, + global_guards=global_guards, + config_module=config_module, + ), + ) diff --git a/tests/conftest.py b/tests/conftest.py index 89ee3650..5f168c9e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,6 @@ +import contextlib import functools +import socket from pathlib import PurePath, PurePosixPath, PureWindowsPath from uuid import uuid4 @@ -52,3 +54,17 @@ def reflect_context(): async def async_reflect_context(): async with reflect.async_context(): yield + + +def _unused_port(socket_type: int) -> int: + """Find an unused localhost port from 1024-65535 and return it.""" + with contextlib.closing(socket.socket(type=socket_type)) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +# This was copied from pytest-asyncio. +# Ref.: https://github.com/pytest-dev/pytest-asyncio/blob/25d9592286682bc6dbfbf291028ff7a9594cf283/pytest_asyncio/plugin.py#L525-L527 +@pytest.fixture +def unused_tcp_port() -> int: + return _unused_port(socket.SOCK_STREAM) diff --git a/tests/test_socket_io/test_operation.py b/tests/test_socket_io/test_operation.py index d1ee33c5..be4469c0 100644 --- a/tests/test_socket_io/test_operation.py +++ b/tests/test_socket_io/test_operation.py @@ -20,12 +20,12 @@ class TestEventGateway: test_client = TestGateway.create_test_module(controllers=[EventGateway]) - async def test_socket_connection_work(self): + async def test_socket_connection_work(self, unused_tcp_port): my_response_message = [] connected_called = False disconnected_called = False - async with self.test_client.run_with_server() as ctx: + async with self.test_client.run_with_server(port=unused_tcp_port) as ctx: @ctx.sio.event async def my_response(message): @@ -52,11 +52,11 @@ async def connect(*args): ] assert disconnected_called and connected_called - async def test_broadcast_work(self): + async def test_broadcast_work(self, unused_tcp_port): sio_1_response_message = [] sio_2_response_message = [] - async with self.test_client.run_with_server() as ctx: + async with self.test_client.run_with_server(port=unused_tcp_port) as ctx: ctx_2 = ctx.new_socket_client_context() @ctx.sio.on("my_response") @@ -94,10 +94,10 @@ async def my_response_case_2(message): class TestGatewayWithGuards: test_client = TestGateway.create_test_module(controllers=[GatewayWithGuards]) - async def test_socket_connection_work(self): + async def test_socket_connection_work(self, unused_tcp_port): my_response_message = [] - async with self.test_client.run_with_server() as ctx: + async with self.test_client.run_with_server(port=unused_tcp_port) as ctx: @ctx.sio.event async def my_response(message): @@ -113,10 +113,10 @@ async def my_response(message): {"auth-key": "supersecret", "data": "Testing Broadcast"} ] - async def test_event_with_header_work(self): + async def test_event_with_header_work(self, unused_tcp_port): my_response_message = [] - async with self.test_client.run_with_server() as ctx: + async with self.test_client.run_with_server(port=unused_tcp_port) as ctx: @ctx.sio.event async def my_response(message): @@ -132,10 +132,10 @@ async def my_response(message): {"data": "Testing Broadcast", "x_auth_key": "supersecret"} ] - async def test_event_with_plain_response(self): + async def test_event_with_plain_response(self, unused_tcp_port): my_response_message = [] - async with self.test_client.run_with_server() as ctx: + async with self.test_client.run_with_server(port=unused_tcp_port) as ctx: @ctx.sio.on("my_plain_response") async def message_receive(message): @@ -151,10 +151,10 @@ async def message_receive(message): {"data": "Testing Broadcast", "x_auth_key": "supersecret"} ] - async def test_failed_to_connect(self): + async def test_failed_to_connect(self, unused_tcp_port): my_response_message = [] - async with self.test_client.run_with_server() as ctx: + async with self.test_client.run_with_server(port=unused_tcp_port) as ctx: ctx = typing.cast(RunWithServerContext, ctx) @ctx.sio.on("error") @@ -169,10 +169,10 @@ async def error(message): assert my_response_message == [{"code": 1011, "reason": "Authorization Failed"}] - async def test_failed_process_message_sent(self): + async def test_failed_process_message_sent(self, unused_tcp_port): my_response_message = [] - async with self.test_client.run_with_server() as ctx: + async with self.test_client.run_with_server(port=unused_tcp_port) as ctx: ctx = typing.cast(RunWithServerContext, ctx) @ctx.sio.on("error") @@ -224,13 +224,15 @@ class TestGatewayExceptions: ), ], ) - async def test_exception_handling_works_debug_true_or_false(self, debug, result): + async def test_exception_handling_works_debug_true_or_false( + self, debug, result, unused_tcp_port + ): test_client = TestGateway.create_test_module( controllers=[GatewayOthers], config_module={"DEBUG": debug} ) my_response_message = [] - async with test_client.run_with_server() as ctx: + async with test_client.run_with_server(port=unused_tcp_port) as ctx: ctx = typing.cast(RunWithServerContext, ctx) ctx2 = ctx.new_socket_client_context() @@ -253,11 +255,11 @@ async def error_2(message): assert my_response_message == result - async def test_message_with_extra_args(self): + async def test_message_with_extra_args(self, unused_tcp_port): test_client = TestGateway.create_test_module(controllers=[GatewayOthers]) my_response_message = [] - async with test_client.run_with_server() as ctx: + async with test_client.run_with_server(port=unused_tcp_port) as ctx: ctx = typing.cast(RunWithServerContext, ctx) @ctx.sio.on("error") From bf9404cf6b639e65abd61d8fb7f7df58bdd1a574 Mon Sep 17 00:00:00 2001 From: Ezeudoh Tochukwu Date: Thu, 1 Jan 2026 09:13:39 +0100 Subject: [PATCH 5/7] python-socketio set at 5.14.1 to be upgraded later --- requirements-tests.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-tests.txt b/requirements-tests.txt index 8c88e8c3..e79199e0 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -12,7 +12,7 @@ pytest >= 6.2.4,< 9.0.0 pytest-asyncio pytest-cov >= 2.12.0,< 8.0.0 python-multipart >= 0.0.5 -python-socketio +python-socketio==5.14.1 regex==2025.9.18 ruff ==0.14.3 types-dataclasses ==0.6.6 From 1f1f9c0655a6514d12640554ad9cad39ee56ca0b Mon Sep 17 00:00:00 2001 From: Ezeudoh Tochukwu Date: Thu, 1 Jan 2026 09:23:03 +0100 Subject: [PATCH 6/7] fixing ci tests --- .github/workflows/test_full.yml | 2 +- requirements-tests.txt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test_full.yml b/.github/workflows/test_full.yml index 58229da2..2bf26557 100644 --- a/.github/workflows/test_full.yml +++ b/.github/workflows/test_full.yml @@ -31,7 +31,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: 3.9 + python-version: '3.13' - name: Install Flit run: pip install flit - name: Install Dependencies diff --git a/requirements-tests.txt b/requirements-tests.txt index e79199e0..56682afa 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -1,4 +1,4 @@ -aiohttp == 3.10.5 +aiohttp == 3.13.2 anyio[trio] >= 3.2.1 argon2-cffi == 25.1.0 autoflake @@ -12,7 +12,7 @@ pytest >= 6.2.4,< 9.0.0 pytest-asyncio pytest-cov >= 2.12.0,< 8.0.0 python-multipart >= 0.0.5 -python-socketio==5.14.1 +python-socketio==5.16.0 regex==2025.9.18 ruff ==0.14.3 types-dataclasses ==0.6.6 @@ -21,4 +21,4 @@ types-redis ==4.6.0.20241004 # types types-ujson ==5.10.0.20250822 ujson >= 4.0.1 -uvicorn[standard] == 0.38.0 +uvicorn[standard] == 0.40.0 From 9e09fb2f196820e4953b6e05d3937e741888f401 Mon Sep 17 00:00:00 2001 From: Ezeudoh Tochukwu Date: Thu, 1 Jan 2026 09:25:54 +0100 Subject: [PATCH 7/7] fixing ci tests --- requirements-tests.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-tests.txt b/requirements-tests.txt index 56682afa..b265aadb 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -21,4 +21,4 @@ types-redis ==4.6.0.20241004 # types types-ujson ==5.10.0.20250822 ujson >= 4.0.1 -uvicorn[standard] == 0.40.0 +uvicorn[standard] >= 0.38.0