opendataframework 0.1.0__tar.gz → 0.2.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 (20) hide show
  1. {opendataframework-0.1.0 → opendataframework-0.2.0}/PKG-INFO +4 -6
  2. {opendataframework-0.1.0 → opendataframework-0.2.0}/README.md +3 -5
  3. {opendataframework-0.1.0 → opendataframework-0.2.0}/pyproject.toml +1 -1
  4. {opendataframework-0.1.0 → opendataframework-0.2.0}/src/opendataframework/component.py +46 -0
  5. {opendataframework-0.1.0 → opendataframework-0.2.0}/src/opendataframework/config.py +15 -8
  6. {opendataframework-0.1.0 → opendataframework-0.2.0}/src/opendataframework/namespace.py +16 -2
  7. {opendataframework-0.1.0 → opendataframework-0.2.0}/src/opendataframework/project.py +17 -0
  8. {opendataframework-0.1.0 → opendataframework-0.2.0}/src/opendataframework/view.py +2 -2
  9. {opendataframework-0.1.0 → opendataframework-0.2.0}/LICENSE +0 -0
  10. {opendataframework-0.1.0 → opendataframework-0.2.0}/src/opendataframework/__init__.py +0 -0
  11. {opendataframework-0.1.0 → opendataframework-0.2.0}/src/opendataframework/context.py +0 -0
  12. {opendataframework-0.1.0 → opendataframework-0.2.0}/src/opendataframework/entity.py +0 -0
  13. {opendataframework-0.1.0 → opendataframework-0.2.0}/src/opendataframework/layer.py +0 -0
  14. {opendataframework-0.1.0 → opendataframework-0.2.0}/src/opendataframework/logger.py +0 -0
  15. {opendataframework-0.1.0 → opendataframework-0.2.0}/src/opendataframework/pipeline.py +0 -0
  16. {opendataframework-0.1.0 → opendataframework-0.2.0}/src/opendataframework/py.typed +0 -0
  17. {opendataframework-0.1.0 → opendataframework-0.2.0}/src/opendataframework/repository.py +0 -0
  18. {opendataframework-0.1.0 → opendataframework-0.2.0}/src/opendataframework/service.py +0 -0
  19. {opendataframework-0.1.0 → opendataframework-0.2.0}/src/opendataframework/task.py +0 -0
  20. {opendataframework-0.1.0 → opendataframework-0.2.0}/src/opendataframework/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opendataframework
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Lightweight, config-driven dependency-injection framework for data applications.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -82,9 +82,7 @@ class User:
82
82
  class SQLite:
83
83
  def __init__(self, config: Config):
84
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
- )
85
+ self.conn.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)")
88
86
 
89
87
 
90
88
  @Repository(User)
@@ -281,7 +279,7 @@ No circular dependencies — restructure with a shared third collaborator
281
279
  instead.
282
280
 
283
281
  ```python
284
- component = project.context.get(UsersApi) # typed, by class
282
+ component = project.context.get(UsersApi) # typed, by class
285
283
  ```
286
284
 
287
285
  ## Project
@@ -334,7 +332,7 @@ Project owns everything.
334
332
  See [`examples/`](examples/README.md) — small, focused projects each
335
333
  isolating one core abstraction.
336
334
 
337
- > For the CLI, UI, MCP server, and chat surface
335
+ > For the CLI, UI, MCP server, and chat surface
338
336
  > built on top of this package, see `odf`.
339
337
 
340
338
  ---
@@ -66,9 +66,7 @@ class User:
66
66
  class SQLite:
67
67
  def __init__(self, config: Config):
68
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
- )
69
+ self.conn.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)")
72
70
 
73
71
 
74
72
  @Repository(User)
@@ -265,7 +263,7 @@ No circular dependencies — restructure with a shared third collaborator
265
263
  instead.
266
264
 
267
265
  ```python
268
- component = project.context.get(UsersApi) # typed, by class
266
+ component = project.context.get(UsersApi) # typed, by class
269
267
  ```
270
268
 
271
269
  ## Project
@@ -318,7 +316,7 @@ Project owns everything.
318
316
  See [`examples/`](examples/README.md) — small, focused projects each
319
317
  isolating one core abstraction.
320
318
 
321
- > For the CLI, UI, MCP server, and chat surface
319
+ > For the CLI, UI, MCP server, and chat surface
322
320
  > built on top of this package, see `odf`.
323
321
 
324
322
  ---
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "opendataframework"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Lightweight, config-driven dependency-injection framework for data applications."
5
5
  authors = [
6
6
  {name = "mykytapavlov",email = "mykytapavlov@users.noreply.github.com"}
@@ -6,6 +6,8 @@ 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
+ from collections.abc import Callable
10
+ from dataclasses import dataclass
9
11
  from typing import Protocol, runtime_checkable
10
12
 
11
13
  from opendataframework.namespace import Namespace
@@ -87,6 +89,50 @@ class ChartProtocol(Protocol):
87
89
  ...
88
90
 
89
91
 
92
+ @dataclass
93
+ class McpTool:
94
+ """One MCP tool a Component exposes, returned from ``mcp_tools()``.
95
+
96
+ Attributes:
97
+ name: The tool's local name — namespaced by whatever builds the
98
+ 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.
102
+ description: Human-readable description shown to the MCP client.
103
+ handler: The bound method implementing the tool call.
104
+ structured_output: Whether the handler's return value should be
105
+ reported as structured (JSON) output. ``None`` defers to
106
+ the MCP server's own default behavior, mirroring how this
107
+ field is expected to be forwarded verbatim by whatever
108
+ registers the tool.
109
+ """
110
+
111
+ name: str
112
+ description: str
113
+ handler: Callable[..., object]
114
+ structured_output: bool | None = None
115
+
116
+
117
+ @runtime_checkable
118
+ class McpToolsProtocol(Protocol):
119
+ """Capability interface for a Component's optional custom MCP tools.
120
+
121
+ Independent of ``DetailsProtocol``/``ChartProtocol``/
122
+ ``OnStartProtocol``/``OnStopProtocol``. Never invoked by ``Context``
123
+ — detected structurally via ``isinstance(instance, McpToolsProtocol)``
124
+ and consumed on demand by whatever builds an MCP tool surface. (Today,
125
+ that's the sibling ``odf`` package's ``McpServer``, which registers
126
+ each returned ``McpTool`` under
127
+ ``<kebab(cls.__name__)>.<tool.name>``, alongside its six fixed
128
+ built-in tools.)
129
+ """
130
+
131
+ def mcp_tools(self) -> list[McpTool]:
132
+ """Return the component's custom MCP tools, in addition to the fixed built-in surface."""
133
+ ...
134
+
135
+
90
136
  class Component(Namespace):
91
137
  """Decorator that registers a class with the Context for dependency injection.
92
138
 
@@ -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
@@ -37,8 +38,14 @@ class Config:
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
+ self._data = deepcopy(data)
42
49
 
43
50
  def __getattr__(self, name: str) -> Config | object:
44
51
  """Return value by snake_case attribute name, normalised to kebab-case.
@@ -60,10 +67,10 @@ class Config:
60
67
  return Config(value) if isinstance(value, dict) else value
61
68
 
62
69
  def __getitem__(self, key: str) -> Config | object:
63
- """Return value by key (any name form accepted).
70
+ """Return value by key, normalised to kebab-case (any name form accepted).
64
71
 
65
72
  Args:
66
- key: Config key in any supported form.
73
+ key: Config key (snake_case, kebab-case, or PascalCase).
67
74
 
68
75
  Returns:
69
76
  A ``Config`` wrapping the value if it is a dict, otherwise the
@@ -73,13 +80,13 @@ class Config:
73
80
  KeyError: If the key is not present.
74
81
  """
75
82
  try:
76
- value = self._data[key]
83
+ value = self._data[normalize(key)]
77
84
  except KeyError as err:
78
85
  raise KeyError(key) from err
79
86
  return Config(value) if isinstance(value, dict) else value
80
87
 
81
88
  def get(self, key: str, default: object = None) -> Config | object | None:
82
- """Return value by key, or ``default`` if absent.
89
+ """Return value by key (any name form accepted), or ``default`` if absent.
83
90
 
84
91
  If ``default`` is not given and the key is absent, an empty
85
92
  ``Config`` is returned instead of ``None``, so callers can safely
@@ -91,7 +98,7 @@ class Config:
91
98
  port = cfg.get("port", 5432) # safe fallback
92
99
 
93
100
  Args:
94
- key: Config key.
101
+ key: Config key (snake_case, kebab-case, or PascalCase).
95
102
  default: Fallback value when the key is not present.
96
103
 
97
104
  Returns:
@@ -100,7 +107,7 @@ class Config:
100
107
  or an empty ``Config`` if the key is absent and no default was
101
108
  given.
102
109
  """
103
- value = self._data.get(key)
110
+ value = self._data.get(normalize(key))
104
111
  if value is None:
105
112
  return default if default is not None else Config({})
106
113
  return Config(value) if isinstance(value, dict) else value
@@ -94,7 +94,14 @@ class Namespace:
94
94
  # reveal_type(Classifier) -> type[Classifier], from __call__ below
95
95
  """
96
96
  if isinstance(_, type):
97
- cls._namespace[kebab(_.__name__)] = _
97
+ key = kebab(_.__name__)
98
+ if key in cls._namespace:
99
+ existing = cls._namespace[key]
100
+ raise ValueError(
101
+ f"Duplicate registration for {key!r} in {cls.__name__}: "
102
+ f"{existing.__name__} and {_.__name__}"
103
+ )
104
+ cls._namespace[key] = _
98
105
  return _
99
106
  return super().__new__(cls)
100
107
 
@@ -129,7 +136,14 @@ class Namespace:
129
136
  Returns:
130
137
  The original class, unmodified.
131
138
  """
132
- self.__class__._namespace[self._name or kebab(wrapped.__name__)] = wrapped
139
+ key = self._name or kebab(wrapped.__name__)
140
+ if key in self.__class__._namespace:
141
+ existing = self.__class__._namespace[key]
142
+ raise ValueError(
143
+ f"Duplicate registration for {key!r} in {self.__class__.__name__}: "
144
+ f"{existing.__name__} and {wrapped.__name__}"
145
+ )
146
+ self.__class__._namespace[key] = wrapped
133
147
  return wrapped
134
148
 
135
149
  @classmethod
@@ -5,6 +5,8 @@ pure DI lifecycle boundary. It has no knowledge of any UI/CLI/MCP layer
5
5
  — those are composed on top of it by consuming packages (e.g. ``odf``).
6
6
  """
7
7
 
8
+ import importlib
9
+
8
10
  from opendataframework.config import load as load_config
9
11
  from opendataframework.context import Context
10
12
 
@@ -143,6 +145,14 @@ class Project:
143
145
  (default ``"logs"``, relative to the current working directory) —
144
146
  see ``opendataframework.logger.LogManager``.
145
147
 
148
+ Imports the module named under ``[project] app`` (if present)
149
+ before constructing the ``Context`` — equivalent to writing
150
+ ``import app`` by hand before calling ``from_config``/``from_dict``.
151
+ That module's own imports (e.g. an ``app/__init__.py`` that imports
152
+ ``app.components``, ``app.repositories``, ...) are what actually
153
+ register framework-decorated classes; this just ensures the root
154
+ import happens before resolution needs it.
155
+
146
156
  Args:
147
157
  config: Mapping of configuration values, equivalent to what
148
158
  ``from_config`` would parse from a ``.toml`` file.
@@ -150,6 +160,10 @@ class Project:
150
160
  Returns:
151
161
  A new ``Project`` configured from the given dict.
152
162
 
163
+ Raises:
164
+ ImportError: If the module named under ``[project] app`` cannot
165
+ be imported.
166
+
153
167
  Example:
154
168
  ::
155
169
 
@@ -159,5 +173,8 @@ class Project:
159
173
  }
160
174
  })
161
175
  """
176
+ app = config.get("project", {}).get("app")
177
+ if app is not None:
178
+ importlib.import_module(app)
162
179
  log_dir = config.get("project", {}).get("log-dir", "logs")
163
180
  return cls(context=Context(config=config, log_dir=log_dir), config=config)
@@ -178,12 +178,12 @@ class ReplayProtocol(Protocol):
178
178
  ``ChartProtocol``/``DetailsProtocol`` (``opendataframework.component``) and
179
179
  ``DataViewProtocol`` above. (Today, the sibling ``odf`` package's UI is
180
180
  what offers a timeline scrubber for this.) A repository with no
181
- ``replay_field()`` gets no such affordance — this only matters for
181
+ ``field()`` gets no such affordance — this only matters for
182
182
  ``LocationView``/``VideoView``/``AudioView``-backed repositories; a
183
183
  ``TimeseriesView`` repository is always replayable since it already
184
184
  names its own timestamp via ``field``.
185
185
  """
186
186
 
187
- def replay_field(self) -> str:
187
+ def field(self) -> str:
188
188
  """Return the entity's timestamp field records can be scrubbed through over time by."""
189
189
  ...