mic-evals 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.
Files changed (51) hide show
  1. mic/__init__.py +59 -0
  2. mic/__main__.py +3 -0
  3. mic/_async.py +36 -0
  4. mic/_runtime/__init__.py +1 -0
  5. mic/_runtime/artifacts.py +177 -0
  6. mic/_runtime/batch.py +78 -0
  7. mic/_runtime/callbacks.py +51 -0
  8. mic/_runtime/case.py +111 -0
  9. mic/_runtime/contracts.py +14 -0
  10. mic/_runtime/discovery.py +57 -0
  11. mic/_runtime/engine.py +279 -0
  12. mic/_runtime/files.py +27 -0
  13. mic/_runtime/materialization.py +222 -0
  14. mic/_runtime/options.py +76 -0
  15. mic/_runtime/reporting.py +68 -0
  16. mic/_runtime/summary.py +107 -0
  17. mic/_runtime/validation.py +140 -0
  18. mic/cli.py +216 -0
  19. mic/decorators.py +130 -0
  20. mic/errors.py +17 -0
  21. mic/integrations/__init__.py +3 -0
  22. mic/integrations/pydantic.py +120 -0
  23. mic/models.py +152 -0
  24. mic/providers/__init__.py +21 -0
  25. mic/providers/_braintrust/__init__.py +1 -0
  26. mic/providers/_braintrust/transport.py +184 -0
  27. mic/providers/_io.py +98 -0
  28. mic/providers/base.py +102 -0
  29. mic/providers/bigquery.py +328 -0
  30. mic/providers/braintrust.py +248 -0
  31. mic/providers/files.py +114 -0
  32. mic/providers/memory.py +78 -0
  33. mic/py.typed +0 -0
  34. mic/reporters/__init__.py +7 -0
  35. mic/reporters/base.py +15 -0
  36. mic/reporters/braintrust.py +229 -0
  37. mic/reporters/console.py +47 -0
  38. mic/reporters/html.py +82 -0
  39. mic/reporters/templates/report.html +21 -0
  40. mic/reporters/templates/report.js +219 -0
  41. mic/runner.py +5 -0
  42. mic/schema/__init__.py +64 -0
  43. mic/schema/_compiler.py +176 -0
  44. mic/schema/_contracts.py +76 -0
  45. mic/schema/_dataclasses.py +148 -0
  46. mic/schema/_values.py +236 -0
  47. mic_evals-0.1.0.dist-info/METADATA +268 -0
  48. mic_evals-0.1.0.dist-info/RECORD +51 -0
  49. mic_evals-0.1.0.dist-info/WHEEL +4 -0
  50. mic_evals-0.1.0.dist-info/entry_points.txt +2 -0
  51. mic_evals-0.1.0.dist-info/licenses/LICENSE +21 -0
mic/__init__.py ADDED
@@ -0,0 +1,59 @@
1
+ """Typed micro-evaluations. Cloud SDKs and credentials are loaded only on demand."""
2
+
3
+ from ._runtime.materialization import ainspect_dataset, inspect_dataset
4
+ from .decorators import case_schema, dataset, eval, scorer
5
+ from .errors import ConfigurationError, DatasetError, MicError, MissingExpectedError
6
+ from .models import (
7
+ MISSING,
8
+ CaseSchema,
9
+ Dataset,
10
+ Evaluation,
11
+ JsonObject,
12
+ JsonValue,
13
+ Missing,
14
+ RawCase,
15
+ ReadLimits,
16
+ RunResult,
17
+ Score,
18
+ ScoreContext,
19
+ Scorer,
20
+ TaskContext,
21
+ TaskResult,
22
+ )
23
+ from .runner import apreflight, arun, preflight, run
24
+ from .schema import Schema, SchemaError, schema
25
+
26
+ __all__ = [
27
+ "MISSING",
28
+ "CaseSchema",
29
+ "ConfigurationError",
30
+ "Dataset",
31
+ "DatasetError",
32
+ "Evaluation",
33
+ "JsonObject",
34
+ "JsonValue",
35
+ "MicError",
36
+ "Missing",
37
+ "MissingExpectedError",
38
+ "RawCase",
39
+ "ReadLimits",
40
+ "RunResult",
41
+ "Schema",
42
+ "SchemaError",
43
+ "schema",
44
+ "Score",
45
+ "ScoreContext",
46
+ "Scorer",
47
+ "TaskContext",
48
+ "TaskResult",
49
+ "ainspect_dataset",
50
+ "apreflight",
51
+ "arun",
52
+ "case_schema",
53
+ "dataset",
54
+ "eval",
55
+ "inspect_dataset",
56
+ "preflight",
57
+ "run",
58
+ "scorer",
59
+ ]
mic/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
mic/_async.py ADDED
@@ -0,0 +1,36 @@
1
+ """Cancellation-safe joins for resources that must outlive their worker."""
2
+
3
+ import asyncio
4
+ from collections.abc import Callable, Coroutine
5
+ from typing import Any
6
+
7
+ from .errors import ConfigurationError
8
+
9
+
10
+ def run_sync[T](
11
+ sync_name: str, async_name: str, factory: Callable[[], Coroutine[Any, Any, T]]
12
+ ) -> T:
13
+ """Run an async implementation from synchronous code without leaking a coroutine."""
14
+ try:
15
+ asyncio.get_running_loop()
16
+ except RuntimeError:
17
+ pass
18
+ else:
19
+ raise ConfigurationError(
20
+ f"{sync_name} cannot run inside an event loop; use 'await {async_name}(...)'"
21
+ )
22
+ return asyncio.run(factory())
23
+
24
+
25
+ async def drain[T](future: asyncio.Future[T]) -> T:
26
+ """Join after cancellation has been recorded, tolerating further cancellation.
27
+
28
+ Shielding prevents the caller from cancelling the worker. Cancellation of the
29
+ worker itself still propagates; it cannot make progress and must not be retried.
30
+ """
31
+ while True:
32
+ try:
33
+ return await asyncio.shield(future)
34
+ except asyncio.CancelledError:
35
+ if future.done():
36
+ return future.result()
@@ -0,0 +1 @@
1
+ """Internal evaluation lifecycle, scheduling and case execution."""
@@ -0,0 +1,177 @@
1
+ """Local evidence is written before optional remote reporting."""
2
+
3
+ import hashlib
4
+ import importlib.metadata
5
+ import platform
6
+ import sys
7
+ import time
8
+ from collections.abc import Sequence
9
+ from dataclasses import dataclass, field
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+ from typing import cast
13
+
14
+ from ..errors import ConfigurationError
15
+ from ..models import JsonObject, JsonValue
16
+ from .files import atomic_write
17
+ from .validation import dumps, json_object
18
+
19
+
20
+ def atomic_json(path: Path, value: JsonObject) -> None:
21
+ atomic_write(path, (dumps(value) + "\n",))
22
+
23
+
24
+ def prepare_directory(path: Path) -> None:
25
+ if path.exists() and (not path.is_dir() or any(path.iterdir())):
26
+ raise ConfigurationError(f"Output directory must be empty: {path}")
27
+ path.mkdir(parents=True, exist_ok=True)
28
+
29
+
30
+ def write_dataset(path: Path, rows: Sequence[JsonObject]) -> None:
31
+ atomic_write(path, (dumps(row) + "\n" for row in rows))
32
+
33
+
34
+ def append_case(path: Path, case: JsonObject) -> None:
35
+ with path.open("a", encoding="utf-8") as stream:
36
+ stream.write(dumps(case) + "\n")
37
+
38
+
39
+ @dataclass
40
+ class Persistence:
41
+ """Successful writes and all failed attempts, without claiming disk recovery."""
42
+
43
+ written: set[str] = field(default_factory=set[str])
44
+ errors: list[tuple[str, Exception]] = field(default_factory=list[tuple[str, Exception]])
45
+
46
+ def annotate(self, exc: BaseException, path: Path) -> None:
47
+ if self.errors:
48
+ exc.add_note(f"Local evidence in {path} may be incomplete or stale")
49
+ for name, error in self.errors:
50
+ exc.add_note(f"Could not persist {name}: {type(error).__name__}: {error}")
51
+ elif "report.html" in self.written:
52
+ exc.add_note(f"Local evidence: {path / 'report.html'}")
53
+
54
+
55
+ def has_artifact_failure(manifest: JsonObject) -> bool:
56
+ return any(
57
+ isinstance(error, dict) and error.get("phase") == "artifact"
58
+ for error in cast(list[JsonValue], manifest["failures"])
59
+ )
60
+
61
+
62
+ def end_run(manifest: JsonObject, *, started: float) -> None:
63
+ """Stamp terminal timing even when no output directory could be acquired."""
64
+ manifest["ended_at"] = datetime.now(UTC).isoformat()
65
+ manifest["duration_ms"] = (time.perf_counter() - started) * 1000
66
+
67
+
68
+ def finish_artifacts(
69
+ path: Path, manifest: JsonObject, cases: Sequence[JsonObject], *, started: float
70
+ ) -> Persistence:
71
+ """Save a terminal outcome and attempt one bounded recovery after write failures.
72
+
73
+ Both destinations are attempted independently. Recovery reprojects the updated
74
+ failure manifest; persistent faults may leave disk behind the in-memory result.
75
+ Setup errors (2) and cancellation (130) retain their original precedence.
76
+ """
77
+ from ..reporters.html import render_report
78
+
79
+ end_run(manifest, started=started)
80
+ result = Persistence()
81
+ for _ in range(2):
82
+ errors: list[tuple[str, Exception]] = []
83
+ for name in ("run.json", "report.html"):
84
+ try:
85
+ if name == "run.json":
86
+ atomic_json(path / name, manifest)
87
+ else:
88
+ atomic_write(path / name, (render_report(manifest, cases),))
89
+ result.written.add(name)
90
+ except Exception as exc:
91
+ # Rendering is an artifact boundary too. BaseException (including
92
+ # cancellation) deliberately remains outside this recovery path.
93
+ errors.append((name, exc))
94
+ result.written.discard(name)
95
+ if not errors:
96
+ break
97
+ result.errors.extend(errors)
98
+ for name, exc in errors:
99
+ record = failure("artifact", exc)
100
+ record["message"] = f"Could not persist {name}: {exc}"
101
+ cast(list[JsonValue], manifest["failures"]).append(record)
102
+ if manifest["exit_code"] not in (2, 130):
103
+ manifest.update({"status": "failed", "exit_code": 1})
104
+ return result
105
+
106
+
107
+ def code_provenance(fn: object) -> JsonObject:
108
+ module_name = str(getattr(fn, "__module__", "unknown"))
109
+ symbol = str(getattr(fn, "__qualname__", getattr(fn, "__name__", "unknown")))
110
+ try:
111
+ package_version = importlib.metadata.version("mic-evals")
112
+ except importlib.metadata.PackageNotFoundError:
113
+ package_version = "uninstalled"
114
+ metadata: JsonObject = {
115
+ "python": platform.python_version(),
116
+ "package_version": package_version,
117
+ "definition": f"{module_name}:{symbol}",
118
+ "dependencies": {},
119
+ }
120
+ # Record optional schema/backend versions only when they were actually loaded.
121
+ dependencies: JsonObject = {}
122
+ for distribution, module_prefix in (("pydantic", "pydantic"),):
123
+ if module_prefix in sys.modules:
124
+ try:
125
+ dependencies[distribution] = importlib.metadata.version(distribution)
126
+ except importlib.metadata.PackageNotFoundError:
127
+ pass
128
+ metadata["dependencies"] = dependencies
129
+ module = sys.modules.get(module_name)
130
+ module_file = getattr(module, "__file__", None)
131
+ if module_file is not None:
132
+ source_path = Path(module_file).resolve()
133
+ if source_path.is_file():
134
+ metadata["source_path"] = str(source_path)
135
+ metadata["source_sha256"] = hashlib.sha256(source_path.read_bytes()).hexdigest()
136
+ # A framework content hash also works outside Git and is meaningful for an
137
+ # editable install with uncommitted changes.
138
+ package = Path(__file__).parents[1]
139
+ hasher = hashlib.sha256()
140
+ for source in sorted(package.rglob("*.py")):
141
+ hasher.update(str(source.relative_to(package)).encode())
142
+ hasher.update(source.read_bytes())
143
+ metadata["framework_sha256"] = hasher.hexdigest()
144
+ return metadata
145
+
146
+
147
+ def failure(
148
+ phase: str,
149
+ exc: BaseException,
150
+ *,
151
+ row_index: int | None = None,
152
+ case_id: str | None = None,
153
+ trial: int | None = None,
154
+ scorer: str | None = None,
155
+ ) -> JsonObject:
156
+ import traceback
157
+
158
+ result: JsonObject = {
159
+ "phase": phase,
160
+ "type": type(exc).__name__,
161
+ "message": str(exc),
162
+ "row_index": row_index,
163
+ "case_id": case_id,
164
+ "trial": trial,
165
+ "traceback": "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)),
166
+ }
167
+ if scorer is not None:
168
+ result["scorer"] = scorer
169
+ return result
170
+
171
+
172
+ def case_errors(cases: Sequence[JsonObject]) -> list[JsonValue]:
173
+ errors: list[JsonValue] = []
174
+ for case in cases:
175
+ for error in cast(list[JsonValue], case.get("errors", [])):
176
+ errors.append(json_object(error))
177
+ return errors
mic/_runtime/batch.py ADDED
@@ -0,0 +1,78 @@
1
+ """Admit bounded trials, journal completion and join workers before returning."""
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import cast
7
+
8
+ from .._async import drain
9
+ from ..models import Evaluation, JsonObject
10
+ from .artifacts import append_case
11
+ from .callbacks import CallbackPool
12
+ from .case import run_case
13
+ from .materialization import DatasetSnapshot
14
+ from .options import Options
15
+
16
+
17
+ @dataclass
18
+ class BatchResult:
19
+ cases: list[JsonObject]
20
+ interrupted: asyncio.CancelledError | None
21
+ error: Exception | None
22
+
23
+
24
+ async def execute[I, O, E, M](
25
+ spec: Evaluation[I, O, E, M],
26
+ snapshot: DatasetSnapshot[I, E, M],
27
+ options: Options,
28
+ destination: Path,
29
+ ) -> BatchResult:
30
+ planned = len(snapshot.cases) * options.trials
31
+ cases: list[JsonObject] = []
32
+ pool = CallbackPool(options.concurrency)
33
+ next_job = 0
34
+ interrupted: asyncio.CancelledError | None = None
35
+ runtime_error: Exception | None = None
36
+
37
+ async def worker() -> None:
38
+ nonlocal next_job
39
+ while next_job < planned:
40
+ sequence = next_job
41
+ next_job += 1
42
+ row_index, trial_index = divmod(sequence, options.trials)
43
+ case = await run_case(
44
+ spec,
45
+ snapshot.cases[row_index],
46
+ snapshot.rows[row_index],
47
+ row_index,
48
+ trial_index + 1,
49
+ options,
50
+ pool,
51
+ )
52
+ cases.append(case)
53
+ append_case(destination / "cases.jsonl", case)
54
+ if case["status"] == "cancelled":
55
+ raise asyncio.CancelledError(f"Case {case['case_id']} was cancelled")
56
+
57
+ workers = [asyncio.create_task(worker()) for _ in range(min(options.concurrency, planned))]
58
+ try:
59
+ await asyncio.gather(*workers)
60
+ except asyncio.CancelledError as exc:
61
+ interrupted = exc
62
+ for worker_task in workers:
63
+ if not worker_task.done():
64
+ worker_task.cancel()
65
+ await drain(asyncio.gather(*workers, return_exceptions=True))
66
+ except Exception as exc:
67
+ runtime_error = exc
68
+ for worker_task in workers:
69
+ if not worker_task.done():
70
+ worker_task.cancel()
71
+ await drain(asyncio.gather(*workers, return_exceptions=True))
72
+ finally:
73
+ try:
74
+ await pool.close()
75
+ except asyncio.CancelledError as exc:
76
+ interrupted = exc
77
+ cases.sort(key=lambda value: (cast(int, value["row_index"]), cast(int, value["trial"])))
78
+ return BatchResult(cases, interrupted, runtime_error)
@@ -0,0 +1,51 @@
1
+ """Bounded dispatch of synchronous and asynchronous callbacks."""
2
+
3
+ import asyncio
4
+ import inspect
5
+ from collections.abc import Awaitable, Callable
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from functools import partial
8
+ from typing import cast
9
+
10
+ from .._async import drain
11
+
12
+
13
+ class CallbackPool:
14
+ def __init__(self, concurrency: int) -> None:
15
+ self.executor = ThreadPoolExecutor(max_workers=concurrency, thread_name_prefix="mic")
16
+
17
+ async def invoke[T, **P](
18
+ self,
19
+ fn: Callable[P, T | Awaitable[T]],
20
+ *args: P.args,
21
+ **kwargs: P.kwargs,
22
+ ) -> T:
23
+ if inspect.iscoroutinefunction(fn):
24
+ return await cast(Awaitable[T], fn(*args, **kwargs))
25
+ loop = asyncio.get_running_loop()
26
+ future = loop.run_in_executor(self.executor, partial(fn, *args, **kwargs))
27
+ try:
28
+ result = await asyncio.shield(future)
29
+ except asyncio.CancelledError:
30
+ # A running Python thread cannot be killed. Retain this worker slot
31
+ # and drain it rather than admitting replacements under a false bound.
32
+ try:
33
+ abandoned = await drain(future)
34
+ if inspect.iscoroutine(abandoned):
35
+ abandoned.close()
36
+ elif isinstance(abandoned, asyncio.Future):
37
+ abandoned.cancel()
38
+ except Exception:
39
+ pass
40
+ raise
41
+ return await cast(Awaitable[T], result) if inspect.isawaitable(result) else cast(T, result)
42
+
43
+ async def close(self) -> None:
44
+ closing = asyncio.create_task(
45
+ asyncio.to_thread(self.executor.shutdown, wait=True, cancel_futures=True)
46
+ )
47
+ try:
48
+ await asyncio.shield(closing)
49
+ except asyncio.CancelledError:
50
+ await drain(closing)
51
+ raise
mic/_runtime/case.py ADDED
@@ -0,0 +1,111 @@
1
+ """Execute one isolated trial and preserve its successful scores and failures."""
2
+
3
+ import asyncio
4
+ import copy
5
+ import time
6
+ from typing import cast
7
+
8
+ from ..models import Evaluation, JsonObject, JsonValue, ScoreContext, TaskContext, TaskResult
9
+ from .artifacts import failure
10
+ from .callbacks import CallbackPool
11
+ from .contracts import Case
12
+ from .options import Options
13
+ from .validation import json_object, normalize_score, serialize, validate
14
+
15
+
16
+ async def run_case[I, O, E, M](
17
+ spec: Evaluation[I, O, E, M],
18
+ row: Case[I, E, M],
19
+ encoded: JsonObject,
20
+ row_index: int,
21
+ trial: int,
22
+ options: Options,
23
+ pool: CallbackPool,
24
+ ) -> JsonObject:
25
+ result: JsonObject = copy.deepcopy(encoded)
26
+ result.pop("id", None)
27
+ scores: list[JsonValue] = []
28
+ errors: list[JsonValue] = []
29
+ latencies: JsonObject = {"task_ms": 0.0, "scoring_ms": 0.0, "total_ms": 0.0}
30
+ result.update(
31
+ {
32
+ "case_id": row.id,
33
+ "row_index": row_index,
34
+ "trial": trial,
35
+ "status": "completed",
36
+ "scores": scores,
37
+ "errors": errors,
38
+ "latency": latencies,
39
+ "provenance": copy.deepcopy(row.provenance),
40
+ }
41
+ )
42
+ started = time.perf_counter()
43
+ phase = "task"
44
+ scorer_name: str | None = None
45
+ phase_started = started
46
+ try:
47
+ async with asyncio.timeout(options.timeout):
48
+ cloned = copy.deepcopy(row)
49
+ ctx = TaskContext(cloned.id, trial, cloned.expected, cloned.metadata)
50
+ raw = await pool.invoke(spec.function, ctx, cloned.input)
51
+ latencies["task_ms"] = (time.perf_counter() - phase_started) * 1000
52
+ phase = "schema"
53
+ metadata = copy.deepcopy(row.metadata)
54
+ if isinstance(raw, TaskResult):
55
+ enriched = cast(TaskResult[O], raw)
56
+ output_raw = enriched.output
57
+ task_metadata = json_object(enriched.metadata, "$.task_metadata")
58
+ result["task_metadata"] = task_metadata
59
+ merged = (
60
+ {}
61
+ if metadata is None
62
+ else json_object(serialize(spec.dataset.schema.metadata, metadata))
63
+ )
64
+ merged.update(task_metadata)
65
+ metadata = validate(spec.dataset.schema.metadata, merged)
66
+ json_object(serialize(spec.dataset.schema.metadata, metadata), "$.metadata")
67
+ else:
68
+ output_raw = raw
69
+ output = validate(spec.output, output_raw)
70
+ result["output"] = serialize(spec.output, output)
71
+ phase = "scorer"
72
+ phase_started = time.perf_counter()
73
+ for scorer in spec.scorers:
74
+ scorer_name = scorer.name
75
+ context = ScoreContext(
76
+ copy.deepcopy(row.input),
77
+ copy.deepcopy(output),
78
+ copy.deepcopy(row.expected),
79
+ copy.deepcopy(metadata),
80
+ row.id,
81
+ trial,
82
+ )
83
+ raw_score = await pool.invoke(scorer.function, context)
84
+ scores.append(normalize_score(raw_score, scorer.name))
85
+ latencies["scoring_ms"] = (time.perf_counter() - phase_started) * 1000
86
+ except asyncio.CancelledError as exc:
87
+ result["status"] = "cancelled"
88
+ errors.append(
89
+ failure(
90
+ "cancelled",
91
+ exc,
92
+ row_index=row_index,
93
+ case_id=row.id,
94
+ trial=trial,
95
+ scorer=scorer_name,
96
+ )
97
+ )
98
+ except Exception as exc:
99
+ result["status"] = "failed"
100
+ errors.append(
101
+ failure(
102
+ phase, exc, row_index=row_index, case_id=row.id, trial=trial, scorer=scorer_name
103
+ )
104
+ )
105
+ finally:
106
+ if phase == "task" and latencies["task_ms"] == 0:
107
+ latencies["task_ms"] = (time.perf_counter() - phase_started) * 1000
108
+ elif phase == "scorer" and latencies["scoring_ms"] == 0:
109
+ latencies["scoring_ms"] = (time.perf_counter() - phase_started) * 1000
110
+ latencies["total_ms"] = (time.perf_counter() - started) * 1000
111
+ return result
@@ -0,0 +1,14 @@
1
+ """Internal normalized values shared by materialization and execution."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from ..models import JsonObject, JsonValue, Missing
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Case[I, E, M]:
10
+ id: str
11
+ input: I
12
+ expected: E | Missing
13
+ metadata: M | None
14
+ provenance: JsonObject = field(default_factory=dict[str, JsonValue])
@@ -0,0 +1,57 @@
1
+ """Explicit-module discovery; descriptor inspection never invokes a dataset factory."""
2
+
3
+ import importlib
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ import mic
8
+ from mic.errors import ConfigurationError
9
+
10
+ type Definition = (
11
+ mic.Dataset[Any, Any, Any] | mic.Evaluation[Any, Any, Any, Any] | mic.Scorer[Any, Any, Any, Any]
12
+ )
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class DefinitionInfo:
17
+ kind: str
18
+ name: str
19
+ selector: str
20
+ definition: Definition
21
+
22
+
23
+ def _kind(value: object) -> str | None:
24
+ if isinstance(value, mic.Dataset):
25
+ return "dataset"
26
+ if isinstance(value, mic.Evaluation):
27
+ return "eval"
28
+ if isinstance(value, mic.Scorer):
29
+ return "scorer"
30
+ return None
31
+
32
+
33
+ def list_definitions(module_name: str) -> list[DefinitionInfo]:
34
+ """Import only the requested trusted Python module, then inspect its exports."""
35
+ try:
36
+ module = importlib.import_module(module_name)
37
+ except Exception as exc:
38
+ raise ConfigurationError(f"Cannot import {module_name!r}: {exc}") from exc
39
+ result: list[DefinitionInfo] = []
40
+ for symbol, value in sorted(vars(module).items()):
41
+ kind = _kind(value)
42
+ if not symbol.startswith("_") and kind is not None:
43
+ result.append(DefinitionInfo(kind, value.name, f"{module_name}:{symbol}", value))
44
+ return result
45
+
46
+
47
+ def resolve_definition(selector: str) -> Definition:
48
+ """Exact ``module:symbol`` selection; no substring matching or global registry."""
49
+ if selector.count(":") != 1:
50
+ raise ConfigurationError("Select an exact definition with module:symbol")
51
+ module_name, symbol = selector.split(":")
52
+ if not module_name or not symbol.isidentifier() or symbol.startswith("_"):
53
+ raise ConfigurationError("Select an exact public definition with module:symbol")
54
+ for definition in list_definitions(module_name):
55
+ if definition.selector == selector:
56
+ return definition.definition
57
+ raise ConfigurationError(f"No mic definition found at {selector!r}")