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 +88 -22
- neva/arch/config.py +2 -2
- neva/arch/scopes.py +0 -1
- neva/arch/service_provider.py +82 -7
- neva/config/loader.py +14 -2
- neva/config/repository.py +24 -5
- neva/database/connection.py +63 -46
- neva/database/manager.py +11 -0
- neva/database/provider.py +8 -6
- neva/database/transaction.py +1 -2
- neva/events/contracts/__init__.py +2 -0
- neva/events/dispatcher.py +12 -9
- neva/events/event_registry.py +40 -3
- neva/events/listener.py +2 -3
- neva/events/provider.py +3 -3
- neva/obs/logging/provider.py +1 -1
- neva/polyfactory/__init__.py +2 -0
- neva/security/hashing/hash_manager.py +49 -2
- neva/security/hashing/hashers/bcrypt.py +1 -1
- neva/security/provider.py +2 -2
- neva/support/facade/config.pyi +6 -3
- neva/support/facade/hash.pyi +45 -1
- python_neva-3.6.0.dist-info/METADATA +106 -0
- {python_neva-3.5.0.dist-info → python_neva-3.6.0.dist-info}/RECORD +25 -25
- {python_neva-3.5.0.dist-info → python_neva-3.6.0.dist-info}/WHEEL +1 -1
- python_neva-3.5.0.dist-info/METADATA +0 -27
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(
|
|
83
|
+
self.build_container()
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
neva/arch/service_provider.py
CHANGED
|
@@ -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 =
|
|
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
|
|
202
|
+
"""Binds the source at REQUEST scope.
|
|
128
203
|
|
|
129
|
-
|
|
130
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
134
|
-
|
|
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]
|
|
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
|
neva/database/connection.py
CHANGED
|
@@ -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
|
-
"""
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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.
|
|
21
|
-
self.
|
|
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
|
|
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
|
-
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
yield
|
|
42
|
+
finally:
|
|
43
|
+
await db.close()
|
neva/database/transaction.py
CHANGED
|
@@ -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
|
|
26
|
+
class Transaction:
|
|
28
27
|
"""Represents a database transaction with callback support."""
|
|
29
28
|
|
|
30
29
|
conn_name: str
|
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
|
-
|
|
68
|
+
registries = self._registry.resolve_listeners(event)
|
|
64
69
|
|
|
65
|
-
immediate
|
|
66
|
-
deferred
|
|
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.
|
|
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)
|
neva/events/event_registry.py
CHANGED
|
@@ -46,14 +46,51 @@ class EventRegistry:
|
|
|
46
46
|
)
|
|
47
47
|
|
|
48
48
|
def resolve_listeners(self, event: T) -> list[EventListenerRegistry[T]]:
|
|
49
|
-
"""Return all
|
|
50
|
-
|
|
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:
|
|
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
|
-
)
|
|
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.
|
|
28
|
-
self.
|
|
29
|
-
self.
|
|
27
|
+
_ = self.singleton(EventRegistry)
|
|
28
|
+
_ = self.singleton(EventDispatcher)
|
|
29
|
+
_ = self.singleton(EventDispatcher, interface=contracts.EventDispatcher)
|
|
30
30
|
return Ok(self)
|
neva/obs/logging/provider.py
CHANGED
neva/polyfactory/__init__.py
CHANGED
|
@@ -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
|
-
).
|
|
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
|
-
).
|
|
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"] = "
|
|
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.
|
|
17
|
-
self.
|
|
16
|
+
_ = self.singleton(HashManager, interface=HashManager)
|
|
17
|
+
_ = self.singleton(AesEncrypter, interface=Encrypter)
|
|
18
18
|
return Ok(self)
|
neva/support/facade/config.pyi
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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
|
...
|
neva/support/facade/hash.pyi
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Type
|
|
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=
|
|
3
|
-
neva/arch/config.py,sha256=
|
|
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=
|
|
8
|
-
neva/arch/service_provider.py,sha256=
|
|
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=
|
|
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
|
|
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=
|
|
19
|
-
neva/database/manager.py,sha256=
|
|
20
|
-
neva/database/provider.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
27
|
-
neva/events/listener.py,sha256=
|
|
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
|
|
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=
|
|
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
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
|
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=
|
|
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=
|
|
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.
|
|
95
|
-
python_neva-3.
|
|
96
|
-
python_neva-3.
|
|
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,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
|