python-neva 3.4.0__py3-none-any.whl → 3.5.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.
@@ -95,7 +95,7 @@ class ConnectionManager:
95
95
 
96
96
  @asynccontextmanager
97
97
  async def _scoped(self, tx: BoundTransaction) -> AsyncIterator[None]:
98
- """Manage registry, state transitions, and callbacks for a transaction.
98
+ """Expose the transaction on the registry for the duration of the block.
99
99
 
100
100
  Yields:
101
101
  None
@@ -108,40 +108,37 @@ class ConnectionManager:
108
108
  )
109
109
  try:
110
110
  yield
111
- if tx.rollback_requested:
112
- tx.state = TransactionState.ROLLED_BACK
113
- if tx.is_root:
114
- for result in await tx.execute_on_rollback_callbacks():
115
- if result.is_err and self.logger is not None:
116
- self.logger.error(
117
- "rollback callback failed",
118
- error=result.err().unwrap(),
119
- connection=self.name,
120
- )
121
- else:
122
- tx.state = TransactionState.COMMITTED
123
- if tx.is_root:
124
- for result in await tx.execute_on_commit_callbacks():
125
- if result.is_err and self.logger is not None:
126
- self.logger.error(
127
- "commit callback failed",
128
- error=result.err().unwrap(),
129
- connection=self.name,
130
- )
131
- except BaseException:
132
- tx.state = TransactionState.ROLLED_BACK
133
- if tx.is_root:
134
- for result in await tx.execute_on_rollback_callbacks():
135
- if result.is_err and self.logger is not None:
136
- self.logger.error(
137
- "rollback callback failed",
138
- error=result.err().unwrap(),
139
- connection=self.name,
140
- )
141
- raise
142
111
  finally:
143
112
  _tx_registry.reset(token)
144
113
 
114
+ async def _settle(self, tx: BoundTransaction, *, committed: bool) -> None:
115
+ """Mark the transaction settled and drain its callbacks.
116
+
117
+ Callers must have already committed or rolled back the underlying
118
+ session: callbacks observe a settled transaction, and the registry no
119
+ longer exposes it, so a callback touching the database opens its own
120
+ transaction rather than reusing the drained one.
121
+ """
122
+ tx.state = (
123
+ TransactionState.COMMITTED if committed else TransactionState.ROLLED_BACK
124
+ )
125
+ if not tx.is_root:
126
+ return
127
+
128
+ results = (
129
+ await tx.execute_on_commit_callbacks()
130
+ if committed
131
+ else await tx.execute_on_rollback_callbacks()
132
+ )
133
+ label = "commit" if committed else "rollback"
134
+ for result in results:
135
+ if result.is_err and self.logger is not None:
136
+ self.logger.error(
137
+ f"{label} callback failed",
138
+ error=result.err().unwrap(),
139
+ connection=self.name,
140
+ )
141
+
145
142
  @asynccontextmanager
146
143
  async def begin(self) -> AsyncIterator[BoundTransaction]:
147
144
  """Open a new bound transaction with an active database session.
@@ -162,23 +159,27 @@ class ConnectionManager:
162
159
  try:
163
160
  async with self._scoped(tx):
164
161
  yield tx
165
- if tx.rollback_requested:
166
- await sp.rollback()
167
- else:
168
- await sp.commit()
162
+ if tx.rollback_requested:
163
+ await sp.rollback()
164
+ else:
165
+ await sp.commit()
169
166
  except BaseException:
170
167
  await sp.rollback()
168
+ await self._settle(tx, committed=False)
171
169
  raise
170
+ await self._settle(tx, committed=not tx.rollback_requested)
172
171
  case _:
173
172
  async with self.session_factory() as session:
174
173
  tx = BoundTransaction(self.name, session)
175
174
  try:
176
175
  async with self._scoped(tx):
177
176
  yield tx
178
- if tx.rollback_requested:
179
- await session.rollback()
180
- else:
181
- await session.commit()
177
+ if tx.rollback_requested:
178
+ await session.rollback()
179
+ else:
180
+ await session.commit()
182
181
  except BaseException:
183
182
  await session.rollback()
183
+ await self._settle(tx, committed=False)
184
184
  raise
185
+ await self._settle(tx, committed=not tx.rollback_requested)
neva/events/dispatcher.py CHANGED
@@ -1,5 +1,6 @@
1
1
  """Base implementation of the event dispatcher."""
2
2
 
3
+ import inspect
3
4
  from typing import override
4
5
 
5
6
  from neva.arch.application import Application
@@ -35,11 +36,9 @@ class EventDispatcher(contracts.EventDispatcher):
35
36
  event: The event about to be dispatched.
36
37
  """
37
38
  for hook in self._before_dispatch_hooks:
38
- match hook:
39
- case contracts.AsyncBeforeDispatchHook():
40
- await hook(event)
41
- case contracts.SyncBeforeDispatchHook():
42
- hook(event)
39
+ result = hook(event)
40
+ if inspect.isawaitable(result):
41
+ await result
43
42
 
44
43
  @override
45
44
  async def before_dispatch(self, hook: contracts.BeforeDispatchHook) -> None:
@@ -1,14 +1,24 @@
1
1
  """Type stub for App facade."""
2
2
 
3
- from typing import override
3
+ from contextlib import AbstractAsyncContextManager
4
+ from typing import Any, Self, override
4
5
 
5
6
  from neva.arch import Facade
7
+ from neva.arch.scopes import BaseScope
6
8
  from neva.support import Result
7
9
 
8
10
  class App(Facade):
9
11
  @classmethod
10
12
  @override
11
13
  def get_facade_accessor(cls) -> type: ...
14
+ @classmethod
15
+ async def make_async[T](cls, interface: type[T]) -> Result[T, str]:
16
+ """Resolve and instanciate a type from the container.
17
+
18
+ Returns:
19
+ Result containing the resolved type instance or an error message.
20
+ """
21
+
12
22
  @classmethod
13
23
  def make[T](cls, interface: type[T]) -> Result[T, str]:
14
24
  """Resolve an interface from the container by its alias.
@@ -23,3 +33,15 @@ class App(Facade):
23
33
  Result containing the resolved service instance or an error message.
24
34
 
25
35
  """
36
+
37
+ @classmethod
38
+ async def scope(
39
+ cls,
40
+ scope: BaseScope | None = None,
41
+ context: dict[type, Any] | None = None,
42
+ ) -> AbstractAsyncContextManager[Self]:
43
+ """Enter a new scope.
44
+
45
+ Yields:
46
+ The application instance with the new scope.
47
+ """
@@ -5,7 +5,7 @@ from contextlib import contextmanager
5
5
  from typing import TYPE_CHECKING, override
6
6
 
7
7
  from neva.arch import Facade
8
- from neva.events import EventDispatcher
8
+ from neva.events import contracts
9
9
 
10
10
 
11
11
  if TYPE_CHECKING:
@@ -23,7 +23,7 @@ class Event(Facade):
23
23
  Returns:
24
24
  EventDispatcher class.
25
25
  """
26
- return EventDispatcher
26
+ return contracts.EventDispatcher
27
27
 
28
28
  @classmethod
29
29
  def fake(cls) -> "EventFake":
@@ -13,10 +13,15 @@ class Event(Facade):
13
13
  @override
14
14
  def get_facade_accessor(cls) -> type: ...
15
15
  @classmethod
16
- async def dispatch(cls, event: contracts.Event) -> list[Result[None, str]]: ...
16
+ async def before_dispatch(cls, hook: contracts.BeforeDispatchHook) -> None:
17
+ """Register a hook to be called before listeners are invoked."""
18
+ @classmethod
19
+ async def dispatch(cls, event: contracts.Event) -> list[Result[None, str]]:
20
+ """Dispatch an event to all registered listeners."""
17
21
  @classmethod
18
22
  def listen[T: contracts.Event](
19
23
  cls,
20
24
  event_cls: type[T],
21
25
  listener_cls: type[contracts.EventListener[T]],
22
- ) -> None: ...
26
+ ) -> None:
27
+ """Register a listener for an event."""
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.5
2
+ Name: python-neva
3
+ Version: 3.5.1
4
+ Summary: Add your description here
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: aiosqlite>=0.20.0
7
+ Requires-Dist: asyncpg>=0.30.0
8
+ Requires-Dist: cryptography>=46.0.3
9
+ Requires-Dist: dishka>=1.10.0
10
+ Requires-Dist: fastapi[all]>=0.129.0
11
+ Requires-Dist: faststream>=0.6.6
12
+ Requires-Dist: opentelemetry-instrumentation-sqlalchemy>=0.62b0
13
+ Requires-Dist: pwdlib[argon2,bcrypt]>=0.3.0
14
+ Requires-Dist: pyinstrument>=5.1.1
15
+ Requires-Dist: sqlalchemy[asyncio]>=2.0.0
16
+ Requires-Dist: structlog>=25.5.0
17
+ Requires-Dist: typer>=0.21.1
18
+ Provides-Extra: fastapi
19
+ Requires-Dist: neva-fastapi>=1.0.0; extra == 'fastapi'
20
+ Provides-Extra: testing
21
+ Requires-Dist: pytest-asyncio>=0.25.3; extra == 'testing'
22
+ Requires-Dist: pytest>=9.0.2; extra == 'testing'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # python-neva
26
+
27
+ The core of the **Neva** framework — a Laravel-inspired toolkit for Python that
28
+ brings Laravel's philosophy, terminology, and conventions to the Python
29
+ ecosystem: service providers, facades, `bind`/`make` dependency injection (on
30
+ [dishka](https://github.com/reagento/dishka)), `Hash`/`Crypt`/`Config`/`Log`/`Event`
31
+ facades, a transaction-aware event system, and Result/Option error handling.
32
+
33
+ `python-neva` is the **framework-agnostic core**. Web- and protocol-specific
34
+ concerns live in separate integration packages (the plugins below) so the core
35
+ never depends on a particular HTTP or messaging framework.
36
+
37
+ ## Plugins & ecosystem
38
+
39
+ Neva is split into one repository per package. The core is published as
40
+ [`python-neva`](https://pypi.org/project/python-neva/); each plugin is an
41
+ independent repo, independently versioned and published.
42
+
43
+ | Package | Repo / directory | Role | Status |
44
+ | ----------------- | ------------------ | ------------------------------------------------------------------------------------- | ------------------------------- |
45
+ | `python-neva` | `neva/` | **Core, framework-agnostic.** DI, service providers, facades, Result/Option, events, security, observability, SQLAlchemy database layer. | published ([PyPI](https://pypi.org/project/python-neva/)) |
46
+ | `neva-fastapi` | `neva-fastapi/` | FastAPI integration — `App` extends `FastAPI` and wires dishka into routes. Pulled in via the `python-neva[fastapi]` extra. | published ([PyPI](https://pypi.org/project/neva-fastapi/)) |
47
+ | `neva-faststream` | `neva-faststream/` | FastStream (messaging) integration. | scaffolded, early placeholder |
48
+ | `neva-auth` | `neva-auth/` | Authentication & identity toolkit — token-issuance engine, OAuth2 grants, guards, pluggable formats/backends. | core complete, OAuth-server slice in progress |
49
+ | `neva-example` | `neva-example/` | Reference application consuming `python-neva[fastapi]`. Not published; demonstration + integration test bed. | unpublished |
50
+
51
+ > When working across packages locally, each repo can be cloned as a sibling
52
+ > directory; integration packages depend on `python-neva` from PyPI (or an
53
+ > editable local path during development).
54
+
55
+ ## Develop
56
+
57
+ ```bash
58
+ uv sync # install/refresh deps from uv.lock
59
+ poe lint # ruff check
60
+ poe fmt # ruff format
61
+ poe tc # type-check (mypy)
62
+ poe test # pytest
63
+ poe test-cov # tests with coverage
64
+
65
+ # Run a single test
66
+ pytest tests/test_file.py::TestClass::test_method -v
67
+ ```
68
+
69
+ `asyncio_mode = "auto"` is set, so async tests need no `@pytest.mark.asyncio`.
70
+
71
+ ## Contributing
72
+
73
+ **Commits** follow [Conventional Commits](https://www.conventionalcommits.org/)
74
+ with [gitmoji](https://gitmoji.dev/) prefixes, enforced by
75
+ [`cz_gitmoji`](https://github.com/ljnsn/cz-conventional-gitmoji). Commitizen is
76
+ provided as a dev dependency — no separate install needed. Run the guided
77
+ wizard:
78
+
79
+ ```bash
80
+ cz commit
81
+ ```
82
+
83
+ or format manually as `:gitmoji: type(scope): subject`.
84
+
85
+ **Releases** are cut per-repo with commitizen, from the repo root:
86
+
87
+ ```bash
88
+ cz bump # bump version in pyproject, write CHANGELOG, tag v<version>
89
+ git push --follow-tags origin main
90
+ uv build && uv publish # build + publish the wheel/sdist
91
+ ```
92
+
93
+ `cz bump` derives the bump level (major/minor/patch) from the commits since the
94
+ last tag, updates `CHANGELOG.md`, and runs `scripts/retag-with-changelog.sh` to
95
+ rewrite the new tag with the rendered changelog as its annotation.
96
+
97
+ ## Feature ideas
98
+
99
+ - Feature-parity FastStream integration (now scaffolded as `neva-faststream`)
100
+ - Finish cleaning up the core package (moving any remaining ASGI-dependent code into the appropriate integration packages)
101
+ - Improved router registration (auto-discovery OR provider-based? both?)
102
+ - Improved security tooling (performance improvements, better defaults, etc.)
103
+ - Better OpenTelemetry integration
104
+ - Improved factory module (based on Polyfactory)
105
+ - Queue/Jobs system
106
+ - CLI integration
@@ -15,13 +15,13 @@ neva/config/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
15
  neva/config/repository.py,sha256=-6lG0LcAO30OoJ9vSS4U1xO2o8-EWhSib5_CVEiZkyk,4750
16
16
  neva/database/__init__.py,sha256=3yYnEe8HQM86tURyAhTbMtE7AXubIEMN3GuwxkkT-bk,508
17
17
  neva/database/config.py,sha256=bVKUqlrYDFrURqg_FbntPXyVkbOUPSVXQJTKjyucjL0,451
18
- neva/database/connection.py,sha256=7bETJ9wXb6_lwbazG25XDTuGKKKaEmetBTSI9JdM94Q,6308
18
+ neva/database/connection.py,sha256=zRN0AJHj2BTXC92EnE6UKSMOhlCbikCAT0W7yue11Qg,6211
19
19
  neva/database/manager.py,sha256=VNvhVumZD2hoGKpkdXMAkT6KykE4H80WcG2DgShK5Hs,4240
20
20
  neva/database/provider.py,sha256=l2be4Ri53RtQq1-qzohy3cqzJQpTaNzqXGVhXPKUCXc,1674
21
21
  neva/database/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
22
  neva/database/transaction.py,sha256=_0Dsao_vhCJT5YRUmrHOLb0uxvfdE-5k3n0yMPLC1o8,5167
23
23
  neva/events/__init__.py,sha256=Gpi5q-pNlIi-q23U2ZOsNf8Y9rB6AQtsBbbBFU0tkFc,776
24
- neva/events/dispatcher.py,sha256=5M8s3aVzx9lIViGkigtu1kq6gFbam4OKDajuWNGuqA0,4376
24
+ neva/events/dispatcher.py,sha256=KR5uDZbnNwrMCI6s9S_SoNrNHx2z2lugoHk1-g3fzXA,4288
25
25
  neva/events/event.py,sha256=q-KyyxNkU3AN2YSaujuNPIoXQMXhGOwClTap5y8fU5I,282
26
26
  neva/events/event_registry.py,sha256=IZnpR3oLyvt5K2pn9KMCBw5W4JP7Ok_zMKI2SgQoXVM,1945
27
27
  neva/events/listener.py,sha256=2d5RHVKO0pU-eL_evLReTG0DIoX9tZyOcn8PWLeECZo,1435
@@ -73,15 +73,15 @@ neva/support/strconv.py,sha256=cvYRtJVG3IuhQzZCqJD1nVm3g0wxRu-IIIFK16Mq7d8,1296
73
73
  neva/support/time.py,sha256=QYH1F38V33F1C-rlH1Xbrblnaue1-BnercFYUBMmq88,351
74
74
  neva/support/facade/__init__.py,sha256=aysM4AyDAxAgv8uZCka49t2vLHeVraTNAHeCIpXLd-s,465
75
75
  neva/support/facade/app.py,sha256=xdL9qpeQnJtAMomxfrImM82qM2YV2eQFsckmUNE2w7I,297
76
- neva/support/facade/app.pyi,sha256=QBk-1cRpcNPsxQM0cGT0IYU1B4DQeExhzf1w8Sbax2g,643
76
+ neva/support/facade/app.pyi,sha256=gTAKp_r8DUqOj-oggAyAuK1WvR2AzX63-G2U5NixHbM,1304
77
77
  neva/support/facade/config.py,sha256=cqmtwAekHE4qWH2LqzmY3BAbyAadvZYfPgLHyTcCnJE,358
78
78
  neva/support/facade/config.pyi,sha256=LpI6xwqURRlWPcohXBxTL-iWTHq3MMMNdu670Q_5jnk,2549
79
79
  neva/support/facade/crypt.py,sha256=BUx2woDla3TrvVOVRNzOg97zfCaXIOqIz5EdoN3BvlA,298
80
80
  neva/support/facade/crypt.pyi,sha256=_MV_qXDATaCs1pZTOw_-qPx_jjH_nG4-tUBOMX4nZIE,857
81
81
  neva/support/facade/db.py,sha256=mXMzuCKf390ICCBgzaub1ZoMqUBgOQByQYMEM3YnN_0,324
82
82
  neva/support/facade/db.pyi,sha256=UQFMgBuw0BGquMnuUNULC8Pdo7lDCVTCNkDaUmIZJhw,1911
83
- neva/support/facade/event.py,sha256=sRU-fxaRT59Fe-u2WTS5L9ncGkGLBndKa9gKbqwAq3M,1232
84
- neva/support/facade/event.pyi,sha256=AL_v8fxkvtd9KwbiWbVuUAcdZz0yzLn8xZROFa6lXLE,546
83
+ neva/support/facade/event.py,sha256=BkJoof1hYTxhMBOxLrq21NIgEPIiLBlj18Pttl0FHa8,1236
84
+ neva/support/facade/event.pyi,sha256=7R6osyTZbHt_zkCfQ-uZnTLfyZTeN1bi3exsgl3-36k,817
85
85
  neva/support/facade/hash.py,sha256=tGhsvfGovt-mcRXSjDKf4jt1n7ib0eDPXFvRUfVxLUc,292
86
86
  neva/support/facade/hash.pyi,sha256=jTs3E5ZF5cp76jrgH70DEog8gzfYIcIV2PrO9xskHyI,1656
87
87
  neva/support/facade/log.py,sha256=_uLoHB9tkpHkiqEJXazbaqfiKUOtv3NS71ZCZAprzOI,347
@@ -91,6 +91,6 @@ neva/testing/fakes.py,sha256=w2dnFaO6x14l0_si7iBNELMCxaFi8VsVOSQ6PmTlclE,2936
91
91
  neva/testing/fixtures.py,sha256=oiPa5ntUkW-SbySDvwEHXtti8NqSwI-R0CT1_ezTx_Y,1445
92
92
  neva/testing/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
93
93
  neva/testing/test_case.py,sha256=zJBZJeKI-sv-EOaXNuSJ0V2Zp_6hdk64W4dOh0eP8RI,3290
94
- python_neva-3.4.0.dist-info/METADATA,sha256=rW3P72J2ukVUItHYsF1EDWCu4SJzGqv--6PjnYDnO5o,846
95
- python_neva-3.4.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
96
- python_neva-3.4.0.dist-info/RECORD,,
94
+ python_neva-3.5.1.dist-info/METADATA,sha256=g3oinewGocjJnVZ2LztQQMtNLOCEJcsOwjDw-F6_o50,5026
95
+ python_neva-3.5.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
96
+ python_neva-3.5.1.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.30.1
2
+ Generator: hatchling 1.32.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,27 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: python-neva
3
- Version: 3.4.0
4
- Summary: Add your description here
5
- Requires-Python: >=3.12
6
- Requires-Dist: aiosqlite>=0.20.0
7
- Requires-Dist: asyncpg>=0.30.0
8
- Requires-Dist: cryptography>=46.0.3
9
- Requires-Dist: dishka>=1.10.0
10
- Requires-Dist: fastapi[all]>=0.129.0
11
- Requires-Dist: faststream>=0.6.6
12
- Requires-Dist: opentelemetry-instrumentation-sqlalchemy>=0.62b0
13
- Requires-Dist: pwdlib[argon2,bcrypt]>=0.3.0
14
- Requires-Dist: pyinstrument>=5.1.1
15
- Requires-Dist: sqlalchemy[asyncio]>=2.0.0
16
- Requires-Dist: structlog>=25.5.0
17
- Requires-Dist: typer>=0.21.1
18
- Provides-Extra: fastapi
19
- Requires-Dist: neva-fastapi>=1.0.0; extra == 'fastapi'
20
- Provides-Extra: testing
21
- Requires-Dist: pytest-asyncio>=0.25.3; extra == 'testing'
22
- Requires-Dist: pytest>=9.0.2; extra == 'testing'
23
- Description-Content-Type: text/markdown
24
-
25
- # The Neva framework
26
-
27
- PLACEHOLDER