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 +1 -0
- agctl/assertion_registry.py +243 -0
- agctl/assertions.py +135 -0
- agctl/cli.py +163 -0
- agctl/clients/__init__.py +0 -0
- agctl/clients/db_client.py +94 -0
- agctl/clients/db_driver_protocol.py +27 -0
- agctl/clients/db_drivers/__init__.py +0 -0
- agctl/clients/db_drivers/postgresql.py +92 -0
- agctl/clients/http_client.py +116 -0
- agctl/clients/kafka_client.py +398 -0
- agctl/command.py +74 -0
- agctl/commands/__init__.py +1 -0
- agctl/commands/check_commands.py +115 -0
- agctl/commands/config_commands.py +175 -0
- agctl/commands/db_commands.py +329 -0
- agctl/commands/discover_commands.py +372 -0
- agctl/commands/http_commands.py +534 -0
- agctl/commands/kafka_commands.py +485 -0
- agctl/config/__init__.py +5 -0
- agctl/config/loader.py +108 -0
- agctl/config/models.py +104 -0
- agctl/config/resolver.py +84 -0
- agctl/config/validator.py +86 -0
- agctl/errors.py +46 -0
- agctl/output.py +25 -0
- agctl/params.py +29 -0
- agctl/plugin_protocol.py +42 -0
- agctl/resolution.py +77 -0
- agctl-0.1.0.dist-info/METADATA +414 -0
- agctl-0.1.0.dist-info/RECORD +34 -0
- agctl-0.1.0.dist-info/WHEEL +4 -0
- agctl-0.1.0.dist-info/entry_points.txt +6 -0
- agctl-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|