anvil 0.30.1__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 (71) hide show
  1. anvil/__init__.py +0 -0
  2. anvil/_components.py +282 -0
  3. anvil/actions.py +17 -0
  4. anvil/benchmark.py +50 -0
  5. anvil/cli.py +1487 -0
  6. anvil/descriptors.py +155 -0
  7. anvil/execution_context.py +24 -0
  8. anvil/processor_loader.py +470 -0
  9. anvil/processor_validation.py +90 -0
  10. anvil/processors/__init__.py +0 -0
  11. anvil/processors/html_report.py +645 -0
  12. anvil/processors/sarif_report.py +348 -0
  13. anvil/provider_loader.py +170 -0
  14. anvil/providers/__init__.py +1 -0
  15. anvil/providers/aws/__init__.py +17 -0
  16. anvil/providers/aws/account.py +178 -0
  17. anvil/providers/aws/account_resolver.py +66 -0
  18. anvil/providers/aws/auth.py +148 -0
  19. anvil/providers/aws/config.py +13 -0
  20. anvil/providers/aws/organization.py +215 -0
  21. anvil/providers/aws/provider.py +497 -0
  22. anvil/providers/aws/regions.py +65 -0
  23. anvil/providers/aws/session.py +258 -0
  24. anvil/providers/aws/tasks/__init__.py +1 -0
  25. anvil/providers/aws/tasks/compare_asg_to_cluster_instances.py +167 -0
  26. anvil/providers/aws/tasks/count_subnets_with_timings.py +140 -0
  27. anvil/providers/aws/tasks/count_vpc.py +68 -0
  28. anvil/providers/aws/tasks/detect_deprecated_lambda_runtimes.py +204 -0
  29. anvil/providers/aws/tasks/get_aws_inline_policies.py +309 -0
  30. anvil/providers/aws/tasks/get_organization_structure.py +197 -0
  31. anvil/providers/aws/tasks/list_lambdas_by_runtime.py +145 -0
  32. anvil/providers/aws/tasks/remove_iam_user.py +243 -0
  33. anvil/providers/aws/tasks/remove_missing_group_assignments.py +311 -0
  34. anvil/providers/azure/__init__.py +20 -0
  35. anvil/providers/azure/provider.py +830 -0
  36. anvil/providers/azure/tasks/__init__.py +1 -0
  37. anvil/providers/azure/tasks/count_resource_groups.py +128 -0
  38. anvil/providers/base.py +278 -0
  39. anvil/providers/gcp/__init__.py +17 -0
  40. anvil/providers/gcp/provider.py +585 -0
  41. anvil/providers/gcp/tasks/__init__.py +1 -0
  42. anvil/providers/gcp/tasks/get_project_info.py +137 -0
  43. anvil/providers/github/__init__.py +20 -0
  44. anvil/providers/github/provider.py +1611 -0
  45. anvil/providers/github/tasks/__init__.py +1 -0
  46. anvil/providers/github/tasks/_rest.py +246 -0
  47. anvil/providers/github/tasks/audit_branch_protection.py +82 -0
  48. anvil/providers/github/tasks/audit_repo_security_settings.py +76 -0
  49. anvil/providers/github/tasks/audit_rulesets.py +73 -0
  50. anvil/providers/github/tasks/list_code_scanning_alerts.py +75 -0
  51. anvil/providers/github/tasks/list_dependabot_alerts.py +78 -0
  52. anvil/providers/github/tasks/list_secret_scanning_alerts.py +78 -0
  53. anvil/providers/github/tasks/search_code.py +283 -0
  54. anvil/providers/tasks/__init__.py +1 -0
  55. anvil/providers/tasks/noop.py +58 -0
  56. anvil/providers/tasks/noop_fail.py +62 -0
  57. anvil/regions.py +151 -0
  58. anvil/result_query.py +537 -0
  59. anvil/results.py +325 -0
  60. anvil/runner.py +1257 -0
  61. anvil/schemas/common.schema.v2.json +174 -0
  62. anvil/schemas/targets.schema.v2.json +102 -0
  63. anvil/task_context.py +35 -0
  64. anvil/task_loader.py +520 -0
  65. anvil/task_validation.py +109 -0
  66. anvil/validators.py +194 -0
  67. anvil-0.30.1.dist-info/METADATA +471 -0
  68. anvil-0.30.1.dist-info/RECORD +71 -0
  69. anvil-0.30.1.dist-info/WHEEL +4 -0
  70. anvil-0.30.1.dist-info/entry_points.txt +3 -0
  71. anvil-0.30.1.dist-info/licenses/LICENSE +21 -0
anvil/__init__.py ADDED
File without changes
anvil/_components.py ADDED
@@ -0,0 +1,282 @@
1
+ """Shared lazy component discovery and resolution primitives.
2
+
3
+ This module deliberately knows nothing about task, processor, or provider runtime
4
+ contracts. Component-specific loaders validate those contracts after selection.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib
10
+ import pkgutil
11
+ from collections import defaultdict
12
+ from collections.abc import Callable, Iterable, Mapping
13
+ from dataclasses import dataclass, field
14
+ from enum import StrEnum
15
+ from importlib.metadata import EntryPoint
16
+ from inspect import Parameter, signature
17
+ from types import MappingProxyType
18
+ from typing import Generic, TypeVar
19
+
20
+
21
+ T = TypeVar("T")
22
+
23
+
24
+ class ComponentKind(StrEnum):
25
+ """Kinds of extension components understood by Anvil."""
26
+
27
+ TASK = "task"
28
+ PROCESSOR = "processor"
29
+ PROVIDER = "provider"
30
+
31
+
32
+ class ComponentOrigin(StrEnum):
33
+ """Origin of a discovered component."""
34
+
35
+ STOCK = "stock"
36
+ PLUGIN = "plugin"
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class ComponentSource:
41
+ """Structured origin information for a discovered component."""
42
+
43
+ origin: ComponentOrigin
44
+ package: str
45
+ label: str
46
+ distribution: str | None = None
47
+ entry_point_group: str | None = None
48
+ entry_point_name: str | None = None
49
+ provider: str | None = None
50
+
51
+ def __str__(self) -> str:
52
+ return self.label
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class ComponentDescriptor(Generic[T]):
57
+ """A named component and a loader that imports only that component."""
58
+
59
+ name: str
60
+ source: ComponentSource
61
+ load: Callable[[], T] = field(compare=False, hash=False, repr=False)
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class DiscoveryIssue:
66
+ """A non-fatal problem encountered while inspecting a component source."""
67
+
68
+ name: str
69
+ source: ComponentSource
70
+ error: str
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class ComponentCatalog(Generic[T]):
75
+ """Immutable discovery snapshot retaining every candidate by name."""
76
+
77
+ descriptors: tuple[ComponentDescriptor[T], ...]
78
+ issues: tuple[DiscoveryIssue, ...] = ()
79
+ inventory: Mapping[str, tuple[ComponentDescriptor[T], ...]] = field(init=False)
80
+
81
+ def __post_init__(self) -> None:
82
+ grouped: dict[str, list[ComponentDescriptor[T]]] = defaultdict(list)
83
+ for descriptor in self.descriptors:
84
+ grouped[descriptor.name].append(descriptor)
85
+ inventory = {
86
+ name: tuple(candidates) for name, candidates in sorted(grouped.items())
87
+ }
88
+ object.__setattr__(self, "inventory", MappingProxyType(inventory))
89
+
90
+ @classmethod
91
+ def build(
92
+ cls,
93
+ descriptors: Iterable[ComponentDescriptor[T]],
94
+ issues: Iterable[DiscoveryIssue] = (),
95
+ ) -> ComponentCatalog[T]:
96
+ """Build a deterministically ordered immutable catalog."""
97
+
98
+ return cls(
99
+ descriptors=tuple(sorted(descriptors, key=_descriptor_sort_key)),
100
+ issues=tuple(sorted(issues, key=_issue_sort_key)),
101
+ )
102
+
103
+
104
+ class ComponentResolutionError(RuntimeError):
105
+ """Raised when a component name is missing or ambiguous."""
106
+
107
+
108
+ @dataclass(frozen=True, slots=True)
109
+ class PackageComponentSource(Generic[T]):
110
+ """Discover immediate public children of one importable package root."""
111
+
112
+ package_name: str
113
+ source: ComponentSource
114
+ component_loader: Callable[[str, str, ComponentSource], T]
115
+ reserved_children: frozenset[str] = frozenset()
116
+
117
+ def discover(
118
+ self, *, issue_name: str | None = None
119
+ ) -> tuple[tuple[ComponentDescriptor[T], ...], tuple[DiscoveryIssue, ...]]:
120
+ """Return lazy child descriptors without importing child modules."""
121
+
122
+ try:
123
+ package = importlib.import_module(self.package_name)
124
+ except Exception as error:
125
+ return (), (
126
+ DiscoveryIssue(
127
+ name=issue_name or self.package_name,
128
+ source=self.source,
129
+ error=f"package import failed ({error})",
130
+ ),
131
+ )
132
+
133
+ package_path = getattr(package, "__path__", None)
134
+ if package_path is None:
135
+ return (), (
136
+ DiscoveryIssue(
137
+ name=issue_name or self.package_name,
138
+ source=self.source,
139
+ error="component source must reference a package",
140
+ ),
141
+ )
142
+
143
+ descriptors: list[ComponentDescriptor[T]] = []
144
+ for module_info in pkgutil.iter_modules(package_path):
145
+ name = module_info.name
146
+ if name.startswith("_") or name in self.reserved_children:
147
+ continue
148
+ descriptors.append(
149
+ ComponentDescriptor(
150
+ name=name,
151
+ source=self.source,
152
+ load=lambda n=name: self.component_loader(
153
+ self.package_name, n, self.source
154
+ ),
155
+ )
156
+ )
157
+ return tuple(descriptors), ()
158
+
159
+
160
+ class ComponentResolver(Generic[T]):
161
+ """Resolve unique names and load only selected components."""
162
+
163
+ def __init__(
164
+ self,
165
+ *,
166
+ kind: ComponentKind,
167
+ catalog: ComponentCatalog[T],
168
+ error_type: type[Exception] = ComponentResolutionError,
169
+ context: str | None = None,
170
+ ) -> None:
171
+ self._kind = kind
172
+ self._catalog = catalog
173
+ self._error_type = error_type
174
+ self._context = context
175
+
176
+ def catalog(self) -> ComponentCatalog[T]:
177
+ """Return the resolver's immutable discovery snapshot."""
178
+
179
+ return self._catalog
180
+
181
+ def descriptor(self, name: str) -> ComponentDescriptor[T]:
182
+ """Return the sole descriptor for name or raise an actionable error."""
183
+
184
+ candidates = self._catalog.inventory.get(name, ())
185
+ context = f" {self._context}" if self._context else ""
186
+ if not candidates:
187
+ available = ", ".join(self._catalog.inventory) or "none"
188
+ raise self._error_type(
189
+ f"Unknown {self._kind.value}{context} '{name}'. "
190
+ f"Available {self._kind.value}s: {available}"
191
+ )
192
+ if len(candidates) > 1:
193
+ sources = ", ".join(str(candidate.source) for candidate in candidates)
194
+ raise self._error_type(
195
+ f"{self._kind.value.capitalize()} '{name}' is ambiguous{context}; "
196
+ f"found in multiple sources: {sources}"
197
+ )
198
+ return candidates[0]
199
+
200
+ def load(self, name: str) -> T:
201
+ """Load the uniquely selected component."""
202
+
203
+ return self.descriptor(name).load()
204
+
205
+
206
+ def validate_keyword_only_invocation(
207
+ callable_object: Callable[..., object], *, keyword_names: frozenset[str]
208
+ ) -> None:
209
+ """Validate that a callable accepts the supplied runtime keywords.
210
+
211
+ Additional optional keyword-only parameters and ``**kwargs`` are allowed.
212
+ Additional required parameters are rejected because the runtime cannot
213
+ supply them.
214
+
215
+ Args:
216
+ callable_object: Callable to inspect.
217
+ keyword_names: Runtime keyword names that will always be supplied.
218
+
219
+ Raises:
220
+ ValueError: If the signature cannot accept the runtime invocation.
221
+ """
222
+
223
+ try:
224
+ callable_signature = signature(callable_object)
225
+ except (TypeError, ValueError) as error:
226
+ raise ValueError("unable to inspect callable signature") from error
227
+
228
+ parameters = callable_signature.parameters
229
+ missing = keyword_names - set(parameters)
230
+ if missing:
231
+ raise ValueError(f"missing required parameters: {sorted(missing)}")
232
+
233
+ unsupported_parameters = sorted(
234
+ parameter.name
235
+ for parameter in parameters.values()
236
+ if parameter.kind not in {Parameter.KEYWORD_ONLY, Parameter.VAR_KEYWORD}
237
+ )
238
+ if unsupported_parameters:
239
+ raise ValueError(f"parameters must be keyword-only: {unsupported_parameters}")
240
+
241
+ invocation_kwargs = {name: object() for name in keyword_names}
242
+ try:
243
+ callable_signature.bind(**invocation_kwargs)
244
+ except TypeError as error:
245
+ raise ValueError(f"cannot be invoked with runtime keywords: {error}") from error
246
+
247
+
248
+ def source_from_entry_point(
249
+ *,
250
+ entry_point: EntryPoint,
251
+ package: str,
252
+ label_prefix: str = "plugin:",
253
+ provider: str | None = None,
254
+ ) -> ComponentSource:
255
+ """Build structured source metadata for a package entry point."""
256
+
257
+ distribution = entry_point.dist.name if entry_point.dist is not None else None
258
+ distribution_label = distribution or "unpackaged"
259
+ return ComponentSource(
260
+ origin=ComponentOrigin.PLUGIN,
261
+ package=package,
262
+ label=f"{label_prefix} {distribution_label}",
263
+ distribution=distribution,
264
+ entry_point_group=entry_point.group,
265
+ entry_point_name=entry_point.name,
266
+ provider=provider,
267
+ )
268
+
269
+
270
+ def _descriptor_sort_key(
271
+ descriptor: ComponentDescriptor[T],
272
+ ) -> tuple[str, str, str, str]:
273
+ return (
274
+ descriptor.source.label,
275
+ descriptor.name,
276
+ descriptor.source.entry_point_name or "",
277
+ descriptor.source.package,
278
+ )
279
+
280
+
281
+ def _issue_sort_key(issue: DiscoveryIssue) -> tuple[str, str, str]:
282
+ return (issue.source.label, issue.name, issue.error)
anvil/actions.py ADDED
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(slots=True)
7
+ class ActionRecorder:
8
+ """
9
+ Collects actions performed during task execution.
10
+
11
+ Created by the engine and passed into tasks.
12
+ """
13
+
14
+ actions: list[str]
15
+
16
+ def record(self, message: str) -> None:
17
+ self.actions.append(message)
anvil/benchmark.py ADDED
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from contextlib import contextmanager
5
+ from typing import Iterator
6
+
7
+
8
+ class BenchmarkRecorder:
9
+ """
10
+ Small opt-in recorder for benchmark phase timings and metadata.
11
+ """
12
+
13
+ def __init__(
14
+ self, *, enabled: bool = False, data: dict[str, object] | None = None
15
+ ) -> None:
16
+ self._data = data if data is not None else ({} if enabled else None)
17
+
18
+ @property
19
+ def enabled(self) -> bool:
20
+ return self._data is not None
21
+
22
+ @property
23
+ def data(self) -> dict[str, object] | None:
24
+ return self._data
25
+
26
+ @contextmanager
27
+ def phase(self, name: str) -> Iterator[None]:
28
+ if self._data is None:
29
+ yield
30
+ return
31
+
32
+ started = time.perf_counter()
33
+ try:
34
+ yield
35
+ finally:
36
+ self._data[name] = time.perf_counter() - started
37
+
38
+ def set(self, name: str, value: object) -> None:
39
+ if self._data is not None:
40
+ self._data[name] = value
41
+
42
+ def pop(self, name: str) -> object | None:
43
+ if self._data is None:
44
+ return None
45
+
46
+ return self._data.pop(name, None)
47
+
48
+ def update(self, values: dict[str, object]) -> None:
49
+ if self._data is not None:
50
+ self._data.update(values)