Skip to content

Commit 1bcf647

Browse files
authored
Merge pull request #310 from python-ellar/api_versioning_resolution
fixed keyerror when resolving api versioning
2 parents 651308a + 35056b1 commit 1bcf647

File tree

9 files changed

+173
-25
lines changed

9 files changed

+173
-25
lines changed

.github/workflows/test_full.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ jobs:
3131
- name: Set up Python
3232
uses: actions/setup-python@v6
3333
with:
34-
python-version: 3.9
34+
python-version: '3.13'
3535
- name: Install Flit
3636
run: pip install flit
3737
- name: Install Dependencies

docs/techniques/configurations.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,3 +304,91 @@ application = AppFactory.create_from_app_module(
304304
)
305305
)
306306
```
307+
308+
## **Complete Configuration Example**
309+
310+
Below is a complete configuration example showing all available configuration options with their default values:
311+
312+
```python
313+
import typing as t
314+
315+
from ellar.common import IExceptionHandler, JSONResponse
316+
from ellar.core import ConfigDefaultTypesMixin
317+
from ellar.core.versioning import BaseAPIVersioning, DefaultAPIVersioning
318+
from ellar.pydantic import ENCODERS_BY_TYPE as encoders_by_type
319+
from starlette.middleware import Middleware
320+
from starlette.requests import Request
321+
322+
323+
class BaseConfig(ConfigDefaultTypesMixin):
324+
DEBUG: bool = False
325+
326+
DEFAULT_JSON_CLASS: t.Type[JSONResponse] = JSONResponse
327+
SECRET_KEY: str = "your-secret-key-here"
328+
329+
# injector auto_bind = True allows you to resolve types that are not registered on the container
330+
# For more info, read: https://injector.readthedocs.io/en/latest/index.html
331+
INJECTOR_AUTO_BIND = False
332+
333+
# jinja Environment options
334+
# https://jinja.palletsprojects.com/en/3.0.x/api/#high-level-api
335+
JINJA_TEMPLATES_OPTIONS: t.Dict[str, t.Any] = {}
336+
337+
# Injects context to jinja templating context values
338+
TEMPLATES_CONTEXT_PROCESSORS: t.List[
339+
t.Union[str, t.Callable[[t.Union[Request]], t.Dict[str, t.Any]]]
340+
] = [
341+
"ellar.core.templating.context_processors:request_context",
342+
"ellar.core.templating.context_processors:user",
343+
"ellar.core.templating.context_processors:request_state",
344+
]
345+
346+
# Application route versioning scheme
347+
VERSIONING_SCHEME: BaseAPIVersioning = DefaultAPIVersioning()
348+
349+
# Enable or Disable Application Router route searching by appending backslash
350+
REDIRECT_SLASHES: bool = False
351+
352+
# Define references to static folders in python packages.
353+
# eg STATIC_FOLDER_PACKAGES = [('boostrap4', 'statics')]
354+
STATIC_FOLDER_PACKAGES: t.Optional[t.List[t.Union[str, t.Tuple[str, str]]]] = []
355+
356+
# Define references to static folders defined within the project
357+
STATIC_DIRECTORIES: t.Optional[t.List[t.Union[str, t.Any]]] = []
358+
359+
# static route path
360+
STATIC_MOUNT_PATH: str = "/static"
361+
362+
CORS_ALLOW_ORIGINS: t.List[str] = ["*"]
363+
CORS_ALLOW_METHODS: t.List[str] = ["*"]
364+
CORS_ALLOW_HEADERS: t.List[str] = ["*"]
365+
ALLOWED_HOSTS: t.List[str] = ["*"]
366+
367+
# Application middlewares
368+
MIDDLEWARE: t.List[t.Union[str, Middleware]] = [
369+
"ellar.core.middleware.trusted_host:trusted_host_middleware",
370+
"ellar.core.middleware.cors:cors_middleware",
371+
"ellar.core.middleware.errors:server_error_middleware",
372+
"ellar.core.middleware.versioning:versioning_middleware",
373+
"ellar.auth.middleware.session:session_middleware",
374+
"ellar.auth.middleware.auth:identity_middleware",
375+
"ellar.core.middleware.exceptions:exception_middleware",
376+
]
377+
378+
# A dictionary mapping either integer status codes,
379+
# or exception class types onto callables which handle the exceptions.
380+
# Exception handler callables should be of the form
381+
# `handler(context:IExecutionContext, exc: Exception) -> response`
382+
# and may be either standard functions, or async functions.
383+
EXCEPTION_HANDLERS: t.List[t.Union[str, IExceptionHandler]] = [
384+
"ellar.core.exceptions:error_404_handler"
385+
]
386+
387+
# Object Serializer custom encoders
388+
SERIALIZER_CUSTOM_ENCODER: t.Dict[t.Any, t.Callable[[t.Any], t.Any]] = (
389+
encoders_by_type
390+
)
391+
```
392+
393+
!!! tip
394+
You can copy this configuration as a starting point and modify only the values you need to change for your application.

ellar/core/conf/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class Config(ConfigDefaultTypesMixin):
2222

2323
def __init__(
2424
self,
25-
config_module: t.Optional[str] = None,
25+
config_module: t.Optional[t.Union[str, dict]] = None,
2626
config_prefix: t.Optional[str] = None,
2727
**mapping: t.Any,
2828
):

ellar/core/routing/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ def matches(self, scope: TScope) -> t.Tuple[Match, TScope]:
120120
if match[0] is Match.FULL:
121121
version_scheme_resolver: "BaseAPIVersioningResolver" = t.cast(
122122
"BaseAPIVersioningResolver",
123-
scope[constants.SCOPE_API_VERSIONING_RESOLVER],
123+
scope.get(constants.SCOPE_API_VERSIONING_RESOLVER),
124124
)
125125
if not version_scheme_resolver.can_activate(
126126
route_versions=self.allowed_version

ellar/core/routing/mount.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ def router_default_decorator(func: ASGIApp) -> ASGIApp:
116116
@functools.wraps(func)
117117
async def _wrap(scope: TScope, receive: TReceive, send: TSend) -> None:
118118
version_scheme_resolver: "BaseAPIVersioningResolver" = t.cast(
119-
"BaseAPIVersioningResolver", scope[SCOPE_API_VERSIONING_RESOLVER]
119+
"BaseAPIVersioningResolver", scope.get(SCOPE_API_VERSIONING_RESOLVER)
120120
)
121121
if version_scheme_resolver and version_scheme_resolver.matched_any_route:
122122
version_scheme_resolver.raise_exception()

ellar/socket_io/testing/module.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
import typing as t
22
from contextlib import asynccontextmanager
3+
from pathlib import Path
34

45
import socketio
56
from ellar.testing.module import Test, TestingModule
67
from ellar.testing.uvicorn_server import EllarUvicornServer
8+
from starlette.routing import Host, Mount
9+
10+
if t.TYPE_CHECKING: # pragma: no cover
11+
from ellar.common import ControllerBase, GuardCanActivate, ModuleRouter
12+
from ellar.core import ModuleBase
13+
from ellar.core.routing import EllarControllerMount
14+
from ellar.di import ProviderConfig
715

816

917
class RunWithServerContext:
@@ -62,3 +70,37 @@ async def run_with_server(
6270

6371
class TestGateway(Test):
6472
TESTING_MODULE = SocketIOTestingModule
73+
74+
@classmethod
75+
def create_test_module(
76+
cls,
77+
controllers: t.Sequence[t.Union[t.Type["ControllerBase"], t.Type]] = (),
78+
routers: t.Sequence[
79+
t.Union["ModuleRouter", "EllarControllerMount", Mount, Host, t.Callable]
80+
] = (),
81+
providers: t.Sequence[t.Union[t.Type, "ProviderConfig"]] = (),
82+
template_folder: t.Optional[str] = "templates",
83+
base_directory: t.Optional[t.Union[Path, str]] = None,
84+
static_folder: str = "static",
85+
modules: t.Sequence[t.Union[t.Type, t.Any]] = (),
86+
application_module: t.Optional[t.Union[t.Type["ModuleBase"], str]] = None,
87+
global_guards: t.Optional[
88+
t.List[t.Union[t.Type["GuardCanActivate"], "GuardCanActivate"]]
89+
] = None,
90+
config_module: t.Optional[t.Union[str, t.Dict]] = None,
91+
) -> SocketIOTestingModule:
92+
return t.cast(
93+
SocketIOTestingModule,
94+
super().create_test_module(
95+
controllers=controllers,
96+
routers=routers,
97+
providers=providers,
98+
template_folder=template_folder,
99+
base_directory=base_directory,
100+
static_folder=static_folder,
101+
modules=modules,
102+
application_module=application_module,
103+
global_guards=global_guards,
104+
config_module=config_module,
105+
),
106+
)

requirements-tests.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
aiohttp == 3.10.5
1+
aiohttp == 3.13.2
22
anyio[trio] >= 3.2.1
33
argon2-cffi == 25.1.0
44
autoflake
@@ -12,7 +12,7 @@ pytest >= 6.2.4,< 9.0.0
1212
pytest-asyncio
1313
pytest-cov >= 2.12.0,< 8.0.0
1414
python-multipart >= 0.0.5
15-
python-socketio
15+
python-socketio==5.16.0
1616
regex==2025.9.18
1717
ruff ==0.14.7
1818
types-dataclasses ==0.6.6
@@ -21,4 +21,4 @@ types-redis ==4.6.0.20241004
2121
# types
2222
types-ujson ==5.10.0.20250822
2323
ujson >= 4.0.1
24-
uvicorn[standard] == 0.39.0
24+
uvicorn[standard] >= 0.39.0

tests/conftest.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import contextlib
12
import functools
3+
import socket
24
from pathlib import PurePath, PurePosixPath, PureWindowsPath
35
from uuid import uuid4
46

@@ -52,3 +54,17 @@ def reflect_context():
5254
async def async_reflect_context():
5355
async with reflect.async_context():
5456
yield
57+
58+
59+
def _unused_port(socket_type: int) -> int:
60+
"""Find an unused localhost port from 1024-65535 and return it."""
61+
with contextlib.closing(socket.socket(type=socket_type)) as sock:
62+
sock.bind(("127.0.0.1", 0))
63+
return sock.getsockname()[1]
64+
65+
66+
# This was copied from pytest-asyncio.
67+
# Ref.: https://github.com/pytest-dev/pytest-asyncio/blob/25d9592286682bc6dbfbf291028ff7a9594cf283/pytest_asyncio/plugin.py#L525-L527
68+
@pytest.fixture
69+
def unused_tcp_port() -> int:
70+
return _unused_port(socket.SOCK_STREAM)

tests/test_socket_io/test_operation.py

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@
2020
class TestEventGateway:
2121
test_client = TestGateway.create_test_module(controllers=[EventGateway])
2222

23-
async def test_socket_connection_work(self):
23+
async def test_socket_connection_work(self, unused_tcp_port):
2424
my_response_message = []
2525
connected_called = False
2626
disconnected_called = False
2727

28-
async with self.test_client.run_with_server() as ctx:
28+
async with self.test_client.run_with_server(port=unused_tcp_port) as ctx:
2929

3030
@ctx.sio.event
3131
async def my_response(message):
@@ -52,11 +52,11 @@ async def connect(*args):
5252
]
5353
assert disconnected_called and connected_called
5454

55-
async def test_broadcast_work(self):
55+
async def test_broadcast_work(self, unused_tcp_port):
5656
sio_1_response_message = []
5757
sio_2_response_message = []
5858

59-
async with self.test_client.run_with_server() as ctx:
59+
async with self.test_client.run_with_server(port=unused_tcp_port) as ctx:
6060
ctx_2 = ctx.new_socket_client_context()
6161

6262
@ctx.sio.on("my_response")
@@ -94,10 +94,10 @@ async def my_response_case_2(message):
9494
class TestGatewayWithGuards:
9595
test_client = TestGateway.create_test_module(controllers=[GatewayWithGuards])
9696

97-
async def test_socket_connection_work(self):
97+
async def test_socket_connection_work(self, unused_tcp_port):
9898
my_response_message = []
9999

100-
async with self.test_client.run_with_server() as ctx:
100+
async with self.test_client.run_with_server(port=unused_tcp_port) as ctx:
101101

102102
@ctx.sio.event
103103
async def my_response(message):
@@ -113,10 +113,10 @@ async def my_response(message):
113113
{"auth-key": "supersecret", "data": "Testing Broadcast"}
114114
]
115115

116-
async def test_event_with_header_work(self):
116+
async def test_event_with_header_work(self, unused_tcp_port):
117117
my_response_message = []
118118

119-
async with self.test_client.run_with_server() as ctx:
119+
async with self.test_client.run_with_server(port=unused_tcp_port) as ctx:
120120

121121
@ctx.sio.event
122122
async def my_response(message):
@@ -132,10 +132,10 @@ async def my_response(message):
132132
{"data": "Testing Broadcast", "x_auth_key": "supersecret"}
133133
]
134134

135-
async def test_event_with_plain_response(self):
135+
async def test_event_with_plain_response(self, unused_tcp_port):
136136
my_response_message = []
137137

138-
async with self.test_client.run_with_server() as ctx:
138+
async with self.test_client.run_with_server(port=unused_tcp_port) as ctx:
139139

140140
@ctx.sio.on("my_plain_response")
141141
async def message_receive(message):
@@ -151,10 +151,10 @@ async def message_receive(message):
151151
{"data": "Testing Broadcast", "x_auth_key": "supersecret"}
152152
]
153153

154-
async def test_failed_to_connect(self):
154+
async def test_failed_to_connect(self, unused_tcp_port):
155155
my_response_message = []
156156

157-
async with self.test_client.run_with_server() as ctx:
157+
async with self.test_client.run_with_server(port=unused_tcp_port) as ctx:
158158
ctx = typing.cast(RunWithServerContext, ctx)
159159

160160
@ctx.sio.on("error")
@@ -169,10 +169,10 @@ async def error(message):
169169

170170
assert my_response_message == [{"code": 1011, "reason": "Authorization Failed"}]
171171

172-
async def test_failed_process_message_sent(self):
172+
async def test_failed_process_message_sent(self, unused_tcp_port):
173173
my_response_message = []
174174

175-
async with self.test_client.run_with_server() as ctx:
175+
async with self.test_client.run_with_server(port=unused_tcp_port) as ctx:
176176
ctx = typing.cast(RunWithServerContext, ctx)
177177

178178
@ctx.sio.on("error")
@@ -224,13 +224,15 @@ class TestGatewayExceptions:
224224
),
225225
],
226226
)
227-
async def test_exception_handling_works_debug_true_or_false(self, debug, result):
227+
async def test_exception_handling_works_debug_true_or_false(
228+
self, debug, result, unused_tcp_port
229+
):
228230
test_client = TestGateway.create_test_module(
229231
controllers=[GatewayOthers], config_module={"DEBUG": debug}
230232
)
231233
my_response_message = []
232234

233-
async with test_client.run_with_server() as ctx:
235+
async with test_client.run_with_server(port=unused_tcp_port) as ctx:
234236
ctx = typing.cast(RunWithServerContext, ctx)
235237
ctx2 = ctx.new_socket_client_context()
236238

@@ -253,11 +255,11 @@ async def error_2(message):
253255

254256
assert my_response_message == result
255257

256-
async def test_message_with_extra_args(self):
258+
async def test_message_with_extra_args(self, unused_tcp_port):
257259
test_client = TestGateway.create_test_module(controllers=[GatewayOthers])
258260
my_response_message = []
259261

260-
async with test_client.run_with_server() as ctx:
262+
async with test_client.run_with_server(port=unused_tcp_port) as ctx:
261263
ctx = typing.cast(RunWithServerContext, ctx)
262264

263265
@ctx.sio.on("error")

0 commit comments

Comments
 (0)