Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,10 @@ Prefer explicit type checks over assuming YAML input shape. Validate lists, bool
```python
if dry_run:
__LOGGER__.info(
f"(dry-run) Would delete IAM user {user_name} in target "
f"{execution_target_id}"
f"(dry-run) Would delete IAM user {user_name} in target {execution_target_id}"
)
actions.record(
f"(dry-run) Would delete IAM user {user_name} in target "
f"{execution_target_id}"
f"(dry-run) Would delete IAM user {user_name} in target {execution_target_id}"
)
return {"planned": True, "deleted": False, "user_name": user_name}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ Runtime facts:
- For region-scoped tasks, `region` is the current task execution region. AWS
sessions also expose `session.region_name`.
- Operator-provided task inputs come from `metadata`.
- Tasks should treat metadata as read-only configuration.
Anvil isolates changes to top-level metadata keys,
but not changes inside nested lists or dictionaries.
- `actions` is an `ActionRecorder` for audit-level actions.
- Returned values are included in Anvil result JSON.
- The engine already includes execution context such as target identity, `region`, and `dry_run` in normal results.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,7 @@ Each finding must include:
}
],
"fingerprint": "stable-rule-account-region-resource-condition",
"properties": {
"resource_name": "example",
},
"properties": {"resource_name": "example"},
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ Ensure the code is clean, maintainable, PEP 8 compliant, and high quality.
Avoid:

```python
def run(x,y): return x+y
def run(x, y):
return x + y
```

Prefer:
Expand Down
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
repos:
- repo: https://github.com/astral-sh/uv-pre-commit
# uv version.
rev: d9fca3320346514799461a80b0753eb45d707d46 # 0.11.28
rev: e3e6ef7d9bda544b2e795782dbd7d2a4fbd7eb6d # 0.11.32
hooks:
- id: uv-lock
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: 01a675ea018f2fb714478a5ffb83fcea8374bb06 # v0.15.21
rev: cb8c523fd4835aba42af70f4cad5568db4df0b6c # v0.16.0
hooks:
# Run the linter. https://docs.astral.sh/ruff/linter/
- id: ruff-check
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,9 @@ Run focused validation categories:
anvil validate --tasks --processors --auth --config-file ./yaml/orgs.yaml
```

`--tasks` and `--processors` validate discovery and callable signatures.
`--tasks` and `--processors` validate discovery, keyword-only callable
signatures, and operator-facing detail documentation. Validation rejects
additional required parameters that Anvil cannot supply at runtime.
`--providers` validates the provider contract. `--auth` validates cloud access
for the configured targets after loading and validating the config file.

Expand All @@ -279,6 +281,11 @@ See more at [Task validation](https://opsfoundry.dev/anvil/task-contract/#task-v
Processors run after a target finishes and turn Anvil results into reports or
integration artifacts. Use them for formats that should stay outside task logic,
such as HTML, SARIF, Markdown, JSON summaries, tickets, or notification payloads.
Processor modules expose a documented keyword-only
`run(*, context, output, metadata)` callable. `context.target_results` is the
canonical result collection; target-level runs additionally set
`context.target_name`, from which `target_result` and `target_result_path` are
derived. Treat context data and processor metadata as invocation snapshots.
Target `post_run` processor output is written under the run's `reports`
directory, so `output: smoke.html` becomes `<run_dir>/reports/smoke.html`.

Expand Down
18 changes: 4 additions & 14 deletions examples/Results/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,7 @@ __LOGGER__ = logging.getLogger(__name__)


def cleanup_user_resources(
iam_client,
user_name: str,
dry_run: bool,
iam_client, user_name: str, dry_run: bool
) -> dict[str, object]:
group_results: list[dict[str, object]] = []
access_key_results: list[dict[str, object]] = []
Expand Down Expand Up @@ -151,11 +149,7 @@ def run(
raise RuntimeError("example_cleanup requires metadata.user_name to be a string")

iam = session.client("iam")
return cleanup_user_resources(
iam_client=iam,
user_name=user_name,
dry_run=dry_run,
)
return cleanup_user_resources(iam_client=iam, user_name=user_name, dry_run=dry_run)
```

The returned value appears in the task result:
Expand Down Expand Up @@ -211,6 +205,7 @@ Record actions directly inside the required `run()` function for small tasks:
```python
from anvil.actions import ActionRecorder


def run(
*,
account_id: str,
Expand All @@ -237,12 +232,7 @@ from anvil.actions import ActionRecorder
__LOGGER__ = logging.getLogger(__name__)


def cleanup_user(
iam,
user_name: str,
dry_run: bool,
actions: ActionRecorder,
) -> None:
def cleanup_user(iam, user_name: str, dry_run: bool, actions: ActionRecorder) -> None:
if dry_run:
message = f"(dry-run) Would delete IAM user: {user_name}"
__LOGGER__.debug(message)
Expand Down
25 changes: 24 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ testpaths = ["tests"]

[tool.coverage.run]
omit = [
"src/anvil/tasks/*",
"src/anvil/providers/tasks/*",
]

# Keep release assets empty; update and stage uv.lock without uploading it.
Expand Down Expand Up @@ -126,5 +126,28 @@ task-tags = ["TODO", "FIXME", "HACK"]
[tool.ruff.format]
skip-magic-trailing-comma = true

[tool.ty.analysis]
# Cloud SDKs are imported lazily from optional extras. Matplotlib is used only
# by the optional benchmark chart generator.
allowed-unresolved-imports = [
"azure.**",
"github",
"google.**",
"matplotlib.**",
]

[[tool.ty.overrides]]
include = ["tests/**"]

[tool.ty.overrides.rules]
# Tests intentionally use lightweight structural doubles and heterogeneous
# dictionaries instead of constructing third-party SDK and runtime objects.
invalid-argument-type = "ignore"
unresolved-attribute = "ignore"
unsupported-operator = "ignore"
not-iterable = "ignore"
not-subscriptable = "ignore"
invalid-assignment = "ignore"

[tool.uv]
exclude-newer = "1 week"
14 changes: 12 additions & 2 deletions scripts/plot_benchmarks_grouped.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from pathlib import Path
from typing import TypedDict

import matplotlib.pyplot as plt

Expand All @@ -11,7 +12,16 @@
ANVIL = "#7c4dff"
BASELINE = "#f97316"

ROWS: list[dict[str, object]] = [

class BenchmarkRow(TypedDict):
"""One runtime measurement displayed by the benchmark chart."""

group: str
region_label: str
minutes: float


ROWS: list[BenchmarkRow] = [
{
"group": "Sequential orgs\nSequential accounts",
"region_label": "1 region",
Expand Down Expand Up @@ -65,7 +75,7 @@ def speedup(old: float, new: float) -> float:
return old / new


def plot_grouped(rows: list[dict[str, object]], *, output_path: Path) -> None:
def plot_grouped(rows: list[BenchmarkRow], *, output_path: Path) -> None:
plt.style.use("dark_background")
fig, ax = plt.subplots(figsize=(15, 7.5))

Expand Down
51 changes: 49 additions & 2 deletions src/anvil/_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from dataclasses import dataclass, field
from enum import StrEnum
from importlib.metadata import EntryPoint
from inspect import Parameter, signature
from types import MappingProxyType
from typing import Generic, TypeVar

Expand Down Expand Up @@ -108,7 +109,6 @@ class ComponentResolutionError(RuntimeError):
class PackageComponentSource(Generic[T]):
"""Discover immediate public children of one importable package root."""

kind: ComponentKind
package_name: str
source: ComponentSource
component_loader: Callable[[str, str, ComponentSource], T]
Expand Down Expand Up @@ -203,8 +203,54 @@ def load(self, name: str) -> T:
return self.descriptor(name).load()


def validate_keyword_only_invocation(
callable_object: Callable[..., object], *, keyword_names: frozenset[str]
) -> None:
"""Validate that a callable accepts the supplied runtime keywords.

Additional optional keyword-only parameters and ``**kwargs`` are allowed.
Additional required parameters are rejected because the runtime cannot
supply them.

Args:
callable_object: Callable to inspect.
keyword_names: Runtime keyword names that will always be supplied.

Raises:
ValueError: If the signature cannot accept the runtime invocation.
"""

try:
callable_signature = signature(callable_object)
except (TypeError, ValueError) as error:
raise ValueError("unable to inspect callable signature") from error

parameters = callable_signature.parameters
missing = keyword_names - set(parameters)
if missing:
raise ValueError(f"missing required parameters: {sorted(missing)}")

unsupported_parameters = sorted(
parameter.name
for parameter in parameters.values()
if parameter.kind not in {Parameter.KEYWORD_ONLY, Parameter.VAR_KEYWORD}
)
if unsupported_parameters:
raise ValueError(f"parameters must be keyword-only: {unsupported_parameters}")

invocation_kwargs = {name: object() for name in keyword_names}
try:
callable_signature.bind(**invocation_kwargs)
except TypeError as error:
raise ValueError(f"cannot be invoked with runtime keywords: {error}") from error


def source_from_entry_point(
*, entry_point: EntryPoint, package: str, label_prefix: str = "plugin:"
*,
entry_point: EntryPoint,
package: str,
label_prefix: str = "plugin:",
provider: str | None = None,
) -> ComponentSource:
"""Build structured source metadata for a package entry point."""

Expand All @@ -217,6 +263,7 @@ def source_from_entry_point(
distribution=distribution,
entry_point_group=entry_point.group,
entry_point_name=entry_point.name,
provider=provider,
)


Expand Down
Loading