agctl 0.1.0__py3-none-any.whl

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.
agctl/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """agctl — agent-facing CLI harness for testing distributed systems."""
@@ -0,0 +1,243 @@
1
+ """Pluggable assertion-mode registry (DESIGN §9.3).
2
+
3
+ New assertion modes for ``db assert`` / ``kafka assert`` are added by
4
+ subclassing :class:`Assertion` and registering an instance (or the class) with
5
+ an :class:`AssertionRegistry` keyed by its ``.name``. Third-party modes are
6
+ discovered via the ``agctl.assertions`` entry-point group.
7
+
8
+ This module is PRAGMATIC: it provides the extension point + discovery. The
9
+ built-in ``db assert`` / ``kafka assert`` command logic continues to live in
10
+ the command modules (``db_commands.py`` / ``kafka_commands.py``) — those modes
11
+ are also registered here by NAME so that:
12
+
13
+ (a) known built-in mode names are discoverable (``registry.names()``),
14
+ (b) an unknown mode resolves to a clear :class:`TemplateNotFound` error, and
15
+ (c) third-party entry points can extend the set of available modes.
16
+
17
+ The registry guarantees ``registry.get(name)`` returns an :class:`Assertion`
18
+ instance for any registered mode; calling ``evaluate`` on a built-in mode
19
+ raises :class:`NotImplementedError` because the real logic is in the command
20
+ layer.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import importlib.metadata
26
+ from typing import Any, Iterable, Union
27
+
28
+ from .errors import TemplateNotFound
29
+
30
+ #: Entry-point group for third-party assertion modes (DESIGN §9.3).
31
+ ASSERTION_ENTRY_POINT_GROUP = "agctl.assertions"
32
+
33
+
34
+ def _entry_points(group: str) -> list:
35
+ """Return entry points registered under ``group`` (3.11+ shim).
36
+
37
+ Mirrors :func:`agctl.cli._entry_points`. Factored out so tests can
38
+ monkeypatch discovery. Returns ``[]`` on any failure.
39
+ """
40
+ try:
41
+ eps = importlib.metadata.entry_points()
42
+ if hasattr(eps, "select"):
43
+ return list(eps.select(group=group))
44
+ return list(eps.get(group, []))
45
+ except Exception:
46
+ return []
47
+
48
+
49
+ class Assertion:
50
+ """Base class for pluggable assertion modes (DESIGN §9.3).
51
+
52
+ Subclasses set :attr:`name` (the mode key, e.g. ``'expect_rows'`` or a
53
+ third-party ``'json_schema'``) and implement :meth:`evaluate`.
54
+
55
+ ``evaluate`` receives a free-form ``context`` dict and returns a result
56
+ dict with at least ``{"passed": bool}`` plus any assertion-specific
57
+ fields. Either raise :class:`AssertionFailure` on failure (the command
58
+ layer translates that) or return ``{"passed": False, ...}`` and let the
59
+ command raise. Keep the contract simple: ``evaluate`` returns a dict;
60
+ ``passed=False`` means fail.
61
+ """
62
+
63
+ name: str = ""
64
+
65
+ def evaluate(self, context: dict) -> dict: # pragma: no cover - interface
66
+ raise NotImplementedError
67
+
68
+
69
+ class _BuiltInMode(Assertion):
70
+ """Marker subclass for built-in modes.
71
+
72
+ Built-in modes are implemented inline in the command layer; calling
73
+ ``evaluate`` on one raises so a caller can't accidentally bypass the
74
+ command's validated path. The registry registers these by NAME purely for
75
+ discovery and unknown-mode rejection.
76
+ """
77
+
78
+ def evaluate(self, context: dict) -> dict:
79
+ raise NotImplementedError(
80
+ f"built-in mode '{self.name}' is implemented in the command layer"
81
+ )
82
+
83
+
84
+ # The built-in mode names registered in the default registry.
85
+ _BUILT_IN_MODES: tuple[str, ...] = (
86
+ "expect_rows",
87
+ "expect_value",
88
+ "contains",
89
+ "match",
90
+ "pattern",
91
+ )
92
+
93
+
94
+ class AssertionRegistry:
95
+ """Registry of assertion modes keyed by :attr:`Assertion.name`."""
96
+
97
+ def __init__(self) -> None:
98
+ self._modes: dict[str, Assertion] = {}
99
+
100
+ def register(
101
+ self, assertion_cls_or_instance: Union[type, Assertion]
102
+ ) -> None:
103
+ """Register an :class:`Assertion` subclass or instance, keyed by ``.name``.
104
+
105
+ A class is instantiated once; an instance is used as-is. A blank
106
+ ``.name`` is ignored (no-op) so a misconfigured entry point can't
107
+ shadow real modes.
108
+ """
109
+ if isinstance(assertion_cls_or_instance, Assertion):
110
+ instance = assertion_cls_or_instance
111
+ elif isinstance(assertion_cls_or_instance, type) and issubclass(
112
+ assertion_cls_or_instance, Assertion
113
+ ):
114
+ instance = assertion_cls_or_instance()
115
+ else:
116
+ # Not an Assertion at all: ignore rather than corrupt the registry.
117
+ return
118
+ name = getattr(instance, "name", "") or ""
119
+ if not name:
120
+ return
121
+ self._modes[name] = instance
122
+
123
+ def get(self, name: str) -> Assertion:
124
+ """Resolve a mode by name.
125
+
126
+ Raises :class:`TemplateNotFound` (DESIGN §4.1) for unknown modes so the
127
+ command layer's existing error handling maps it to a clean envelope.
128
+ """
129
+ try:
130
+ return self._modes[name]
131
+ except KeyError:
132
+ raise TemplateNotFound(
133
+ f"Unknown assertion mode: {name}", {"mode": name}
134
+ )
135
+
136
+ def names(self) -> list[str]:
137
+ """Sorted list of registered mode names."""
138
+ return sorted(self._modes)
139
+
140
+ def load_entry_points(self) -> "AssertionRegistry":
141
+ """Load third-party modes from the ``agctl.assertions`` group.
142
+
143
+ Each load is wrapped in try/except so a single broken entry point is
144
+ skipped (logged to stderr) rather than bricking the registry. Returns
145
+ ``self`` for chaining.
146
+ """
147
+ import sys
148
+
149
+ for ep in _entry_points(ASSERTION_ENTRY_POINT_GROUP):
150
+ try:
151
+ obj = ep.load()
152
+ except Exception as exc: # noqa: BLE001 - entry-point isolation
153
+ print(
154
+ f"agctl: failed to load assertion mode {ep.name}: {exc}",
155
+ file=sys.stderr,
156
+ )
157
+ continue
158
+ self.register(obj)
159
+ return self
160
+
161
+
162
+ # --- module-level default registry -----------------------------------------
163
+
164
+
165
+ def _default_registry() -> AssertionRegistry:
166
+ """Build a fresh registry with the built-in modes registered.
167
+
168
+ Does NOT load entry points — entry-point loading happens once in
169
+ :func:`get_default_registry` so the cached registry reflects the real
170
+ environment.
171
+ """
172
+ reg = AssertionRegistry()
173
+ for mode_name in _BUILT_IN_MODES:
174
+ # A distinct subclass per name keeps each registered instance's
175
+ # ``.name`` accurate for introspection.
176
+ cls = type(f"_BuiltIn_{mode_name}", (_BuiltInMode,), {"name": mode_name})
177
+ reg.register(cls)
178
+ return reg
179
+
180
+
181
+ _DEFAULT_REGISTRY: AssertionRegistry | None = None
182
+
183
+
184
+ def get_default_registry() -> AssertionRegistry:
185
+ """Return the cached default registry (built-ins + loaded entry points).
186
+
187
+ Built once on first call; subsequent calls return the same instance so
188
+ entry-point loading is not repeated.
189
+ """
190
+ global _DEFAULT_REGISTRY
191
+ if _DEFAULT_REGISTRY is None:
192
+ reg = _default_registry()
193
+ reg.load_entry_points()
194
+ _DEFAULT_REGISTRY = reg
195
+ return _DEFAULT_REGISTRY
196
+
197
+
198
+ def evaluate_custom(name: str, context: dict, registry: "AssertionRegistry | None" = None):
199
+ """Resolve ``name`` and run its ``evaluate(context)`` (DESIGN §9.3).
200
+
201
+ This is the bridge that makes third-party ``agctl.assertions`` modes
202
+ reachable from ``db assert --assertion <name>`` / ``kafka assert --assertion
203
+ <name>``. ``context`` is a free-form dict the command builds for the mode
204
+ (rows/messages + metadata).
205
+
206
+ Returns ``(True, detail)`` on success. Raises:
207
+ - :class:`TemplateNotFound` (exit 2) for an unknown mode name.
208
+ - :class:`ConfigError` (exit 2) if ``name`` is a built-in mode (those have
209
+ dedicated flags and intentionally raise NotImplementedError from
210
+ ``evaluate``).
211
+ - :class:`AssertionFailure` (exit 1) if the mode returns ``passed=False``
212
+ or itself raises.
213
+
214
+ ``registry`` defaults to :func:`get_default_registry` (monkeypatchable).
215
+ """
216
+ from .errors import AssertionFailure, ConfigError
217
+
218
+ reg = registry if registry is not None else get_default_registry()
219
+ mode = reg.get(name) # TemplateNotFound for unknown name
220
+ try:
221
+ result = mode.evaluate(context)
222
+ except AssertionFailure:
223
+ raise
224
+ except NotImplementedError:
225
+ raise ConfigError(
226
+ f"Built-in assertion mode '{name}' has dedicated flags; "
227
+ "do not invoke it via --assertion",
228
+ {"mode": name},
229
+ )
230
+ except Exception as exc: # noqa: BLE001 - third-party mode isolation
231
+ raise AssertionFailure(f"assertion '{name}' raised: {exc}", {"mode": name})
232
+
233
+ if not isinstance(result, dict):
234
+ raise AssertionFailure(
235
+ f"assertion '{name}' returned a non-dict result", {"mode": name}
236
+ )
237
+ detail = {k: v for k, v in result.items() if k != "passed"}
238
+ if not result.get("passed"):
239
+ raise AssertionFailure(
240
+ result.get("message") or f"assertion '{name}' did not pass",
241
+ {"mode": name, **detail},
242
+ )
243
+ return True, detail
agctl/assertions.py ADDED
@@ -0,0 +1,135 @@
1
+ """Assertion primitives backing Kafka/DB checks.
2
+
3
+ DESIGN §3.2 (jq predicate/value evaluation, silent skip on error),
4
+ and D8 (equals coercion + type-aware comparison rules).
5
+ """
6
+
7
+ import datetime
8
+ import decimal
9
+ import json
10
+ import uuid
11
+
12
+ from .errors import ConfigError
13
+
14
+
15
+ def _jq():
16
+ """Lazily import jq so the package imports without the optional extra.
17
+ jq is only needed for match/path assertions (Kafka --match/--path, DB --path).
18
+ A missing library is a configuration problem -> ConfigError (exit 2), distinct
19
+ from a jq *expression* error (handled by callers as a silent skip per §3.2)."""
20
+ try:
21
+ import jq
22
+ except ImportError as exc: # pragma: no cover - exercised via sys.modules in tests
23
+ raise ConfigError(
24
+ "jq is required for match/path assertions: pip install 'agctl[db]' or 'agctl[kafka]'",
25
+ {},
26
+ ) from exc
27
+ return jq
28
+
29
+
30
+ def jq_bool(value, expr: str) -> bool:
31
+ """Evaluate a jq predicate against value; True only if the result is truthy.
32
+
33
+ A jq compile/runtime error OR a falsy/empty result -> False (silently skipped
34
+ per DESIGN §3.2). A missing jq library -> ConfigError (propagates, exit 2).
35
+ """
36
+ try:
37
+ outputs = _jq().compile(expr).input(value).all()
38
+ except ConfigError:
39
+ raise
40
+ except Exception:
41
+ return False
42
+ return any(bool(o) for o in outputs)
43
+
44
+
45
+ def jq_value(value, expr: str):
46
+ """Evaluate a jq path/value expression (e.g. '.status'). Returns the first
47
+ output value, or None if the expression errors or yields nothing. A missing jq
48
+ library -> ConfigError (propagates, exit 2)."""
49
+ try:
50
+ outputs = _jq().compile(expr).input(value).all()
51
+ except ConfigError:
52
+ raise
53
+ except Exception:
54
+ return None
55
+ if not outputs:
56
+ return None
57
+ return outputs[0]
58
+
59
+
60
+ def json_subset(needle, haystack) -> bool:
61
+ """DESIGN --contains: True if every key/element in needle is present-and-equal
62
+ in haystack, recursively for nested dict/list. Subset, not equality.
63
+
64
+ - dict needle: every key in needle must exist in haystack with a
65
+ json_subset-equal value.
66
+ - list needle: every element of needle must be json_subset-matched by SOME
67
+ element of haystack (order-independent).
68
+ - scalar needle: needle == haystack.
69
+ """
70
+ if isinstance(needle, dict):
71
+ if not isinstance(haystack, dict):
72
+ return False
73
+ return all(
74
+ k in haystack and json_subset(v, haystack[k]) for k, v in needle.items()
75
+ )
76
+ if isinstance(needle, list):
77
+ if not isinstance(haystack, list):
78
+ return False
79
+ # each needle element must match at least one haystack element
80
+ return all(
81
+ any(json_subset(n, h) for h in haystack) for n in needle
82
+ )
83
+ # scalar
84
+ return needle == haystack
85
+
86
+
87
+ def parse_equals(text: str):
88
+ """DESIGN D8 step 1-2: try json.loads(text); if it parses, return the typed
89
+ value ('0'->int 0, 'true'->bool True, 'null'->None, '[1,2]'->[1,2]); else
90
+ return the raw string."""
91
+ try:
92
+ return json.loads(text)
93
+ except (json.JSONDecodeError, ValueError):
94
+ return text
95
+
96
+
97
+ def coerce_db_value(value):
98
+ """DESIGN D8 step 3: coerce a DB cell to a JSON-native type before comparison.
99
+
100
+ - None -> None
101
+ - bool -> bool (checked BEFORE int; bool is a subclass of int in Python)
102
+ - decimal.Decimal -> int if integral else float
103
+ - datetime.datetime/datetime.date/datetime.time -> .isoformat()
104
+ - uuid.UUID -> str(value)
105
+ - int/float/str -> unchanged
106
+ - everything else -> unchanged
107
+ """
108
+ if value is None:
109
+ return None
110
+ # bool MUST be checked before int (bool subclasses int)
111
+ if isinstance(value, bool):
112
+ return value
113
+ if isinstance(value, decimal.Decimal):
114
+ if value % 1 == 0:
115
+ return int(value)
116
+ return float(value)
117
+ if isinstance(value, (datetime.datetime, datetime.date, datetime.time)):
118
+ return value.isoformat()
119
+ if isinstance(value, uuid.UUID):
120
+ return str(value)
121
+ return value
122
+
123
+
124
+ def type_aware_equal(expected, actual) -> bool:
125
+ """DESIGN D8 step 4: strict, type-aware equality. 0 != '0' (number vs string).
126
+
127
+ A number never equals a string of the same digits. Otherwise compares with ==,
128
+ recursing element-wise for dict/list.
129
+ """
130
+ # number-vs-string mismatch (in either order) -> never equal
131
+ if isinstance(expected, (int, float, decimal.Decimal, bool)) and isinstance(actual, str):
132
+ return False
133
+ if isinstance(actual, (int, float, decimal.Decimal, bool)) and isinstance(expected, str):
134
+ return False
135
+ return expected == actual
agctl/cli.py ADDED
@@ -0,0 +1,163 @@
1
+ """Click entry point (DESIGN §3, §7). Wires command groups and emits envelopes.
2
+
3
+ The ``config`` command group lives in :mod:`agctl.commands.config_commands`;
4
+ plugin loading (``_load_plugins`` / ``_LOADED_PLUGINS``) stays here because it
5
+ is a CLI-bootstrap concern. Config validation reads the live plugin list via a
6
+ thunk injected into ``config_commands`` (see :func:`set_plugins_provider`), so
7
+ the dependency runs one way: ``cli → config_commands``.
8
+ """
9
+
10
+ import importlib.metadata
11
+ import sys
12
+ from typing import Any
13
+
14
+ import click
15
+
16
+ from .commands.check_commands import check_ready
17
+ from .commands.config_commands import (
18
+ config_show,
19
+ config_validate,
20
+ set_plugins_provider,
21
+ )
22
+ from .commands.db_commands import db_assert, db_query
23
+ from .commands.discover_commands import discover
24
+ from .commands.http_commands import http_call, http_ping, http_request
25
+ from .commands.kafka_commands import kafka_assert, kafka_consume, kafka_produce
26
+
27
+ #: Entry-point group for third-party protocol plugins (DESIGN §9.2).
28
+ PLUGIN_ENTRY_POINT_GROUP = "agctl.plugins"
29
+
30
+ #: Plugins successfully loaded at import time (DESIGN §9.2). Populated by
31
+ #: :func:`_load_plugins`; read by ``agctl config validate`` via the thunk passed
32
+ #: to :func:`agctl.commands.config_commands.set_plugins_provider` so each plugin
33
+ #: can validate its own config section.
34
+ _LOADED_PLUGINS: list[Any] = []
35
+
36
+
37
+ def _entry_points(group: str) -> list:
38
+ """Return the entry points registered under ``group`` (3.11+ shim).
39
+
40
+ Factored out so tests can monkeypatch discovery. Returns ``[]`` on any
41
+ failure (a broken importlib state must never crash the CLI).
42
+ """
43
+ try:
44
+ eps = importlib.metadata.entry_points()
45
+ if hasattr(eps, "select"):
46
+ return list(eps.select(group=group))
47
+ return list(eps.get(group, []))
48
+ except Exception:
49
+ return []
50
+
51
+
52
+ def _load_plugins(cli_group: click.Group) -> None:
53
+ """Load ``agctl.plugins`` entry points onto ``cli_group`` (DESIGN §9.2).
54
+
55
+ Each plugin object exposes ``.command_group`` (a :class:`click.Group`) and
56
+ optionally ``.name`` (subcommand name) and ``.validate_config(config)``
57
+ (consulted by ``agctl config validate`` via the provider set below). Each
58
+ load+register is wrapped in try/except so a broken plugin logs to stderr
59
+ and is skipped rather than bricking the CLI. An empty/missing
60
+ ``agctl.plugins`` group is a clean no-op. Successfully loaded plugins are
61
+ recorded in ``_LOADED_PLUGINS`` so config validation can delegate to them.
62
+ """
63
+ global _LOADED_PLUGINS
64
+ _LOADED_PLUGINS = []
65
+ try:
66
+ for ep in _entry_points(PLUGIN_ENTRY_POINT_GROUP):
67
+ try:
68
+ obj = ep.load()
69
+ except Exception as exc: # noqa: BLE001 - plugin isolation
70
+ print(f"agctl: failed to load plugin {ep.name}: {exc}", file=sys.stderr)
71
+ continue
72
+ command_group = getattr(obj, "command_group", None)
73
+ if isinstance(command_group, click.Group):
74
+ # DESIGN §9.2: `.name` is the subcommand name; fall back to the
75
+ # group's own name, then the entry-point name.
76
+ name = getattr(obj, "name", None) or command_group.name or ep.name
77
+ cli_group.add_command(command_group, name=name)
78
+ _LOADED_PLUGINS.append(obj)
79
+ else:
80
+ print(
81
+ f"agctl: plugin {ep.name} has no valid command_group; skipping",
82
+ file=sys.stderr,
83
+ )
84
+ except Exception as exc: # noqa: BLE001 - never let the loader crash the CLI
85
+ print(f"agctl: plugin loader error: {exc}", file=sys.stderr)
86
+
87
+
88
+ @click.group()
89
+ @click.option("--config", "config_path", default=None, help="Path to agctl.yaml")
90
+ @click.pass_context
91
+ def cli(ctx: click.Context, config_path: str | None) -> None:
92
+ """agctl — agent-facing CLI harness for testing distributed systems."""
93
+ ctx.ensure_object(dict)
94
+ ctx.obj["config_path"] = config_path
95
+
96
+
97
+ @cli.group(name="config")
98
+ def config_group() -> None:
99
+ """Config introspection."""
100
+
101
+
102
+ @cli.group(name="http")
103
+ def http_group() -> None:
104
+ """HTTP request commands."""
105
+
106
+
107
+ @cli.group(name="db")
108
+ def db_group() -> None:
109
+ """Database query/assert commands."""
110
+
111
+
112
+ @cli.group(name="kafka")
113
+ def kafka_group() -> None:
114
+ """Kafka produce/consume/assert commands."""
115
+
116
+
117
+ @cli.group(name="check")
118
+ def check_group() -> None:
119
+ """Health/readiness checks."""
120
+
121
+
122
+ # Register subcommands on the config group (commands live in config_commands.py).
123
+ config_group.add_command(config_validate)
124
+ config_group.add_command(config_show)
125
+
126
+ # Register subcommands on the http group.
127
+ http_group.add_command(http_call)
128
+ http_group.add_command(http_request)
129
+ http_group.add_command(http_ping)
130
+
131
+
132
+ # Register subcommands on the db group.
133
+ db_group.add_command(db_query)
134
+ db_group.add_command(db_assert)
135
+
136
+
137
+ # Register subcommands on the kafka group.
138
+ kafka_group.add_command(kafka_produce)
139
+ kafka_group.add_command(kafka_consume)
140
+ kafka_group.add_command(kafka_assert)
141
+
142
+
143
+ # Register subcommands on the check group.
144
+ check_group.add_command(check_ready)
145
+
146
+
147
+ # Register the top-level `discover` command directly on the root group.
148
+ cli.add_command(discover)
149
+
150
+
151
+ # Bridge config validation to the live loaded-plugins list. The thunk reads this
152
+ # module's ``_LOADED_PLUGINS`` global at call time, so it stays correct even after
153
+ # :func:`_load_plugins` reassigns that global.
154
+ set_plugins_provider(lambda: _LOADED_PLUGINS)
155
+
156
+
157
+ # Load third-party protocol plugins (DESIGN §9.2). A clean no-op today since no
158
+ # plugins are registered; guarded so a broken plugin/importlib never bricks the CLI.
159
+ _load_plugins(cli)
160
+
161
+
162
+ if __name__ == "__main__":
163
+ cli()
File without changes
@@ -0,0 +1,94 @@
1
+ """DbClient with entry-point driver dispatch (DESIGN §9.1).
2
+
3
+ Selects a :class:`DBDriver` implementation by the connection's ``type`` field,
4
+ discovering third-party drivers via the ``agctl.db_drivers`` entry-point group
5
+ while always falling back to the built-in ``postgresql`` driver.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib.metadata
11
+ from typing import Any
12
+
13
+ from ..errors import ConfigError
14
+ from .db_drivers.postgresql import PostgreSQLDriver
15
+
16
+ #: Entry-point group used to discover third-party DB drivers.
17
+ DB_DRIVER_ENTRY_POINT_GROUP = "agctl.db_drivers"
18
+
19
+ #: Built-in drivers always available even without entry-point registration.
20
+ BUILTIN_DRIVERS: dict[str, type] = {"postgresql": PostgreSQLDriver}
21
+
22
+
23
+ class DbClient:
24
+ """High-level database client that delegates to a discovered driver.
25
+
26
+ The driver is selected by ``connection["type"]``:
27
+
28
+ - If ``driver`` is injected (DI), it is used directly and no lookup occurs.
29
+ - Otherwise the driver class is looked up in ``drivers`` (or
30
+ :meth:`load_drivers` by default) and instantiated.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ connection: Any,
36
+ *,
37
+ driver: Any = None,
38
+ drivers: dict[str, type] | None = None,
39
+ ) -> None:
40
+ # Normalize the connection into a plain dict (pydantic models expose
41
+ # model_dump(); plain dicts pass through unchanged).
42
+ self._conn_dict = getattr(connection, "model_dump", lambda: connection)()
43
+
44
+ if driver is not None:
45
+ self._driver = driver
46
+ else:
47
+ available = drivers if drivers is not None else self.load_drivers()
48
+ db_type = self._conn_dict.get("type")
49
+ if not db_type or db_type not in available:
50
+ raise ConfigError(
51
+ f"Unknown database type: {db_type}", {"type": db_type}
52
+ )
53
+ driver_class = available[db_type]
54
+ self._driver = driver_class()
55
+
56
+ @classmethod
57
+ def load_drivers(cls) -> dict[str, type]:
58
+ """Discover DB drivers via entry points, merging built-ins.
59
+
60
+ Returns a ``{type_name: driver_class}`` mapping. The built-in
61
+ ``postgresql`` driver is always present. Broken third-party drivers
62
+ (``.load()`` raising) are skipped rather than crashing discovery.
63
+ """
64
+ drivers: dict[str, type] = {}
65
+ try:
66
+ eps = importlib.metadata.entry_points()
67
+ group = (
68
+ eps.select(group=DB_DRIVER_ENTRY_POINT_GROUP)
69
+ if hasattr(eps, "select")
70
+ else eps.get(DB_DRIVER_ENTRY_POINT_GROUP, [])
71
+ )
72
+ except Exception: # pragma: no cover - defensive; shouldn't happen
73
+ group = []
74
+
75
+ for ep in group:
76
+ try:
77
+ driver_class = ep.load()
78
+ except Exception:
79
+ # A broken third-party driver must not break discovery.
80
+ continue
81
+ drivers[ep.name] = driver_class
82
+
83
+ # Built-ins are always available and win over any registration gaps.
84
+ drivers.update(BUILTIN_DRIVERS)
85
+ return drivers
86
+
87
+ def connect(self) -> None:
88
+ self._driver.connect(self._conn_dict)
89
+
90
+ def execute(self, sql: str, params: dict) -> list[dict]:
91
+ return self._driver.execute(sql, params)
92
+
93
+ def close(self) -> None:
94
+ self._driver.close()
@@ -0,0 +1,27 @@
1
+ """DBDriver protocol (DESIGN §9.1).
2
+
3
+ A minimal structural protocol describing the contract every database driver
4
+ must satisfy. Drivers are registered as entry points (``agctl.db_drivers``)
5
+ and selected by the ``DbClient`` based on the connection's ``type`` config field.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Protocol, runtime_checkable
11
+
12
+
13
+ @runtime_checkable
14
+ class DBDriver(Protocol):
15
+ """Structural contract for a database driver.
16
+
17
+ - :meth:`connect` opens (or retains an injected) connection from config.
18
+ - :meth:`execute` runs a read-only SQL statement with named params and
19
+ returns a list of dict rows (column name -> coerced value).
20
+ - :meth:`close` releases the connection if the driver owns it.
21
+ """
22
+
23
+ def connect(self, config: dict) -> None: ...
24
+
25
+ def execute(self, sql: str, params: dict) -> list[dict[str, Any]]: ...
26
+
27
+ def close(self) -> None: ...
File without changes