python-neva 4.1.0__py3-none-any.whl → 5.1.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/database/connection.py +3 -6
- neva/database/manager.py +0 -2
- neva/guidelines/fragments/facades.md +95 -0
- neva/guidelines/fragments/factories.md +79 -0
- neva/guidelines/fragments/observability.md +130 -0
- neva/guidelines/fragments/security.md +142 -0
- neva/guidelines/fragments/service-providers.md +61 -2
- neva/obs/__init__.py +12 -2
- neva/obs/config.py +37 -0
- neva/obs/logging/__init__.py +14 -2
- neva/obs/logging/channels.py +310 -0
- neva/obs/logging/contracts.py +40 -0
- neva/obs/logging/manager.py +135 -29
- neva/obs/logging/provider.py +18 -8
- neva/obs/logging/resolver.py +138 -0
- neva/security/__init__.py +18 -2
- neva/security/hashing/__init__.py +4 -0
- neva/support/facade/log.pyi +46 -1
- {python_neva-4.1.0.dist-info → python_neva-5.1.0.dist-info}/METADATA +37 -6
- {python_neva-4.1.0.dist-info → python_neva-5.1.0.dist-info}/RECORD +22 -16
- neva/obs/instrumentation/__init__.py +0 -1
- neva/obs/instrumentation/sqlalchemy.py +0 -15
- {python_neva-4.1.0.dist-info → python_neva-5.1.0.dist-info}/WHEEL +0 -0
- {python_neva-4.1.0.dist-info → python_neva-5.1.0.dist-info}/entry_points.txt +0 -0
neva/database/connection.py
CHANGED
|
@@ -7,7 +7,6 @@ from dataclasses import dataclass, field
|
|
|
7
7
|
from typing import final
|
|
8
8
|
|
|
9
9
|
from sqlalchemy.ext import asyncio
|
|
10
|
-
from structlog.stdlib import get_logger
|
|
11
10
|
|
|
12
11
|
from neva.database.transaction import BoundTransaction, TransactionState
|
|
13
12
|
from neva.obs import LogManager
|
|
@@ -87,7 +86,7 @@ class ConnectionManager:
|
|
|
87
86
|
self,
|
|
88
87
|
name: str,
|
|
89
88
|
tx_context: TransactionContext,
|
|
90
|
-
logger: LogManager
|
|
89
|
+
logger: LogManager,
|
|
91
90
|
engine: asyncio.AsyncEngine,
|
|
92
91
|
) -> None:
|
|
93
92
|
self.name = name
|
|
@@ -149,12 +148,10 @@ class ConnectionManager:
|
|
|
149
148
|
)
|
|
150
149
|
label = "commit" if committed else "rollback"
|
|
151
150
|
# A post-commit callback cannot un-commit the transaction, so its
|
|
152
|
-
# failure is reported rather than raised.
|
|
153
|
-
# back to a module logger when the manager has none.
|
|
154
|
-
logger = self.logger if self.logger is not None else get_logger(__name__)
|
|
151
|
+
# failure is reported rather than raised.
|
|
155
152
|
for result in results:
|
|
156
153
|
if result.is_err:
|
|
157
|
-
logger.error(
|
|
154
|
+
self.logger.error(
|
|
158
155
|
f"{label} callback failed",
|
|
159
156
|
error=result.err().unwrap(),
|
|
160
157
|
connection=self.name,
|
neva/database/manager.py
CHANGED
|
@@ -10,7 +10,6 @@ from neva.database.config import ConnectionConfig
|
|
|
10
10
|
from neva.database.connection import ConnectionManager, TransactionContext
|
|
11
11
|
from neva.database.transaction import BoundTransaction
|
|
12
12
|
from neva.obs import LogManager
|
|
13
|
-
from neva.obs.instrumentation.sqlalchemy import instrument
|
|
14
13
|
from neva.support import Nothing, Option, Some, from_optional
|
|
15
14
|
|
|
16
15
|
|
|
@@ -36,7 +35,6 @@ class DatabaseManager:
|
|
|
36
35
|
name: The connection name.
|
|
37
36
|
engine: The async engine.
|
|
38
37
|
"""
|
|
39
|
-
instrument(engine)
|
|
40
38
|
self._connections[name] = ConnectionManager(
|
|
41
39
|
name,
|
|
42
40
|
self._tx_context,
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: facades
|
|
3
|
+
title: Facades
|
|
4
|
+
requires: python-neva>=5.0
|
|
5
|
+
triggers: [Log facade, DB facade, static access to a service, adding a facade, facade root, why does my facade raise AttributeError, pyi stub]
|
|
6
|
+
priority: 25
|
|
7
|
+
verified_by: [tests/arch/test_facade_resolution.py, tests/arch/test_facade_root_nesting.py, tests/testing/test_facade_restore.py]
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Facades
|
|
11
|
+
|
|
12
|
+
A static front for a container-bound service. `Log.info(...)` resolves `LogManager` from the
|
|
13
|
+
application and calls `info` on it.
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from neva.support.facade import App, Config, Crypt, DB, Event, Hash, Log
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Each names one service through `get_facade_accessor()`. Nothing else is special: a facade holds
|
|
20
|
+
no state and caches no instance.
|
|
21
|
+
|
|
22
|
+
## When to use one
|
|
23
|
+
|
|
24
|
+
Use a facade for genuinely cross-cutting access — logging, config, the ambient transaction.
|
|
25
|
+
**Inject the service anywhere it is part of a unit's contract.** A class taking
|
|
26
|
+
`DatabaseManager` in its constructor says what it needs and can be unit-tested by passing a
|
|
27
|
+
double; the same class reaching for `DB` hides the dependency and can only be tested by
|
|
28
|
+
installing a global. Actions, repositories and services take constructor parameters.
|
|
29
|
+
|
|
30
|
+
## Resolution is per access
|
|
31
|
+
|
|
32
|
+
`FacadeMeta.__getattr__` resolves the root on **every** attribute access. A swap installed
|
|
33
|
+
mid-test therefore takes effect immediately, and a facade never pins a stale instance across a
|
|
34
|
+
rebuild.
|
|
35
|
+
|
|
36
|
+
The cost is that failures arrive as `AttributeError`, not as a `Result`:
|
|
37
|
+
|
|
38
|
+
| Situation | What you get |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| No application set | `AttributeError: A facade root (App instance) has not been set for <Facade>. Call Facade.set_facade_application(app) first.` |
|
|
41
|
+
| Service not bound | `AttributeError` carrying the container's resolution error. |
|
|
42
|
+
| Attribute the service lacks | `AttributeError: 'LogManager' object has no attribute '...'` |
|
|
43
|
+
|
|
44
|
+
An `AttributeError` from a facade almost always means a missing provider, not a typo. Check the
|
|
45
|
+
`providers` config namespace first.
|
|
46
|
+
|
|
47
|
+
**A swap bypasses the container but not the root check.** `DB.swap(manager)` outside a booted
|
|
48
|
+
application still raises "a facade root has not been set" — the double is only consulted once
|
|
49
|
+
an application is present. A test installing a double therefore needs an application anyway,
|
|
50
|
+
which is what `TestCase` and the `application` fixture provide.
|
|
51
|
+
|
|
52
|
+
## The facade root
|
|
53
|
+
|
|
54
|
+
`Application.lifespan()` sets the root on entry and restores it on exit — you never call
|
|
55
|
+
`set_facade_application` yourself outside a bespoke harness.
|
|
56
|
+
|
|
57
|
+
The root is a single process-global slot, and teardown **hands it back rather than clearing
|
|
58
|
+
it**: an application booted inside a longer-lived one restores the outer application on exit,
|
|
59
|
+
and only the outermost teardown leaves it unset. Without that, a short-lived inner application
|
|
60
|
+
would disarm every facade for whatever the outer one still had to do — exactly what a test
|
|
61
|
+
harness does when its application outlives a single test.
|
|
62
|
+
|
|
63
|
+
## Type checking needs a stub
|
|
64
|
+
|
|
65
|
+
Forwarding through `__getattr__` is invisible to a type checker, so every facade ships a
|
|
66
|
+
sibling `.pyi` declaring its methods as classmethods (`neva/support/facade/log.pyi`). Two
|
|
67
|
+
consequences:
|
|
68
|
+
|
|
69
|
+
- **Adding a method to a service does not add it to the facade's public API.** Update the stub
|
|
70
|
+
in the same change, or callers get an error on a call that works at runtime.
|
|
71
|
+
- A stub can drift the other way too — the events fragment documents one such gap, where the
|
|
72
|
+
dispatcher accepts a list its contract does not declare.
|
|
73
|
+
|
|
74
|
+
## Adding a facade
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
class Cache(Facade):
|
|
78
|
+
@classmethod
|
|
79
|
+
@override
|
|
80
|
+
def get_facade_accessor(cls) -> type:
|
|
81
|
+
return CacheManager
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Import the service inside the method when the module would otherwise import at package-import
|
|
85
|
+
time — the shipped facades do this to keep `neva.support.facade` cheap. Bind the service in a
|
|
86
|
+
provider, write the `.pyi`, and export it from your facade package.
|
|
87
|
+
|
|
88
|
+
## Testing
|
|
89
|
+
|
|
90
|
+
Doubles are installed on the concrete facade, never on `Facade` itself, and live in one global
|
|
91
|
+
registry keyed by facade class. See the testing fragment for the full table — `fake()`,
|
|
92
|
+
`swap()`, `spy()`, `faking()`, `restore()`, `restore_all()`.
|
|
93
|
+
|
|
94
|
+
`TestCase` calls `Facade.restore_all()` after every test. Outside it, prefer `faking()` so a
|
|
95
|
+
failing assertion cannot leak a double into the next test.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: factories
|
|
3
|
+
title: Model factories
|
|
4
|
+
requires: python-neva>=5.0
|
|
5
|
+
triggers: [building test data, ModelFactory, polyfactory, seeding a model, create_async, factory for a SQLAlchemy model]
|
|
6
|
+
priority: 65
|
|
7
|
+
verified_by: [tests/polyfactory/test_model_factory.py]
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Model factories
|
|
11
|
+
|
|
12
|
+
`ModelFactory` builds SQLAlchemy models for tests and seeds, persisting them through the
|
|
13
|
+
**caller's** transaction. It is a thin base over Polyfactory's `SQLAlchemyFactory`.
|
|
14
|
+
|
|
15
|
+
Ships behind an extra — `python-neva[polyfactory]`. Importing `neva.polyfactory` without it
|
|
16
|
+
fails, so it belongs in a dev dependency group, not in application code paths.
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from neva.polyfactory import ModelFactory
|
|
20
|
+
|
|
21
|
+
class ActorFactory(ModelFactory[Actor]):
|
|
22
|
+
__model__ = Actor
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Persisting
|
|
26
|
+
|
|
27
|
+
`create_async()` and `create_batch_async(n)` write through the ambient session — they read it
|
|
28
|
+
from the `DB` facade rather than opening a transaction of their own, and they **flush without
|
|
29
|
+
committing**. The enclosing block still owns settling, so a factory call inside a rolled-back
|
|
30
|
+
test leaves nothing behind.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
async with DB.begin() as tx:
|
|
34
|
+
actor = await ActorFactory.create_async()
|
|
35
|
+
actors = await ActorFactory.create_batch_async(3)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**There must be an open transaction.** Outside one, `create_async` raises `UnwrapError` from
|
|
39
|
+
the unwrapped `DB.session()` rather than quietly opening its own — the same rule the database
|
|
40
|
+
fragment states for actions. Under `RefreshDatabase` the wrapper transaction already satisfies
|
|
41
|
+
this.
|
|
42
|
+
|
|
43
|
+
`build()` and `batch()` are inherited unchanged and touch no database. Use them when the model
|
|
44
|
+
never needs to be persisted.
|
|
45
|
+
|
|
46
|
+
## Relationships are not populated — but foreign keys still are
|
|
47
|
+
|
|
48
|
+
`__set_relationships__` is `False` on the base, so a factory never sets a relationship
|
|
49
|
+
attribute and never invents the related row.
|
|
50
|
+
|
|
51
|
+
**It does still fill the foreign-key column**, because that is an ordinary column: a required
|
|
52
|
+
`author_id` comes back a random integer pointing at no row. SQLite does not check the
|
|
53
|
+
constraint by default and lets it through; Postgres rejects it. This is the failure to expect
|
|
54
|
+
when a factory that passed locally breaks against a real database.
|
|
55
|
+
|
|
56
|
+
Always supply the parent — the relationship or the id — rather than letting the factory invent
|
|
57
|
+
one:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
author = await AuthorFactory.create_async()
|
|
61
|
+
book = await BookFactory.create_async(author=author) # or author_id=author.id
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
For a nullable key with no parent in the scenario, say so explicitly:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
book = await BookFactory.create_async(author_id=None)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Set `__set_relationships__ = True` on one factory where a fixture genuinely wants the whole
|
|
71
|
+
graph built for it.
|
|
72
|
+
|
|
73
|
+
## Rules
|
|
74
|
+
|
|
75
|
+
- One factory per model, named `<Model>Factory`, beside the component's other test support.
|
|
76
|
+
- Never call `session.commit()` from a factory or a seeder — the caller's block settles.
|
|
77
|
+
- Do not give a factory its own `DB.begin()`; it inherits the caller's unit of work.
|
|
78
|
+
- Override a field with a keyword argument rather than editing the instance afterwards, so the
|
|
79
|
+
value is present when the row is flushed.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: observability
|
|
3
|
+
title: Observability
|
|
4
|
+
requires: python-neva>=5.0
|
|
5
|
+
triggers: [logging, Log facade, log channel, structured logging, log level, JSON logs, audit log, obs config, tracing, OpenTelemetry, metrics, correlation id]
|
|
6
|
+
priority: 55
|
|
7
|
+
verified_by: [tests/obs/test_channels.py, tests/obs/test_resolver.py, tests/obs/test_manager.py, tests/obs/test_facade.py, tests/obs/test_provider_hook.py, tests/support/test_strategy.py]
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Observability
|
|
11
|
+
|
|
12
|
+
The core ships **logging only**. Tracing, metrics, exporters and instrumentors live in the
|
|
13
|
+
`neva-otel` plugin (`python-neva[otel]`), so installing the core commits an application to no
|
|
14
|
+
observability backend. There is no `Trace` facade, no tracer, and no OpenTelemetry dependency
|
|
15
|
+
here — do not add one. Asked for tracing, install the plugin; never reach for
|
|
16
|
+
`opentelemetry-*` inside this package.
|
|
17
|
+
|
|
18
|
+
## Channels
|
|
19
|
+
|
|
20
|
+
Logging is a set of named channels, each with its own driver, level and output. One
|
|
21
|
+
`config/obs.py` namespace configures them.
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
config = {
|
|
25
|
+
"logging": {
|
|
26
|
+
"default": "stdout",
|
|
27
|
+
"channels": {
|
|
28
|
+
"stdout": {"driver": "console", "level": "DEBUG"},
|
|
29
|
+
"json": {"driver": "json", "stream": "stderr", "level": "INFO"},
|
|
30
|
+
"audit": {"driver": "file", "path": "var/audit.log"},
|
|
31
|
+
"both": {"driver": "stack", "channels": ["json", "audit"]},
|
|
32
|
+
"quiet": {"driver": "null"},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
| Driver | Writes | Driver-specific keys |
|
|
39
|
+
| --- | --- | --- |
|
|
40
|
+
| `console` | human-readable, to a stream | `stream` |
|
|
41
|
+
| `json` | one JSON object per line, to a stream | `stream` |
|
|
42
|
+
| `file` | JSON lines appended to a path | `path` (required) |
|
|
43
|
+
| `stack` | fans one record out to other channels | `channels` (required) |
|
|
44
|
+
| `null` | nothing | — |
|
|
45
|
+
|
|
46
|
+
`level` is one of `DEBUG`/`INFO`/`WARNING`/`ERROR`/`CRITICAL`, default `DEBUG`. `stream` is
|
|
47
|
+
`stdout` (default) or `stderr`. A `file` channel creates parent directories and holds its
|
|
48
|
+
handle open for the process's life.
|
|
49
|
+
|
|
50
|
+
**With no `config/obs.py` at all**, logging still works: a `console` channel named `stdout` on
|
|
51
|
+
stdout at `DEBUG`.
|
|
52
|
+
|
|
53
|
+
## Writing
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from neva.support.facade import Log
|
|
57
|
+
|
|
58
|
+
Log.info("order placed", order_id=42) # default channel
|
|
59
|
+
Log.channel("audit").warning("role granted", actor="root")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Level methods are `debug`, `info`, `warning`, `error`, `critical` and `exception`, and go to the
|
|
63
|
+
default channel. **Use `Log.exception(...)` inside an `except` block** — it attaches the
|
|
64
|
+
traceback, which `Log.error(str(e))` throws away. Everything after the message is structured
|
|
65
|
+
context, not format arguments — never build the message with an f-string when the value belongs
|
|
66
|
+
in a field.
|
|
67
|
+
|
|
68
|
+
`Log.channel(name)` **raises `UnwrapError`** on a name no channel is configured for, carrying
|
|
69
|
+
the reason. A typo is a configuration mistake, and serving the default instead would hide it
|
|
70
|
+
behind working output. Where the failure has to be a value, go through the resolver:
|
|
71
|
+
`Log.channels` returns `Option[ChannelResolver]` — `Nothing` for a manager built with no
|
|
72
|
+
application — and the resolver's `use(name)` returns `Result[Channel, str]`.
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
channel = Log.channels.and_then(lambda r: r.use("audit").ok())
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Three failures are told apart by their message: no channel of that name, an unknown `driver`
|
|
79
|
+
(the error lists the drivers that are registered), and a `stack` channel that reaches itself,
|
|
80
|
+
which reports being "defined in terms of itself" rather than recursing.
|
|
81
|
+
|
|
82
|
+
## Ambient context
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
Log.bind(tenant="acme") # every subsequent record in this context, every channel
|
|
86
|
+
Log.unbind("tenant")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Backed by structlog's contextvars, so a binding follows the async task rather than the
|
|
90
|
+
channel. This is how a request's correlation ID reaches log lines without being passed
|
|
91
|
+
around — `neva-asgi`'s middleware binds it the same way.
|
|
92
|
+
|
|
93
|
+
## Enriching records from a plugin
|
|
94
|
+
|
|
95
|
+
`LogManager.processor(fn)` appends a structlog processor to **every** channel's chain. This is
|
|
96
|
+
the seam a plugin adds derived fields through — `neva-otel` injects the current span's ids
|
|
97
|
+
this way.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
class MyProvider(ServiceProvider):
|
|
101
|
+
@asynccontextmanager
|
|
102
|
+
async def lifespan(self) -> AsyncIterator[None]:
|
|
103
|
+
manager = (await self.app.make_async(LogManager)).unwrap()
|
|
104
|
+
_ = manager.processor(add_my_field)
|
|
105
|
+
yield
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**Call it from `lifespan()`, never from `register()`.** Resolving anything during
|
|
109
|
+
registration hands back an instance the booted application does not use, so a processor
|
|
110
|
+
registered there silently never runs. See the service-providers fragment.
|
|
111
|
+
|
|
112
|
+
Records emitted by base providers' own startup are not enriched — their lifespans run before
|
|
113
|
+
a plugin's. Everything after is. Already-built channels are discarded on registration, so the
|
|
114
|
+
next resolution picks the processor up.
|
|
115
|
+
|
|
116
|
+
Custom drivers register the same way, through `resolver.driver(name, builder)` — the
|
|
117
|
+
resolver is a `StrategyResolver`, described in the service-providers fragment.
|
|
118
|
+
|
|
119
|
+
## Rules
|
|
120
|
+
|
|
121
|
+
- **Never call `structlog.configure()`.** It is process-global; channels carry their own
|
|
122
|
+
chains precisely so one application's renderer cannot leak into another in the same
|
|
123
|
+
interpreter. Configuring it globally breaks that isolation and every channel's level.
|
|
124
|
+
- **`merge_contextvars` must lead every chain.** A custom driver that omits it silently drops
|
|
125
|
+
the correlation ID from every line it renders.
|
|
126
|
+
- **A broken default channel fails at boot**, not at the first log call — the framework logs
|
|
127
|
+
during provider startup, so a `file` channel with no `path` surfaces there. The error names
|
|
128
|
+
the channel and the reason.
|
|
129
|
+
- `LogManager()` with no application is legitimate and serves one console channel; it is what
|
|
130
|
+
a test needing somewhere for records to go should use.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: security
|
|
3
|
+
title: Hashing, tokens and encryption
|
|
4
|
+
requires: python-neva>=5.0
|
|
5
|
+
triggers: [hashing a password, checking a password, generating an API key, storing a token, encrypting a value, key rotation, app.key, Hash facade, Crypt facade]
|
|
6
|
+
priority: 45
|
|
7
|
+
verified_by: [tests/security/test_hash_manager.py, tests/security/test_encrypter.py, tests/security/test_tokens.py, tests/security/test_config_shapes.py, tests/support/test_strategy.py]
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Hashing, tokens and encryption
|
|
11
|
+
|
|
12
|
+
Three separate concerns with three separate tools. Reaching for the wrong one is the mistake
|
|
13
|
+
this fragment exists to prevent — they are not interchangeable, and two of the three pairings
|
|
14
|
+
are outright vulnerabilities.
|
|
15
|
+
|
|
16
|
+
| Secret | Tool | Why |
|
|
17
|
+
| ------ | ---- | --- |
|
|
18
|
+
| A user's password | `Hash` (argon2/bcrypt) | Human-chosen, low entropy. Needs a deliberately slow, salted KDF. |
|
|
19
|
+
| A token you generated | `generate_token` + `hash_token` | Already high-entropy. A fast digest is correct and a KDF is pure cost. |
|
|
20
|
+
| Data you must read back | `Crypt` (AES-256-GCM) | Reversible. Never a hash. |
|
|
21
|
+
|
|
22
|
+
Never hash a password with `hash_token` — SHA-256 is unsalted and fast, so a stolen table is
|
|
23
|
+
brute-forceable. Never encrypt a password with `Crypt` — a password must not be recoverable.
|
|
24
|
+
|
|
25
|
+
Everything here comes from `SecurityProvider`, which binds `HashManager` and `Encrypter`. It is
|
|
26
|
+
**not** a base provider: list it in the `providers` config namespace or `Hash` and `Crypt`
|
|
27
|
+
resolve to nothing.
|
|
28
|
+
|
|
29
|
+
## Hashing passwords
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from neva.support.facade import Hash
|
|
33
|
+
|
|
34
|
+
hashed = await Hash.make_async(password)
|
|
35
|
+
if await Hash.check_async(password, user.password):
|
|
36
|
+
...
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**In async code use `make_async` / `check_async`.** Hashing is deliberately CPU- and
|
|
40
|
+
memory-expensive — argon2 defaults to 100 MiB and tens of milliseconds — so the sync `make` and
|
|
41
|
+
`check` stall the whole event loop, blocking every other request for the duration. The async
|
|
42
|
+
pair hands the work to a thread (`asyncio.to_thread`); they are otherwise identical.
|
|
43
|
+
|
|
44
|
+
One `hashing` namespace, drivers `argon2` and `bcrypt`. `driver` is the only required key; both
|
|
45
|
+
tuning namespaces are optional and every field has a default.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from neva.security import HashingConfig
|
|
49
|
+
|
|
50
|
+
config: HashingConfig = {"driver": "argon2", "argon": {"time_cost": 4}}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
| Key | Default |
|
|
54
|
+
| --- | ------- |
|
|
55
|
+
| `argon.time_cost` | 2 |
|
|
56
|
+
| `argon.memory_cost` | 102400 (KiB) |
|
|
57
|
+
| `argon.parallelism` | 8 |
|
|
58
|
+
| `argon.hash_len` | 16 |
|
|
59
|
+
| `argon.salt_len` | 16 |
|
|
60
|
+
| `bcrypt.rounds` | 12 |
|
|
61
|
+
| `bcrypt.prefix` | `"2b"` |
|
|
62
|
+
|
|
63
|
+
**A missing `hashing` namespace does not fail boot.** `HashManager` resolves fine and the
|
|
64
|
+
failure surfaces at the first call, as `UnwrapError: Failed to resolve hasher 'default'` — the
|
|
65
|
+
same shape as an unknown driver name. If you see it, check the namespace before anything else.
|
|
66
|
+
Passing `hasher="bcrypt"` explicitly bypasses the default and works regardless.
|
|
67
|
+
|
|
68
|
+
`Hash.needs_rehash(hashed)` reports whether a stored hash predates the current tuning. Check it
|
|
69
|
+
on successful login and re-hash there — it is the only moment the plaintext is available.
|
|
70
|
+
|
|
71
|
+
## Tokens
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from neva.security import generate_token, hash_token, verify_token
|
|
75
|
+
|
|
76
|
+
token = generate_token() # give this to the caller, once
|
|
77
|
+
user.api_key = hash_token(token) # store only this
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`generate_token(nbytes=32)` draws from `secrets.token_urlsafe`. **`nbytes` is entropy, not
|
|
81
|
+
output length** — base64url makes the string roughly `4 * nbytes / 3` characters, so the default
|
|
82
|
+
yields 43. Do not shrink it to control the column width.
|
|
83
|
+
|
|
84
|
+
`hash_token` is a bare hex SHA-256, and `verify_token` compares with `hmac.compare_digest` so
|
|
85
|
+
the check is constant-time. Both accept `str` or `bytes` and agree on either. Store the hash,
|
|
86
|
+
never the token; a stolen database then yields nothing usable.
|
|
87
|
+
|
|
88
|
+
## Encryption
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from neva.support.facade import Crypt
|
|
92
|
+
|
|
93
|
+
match Crypt.encrypt({"iban": iban}):
|
|
94
|
+
case Ok(payload): ...
|
|
95
|
+
case Err(reason): ...
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
AES-256-GCM. `encrypt(value) -> Result[str, str]` takes any JSON-serialisable value — strings
|
|
99
|
+
round-trip as strings, everything else through JSON — and returns a base64 payload carrying its
|
|
100
|
+
own random IV, so the same input never produces the same ciphertext. `decrypt(payload) ->
|
|
101
|
+
Result[JsonValue, str]`.
|
|
102
|
+
|
|
103
|
+
**Both return `Result`; neither raises.** `DecryptionError` is exported but never raised
|
|
104
|
+
anywhere in the core — an `except DecryptionError` block is dead code. Handle the `Err`.
|
|
105
|
+
|
|
106
|
+
### Keys and rotation
|
|
107
|
+
|
|
108
|
+
`app.key` is a base64-encoded 32 bytes; `AesEncrypter.generate_key()` mints one. `app.previous_keys`
|
|
109
|
+
is a list of superseded keys.
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
config: AppConfig = {"key": CURRENT, "previous_keys": [SUPERSEDED]}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Encryption always uses `app.key`. Decryption tries `app.key` first, then each previous key in
|
|
116
|
+
order, so rotating a key leaves existing ciphertext readable without a migration. Re-encrypt at
|
|
117
|
+
your own pace, then drop the old key from the list.
|
|
118
|
+
|
|
119
|
+
**Keys load lazily, on the first encrypt or decrypt.** A missing or malformed `app.key` raises
|
|
120
|
+
`ValueError` there, not at boot — an application with no key configured starts perfectly and
|
|
121
|
+
fails at the first secret it touches.
|
|
122
|
+
|
|
123
|
+
## Registering another hasher
|
|
124
|
+
|
|
125
|
+
`HashManager` is a `StrategyResolver` — see the service-providers fragment for the pattern and
|
|
126
|
+
its `use()` contract. Register a factory from a provider's `lifespan()`, never `register()`.
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
manager = (await self.app.make_async(HashManager)).unwrap()
|
|
130
|
+
_ = manager.register("scrypt", lambda resolver: ScryptHasher())
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
A hasher satisfies the `Hasher` protocol: `make`, `check`, `needs_rehash`.
|
|
134
|
+
|
|
135
|
+
## Pitfalls
|
|
136
|
+
|
|
137
|
+
- Sync `Hash.make` / `Hash.check` in async code. Use the `_async` pair.
|
|
138
|
+
- `SecurityProvider` left out of the `providers` namespace.
|
|
139
|
+
- Storing a token instead of `hash_token(token)`.
|
|
140
|
+
- Treating `nbytes` as the token's length.
|
|
141
|
+
- Expecting `Crypt` to raise. It returns `Err`.
|
|
142
|
+
- Assuming a missing `hashing` namespace or `app.key` fails at boot. Both fail at first use.
|
|
@@ -4,7 +4,7 @@ title: Service providers and bindings
|
|
|
4
4
|
requires: python-neva>=4.0
|
|
5
5
|
triggers: [registering a service, binding a dependency, provider.py, wiring a component, DI container, resolving a type]
|
|
6
6
|
priority: 20
|
|
7
|
-
verified_by: [tests/arch/test_lifetimes.py, tests/arch/test_registration.py, tests/arch/test_cache.py, tests/arch/test_scope.py, tests/arch/test_extends.py]
|
|
7
|
+
verified_by: [tests/arch/test_lifetimes.py, tests/arch/test_registration.py, tests/arch/test_cache.py, tests/arch/test_scope.py, tests/arch/test_extends.py, tests/arch/test_register_resolution.py, tests/support/test_strategy.py]
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# Service providers and bindings
|
|
@@ -28,6 +28,36 @@ class ActorServiceProvider(ServiceProvider):
|
|
|
28
28
|
`register()` returns `Ok(self)`, or `Err("...")` to abort boot with a message. Bind only — no
|
|
29
29
|
I/O, no connections. Startup work goes in `lifespan()`.
|
|
30
30
|
|
|
31
|
+
## Never resolve in register()
|
|
32
|
+
|
|
33
|
+
`register()` **declares**; `lifespan()` **uses**. Calling `make()` or `make_async()` during
|
|
34
|
+
registration cannot work, and fails in one of two ways depending on who bound the type:
|
|
35
|
+
|
|
36
|
+
| Resolving | Outcome |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| A type **this provider** binds | `Err` — a provider joins the graph only once `register()` has returned, so its own bindings are not there yet. |
|
|
39
|
+
| A type **another provider** bound | Succeeds, then is **orphaned**. Resolving builds the container early; a successful registration discards it, so the booted application serves a *different* instance. |
|
|
40
|
+
|
|
41
|
+
The second is the dangerous one: nothing raises, nothing logs, and the object looks right. A
|
|
42
|
+
provider that configures a manager this way — adding a log processor, registering a driver —
|
|
43
|
+
silently configures a throwaway.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
def register(self) -> Result[Self, str]:
|
|
47
|
+
manager = self.app.make(SomeManager).unwrap() # WRONG: discarded instance
|
|
48
|
+
manager.configure(...) # silently affects nothing
|
|
49
|
+
return Ok(self)
|
|
50
|
+
|
|
51
|
+
@asynccontextmanager
|
|
52
|
+
async def lifespan(self) -> AsyncIterator[None]:
|
|
53
|
+
manager = (await self.app.make_async(SomeManager)).unwrap() # the real one
|
|
54
|
+
manager.configure(...)
|
|
55
|
+
yield
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Needing another service in order to *bind* is a sign the binding wants a factory function
|
|
59
|
+
taking it as a parameter — let the container inject it, rather than reaching for it.
|
|
60
|
+
|
|
31
61
|
## A lifetime is mandatory
|
|
32
62
|
|
|
33
63
|
Bare `bind()` with neither `scope` nor `cache` **raises `TypeError`** since 4.0. Use the named
|
|
@@ -65,6 +95,33 @@ the binding behind a marker.
|
|
|
65
95
|
- `when: ClassVar[Marker | None]` on the provider gates all of its bindings at once.
|
|
66
96
|
- `listen: ClassVar[dict[type[Event], list[type[EventListener]]]]` — see the events fragment.
|
|
67
97
|
|
|
98
|
+
## Strategy resolvers
|
|
99
|
+
|
|
100
|
+
Where a service picks one of several interchangeable implementations by **name** from config,
|
|
101
|
+
the core uses `StrategyResolver` (`neva.support.strategy`) rather than a second container.
|
|
102
|
+
`HashManager` and the logging `ChannelResolver` are both instances of it, so the contract is
|
|
103
|
+
worth knowing once:
|
|
104
|
+
|
|
105
|
+
| Call | Meaning |
|
|
106
|
+
| ---- | ------- |
|
|
107
|
+
| `use(name=None)` | `Result[T, str]`. Resolves, **caches**, and returns. `None` means the configured default. |
|
|
108
|
+
| `default()` | Abstract — each resolver reads its own config key (`hashing.driver`, `obs.logging.default`). |
|
|
109
|
+
| `register(name, factory)` | Add an implementation. The factory takes the resolver, so it can read config. |
|
|
110
|
+
| `resolve(name)` | Build without caching. Override to change what a name means. |
|
|
111
|
+
| `clear()` | Drop cached instances, so the next `use` rebuilds. |
|
|
112
|
+
|
|
113
|
+
Two failures come back as `Err`, never as an exception: `"No default strategy configured."`
|
|
114
|
+
when `default()` is `Nothing`, and `"No strategy registered with name '<name>'"`. A factory that
|
|
115
|
+
raises is caught and returned as `Err` naming the strategy.
|
|
116
|
+
|
|
117
|
+
**Register from `lifespan()`, not `register()`** — the resolver is a container-bound service,
|
|
118
|
+
so the rule above applies: one resolved during registration is discarded, and the
|
|
119
|
+
implementation registered on it silently never serves. `clear()` matters when adding a
|
|
120
|
+
processor or driver after something has already been resolved.
|
|
121
|
+
|
|
122
|
+
Use it for named, config-selected implementations. Use the container for everything else —
|
|
123
|
+
resolution there is by type, and a strategy resolver is not a general service locator.
|
|
124
|
+
|
|
68
125
|
## Startup and shutdown
|
|
69
126
|
|
|
70
127
|
Implement `lifespan()` and the provider is `Bootable` — a runtime-checkable Protocol, so no
|
|
@@ -111,4 +168,6 @@ catches typos before boot. Each package declares the shape for the keys it reads
|
|
|
111
168
|
- A provider absent from the `providers` config never boots, and nothing says so.
|
|
112
169
|
- Resolution is by **type**. There are no string keys; for two implementations of one interface
|
|
113
170
|
use distinct types or markers.
|
|
114
|
-
- `register()` must be pure binding. Connections
|
|
171
|
+
- `register()` must be pure binding. Connections **and any `make()` call** belong in
|
|
172
|
+
`lifespan()` — see above; resolving during registration silently yields a discarded
|
|
173
|
+
instance.
|
neva/obs/__init__.py
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
|
-
"""Observability tooling.
|
|
1
|
+
"""Observability tooling.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Logging only: tracing, metrics and their exporters live in the `neva-otel`
|
|
4
|
+
plugin, so the core commits no application to an observability backend.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from neva.obs.config import ChannelConfig, LoggingConfig, ObsConfig
|
|
8
|
+
from neva.obs.logging import Channel, ChannelResolver, LogManager, LogServiceProvider
|
|
4
9
|
|
|
5
10
|
|
|
6
11
|
__all__ = [
|
|
12
|
+
"Channel",
|
|
13
|
+
"ChannelConfig",
|
|
14
|
+
"ChannelResolver",
|
|
7
15
|
"LogManager",
|
|
8
16
|
"LogServiceProvider",
|
|
17
|
+
"LoggingConfig",
|
|
18
|
+
"ObsConfig",
|
|
9
19
|
]
|
neva/obs/config.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Observability configs."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal, NotRequired, TypedDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
7
|
+
ChannelDriver = Literal["console", "json", "file", "stack", "null"]
|
|
8
|
+
Stream = Literal["stdout", "stderr"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ChannelConfig(TypedDict):
|
|
12
|
+
"""Configuration for a single log channel.
|
|
13
|
+
|
|
14
|
+
Three keys are driver-specific and carry no meaning elsewhere: `stream`
|
|
15
|
+
belongs to `console` and `json`, `path` to `file`, and `channels` to
|
|
16
|
+
`stack`. A `file` channel without `path`, or a `stack` without `channels`,
|
|
17
|
+
fails to resolve rather than logging nowhere.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
driver: ChannelDriver
|
|
21
|
+
level: NotRequired[LogLevel]
|
|
22
|
+
stream: NotRequired[Stream]
|
|
23
|
+
path: NotRequired[str]
|
|
24
|
+
channels: NotRequired[list[str]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LoggingConfig(TypedDict):
|
|
28
|
+
"""Logging config."""
|
|
29
|
+
|
|
30
|
+
default: NotRequired[str]
|
|
31
|
+
channels: NotRequired[dict[str, ChannelConfig]]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ObsConfig(TypedDict):
|
|
35
|
+
"""Observability config."""
|
|
36
|
+
|
|
37
|
+
logging: NotRequired[LoggingConfig]
|
neva/obs/logging/__init__.py
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
"""Logging module.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Structured logging over structlog, shaped as named channels resolved from
|
|
4
|
+
configuration, reached through the `Log` facade.
|
|
4
5
|
"""
|
|
5
6
|
|
|
7
|
+
from neva.obs.logging.channels import NullChannel, StackChannel
|
|
8
|
+
from neva.obs.logging.contracts import Channel, Processor
|
|
6
9
|
from neva.obs.logging.manager import LogManager
|
|
7
10
|
from neva.obs.logging.provider import LogServiceProvider
|
|
11
|
+
from neva.obs.logging.resolver import ChannelResolver
|
|
8
12
|
|
|
9
13
|
|
|
10
|
-
__all__ = [
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Channel",
|
|
16
|
+
"ChannelResolver",
|
|
17
|
+
"LogManager",
|
|
18
|
+
"LogServiceProvider",
|
|
19
|
+
"NullChannel",
|
|
20
|
+
"Processor",
|
|
21
|
+
"StackChannel",
|
|
22
|
+
]
|