python-neva 2.2.0__py3-none-any.whl → 2.2.1__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,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)
neva/database/manager.py CHANGED
@@ -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
neva/testing/test_case.py CHANGED
@@ -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()
@@ -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
@@ -13,10 +13,10 @@ neva/config/loader.py,sha256=ZIcx8oYIi2r4wv1G_Q7_a6nG7wARyzjiZ2wF43t3B2I,3942
13
13
  neva/config/repository.py,sha256=B7iWgJLtOIwdmp7I0y9ISgl3g_Xc60tUe-ofDUKM6fg,4827
14
14
  neva/database/__init__.py,sha256=3yYnEe8HQM86tURyAhTbMtE7AXubIEMN3GuwxkkT-bk,508
15
15
  neva/database/config.py,sha256=bVKUqlrYDFrURqg_FbntPXyVkbOUPSVXQJTKjyucjL0,451
16
- neva/database/connection.py,sha256=yVosFiZWTOircIUKBewXI6Cksbh5hwx6dVitsF0QWhc,7013
17
- neva/database/manager.py,sha256=OwArEKGmjjPPj5iGNm8Fg8rq-jtnwAiHAqLbBAp_oG8,3947
16
+ neva/database/connection.py,sha256=et1fvGxa2iHTJ8yzAv9VIYqfD4GtGaV0KQUW97SdLHQ,7107
17
+ neva/database/manager.py,sha256=lgW0rd6CHQKNUSfIR25mTMIalu13HZD7Vklyfq6Tl10,4425
18
18
  neva/database/provider.py,sha256=R-K4aFCgF8Ewu6I_-BXo874IYgQgI3b8qYPsGe7ArFA,1785
19
- neva/database/transaction.py,sha256=vZR7nUuQbkuFcxA07wBUOalY6ilz7NwmLWKCNQmnkXU,4321
19
+ neva/database/transaction.py,sha256=yQuFDhdXipUWac3zx4rVy6R6XUB_xPT_CLoQ-26223Y,5933
20
20
  neva/events/__init__.py,sha256=xwIAXNcOASpuCY4DJpshZc1gtxQHimzwy-s7c-4QuiQ,746
21
21
  neva/events/dispatcher.py,sha256=3xMhxE5uQBY7648jp75NkRwu09lqYo7ymmfSvnl6kT0,2850
22
22
  neva/events/event.py,sha256=g3vAx-2qiR3QRM_or8G9mK5qx66A2LZiZ3n517R4lGQ,251
@@ -71,7 +71,7 @@ neva/testing/__init__.py,sha256=vt5zxG1N84vn_YwP44jhYPWYnyb02-cha3m-1l-qoEw,207
71
71
  neva/testing/fakes.py,sha256=ffAAZC7WymuutVYk3vefuPDUvpNHav2QV2lj-Gw0Kic,2666
72
72
  neva/testing/fixtures.py,sha256=-l_Drw6nXTD_cKpfg1Z1pmsXnaD3NM2kihdRHxSkfzE,1657
73
73
  neva/testing/http.py,sha256=9oKNzaz38nCKkL-ZUF5CRWFPqpNmXkqbYcoukC509_Q,494
74
- neva/testing/test_case.py,sha256=8ag24TpvOhrr0Vgwq1j5NO_ASYwWJzhOq-wH26DhbDs,3302
75
- python_neva-2.2.0.dist-info/METADATA,sha256=H5ABzyvGdhskuZvxWy-7wLsBnVwRQ3ZXUV0KZ30i6Vc,627
76
- python_neva-2.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
77
- python_neva-2.2.0.dist-info/RECORD,,
74
+ neva/testing/test_case.py,sha256=FzXBHctkVkFNu46S5lnt7iRX91TLPLfL1Lzgn-iU-_E,3325
75
+ python_neva-2.2.1.dist-info/METADATA,sha256=XhKt4y0UPn7sq-dj0xaKMi_gHIe3RB3TzLwrEtQfKcQ,627
76
+ python_neva-2.2.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
77
+ python_neva-2.2.1.dist-info/RECORD,,