python-neva 2.2.0__py3-none-any.whl → 2.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,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
@@ -10,6 +10,7 @@ from neva import Nothing, Option, Some
10
10
  from neva.database.connection import ConnectionManager, TransactionContext
11
11
  from neva.database.transaction import BoundTransaction, Transaction
12
12
  from neva.obs import LogManager
13
+ from neva.obs.instrumentation.sqlalchemy import instrument
13
14
 
14
15
 
15
16
  @final
@@ -30,6 +31,7 @@ class DatabaseManager:
30
31
  name: The connection name.
31
32
  engine: The async engine.
32
33
  """
34
+ instrument(engine)
33
35
  self._engines[name] = engine
34
36
  self._session_factories[name] = async_sessionmaker(
35
37
  bind=engine,
@@ -60,15 +62,24 @@ class DatabaseManager:
60
62
  def session(self, connection: str | None = None) -> Option[AsyncSession]:
61
63
  """Returns the current session for a connection.
62
64
 
65
+ Only returns a session if the active transaction was opened by the
66
+ current asyncio task. Transactions inherited from a parent task
67
+ (e.g. via context propagation in Strawberry GraphQL resolvers) are
68
+ not accessible this way — each task must call ``begin()`` to obtain
69
+ its own session.
70
+
63
71
  Args:
64
72
  connection: The connection name. Defaults to "default".
65
73
 
66
74
  Returns:
67
75
  The current session, if any. Returns Nothing if there is no active
68
- bound transaction on the given connection.
76
+ bound transaction on the given connection, or if the active
77
+ transaction was opened by a different asyncio task.
69
78
  """
70
79
  match self.current(connection):
71
- case Some(tx) if isinstance(tx, BoundTransaction):
80
+ case Some(tx) if (
81
+ isinstance(tx, BoundTransaction) and tx.is_accessible_from_current_task
82
+ ):
72
83
  return Some(tx.session)
73
84
  case _:
74
85
  return Nothing()
@@ -93,11 +104,11 @@ class DatabaseManager:
93
104
  Args:
94
105
  name: The connection name.
95
106
 
96
- Raises:
97
- RuntimeError: If no engine has been registered for the connection.
98
-
99
107
  Yields:
100
108
  BoundTransaction: A transaction with a guaranteed non-null session.
109
+
110
+ Raises:
111
+ RuntimeError: If no engine has been registered for the connection.
101
112
  """ # noqa: DOC502 explicit documentation of the possible exception raised by 'begin'
102
113
  async with self.connection(name).begin() as tx:
103
114
  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
@@ -0,0 +1 @@
1
+ """Various OTel instrumentation."""
@@ -0,0 +1,15 @@
1
+ """SQLAlchemy instrumentation."""
2
+
3
+ from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
4
+ from sqlalchemy import Engine
5
+ from sqlalchemy.ext.asyncio import AsyncEngine
6
+
7
+
8
+ def instrument(engine: AsyncEngine | Engine) -> None:
9
+ """Instrument SQLAlchemy engine."""
10
+ match engine:
11
+ case AsyncEngine():
12
+ to_instrument = engine.sync_engine
13
+ case Engine():
14
+ to_instrument = engine
15
+ SQLAlchemyInstrumentor().instrument(engine=to_instrument)
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.3.0
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: aiosqlite>=0.20.0
@@ -9,6 +9,7 @@ Requires-Dist: cryptography>=46.0.3
9
9
  Requires-Dist: dishka>=1.7.2
10
10
  Requires-Dist: fastapi[all]>=0.129.0
11
11
  Requires-Dist: faststream>=0.6.6
12
+ Requires-Dist: opentelemetry-instrumentation-sqlalchemy>=0.62b0
12
13
  Requires-Dist: pwdlib[argon2,bcrypt]>=0.3.0
13
14
  Requires-Dist: pyinstrument>=5.1.1
14
15
  Requires-Dist: sqlalchemy[asyncio]>=2.0.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=tQFHj2HZzo3oxdWyWNQlv5277I7vh7vDlEEiUyAKjDs,4511
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
@@ -24,6 +24,8 @@ neva/events/event_registry.py,sha256=jucYOUjCERz-ICdPiDrxYw_ySvmPJOcRzYKCgka29qY
24
24
  neva/events/listener.py,sha256=AMfi24R_WA_-mVg0fxfOrwY6EV8kO1wU5YpAFPvGrus,1825
25
25
  neva/events/provider.py,sha256=ilPW_-zaLMrh-cgWphLe_aqvs00LNp93OOMFFpIppyE,767
26
26
  neva/obs/__init__.py,sha256=dVzgljk9Hvo44LI34RcwbDyT42_z4nSnFVmL4GLbKD0,331
27
+ neva/obs/instrumentation/__init__.py,sha256=SfIAvuCs-L0kEzOxUJ4aOIkhbydCe7qBwWtGeDELVA0,36
28
+ neva/obs/instrumentation/sqlalchemy.py,sha256=kwk43NthFi4mPMhDJI55rxt9YPOY_mwOkr2E-cD64ZY,497
27
29
  neva/obs/logging/__init__.py,sha256=gIYBAzMK7-wPGEpA_kYKvJPT3hYN99OcCc9UuHFeiHk,251
28
30
  neva/obs/logging/manager.py,sha256=ktd3muBGU3e1KuBK6tSU5faRJRliPwmknuqJ2EYeFBk,2422
29
31
  neva/obs/logging/provider.py,sha256=lt5oma5eV6mjN4C5iFhyYf0BmI9BMbFVBJJNSy2XOkg,638
@@ -71,7 +73,7 @@ neva/testing/__init__.py,sha256=vt5zxG1N84vn_YwP44jhYPWYnyb02-cha3m-1l-qoEw,207
71
73
  neva/testing/fakes.py,sha256=ffAAZC7WymuutVYk3vefuPDUvpNHav2QV2lj-Gw0Kic,2666
72
74
  neva/testing/fixtures.py,sha256=-l_Drw6nXTD_cKpfg1Z1pmsXnaD3NM2kihdRHxSkfzE,1657
73
75
  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,,
76
+ neva/testing/test_case.py,sha256=FzXBHctkVkFNu46S5lnt7iRX91TLPLfL1Lzgn-iU-_E,3325
77
+ python_neva-2.3.0.dist-info/METADATA,sha256=woz2CCGwVG7vPpl7C6tGjv2634D5iwO4WPeXzrPk8mw,691
78
+ python_neva-2.3.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
79
+ python_neva-2.3.0.dist-info/RECORD,,