agentixx 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.
agentix/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """agentix — typed remote calls for sandboxed Python modules.
2
+
3
+ Integration wheels may contribute modules under `agentix.<short>`
4
+ (e.g. `agentix.bash`). Extending `agentix.__path__` lets those modules
5
+ co-exist with the framework modules in this package.
6
+ """
7
+
8
+ import pkgutil
9
+
10
+ __path__ = pkgutil.extend_path(__path__, __name__)
11
+
12
+ from agentix.runtime.client import RemoteCallError, RuntimeClient
13
+ from agentix.runtime.shared.rpc import Bidi, Channel, RemoteCall, Stream, Unary
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ __all__ = [
18
+ "Bidi",
19
+ "Channel",
20
+ "RemoteCall",
21
+ "RemoteCallError",
22
+ "RuntimeClient",
23
+ "Stream",
24
+ "Unary",
25
+ "__version__",
26
+ ]
@@ -0,0 +1,70 @@
1
+ """`agentix` command-line interface.
2
+
3
+ The core CLI intentionally stays narrow: `agentix build` packages a
4
+ project into a bundle image. Other workflows should expose their own
5
+ `console_scripts` entry instead of expanding the central CLI.
6
+
7
+ The CLI deliberately doesn't use argparse subparsers — argparse
8
+ intercepts `--help` greedily at the root level, so `agentix build --help`
9
+ would never reach `build`'s parser. Manual routing keeps each
10
+ subcommand's `--help` intact.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import importlib
16
+ import inspect
17
+ import sys
18
+ from collections.abc import Callable, Sequence
19
+
20
+ # Built-in subcommands. Each value names the submodule under
21
+ # `agentix.cli` whose `main(argv)` handles the verb.
22
+ _COMMANDS: tuple[tuple[str, str], ...] = (
23
+ ("build", "agentix.cli.build"),
24
+ )
25
+
26
+
27
+ def _first_doc_line(obj: object) -> str:
28
+ """First non-empty line of an object's docstring, or empty."""
29
+ doc = inspect.getdoc(obj) or ""
30
+ return next((line.strip() for line in doc.splitlines() if line.strip()), "")
31
+
32
+
33
+ def _load(module_name: str) -> Callable[[list[str]], int]:
34
+ return importlib.import_module(module_name).main # type: ignore[no-any-return]
35
+
36
+
37
+ def _describe(module_name: str) -> str:
38
+ """Description for help output. main() docstring → module docstring → empty."""
39
+ mod = importlib.import_module(module_name)
40
+ desc = _first_doc_line(getattr(mod, "main", None))
41
+ return desc or _first_doc_line(mod)
42
+
43
+
44
+ def _print_root_help() -> None:
45
+ print("usage: agentix <command> [args...]\n")
46
+ print("Agentix developer CLI\n")
47
+ print("commands:")
48
+ width = max(len(name) for name, _ in _COMMANDS) + 2
49
+ for name, mod in _COMMANDS:
50
+ print(f" {name.ljust(width)}{_describe(mod)}")
51
+ print("\nRun `agentix <command> --help` for command-specific options.")
52
+
53
+
54
+ def main(argv: Sequence[str] | None = None) -> int:
55
+ if argv is None:
56
+ argv = sys.argv[1:]
57
+ if not argv or argv[0] in ("-h", "--help"):
58
+ _print_root_help()
59
+ return 0
60
+ cmd, *rest = argv
61
+ for name, mod in _COMMANDS:
62
+ if name == cmd:
63
+ return _load(mod)(rest)
64
+ print(f"unknown command: {cmd!r}\n", file=sys.stderr)
65
+ _print_root_help()
66
+ return 2
67
+
68
+
69
+ if __name__ == "__main__":
70
+ sys.exit(main())
@@ -0,0 +1,10 @@
1
+ """`python -m agentix.cli` entry point — defers to `agentix.cli.main`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from agentix.cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
@@ -0,0 +1,57 @@
1
+ """Read a project's pyproject.toml and derive build metadata.
2
+
3
+ `agentix build` takes one project root — a directory containing
4
+ `pyproject.toml`. Plugins (other `agentix-*` packages) are pulled in
5
+ transitively via pip from the user's declared `[project].dependencies`;
6
+ neither the CLI nor the user enumerates them on the command line.
7
+
8
+ This module owns the small bit of metadata extraction the build needs:
9
+ * `read_pyproject(path)` — parse the project's pyproject.toml.
10
+ * `short_name(pyproject)` — display/tag short name derived from the
11
+ distribution name.
12
+ * `derive_tag(pyproject)` — `<short>:<version>` from name+version.
13
+
14
+ There's no multi-spec resolver, no PyPI fallback, no path-vs-image
15
+ disambiguation. The spec is always a local project root.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import tomllib
21
+ from pathlib import Path
22
+
23
+ REPO_ROOT = Path(__file__).resolve().parents[2]
24
+
25
+
26
+ def read_pyproject(project_dir: Path) -> dict:
27
+ pp = project_dir / "pyproject.toml"
28
+ if not pp.is_file():
29
+ raise SystemExit(f"{project_dir}: missing pyproject.toml")
30
+ with pp.open("rb") as f:
31
+ return tomllib.load(f)
32
+
33
+
34
+ def short_name(pyproject: dict) -> str:
35
+ """Derive a short display/tag name for the project.
36
+
37
+ The short name only affects the image tag and a few build
38
+ diagnostics — wire routing is by `fn.__module__`, which is
39
+ determined by the user's actual Python import path.
40
+ """
41
+ project = pyproject.get("project", {})
42
+ name = project.get("name", "")
43
+ if not isinstance(name, str) or not name:
44
+ raise SystemExit("pyproject.toml: [project].name is required")
45
+ return name.removeprefix("agentix-")
46
+
47
+
48
+ def derive_tag(pyproject: dict) -> str:
49
+ """`<short>:<version>` from the pyproject."""
50
+ project = pyproject.get("project", {})
51
+ version = project.get("version")
52
+ if not isinstance(version, str):
53
+ raise SystemExit("pyproject.toml: [project].version is required")
54
+ return f"{short_name(pyproject)}:{version}"
55
+
56
+
57
+ __all__ = ["REPO_ROOT", "derive_tag", "read_pyproject", "short_name"]
agentix/cli/build.py ADDED
@@ -0,0 +1,247 @@
1
+ """`agentix build` — package a Python project into a deploy-ready sandbox image.
2
+
3
+ Usage:
4
+
5
+ agentix build # current directory's pyproject
6
+ agentix build path/to/project # explicit project root
7
+ agentix build . -o my:0.1.0 # explicit image tag
8
+ agentix build . --dry-run # stage Dockerfile to ./build/<tag>/, no docker invoke
9
+
10
+ The single argument is a path to a directory containing `pyproject.toml`.
11
+ That project's `[project].dependencies` are the bundle's plugin set —
12
+ pip resolves them transitively, and the resulting wheels install into
13
+ the framework's venv at `/nix/runtime/`. At runtime, calls execute the
14
+ pickle-serialized callable payload. **Neither the CLI nor the user
15
+ enumerates plugins on the command line.**
16
+
17
+ Build shape:
18
+
19
+ * **One pip install for the whole bundle.** Your project + every
20
+ declared `agentix-*` dep + transitive deps install into a single
21
+ /nix/runtime/ venv. Inline imports across plugins work as regular
22
+ Python (`from agentix.bash import run` inside your worker resolves
23
+ because bash is in the same site-packages).
24
+
25
+ * **System binaries via Nix (optional).** If the project root carries
26
+ a `default.nix`, a Nix builder stage runs first; the derivation's
27
+ `bin/*` is symlinked into `/nix/runtime/bin/` so user code can
28
+ `subprocess.run("git", …)` without absolute paths. Plugins that
29
+ need their own system binaries are expected to declare them in the
30
+ user's project `default.nix` for now.
31
+
32
+ The output image extends `agentix/runtime:<framework-version>` (override
33
+ via `--runtime-image`). That base image must already exist locally —
34
+ build it from `Agentix-Runtime-Basic/runtime/Dockerfile` or pull from
35
+ a registry.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import argparse
41
+ import shutil
42
+ import subprocess
43
+ import sys
44
+ from collections.abc import Sequence
45
+ from pathlib import Path
46
+ from tempfile import TemporaryDirectory
47
+
48
+ from agentix import __version__ as FRAMEWORK_VERSION
49
+ from agentix.cli._resolve import REPO_ROOT, derive_tag, read_pyproject, short_name
50
+
51
+ _SOURCE_SKIP = {
52
+ "__pycache__", ".venv", "build", "dist", ".git",
53
+ ".pytest_cache", ".ruff_cache", ".mypy_cache",
54
+ }
55
+
56
+
57
+ def _has_system_deps(src: Path) -> bool:
58
+ """True if the project ships a `default.nix` next to its pyproject."""
59
+ return (src / "default.nix").is_file()
60
+
61
+
62
+ def _image_exists_locally(image: str) -> bool:
63
+ """`docker image inspect` returns 0 iff the image is locally present."""
64
+ proc = subprocess.run(
65
+ ["docker", "image", "inspect", image],
66
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
67
+ )
68
+ return proc.returncode == 0
69
+
70
+
71
+ def _ensure_runtime_image(runtime_image: str) -> None:
72
+ """Verify the runtime base image exists locally.
73
+
74
+ The Dockerfile ships with `agentix-runtime-basic`; users build it
75
+ from that repo or pull it from a registry. The bundle build
76
+ doesn't auto-build the base — making the user own that step keeps
77
+ `agentix build`'s scope narrow.
78
+ """
79
+ if _image_exists_locally(runtime_image):
80
+ return
81
+ raise SystemExit(
82
+ f"runtime image {runtime_image!r} not found locally. Build it from "
83
+ f"Agentix-Runtime-Basic (`runtime/Dockerfile`) or pull it from your "
84
+ f"registry, then re-run `agentix build`."
85
+ )
86
+
87
+
88
+ def _stage(src: Path, build_dir: Path) -> None:
89
+ """Copy the project tree into `build_dir/project/`, skipping caches."""
90
+ dest = build_dir / "project"
91
+ dest.mkdir()
92
+ for item in src.iterdir():
93
+ if item.name in _SOURCE_SKIP or item.name.endswith(".egg-info"):
94
+ continue
95
+ d = dest / item.name
96
+ if item.is_dir():
97
+ shutil.copytree(item, d, ignore=shutil.ignore_patterns(*_SOURCE_SKIP))
98
+ else:
99
+ shutil.copy2(item, d)
100
+
101
+
102
+ def _render_dockerfile(src: Path, runtime_image: str) -> str:
103
+ """Generate the bundle's Dockerfile.
104
+
105
+ Two stages, both optional in different ways:
106
+
107
+ Stage 1 (Nix builder, only if project has `default.nix`): build
108
+ the derivation, stash its closure + store-path so the next stage
109
+ can symlink binaries.
110
+
111
+ Stage 2 (final image): `FROM <runtime>`, copy the project source,
112
+ one `pip install /src/project` to merge everything into
113
+ `/nix/runtime/`. If a Nix derivation was built, symlink its
114
+ `bin/*` into `/nix/runtime/bin/`.
115
+ """
116
+ has_nix = _has_system_deps(src)
117
+ parts: list[str] = ["# Generated by `agentix build`. Do not hand-edit."]
118
+
119
+ if has_nix:
120
+ parts += [
121
+ "ARG NIX_IMAGE=nixos/nix:latest",
122
+ "",
123
+ "FROM ${NIX_IMAGE} AS sys-builder",
124
+ "RUN mkdir -p ~/.config/nix && \\",
125
+ " echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf && \\",
126
+ " nix-channel --update 2>/dev/null || true",
127
+ "RUN mkdir -p /export/nix/store /export/nix/.sys-paths",
128
+ "",
129
+ "WORKDIR /src/project",
130
+ "COPY project/ ./",
131
+ "RUN nix-build --no-out-link default.nix -o ./result && \\",
132
+ " STORE_PATH=$(readlink -f ./result) && \\",
133
+ " for p in $(nix-store -qR \"$STORE_PATH\"); do \\",
134
+ " cp -a \"$p\" /export/nix/store/ || true; \\",
135
+ " done && \\",
136
+ " echo \"$STORE_PATH\" > /export/nix/.sys-paths/project",
137
+ ]
138
+
139
+ parts += ["", f"FROM {runtime_image}"]
140
+ if has_nix:
141
+ parts += [
142
+ "COPY --from=sys-builder /export/nix/store /nix/store",
143
+ "COPY --from=sys-builder /export/nix/.sys-paths /nix/.sys-paths",
144
+ ]
145
+
146
+ # Stage the source + install. Pip resolves the project's declared
147
+ # deps (including transitive agentix-* plugins) into /nix/runtime/.
148
+ parts += [
149
+ "COPY project/ /src/project/",
150
+ "RUN /nix/runtime/bin/pip install --no-cache-dir /src/project",
151
+ ]
152
+ if has_nix:
153
+ parts += [
154
+ "RUN SP=$(cat /nix/.sys-paths/project) && \\",
155
+ " for f in $SP/bin/*; do \\",
156
+ " ln -sf \"$f\" /nix/runtime/bin/$(basename \"$f\"); \\",
157
+ " done",
158
+ ]
159
+ return "\n".join(parts) + "\n"
160
+
161
+
162
+ def main(argv: Sequence[str] | None = None) -> int:
163
+ parser = argparse.ArgumentParser(
164
+ prog="agentix build",
165
+ description=__doc__,
166
+ formatter_class=argparse.RawDescriptionHelpFormatter,
167
+ )
168
+ parser.add_argument(
169
+ "path", nargs="?", default=".",
170
+ help="project root with pyproject.toml (default: current directory)",
171
+ )
172
+ parser.add_argument(
173
+ "-o", "--output", "--tag", dest="output", default=None,
174
+ help="output image tag. Defaults to <name>:<version> from pyproject.",
175
+ )
176
+ parser.add_argument(
177
+ "--runtime-image", default=f"agentix/runtime:{FRAMEWORK_VERSION}",
178
+ help="base runtime image. Must exist locally; build from "
179
+ "Agentix-Runtime-Basic/runtime/Dockerfile or pull from your "
180
+ f"registry. (default: agentix/runtime:{FRAMEWORK_VERSION})",
181
+ )
182
+ parser.add_argument(
183
+ "--dry-run", action="store_true",
184
+ help="stage Dockerfile to ./build/<tag>/; do not invoke docker",
185
+ )
186
+ args = parser.parse_args(argv)
187
+
188
+ src = Path(args.path).resolve()
189
+ if not src.is_dir():
190
+ raise SystemExit(f"{src}: not a directory")
191
+ pp = read_pyproject(src)
192
+
193
+ tag = args.output or derive_tag(pp)
194
+ if ":" not in tag:
195
+ raise SystemExit(f"image tag must include `:<version>` (got {tag!r})")
196
+
197
+ short = short_name(pp)
198
+
199
+ if args.dry_run:
200
+ out = REPO_ROOT / "build" / tag.rsplit("/", 1)[-1].split(":", 1)[0]
201
+ if out.exists():
202
+ shutil.rmtree(out)
203
+ out.mkdir(parents=True)
204
+ _stage(src, out)
205
+ (out / "Dockerfile").write_text(_render_dockerfile(src, args.runtime_image))
206
+ print(f"staged build context → {out}")
207
+ print(f"would build → {tag}")
208
+ print(f" extends → {args.runtime_image}")
209
+ print(f" project → {short}")
210
+ print(f" nix derivation → {'yes' if _has_system_deps(src) else 'no'}")
211
+ return 0
212
+
213
+ _ensure_runtime_image(args.runtime_image)
214
+
215
+ with TemporaryDirectory(prefix="agentix-build-") as tmp:
216
+ build_dir = Path(tmp)
217
+ _stage(src, build_dir)
218
+ (build_dir / "Dockerfile").write_text(_render_dockerfile(src, args.runtime_image))
219
+ print(
220
+ f"building {tag} (project {short!r} extending {args.runtime_image})…",
221
+ file=sys.stderr,
222
+ )
223
+ proc = subprocess.run(
224
+ ["docker", "build", "-t", tag, str(build_dir)],
225
+ check=False,
226
+ stderr=subprocess.PIPE,
227
+ )
228
+ if proc.stderr:
229
+ sys.stderr.buffer.write(proc.stderr)
230
+ if proc.returncode != 0:
231
+ err = proc.stderr.decode(errors="replace") if proc.stderr else ""
232
+ if any(
233
+ token in err
234
+ for token in ("conflict", "no matching distribution", "incompatible", "ResolutionImpossible")
235
+ ):
236
+ print(
237
+ "\nhint: pip couldn't resolve the unified dep set. Pin a "
238
+ "compatible version pair across your `agentix-*` deps in "
239
+ "pyproject.toml, or split the conflicting plugins into "
240
+ "two bundles.",
241
+ file=sys.stderr,
242
+ )
243
+ return proc.returncode
244
+
245
+
246
+ if __name__ == "__main__":
247
+ raise SystemExit(main())
@@ -0,0 +1,16 @@
1
+ """Deployment Protocol + backend discovery.
2
+
3
+ Core ships the `Deployment` Protocol, `Sandbox` dataclass, and backend
4
+ registry. Backend wheels (`agentix-deployment-docker`, `-daytona`,
5
+ `-e2b`, third-party) each install a sibling module under
6
+ `agentix.deployment`; extending `__path__` lets those siblings co-exist
7
+ with the framework files in this directory.
8
+ """
9
+
10
+ import pkgutil
11
+
12
+ __path__ = pkgutil.extend_path(__path__, __name__)
13
+
14
+ from agentix.deployment.base import Deployment
15
+
16
+ __all__ = ["Deployment"]
@@ -0,0 +1,202 @@
1
+ """Plugin registry for the `agentix.deployment` entry-point group.
2
+
3
+ Lives under `agentix.deployment` because deployments are the only
4
+ framework axis discovered via entry points. Remote callables do not need
5
+ a framework registry; only sandbox lifecycle backends need a name-to-class
6
+ registry.
7
+
8
+ Two ways to find plugins:
9
+
10
+ * **Production:** `importlib.metadata.entry_points(group=...)` — what
11
+ `pip install some-plugin` populates. The dist's `pyproject.toml`
12
+ declares its entries; nothing in the framework changes when a new
13
+ plugin is installed.
14
+ * **Testing / dynamic:** the in-process `register(name, factory)`
15
+ method. Convenient for unit tests, fixtures, or programmatic
16
+ composition; not part of the documented user-facing surface.
17
+
18
+ Lookup is lazy — entry points are walked on the first `get()` /
19
+ `all()` call. Loaders that raise are caught and remembered per-entry
20
+ (`errors()`), so one broken plugin doesn't poison the rest.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import importlib.metadata
26
+ import logging
27
+ from collections.abc import Callable
28
+ from dataclasses import dataclass
29
+ from typing import Generic, TypeVar
30
+
31
+ logger = logging.getLogger("agentix.deployment.plugin")
32
+
33
+ T = TypeVar("T")
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class PluginSource:
38
+ """Where a registered plugin came from — used in conflict reports
39
+ and CLI listings."""
40
+
41
+ dist_name: str | None
42
+ dist_version: str | None
43
+
44
+ def label(self) -> str:
45
+ if self.dist_name:
46
+ return f"{self.dist_name}@{self.dist_version or '?'}"
47
+ return "(in-process)"
48
+
49
+
50
+ class PluginConflictError(RuntimeError):
51
+ """Two plugins claim the same name in the same group.
52
+
53
+ The framework refuses to silently last-wins because it would hide
54
+ bugs (e.g. a stale wheel from a previous install). Users see the
55
+ conflict on first registry access and uninstall the duplicate dist.
56
+ """
57
+
58
+
59
+ class Registry(Generic[T]):
60
+ """One `agentix.<axis>` entry-point group + in-process `register()`.
61
+
62
+ `T` is whatever the entry-point load resolves to — typically a class
63
+ (`type[Deployment]`) or a callable factory. The registry doesn't
64
+ instantiate anything; callers decide what to do with the loaded
65
+ value. This keeps the contract narrow: T's shape is the axis's
66
+ Protocol; how it gets instantiated is the axis's concern.
67
+ """
68
+
69
+ def __init__(self, group: str) -> None:
70
+ self._group = group
71
+ self._extra: dict[str, tuple[Callable[[], T], PluginSource]] = {}
72
+ # Lazy: populated on first _load().
73
+ self._cache: dict[str, T] | None = None
74
+ self._sources: dict[str, PluginSource] = {}
75
+ self._errors: dict[str, Exception] = {}
76
+
77
+ @property
78
+ def group(self) -> str:
79
+ return self._group
80
+
81
+ def register(
82
+ self,
83
+ name: str,
84
+ factory: Callable[[], T],
85
+ *,
86
+ dist_name: str | None = None,
87
+ dist_version: str | None = None,
88
+ ) -> None:
89
+ """Register a plugin imperatively.
90
+
91
+ Intended for tests and programmatic composition; production
92
+ plugins use entry points. Calling `register()` invalidates the
93
+ cache so the next lookup re-runs the merge.
94
+ """
95
+ self._extra[name] = (factory, PluginSource(dist_name, dist_version))
96
+ self._cache = None # invalidate
97
+
98
+ def _walk_entry_points(self) -> list[tuple[str, Callable[[], T], PluginSource]]:
99
+ eps = importlib.metadata.entry_points()
100
+ # Python 3.10+: SelectableGroups; earlier: dict (we target 3.11+ but
101
+ # mirror the standard library's defensive branch anyway).
102
+ selected = (
103
+ list(eps.select(group=self._group))
104
+ if hasattr(eps, "select")
105
+ else list(eps.get(self._group, [])) # type: ignore[attr-defined]
106
+ )
107
+ out: list[tuple[str, Callable[[], T], PluginSource]] = []
108
+ for ep in selected:
109
+ dist = ep.dist
110
+ src = PluginSource(
111
+ dist_name=getattr(dist, "name", None) if dist else None,
112
+ dist_version=getattr(dist, "version", None) if dist else None,
113
+ )
114
+ out.append((ep.name, ep.load, src))
115
+ return out
116
+
117
+ def _load(self) -> dict[str, T]:
118
+ if self._cache is not None:
119
+ return self._cache
120
+
121
+ items: dict[str, T] = {}
122
+ sources: dict[str, PluginSource] = {}
123
+ errors: dict[str, Exception] = {}
124
+
125
+ # Entry-point pass first. Two dists declaring the same name is
126
+ # ambiguous and must surface — they'd silently last-wins otherwise.
127
+ for name, loader, src in self._walk_entry_points():
128
+ if name in sources:
129
+ raise PluginConflictError(
130
+ f"duplicate plugin {name!r} in group {self._group!r}: "
131
+ f"{sources[name].label()} vs {src.label()}"
132
+ )
133
+ try:
134
+ items[name] = loader()
135
+ sources[name] = src
136
+ except Exception as exc:
137
+ logger.warning(
138
+ "plugin %r in group %r failed to load: %s",
139
+ name, self._group, exc,
140
+ )
141
+ errors[name] = exc
142
+
143
+ # In-process extras override entry points — this is deliberate
144
+ # for tests (`register("local", FakeDocker)` swaps in a stub).
145
+ # Production code paths don't call `register()`, so this is safe.
146
+ for name, (factory, src) in self._extra.items():
147
+ try:
148
+ items[name] = factory()
149
+ sources[name] = src
150
+ errors.pop(name, None)
151
+ except Exception as exc:
152
+ errors[name] = exc
153
+
154
+ self._cache = items
155
+ self._sources = sources
156
+ self._errors = errors
157
+ return items
158
+
159
+ def get(self, name: str) -> T:
160
+ """Return the plugin registered under `name`.
161
+
162
+ Raises `KeyError` if no plugin claims the name (with the list of
163
+ available names in the error message), or re-raises the original
164
+ exception if the named plugin failed to load.
165
+ """
166
+ items = self._load()
167
+ if name in items:
168
+ return items[name]
169
+ if name in self._errors:
170
+ raise self._errors[name]
171
+ raise KeyError(
172
+ f"no plugin {name!r} in group {self._group!r}; "
173
+ f"available: {sorted(items)}"
174
+ )
175
+
176
+ def all(self) -> dict[str, T]:
177
+ """Snapshot of all successfully-loaded plugins, name → value."""
178
+ return dict(self._load())
179
+
180
+ def sources(self) -> dict[str, PluginSource]:
181
+ """`name → PluginSource` for every successfully-loaded plugin."""
182
+ self._load()
183
+ return dict(self._sources)
184
+
185
+ def errors(self) -> dict[str, Exception]:
186
+ """`name → Exception` for plugins whose load failed (cached)."""
187
+ self._load()
188
+ return dict(self._errors)
189
+
190
+ def reset(self) -> None:
191
+ """Test-only: drop cache + in-process registrations.
192
+
193
+ Useful in pytest fixtures that need a known-empty registry
194
+ between tests.
195
+ """
196
+ self._cache = None
197
+ self._extra.clear()
198
+ self._sources.clear()
199
+ self._errors.clear()
200
+
201
+
202
+ __all__ = ["PluginConflictError", "PluginSource", "Registry"]