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.
@@ -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 | None,
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. It must never be lost: fall
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 belong in `lifespan()`.
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
- from neva.obs.logging import LogManager, LogServiceProvider
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]
@@ -1,10 +1,22 @@
1
1
  """Logging module.
2
2
 
3
- Provides structured logging using structlog with Laravel-style facades.
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__ = ["LogManager", "LogServiceProvider"]
14
+ __all__ = [
15
+ "Channel",
16
+ "ChannelResolver",
17
+ "LogManager",
18
+ "LogServiceProvider",
19
+ "NullChannel",
20
+ "Processor",
21
+ "StackChannel",
22
+ ]