python-neva 2.1.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 (121) hide show
  1. {python_neva-2.1.0 → python_neva-2.2.1}/.pre-commit-config.yaml +5 -8
  2. {python_neva-2.1.0 → python_neva-2.2.1}/PKG-INFO +1 -1
  3. {python_neva-2.1.0 → python_neva-2.2.1}/neva/arch/app.py +3 -3
  4. {python_neva-2.1.0 → python_neva-2.2.1}/neva/arch/application.py +25 -31
  5. {python_neva-2.1.0 → python_neva-2.2.1}/neva/arch/facade.py +8 -4
  6. {python_neva-2.1.0 → python_neva-2.2.1}/neva/config/__init__.py +0 -2
  7. {python_neva-2.1.0 → python_neva-2.2.1}/neva/database/connection.py +7 -4
  8. {python_neva-2.1.0 → python_neva-2.2.1}/neva/database/manager.py +14 -5
  9. {python_neva-2.1.0 → python_neva-2.2.1}/neva/database/transaction.py +37 -0
  10. {python_neva-2.1.0 → python_neva-2.2.1}/neva/security/encryption/encrypter.py +3 -1
  11. {python_neva-2.1.0 → python_neva-2.2.1}/neva/support/results.py +4 -4
  12. {python_neva-2.1.0 → python_neva-2.2.1}/neva/testing/fixtures.py +2 -3
  13. {python_neva-2.1.0 → python_neva-2.2.1}/neva/testing/test_case.py +1 -0
  14. {python_neva-2.1.0 → python_neva-2.2.1}/pyproject.toml +8 -6
  15. {python_neva-2.1.0 → python_neva-2.2.1}/tests/database/test_sqlalchemy_integration.py +135 -0
  16. {python_neva-2.1.0 → python_neva-2.2.1}/tests/testing/test_refresh_database.py +26 -0
  17. {python_neva-2.1.0 → python_neva-2.2.1}/uv.lock +136 -51
  18. python_neva-2.1.0/neva/config/provider.py +0 -74
  19. {python_neva-2.1.0 → python_neva-2.2.1}/.envrc +0 -0
  20. {python_neva-2.1.0 → python_neva-2.2.1}/.gitignore +0 -0
  21. {python_neva-2.1.0 → python_neva-2.2.1}/.python-version +0 -0
  22. {python_neva-2.1.0 → python_neva-2.2.1}/README.md +0 -0
  23. {python_neva-2.1.0 → python_neva-2.2.1}/neva/__init__.py +0 -0
  24. {python_neva-2.1.0 → python_neva-2.2.1}/neva/arch/__init__.py +0 -0
  25. {python_neva-2.1.0 → python_neva-2.2.1}/neva/arch/config.py +0 -0
  26. {python_neva-2.1.0 → python_neva-2.2.1}/neva/arch/faststream.py +0 -0
  27. {python_neva-2.1.0 → python_neva-2.2.1}/neva/arch/service_provider.py +0 -0
  28. {python_neva-2.1.0 → python_neva-2.2.1}/neva/config/base_providers.py +0 -0
  29. {python_neva-2.1.0 → python_neva-2.2.1}/neva/config/loader.py +0 -0
  30. {python_neva-2.1.0 → python_neva-2.2.1}/neva/config/repository.py +0 -0
  31. {python_neva-2.1.0 → python_neva-2.2.1}/neva/database/__init__.py +0 -0
  32. {python_neva-2.1.0 → python_neva-2.2.1}/neva/database/config.py +0 -0
  33. {python_neva-2.1.0 → python_neva-2.2.1}/neva/database/provider.py +0 -0
  34. {python_neva-2.1.0 → python_neva-2.2.1}/neva/events/__init__.py +0 -0
  35. {python_neva-2.1.0 → python_neva-2.2.1}/neva/events/dispatcher.py +0 -0
  36. {python_neva-2.1.0 → python_neva-2.2.1}/neva/events/event.py +0 -0
  37. {python_neva-2.1.0 → python_neva-2.2.1}/neva/events/event_registry.py +0 -0
  38. {python_neva-2.1.0 → python_neva-2.2.1}/neva/events/listener.py +0 -0
  39. {python_neva-2.1.0 → python_neva-2.2.1}/neva/events/provider.py +0 -0
  40. {python_neva-2.1.0 → python_neva-2.2.1}/neva/obs/__init__.py +0 -0
  41. {python_neva-2.1.0 → python_neva-2.2.1}/neva/obs/logging/__init__.py +0 -0
  42. {python_neva-2.1.0 → python_neva-2.2.1}/neva/obs/logging/manager.py +0 -0
  43. {python_neva-2.1.0 → python_neva-2.2.1}/neva/obs/logging/provider.py +0 -0
  44. {python_neva-2.1.0 → python_neva-2.2.1}/neva/obs/middleware/__init__.py +0 -0
  45. {python_neva-2.1.0 → python_neva-2.2.1}/neva/obs/middleware/correlation.py +0 -0
  46. {python_neva-2.1.0 → python_neva-2.2.1}/neva/obs/middleware/profiler.py +0 -0
  47. {python_neva-2.1.0 → python_neva-2.2.1}/neva/py.typed +0 -0
  48. {python_neva-2.1.0 → python_neva-2.2.1}/neva/security/__init__.py +0 -0
  49. {python_neva-2.1.0 → python_neva-2.2.1}/neva/security/encryption/__init__.py +0 -0
  50. {python_neva-2.1.0 → python_neva-2.2.1}/neva/security/encryption/protocol.py +0 -0
  51. {python_neva-2.1.0 → python_neva-2.2.1}/neva/security/hashing/__init__.py +0 -0
  52. {python_neva-2.1.0 → python_neva-2.2.1}/neva/security/hashing/config.py +0 -0
  53. {python_neva-2.1.0 → python_neva-2.2.1}/neva/security/hashing/hash_manager.py +0 -0
  54. {python_neva-2.1.0 → python_neva-2.2.1}/neva/security/hashing/hashers/__init__.py +0 -0
  55. {python_neva-2.1.0 → python_neva-2.2.1}/neva/security/hashing/hashers/argon2.py +0 -0
  56. {python_neva-2.1.0 → python_neva-2.2.1}/neva/security/hashing/hashers/bcrypt.py +0 -0
  57. {python_neva-2.1.0 → python_neva-2.2.1}/neva/security/hashing/hashers/protocol.py +0 -0
  58. {python_neva-2.1.0 → python_neva-2.2.1}/neva/security/provider.py +0 -0
  59. {python_neva-2.1.0 → python_neva-2.2.1}/neva/security/tokens/__init__.py +0 -0
  60. {python_neva-2.1.0 → python_neva-2.2.1}/neva/security/tokens/generate_token.py +0 -0
  61. {python_neva-2.1.0 → python_neva-2.2.1}/neva/security/tokens/hash_token.py +0 -0
  62. {python_neva-2.1.0 → python_neva-2.2.1}/neva/security/tokens/verify_token.py +0 -0
  63. {python_neva-2.1.0 → python_neva-2.2.1}/neva/support/__init__.py +0 -0
  64. {python_neva-2.1.0 → python_neva-2.2.1}/neva/support/accessors.py +0 -0
  65. {python_neva-2.1.0 → python_neva-2.2.1}/neva/support/facade/__init__.py +0 -0
  66. {python_neva-2.1.0 → python_neva-2.2.1}/neva/support/facade/app.py +0 -0
  67. {python_neva-2.1.0 → python_neva-2.2.1}/neva/support/facade/app.pyi +0 -0
  68. {python_neva-2.1.0 → python_neva-2.2.1}/neva/support/facade/config.py +0 -0
  69. {python_neva-2.1.0 → python_neva-2.2.1}/neva/support/facade/config.pyi +0 -0
  70. {python_neva-2.1.0 → python_neva-2.2.1}/neva/support/facade/crypt.py +0 -0
  71. {python_neva-2.1.0 → python_neva-2.2.1}/neva/support/facade/crypt.pyi +0 -0
  72. {python_neva-2.1.0 → python_neva-2.2.1}/neva/support/facade/db.py +0 -0
  73. {python_neva-2.1.0 → python_neva-2.2.1}/neva/support/facade/db.pyi +0 -0
  74. {python_neva-2.1.0 → python_neva-2.2.1}/neva/support/facade/event.py +0 -0
  75. {python_neva-2.1.0 → python_neva-2.2.1}/neva/support/facade/event.pyi +0 -0
  76. {python_neva-2.1.0 → python_neva-2.2.1}/neva/support/facade/hash.py +0 -0
  77. {python_neva-2.1.0 → python_neva-2.2.1}/neva/support/facade/hash.pyi +0 -0
  78. {python_neva-2.1.0 → python_neva-2.2.1}/neva/support/facade/log.py +0 -0
  79. {python_neva-2.1.0 → python_neva-2.2.1}/neva/support/facade/log.pyi +0 -0
  80. {python_neva-2.1.0 → python_neva-2.2.1}/neva/support/strategy.py +0 -0
  81. {python_neva-2.1.0 → python_neva-2.2.1}/neva/support/strconv.py +0 -0
  82. {python_neva-2.1.0 → python_neva-2.2.1}/neva/support/time.py +0 -0
  83. {python_neva-2.1.0 → python_neva-2.2.1}/neva/testing/__init__.py +0 -0
  84. {python_neva-2.1.0 → python_neva-2.2.1}/neva/testing/fakes.py +0 -0
  85. {python_neva-2.1.0 → python_neva-2.2.1}/neva/testing/http.py +0 -0
  86. {python_neva-2.1.0 → python_neva-2.2.1}/ruff.toml +0 -0
  87. {python_neva-2.1.0 → python_neva-2.2.1}/tests/__init__.py +0 -0
  88. {python_neva-2.1.0 → python_neva-2.2.1}/tests/arch/__init__.py +0 -0
  89. {python_neva-2.1.0 → python_neva-2.2.1}/tests/arch/test_scope.py +0 -0
  90. {python_neva-2.1.0 → python_neva-2.2.1}/tests/config/__init__.py +0 -0
  91. {python_neva-2.1.0 → python_neva-2.2.1}/tests/config/test_loader.py +0 -0
  92. {python_neva-2.1.0 → python_neva-2.2.1}/tests/config/test_repository.py +0 -0
  93. {python_neva-2.1.0 → python_neva-2.2.1}/tests/conftest.py +0 -0
  94. {python_neva-2.1.0 → python_neva-2.2.1}/tests/database/__init__.py +0 -0
  95. {python_neva-2.1.0 → python_neva-2.2.1}/tests/database/conftest.py +0 -0
  96. {python_neva-2.1.0 → python_neva-2.2.1}/tests/database/test_connection_manager.py +0 -0
  97. {python_neva-2.1.0 → python_neva-2.2.1}/tests/database/test_database_manager.py +0 -0
  98. {python_neva-2.1.0 → python_neva-2.2.1}/tests/database/test_edge_cases.py +0 -0
  99. {python_neva-2.1.0 → python_neva-2.2.1}/tests/database/test_multi_connection.py +0 -0
  100. {python_neva-2.1.0 → python_neva-2.2.1}/tests/database/test_transaction.py +0 -0
  101. {python_neva-2.1.0 → python_neva-2.2.1}/tests/database/test_transaction_context.py +0 -0
  102. {python_neva-2.1.0 → python_neva-2.2.1}/tests/database/test_transaction_registry.py +0 -0
  103. {python_neva-2.1.0 → python_neva-2.2.1}/tests/events/__init__.py +0 -0
  104. {python_neva-2.1.0 → python_neva-2.2.1}/tests/events/conftest.py +0 -0
  105. {python_neva-2.1.0 → python_neva-2.2.1}/tests/events/test_deferred.py +0 -0
  106. {python_neva-2.1.0 → python_neva-2.2.1}/tests/events/test_dispatch.py +0 -0
  107. {python_neva-2.1.0 → python_neva-2.2.1}/tests/events/test_event.py +0 -0
  108. {python_neva-2.1.0 → python_neva-2.2.1}/tests/events/test_event_registry.py +0 -0
  109. {python_neva-2.1.0 → python_neva-2.2.1}/tests/events/test_function_listener.py +0 -0
  110. {python_neva-2.1.0 → python_neva-2.2.1}/tests/events/test_immediate.py +0 -0
  111. {python_neva-2.1.0 → python_neva-2.2.1}/tests/obs/__init__.py +0 -0
  112. {python_neva-2.1.0 → python_neva-2.2.1}/tests/obs/test_correlation.py +0 -0
  113. {python_neva-2.1.0 → python_neva-2.2.1}/tests/obs/test_profiler.py +0 -0
  114. {python_neva-2.1.0 → python_neva-2.2.1}/tests/security/__init__.py +0 -0
  115. {python_neva-2.1.0 → python_neva-2.2.1}/tests/security/test_encrypter.py +0 -0
  116. {python_neva-2.1.0 → python_neva-2.2.1}/tests/security/test_hash_manager.py +0 -0
  117. {python_neva-2.1.0 → python_neva-2.2.1}/tests/testing/__init__.py +0 -0
  118. {python_neva-2.1.0 → python_neva-2.2.1}/tests/testing/test_event_fake.py +0 -0
  119. {python_neva-2.1.0 → python_neva-2.2.1}/tests/testing/test_facade_restore.py +0 -0
  120. {python_neva-2.1.0 → python_neva-2.2.1}/tests/testing/test_fixtures.py +0 -0
  121. {python_neva-2.1.0 → python_neva-2.2.1}/tests/testing/test_test_case.py +0 -0
@@ -1,6 +1,6 @@
1
1
  repos:
2
2
  - repo: https://github.com/gitleaks/gitleaks
3
- rev: v8.24.2
3
+ rev: v8.30.1
4
4
  hooks:
5
5
  - id: gitleaks
6
6
 
@@ -16,16 +16,13 @@ repos:
16
16
  - id: debug-statements
17
17
 
18
18
  - repo: https://github.com/astral-sh/ruff-pre-commit
19
- rev: v0.13.3
19
+ rev: v0.15.6
20
20
  hooks:
21
21
  - id: ruff
22
22
  args: [--fix, --exit-non-zero-on-fix]
23
23
  - id: ruff-format
24
24
 
25
- - repo: local
25
+ - repo: https://github.com/pre-commit/mirrors-mypy
26
+ rev: v1.19.1
26
27
  hooks:
27
- - id: ty
28
- name: ty check
29
- entry: ty check . --project neva
30
- files: ^neva/
31
- language: system
28
+ - id: mypy
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-neva
3
- Version: 2.1.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,6 +13,7 @@ from typing import Any, Callable
13
13
  import dishka
14
14
  import fastapi
15
15
  from dishka.integrations.fastapi import setup_dishka
16
+ from starlette.middleware import Middleware
16
17
  from starlette.routing import BaseRoute
17
18
  from starlette.types import StatefulLifespan, StatelessLifespan
18
19
 
@@ -28,7 +29,7 @@ class App(fastapi.FastAPI):
28
29
  self,
29
30
  *,
30
31
  routes: list[BaseRoute] | None = None,
31
- middlewares: Sequence[type] | None = None,
32
+ middlewares: Sequence[Middleware] | None = None,
32
33
  lifespan: StatelessLifespan["App"] | StatefulLifespan["App"] | None = None,
33
34
  config_path: str | Path | None = None,
34
35
  ) -> None:
@@ -58,9 +59,8 @@ class App(fastapi.FastAPI):
58
59
  docs_url=config.get("app.docs_url", default="/docs").unwrap(),
59
60
  redoc_url=config.get("app.redoc_url", default="/redoc").unwrap(),
60
61
  lifespan=self._create_lifespan(),
62
+ middleware=middlewares,
61
63
  )
62
- for middleware in middlewares or []:
63
- self.add_middleware(middleware)
64
64
 
65
65
  setup_dishka(self.application.container, app=self)
66
66
 
@@ -12,6 +12,7 @@ from dishka.integrations.fastapi import FastapiProvider
12
12
  from neva import Err, Ok, Result
13
13
  from neva.arch.facade import Facade
14
14
  from neva.arch.service_provider import Bootable, ServiceProvider
15
+ from neva.config.loader import ConfigLoader
15
16
 
16
17
 
17
18
  class Application:
@@ -28,45 +29,38 @@ class Application:
28
29
  RuntimeError: If the application fails to initialize.
29
30
  """
30
31
  from neva.config.base_providers import base_providers
31
- from neva.config.provider import ConfigServiceProvider
32
32
  from neva.config.repository import ConfigRepository
33
33
 
34
+ self.config: ConfigRepository = ConfigRepository()
34
35
  self.providers: dict[type, ServiceProvider] = {}
35
36
  self.di_provider: dishka.Provider = dishka.Provider(scope=dishka.Scope.APP)
36
37
 
37
- configuration_path = config_path or os.getenv("NEVA_CONFIG_PATH", default=None)
38
- config_provider = ConfigServiceProvider(
39
- app=self,
40
- config_path=configuration_path,
41
- ).register()
42
- if config_provider.is_err:
43
- raise RuntimeError(
44
- f"Failed to register config provider: {config_provider.unwrap_err()}"
45
- )
46
- self.providers[ConfigServiceProvider] = config_provider.unwrap()
47
-
48
- self.register_providers(base_providers())
49
- _ = self.di_provider.provide(source=lambda: self, provides=Application)
50
-
51
- self.container: dishka.Container = dishka.make_container(self.di_provider)
52
-
53
- config_result: Result[ConfigRepository, str] = self.make(
54
- interface=ConfigRepository
38
+ configuration_path = (
39
+ config_path
40
+ if config_path is not None
41
+ else os.getenv("NEVA_CONFIG_PATH", default=Path.cwd())
55
42
  )
56
- match config_result:
57
- case Ok(config):
58
- self.config: ConfigRepository = config
59
- providers_from_file = config.get("providers.providers").unwrap_or([])
60
- providers_from_app = config.get("app.providers").unwrap_or([])
61
- providers: set[type[ServiceProvider]] = set(providers_from_file).union(
62
- set(providers_from_app)
63
- )
64
- _ = self.register_providers(providers)
43
+ match ConfigLoader(configuration_path).load_all():
65
44
  case Err(e):
66
- raise RuntimeError(f"Failed to load configuration during boot: {e}")
67
-
45
+ raise RuntimeError(f"Failed to register config: {e}")
46
+ case Ok(configs):
47
+ if any(
48
+ self.config.merge(namespace, config).is_err
49
+ for namespace, config in configs.items()
50
+ ):
51
+ raise RuntimeError("Failed to register config")
52
+
53
+ self.bind(lambda: self.config, interface=ConfigRepository)
54
+
55
+ providers_from_file = self.config.get("providers.providers").unwrap_or([])
56
+ providers_from_app = self.config.get("app.providers").unwrap_or([])
57
+ providers: set[type[ServiceProvider]] = set(providers_from_file).union(
58
+ set(providers_from_app)
59
+ )
60
+ self.register_providers(base_providers().union(providers))
61
+ self.bind(source=lambda: self, interface=Application)
68
62
  self._bind_event_listeners()
69
- self.container = dishka.make_container(self.di_provider)
63
+ self.container: dishka.Container = dishka.make_container(self.di_provider)
70
64
 
71
65
  def bind_to_fastapi(self) -> None:
72
66
  """Setup the FastapiProvider for FastAPI integration."""
@@ -8,7 +8,7 @@ injection container, enabling convenient access without explicit dependency inje
8
8
  from abc import ABC, ABCMeta, abstractmethod
9
9
  from collections.abc import Iterator
10
10
  from contextlib import contextmanager
11
- from typing import TYPE_CHECKING, Any, ClassVar
11
+ from typing import TYPE_CHECKING, Any, ClassVar, cast
12
12
  from unittest.mock import AsyncMock, MagicMock
13
13
 
14
14
  from neva import Ok, Option, Result, from_optional
@@ -150,8 +150,12 @@ class FacadeMeta(ABCMeta):
150
150
  The MagicMock spy installed as the facade root.
151
151
 
152
152
  """
153
- app = cls._get_app().ok_or(f"Cannot spy on {cls.__name__}: no application set.")
154
- real = app.and_then(lambda a: a.make(cls.get_facade_accessor())).unwrap()
153
+ real: object = (
154
+ cls._get_app()
155
+ .ok_or(f"Cannot spy on {cls.__name__}: no application set.")
156
+ .and_then(lambda a: a.make(cls.get_facade_accessor()))
157
+ .unwrap()
158
+ )
155
159
  spy_obj = MagicMock(spec=type(real), wraps=real)
156
160
  cls.swap(spy_obj)
157
161
  return spy_obj
@@ -193,7 +197,7 @@ class FacadeMeta(ABCMeta):
193
197
 
194
198
  """
195
199
  if cls in FacadeMeta._fake_instances:
196
- return Ok(FacadeMeta._fake_instances[cls])
200
+ return Ok(cast(T, FacadeMeta._fake_instances[cls]))
197
201
  return app.make(interface)
198
202
 
199
203
 
@@ -1,10 +1,8 @@
1
1
  """Configuration system."""
2
2
 
3
- from neva.config.provider import ConfigServiceProvider
4
3
  from neva.config.repository import ConfigRepository
5
4
 
6
5
 
7
6
  __all__ = [
8
7
  "ConfigRepository",
9
- "ConfigServiceProvider",
10
8
  ]
@@ -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
@@ -51,7 +51,9 @@ class AesEncrypter:
51
51
  Returns:
52
52
  Ok with base64-encoded encrypted payload, or Err with message.
53
53
  """
54
- wrapper = {"__str__": value} if isinstance(value, str) else {"__json__": value}
54
+ wrapper: dict[str, JsonValue] = (
55
+ {"__str__": value} if isinstance(value, str) else {"__json__": value}
56
+ )
55
57
 
56
58
  try:
57
59
  payload = json.dumps(wrapper)
@@ -246,7 +246,7 @@ class OptionProtocol[T](ABC):
246
246
 
247
247
 
248
248
  @dataclass(eq=True, frozen=True)
249
- class Some(OptionProtocol[T]):
249
+ class Some[T](OptionProtocol[T]):
250
250
  """An Option containing a value.
251
251
 
252
252
  Represents the presence of a value in an Option type. All transformation
@@ -330,7 +330,7 @@ class Some(OptionProtocol[T]):
330
330
 
331
331
 
332
332
  @dataclass(eq=True, frozen=True)
333
- class Nothing(OptionProtocol[T]):
333
+ class Nothing[T](OptionProtocol[T]):
334
334
  """An Option containing no value.
335
335
 
336
336
  Represents the absence of a value in an Option type. All transformation
@@ -594,7 +594,7 @@ class ResultProtocol[T, E](ABC):
594
594
 
595
595
 
596
596
  @dataclass(eq=True, frozen=True)
597
- class Ok(ResultProtocol[T, E]):
597
+ class Ok[T, E](ResultProtocol[T, E]):
598
598
  """A Result containing a success value.
599
599
 
600
600
  Represents a successful operation in a Result type. All transformation
@@ -671,7 +671,7 @@ class Ok(ResultProtocol[T, E]):
671
671
 
672
672
 
673
673
  @dataclass(eq=True, frozen=True)
674
- class Err(ResultProtocol[T, E]):
674
+ class Err[T, E](ResultProtocol[T, E]):
675
675
  """A Result containing an error value.
676
676
 
677
677
  Represents a failed operation in a Result type. All transformation methods
@@ -1,9 +1,8 @@
1
1
  """Fixtures for testing."""
2
2
 
3
3
  from collections.abc import AsyncIterator, Callable
4
- from contextlib import asynccontextmanager
4
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
5
5
  from pathlib import Path
6
- from typing import AsyncContextManager
7
6
 
8
7
  import pytest
9
8
 
@@ -45,7 +44,7 @@ def webapp(test_config: Path) -> App:
45
44
 
46
45
 
47
46
  @pytest.fixture
48
- def app_factory() -> Callable[[Path], AsyncContextManager[Application]]:
47
+ def app_factory() -> Callable[[Path], AbstractAsyncContextManager[Application]]:
49
48
  """Factory fixture for creating applications with custom configs.
50
49
 
51
50
  Returns:
@@ -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.1.0"
10
+ version = "2.2.1"
11
11
  description = "Add your description here"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.12"
@@ -31,6 +31,7 @@ testing = ["pytest>=9.0.2", "pytest-asyncio>=0.25.3"]
31
31
  [dependency-groups]
32
32
  dev = [
33
33
  "bandit>=1.9.2",
34
+ "mypy>=1.19.1",
34
35
  "poethepoet>=0.38.0",
35
36
  "polyfactory>=3.1.0",
36
37
  "pre-commit>=4.5.0",
@@ -38,8 +39,7 @@ dev = [
38
39
  "pytest-asyncio>=0.25.3",
39
40
  "pytest-benchmark>=5.2.3",
40
41
  "pytest-cov>=7.0.0",
41
- "ruff>=0.14.8",
42
- "ty>=0.0.8",
42
+ "ruff>=0.15.6",
43
43
  ]
44
44
 
45
45
  [tool.pytest.ini_options]
@@ -49,9 +49,11 @@ testpaths = ["tests"]
49
49
 
50
50
  [tool.poe.tasks]
51
51
  # Code quality
52
- lint = "ruff check"
53
- fmt = "ruff format"
54
- tc = "ty check"
52
+ ruff = "uv run ruff"
53
+ mypy = "uv run mypy"
54
+ lint = "poe ruff check"
55
+ fmt = "poe ruff format"
56
+ tc = "poe mypy ."
55
57
 
56
58
  # Testing
57
59
  test = "pytest"
@@ -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]
@@ -83,3 +83,29 @@ class TestRefreshDatabaseMechanism(DatabaseTestCase):
83
83
 
84
84
  result = await session.execute(select(Item).where(Item.id == 10))
85
85
  assert result.scalar_one().name == "Visible"
86
+
87
+
88
+ class TestRefreshDatabaseNestedRollback(DatabaseTestCase):
89
+ """Verify that data written inside a nested db.begin() is rolled back.
90
+
91
+ A nested begin() releases a savepoint on exit, folding its writes into
92
+ the outer connection-level transaction. The outer RefreshDatabase rollback
93
+ must undo those writes too. Tests use an a/b prefix to enforce order.
94
+ """
95
+
96
+ async def test_a_write_inside_nested_begin(self) -> None:
97
+ """Write via a nested db.begin() that exits normally (savepoint released)."""
98
+ db = self.app.make(DatabaseManager).unwrap()
99
+ async with db.begin() as tx:
100
+ tx.session.add(Item(id=50, name="Nested"))
101
+ await tx.session.flush()
102
+
103
+ result = await tx.session.execute(select(Item).where(Item.id == 50))
104
+ assert result.scalar_one().name == "Nested"
105
+
106
+ async def test_b_nested_write_is_gone_after_rollback(self) -> None:
107
+ """The savepoint-released write must not survive across tests."""
108
+ db = self.app.make(DatabaseManager).unwrap()
109
+ async with db.begin() as tx:
110
+ result = await tx.session.execute(select(Item).where(Item.id == 50))
111
+ assert result.scalar_one_or_none() is None