python-neva 5.1.0__py3-none-any.whl → 5.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -152,10 +152,15 @@ request-scoped container to another framework wants that, not `container`.
152
152
 
153
153
  ## Registration is closed after boot
154
154
 
155
- `register()` is idempotent for an already-registered class, but **returns `Err` once the
156
- application has booted**: dishka validates the graph at build time, and the booted container
157
- holds APP-scoped instances a rebuild would orphan. Declare providers in config, or register
158
- before entering `lifespan()`.
155
+ `register()` **returns `Err` once the application has booted**: dishka validates the graph at
156
+ build time, and the booted container holds APP-scoped instances a rebuild would orphan. Declare
157
+ providers in config, or register before entering `lifespan()`.
158
+
159
+ One case slips past that, and the order is what makes it: the already-registered check is read
160
+ **first**, so re-registering a class the application already holds returns `Ok(existing)` even
161
+ booted. Nothing is rebuilt, so nothing is at risk. It does mean a test asserting the refusal
162
+ must pass a provider the application never registered: hand it one the config or the setup
163
+ already installed and the assertion comes back `Ok`, proving nothing.
159
164
 
160
165
  ## Typed config shapes
161
166
 
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  id: testing
3
3
  title: Testing
4
- requires: python-neva>=4.0
5
- triggers: [writing a test, TestCase, RefreshDatabase, faking a facade, database isolation in tests, create_config]
4
+ requires: python-neva>=5.2
5
+ triggers: [writing a test, TestCase, RefreshDatabase, faking a facade, database isolation in tests, create_config, boot_application]
6
6
  priority: 60
7
- verified_by: [tests/testing/test_test_case.py, tests/testing/test_application_reuse.py, tests/testing/test_create_config_migration.py, tests/testing/test_refresh_database.py, tests/testing/test_facade_restore.py, tests/testing/test_event_fake.py, tests/testing/test_fixtures.py]
7
+ verified_by: [tests/testing/test_test_case.py, tests/testing/test_boot_application.py, tests/testing/test_application_reuse.py, tests/testing/test_create_config_migration.py, tests/testing/test_refresh_database.py, tests/testing/test_facade_restore.py, tests/testing/test_event_fake.py, tests/testing/test_fixtures.py]
8
8
  ---
9
9
 
10
10
  # Testing
@@ -66,6 +66,33 @@ naming the fix. There is no instance-method fallback and no deprecation path —
66
66
  Omit the override entirely and the default writes a minimal `app.py` / `providers.py` into
67
67
  `tmp_path`.
68
68
 
69
+ ## boot_application picks the application, not just its config
70
+
71
+ `create_config` says what configuration the class runs on; `boot_application` says what
72
+ application runs over it, and which lifespan holds it open. Override it when the thing under
73
+ test **owns** an `Application` instead of being one — an HTTP app, whose own lifespan is what
74
+ seeds its per-request state — so entering `Application.lifespan()` would boot the container and
75
+ skip everything wrapped around it.
76
+
77
+ ```python
78
+ class HttpTestCase(TestCase):
79
+ @override
80
+ @classmethod
81
+ @asynccontextmanager
82
+ async def boot_application(cls, config_path: Path) -> AsyncIterator[Application]:
83
+ webapp = App(config_path=config_path)
84
+ async with webapp.router.lifespan_context(webapp):
85
+ yield webapp.application
86
+ ```
87
+
88
+ Yield the application `self.app` and the facade root must point at. It is a **context manager**,
89
+ so `@classmethod` and `@asynccontextmanager` both belong on the override — a factory returning
90
+ an application would hand the lifespan back to `TestCase`. `FreshApplication` routes through the
91
+ same hook, once per test.
92
+
93
+ Registration order still holds: register providers and include routers *before* entering the
94
+ lifespan. `Application.register` returns `Err` once booted.
95
+
69
96
  ## RefreshDatabase
70
97
 
71
98
  Mix it in **alongside** `TestCase`; it cannot boot an application alone.
neva/testing/test_case.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import inspect
4
4
  from collections.abc import AsyncIterator, Iterator
5
+ from contextlib import asynccontextmanager
5
6
  from pathlib import Path
6
7
  from typing import override
7
8
 
@@ -39,6 +40,10 @@ class TestCase:
39
40
 
40
41
  A class that genuinely needs a fresh application per test -- one varying the
41
42
  *shape* of its config rather than reading it -- inherits `FreshApplication`.
43
+
44
+ Two public override points: `create_config` for the configuration, and
45
+ `boot_application` for the application built over it and the lifespan it is
46
+ held open by.
42
47
  """
43
48
 
44
49
  pytestmark = pytest.mark.asyncio(loop_scope="class")
@@ -103,6 +108,39 @@ class TestCase:
103
108
  )
104
109
  raise TypeError(msg)
105
110
 
111
+ @classmethod
112
+ @asynccontextmanager
113
+ async def boot_application(cls, config_path: Path) -> AsyncIterator[Application]:
114
+ """Build the application under test and hold it booted.
115
+
116
+ The public override point for *what* a class tests and *how* it starts.
117
+ `create_config` decides the configuration; this decides the application.
118
+ Overriding it is how a downstream package tests something that owns an
119
+ `Application` rather than being one -- an HTTP app whose own lifespan has
120
+ to be the one entered, because that is what seeds its per-request state.
121
+
122
+ An override yields the `Application` the facades and `self.app` must point
123
+ at, from inside whatever lifespan it needs::
124
+
125
+ @classmethod
126
+ @asynccontextmanager
127
+ async def boot_application(cls, config_path):
128
+ webapp = App(config_path=config_path)
129
+ async with webapp.router.lifespan_context(webapp):
130
+ yield webapp.application
131
+
132
+ It is a context manager rather than a factory so that the lifespan stays
133
+ the subclass's to choose. A factory would leave `TestCase` entering
134
+ `Application.lifespan()` itself, which boots the container but skips any
135
+ lifespan wrapped around it.
136
+
137
+ Yields:
138
+ The booted application under test.
139
+ """
140
+ app = Application(config_path=config_path)
141
+ async with app.lifespan():
142
+ yield app
143
+
106
144
  @pytest_asyncio.fixture(scope="class", loop_scope="class")
107
145
  @classmethod
108
146
  async def _test_case_application(
@@ -113,8 +151,7 @@ class TestCase:
113
151
  Yields:
114
152
  Application instance with lifespan managed.
115
153
  """
116
- app = Application(config_path=_test_case_config)
117
- async with app.lifespan():
154
+ async with cls.boot_application(_test_case_config) as app:
118
155
  yield app
119
156
 
120
157
  @pytest.fixture(autouse=True)
@@ -174,8 +211,7 @@ class FreshApplication(TestCase):
174
211
  Yields:
175
212
  A freshly built Application, torn down at the end of the test.
176
213
  """
177
- app = Application(config_path=self._build_config(tmp_path))
178
- async with app.lifespan():
214
+ async with self.boot_application(self._build_config(tmp_path)) as app:
179
215
  yield app
180
216
 
181
217
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-neva
3
- Version: 5.1.0
3
+ Version: 5.3.0
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: aiosqlite>=0.20.0
@@ -44,8 +44,8 @@ neva/guidelines/fragments/factories.md,sha256=4ajYgtN1z4Fwb55FnXw7ROGQUrhqE4N9sg
44
44
  neva/guidelines/fragments/observability.md,sha256=RjX73YXulr30v-SCw1J3NHN_f6CGLCKUHQPS-rFeOLE,5760
45
45
  neva/guidelines/fragments/result-option.md,sha256=fGyuGwJmSIqb7gWjecbQXiKBdstJ7p5_k82j8KLOzcc,2809
46
46
  neva/guidelines/fragments/security.md,sha256=Pz9OHB7o7qVE-Pc3JpSNfMwPqov0ZxLlaIxMmsYfxbQ,5994
47
- neva/guidelines/fragments/service-providers.md,sha256=Jdzz4J28PkziEdkVoF3sx_qVUJVBrocVQm8E5mL2tyA,8051
48
- neva/guidelines/fragments/testing.md,sha256=0HZEdv1RmKbdVnzVVEe5ug4AD73gJoD6r74WC9WT5kI,5659
47
+ neva/guidelines/fragments/service-providers.md,sha256=NAJfbChBUq9JC5GyWH7eZbUg55lvVi0tOGnxbvkbQtI,8445
48
+ neva/guidelines/fragments/testing.md,sha256=cFOwNHjjyorpooUyEwsSk3O8XynAlkyk3_AzlQUO_K0,6993
49
49
  neva/obs/__init__.py,sha256=SShyp-wlNt6USx7Eus8_Csy0miEHXOS2YnVbP2EARjs,490
50
50
  neva/obs/config.py,sha256=cFDverbec3QSpLn0txcXnBnMvnUV7dGRTEdVERhl9L4,1026
51
51
  neva/obs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -102,8 +102,8 @@ neva/testing/__init__.py,sha256=QW9inJP1lC_HeoJbKjD_kyHCy4cFV6qt9Rp-EjsKmOY,245
102
102
  neva/testing/fakes.py,sha256=w2dnFaO6x14l0_si7iBNELMCxaFi8VsVOSQ6PmTlclE,2936
103
103
  neva/testing/fixtures.py,sha256=oiPa5ntUkW-SbySDvwEHXtti8NqSwI-R0CT1_ezTx_Y,1445
104
104
  neva/testing/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
105
- neva/testing/test_case.py,sha256=quVjECLWw5ZOnf7kwvtRsyD-_mkyxv5yRuvNSOAJYUI,8145
106
- python_neva-5.1.0.dist-info/METADATA,sha256=M7wOZqbMQilm0N-giNwTqL6JupSTdDzmZIDmkov4IC0,7352
107
- python_neva-5.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
108
- python_neva-5.1.0.dist-info/entry_points.txt,sha256=DmWZ-qNgvl4eUAXxwX4iLgb38e6X-zU8JDx_brlmkyk,45
109
- python_neva-5.1.0.dist-info/RECORD,,
105
+ neva/testing/test_case.py,sha256=GdIKrHz9ZuirJybUuaXbW8o-PhOzkvg-pJwe8PAwFD4,9789
106
+ python_neva-5.3.0.dist-info/METADATA,sha256=oGUx6-CaM5vaFk2bx2Jq8anhB-ZETQCktJrh3XwTJl4,7352
107
+ python_neva-5.3.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
108
+ python_neva-5.3.0.dist-info/entry_points.txt,sha256=DmWZ-qNgvl4eUAXxwX4iLgb38e6X-zU8JDx_brlmkyk,45
109
+ python_neva-5.3.0.dist-info/RECORD,,