python-neva 2.2.0__tar.gz → 2.2.1__tar.gz

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.
Files changed (120) hide show
  1. {python_neva-2.2.0 → python_neva-2.2.1}/PKG-INFO +1 -1
  2. {python_neva-2.2.0 → python_neva-2.2.1}/neva/database/connection.py +7 -4
  3. {python_neva-2.2.0 → python_neva-2.2.1}/neva/database/manager.py +14 -5
  4. {python_neva-2.2.0 → python_neva-2.2.1}/neva/database/transaction.py +37 -0
  5. {python_neva-2.2.0 → python_neva-2.2.1}/neva/testing/test_case.py +1 -0
  6. {python_neva-2.2.0 → python_neva-2.2.1}/pyproject.toml +1 -1
  7. {python_neva-2.2.0 → python_neva-2.2.1}/tests/database/test_sqlalchemy_integration.py +135 -0
  8. {python_neva-2.2.0 → python_neva-2.2.1}/uv.lock +1 -1
  9. {python_neva-2.2.0 → python_neva-2.2.1}/.envrc +0 -0
  10. {python_neva-2.2.0 → python_neva-2.2.1}/.gitignore +0 -0
  11. {python_neva-2.2.0 → python_neva-2.2.1}/.pre-commit-config.yaml +0 -0
  12. {python_neva-2.2.0 → python_neva-2.2.1}/.python-version +0 -0
  13. {python_neva-2.2.0 → python_neva-2.2.1}/README.md +0 -0
  14. {python_neva-2.2.0 → python_neva-2.2.1}/neva/__init__.py +0 -0
  15. {python_neva-2.2.0 → python_neva-2.2.1}/neva/arch/__init__.py +0 -0
  16. {python_neva-2.2.0 → python_neva-2.2.1}/neva/arch/app.py +0 -0
  17. {python_neva-2.2.0 → python_neva-2.2.1}/neva/arch/application.py +0 -0
  18. {python_neva-2.2.0 → python_neva-2.2.1}/neva/arch/config.py +0 -0
  19. {python_neva-2.2.0 → python_neva-2.2.1}/neva/arch/facade.py +0 -0
  20. {python_neva-2.2.0 → python_neva-2.2.1}/neva/arch/faststream.py +0 -0
  21. {python_neva-2.2.0 → python_neva-2.2.1}/neva/arch/service_provider.py +0 -0
  22. {python_neva-2.2.0 → python_neva-2.2.1}/neva/config/__init__.py +0 -0
  23. {python_neva-2.2.0 → python_neva-2.2.1}/neva/config/base_providers.py +0 -0
  24. {python_neva-2.2.0 → python_neva-2.2.1}/neva/config/loader.py +0 -0
  25. {python_neva-2.2.0 → python_neva-2.2.1}/neva/config/repository.py +0 -0
  26. {python_neva-2.2.0 → python_neva-2.2.1}/neva/database/__init__.py +0 -0
  27. {python_neva-2.2.0 → python_neva-2.2.1}/neva/database/config.py +0 -0
  28. {python_neva-2.2.0 → python_neva-2.2.1}/neva/database/provider.py +0 -0
  29. {python_neva-2.2.0 → python_neva-2.2.1}/neva/events/__init__.py +0 -0
  30. {python_neva-2.2.0 → python_neva-2.2.1}/neva/events/dispatcher.py +0 -0
  31. {python_neva-2.2.0 → python_neva-2.2.1}/neva/events/event.py +0 -0
  32. {python_neva-2.2.0 → python_neva-2.2.1}/neva/events/event_registry.py +0 -0
  33. {python_neva-2.2.0 → python_neva-2.2.1}/neva/events/listener.py +0 -0
  34. {python_neva-2.2.0 → python_neva-2.2.1}/neva/events/provider.py +0 -0
  35. {python_neva-2.2.0 → python_neva-2.2.1}/neva/obs/__init__.py +0 -0
  36. {python_neva-2.2.0 → python_neva-2.2.1}/neva/obs/logging/__init__.py +0 -0
  37. {python_neva-2.2.0 → python_neva-2.2.1}/neva/obs/logging/manager.py +0 -0
  38. {python_neva-2.2.0 → python_neva-2.2.1}/neva/obs/logging/provider.py +0 -0
  39. {python_neva-2.2.0 → python_neva-2.2.1}/neva/obs/middleware/__init__.py +0 -0
  40. {python_neva-2.2.0 → python_neva-2.2.1}/neva/obs/middleware/correlation.py +0 -0
  41. {python_neva-2.2.0 → python_neva-2.2.1}/neva/obs/middleware/profiler.py +0 -0
  42. {python_neva-2.2.0 → python_neva-2.2.1}/neva/py.typed +0 -0
  43. {python_neva-2.2.0 → python_neva-2.2.1}/neva/security/__init__.py +0 -0
  44. {python_neva-2.2.0 → python_neva-2.2.1}/neva/security/encryption/__init__.py +0 -0
  45. {python_neva-2.2.0 → python_neva-2.2.1}/neva/security/encryption/encrypter.py +0 -0
  46. {python_neva-2.2.0 → python_neva-2.2.1}/neva/security/encryption/protocol.py +0 -0
  47. {python_neva-2.2.0 → python_neva-2.2.1}/neva/security/hashing/__init__.py +0 -0
  48. {python_neva-2.2.0 → python_neva-2.2.1}/neva/security/hashing/config.py +0 -0
  49. {python_neva-2.2.0 → python_neva-2.2.1}/neva/security/hashing/hash_manager.py +0 -0
  50. {python_neva-2.2.0 → python_neva-2.2.1}/neva/security/hashing/hashers/__init__.py +0 -0
  51. {python_neva-2.2.0 → python_neva-2.2.1}/neva/security/hashing/hashers/argon2.py +0 -0
  52. {python_neva-2.2.0 → python_neva-2.2.1}/neva/security/hashing/hashers/bcrypt.py +0 -0
  53. {python_neva-2.2.0 → python_neva-2.2.1}/neva/security/hashing/hashers/protocol.py +0 -0
  54. {python_neva-2.2.0 → python_neva-2.2.1}/neva/security/provider.py +0 -0
  55. {python_neva-2.2.0 → python_neva-2.2.1}/neva/security/tokens/__init__.py +0 -0
  56. {python_neva-2.2.0 → python_neva-2.2.1}/neva/security/tokens/generate_token.py +0 -0
  57. {python_neva-2.2.0 → python_neva-2.2.1}/neva/security/tokens/hash_token.py +0 -0
  58. {python_neva-2.2.0 → python_neva-2.2.1}/neva/security/tokens/verify_token.py +0 -0
  59. {python_neva-2.2.0 → python_neva-2.2.1}/neva/support/__init__.py +0 -0
  60. {python_neva-2.2.0 → python_neva-2.2.1}/neva/support/accessors.py +0 -0
  61. {python_neva-2.2.0 → python_neva-2.2.1}/neva/support/facade/__init__.py +0 -0
  62. {python_neva-2.2.0 → python_neva-2.2.1}/neva/support/facade/app.py +0 -0
  63. {python_neva-2.2.0 → python_neva-2.2.1}/neva/support/facade/app.pyi +0 -0
  64. {python_neva-2.2.0 → python_neva-2.2.1}/neva/support/facade/config.py +0 -0
  65. {python_neva-2.2.0 → python_neva-2.2.1}/neva/support/facade/config.pyi +0 -0
  66. {python_neva-2.2.0 → python_neva-2.2.1}/neva/support/facade/crypt.py +0 -0
  67. {python_neva-2.2.0 → python_neva-2.2.1}/neva/support/facade/crypt.pyi +0 -0
  68. {python_neva-2.2.0 → python_neva-2.2.1}/neva/support/facade/db.py +0 -0
  69. {python_neva-2.2.0 → python_neva-2.2.1}/neva/support/facade/db.pyi +0 -0
  70. {python_neva-2.2.0 → python_neva-2.2.1}/neva/support/facade/event.py +0 -0
  71. {python_neva-2.2.0 → python_neva-2.2.1}/neva/support/facade/event.pyi +0 -0
  72. {python_neva-2.2.0 → python_neva-2.2.1}/neva/support/facade/hash.py +0 -0
  73. {python_neva-2.2.0 → python_neva-2.2.1}/neva/support/facade/hash.pyi +0 -0
  74. {python_neva-2.2.0 → python_neva-2.2.1}/neva/support/facade/log.py +0 -0
  75. {python_neva-2.2.0 → python_neva-2.2.1}/neva/support/facade/log.pyi +0 -0
  76. {python_neva-2.2.0 → python_neva-2.2.1}/neva/support/results.py +0 -0
  77. {python_neva-2.2.0 → python_neva-2.2.1}/neva/support/strategy.py +0 -0
  78. {python_neva-2.2.0 → python_neva-2.2.1}/neva/support/strconv.py +0 -0
  79. {python_neva-2.2.0 → python_neva-2.2.1}/neva/support/time.py +0 -0
  80. {python_neva-2.2.0 → python_neva-2.2.1}/neva/testing/__init__.py +0 -0
  81. {python_neva-2.2.0 → python_neva-2.2.1}/neva/testing/fakes.py +0 -0
  82. {python_neva-2.2.0 → python_neva-2.2.1}/neva/testing/fixtures.py +0 -0
  83. {python_neva-2.2.0 → python_neva-2.2.1}/neva/testing/http.py +0 -0
  84. {python_neva-2.2.0 → python_neva-2.2.1}/ruff.toml +0 -0
  85. {python_neva-2.2.0 → python_neva-2.2.1}/tests/__init__.py +0 -0
  86. {python_neva-2.2.0 → python_neva-2.2.1}/tests/arch/__init__.py +0 -0
  87. {python_neva-2.2.0 → python_neva-2.2.1}/tests/arch/test_scope.py +0 -0
  88. {python_neva-2.2.0 → python_neva-2.2.1}/tests/config/__init__.py +0 -0
  89. {python_neva-2.2.0 → python_neva-2.2.1}/tests/config/test_loader.py +0 -0
  90. {python_neva-2.2.0 → python_neva-2.2.1}/tests/config/test_repository.py +0 -0
  91. {python_neva-2.2.0 → python_neva-2.2.1}/tests/conftest.py +0 -0
  92. {python_neva-2.2.0 → python_neva-2.2.1}/tests/database/__init__.py +0 -0
  93. {python_neva-2.2.0 → python_neva-2.2.1}/tests/database/conftest.py +0 -0
  94. {python_neva-2.2.0 → python_neva-2.2.1}/tests/database/test_connection_manager.py +0 -0
  95. {python_neva-2.2.0 → python_neva-2.2.1}/tests/database/test_database_manager.py +0 -0
  96. {python_neva-2.2.0 → python_neva-2.2.1}/tests/database/test_edge_cases.py +0 -0
  97. {python_neva-2.2.0 → python_neva-2.2.1}/tests/database/test_multi_connection.py +0 -0
  98. {python_neva-2.2.0 → python_neva-2.2.1}/tests/database/test_transaction.py +0 -0
  99. {python_neva-2.2.0 → python_neva-2.2.1}/tests/database/test_transaction_context.py +0 -0
  100. {python_neva-2.2.0 → python_neva-2.2.1}/tests/database/test_transaction_registry.py +0 -0
  101. {python_neva-2.2.0 → python_neva-2.2.1}/tests/events/__init__.py +0 -0
  102. {python_neva-2.2.0 → python_neva-2.2.1}/tests/events/conftest.py +0 -0
  103. {python_neva-2.2.0 → python_neva-2.2.1}/tests/events/test_deferred.py +0 -0
  104. {python_neva-2.2.0 → python_neva-2.2.1}/tests/events/test_dispatch.py +0 -0
  105. {python_neva-2.2.0 → python_neva-2.2.1}/tests/events/test_event.py +0 -0
  106. {python_neva-2.2.0 → python_neva-2.2.1}/tests/events/test_event_registry.py +0 -0
  107. {python_neva-2.2.0 → python_neva-2.2.1}/tests/events/test_function_listener.py +0 -0
  108. {python_neva-2.2.0 → python_neva-2.2.1}/tests/events/test_immediate.py +0 -0
  109. {python_neva-2.2.0 → python_neva-2.2.1}/tests/obs/__init__.py +0 -0
  110. {python_neva-2.2.0 → python_neva-2.2.1}/tests/obs/test_correlation.py +0 -0
  111. {python_neva-2.2.0 → python_neva-2.2.1}/tests/obs/test_profiler.py +0 -0
  112. {python_neva-2.2.0 → python_neva-2.2.1}/tests/security/__init__.py +0 -0
  113. {python_neva-2.2.0 → python_neva-2.2.1}/tests/security/test_encrypter.py +0 -0
  114. {python_neva-2.2.0 → python_neva-2.2.1}/tests/security/test_hash_manager.py +0 -0
  115. {python_neva-2.2.0 → python_neva-2.2.1}/tests/testing/__init__.py +0 -0
  116. {python_neva-2.2.0 → python_neva-2.2.1}/tests/testing/test_event_fake.py +0 -0
  117. {python_neva-2.2.0 → python_neva-2.2.1}/tests/testing/test_facade_restore.py +0 -0
  118. {python_neva-2.2.0 → python_neva-2.2.1}/tests/testing/test_fixtures.py +0 -0
  119. {python_neva-2.2.0 → python_neva-2.2.1}/tests/testing/test_refresh_database.py +0 -0
  120. {python_neva-2.2.0 → python_neva-2.2.1}/tests/testing/test_test_case.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-neva
3
- Version: 2.2.0
3
+ Version: 2.2.1
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: aiosqlite>=0.20.0
@@ -152,11 +152,11 @@ class ConnectionManager:
152
152
  For nested calls on the same connection, reuses the parent session
153
153
  and begins a savepoint instead of a full transaction.
154
154
 
155
- Raises:
156
- RuntimeError: If no engine has been registered for this connection.
157
-
158
155
  Yields:
159
156
  BoundTransaction: A transaction with a guaranteed non-null session.
157
+
158
+ Raises:
159
+ RuntimeError: If no engine has been registered for this connection.
160
160
  """
161
161
  if self.session_factory is None:
162
162
  raise RuntimeError(
@@ -167,7 +167,10 @@ class ConnectionManager:
167
167
  parent = self.tx_context.current(self.name)
168
168
 
169
169
  match parent:
170
- case Some(parent_tx) if isinstance(parent_tx, BoundTransaction):
170
+ case Some(parent_tx) if (
171
+ isinstance(parent_tx, BoundTransaction)
172
+ and parent_tx.is_accessible_from_current_task
173
+ ):
171
174
  tx = Transaction(self.name)
172
175
  tx.parent = parent
173
176
  bound = tx.begin(parent_tx.session)
@@ -60,15 +60,24 @@ class DatabaseManager:
60
60
  def session(self, connection: str | None = None) -> Option[AsyncSession]:
61
61
  """Returns the current session for a connection.
62
62
 
63
+ Only returns a session if the active transaction was opened by the
64
+ current asyncio task. Transactions inherited from a parent task
65
+ (e.g. via context propagation in Strawberry GraphQL resolvers) are
66
+ not accessible this way — each task must call ``begin()`` to obtain
67
+ its own session.
68
+
63
69
  Args:
64
70
  connection: The connection name. Defaults to "default".
65
71
 
66
72
  Returns:
67
73
  The current session, if any. Returns Nothing if there is no active
68
- bound transaction on the given connection.
74
+ bound transaction on the given connection, or if the active
75
+ transaction was opened by a different asyncio task.
69
76
  """
70
77
  match self.current(connection):
71
- case Some(tx) if isinstance(tx, BoundTransaction):
78
+ case Some(tx) if (
79
+ isinstance(tx, BoundTransaction) and tx.is_accessible_from_current_task
80
+ ):
72
81
  return Some(tx.session)
73
82
  case _:
74
83
  return Nothing()
@@ -93,11 +102,11 @@ class DatabaseManager:
93
102
  Args:
94
103
  name: The connection name.
95
104
 
96
- Raises:
97
- RuntimeError: If no engine has been registered for the connection.
98
-
99
105
  Yields:
100
106
  BoundTransaction: A transaction with a guaranteed non-null session.
107
+
108
+ Raises:
109
+ RuntimeError: If no engine has been registered for the connection.
101
110
  """ # noqa: DOC502 explicit documentation of the possible exception raised by 'begin'
102
111
  async with self.connection(name).begin() as tx:
103
112
  yield tx
@@ -1,5 +1,6 @@
1
1
  """Transaction management systems."""
2
2
 
3
+ import asyncio
3
4
  from collections.abc import Awaitable
4
5
  from dataclasses import dataclass, field
5
6
  from enum import Enum, auto
@@ -128,3 +129,39 @@ class BoundTransaction(Transaction):
128
129
  """Transaction bound to an SQLAlchemy session."""
129
130
 
130
131
  session: AsyncSession
132
+ _owning_task: asyncio.Task[object] | None = field(default=None, init=False)
133
+ _shared: bool = field(default=False, init=False)
134
+
135
+ def __post_init__(self) -> None:
136
+ """Capture the current asyncio task as the owner of this transaction."""
137
+ self._owning_task = asyncio.current_task()
138
+
139
+ def share(self) -> None:
140
+ """Allow any asyncio task to access this transaction's session.
141
+
142
+ By default, a transaction is only accessible from the task that
143
+ created it. Calling this method lifts that restriction, allowing
144
+ other tasks that inherited the transaction context to use the same
145
+ session.
146
+
147
+ This is intended for controlled sequential-access patterns such as
148
+ test isolation wrappers (e.g. ``RefreshDatabase``) where a fixture
149
+ opens a transaction and the test body runs inside it. It must
150
+ **not** be used when multiple tasks may access the session
151
+ concurrently, as ``AsyncSession`` is not concurrent-safe.
152
+ """
153
+ self._shared = True
154
+
155
+ @property
156
+ def is_accessible_from_current_task(self) -> bool:
157
+ """Return True if the current asyncio task may use this transaction's session.
158
+
159
+ Returns:
160
+ True if the transaction was created by the current task, or if
161
+ ``share()`` has been called. False if called from a different
162
+ task without sharing, or from outside any async task.
163
+ """
164
+ if self._shared:
165
+ return True
166
+ current = asyncio.current_task()
167
+ return current is not None and current is self._owning_task
@@ -97,5 +97,6 @@ class RefreshDatabase:
97
97
  """Wrap the test in a DB transaction and roll it back on completion."""
98
98
  db = _test_case_application.make(DatabaseManager).unwrap()
99
99
  async with db.begin() as tx:
100
+ tx.share()
100
101
  yield
101
102
  await tx.rollback()
@@ -7,7 +7,7 @@ packages = ["neva"]
7
7
 
8
8
  [project]
9
9
  name = "python-neva"
10
- version = "2.2.0"
10
+ version = "2.2.1"
11
11
  description = "Add your description here"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.12"
@@ -1,5 +1,6 @@
1
1
  """SQLAlchemy integration tests with real in-memory SQLite."""
2
2
 
3
+ import asyncio
3
4
  from collections.abc import AsyncIterator
4
5
  from typing import final
5
6
 
@@ -177,3 +178,137 @@ class TestDatabaseManagerClose:
177
178
 
178
179
  manager_after = db.connection("default")
179
180
  assert manager_before is not manager_after
181
+
182
+
183
+ class TestConcurrentTaskIsolation:
184
+ async def test_session_not_visible_from_different_task(
185
+ self, db: DatabaseManager
186
+ ) -> None:
187
+ result: list[bool] = []
188
+
189
+ async with db.begin():
190
+
191
+ async def child() -> None:
192
+ result.append(db.session().is_some)
193
+
194
+ await asyncio.create_task(child())
195
+
196
+ assert result == [False]
197
+
198
+ async def test_session_visible_from_same_task(self, db: DatabaseManager) -> None:
199
+ async with db.begin() as tx:
200
+ assert db.session().is_some
201
+ assert db.session().unwrap() is tx.session
202
+
203
+ async def test_begin_from_different_task_creates_new_session(
204
+ self, db: DatabaseManager
205
+ ) -> None:
206
+ outer_session_id: list[int] = []
207
+ inner_session_id: list[int] = []
208
+
209
+ async with db.begin() as outer:
210
+ outer_session_id.append(id(outer.session))
211
+
212
+ async def child() -> None:
213
+ async with db.begin() as inner:
214
+ inner_session_id.append(id(inner.session))
215
+
216
+ await asyncio.create_task(child())
217
+
218
+ assert outer_session_id[0] != inner_session_id[0]
219
+
220
+ async def test_begin_from_different_task_has_no_parent(
221
+ self, db: DatabaseManager
222
+ ) -> None:
223
+ parent_set: list[bool] = []
224
+
225
+ async with db.begin():
226
+
227
+ async def child() -> None:
228
+ async with db.begin() as tx:
229
+ parent_set.append(tx.parent.is_some)
230
+
231
+ await asyncio.create_task(child())
232
+
233
+ assert parent_set == [False]
234
+
235
+ async def test_concurrent_tasks_get_distinct_sessions(
236
+ self, db: DatabaseManager
237
+ ) -> None:
238
+ session_ids: list[int] = []
239
+
240
+ async with db.begin():
241
+
242
+ async def task_work() -> None:
243
+ async with db.begin() as tx:
244
+ session_ids.append(id(tx.session))
245
+
246
+ _ = await asyncio.gather(
247
+ asyncio.create_task(task_work()),
248
+ asyncio.create_task(task_work()),
249
+ asyncio.create_task(task_work()),
250
+ )
251
+
252
+ assert len(session_ids) == 3
253
+ assert len(set(session_ids)) == 3
254
+
255
+ async def test_concurrent_reads_do_not_raise(self, db: DatabaseManager) -> None:
256
+ errors: list[Exception] = []
257
+
258
+ async with db.begin():
259
+
260
+ async def resolver() -> None:
261
+ try:
262
+ async with db.begin() as tx:
263
+ _ = await tx.session.execute(select(User))
264
+ except Exception as exc:
265
+ errors.append(exc)
266
+
267
+ _ = await asyncio.gather(
268
+ asyncio.create_task(resolver()),
269
+ asyncio.create_task(resolver()),
270
+ asyncio.create_task(resolver()),
271
+ )
272
+
273
+ assert not errors, f"Unexpected errors: {errors}"
274
+
275
+ async def test_nested_begin_same_task_still_uses_savepoint(
276
+ self, db: DatabaseManager
277
+ ) -> None:
278
+ async with db.begin() as outer, db.connection("default").begin() as inner:
279
+ assert inner.session is outer.session
280
+ assert inner.parent.is_some
281
+
282
+ async def test_shared_transaction_is_visible_from_different_task(
283
+ self, db: DatabaseManager
284
+ ) -> None:
285
+ result: list[bool] = []
286
+
287
+ async with db.begin() as tx:
288
+ tx.share()
289
+
290
+ async def child() -> None:
291
+ result.append(db.session().is_some)
292
+
293
+ await asyncio.create_task(child())
294
+
295
+ assert result == [True]
296
+
297
+ async def test_shared_transaction_child_begin_creates_savepoint(
298
+ self, db: DatabaseManager
299
+ ) -> None:
300
+ inner_parent_set: list[bool] = []
301
+ same_session: list[bool] = []
302
+
303
+ async with db.begin() as outer:
304
+ outer.share()
305
+
306
+ async def child() -> None:
307
+ async with db.begin() as inner:
308
+ inner_parent_set.append(inner.parent.is_some)
309
+ same_session.append(inner.session is outer.session)
310
+
311
+ await asyncio.create_task(child())
312
+
313
+ assert inner_parent_set == [True]
314
+ assert same_session == [True]
@@ -1438,7 +1438,7 @@ wheels = [
1438
1438
 
1439
1439
  [[package]]
1440
1440
  name = "python-neva"
1441
- version = "2.2.0"
1441
+ version = "2.2.1"
1442
1442
  source = { editable = "." }
1443
1443
  dependencies = [
1444
1444
  { name = "aiosqlite" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes