python-neva 3.5.0__py3-none-any.whl → 3.6.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.
neva/arch/application.py CHANGED
@@ -43,6 +43,9 @@ class Application:
43
43
  self.config: ConfigRepository = ConfigRepository()
44
44
  self.providers: OrderedDict[type, ServiceProvider] = OrderedDict()
45
45
  self.root_provider: dishka.Provider = dishka.Provider(scope=Scope.APP)
46
+ self._extra_providers: list[BaseProvider] = []
47
+ self._container: dishka.AsyncContainer | None = None
48
+ self._booted: bool = False
46
49
 
47
50
  configuration_path = (
48
51
  config_path
@@ -77,30 +80,70 @@ class Application:
77
80
  _ = self.root_provider.provide(source=lambda: self, provides=Application)
78
81
  self.register_providers(providers)
79
82
  self._bind_event_listeners()
80
- self.build_container(self.root_provider)
83
+ self.build_container()
81
84
 
82
- def build_container(self, *providers: BaseProvider) -> None:
83
- """Build the application container."""
84
- self.container: dishka.AsyncContainer = dishka.make_async_container(
85
+ @property
86
+ def container(self) -> dishka.AsyncContainer:
87
+ """The application container, built on first access.
88
+
89
+ Registering a provider discards the built container so the next access
90
+ rebuilds the graph with that provider's bindings in it.
91
+
92
+ Returns:
93
+ The application container.
94
+ """
95
+ if self._container is not None:
96
+ return self._container
97
+ return self.build_container()
98
+
99
+ def build_container(self, *providers: BaseProvider) -> dishka.AsyncContainer:
100
+ """Build the application container.
101
+
102
+ Args:
103
+ providers: Extra dishka providers to compose into the graph. They
104
+ are remembered, so a later rebuild keeps them.
105
+
106
+ Returns:
107
+ The freshly built container.
108
+ """
109
+ self._extra_providers.extend(providers)
110
+ self._container = dishka.make_async_container(
85
111
  self.root_provider,
86
112
  *[p.provider for p in self.providers.values()],
87
- *providers,
113
+ *self._extra_providers,
88
114
  )
115
+ return self._container
89
116
 
90
117
  def register(self, provider: type[ServiceProvider]) -> Result[ServiceProvider, str]:
91
118
  """Registers a service provider with the application.
92
119
 
120
+ Providers must be registered before the application boots: dishka
121
+ validates the dependency graph when the container is built, and the
122
+ booted container already holds APP-scoped instances that a rebuild
123
+ would orphan. Registering after boot is an error rather than a
124
+ silently ignored call.
125
+
93
126
  Returns:
94
127
  Result containing the registered provider instance or an error message.
95
128
  """
96
129
  if provider in self.providers:
97
130
  return Ok(self.providers[provider])
98
131
 
99
- return (
132
+ if self._booted:
133
+ return Err(
134
+ f"Cannot register '{provider.__name__}': the application is "
135
+ "already booted. Register providers before entering lifespan(), "
136
+ "or declare them in the 'providers' config namespace."
137
+ )
138
+
139
+ registered = (
100
140
  provider(self)
101
141
  .register()
102
142
  .map(lambda p: self.providers.setdefault(provider, p))
103
143
  )
144
+ if registered.is_ok:
145
+ self._container = None
146
+ return registered
104
147
 
105
148
  def register_providers(self, providers: Sequence[type[ServiceProvider]]) -> None:
106
149
  """Registers a set of providers."""
@@ -122,15 +165,28 @@ class Application:
122
165
  provides=interface,
123
166
  )
124
167
 
168
+ @property
169
+ def current_container(self) -> dishka.AsyncContainer:
170
+ """The container backing resolution in the current context.
171
+
172
+ Inside ``scope()`` this is the child container for that scope; outside
173
+ it, the application container. Integrations that hand a container to
174
+ another framework — a request-scoped container to a route layer, say —
175
+ want this one, not ``container``.
176
+
177
+ Returns:
178
+ The innermost container entered in this context.
179
+ """
180
+ return _current_container.get(self.container)
181
+
125
182
  async def make_async[T](self, interface: type[T]) -> Result[T, str]:
126
183
  """Resolve and instanciate a type from the container.
127
184
 
128
185
  Returns:
129
186
  Result containing the resolved type instance or an error message.
130
187
  """
131
- container = _current_container.get(self.container)
132
188
  try:
133
- return Ok(await container.get(interface))
189
+ return Ok(await self.current_container.get(interface))
134
190
  except Exception as e:
135
191
  return Err(f"Failed to resolve service '{interface.__name__}': {e}")
136
192
 
@@ -140,7 +196,7 @@ class Application:
140
196
  Returns:
141
197
  Result containing the resolved type instance or an error message.
142
198
  """
143
- container = _current_container.get(self.container)
199
+ container = self.current_container
144
200
  try:
145
201
  return Ok(container.get_sync(interface))
146
202
  except Exception as e:
@@ -154,11 +210,13 @@ class Application:
154
210
  ) -> AsyncGenerator[Self]:
155
211
  """Enter a new scope.
156
212
 
213
+ The application is yielded, not the child container; read
214
+ ``current_container`` inside the block to reach it.
215
+
157
216
  Yields:
158
217
  The application instance with the new scope.
159
218
  """
160
- parent = _current_container.get(self.container)
161
- async with parent(scope=scope, context=context) as container:
219
+ async with self.current_container(scope=scope, context=context) as container:
162
220
  token = _current_container.set(container)
163
221
  try:
164
222
  yield self
@@ -169,24 +227,32 @@ class Application:
169
227
  async def lifespan(self) -> AsyncGenerator[None]:
170
228
  """Wire the facades and providers."""
171
229
  Facade.set_facade_application(self)
230
+ self._booted = True
172
231
 
173
- async with AsyncExitStack() as stack:
174
- for provider in self.providers.values():
175
- if isinstance(provider, Bootable):
176
- await stack.enter_async_context(provider.lifespan())
177
-
178
- await self._wire_event_listeners()
179
- yield
180
-
181
- await self.container.close()
182
- Facade.reset_facade_application()
232
+ try:
233
+ async with AsyncExitStack() as stack:
234
+ for provider in self.providers.values():
235
+ if isinstance(provider, Bootable):
236
+ await stack.enter_async_context(provider.lifespan())
237
+
238
+ await self._wire_event_listeners()
239
+ yield
240
+ finally:
241
+ # Read `_container` rather than the property: the container may
242
+ # never have been built, and closing is no reason to build one.
243
+ if self._container is not None:
244
+ await self._container.close()
245
+ self._booted = False
246
+ Facade.reset_facade_application()
183
247
 
184
248
  def _bind_event_listeners(self) -> None:
185
249
  """Bind all listener classes declared in provider ``listen`` dicts."""
186
250
  for provider in self.providers.values():
187
251
  for listeners in provider.listen.values():
188
252
  for listener_cls in listeners:
189
- self.bind(listener_cls)
253
+ # Straight onto the root provider: routing this through the
254
+ # deprecated `bind` warned on every boot.
255
+ _ = self.root_provider.provide(listener_cls, scope=Scope.APP)
190
256
 
191
257
  async def _wire_event_listeners(self) -> None:
192
258
  """Wire event-listener mappings from all providers onto the dispatcher."""
neva/arch/config.py CHANGED
@@ -24,7 +24,7 @@ class AppConfig(TypedDict):
24
24
  openapi_url: NotRequired[str]
25
25
  docs_url: NotRequired[str]
26
26
  redoc_url: NotRequired[str]
27
- providers: NotRequired[list[ServiceProvider]]
27
+ providers: NotRequired[list[type[ServiceProvider]]]
28
28
 
29
29
 
30
30
  class ProviderConfig(TypedDict):
@@ -34,4 +34,4 @@ class ProviderConfig(TypedDict):
34
34
  providers: List of service providers to register.
35
35
  """
36
36
 
37
- providers: NotRequired[list[ServiceProvider]]
37
+ providers: NotRequired[list[type[ServiceProvider]]]
neva/arch/scopes.py CHANGED
@@ -2,4 +2,3 @@ from dishka.entities.scope import BaseScope, Scope
2
2
 
3
3
 
4
4
  __all__ = ["BaseScope", "Scope"]
5
-
@@ -8,6 +8,7 @@ container and optionally managing their lifecycle.
8
8
  from __future__ import annotations
9
9
 
10
10
  import abc
11
+ import warnings
11
12
  from contextlib import AbstractAsyncContextManager
12
13
  from typing import (
13
14
  TYPE_CHECKING,
@@ -70,7 +71,7 @@ class ServiceProvider(abc.ABC):
70
71
  """
71
72
 
72
73
  app: "Application"
73
- listen: ClassVar[dict[type[Event], list[type[EventListener[Any]]]]] = {}
74
+ listen: ClassVar[dict[type[Event[Any]], list[type[EventListener[Any]]]]] = {}
74
75
  when: ClassVar[Marker | None] = None
75
76
 
76
77
  def __init__(self, app: "Application") -> None:
@@ -106,17 +107,91 @@ class ServiceProvider(abc.ABC):
106
107
  interface: type | None = None,
107
108
  scope: BaseScope | None = None,
108
109
  when: BaseMarker | None = None,
109
- cache: bool = True,
110
+ cache: bool | None = None,
110
111
  ) -> None:
111
- """Binds a source to the container."""
112
+ """Binds a source to the container.
113
+
114
+ Prefer the named lifetimes — `singleton`, `scoped`, `transient`,
115
+ `instance` — which say what they mean. Use `bind` directly only to
116
+ reach a scope those do not cover, and then pass `scope` explicitly.
117
+
118
+ Calling this with neither `scope` nor `cache` is deprecated: the
119
+ resulting binding is a process-wide singleton, which is the opposite of
120
+ Laravel's transient `bind`, and nothing at the call site says so.
121
+
122
+ Args:
123
+ source: The class or factory to bind.
124
+ interface: The type to resolve it as. Defaults to `source`.
125
+ scope: The dishka scope. Defaults to the provider's (APP).
126
+ when: Marker gating this binding.
127
+ cache: Whether to reuse one instance per scope. Defaults to True.
128
+ """
129
+ if scope is None and cache is None:
130
+ warnings.warn(
131
+ "bind() without an explicit lifetime is deprecated and will "
132
+ "require one in 4.0. It currently creates a process-wide "
133
+ "singleton: use singleton() for that, or scoped() / "
134
+ "transient() / instance(), or pass scope= or cache= yourself.",
135
+ DeprecationWarning,
136
+ stacklevel=2,
137
+ )
138
+
112
139
  _ = self.provider.provide(
113
140
  source=source,
114
141
  scope=scope,
115
142
  provides=interface,
116
- cache=cache,
143
+ cache=True if cache is None else cache,
117
144
  when=when,
118
145
  )
119
146
 
147
+ def singleton(
148
+ self,
149
+ source: type | Callable[..., Any],
150
+ *,
151
+ interface: type | None = None,
152
+ when: BaseMarker | None = None,
153
+ ) -> Self:
154
+ """Binds the source at APP scope, as one instance for the process.
155
+
156
+ This is what a bare `bind` has always done; naming it makes the
157
+ lifetime visible at the call site.
158
+
159
+ Returns:
160
+ the provider itself for chaining purposes
161
+ """
162
+ self.bind(source, interface=interface, scope=Scope.APP, when=when, cache=True)
163
+ return self
164
+
165
+ def instance(
166
+ self,
167
+ value: object,
168
+ *,
169
+ interface: type | None = None,
170
+ when: BaseMarker | None = None,
171
+ ) -> Self:
172
+ """Binds an object that already exists.
173
+
174
+ Nothing is constructed: the value handed in is what every resolution
175
+ returns. Use it for configuration objects, clients built during boot,
176
+ and test doubles.
177
+
178
+ Args:
179
+ value: The object to hand out.
180
+ interface: The type to resolve it as. Defaults to `type(value)`.
181
+ when: Marker gating this binding.
182
+
183
+ Returns:
184
+ the provider itself for chaining purposes
185
+ """
186
+ self.bind(
187
+ lambda: value,
188
+ interface=interface if interface is not None else type(value),
189
+ scope=Scope.APP,
190
+ when=when,
191
+ cache=True,
192
+ )
193
+ return self
194
+
120
195
  def scoped(
121
196
  self,
122
197
  source: type | Callable[..., Any],
@@ -124,10 +199,10 @@ class ServiceProvider(abc.ABC):
124
199
  interface: type | None = None,
125
200
  when: BaseMarker | None = None,
126
201
  ) -> Self:
127
- """Binds the source to the container.
202
+ """Binds the source at REQUEST scope.
128
203
 
129
- Scope is REQUEST by default but a custom scope be provided.
130
- Dependency declared with this are cached no matter what.
204
+ One instance is created per request scope and cached for its duration.
205
+ For any other scope, call `bind` with an explicit `scope`.
131
206
 
132
207
  Returns:
133
208
  the provider itself for chaining purposes
neva/config/loader.py CHANGED
@@ -14,6 +14,11 @@ from neva.support import Err, Ok, Option, Result
14
14
  from neva.support.accessors import get_attr
15
15
 
16
16
 
17
+ # Deliberately not "neva.config": a config file named repository.py or
18
+ # loader.py would otherwise displace the real module of that name.
19
+ _CONFIG_MODULE_NAMESPACE = "neva._app_config"
20
+
21
+
17
22
  class ConfigLoader:
18
23
  """Loads configuration files from a directory.
19
24
 
@@ -90,15 +95,22 @@ class ConfigLoader:
90
95
  """
91
96
  try:
92
97
  spec = importlib.util.spec_from_file_location(
93
- f"neva.config.{module_name}", file_path
98
+ f"{_CONFIG_MODULE_NAMESPACE}.{module_name}", file_path
94
99
  )
95
100
 
96
101
  if spec is None or spec.loader is None:
97
102
  return Err(f"Could not load module spec for {file_path}")
98
103
 
99
104
  module = importlib.util.module_from_spec(spec)
105
+ # Registered so dataclasses, pickle and typing lookups inside the
106
+ # config file can resolve it, then removed: these are data files,
107
+ # not importable modules, and leaving them in place would let a
108
+ # second config directory shadow the first.
100
109
  sys.modules[spec.name] = module
101
- spec.loader.exec_module(module)
110
+ try:
111
+ spec.loader.exec_module(module)
112
+ finally:
113
+ _ = sys.modules.pop(spec.name, None)
102
114
 
103
115
  config_dict = self._extract_config(module)
104
116
  return Ok(config_dict)
neva/config/repository.py CHANGED
@@ -5,6 +5,7 @@ store for all application configuration values. It supports dot notation for
5
5
  nested access and can be frozen to prevent modifications after initialization.
6
6
  """
7
7
 
8
+ import copy
8
9
  from typing import Any, cast
9
10
 
10
11
  from typing_extensions import TypeForm
@@ -105,10 +106,11 @@ class ConfigRepository:
105
106
  """Get all configuration items as a dictionary.
106
107
 
107
108
  Returns:
108
- A copy of the entire configuration dictionary.
109
+ A deep copy of the entire configuration dictionary. Mutating it
110
+ never reaches the repository, frozen or not.
109
111
 
110
112
  """
111
- return self._items.copy()
113
+ return copy.deepcopy(self._items)
112
114
 
113
115
  def freeze(self) -> None:
114
116
  """Freeze the configuration to prevent further changes.
@@ -130,8 +132,10 @@ class ConfigRepository:
130
132
  def merge(self, key: str, items: dict[str, Any]) -> Result[None, str]:
131
133
  """Merge items into a configuration namespace.
132
134
 
133
- Updates the dictionary at the given key with the provided items.
134
- Creates the namespace if it doesn't exist.
135
+ Merges recursively: nested dictionaries are combined rather than
136
+ replaced, so a package can register defaults and an application can
137
+ override individual leaves without restating the whole namespace.
138
+ Non-dict values are replaced outright.
135
139
 
136
140
  Args:
137
141
  key: The namespace key to merge into.
@@ -153,5 +157,20 @@ class ConfigRepository:
153
157
  f"existing value is {type(self._items[key]).__name__}, not dict"
154
158
  )
155
159
 
156
- self._items[key].update(items)
160
+ _deep_merge(self._items[key], items)
157
161
  return Ok(None)
162
+
163
+
164
+ def _deep_merge(target: dict[str, Any], source: dict[str, Any]) -> None:
165
+ """Merge source into target in place, recursing into nested dictionaries.
166
+
167
+ Args:
168
+ target: The dictionary to merge into. Modified in place.
169
+ source: The dictionary to read from.
170
+ """
171
+ for key, value in source.items():
172
+ existing = target.get(key)
173
+ if isinstance(existing, dict) and isinstance(value, dict):
174
+ _deep_merge(existing, value)
175
+ else:
176
+ target[key] = value
@@ -63,6 +63,20 @@ class TransactionContext:
63
63
  return Some(registry.stack[-1])
64
64
  return Nothing()
65
65
 
66
+ def outermost(self) -> Option[BoundTransaction]:
67
+ """Get the outermost open transaction, across all connections.
68
+
69
+ This is the last one to commit, so work that must not outlive *any*
70
+ open transaction — a deferred event listener — belongs on it.
71
+
72
+ Returns:
73
+ The outermost transaction, if any.
74
+ """
75
+ registry = _tx_registry.get()
76
+ if registry is None or not registry.stack:
77
+ return Nothing()
78
+ return Some(registry.stack[0])
79
+
66
80
 
67
81
  @final
68
82
  class ConnectionManager:
@@ -95,53 +109,52 @@ class ConnectionManager:
95
109
 
96
110
  @asynccontextmanager
97
111
  async def _scoped(self, tx: BoundTransaction) -> AsyncIterator[None]:
98
- """Manage registry, state transitions, and callbacks for a transaction.
112
+ """Expose the transaction on the registry for the duration of the block.
113
+
114
+ The registry may be unset in the current context: ``TransactionContext``
115
+ seeds it when it is constructed, and a task that did not inherit that
116
+ context — an ASGI request, when the lifespan ran on its own task — sees
117
+ the ``None`` default. Seed a fresh registry in that case, but still put
118
+ the transaction in it.
99
119
 
100
120
  Yields:
101
121
  None
102
122
  """
103
- old_registry = _tx_registry.get()
104
- token = (
105
- _tx_registry.set(old_registry.extend(tx))
106
- if old_registry is not None
107
- else _tx_registry.set(TransactionRegistry())
108
- )
123
+ old_registry = _tx_registry.get() or TransactionRegistry()
124
+ token = _tx_registry.set(old_registry.extend(tx))
109
125
  try:
110
126
  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
127
  finally:
143
128
  _tx_registry.reset(token)
144
129
 
130
+ async def _settle(self, tx: BoundTransaction, *, committed: bool) -> None:
131
+ """Mark the transaction settled and drain its callbacks.
132
+
133
+ Callers must have already committed or rolled back the underlying
134
+ session: callbacks observe a settled transaction, and the registry no
135
+ longer exposes it, so a callback touching the database opens its own
136
+ transaction rather than reusing the drained one.
137
+ """
138
+ tx.state = (
139
+ TransactionState.COMMITTED if committed else TransactionState.ROLLED_BACK
140
+ )
141
+ if not tx.is_root:
142
+ return
143
+
144
+ results = (
145
+ await tx.execute_on_commit_callbacks()
146
+ if committed
147
+ else await tx.execute_on_rollback_callbacks()
148
+ )
149
+ label = "commit" if committed else "rollback"
150
+ for result in results:
151
+ if result.is_err and self.logger is not None:
152
+ self.logger.error(
153
+ f"{label} callback failed",
154
+ error=result.err().unwrap(),
155
+ connection=self.name,
156
+ )
157
+
145
158
  @asynccontextmanager
146
159
  async def begin(self) -> AsyncIterator[BoundTransaction]:
147
160
  """Open a new bound transaction with an active database session.
@@ -162,23 +175,27 @@ class ConnectionManager:
162
175
  try:
163
176
  async with self._scoped(tx):
164
177
  yield tx
165
- if tx.rollback_requested:
166
- await sp.rollback()
167
- else:
168
- await sp.commit()
178
+ if tx.rollback_requested:
179
+ await sp.rollback()
180
+ else:
181
+ await sp.commit()
169
182
  except BaseException:
170
183
  await sp.rollback()
184
+ await self._settle(tx, committed=False)
171
185
  raise
186
+ await self._settle(tx, committed=not tx.rollback_requested)
172
187
  case _:
173
188
  async with self.session_factory() as session:
174
189
  tx = BoundTransaction(self.name, session)
175
190
  try:
176
191
  async with self._scoped(tx):
177
192
  yield tx
178
- if tx.rollback_requested:
179
- await session.rollback()
180
- else:
181
- await session.commit()
193
+ if tx.rollback_requested:
194
+ await session.rollback()
195
+ else:
196
+ await session.commit()
182
197
  except BaseException:
183
198
  await session.rollback()
199
+ await self._settle(tx, committed=False)
184
200
  raise
201
+ await self._settle(tx, committed=not tx.rollback_requested)
neva/database/manager.py CHANGED
@@ -68,6 +68,17 @@ class DatabaseManager:
68
68
  """
69
69
  return self._tx_context.current(connection)
70
70
 
71
+ def outermost(self) -> Option[BoundTransaction]:
72
+ """Returns the outermost open transaction, across all connections.
73
+
74
+ Unlike `current`, which returns the most recently opened transaction,
75
+ this returns the one that commits last.
76
+
77
+ Returns:
78
+ The outermost transaction, if any.
79
+ """
80
+ return self._tx_context.outermost()
81
+
71
82
  def session(self, connection: str | None = None) -> Option[asyncio.AsyncSession]:
72
83
  """Returns the current session for a connection.
73
84
 
neva/database/provider.py CHANGED
@@ -17,8 +17,8 @@ class DatabaseServiceProvider(ServiceProvider):
17
17
 
18
18
  @override
19
19
  def register(self) -> Result[Self, str]:
20
- self.bind(TransactionContext)
21
- self.bind(DatabaseManager)
20
+ _ = self.singleton(TransactionContext)
21
+ _ = self.singleton(DatabaseManager)
22
22
  return Ok(self)
23
23
 
24
24
  @asynccontextmanager
@@ -29,13 +29,15 @@ class DatabaseServiceProvider(ServiceProvider):
29
29
  logger.info("Beginning SQLAlchemy initialization...")
30
30
  match self.app.config.get("database", type_=DatabaseConfig):
31
31
  case Ok(config):
32
- connections: dict[str, ConnectionConfig] = config["connections"]
32
+ connections: dict[str, ConnectionConfig] = config.get("connections", {})
33
33
  for name, conn_config in connections.items():
34
34
  db.register_connection(name, conn_config)
35
35
  logger.info(f"Registered engine for connection '{name}'.")
36
36
  logger.info("SQLAlchemy initialization complete.")
37
- yield
38
- await db.close()
39
37
  case Err(err):
40
38
  logger.error(f"Failed to load database configuration: {err}")
41
- yield
39
+
40
+ try:
41
+ yield
42
+ finally:
43
+ await db.close()
@@ -1,6 +1,5 @@
1
1
  """Transaction management systems."""
2
2
 
3
- import abc
4
3
  import asyncio
5
4
  from collections.abc import Awaitable
6
5
  from dataclasses import dataclass, field
@@ -24,7 +23,7 @@ class TransactionState(Enum):
24
23
 
25
24
 
26
25
  @dataclass
27
- class Transaction(abc.ABC):
26
+ class Transaction:
28
27
  """Represents a database transaction with callback support."""
29
28
 
30
29
  conn_name: str
@@ -1,3 +1,5 @@
1
+ """Protocols describing the event system's public contracts."""
2
+
1
3
  from neva.events.contracts.dispatcher import (
2
4
  AsyncBeforeDispatchHook,
3
5
  BeforeDispatchHook,
neva/events/dispatcher.py CHANGED
@@ -7,7 +7,7 @@ from neva.arch.application import Application
7
7
  from neva.database.manager import DatabaseManager
8
8
  from neva.database.transaction import TransactionCallback
9
9
  from neva.events import contracts
10
- from neva.events.event_registry import EventRegistry
10
+ from neva.events.event_registry import EventRegistry, flatten_listeners
11
11
  from neva.support import Err, Nothing, Result, Some
12
12
 
13
13
 
@@ -52,27 +52,30 @@ class EventDispatcher(contracts.EventDispatcher):
52
52
  Listeners are resolved from the DI container when available,
53
53
  falling back to direct instantiation otherwise.
54
54
 
55
+ Deferred listeners are attached to the outermost open transaction —
56
+ the last one to commit — so they never run for work an enclosing
57
+ transaction may still roll back.
58
+
55
59
  Args:
56
60
  event: The event to dispatch.
57
61
 
58
62
  Returns:
59
- A list of results, one per listener invocation.
63
+ A list of results, one per immediate listener invocation. Deferred
64
+ listeners report through the transaction's callback log instead.
60
65
  """
61
66
  await self._apply_before_dispatch(event)
62
67
  results: list[Result[None, str]] = []
63
- listeners = self._registry.resolve_listeners(event)
68
+ registries = self._registry.resolve_listeners(event)
64
69
 
65
- immediate: list[type[contracts.EventListener[contracts.Event]]] = []
66
- deferred: list[type[contracts.EventListener[contracts.Event]]] = []
67
- [immediate.extend(listener.immediate.copy()) for listener in listeners]
68
- [deferred.extend(listener.deferred.copy()) for listener in listeners]
70
+ immediate = flatten_listeners(registries, deferred=False)
71
+ deferred = flatten_listeners(registries, deferred=True)
69
72
 
70
- match self._db.current():
73
+ match self._db.outermost():
71
74
  case Some(tx):
72
75
  for listener_cls in deferred:
73
76
  _ = tx.on_commit(self._build_callback(event, listener_cls))
74
77
  case Nothing():
75
- immediate.extend(deferred)
78
+ immediate.extend(d for d in deferred if d not in immediate)
76
79
 
77
80
  for listener_cls in immediate:
78
81
  listener = self._resolve_listener(listener_cls)
@@ -46,14 +46,51 @@ class EventRegistry:
46
46
  )
47
47
 
48
48
  def resolve_listeners(self, event: T) -> list[EventListenerRegistry[T]]:
49
- """Return all listeners registries for an event type."""
50
- registries = [
49
+ """Return all listener registries for an event type, most specific first.
50
+
51
+ Walks the MRO so a listener registered against a base event also
52
+ handles its subclasses. Callers must flatten these with
53
+ `flatten_listeners` rather than concatenating them, or a listener
54
+ registered against both a class and its parent runs twice.
55
+ """
56
+ return [
51
57
  self.__listeners[cls]
52
58
  for cls in inspect.getmro(type(event))
53
59
  if cls in self.__listeners
54
60
  ]
55
- return registries
56
61
 
57
62
  def get_listeners(self, event: T) -> EventListenerRegistry[T]:
58
63
  """Return all listeners registered for an event type."""
59
64
  return self.__listeners.get(type(event), EventListenerRegistry[T]())
65
+
66
+
67
+ def flatten_listeners[E: contracts.Event](
68
+ registries: list[EventListenerRegistry[E]],
69
+ *,
70
+ deferred: bool,
71
+ ) -> list[type[contracts.EventListener[E]]]:
72
+ """Collapse listener registries into one ordered, duplicate-free list.
73
+
74
+ Args:
75
+ registries: Registries for an event, most specific first.
76
+ deferred: Select the deferred bucket rather than the immediate one.
77
+
78
+ Returns:
79
+ The listener classes to invoke, most-specific registry first. A listener
80
+ reached through more than one registry appears only once; repeated
81
+ registrations against the same event are preserved.
82
+ """
83
+ flattened: list[type[contracts.EventListener[E]]] = []
84
+ covered: set[type[contracts.EventListener[E]]] = set()
85
+
86
+ for registry in registries:
87
+ bucket = registry.deferred if deferred else registry.immediate
88
+ # Duplicates *within* one registry are explicit repeat registrations
89
+ # and are honoured. Duplicates *across* registries come from the MRO
90
+ # walk — one listener registered against both a class and its parent —
91
+ # and must collapse to the registrations already counted.
92
+ fresh = [cls for cls in bucket if cls not in covered]
93
+ flattened.extend(fresh)
94
+ covered.update(fresh)
95
+
96
+ return flattened
neva/events/listener.py CHANGED
@@ -15,7 +15,7 @@ class EventListener[T: contracts.Event](contracts.EventListener[T]):
15
15
  policy: HandlingPolicy = HandlingPolicy.IMMEDIATE
16
16
 
17
17
  @override
18
- async def handle(self, event: contracts.Event) -> Result[None, str]: ...
18
+ async def handle(self, event: T) -> Result[None, str]: ...
19
19
 
20
20
 
21
21
  def listener[T: contracts.Event](
@@ -37,11 +37,10 @@ def listener[T: contracts.Event](
37
37
  namespace["handle"] = handle
38
38
  namespace["policy"] = policy
39
39
 
40
- # Ty struggles with infering type here, hence the type: ignore
41
40
  return new_class(
42
41
  snake2pascal(func.__name__) + "Listener",
43
42
  (EventListener[T],),
44
43
  exec_body=exec_body,
45
- ) # type: ignore[invalid-return-type]
44
+ )
46
45
 
47
46
  return decorator
neva/events/provider.py CHANGED
@@ -24,7 +24,7 @@ class EventServiceProvider(ServiceProvider):
24
24
  Returns:
25
25
  Result[Self, str]: The result of the registration.
26
26
  """
27
- self.bind(EventRegistry)
28
- self.bind(EventDispatcher)
29
- self.bind(EventDispatcher, interface=contracts.EventDispatcher)
27
+ _ = self.singleton(EventRegistry)
28
+ _ = self.singleton(EventDispatcher)
29
+ _ = self.singleton(EventDispatcher, interface=contracts.EventDispatcher)
30
30
  return Ok(self)
@@ -21,6 +21,6 @@ class LogServiceProvider(ServiceProvider):
21
21
 
22
22
  @override
23
23
  def register(self) -> Result[Self, str]:
24
- self.bind(LogManager)
24
+ _ = self.singleton(LogManager)
25
25
 
26
26
  return Ok(self)
@@ -1,3 +1,5 @@
1
+ """Polyfactory helpers for building and persisting model factories."""
2
+
1
3
  from neva.polyfactory.factories import ModelFactory
2
4
 
3
5
 
@@ -1,5 +1,6 @@
1
1
  """Hash manager for managing password hashing strategies."""
2
2
 
3
+ import asyncio
3
4
  from typing import override
4
5
 
5
6
  from neva.arch import Application
@@ -78,6 +79,52 @@ class HashManager(StrategyResolver[Hasher]):
78
79
  )
79
80
  return hasher_instance.check(plaintext, hashed)
80
81
 
82
+ async def make_async(
83
+ self,
84
+ plaintext: str | bytes,
85
+ *,
86
+ salt: bytes | None = None,
87
+ hasher: str | None = None,
88
+ ) -> str:
89
+ """Hash a plaintext password on a worker thread.
90
+
91
+ Password hashing is deliberately CPU- and memory-expensive — argon2
92
+ defaults to 100 MiB and tens of milliseconds — so calling `make` from
93
+ a coroutine stalls the whole event loop. Prefer this in async code.
94
+
95
+ Args:
96
+ plaintext: The password to hash.
97
+ salt: Optional salt for the hash.
98
+ hasher: Name of the hasher to use (defaults to configured hasher).
99
+
100
+ Returns:
101
+ The hashed password string.
102
+ """
103
+ return await asyncio.to_thread(self.make, plaintext, salt=salt, hasher=hasher)
104
+
105
+ async def check_async(
106
+ self,
107
+ plaintext: str | bytes,
108
+ hashed: str | bytes,
109
+ *,
110
+ hasher: str | None = None,
111
+ ) -> bool:
112
+ """Verify a password against a hashed value on a worker thread.
113
+
114
+ Verification costs the same as hashing by design. Prefer this over
115
+ `check` in async code — a login endpoint calling `check` blocks every
116
+ other request on the loop for the duration.
117
+
118
+ Args:
119
+ plaintext: The plaintext password to verify.
120
+ hashed: The hashed password to verify against.
121
+ hasher: Name of the hasher to use (defaults to configured hasher).
122
+
123
+ Returns:
124
+ True if the password matches, False otherwise.
125
+ """
126
+ return await asyncio.to_thread(self.check, plaintext, hashed, hasher=hasher)
127
+
81
128
  def needs_rehash(
82
129
  self,
83
130
  hashed: str | bytes,
@@ -109,7 +156,7 @@ class HashManager(StrategyResolver[Hasher]):
109
156
  """
110
157
  argon_config = manager.app.config.get(
111
158
  "hashing.argon", type_=Argon2Config
112
- ).unwrap()
159
+ ).unwrap_or(Argon2Config())
113
160
 
114
161
  return Argon2Hasher(
115
162
  time_cost=argon_config.get("time_cost", 2),
@@ -130,7 +177,7 @@ class HashManager(StrategyResolver[Hasher]):
130
177
  """
131
178
  bcrypt_config = manager.app.config.get(
132
179
  "hashing.bcrypt", type_=BcryptConfig
133
- ).unwrap()
180
+ ).unwrap_or(BcryptConfig())
134
181
 
135
182
  return BcryptHasher(
136
183
  rounds=bcrypt_config.get("rounds", 12),
@@ -13,7 +13,7 @@ class BcryptHasher(Hasher):
13
13
  def __init__(
14
14
  self,
15
15
  rounds: int = 12,
16
- prefix: Literal["2a", "2b"] = "2a",
16
+ prefix: Literal["2a", "2b"] = "2b",
17
17
  ) -> None:
18
18
  """Initialize the hasher."""
19
19
  self._hasher: PwdLibBcryptHasher = PwdLibBcryptHasher(rounds, prefix)
neva/security/provider.py CHANGED
@@ -13,6 +13,6 @@ class SecurityProvider(arch.ServiceProvider):
13
13
 
14
14
  @override
15
15
  def register(self) -> Result[Self, str]:
16
- self.bind(HashManager, interface=HashManager)
17
- self.bind(AesEncrypter, interface=Encrypter)
16
+ _ = self.singleton(HashManager, interface=HashManager)
17
+ _ = self.singleton(AesEncrypter, interface=Encrypter)
18
18
  return Ok(self)
@@ -2,6 +2,8 @@
2
2
 
3
3
  from typing import Any, override
4
4
 
5
+ from typing_extensions import TypeForm
6
+
5
7
  from neva.arch import Facade
6
8
  from neva.support import Result
7
9
 
@@ -26,15 +28,16 @@ class Config(Facade):
26
28
  ...
27
29
 
28
30
  @classmethod
29
- def get(cls, key: str, default: object = None) -> Result[Any, str]:
31
+ def get[T](cls, key: str, type_: TypeForm[T] | None = None) -> Result[T, str]:
30
32
  """Get a configuration value using dot notation.
31
33
 
32
34
  Args:
33
35
  key: Dot-notated key path (e.g., "database.host").
34
- default: Default value to return if key is not found.
36
+ type_: Type to cast the value to. This is a cast, not validation.
35
37
 
36
38
  Returns:
37
- Result containing the configuration value or the default.
39
+ Result containing the configuration value, or an error if absent.
40
+ Supply a fallback with `Config.get(key).unwrap_or(default)`.
38
41
 
39
42
  """
40
43
  ...
@@ -1,4 +1,4 @@
1
- """Type stop for Hash facade."""
1
+ """Type stub for Hash facade."""
2
2
 
3
3
  from typing import override
4
4
 
@@ -46,6 +46,50 @@ class Hash(Facade):
46
46
  True if the password matches, False otherwise.
47
47
  """
48
48
 
49
+ @classmethod
50
+ async def make_async(
51
+ cls,
52
+ plaintext: str | bytes,
53
+ *,
54
+ salt: bytes | None = None,
55
+ hasher: str | None = None,
56
+ ) -> str:
57
+ """Hash a plaintext password on a worker thread.
58
+
59
+ Prefer this over `make` in async code: hashing is deliberately
60
+ expensive and blocks the event loop for its whole duration.
61
+
62
+ Args:
63
+ plaintext: The password to hash.
64
+ salt: Optional salt for the hash.
65
+ hasher: Name of the hasher to use (defaults to configured hasher).
66
+
67
+ Returns:
68
+ The hashed password string.
69
+ """
70
+
71
+ @classmethod
72
+ async def check_async(
73
+ cls,
74
+ plaintext: str | bytes,
75
+ hashed: str | bytes,
76
+ *,
77
+ hasher: str | None = None,
78
+ ) -> bool:
79
+ """Verify a password against a hashed value on a worker thread.
80
+
81
+ Prefer this over `check` in async code: verification costs the same as
82
+ hashing by design.
83
+
84
+ Args:
85
+ plaintext: The plaintext password to verify.
86
+ hashed: The hashed password to verify against.
87
+ hasher: Name of the hasher to use (defaults to configured hasher).
88
+
89
+ Returns:
90
+ True if the password matches, False otherwise.
91
+ """
92
+
49
93
  @classmethod
50
94
  def needs_rehash(
51
95
  cls,
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.5
2
+ Name: python-neva
3
+ Version: 3.6.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
+ # 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 (pyrefly)
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
@@ -1,34 +1,34 @@
1
1
  neva/arch/__init__.py,sha256=1ukTl5Ynz5PrGa5e-tFSMaNgVkfhQRrYQGPUqUeXMMg,429
2
- neva/arch/application.py,sha256=DH1CXVEsiOCDQIigOXfXI2jYGVZr-TwMgilTQu6qPq0,7258
3
- neva/arch/config.py,sha256=R8_wMKTwX8vTFqfQhgglDQ9PfB6Yo6mx6OnKg8bnIWw,1042
2
+ neva/arch/application.py,sha256=PQ5Zb29kwt6srDArnDrxwOjc62MYutQTmhlUCQ5wSlQ,9968
3
+ neva/arch/config.py,sha256=k8Nf5kBfw0We-gFxPqDEi78T8eFHBhZe1qTpCi2Q0kA,1054
4
4
  neva/arch/facade.py,sha256=1kYVhyYto8zSyOZV6sAYJ3xWzQDJRzoVV-e_EarTp0Q,7927
5
5
  neva/arch/markers.py,sha256=STdhmW88qV8uogIa9kSD9gs-uMRaglZI8HOw3PrhX1Q,103
6
6
  neva/arch/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- neva/arch/scopes.py,sha256=Gu0kJk39Yv8LCCzxjdu0lIE0FXtnsG36LGKDuNin2CE,87
8
- neva/arch/service_provider.py,sha256=SqL3uQGBh7aDIc_Lcp8Ijr3fdOtB8wbsmMnjU4GuUy4,5862
7
+ neva/arch/scopes.py,sha256=9sc1nIVeODQzAtNwvSUWiij6IIssNyOaKW2CNknBgTo,86
8
+ neva/arch/service_provider.py,sha256=BRHMHuG3SRbVzQUipNaKgNQmn9GVUk5VO3Nart1JmnI,8630
9
9
  neva/arch/integrations/__init__.py,sha256=cXuqC4JfZdGYLlh6POgYUVXUT2NQ84276eC-RYTyFxk,75
10
10
  neva/arch/integrations/faststream.py,sha256=AOwfpfdL3O5HLAx80SwUGQb_hKEeAkFOUyIXtjODaHM,1696
11
11
  neva/config/__init__.py,sha256=Ygfnh-MRRKYDdf54_LeMSHwmck5VpNHTdnnMtGEFLtU,121
12
12
  neva/config/base_providers.py,sha256=vXv3kGWnrWQGBMo4WvEmYnryCKLkOdFFTjrDFDnhXFI,909
13
- neva/config/loader.py,sha256=WGXJDc7fjPc7QvnnBLdSkHkBgxND6x0cvwWpCNdpbPg,3950
13
+ neva/config/loader.py,sha256=E81a6nEboRNNMBEFU26UvEgvAjPIN3Aqn6mmVpa5cf4,4534
14
14
  neva/config/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- neva/config/repository.py,sha256=-6lG0LcAO30OoJ9vSS4U1xO2o8-EWhSib5_CVEiZkyk,4750
15
+ neva/config/repository.py,sha256=p8PjTfjV23ioy_iyb5b6oYPnYq1bVe6q6gVRSegJr7Q,5505
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
19
- neva/database/manager.py,sha256=VNvhVumZD2hoGKpkdXMAkT6KykE4H80WcG2DgShK5Hs,4240
20
- neva/database/provider.py,sha256=l2be4Ri53RtQq1-qzohy3cqzJQpTaNzqXGVhXPKUCXc,1674
18
+ neva/database/connection.py,sha256=iosVrpzZxG4WS3fNSdUwiO2-aTH1hR960PkiAefuBqI,6980
19
+ neva/database/manager.py,sha256=yPbDMcVlsn5_8ISPVhyajAM42i34r2gJgI9o-TVp59c,4617
20
+ neva/database/provider.py,sha256=XCi8-mfJ5KDVpVO10YlNX4jt0a9ESSeTyn8o3ohOe9c,1701
21
21
  neva/database/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
- neva/database/transaction.py,sha256=_0Dsao_vhCJT5YRUmrHOLb0uxvfdE-5k3n0yMPLC1o8,5167
22
+ neva/database/transaction.py,sha256=tUJ_WrAsw9ZSHTHYhlxSpSKYxAkKKbZky-GhDMXA3CY,5147
23
23
  neva/events/__init__.py,sha256=Gpi5q-pNlIi-q23U2ZOsNf8Y9rB6AQtsBbbBFU0tkFc,776
24
- neva/events/dispatcher.py,sha256=KR5uDZbnNwrMCI6s9S_SoNrNHx2z2lugoHk1-g3fzXA,4288
24
+ neva/events/dispatcher.py,sha256=O_sDpbQBRMFg34hoXEtIfWhYVX-_QUjYB0Nk4QXOGsM,4453
25
25
  neva/events/event.py,sha256=q-KyyxNkU3AN2YSaujuNPIoXQMXhGOwClTap5y8fU5I,282
26
- neva/events/event_registry.py,sha256=IZnpR3oLyvt5K2pn9KMCBw5W4JP7Ok_zMKI2SgQoXVM,1945
27
- neva/events/listener.py,sha256=2d5RHVKO0pU-eL_evLReTG0DIoX9tZyOcn8PWLeECZo,1435
26
+ neva/events/event_registry.py,sha256=lMimggnMrz3CCQqM0lNZX1xDPPwuU1Auu9c4_UaBluI,3523
27
+ neva/events/listener.py,sha256=d9Z0jGAnH8swpVcCbZ_C7v17dCJaGs5OzBoV2OttTtI,1313
28
28
  neva/events/policy.py,sha256=I67ziob0IEVeXdkilNPpj2f48gEz2EZneR1Fz8Nlt2U,154
29
- neva/events/provider.py,sha256=cVxADLv7VXCK6gTMJsDNRzspMOH2lrIkvMRhqVh2cK4,954
29
+ neva/events/provider.py,sha256=-6PmL3oU9Tc6hJj0Glvdl5lOyNTLsuD6YEcnQSkiyEA,981
30
30
  neva/events/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
31
- neva/events/contracts/__init__.py,sha256=t1VafYtzOZIF78V8X3K_Fg79Hw4FjSqasjLs0EQVIFA,489
31
+ neva/events/contracts/__init__.py,sha256=_-DpJpfszRHHMQR8dcyS8_cfX_NZwfBSMbl8cEnvFbc,554
32
32
  neva/events/contracts/dispatcher.py,sha256=pzCv_ZLS5ju2IKVMxL5f-fFipEqQZc6zPiIVtJhKJHc,1274
33
33
  neva/events/contracts/event.py,sha256=kUUECex_AlmaPJ-fXRg32eMkiqNS75CHldOr7bbpFqo,89
34
34
  neva/events/contracts/handler.py,sha256=SwLuuRtXhCzGkcBBLdH3MjUAkjEr0_rhqgE6dkWVJxI,327
@@ -39,26 +39,26 @@ neva/obs/instrumentation/__init__.py,sha256=SfIAvuCs-L0kEzOxUJ4aOIkhbydCe7qBwWtG
39
39
  neva/obs/instrumentation/sqlalchemy.py,sha256=kwk43NthFi4mPMhDJI55rxt9YPOY_mwOkr2E-cD64ZY,497
40
40
  neva/obs/logging/__init__.py,sha256=gIYBAzMK7-wPGEpA_kYKvJPT3hYN99OcCc9UuHFeiHk,251
41
41
  neva/obs/logging/manager.py,sha256=ktd3muBGU3e1KuBK6tSU5faRJRliPwmknuqJ2EYeFBk,2422
42
- neva/obs/logging/provider.py,sha256=M-oH-5oytsdUDICvCnb6trDcU0mw-n8t2iWfiWmo_k0,642
42
+ neva/obs/logging/provider.py,sha256=-AIZb60PmTpvO9PtsJwZ5FJeTC_Yn6ik8DdJCHULpV0,651
43
43
  neva/obs/middleware/__init__.py,sha256=WbZ-Pr_fVl6T8LfQRyC_YXMlN7WaQKEwX8KUvGiLOBc,182
44
44
  neva/obs/middleware/correlation.py,sha256=x_3HtlBV1Qe8JRgGpEwSTXvKkeCgWal15b0hrFjNAJs,2034
45
45
  neva/obs/middleware/profiler.py,sha256=ovx3s-39WpNtu2KhzVMWyIJa6OLrSVDuL56xyDA-vQ4,1942
46
- neva/polyfactory/__init__.py,sha256=jA4sQHmFn0NLIAiC1zYrORyp9M9hqb-QFCGBPp0YXXo,81
46
+ neva/polyfactory/__init__.py,sha256=t7uAXaleAyshSyBdGXNzvgY0nNpb5AqFU4zjE8lyKyk,153
47
47
  neva/polyfactory/factories.py,sha256=8VoZJlY0-_ZOM8rmfUcvEbt-KOZsaDj8f6R5EQF62u8,600
48
48
  neva/polyfactory/persistence.py,sha256=1qBg8L_xkiRFq6J2XoZFez43oxNWDaUM8SrlNAeOkQA,605
49
49
  neva/polyfactory/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
50
50
  neva/security/__init__.py,sha256=Dhh3ZsseP6biWgRQY4hYKkoYFCR7ehhxz4VBqN8e02k,440
51
- neva/security/provider.py,sha256=5b4F6JdzoshVWr1HcdX76yPgyUES_Rf08QxkAfl9vyw,515
51
+ neva/security/provider.py,sha256=6GqSzAsgHtOEnkQFmd0Dusgbv65QhJRxOEBfmObspmc,533
52
52
  neva/security/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
53
53
  neva/security/encryption/__init__.py,sha256=eVWW-qk03vy7Bn-sNQ2WJHWZI6tPJOkknJz8llc1m0M,288
54
54
  neva/security/encryption/encrypter.py,sha256=YiJrLmv2C_CkqU3eZDvBHnkwA5zzjb6T3-X2kThywZs,5152
55
55
  neva/security/encryption/protocol.py,sha256=pZtkTCz0k6eUX0fivJrvb7yDXy2eTAg--ZbCXhfUXWc,847
56
56
  neva/security/hashing/__init__.py,sha256=k3SviEvtJLjBXTkR5I6w9YMPw6Q-LIoE1wGNz3GYvOE,374
57
57
  neva/security/hashing/config.py,sha256=ltj7_GW44gSg2N0i8CEYaw6Y6xUfzwsr4zXk5ic_sJI,624
58
- neva/security/hashing/hash_manager.py,sha256=mvZTYiPbv_s10aCxii3EyjSRylaRju7AX-F7PX8Iozo,4444
58
+ neva/security/hashing/hash_manager.py,sha256=F55T1xcQpIEV3A52L4KeAbkUeS7ogCgm8lI79RUJP_M,6123
59
59
  neva/security/hashing/hashers/__init__.py,sha256=Zn_Q2tj_8lhhwJRZ7RXeJLYujd-3j7Rr8JQO2sSGgJs,44
60
60
  neva/security/hashing/hashers/argon2.py,sha256=pJqTl3_oCJCjJarnQwNSFRznlaijjbjdDVpC0DXDoSg,1402
61
- neva/security/hashing/hashers/bcrypt.py,sha256=H_15r-z9DYkd4HEbTz0QA4NHVBiFDXEqHkngP3HMSBY,1083
61
+ neva/security/hashing/hashers/bcrypt.py,sha256=-VsCtbQvEFDoKnwQxUwYFGSI_1qyWfP3R5TAhNvZW0Q,1083
62
62
  neva/security/hashing/hashers/protocol.py,sha256=aVGMO2q3H-u8uyGPt6mIy1Dq7pW0k4WjtHi0nnKsgCQ,611
63
63
  neva/security/tokens/__init__.py,sha256=H1SWLWa4btJqc0wBUFTdKUYmBgXYZYnCitG1BsbLtqI,235
64
64
  neva/security/tokens/generate_token.py,sha256=xqlC80IfCvkwmQR_TAHmEntiJdEV2yEPHX6qBnv5cBg,334
@@ -75,7 +75,7 @@ neva/support/facade/__init__.py,sha256=aysM4AyDAxAgv8uZCka49t2vLHeVraTNAHeCIpXLd
75
75
  neva/support/facade/app.py,sha256=xdL9qpeQnJtAMomxfrImM82qM2YV2eQFsckmUNE2w7I,297
76
76
  neva/support/facade/app.pyi,sha256=gTAKp_r8DUqOj-oggAyAuK1WvR2AzX63-G2U5NixHbM,1304
77
77
  neva/support/facade/config.py,sha256=cqmtwAekHE4qWH2LqzmY3BAbyAadvZYfPgLHyTcCnJE,358
78
- neva/support/facade/config.pyi,sha256=LpI6xwqURRlWPcohXBxTL-iWTHq3MMMNdu670Q_5jnk,2549
78
+ neva/support/facade/config.pyi,sha256=HwXd4JG43-Ep8XckrYUkltQZyb73pOzzxFJRsV-iNJk,2693
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
@@ -83,7 +83,7 @@ neva/support/facade/db.pyi,sha256=UQFMgBuw0BGquMnuUNULC8Pdo7lDCVTCNkDaUmIZJhw,19
83
83
  neva/support/facade/event.py,sha256=BkJoof1hYTxhMBOxLrq21NIgEPIiLBlj18Pttl0FHa8,1236
84
84
  neva/support/facade/event.pyi,sha256=7R6osyTZbHt_zkCfQ-uZnTLfyZTeN1bi3exsgl3-36k,817
85
85
  neva/support/facade/hash.py,sha256=tGhsvfGovt-mcRXSjDKf4jt1n7ib0eDPXFvRUfVxLUc,292
86
- neva/support/facade/hash.pyi,sha256=jTs3E5ZF5cp76jrgH70DEog8gzfYIcIV2PrO9xskHyI,1656
86
+ neva/support/facade/hash.pyi,sha256=Uy0CkV67MImPjHc6BmmLQPd7qF1QNDtO1oDFyyRr-1A,2950
87
87
  neva/support/facade/log.py,sha256=_uLoHB9tkpHkiqEJXazbaqfiKUOtv3NS71ZCZAprzOI,347
88
88
  neva/support/facade/log.pyi,sha256=Xi7gIzw9qRqmD4D3PcW06_HUtgLWEOdsHqs1B0jh1-k,1647
89
89
  neva/testing/__init__.py,sha256=vt5zxG1N84vn_YwP44jhYPWYnyb02-cha3m-1l-qoEw,207
@@ -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.5.0.dist-info/METADATA,sha256=rcHS4PoUAyXqpkmfej_UhyUaBgnb5dV-x8ZbwMWbXFQ,846
95
- python_neva-3.5.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
96
- python_neva-3.5.0.dist-info/RECORD,,
94
+ python_neva-3.6.0.dist-info/METADATA,sha256=0T-O4BAsX8clep5cWzsahGMrn6M7REDxU65agpbKSik,5029
95
+ python_neva-3.6.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
96
+ python_neva-3.6.0.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.5.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