opendataframework 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {opendataframework-0.2.0 → opendataframework-0.3.0}/PKG-INFO +58 -9
  2. {opendataframework-0.2.0 → opendataframework-0.3.0}/README.md +56 -7
  3. {opendataframework-0.2.0 → opendataframework-0.3.0}/pyproject.toml +2 -2
  4. {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/__init__.py +9 -0
  5. opendataframework-0.3.0/src/opendataframework/agent.py +101 -0
  6. {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/component.py +73 -6
  7. {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/config.py +28 -6
  8. {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/context.py +571 -143
  9. {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/entity.py +2 -2
  10. opendataframework-0.3.0/src/opendataframework/function.py +245 -0
  11. opendataframework-0.3.0/src/opendataframework/layer.py +155 -0
  12. {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/logger.py +5 -4
  13. opendataframework-0.3.0/src/opendataframework/namespace.py +349 -0
  14. {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/pipeline.py +2 -2
  15. {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/repository.py +33 -2
  16. {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/service.py +2 -2
  17. {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/task.py +2 -2
  18. {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/view.py +53 -0
  19. opendataframework-0.2.0/src/opendataframework/layer.py +0 -130
  20. opendataframework-0.2.0/src/opendataframework/namespace.py +0 -189
  21. {opendataframework-0.2.0 → opendataframework-0.3.0}/LICENSE +0 -0
  22. {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/project.py +0 -0
  23. {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/py.typed +0 -0
  24. {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/utils.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opendataframework
3
- Version: 0.2.0
4
- Summary: Lightweight, config-driven dependency-injection framework for data applications.
3
+ Version: 0.3.0
4
+ Summary: Lightweight dependency-injection framework for data applications.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
7
7
  Author: mykytapavlov
@@ -25,8 +25,8 @@ Lightweight dependency-injection framework for data applications.
25
25
 
26
26
  This package provides structural abstractions —
27
27
  `Entity`, `Repository`, `Component`, `Service`, `Task`, `Pipeline`,
28
- `Layer`, `Namespace`, `Context`, `Project`, `Config`, `Logger`, `View`
29
- — with zero third-party dependencies. No UI, no CLI, no MCP server.
28
+ `Agent`, `Function`, `Layer`, `Namespace`, `Context`, `Project`, `Config`,
29
+ `Logger`, `View` — with zero third-party dependencies. No UI, no CLI, no MCP server.
30
30
 
31
31
  > Need the CLI, UI, or MCP server? That's the
32
32
  > [`odf`](https://opendataframework.github.io/odf/) package, which
@@ -81,7 +81,7 @@ class User:
81
81
  @Component
82
82
  class SQLite:
83
83
  def __init__(self, config: Config):
84
- self.conn = sqlite3.connect(config.sqlite.path)
84
+ self.conn = sqlite3.connect(config.component.sqlite.path)
85
85
  self.conn.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)")
86
86
 
87
87
 
@@ -102,7 +102,7 @@ class Users:
102
102
  Config file (`config.toml`):
103
103
 
104
104
  ```toml
105
- [sqlite]
105
+ [component.sqlite]
106
106
  path = "app.db"
107
107
  ```
108
108
 
@@ -159,7 +159,10 @@ class Users:
159
159
 
160
160
  An optional, single declaration a `Repository` attaches to itself: which
161
161
  representation (table, map, image, video, ...) and field(s) best fit its
162
- data. Metadata only — this package renders nothing itself. See
162
+ data. Metadata only — this package renders nothing itself. A repository
163
+ whose view points at content (an image, a clip, a document) may also
164
+ implement `content_metadata()` to declare each record's file name and
165
+ MIME type. See
163
166
  [View](https://opendataframework.github.io/opendataframework/view/).
164
167
 
165
168
  ```python
@@ -220,6 +223,51 @@ class DailyAnalytics:
220
223
  def execute(self): ...
221
224
  ```
222
225
 
226
+ ## Agent
227
+
228
+ A finite executable whose steps are decided at run time, typically by a
229
+ language model — handed its goal as arguments to `invoke()`. What it may act
230
+ on is declared through its constructor. The framework ships no model client;
231
+ `invoke()` is where a library of your choice goes.
232
+
233
+ ```python
234
+ @Agent
235
+ class Librarian:
236
+ def __init__(self, books: Books, model: Model): ...
237
+
238
+ def invoke(self, question: str) -> str: ...
239
+
240
+
241
+ project.context.call("agent.librarian", question="Anything by Herbert?")
242
+ ```
243
+
244
+ ## Function
245
+
246
+ A plain function made framework-aware — for adopting existing code without
247
+ a class refactor first. Returned unchanged; called through the `Context`,
248
+ its managed dependencies are injected and the rest are arguments.
249
+
250
+ ```python
251
+ @Function
252
+ def load_data(path: str, db: Postgres) -> int: ...
253
+
254
+
255
+ project.context.call("function.load-data", path="data.csv") # db injected
256
+ ```
257
+
258
+ A class asks for a function through its constructor, and gets it with its
259
+ dependencies already filled in:
260
+
261
+ ```python
262
+ @Task
263
+ class Refresh:
264
+ def __init__(self, load: Annotated[Callable[..., int], load_data]):
265
+ self.load = load
266
+
267
+ def execute(self):
268
+ return self.load("data.csv")
269
+ ```
270
+
223
271
  ## Layer
224
272
 
225
273
  An organisational grouping — which subsystem a component belongs to
@@ -235,8 +283,8 @@ class Postgres: ...
235
283
  ## Namespace
236
284
 
237
285
  The base class underlying every framework decorator — `Entity`,
238
- `Component`, `Service`, `Task`, `Pipeline`, `Repository`, and `Layer` are
239
- all `Namespace` subclasses, each with its own independent name → class
286
+ `Component`, `Service`, `Task`, `Pipeline`, `Agent`, `Function`,
287
+ `Repository`, and `Layer` are all `Namespace` subclasses, each with its own independent name → class
240
288
  mapping. A framework extension point, not something end users subclass
241
289
  directly. See
242
290
  [Namespace](https://opendataframework.github.io/opendataframework/namespace/).
@@ -319,6 +367,7 @@ Components provide capabilities.
319
367
  Services run for extended periods.
320
368
  Tasks execute bounded work.
321
369
  Pipelines coordinate tasks (and other pipelines).
370
+ Agents pursue a goal handed to them.
322
371
 
323
372
  Layers organize components.
324
373
  Context manages components and repositories.
@@ -9,8 +9,8 @@ Lightweight dependency-injection framework for data applications.
9
9
 
10
10
  This package provides structural abstractions —
11
11
  `Entity`, `Repository`, `Component`, `Service`, `Task`, `Pipeline`,
12
- `Layer`, `Namespace`, `Context`, `Project`, `Config`, `Logger`, `View`
13
- — with zero third-party dependencies. No UI, no CLI, no MCP server.
12
+ `Agent`, `Function`, `Layer`, `Namespace`, `Context`, `Project`, `Config`,
13
+ `Logger`, `View` — with zero third-party dependencies. No UI, no CLI, no MCP server.
14
14
 
15
15
  > Need the CLI, UI, or MCP server? That's the
16
16
  > [`odf`](https://opendataframework.github.io/odf/) package, which
@@ -65,7 +65,7 @@ class User:
65
65
  @Component
66
66
  class SQLite:
67
67
  def __init__(self, config: Config):
68
- self.conn = sqlite3.connect(config.sqlite.path)
68
+ self.conn = sqlite3.connect(config.component.sqlite.path)
69
69
  self.conn.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)")
70
70
 
71
71
 
@@ -86,7 +86,7 @@ class Users:
86
86
  Config file (`config.toml`):
87
87
 
88
88
  ```toml
89
- [sqlite]
89
+ [component.sqlite]
90
90
  path = "app.db"
91
91
  ```
92
92
 
@@ -143,7 +143,10 @@ class Users:
143
143
 
144
144
  An optional, single declaration a `Repository` attaches to itself: which
145
145
  representation (table, map, image, video, ...) and field(s) best fit its
146
- data. Metadata only — this package renders nothing itself. See
146
+ data. Metadata only — this package renders nothing itself. A repository
147
+ whose view points at content (an image, a clip, a document) may also
148
+ implement `content_metadata()` to declare each record's file name and
149
+ MIME type. See
147
150
  [View](https://opendataframework.github.io/opendataframework/view/).
148
151
 
149
152
  ```python
@@ -204,6 +207,51 @@ class DailyAnalytics:
204
207
  def execute(self): ...
205
208
  ```
206
209
 
210
+ ## Agent
211
+
212
+ A finite executable whose steps are decided at run time, typically by a
213
+ language model — handed its goal as arguments to `invoke()`. What it may act
214
+ on is declared through its constructor. The framework ships no model client;
215
+ `invoke()` is where a library of your choice goes.
216
+
217
+ ```python
218
+ @Agent
219
+ class Librarian:
220
+ def __init__(self, books: Books, model: Model): ...
221
+
222
+ def invoke(self, question: str) -> str: ...
223
+
224
+
225
+ project.context.call("agent.librarian", question="Anything by Herbert?")
226
+ ```
227
+
228
+ ## Function
229
+
230
+ A plain function made framework-aware — for adopting existing code without
231
+ a class refactor first. Returned unchanged; called through the `Context`,
232
+ its managed dependencies are injected and the rest are arguments.
233
+
234
+ ```python
235
+ @Function
236
+ def load_data(path: str, db: Postgres) -> int: ...
237
+
238
+
239
+ project.context.call("function.load-data", path="data.csv") # db injected
240
+ ```
241
+
242
+ A class asks for a function through its constructor, and gets it with its
243
+ dependencies already filled in:
244
+
245
+ ```python
246
+ @Task
247
+ class Refresh:
248
+ def __init__(self, load: Annotated[Callable[..., int], load_data]):
249
+ self.load = load
250
+
251
+ def execute(self):
252
+ return self.load("data.csv")
253
+ ```
254
+
207
255
  ## Layer
208
256
 
209
257
  An organisational grouping — which subsystem a component belongs to
@@ -219,8 +267,8 @@ class Postgres: ...
219
267
  ## Namespace
220
268
 
221
269
  The base class underlying every framework decorator — `Entity`,
222
- `Component`, `Service`, `Task`, `Pipeline`, `Repository`, and `Layer` are
223
- all `Namespace` subclasses, each with its own independent name → class
270
+ `Component`, `Service`, `Task`, `Pipeline`, `Agent`, `Function`,
271
+ `Repository`, and `Layer` are all `Namespace` subclasses, each with its own independent name → class
224
272
  mapping. A framework extension point, not something end users subclass
225
273
  directly. See
226
274
  [Namespace](https://opendataframework.github.io/opendataframework/namespace/).
@@ -303,6 +351,7 @@ Components provide capabilities.
303
351
  Services run for extended periods.
304
352
  Tasks execute bounded work.
305
353
  Pipelines coordinate tasks (and other pipelines).
354
+ Agents pursue a goal handed to them.
306
355
 
307
356
  Layers organize components.
308
357
  Context manages components and repositories.
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "opendataframework"
3
- version = "0.2.0"
4
- description = "Lightweight, config-driven dependency-injection framework for data applications."
3
+ version = "0.3.0"
4
+ description = "Lightweight dependency-injection framework for data applications."
5
5
  authors = [
6
6
  {name = "mykytapavlov",email = "mykytapavlov@users.noreply.github.com"}
7
7
  ]
@@ -1,9 +1,11 @@
1
1
  """Open Data Framework (ODF) package."""
2
2
 
3
+ from opendataframework.agent import Agent, AgentProtocol
3
4
  from opendataframework.component import Component
4
5
  from opendataframework.config import Config
5
6
  from opendataframework.context import Context, Lifecycle, Resolver
6
7
  from opendataframework.entity import Entity
8
+ from opendataframework.function import Function
7
9
  from opendataframework.layer import Analytics, Api, Layer, Messaging, Monitoring, Security, Storage
8
10
  from opendataframework.logger import Logger
9
11
  from opendataframework.namespace import Namespace
@@ -19,6 +21,8 @@ from opendataframework.service import Service, ServiceProtocol
19
21
  from opendataframework.task import Task, TaskProtocol
20
22
  from opendataframework.view import (
21
23
  AudioView,
24
+ ContentMetadata,
25
+ ContentMetadataProtocol,
22
26
  DataView,
23
27
  DataViewProtocol,
24
28
  DocumentView,
@@ -33,16 +37,21 @@ from opendataframework.view import (
33
37
  )
34
38
 
35
39
  __all__ = [
40
+ "Agent",
41
+ "AgentProtocol",
36
42
  "Analytics",
37
43
  "Api",
38
44
  "AudioView",
39
45
  "Component",
40
46
  "Config",
47
+ "ContentMetadata",
48
+ "ContentMetadataProtocol",
41
49
  "Context",
42
50
  "DataView",
43
51
  "DataViewProtocol",
44
52
  "DocumentView",
45
53
  "Entity",
54
+ "Function",
46
55
  "ImageView",
47
56
  "Layer",
48
57
  "Lifecycle",
@@ -0,0 +1,101 @@
1
+ """The ``@Agent`` decorator — the framework's marker for agentic work.
2
+
3
+ An agent is a class that pursues a goal it is handed at call time, usually
4
+ by driving a model in a loop. The framework owns none of that loop: it
5
+ registers the class, injects what the agent may act on through its
6
+ constructor, and gives the ``Context`` one method — ``invoke()`` — to call
7
+ and log.
8
+ """
9
+
10
+ from typing import Protocol, runtime_checkable
11
+
12
+ from opendataframework.namespace import ExecutionType
13
+
14
+
15
+ @runtime_checkable
16
+ class AgentProtocol(Protocol):
17
+ """Expected interface for classes registered with ``@Agent``.
18
+
19
+ An agent implements a single method that pursues a goal and returns a
20
+ result. Unlike ``TaskProtocol.execute()``, ``invoke()`` takes arguments —
21
+ whatever the agent's author declares. Being ``@runtime_checkable``, an
22
+ ``isinstance`` check confirms only that ``invoke`` is present, not its
23
+ signature.
24
+ """
25
+
26
+ def invoke(self, *args, **kwargs):
27
+ """Pursue the goal described by the arguments and return a result, or ``None``."""
28
+ ...
29
+
30
+
31
+ class Agent(ExecutionType):
32
+ """Decorator that registers an agentic class with the Context.
33
+
34
+ ``@Agent`` marks a class as a managed agent — a finite, explicitly
35
+ invoked unit of work whose steps are decided at run time rather than
36
+ written down in advance. The ``Context`` creates it and injects its
37
+ dependencies like any other class, and calls its ``invoke()`` method by
38
+ name through ``Context.call()``.
39
+
40
+ The decorated class is returned unchanged. No base class is added and no
41
+ methods are injected.
42
+
43
+ Basic usage — ``invoke()`` takes whatever arguments the agent needs::
44
+
45
+ @Agent
46
+ class Librarian:
47
+
48
+ def invoke(self, question: str) -> str:
49
+ ...
50
+
51
+ With an explicit name::
52
+
53
+ @Agent(name="librarian")
54
+ class CatalogueAssistant:
55
+ ...
56
+
57
+ Dependency injection — the constructor declares everything the agent may
58
+ act on, so its reach is visible in the dependency graph::
59
+
60
+ @Agent
61
+ class Librarian:
62
+
63
+ def __init__(self, books: Books, model: Model):
64
+ self.books = books
65
+ self.model = model
66
+
67
+ def invoke(self, question: str) -> str:
68
+ ...
69
+
70
+ project.context.call("librarian", question="Anything by Herbert?")
71
+ project.context.get(Librarian).invoke("Anything by Herbert?")
72
+
73
+ The framework ships no model client and no loop. ``invoke()`` is where an
74
+ agentic library, a provider SDK, or hand-written logic goes; the model
75
+ itself is usually another injected component.
76
+
77
+ Looking up registered agents::
78
+
79
+ Agent.get("Librarian") # → Librarian class
80
+ Agent.get("librarian") # → Librarian class
81
+ dict(Agent.items()) # → all registered agents
82
+
83
+ Raises:
84
+ TypeError: If the decorated object is not a class, or is a class that
85
+ does not define ``invoke()``.
86
+ """
87
+
88
+ @classmethod
89
+ def _register(cls, key: str, wrapped) -> None:
90
+ """Store ``wrapped`` under ``key``, requiring it to define ``invoke()``.
91
+
92
+ Raises:
93
+ TypeError: If ``wrapped`` has no callable ``invoke``.
94
+ ValueError: If ``key`` is already registered in this namespace.
95
+ """
96
+ if not callable(getattr(wrapped, "invoke", None)):
97
+ raise TypeError(
98
+ f"{cls.__name__} can only decorate a class that defines invoke(), "
99
+ f"got {wrapped.__name__!r}"
100
+ )
101
+ super()._register(key, wrapped)
@@ -6,11 +6,12 @@ injected by the ``Context`` with no execution contract of its own. The
6
6
  implement to opt into lifecycle callbacks or inspection tooling.
7
7
  """
8
8
 
9
+ import inspect
9
10
  from collections.abc import Callable
10
11
  from dataclasses import dataclass
11
12
  from typing import Protocol, runtime_checkable
12
13
 
13
- from opendataframework.namespace import Namespace
14
+ from opendataframework.namespace import ExecutionType
14
15
 
15
16
 
16
17
  @runtime_checkable
@@ -96,16 +97,26 @@ class McpTool:
96
97
  Attributes:
97
98
  name: The tool's local name — namespaced by whatever builds the
98
99
  tool surface (today, the sibling ``odf`` package's
99
- ``McpServer`` prefixes it with the component's kebab-case
100
- class name) to avoid collisions with the fixed built-in
101
- tools and with other components' tools.
100
+ ``McpServer`` prefixes it with the component's
101
+ ``<namespace>.<name>`` key) to avoid collisions with the fixed
102
+ built-in tools and with other components' tools.
102
103
  description: Human-readable description shown to the MCP client.
103
- handler: The bound method implementing the tool call.
104
+ Must not be empty.
105
+ handler: The bound method implementing the tool call. Whoever
106
+ calls the tool — typically a model — sees only its signature,
107
+ so it must be fully typed: every parameter annotated, no
108
+ ``*args``/``**kwargs``, a return annotation, and no annotation
109
+ left as a string.
104
110
  structured_output: Whether the handler's return value should be
105
111
  reported as structured (JSON) output. ``None`` defers to
106
112
  the MCP server's own default behavior, mirroring how this
107
113
  field is expected to be forwarded verbatim by whatever
108
114
  registers the tool.
115
+
116
+ Raises:
117
+ ValueError: If ``description`` is empty.
118
+ TypeError: If ``handler`` is not callable, has no readable
119
+ signature, or is not fully typed.
109
120
  """
110
121
 
111
122
  name: str
@@ -113,6 +124,62 @@ class McpTool:
113
124
  handler: Callable[..., object]
114
125
  structured_output: bool | None = None
115
126
 
127
+ def __post_init__(self) -> None:
128
+ _require_tool(self)
129
+
130
+
131
+ def _require_tool(tool: McpTool) -> None:
132
+ """Raise unless ``tool`` has a description and a fully typed handler.
133
+
134
+ A tool is offered to a caller that sees nothing but its description
135
+ and the handler's signature, so both are checked where the tool is
136
+ created rather than left to surface as a poor schema.
137
+
138
+ Raises:
139
+ ValueError: If the description is empty.
140
+ TypeError: If the handler is not callable, has no readable
141
+ signature, or is not fully typed — naming every problem found.
142
+ """
143
+ if not tool.description.strip():
144
+ raise ValueError(f"MCP tool {tool.name!r} has an empty description")
145
+ if not callable(tool.handler):
146
+ raise TypeError(f"MCP tool {tool.name!r}: handler {tool.handler!r} is not callable")
147
+ try:
148
+ signature = inspect.signature(tool.handler)
149
+ except (TypeError, ValueError) as exc:
150
+ raise TypeError(
151
+ f"MCP tool {tool.name!r}: the handler's signature cannot be read ({exc}) — "
152
+ "wrap it in a function with annotated parameters"
153
+ ) from exc
154
+ problems: list[str] = []
155
+ strings = False
156
+ for parameter in signature.parameters.values():
157
+ if parameter.kind is inspect.Parameter.VAR_POSITIONAL:
158
+ problems.append(f"*{parameter.name} is not allowed")
159
+ elif parameter.kind is inspect.Parameter.VAR_KEYWORD:
160
+ problems.append(f"**{parameter.name} is not allowed")
161
+ elif parameter.annotation is inspect.Parameter.empty:
162
+ problems.append(f"parameter {parameter.name!r} is not annotated")
163
+ elif isinstance(parameter.annotation, str):
164
+ problems.append(f"parameter {parameter.name!r} has a string annotation")
165
+ strings = True
166
+ if signature.return_annotation is inspect.Signature.empty:
167
+ problems.append("the return value is not annotated")
168
+ elif isinstance(signature.return_annotation, str):
169
+ problems.append("the return value has a string annotation")
170
+ strings = True
171
+ if not problems:
172
+ return
173
+ hint = (
174
+ " — remove `from __future__ import annotations` from the module, or the quotes "
175
+ "around the annotation"
176
+ if strings
177
+ else ""
178
+ )
179
+ raise TypeError(
180
+ f"MCP tool {tool.name!r}: the handler must be fully typed, but {'; '.join(problems)}{hint}"
181
+ )
182
+
116
183
 
117
184
  @runtime_checkable
118
185
  class McpToolsProtocol(Protocol):
@@ -133,7 +200,7 @@ class McpToolsProtocol(Protocol):
133
200
  ...
134
201
 
135
202
 
136
- class Component(Namespace):
203
+ class Component(ExecutionType):
137
204
  """Decorator that registers a class with the Context for dependency injection.
138
205
 
139
206
  ``@Component`` marks a class as a managed component — the ``Context``
@@ -31,10 +31,10 @@ class Config:
31
31
  >>> cfg["database-url"]
32
32
  'postgres://localhost/db'
33
33
 
34
- Nested access::
34
+ Nested access — ``[service.postgres]`` in TOML::
35
35
 
36
- cfg = Config({"storage": {"postgres": {"port": 5432}}})
37
- cfg.storage.postgres.port # → 5432
36
+ cfg = Config({"service": {"postgres": {"port": 5432}}})
37
+ cfg.service.postgres.port # → 5432
38
38
  """
39
39
 
40
40
  def __init__(self, data: dict) -> None:
@@ -44,8 +44,16 @@ class Config:
44
44
  original dict after construction (e.g. one the caller keeps a
45
45
  reference to and mutates after passing to ``Project.from_dict()``)
46
46
  does not leak through this ``Config``.
47
+
48
+ Keys are normalised to kebab-case (recursively, through nested
49
+ dicts), the same way lookup names are, so a key written as
50
+ ``exclude_years`` in the file is found by any name form.
51
+
52
+ Raises:
53
+ ValueError: If two keys at the same level normalise to the same
54
+ name (e.g. both ``exclude_years`` and ``exclude-years``).
47
55
  """
48
- self._data = deepcopy(data)
56
+ self._data = _normalize_keys(deepcopy(data))
49
57
 
50
58
  def __getattr__(self, name: str) -> Config | object:
51
59
  """Return value by snake_case attribute name, normalised to kebab-case.
@@ -93,7 +101,7 @@ class Config:
93
101
  scope into a section and chain further ``.get()``/attribute access
94
102
  without guarding against it being missing:
95
103
 
96
- cfg = config.get("postgres")
104
+ cfg = config.get("service").get("postgres")
97
105
  url = cfg.database_url # AttributeError if missing
98
106
  port = cfg.get("port", 5432) # safe fallback
99
107
 
@@ -113,6 +121,20 @@ class Config:
113
121
  return Config(value) if isinstance(value, dict) else value
114
122
 
115
123
 
124
+ def _normalize_keys(data: dict) -> dict:
125
+ """Return ``data`` with every key normalised to kebab-case, recursively."""
126
+ result: dict = {}
127
+ for key, value in data.items():
128
+ name = normalize(key)
129
+ if name in result:
130
+ raise ValueError(
131
+ f"Config keys conflict after normalisation: {key!r} and another "
132
+ f"key both map to {name!r}"
133
+ )
134
+ result[name] = _normalize_keys(value) if isinstance(value, dict) else value
135
+ return result
136
+
137
+
116
138
  def load(path: str) -> dict:
117
139
  """Load configuration from a TOML file or directory of TOML files.
118
140
 
@@ -132,7 +154,7 @@ def load(path: str) -> dict:
132
154
 
133
155
  Example:
134
156
  >>> config = load("config.toml")
135
- >>> config["postgres"]["database-url"]
157
+ >>> config["service"]["postgres"]["database-url"]
136
158
  'postgresql://localhost/mydb'
137
159
  """
138
160
  p = Path(path)