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.
- {opendataframework-0.1.0 → opendataframework-0.3.0}/PKG-INFO +61 -14
- {opendataframework-0.1.0 → opendataframework-0.3.0}/README.md +59 -12
- {opendataframework-0.1.0 → opendataframework-0.3.0}/pyproject.toml +2 -2
- {opendataframework-0.1.0 → opendataframework-0.3.0}/src/opendataframework/__init__.py +9 -0
- opendataframework-0.3.0/src/opendataframework/agent.py +101 -0
- {opendataframework-0.1.0 → opendataframework-0.3.0}/src/opendataframework/component.py +115 -2
- {opendataframework-0.1.0 → opendataframework-0.3.0}/src/opendataframework/config.py +42 -13
- {opendataframework-0.1.0 → opendataframework-0.3.0}/src/opendataframework/context.py +571 -143
- {opendataframework-0.1.0 → opendataframework-0.3.0}/src/opendataframework/entity.py +2 -2
- opendataframework-0.3.0/src/opendataframework/function.py +245 -0
- opendataframework-0.3.0/src/opendataframework/layer.py +155 -0
- {opendataframework-0.1.0 → opendataframework-0.3.0}/src/opendataframework/logger.py +5 -4
- opendataframework-0.3.0/src/opendataframework/namespace.py +349 -0
- {opendataframework-0.1.0 → opendataframework-0.3.0}/src/opendataframework/pipeline.py +2 -2
- {opendataframework-0.1.0 → opendataframework-0.3.0}/src/opendataframework/project.py +17 -0
- {opendataframework-0.1.0 → opendataframework-0.3.0}/src/opendataframework/repository.py +33 -2
- {opendataframework-0.1.0 → opendataframework-0.3.0}/src/opendataframework/service.py +2 -2
- {opendataframework-0.1.0 → opendataframework-0.3.0}/src/opendataframework/task.py +2 -2
- {opendataframework-0.1.0 → opendataframework-0.3.0}/src/opendataframework/view.py +55 -2
- opendataframework-0.1.0/src/opendataframework/layer.py +0 -130
- opendataframework-0.1.0/src/opendataframework/namespace.py +0 -175
- {opendataframework-0.1.0 → opendataframework-0.3.0}/LICENSE +0 -0
- {opendataframework-0.1.0 → opendataframework-0.3.0}/src/opendataframework/py.typed +0 -0
- {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.
|
|
4
|
-
Summary: Lightweight
|
|
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`,
|
|
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.
|
|
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`, `
|
|
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)
|
|
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`,
|
|
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.
|
|
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`, `
|
|
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)
|
|
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.
|
|
4
|
-
description = "Lightweight
|
|
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
|
|
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
|
-
|
|
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({"
|
|
36
|
-
cfg.
|
|
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
|
-
|
|
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
|
|
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)
|