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.
- {opendataframework-0.2.0 → opendataframework-0.3.0}/PKG-INFO +58 -9
- {opendataframework-0.2.0 → opendataframework-0.3.0}/README.md +56 -7
- {opendataframework-0.2.0 → opendataframework-0.3.0}/pyproject.toml +2 -2
- {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/__init__.py +9 -0
- opendataframework-0.3.0/src/opendataframework/agent.py +101 -0
- {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/component.py +73 -6
- {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/config.py +28 -6
- {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/context.py +571 -143
- {opendataframework-0.2.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.2.0 → opendataframework-0.3.0}/src/opendataframework/logger.py +5 -4
- opendataframework-0.3.0/src/opendataframework/namespace.py +349 -0
- {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/pipeline.py +2 -2
- {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/repository.py +33 -2
- {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/service.py +2 -2
- {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/task.py +2 -2
- {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/view.py +53 -0
- opendataframework-0.2.0/src/opendataframework/layer.py +0 -130
- opendataframework-0.2.0/src/opendataframework/namespace.py +0 -189
- {opendataframework-0.2.0 → opendataframework-0.3.0}/LICENSE +0 -0
- {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/project.py +0 -0
- {opendataframework-0.2.0 → opendataframework-0.3.0}/src/opendataframework/py.typed +0 -0
- {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.
|
|
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,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.
|
|
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`, `
|
|
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`,
|
|
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.
|
|
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`, `
|
|
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.
|
|
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,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
|
|
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
|
|
100
|
-
|
|
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
|
-
|
|
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(
|
|
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({"
|
|
37
|
-
cfg.
|
|
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)
|