opendataframework 0.1.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.1.0 → opendataframework-0.3.0}/PKG-INFO +61 -14
  2. {opendataframework-0.1.0 → opendataframework-0.3.0}/README.md +59 -12
  3. {opendataframework-0.1.0 → opendataframework-0.3.0}/pyproject.toml +2 -2
  4. {opendataframework-0.1.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.1.0 → opendataframework-0.3.0}/src/opendataframework/component.py +115 -2
  7. {opendataframework-0.1.0 → opendataframework-0.3.0}/src/opendataframework/config.py +42 -13
  8. {opendataframework-0.1.0 → opendataframework-0.3.0}/src/opendataframework/context.py +571 -143
  9. {opendataframework-0.1.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.1.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.1.0 → opendataframework-0.3.0}/src/opendataframework/pipeline.py +2 -2
  15. {opendataframework-0.1.0 → opendataframework-0.3.0}/src/opendataframework/project.py +17 -0
  16. {opendataframework-0.1.0 → opendataframework-0.3.0}/src/opendataframework/repository.py +33 -2
  17. {opendataframework-0.1.0 → opendataframework-0.3.0}/src/opendataframework/service.py +2 -2
  18. {opendataframework-0.1.0 → opendataframework-0.3.0}/src/opendataframework/task.py +2 -2
  19. {opendataframework-0.1.0 → opendataframework-0.3.0}/src/opendataframework/view.py +55 -2
  20. opendataframework-0.1.0/src/opendataframework/layer.py +0 -130
  21. opendataframework-0.1.0/src/opendataframework/namespace.py +0 -175
  22. {opendataframework-0.1.0 → opendataframework-0.3.0}/LICENSE +0 -0
  23. {opendataframework-0.1.0 → opendataframework-0.3.0}/src/opendataframework/py.typed +0 -0
  24. {opendataframework-0.1.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.1.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,10 +81,8 @@ class User:
81
81
  @Component
82
82
  class SQLite:
83
83
  def __init__(self, config: Config):
84
- self.conn = sqlite3.connect(config.sqlite.path)
85
- self.conn.execute(
86
- "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)"
87
- )
84
+ self.conn = sqlite3.connect(config.component.sqlite.path)
85
+ self.conn.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)")
88
86
 
89
87
 
90
88
  @Repository(User)
@@ -104,7 +102,7 @@ class Users:
104
102
  Config file (`config.toml`):
105
103
 
106
104
  ```toml
107
- [sqlite]
105
+ [component.sqlite]
108
106
  path = "app.db"
109
107
  ```
110
108
 
@@ -161,7 +159,10 @@ class Users:
161
159
 
162
160
  An optional, single declaration a `Repository` attaches to itself: which
163
161
  representation (table, map, image, video, ...) and field(s) best fit its
164
- 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
165
166
  [View](https://opendataframework.github.io/opendataframework/view/).
166
167
 
167
168
  ```python
@@ -222,6 +223,51 @@ class DailyAnalytics:
222
223
  def execute(self): ...
223
224
  ```
224
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
+
225
271
  ## Layer
226
272
 
227
273
  An organisational grouping — which subsystem a component belongs to
@@ -237,8 +283,8 @@ class Postgres: ...
237
283
  ## Namespace
238
284
 
239
285
  The base class underlying every framework decorator — `Entity`,
240
- `Component`, `Service`, `Task`, `Pipeline`, `Repository`, and `Layer` are
241
- 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
242
288
  mapping. A framework extension point, not something end users subclass
243
289
  directly. See
244
290
  [Namespace](https://opendataframework.github.io/opendataframework/namespace/).
@@ -281,7 +327,7 @@ No circular dependencies — restructure with a shared third collaborator
281
327
  instead.
282
328
 
283
329
  ```python
284
- component = project.context.get(UsersApi) # typed, by class
330
+ component = project.context.get(UsersApi) # typed, by class
285
331
  ```
286
332
 
287
333
  ## Project
@@ -321,6 +367,7 @@ Components provide capabilities.
321
367
  Services run for extended periods.
322
368
  Tasks execute bounded work.
323
369
  Pipelines coordinate tasks (and other pipelines).
370
+ Agents pursue a goal handed to them.
324
371
 
325
372
  Layers organize components.
326
373
  Context manages components and repositories.
@@ -334,7 +381,7 @@ Project owns everything.
334
381
  See [`examples/`](examples/README.md) — small, focused projects each
335
382
  isolating one core abstraction.
336
383
 
337
- > For the CLI, UI, MCP server, and chat surface
384
+ > For the CLI, UI, MCP server, and chat surface
338
385
  > built on top of this package, see `odf`.
339
386
 
340
387
  ---
@@ -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,10 +65,8 @@ class User:
65
65
  @Component
66
66
  class SQLite:
67
67
  def __init__(self, config: Config):
68
- self.conn = sqlite3.connect(config.sqlite.path)
69
- self.conn.execute(
70
- "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)"
71
- )
68
+ self.conn = sqlite3.connect(config.component.sqlite.path)
69
+ self.conn.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)")
72
70
 
73
71
 
74
72
  @Repository(User)
@@ -88,7 +86,7 @@ class Users:
88
86
  Config file (`config.toml`):
89
87
 
90
88
  ```toml
91
- [sqlite]
89
+ [component.sqlite]
92
90
  path = "app.db"
93
91
  ```
94
92
 
@@ -145,7 +143,10 @@ class Users:
145
143
 
146
144
  An optional, single declaration a `Repository` attaches to itself: which
147
145
  representation (table, map, image, video, ...) and field(s) best fit its
148
- 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
149
150
  [View](https://opendataframework.github.io/opendataframework/view/).
150
151
 
151
152
  ```python
@@ -206,6 +207,51 @@ class DailyAnalytics:
206
207
  def execute(self): ...
207
208
  ```
208
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
+
209
255
  ## Layer
210
256
 
211
257
  An organisational grouping — which subsystem a component belongs to
@@ -221,8 +267,8 @@ class Postgres: ...
221
267
  ## Namespace
222
268
 
223
269
  The base class underlying every framework decorator — `Entity`,
224
- `Component`, `Service`, `Task`, `Pipeline`, `Repository`, and `Layer` are
225
- 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
226
272
  mapping. A framework extension point, not something end users subclass
227
273
  directly. See
228
274
  [Namespace](https://opendataframework.github.io/opendataframework/namespace/).
@@ -265,7 +311,7 @@ No circular dependencies — restructure with a shared third collaborator
265
311
  instead.
266
312
 
267
313
  ```python
268
- component = project.context.get(UsersApi) # typed, by class
314
+ component = project.context.get(UsersApi) # typed, by class
269
315
  ```
270
316
 
271
317
  ## Project
@@ -305,6 +351,7 @@ Components provide capabilities.
305
351
  Services run for extended periods.
306
352
  Tasks execute bounded work.
307
353
  Pipelines coordinate tasks (and other pipelines).
354
+ Agents pursue a goal handed to them.
308
355
 
309
356
  Layers organize components.
310
357
  Context manages components and repositories.
@@ -318,7 +365,7 @@ Project owns everything.
318
365
  See [`examples/`](examples/README.md) — small, focused projects each
319
366
  isolating one core abstraction.
320
367
 
321
- > For the CLI, UI, MCP server, and chat surface
368
+ > For the CLI, UI, MCP server, and chat surface
322
369
  > built on top of this package, see `odf`.
323
370
 
324
371
  ---
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "opendataframework"
3
- version = "0.1.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,9 +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
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
9
12
  from typing import Protocol, runtime_checkable
10
13
 
11
- from opendataframework.namespace import Namespace
14
+ from opendataframework.namespace import ExecutionType
12
15
 
13
16
 
14
17
  @runtime_checkable
@@ -87,7 +90,117 @@ class ChartProtocol(Protocol):
87
90
  ...
88
91
 
89
92
 
90
- class Component(Namespace):
93
+ @dataclass
94
+ class McpTool:
95
+ """One MCP tool a Component exposes, returned from ``mcp_tools()``.
96
+
97
+ Attributes:
98
+ name: The tool's local name — namespaced by whatever builds the
99
+ tool surface (today, the sibling ``odf`` package's
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.
103
+ description: Human-readable description shown to the MCP client.
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.
110
+ structured_output: Whether the handler's return value should be
111
+ reported as structured (JSON) output. ``None`` defers to
112
+ the MCP server's own default behavior, mirroring how this
113
+ field is expected to be forwarded verbatim by whatever
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.
120
+ """
121
+
122
+ name: str
123
+ description: str
124
+ handler: Callable[..., object]
125
+ structured_output: bool | None = None
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
+
183
+
184
+ @runtime_checkable
185
+ class McpToolsProtocol(Protocol):
186
+ """Capability interface for a Component's optional custom MCP tools.
187
+
188
+ Independent of ``DetailsProtocol``/``ChartProtocol``/
189
+ ``OnStartProtocol``/``OnStopProtocol``. Never invoked by ``Context``
190
+ — detected structurally via ``isinstance(instance, McpToolsProtocol)``
191
+ and consumed on demand by whatever builds an MCP tool surface. (Today,
192
+ that's the sibling ``odf`` package's ``McpServer``, which registers
193
+ each returned ``McpTool`` under
194
+ ``<kebab(cls.__name__)>.<tool.name>``, alongside its six fixed
195
+ built-in tools.)
196
+ """
197
+
198
+ def mcp_tools(self) -> list[McpTool]:
199
+ """Return the component's custom MCP tools, in addition to the fixed built-in surface."""
200
+ ...
201
+
202
+
203
+ class Component(ExecutionType):
91
204
  """Decorator that registers a class with the Context for dependency injection.
92
205
 
93
206
  ``@Component`` marks a class as a managed component — the ``Context``
@@ -7,6 +7,7 @@ components declare it like any other constructor dependency.
7
7
  """
8
8
 
9
9
  import tomllib
10
+ from copy import deepcopy
10
11
  from pathlib import Path
11
12
 
12
13
  from opendataframework.utils import normalize
@@ -30,15 +31,29 @@ class Config:
30
31
  >>> cfg["database-url"]
31
32
  'postgres://localhost/db'
32
33
 
33
- Nested access::
34
+ Nested access — ``[service.postgres]`` in TOML::
34
35
 
35
- cfg = Config({"storage": {"postgres": {"port": 5432}}})
36
- cfg.storage.postgres.port # → 5432
36
+ cfg = Config({"service": {"postgres": {"port": 5432}}})
37
+ cfg.service.postgres.port # → 5432
37
38
  """
38
39
 
39
40
  def __init__(self, data: dict) -> None:
40
- """Wrap ``data``, a (possibly nested) configuration dict."""
41
- self._data = data
41
+ """Wrap a deep copy of ``data``, a (possibly nested) configuration dict.
42
+
43
+ ``data`` is copied rather than stored by reference, so mutating the
44
+ original dict after construction (e.g. one the caller keeps a
45
+ reference to and mutates after passing to ``Project.from_dict()``)
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``).
55
+ """
56
+ self._data = _normalize_keys(deepcopy(data))
42
57
 
43
58
  def __getattr__(self, name: str) -> Config | object:
44
59
  """Return value by snake_case attribute name, normalised to kebab-case.
@@ -60,10 +75,10 @@ class Config:
60
75
  return Config(value) if isinstance(value, dict) else value
61
76
 
62
77
  def __getitem__(self, key: str) -> Config | object:
63
- """Return value by key (any name form accepted).
78
+ """Return value by key, normalised to kebab-case (any name form accepted).
64
79
 
65
80
  Args:
66
- key: Config key in any supported form.
81
+ key: Config key (snake_case, kebab-case, or PascalCase).
67
82
 
68
83
  Returns:
69
84
  A ``Config`` wrapping the value if it is a dict, otherwise the
@@ -73,25 +88,25 @@ class Config:
73
88
  KeyError: If the key is not present.
74
89
  """
75
90
  try:
76
- value = self._data[key]
91
+ value = self._data[normalize(key)]
77
92
  except KeyError as err:
78
93
  raise KeyError(key) from err
79
94
  return Config(value) if isinstance(value, dict) else value
80
95
 
81
96
  def get(self, key: str, default: object = None) -> Config | object | None:
82
- """Return value by key, or ``default`` if absent.
97
+ """Return value by key (any name form accepted), or ``default`` if absent.
83
98
 
84
99
  If ``default`` is not given and the key is absent, an empty
85
100
  ``Config`` is returned instead of ``None``, so callers can safely
86
101
  scope into a section and chain further ``.get()``/attribute access
87
102
  without guarding against it being missing:
88
103
 
89
- cfg = config.get("postgres")
104
+ cfg = config.get("service").get("postgres")
90
105
  url = cfg.database_url # AttributeError if missing
91
106
  port = cfg.get("port", 5432) # safe fallback
92
107
 
93
108
  Args:
94
- key: Config key.
109
+ key: Config key (snake_case, kebab-case, or PascalCase).
95
110
  default: Fallback value when the key is not present.
96
111
 
97
112
  Returns:
@@ -100,12 +115,26 @@ class Config:
100
115
  or an empty ``Config`` if the key is absent and no default was
101
116
  given.
102
117
  """
103
- value = self._data.get(key)
118
+ value = self._data.get(normalize(key))
104
119
  if value is None:
105
120
  return default if default is not None else Config({})
106
121
  return Config(value) if isinstance(value, dict) else value
107
122
 
108
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
+
109
138
  def load(path: str) -> dict:
110
139
  """Load configuration from a TOML file or directory of TOML files.
111
140
 
@@ -125,7 +154,7 @@ def load(path: str) -> dict:
125
154
 
126
155
  Example:
127
156
  >>> config = load("config.toml")
128
- >>> config["postgres"]["database-url"]
157
+ >>> config["service"]["postgres"]["database-url"]
129
158
  'postgresql://localhost/mydb'
130
159
  """
131
160
  p = Path(path)