permissiondiff 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.
@@ -0,0 +1,155 @@
1
+ """Pure comparison, invariant evaluation, and finding minimization logic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterable
7
+ from dataclasses import replace
8
+
9
+ from permissiondiff.invariants import Invariant
10
+ from permissiondiff.models import (
11
+ AuthorizationCase,
12
+ CaseEvaluation,
13
+ ChangeType,
14
+ ComparisonResult,
15
+ Decision,
16
+ Finding,
17
+ FindingKind,
18
+ Severity,
19
+ )
20
+
21
+
22
+ def classify_change(baseline: Decision, candidate: Decision) -> ChangeType:
23
+ """Classify one exhaustive pair of authorization decisions."""
24
+ mapping = {
25
+ (Decision.DENY, Decision.DENY): ChangeType.UNCHANGED_DENIED,
26
+ (Decision.ALLOW, Decision.ALLOW): ChangeType.UNCHANGED_ALLOWED,
27
+ (Decision.DENY, Decision.ALLOW): ChangeType.NEWLY_ALLOWED,
28
+ (Decision.ALLOW, Decision.DENY): ChangeType.NEWLY_DENIED,
29
+ }
30
+ return mapping[(baseline, candidate)]
31
+
32
+
33
+ def compare_decisions(
34
+ case: AuthorizationCase,
35
+ baseline: Decision,
36
+ candidate: Decision,
37
+ ) -> ComparisonResult:
38
+ """Compare two decisions for one exact authorization case."""
39
+ return ComparisonResult(
40
+ case=case,
41
+ baseline=baseline,
42
+ candidate=candidate,
43
+ change=classify_change(baseline, candidate),
44
+ )
45
+
46
+
47
+ def invariant_findings(
48
+ evaluations: Iterable[CaseEvaluation],
49
+ invariants: Iterable[Invariant],
50
+ *,
51
+ minimize: bool = True,
52
+ ) -> list[Finding]:
53
+ """Evaluate invariants and return minimized, deduplicated violations."""
54
+ findings: list[Finding] = []
55
+ invariant_list = list(invariants)
56
+ for evaluation in evaluations:
57
+ if evaluation.decision is None:
58
+ findings.append(
59
+ Finding(
60
+ kind=FindingKind.EVALUATION_ERROR,
61
+ severity=Severity.ERROR,
62
+ case=evaluation.case,
63
+ message="Authorizer evaluation failed",
64
+ error=evaluation.error,
65
+ )
66
+ )
67
+ continue
68
+ for invariant in invariant_list:
69
+ result = invariant.evaluate(evaluation.case, evaluation.decision)
70
+ if not result.passed:
71
+ findings.append(
72
+ Finding(
73
+ kind=FindingKind.INVARIANT_VIOLATION,
74
+ severity=Severity.CRITICAL,
75
+ case=evaluation.case,
76
+ message=result.message,
77
+ candidate=evaluation.decision,
78
+ invariant=result.name,
79
+ )
80
+ )
81
+ return minimize_findings(findings) if minimize else findings
82
+
83
+
84
+ def comparison_findings(
85
+ comparisons: Iterable[ComparisonResult], *, minimize: bool = True
86
+ ) -> list[Finding]:
87
+ """Convert changed decisions into minimized security findings."""
88
+ findings: list[Finding] = []
89
+ for comparison in comparisons:
90
+ if comparison.change is ChangeType.NEWLY_ALLOWED:
91
+ findings.append(
92
+ Finding(
93
+ kind=FindingKind.CHANGE,
94
+ severity=Severity.CRITICAL,
95
+ case=comparison.case,
96
+ message="Authorization path is newly allowed",
97
+ baseline=comparison.baseline,
98
+ candidate=comparison.candidate,
99
+ change=comparison.change,
100
+ )
101
+ )
102
+ elif comparison.change is ChangeType.NEWLY_DENIED:
103
+ findings.append(
104
+ Finding(
105
+ kind=FindingKind.CHANGE,
106
+ severity=Severity.WARNING,
107
+ case=comparison.case,
108
+ message="Authorization path is newly denied",
109
+ baseline=comparison.baseline,
110
+ candidate=comparison.candidate,
111
+ change=comparison.change,
112
+ )
113
+ )
114
+ return minimize_findings(findings) if minimize else findings
115
+
116
+
117
+ def minimize_findings(findings: Iterable[Finding]) -> list[Finding]:
118
+ """Deduplicate equivalent failures and retain the smallest reproduction."""
119
+ groups: dict[tuple[object, ...], list[Finding]] = {}
120
+ for finding in findings:
121
+ groups.setdefault(finding_equivalence_key(finding), []).append(finding)
122
+
123
+ minimized: list[Finding] = []
124
+ for equivalents in groups.values():
125
+ smallest = min(equivalents, key=finding_case_complexity)
126
+ minimized.append(replace(smallest, equivalent_cases=len(equivalents)))
127
+ return sorted(minimized, key=lambda finding: finding.semantic_key)
128
+
129
+
130
+ def finding_equivalence_key(finding: Finding) -> tuple[object, ...]:
131
+ """Group failures with the same security meaning before shrinking."""
132
+ case = finding.case
133
+ tenant_relation = (
134
+ "unknown"
135
+ if case.subject.tenant is None or case.resource.tenant is None
136
+ else "same"
137
+ if case.subject.tenant == case.resource.tenant
138
+ else "different"
139
+ )
140
+ return (
141
+ finding.kind,
142
+ finding.change,
143
+ finding.invariant,
144
+ finding.error,
145
+ case.action.name,
146
+ case.subject.role,
147
+ case.resource.type,
148
+ tenant_relation,
149
+ )
150
+
151
+
152
+ def finding_case_complexity(finding: Finding) -> tuple[int, str]:
153
+ """Order reproductions by compact canonical case representation."""
154
+ serialized = json.dumps(finding.case.to_dict(), sort_keys=True, separators=(",", ":"))
155
+ return (len(serialized), serialized)
@@ -0,0 +1,25 @@
1
+ """Domain-specific exceptions exposed by PermissionDiff."""
2
+
3
+
4
+ class PermissionDiffError(Exception):
5
+ """Base class for expected PermissionDiff failures."""
6
+
7
+
8
+ class ConfigurationError(PermissionDiffError):
9
+ """Raised when configuration is missing or invalid."""
10
+
11
+
12
+ class AdapterError(PermissionDiffError):
13
+ """Raised when an authorizer or custom invariant cannot be loaded."""
14
+
15
+
16
+ class EvaluationError(PermissionDiffError):
17
+ """Raised when authorizer evaluation crashes, hangs, or returns invalid data."""
18
+
19
+
20
+ class SnapshotVersionError(PermissionDiffError):
21
+ """Raised when a snapshot schema is unsupported."""
22
+
23
+
24
+ class InvariantDefinitionError(PermissionDiffError):
25
+ """Raised when an invariant declaration is invalid."""
@@ -0,0 +1,86 @@
1
+ """Parent-side orchestration for subprocess authorizer evaluation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from permissiondiff.models import AuthorizationCase, CaseEvaluation, Decision
12
+
13
+
14
+ def evaluate_case(
15
+ case: AuthorizationCase,
16
+ authorizer_spec: str,
17
+ *,
18
+ workdir: Path,
19
+ timeout_seconds: float,
20
+ ) -> CaseEvaluation:
21
+ """Evaluate a case in a fresh worker process with a hard timeout."""
22
+ command = [sys.executable, "-m", "permissiondiff.worker", authorizer_spec]
23
+ payload = json.dumps(case.to_dict(), sort_keys=True, separators=(",", ":"))
24
+ environment = os.environ.copy()
25
+ package_root = str(Path(__file__).resolve().parents[1])
26
+ inherited_path = environment.get("PYTHONPATH")
27
+ environment["PYTHONPATH"] = os.pathsep.join(
28
+ [package_root, inherited_path] if inherited_path else [package_root]
29
+ )
30
+ try:
31
+ completed = subprocess.run(
32
+ command,
33
+ input=payload,
34
+ text=True,
35
+ capture_output=True,
36
+ cwd=workdir,
37
+ env=environment,
38
+ timeout=timeout_seconds,
39
+ check=False,
40
+ )
41
+ except subprocess.TimeoutExpired:
42
+ return CaseEvaluation(
43
+ case=case,
44
+ error=f"authorizer timed out after {timeout_seconds:g} seconds",
45
+ )
46
+ except OSError as exc:
47
+ return CaseEvaluation(case=case, error=f"could not start authorizer worker: {exc}")
48
+
49
+ if completed.returncode != 0:
50
+ detail = completed.stderr.strip() or completed.stdout.strip() or "no worker output"
51
+ return CaseEvaluation(
52
+ case=case,
53
+ error=f"authorizer worker exited with code {completed.returncode}: {detail}",
54
+ )
55
+ try:
56
+ result = json.loads(completed.stdout)
57
+ except json.JSONDecodeError:
58
+ return CaseEvaluation(case=case, error="authorizer worker returned invalid JSON")
59
+ if not isinstance(result, dict):
60
+ return CaseEvaluation(case=case, error="authorizer worker returned a non-object result")
61
+ if isinstance(result.get("error"), str):
62
+ return CaseEvaluation(case=case, error=result["error"])
63
+ try:
64
+ decision = Decision(result["decision"])
65
+ except (KeyError, TypeError, ValueError):
66
+ return CaseEvaluation(case=case, error="authorizer worker returned an invalid decision")
67
+ return CaseEvaluation(case=case, decision=decision)
68
+
69
+
70
+ def evaluate_cases(
71
+ cases: list[AuthorizationCase] | tuple[AuthorizationCase, ...],
72
+ authorizer_spec: str,
73
+ *,
74
+ workdir: Path,
75
+ timeout_seconds: float,
76
+ ) -> list[CaseEvaluation]:
77
+ """Evaluate cases independently so a crash cannot corrupt parent state."""
78
+ return [
79
+ evaluate_case(
80
+ case,
81
+ authorizer_spec,
82
+ workdir=workdir,
83
+ timeout_seconds=timeout_seconds,
84
+ )
85
+ for case in cases
86
+ ]
@@ -0,0 +1,148 @@
1
+ """Bounded authorization-case generation over explicit application domains."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from collections.abc import Callable, Iterable
7
+ from dataclasses import replace
8
+
9
+ from hypothesis import HealthCheck, Phase, find, given, settings
10
+ from hypothesis import seed as hypothesis_seed
11
+ from hypothesis import strategies as st
12
+ from hypothesis.strategies import SearchStrategy
13
+
14
+ from permissiondiff.config import PermissionDiffConfig
15
+ from permissiondiff.engine import finding_case_complexity, finding_equivalence_key
16
+ from permissiondiff.models import Action, AuthorizationCase, Context, Finding, Resource, Subject
17
+
18
+
19
+ def case_strategy(config: PermissionDiffConfig) -> SearchStrategy[AuthorizationCase]:
20
+ """Build a Hypothesis strategy that combines only declared domain entities."""
21
+ subjects = [subject.to_domain() for subject in config.subjects]
22
+ resources = [resource.to_domain() for resource in config.resources]
23
+ actions = [Action(name) for name in config.actions]
24
+ amount_strategy: SearchStrategy[int | None]
25
+ if config.contexts.amounts is None:
26
+ amount_strategy = st.none()
27
+ else:
28
+ amount_domain = config.contexts.amounts
29
+ boundary_values = amount_domain.interesting_values()
30
+ amount_strategy = st.one_of(
31
+ st.sampled_from(boundary_values),
32
+ st.integers(min_value=amount_domain.min, max_value=amount_domain.max),
33
+ )
34
+ return st.builds(
35
+ AuthorizationCase,
36
+ subject=st.sampled_from(subjects),
37
+ action=st.sampled_from(actions),
38
+ resource=st.sampled_from(resources),
39
+ context=st.builds(Context, amount=amount_strategy),
40
+ )
41
+
42
+
43
+ def generate_cases(
44
+ config: PermissionDiffConfig,
45
+ *,
46
+ seed: int,
47
+ max_examples: int | None = None,
48
+ ) -> list[AuthorizationCase]:
49
+ """Generate a stable, bounded corpus with relationship and numeric boundaries."""
50
+ limit = max_examples or config.generation.max_examples
51
+ if limit < 1:
52
+ raise ValueError("max_examples must be positive")
53
+
54
+ cases_by_fingerprint: dict[str, AuthorizationCase] = {}
55
+ coverage_budget = max(1, limit // 2)
56
+ for case in _coverage_cases(config):
57
+ cases_by_fingerprint.setdefault(case.fingerprint, case)
58
+ if len(cases_by_fingerprint) >= coverage_budget:
59
+ break
60
+
61
+ remaining = limit - len(cases_by_fingerprint)
62
+ if remaining:
63
+ strategy = case_strategy(config)
64
+
65
+ @hypothesis_seed(seed)
66
+ @settings(
67
+ max_examples=max(remaining * 3, remaining),
68
+ database=None,
69
+ deadline=None,
70
+ phases=(Phase.generate,),
71
+ suppress_health_check=(HealthCheck.filter_too_much,),
72
+ )
73
+ @given(strategy)
74
+ def collect(case: AuthorizationCase) -> None:
75
+ if len(cases_by_fingerprint) < limit:
76
+ cases_by_fingerprint.setdefault(case.fingerprint, case)
77
+
78
+ collect()
79
+
80
+ return sorted(cases_by_fingerprint.values(), key=lambda case: case.fingerprint)[:limit]
81
+
82
+
83
+ def shrink_findings(findings: Iterable[Finding], *, seed: int) -> list[Finding]:
84
+ """Use Hypothesis shrinking to select one minimal exact-corpus reproduction."""
85
+ groups: dict[tuple[object, ...], list[Finding]] = {}
86
+ for finding in findings:
87
+ groups.setdefault(finding_equivalence_key(finding), []).append(finding)
88
+
89
+ minimized: list[Finding] = []
90
+ for index, equivalents in enumerate(groups.values()):
91
+ ordered = sorted(equivalents, key=finding_case_complexity)
92
+ smallest = find(
93
+ st.sampled_from(ordered),
94
+ equivalents.__contains__,
95
+ settings=settings(
96
+ max_examples=max(10, len(ordered) * 2),
97
+ database=None,
98
+ deadline=None,
99
+ phases=(Phase.generate, Phase.shrink),
100
+ ),
101
+ random=random.Random(seed + index),
102
+ )
103
+ minimized.append(replace(smallest, equivalent_cases=len(equivalents)))
104
+ return sorted(minimized, key=lambda finding: finding.semantic_key)
105
+
106
+
107
+ def _coverage_cases(config: PermissionDiffConfig) -> Iterable[AuthorizationCase]:
108
+ """Yield a lazy spine covering same/cross-tenant and owner relationships."""
109
+ subjects = [subject.to_domain() for subject in config.subjects]
110
+ resources = [resource.to_domain() for resource in config.resources]
111
+ amounts: tuple[int | None, ...]
112
+ if config.contexts.amounts is None:
113
+ amounts = (None,)
114
+ else:
115
+ amounts = config.contexts.amounts.interesting_values()
116
+
117
+ index = 0
118
+ for action_name in config.actions:
119
+ for subject in subjects:
120
+ for resource in _relationship_resources(subject, resources):
121
+ amount = amounts[index % len(amounts)]
122
+ index += 1
123
+ yield AuthorizationCase(
124
+ subject=subject,
125
+ action=Action(action_name),
126
+ resource=resource,
127
+ context=Context(amount=amount),
128
+ )
129
+
130
+
131
+ def _relationship_resources(subject: Subject, resources: list[Resource]) -> list[Resource]:
132
+ selected: list[Resource] = []
133
+ predicates: tuple[Callable[[Resource], bool], ...] = (
134
+ lambda resource: resource.owner_id == subject.id,
135
+ lambda resource: resource.tenant == subject.tenant,
136
+ lambda resource: (
137
+ subject.tenant is not None
138
+ and resource.tenant is not None
139
+ and resource.tenant != subject.tenant
140
+ ),
141
+ )
142
+ for predicate in predicates:
143
+ match = next((resource for resource in resources if predicate(resource)), None)
144
+ if match is not None and match not in selected:
145
+ selected.append(match)
146
+ if not selected:
147
+ selected.append(resources[0])
148
+ return selected
@@ -0,0 +1,193 @@
1
+ """Deterministic built-in and custom authorization invariants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any, Protocol
10
+
11
+ from permissiondiff.errors import InvariantDefinitionError
12
+ from permissiondiff.loader import load_callable
13
+ from permissiondiff.models import AuthorizationCase, Decision, InvariantResult
14
+
15
+
16
+ class Invariant(Protocol):
17
+ """Interface shared by every deterministic invariant."""
18
+
19
+ @property
20
+ def name(self) -> str: ...
21
+
22
+ def evaluate(self, case: AuthorizationCase, decision: Decision) -> InvariantResult: ...
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class TenantIsolationInvariant:
27
+ """Require cross-tenant authorization cases to be denied."""
28
+
29
+ name: str = "tenant_isolation"
30
+
31
+ def evaluate(self, case: AuthorizationCase, decision: Decision) -> InvariantResult:
32
+ """Check tenant isolation when both tenants are known and differ."""
33
+ applies = (
34
+ case.subject.tenant is not None
35
+ and case.resource.tenant is not None
36
+ and case.subject.tenant != case.resource.tenant
37
+ )
38
+ passed = not applies or decision is Decision.DENY
39
+ return InvariantResult(
40
+ name=self.name,
41
+ passed=passed,
42
+ message=(
43
+ "Cross-tenant access must be denied" if not passed else "Tenant isolation holds"
44
+ ),
45
+ )
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class RoleBoundaryInvariant:
50
+ """Restrict selected actions to an explicit set of roles."""
51
+
52
+ actions: frozenset[str]
53
+ allowed_roles: frozenset[str]
54
+ name: str = "role_boundary"
55
+
56
+ def evaluate(self, case: AuthorizationCase, decision: Decision) -> InvariantResult:
57
+ """Require denial when a selected action is attempted by another role."""
58
+ applies = case.action.name in self.actions and case.subject.role not in self.allowed_roles
59
+ passed = not applies or decision is Decision.DENY
60
+ return InvariantResult(
61
+ name=self.name,
62
+ passed=passed,
63
+ message=(
64
+ f"Role {case.subject.role!r} must not perform {case.action.name!r}"
65
+ if not passed
66
+ else "Role boundary holds"
67
+ ),
68
+ )
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class OwnershipInvariant:
73
+ """Require non-owners to be denied for selected actions."""
74
+
75
+ actions: frozenset[str]
76
+ name: str = "ownership"
77
+
78
+ def evaluate(self, case: AuthorizationCase, decision: Decision) -> InvariantResult:
79
+ """Check ownership only when the resource declares an owner."""
80
+ applies = (
81
+ case.action.name in self.actions
82
+ and case.resource.owner_id is not None
83
+ and case.subject.id != case.resource.owner_id
84
+ )
85
+ passed = not applies or decision is Decision.DENY
86
+ return InvariantResult(
87
+ name=self.name,
88
+ passed=passed,
89
+ message=(
90
+ "Non-owner access must be denied" if not passed else "Ownership boundary holds"
91
+ ),
92
+ )
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class CustomInvariant:
97
+ """Adapter for a user-defined deterministic invariant callable."""
98
+
99
+ name: str
100
+ function: Callable[[AuthorizationCase, Decision], bool | InvariantResult]
101
+
102
+ def evaluate(self, case: AuthorizationCase, decision: Decision) -> InvariantResult:
103
+ """Validate and normalize the custom invariant result."""
104
+ try:
105
+ result = self.function(case, decision)
106
+ except Exception as exc:
107
+ raise InvariantDefinitionError(
108
+ f"custom invariant {self.name!r} crashed: {exc}"
109
+ ) from exc
110
+ if isinstance(result, InvariantResult):
111
+ return result
112
+ if isinstance(result, bool):
113
+ return InvariantResult(
114
+ name=self.name,
115
+ passed=result,
116
+ message=(
117
+ f"Custom invariant {self.name!r} failed"
118
+ if not result
119
+ else f"Custom invariant {self.name!r} holds"
120
+ ),
121
+ )
122
+ raise InvariantDefinitionError(
123
+ f"custom invariant {self.name!r} returned {type(result).__name__}, expected bool"
124
+ )
125
+
126
+
127
+ def build_invariants(declarations: list[str | dict[str, Any]], *, workdir: Path) -> list[Invariant]:
128
+ """Turn validated config declarations into core invariant objects."""
129
+ invariants: list[Invariant] = []
130
+ for declaration in declarations:
131
+ if isinstance(declaration, str):
132
+ if declaration != "tenant_isolation":
133
+ raise InvariantDefinitionError(f"unknown invariant {declaration!r}")
134
+ invariants.append(TenantIsolationInvariant())
135
+ continue
136
+ if len(declaration) != 1:
137
+ raise InvariantDefinitionError("invariant mappings must contain exactly one key")
138
+ name, raw_options = next(iter(declaration.items()))
139
+ options = _options(raw_options, name)
140
+ if name == "tenant_isolation":
141
+ invariants.append(TenantIsolationInvariant())
142
+ elif name == "role_boundary":
143
+ actions = _actions(options, name)
144
+ roles = options.get("allowed_roles")
145
+ if (
146
+ not isinstance(roles, list)
147
+ or not roles
148
+ or not all(isinstance(role, str) for role in roles)
149
+ ):
150
+ raise InvariantDefinitionError("role_boundary.allowed_roles must be a string list")
151
+ invariants.append(RoleBoundaryInvariant(actions, frozenset(roles)))
152
+ elif name == "ownership":
153
+ invariants.append(OwnershipInvariant(_actions(options, name)))
154
+ elif name == "custom":
155
+ spec = options.get("callable")
156
+ custom_name = options.get("name", spec)
157
+ if not isinstance(spec, str) or not isinstance(custom_name, str):
158
+ raise InvariantDefinitionError(
159
+ "custom invariant requires callable and optional name"
160
+ )
161
+ invariants.append(CustomInvariant(custom_name, _load_from_workdir(spec, workdir)))
162
+ else:
163
+ raise InvariantDefinitionError(f"unknown invariant {name!r}")
164
+ return invariants
165
+
166
+
167
+ def _actions(options: dict[str, Any], name: str) -> frozenset[str]:
168
+ raw = options.get("actions", options.get("action"))
169
+ if isinstance(raw, str):
170
+ return frozenset({raw})
171
+ if isinstance(raw, list) and raw and all(isinstance(action, str) for action in raw):
172
+ return frozenset(raw)
173
+ raise InvariantDefinitionError(f"{name}.actions must be a string or non-empty string list")
174
+
175
+
176
+ def _options(value: object, name: str) -> dict[str, Any]:
177
+ if value is None:
178
+ return {}
179
+ if not isinstance(value, dict):
180
+ raise InvariantDefinitionError(f"{name} options must be a mapping")
181
+ return value
182
+
183
+
184
+ def _load_from_workdir(spec: str, workdir: Path) -> Callable[..., Any]:
185
+ path = str(workdir.resolve())
186
+ added = path not in sys.path
187
+ if added:
188
+ sys.path.insert(0, path)
189
+ try:
190
+ return load_callable(spec)
191
+ finally:
192
+ if added:
193
+ sys.path.remove(path)
@@ -0,0 +1,27 @@
1
+ """Import helpers for user-provided authorizers and custom invariants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ from collections.abc import Callable
7
+ from typing import Any, cast
8
+
9
+ from permissiondiff.errors import AdapterError
10
+
11
+
12
+ def load_callable(spec: str) -> Callable[..., Any]:
13
+ """Load a callable from a strict ``module:attribute`` reference."""
14
+ module_name, separator, attribute_name = spec.partition(":")
15
+ if not separator or not module_name or not attribute_name:
16
+ raise AdapterError(f"invalid callable {spec!r}; expected 'module:function'")
17
+ try:
18
+ module = importlib.import_module(module_name)
19
+ except Exception as exc:
20
+ raise AdapterError(f"could not import module {module_name!r}: {exc}") from exc
21
+ try:
22
+ value = getattr(module, attribute_name)
23
+ except AttributeError as exc:
24
+ raise AdapterError(f"module {module_name!r} has no attribute {attribute_name!r}") from exc
25
+ if not callable(value):
26
+ raise AdapterError(f"{spec!r} does not reference a callable")
27
+ return cast("Callable[..., Any]", value)