diff --git a/pyproject.toml b/pyproject.toml index b190f143..0b76d8ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "maturin" [project] name = "roar-cli" -version = "0.3.7" +version = "0.4.0" description = "Reproducibility and provenance tracker for ML training pipelines" authors = [ { name="TReqs Team", email="info@treqs.ai" } diff --git a/roar/application/publish/register_execution.py b/roar/application/publish/register_execution.py index 463fcc17..fb78ff77 100644 --- a/roar/application/publish/register_execution.py +++ b/roar/application/publish/register_execution.py @@ -222,8 +222,15 @@ def register_prepared_lineage( skip_confirmation: bool, confirm_callback: Callable[[list[str]], bool] | None, prepared: PreparedRegisterExecution, + composite_leaf_hashes: frozenset[str] = frozenset(), ) -> RegisterResult: - """Register already-collected local lineage with GLaaS.""" + """Register already-collected local lineage with GLaaS. + + ``composite_leaf_hashes`` are true composite-member hashes subsumed into a view + edge (see ``_prepare_view_edges_for_lineage``) — they have no session-scoped edge + on GLaaS, so they're excluded from label sync even though they still appear in + ``lineage.artifacts`` for ordinary artifact registration. + """ self._logger.debug( "Collected lineage: %d jobs, %d artifacts", len(lineage.jobs), @@ -276,6 +283,12 @@ def register_prepared_lineage( omit_filter=omit_filter, ) + label_artifacts = ( + [a for a in lineage.artifacts if a.get("hash") not in composite_leaf_hashes] + if composite_leaf_hashes + else lineage.artifacts + ) + registration_jobs = order_jobs_for_registration( normalize_jobs_for_registration(lineage.jobs) ) @@ -344,7 +357,7 @@ def register_prepared_lineage( session_id=session_id, session_hash=finalized_session_hash, jobs=remote_registration_jobs, - artifacts=lineage.artifacts, + artifacts=label_artifacts, errors=registration_errors, ) elif batch_result.jobs_failed == 0 and batch_result.links_failed == 0: @@ -391,7 +404,7 @@ def register_prepared_lineage( session_id=session_id, session_hash=finalized_session_hash, jobs=remote_registration_jobs, - artifacts=lineage.artifacts, + artifacts=label_artifacts, errors=registration_errors, ) except Exception as e: @@ -411,7 +424,7 @@ def register_prepared_lineage( session_id=session_id, session_hash=finalized_session_hash, jobs=remote_registration_jobs, - artifacts=lineage.artifacts, + artifacts=label_artifacts, errors=registration_errors, ) else: @@ -456,7 +469,7 @@ def register_prepared_lineage( ), db_ctx=db_ctx, session_id=session_id, - label_artifacts=lineage.artifacts, + label_artifacts=label_artifacts, ) else: batch_result = register_publish_lineage( @@ -473,7 +486,7 @@ def register_prepared_lineage( ), db_ctx=None, session_id=None, - label_artifacts=lineage.artifacts, + label_artifacts=label_artifacts, ) registration_errors.extend(batch_result.errors) diff --git a/roar/application/publish/service.py b/roar/application/publish/service.py index 80fd2088..8f48d03c 100644 --- a/roar/application/publish/service.py +++ b/roar/application/publish/service.py @@ -496,7 +496,7 @@ def _load_remote_job_uid_mapping(*, roar_dir: Path, session_id: Any) -> dict[str def _prepare_view_edges_for_lineage( *, roar_dir: Path, lineage: Any, logger: Any -) -> dict[str, list[Any]]: +) -> tuple[dict[str, list[Any]], set[str]]: """Compute consumes/produces view edges per job and prune the subsumed leaves in-place. A job that read part of a dataset gets a ``consumes`` view edge over its inputs; a job @@ -505,15 +505,21 @@ def _prepare_view_edges_for_lineage( and any anchor linked as a plain input/output — are removed from the bundle so the job links the dataset *view* (job -> view -> anchor -> leaves), not the loose leaves or the anchor directly. The anchor composite stays in ``lineage.artifacts`` (already - collected) so it is still registered. Returns ``{job_uid: [view_edge, ...]}`` to push - after the main registration. + collected) so it is still registered. + + Returns ``({job_uid: [view_edge, ...]}, composite_leaf_hashes)``: the view edges to + push after the main registration, and the union of true component/leaf hashes (never + the anchor's own hash) subsumed across all jobs — a leaf has no session-scoped edge on + GLaaS, so the caller uses this set to keep leaf label/tag documents out of publish-time + label sync too. """ from .view_edges import resolve_view_edges_for_job jobs = getattr(lineage, "jobs", []) or [] if not jobs: - return {} + return {}, set() view_edges_by_job: dict[str, list[Any]] = {} + composite_leaf_hashes: set[str] = set() with create_database_context(roar_dir) as db: for job in jobs: if not isinstance(job, dict): @@ -530,7 +536,7 @@ def _prepare_view_edges_for_lineage( hashes = list(job.get(hashes_key) or []) if not hashes: continue - resolved, prune = resolve_view_edges_for_job( + resolved, prune, leaf_hashes = resolve_view_edges_for_job( db_ctx=db, input_hashes=hashes, relation=relation ) if not resolved: @@ -539,9 +545,10 @@ def _prepare_view_edges_for_lineage( job[hashes_key] = [h for h in hashes if h not in prune] if isinstance(job.get(items_key), list): job[items_key] = [i for i in job[items_key] if i.get("hash") not in prune] + composite_leaf_hashes |= leaf_hashes if edges: view_edges_by_job[str(job_uid)] = edges - return view_edges_by_job + return view_edges_by_job, composite_leaf_hashes def register_lineage_target(request: RegisterLineageRequest) -> RegisterLineageResponse: @@ -628,13 +635,15 @@ def register_lineage_target(request: RegisterLineageRequest) -> RegisterLineageR # bloom over the touched leaves) and prune the subsumed leaves from the bundle. # Pushed after the main registration. Best-effort. view_edges_by_job: dict[str, list[Any]] = {} + composite_leaf_hashes: frozenset[str] = frozenset() if not request.dry_run: try: - view_edges_by_job = _prepare_view_edges_for_lineage( + view_edges_by_job, leaf_hashes = _prepare_view_edges_for_lineage( roar_dir=request.roar_dir, lineage=collected_lineage.lineage, logger=logger, ) + composite_leaf_hashes = frozenset(leaf_hashes) except Exception as exc: # view-edge prep is best-effort logger.debug("view-edge preparation skipped: %s", exc) @@ -728,6 +737,7 @@ def register_lineage_target(request: RegisterLineageRequest) -> RegisterLineageR skip_confirmation=request.skip_confirmation, confirm_callback=request.confirm_callback, prepared=prepared, + composite_leaf_hashes=composite_leaf_hashes, ) # Push the consumes view edges now that the jobs + the anchor composite are diff --git a/roar/application/publish/view_edges.py b/roar/application/publish/view_edges.py index 5cc75de1..384cd061 100644 --- a/roar/application/publish/view_edges.py +++ b/roar/application/publish/view_edges.py @@ -115,16 +115,23 @@ def resolve_view_edges_for_job( db_ctx: Any, input_hashes: list[str], relation: Literal["consumes", "produces"] = "consumes", -) -> tuple[list[dict[str, Any]], set[str]]: +) -> tuple[list[dict[str, Any]], set[str], set[str]]: """Resolve a job's leaf *hashes* into view edges + hashes to prune. Works from the collected-lineage hashes (not artifact ids). ``relation`` is the side being resolved: ``consumes`` over a job's input hashes (a run that read part of a dataset) or ``produces`` over its output hashes (a run that wrote one). The resolution is identical either way — each leaf is matched to the anchor composite(s) that carry - it as a component. Returns ``(view_edges, prune_hashes)`` — the second being the leaf - hashes that collapse into a view edge plus any anchor composite hash linked as a plain - input/output (the view edge replaces it). + it as a component. Returns ``(view_edges, prune_hashes, leaf_hashes)``: + + - ``prune_hashes``: the leaf hashes that collapse into a view edge plus any anchor + composite hash linked as a plain input/output (the view edge replaces it). Used to + drop these from a job's plain input/output edges. + - ``leaf_hashes``: strictly the true component/leaf hashes subsumed into a view edge + (a subset of ``prune_hashes`` that excludes a directly-linked anchor's own hash). + A composite member never has its own session-scoped edge on GLaaS — it has no + standing to carry its own labels/tags either, so callers use this set to keep + per-leaf label/tag documents out of publish-time label sync. BOUNDARY: matching uses ``find_by_component_digest``, which only knows the anchor's *stored* components (capped at ``_MAX_STORED_COMPONENTS`` = 1000). For a dataset with @@ -138,7 +145,7 @@ def resolve_view_edges_for_job( artifacts_repo: Any = optional_repo(db_ctx, "artifacts") composites_repo: Any = optional_repo(db_ctx, "composites") if artifacts_repo is None or composites_repo is None: - return [], set() + return [], set(), set() # anchor_id -> {"algorithm": str, "leaves": set, "shards": set} by_anchor: dict[str, dict[str, Any]] = {} @@ -174,6 +181,7 @@ def _attribute(leaf_digest: str, algorithm: str, input_digest: str) -> bool: _attribute(blake3_digest, "blake3", digest) view_edges: list[dict[str, Any]] = [] + leaf_hashes: set[str] = set() for anchor_id, bucket in by_anchor.items(): anchor = artifacts_repo.get(anchor_id) anchor_digest = _primary_composite_digest(anchor) @@ -189,8 +197,9 @@ def _attribute(leaf_digest: str, algorithm: str, input_digest: str) -> bool: ) ) prune |= bucket["shards"] + leaf_hashes |= bucket["shards"] - return view_edges, prune + return view_edges, prune, leaf_hashes def _primary_composite_digest(artifact: dict[str, Any] | None) -> str | None: diff --git a/roar/application/query/__init__.py b/roar/application/query/__init__.py index dbd8b497..80d934ca 100644 --- a/roar/application/query/__init__.py +++ b/roar/application/query/__init__.py @@ -21,6 +21,10 @@ "LogQueryRequest": ".requests", "ShowQueryRequest": ".requests", "StatusQueryRequest": ".requests", + "TagAddRequest": ".requests", + "TagHistoryRequest": ".requests", + "TagRmRequest": ".requests", + "TagShowRequest": ".requests", "LabelCurrentSummary": ".results", "LabelHistorySummary": ".results", "LineageSummary": ".results", @@ -37,6 +41,10 @@ "build_show_labels_summary": ".label", "build_sync_labels_summary": ".label", "build_unset_labels_summary": ".label", + "build_tag_add_summary": ".tag", + "build_tag_history_summary": ".tag", + "build_tag_rm_summary": ".tag", + "build_tag_show_summary": ".tag", "copy_labels": ".label", "label_history": ".label", "remote_label_history": ".label", @@ -51,6 +59,10 @@ "set_labels": ".label", "show_labels": ".label", "sync_labels": ".label", + "tag_add": ".tag", + "tag_history": ".tag", + "tag_rm": ".tag", + "tag_show": ".tag", "unset_labels": ".label", } diff --git a/roar/application/query/requests.py b/roar/application/query/requests.py index 8325f995..3cb75567 100644 --- a/roar/application/query/requests.py +++ b/roar/application/query/requests.py @@ -176,3 +176,55 @@ class InputsQueryRequest: output_json: bool = False unsourced: bool = False # show only unsourced inputs (no producer) sourced: bool = False # show only sourced inputs (produced/ingested by roar) + + +@dataclass(frozen=True) +class TagAddRequest: + roar_dir: Path + cwd: Path + kv: str # "kind=value" + target: str + + +@dataclass(frozen=True) +class TagRmRequest: + roar_dir: Path + cwd: Path + key_or_kv: str # "kind" or "kind=value" + target: str + + +@dataclass(frozen=True) +class TagShowRequest: + roar_dir: Path + cwd: Path + target: str + + +@dataclass(frozen=True) +class TagHistoryRequest: + roar_dir: Path + cwd: Path + target: str + + +@dataclass(frozen=True) +class TagBindRequest: + roar_dir: Path + cwd: Path + targets: tuple[str, ...] + + +@dataclass(frozen=True) +class TagUnbindRequest: + roar_dir: Path + cwd: Path + targets: tuple[str, ...] + + +@dataclass(frozen=True) +class TagWhyRequest: + roar_dir: Path + cwd: Path + target: str + key: str # "kind" or "kind=value" — value narrows the explanation to one value diff --git a/roar/application/query/results.py b/roar/application/query/results.py index 827f92b9..c974ae12 100644 --- a/roar/application/query/results.py +++ b/roar/application/query/results.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: + from ..tags import WhyNode from .diff_graph import JobMatch, JobNode from .git_readiness import GitReadinessSummary @@ -146,6 +147,79 @@ def render(self) -> str: return "\n\n".join(version.render() for version in self.versions) +@dataclass(frozen=True) +class TagBindArtifactSummary: + """One target's outcome from `roar tag bind`/`unbind` — what the CLI echoes.""" + + display_target: str + action: str # "bind" | "unbind" + changed: bool + promoted: dict[str, list[str]] = field(default_factory=dict) + size: int | None = None + + def render(self) -> str: + verb = "Bound" if self.action == "bind" else "Unbound" + header = self.display_target + if self.size is not None: + header += f" ({self.size} bytes)" + lines = [f"{verb}: {header}"] + if self.size == 0: + lines.append( + " warning: this is the empty-content hash — shared by every empty " + "file in every session. Binding it promotes its tags everywhere." + ) + if not self.changed: + if self.promoted: + lines.append(" no change: already up to date") + else: + noun = "currently bound" if self.action == "unbind" else "to bind" + lines.append(f" no change: nothing {noun}") + return "\n".join(lines) + for kind in sorted(self.promoted): + lines.append(f" {kind}={', '.join(self.promoted[kind])}") + return "\n".join(lines) + + +@dataclass(frozen=True) +class TagBindSummary: + artifacts: list[TagBindArtifactSummary] = field(default_factory=list) + + def render(self) -> str: + if not self.artifacts: + return "No targets." + return "\n".join(artifact.render() for artifact in self.artifacts) + + +@dataclass(frozen=True) +class TagWhySummary: + """Renders the provenance walk produced by ``roar tag why``. + + ``roots`` is a forest of ``WhyNode`` (one tree per explained value); each + node's ``label`` describes one hop back toward a human act (``tag add`` / + ``run --add-tag`` / a cross-session ``bind``). + """ + + heading: str + roots: list[WhyNode] = field(default_factory=list) + empty_message: str = "(no such tag on target — nothing to explain)" + + def render(self) -> str: + lines = [self.heading] + if not self.roots: + lines.append(f" {self.empty_message}") + return "\n".join(lines) + for root in self.roots: + lines.extend(self._render_node(root, depth=0)) + return "\n".join(lines) + + def _render_node(self, node: WhyNode, *, depth: int) -> list[str]: + indent = " " + " " * depth + lines = [f"{indent}{'└─ ' if depth else ''}{node.label}"] + for child in node.children: + lines.extend(self._render_node(child, depth=depth + 1)) + return lines + + @dataclass(frozen=True) class ShowHashSummary: algorithm: str diff --git a/roar/application/query/tag.py b/roar/application/query/tag.py new file mode 100644 index 00000000..ebd2ef5a --- /dev/null +++ b/roar/application/query/tag.py @@ -0,0 +1,224 @@ +"""Application orchestration for roar tag workflows.""" + +from __future__ import annotations + +from pathlib import Path + +from ...core.label_constants import TAG_NAMESPACE +from ...db.context import create_database_context +from ..tags import BindResult, TagService, parse_tag_kv, tag_display_pairs +from .requests import ( + TagAddRequest, + TagBindRequest, + TagHistoryRequest, + TagRmRequest, + TagShowRequest, + TagUnbindRequest, + TagWhyRequest, +) +from .results import ( + LabelCurrentSummary, + LabelEntrySummary, + LabelHistorySummary, + LabelHistoryVersionSummary, + TagBindArtifactSummary, + TagBindSummary, + TagWhySummary, +) + + +def tag_add(request: TagAddRequest) -> str: + """Append a value to a tag set and return a rendered summary.""" + return build_tag_add_summary(request).render() + + +def build_tag_add_summary(request: TagAddRequest) -> LabelCurrentSummary: + """Build the typed summary for a tag add operation.""" + kind, value = parse_tag_kv(request.kv) + with create_database_context(request.roar_dir) as db_ctx: + svc = TagService(db_ctx, request.cwd) + resolved = svc.resolve_target(request.target) + changed = svc.add(resolved, kind, value) + tags = svc.get_tags(resolved) + + if changed: + heading = f"Tagged {request.target}: {TAG_NAMESPACE}.{kind} += [{value!r}]" + else: + heading = f"No change: {value!r} already present in {TAG_NAMESPACE}.{kind}" + + return LabelCurrentSummary( + heading=heading, + entries=_tag_entries(tags), + empty_message="(no tags)", + ) + + +def tag_rm(request: TagRmRequest) -> str: + """Remove a value (or entire kind) from a tag set and return a rendered summary.""" + return build_tag_rm_summary(request).render() + + +def build_tag_rm_summary(request: TagRmRequest) -> LabelCurrentSummary: + """Build the typed summary for a tag rm operation.""" + kind, value = _parse_kind_or_kv(request.key_or_kv) + with create_database_context(request.roar_dir) as db_ctx: + svc = TagService(db_ctx, request.cwd) + resolved = svc.resolve_target(request.target) + changed = svc.remove(resolved, kind, value) + tags = svc.get_tags(resolved) + + if changed: + if value is not None: + heading = f"Removed {value!r} from {TAG_NAMESPACE}.{kind} on {request.target}" + else: + heading = f"Removed {TAG_NAMESPACE}.{kind} from {request.target}" + else: + if value is not None: + heading = f"No change: {value!r} not found in {TAG_NAMESPACE}.{kind}" + else: + heading = f"No change: {TAG_NAMESPACE}.{kind} not present on {request.target}" + + return LabelCurrentSummary( + heading=heading, + entries=_tag_entries(tags), + empty_message="(no tags remaining)", + ) + + +def tag_show(request: TagShowRequest) -> str: + """Show current tags for a target and return a rendered summary.""" + return build_tag_show_summary(request).render() + + +def build_tag_show_summary(request: TagShowRequest) -> LabelCurrentSummary: + """Build the typed summary for showing tags.""" + with create_database_context(request.roar_dir) as db_ctx: + svc = TagService(db_ctx, request.cwd) + resolved = svc.resolve_target(request.target) + tags = svc.get_tags(resolved) + + return LabelCurrentSummary( + heading=f"Tags on {request.target}:", + entries=_tag_entries(tags), + empty_message="(no tags)", + ) + + +def tag_history(request: TagHistoryRequest) -> str: + """Show label version history for a target (all keys) and return a rendered summary.""" + return build_tag_history_summary(request).render() + + +def build_tag_history_summary(request: TagHistoryRequest) -> LabelHistorySummary: + """Build the typed summary for tag history.""" + with create_database_context(request.roar_dir) as db_ctx: + svc = TagService(db_ctx, request.cwd) + resolved = svc.resolve_target(request.target) + history = svc.history(resolved) + + return LabelHistorySummary( + versions=[ + LabelHistoryVersionSummary( + version=int(row["version"]), + entries=_tag_entries(row["metadata"].get(TAG_NAMESPACE) or {}), + ) + for row in history + if isinstance(row.get("metadata"), dict) + ] + ) + + +def tag_why(request: TagWhyRequest) -> str: + """Explain how a target acquired a tag and return a rendered summary.""" + return build_tag_why_summary(request).render() + + +def build_tag_why_summary(request: TagWhyRequest) -> TagWhySummary: + """Build the typed summary for a tag why provenance walk.""" + kind, value = _parse_kind_or_kv(request.key) + with create_database_context(request.roar_dir) as db_ctx: + svc = TagService(db_ctx, request.cwd) + resolved = svc.resolve_target(request.target) + roots = svc.why(resolved, kind, value) + + label = f"{kind}={value}" if value is not None else kind + return TagWhySummary(heading=f"Why does {request.target} have {label}?", roots=roots) + + +def tag_bind(request: TagBindRequest) -> str: + """Promote each target's current tags to cross-session scope and return a rendered summary.""" + return build_tag_bind_summary(request).render() + + +def build_tag_bind_summary(request: TagBindRequest) -> TagBindSummary: + """Build the typed summary for a tag bind operation.""" + return _build_bind_or_unbind_summary( + request.roar_dir, request.cwd, request.targets, action="bind" + ) + + +def tag_unbind(request: TagUnbindRequest) -> str: + """Revoke each target's currently-bound tags and return a rendered summary.""" + return build_tag_unbind_summary(request).render() + + +def build_tag_unbind_summary(request: TagUnbindRequest) -> TagBindSummary: + """Build the typed summary for a tag unbind operation.""" + return _build_bind_or_unbind_summary( + request.roar_dir, request.cwd, request.targets, action="unbind" + ) + + +def _build_bind_or_unbind_summary( + roar_dir: Path, cwd: Path, targets: tuple[str, ...], *, action: str +) -> TagBindSummary: + artifacts: list[TagBindArtifactSummary] = [] + with create_database_context(roar_dir) as db_ctx: + svc = TagService(db_ctx, cwd) + for target in targets: + resolved = svc.resolve_target(target) + result: BindResult = svc.bind(resolved) if action == "bind" else svc.unbind(resolved) + + size = None + if resolved.entity_type == "artifact" and resolved.artifact_id: + artifact = db_ctx.artifacts.get(resolved.artifact_id) + if artifact: + size = artifact.get("size") + + artifacts.append( + TagBindArtifactSummary( + display_target=resolved.display_target or target, + action=action, + changed=result.changed, + promoted=result.promoted, + size=size, + ) + ) + return TagBindSummary(artifacts=artifacts) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _parse_kind_or_kv(key_or_kv: str) -> tuple[str, str | None]: + """Parse ``kind`` or ``kind=value``. Returns (kind, value_or_None).""" + if "=" in key_or_kv: + kind, value = parse_tag_kv(key_or_kv) + return kind, value + kind = key_or_kv.strip() + if not kind: + raise ValueError("Kind cannot be empty.") + return kind, None + + +def _tag_entries(tags: dict) -> list[LabelEntrySummary]: + """Convert a tag.* subtree to display entries via the shared renderer. + + Uses ``tag_display_pairs`` so ``roar tag show`` and ``roar show`` render tags + identically (values only; skips the bind ledger). + """ + return [ + LabelEntrySummary(key=kind, display_value=value) for kind, value in tag_display_pairs(tags) + ] diff --git a/roar/application/run/execution.py b/roar/application/run/execution.py index cca2d388..ae835ddd 100644 --- a/roar/application/run/execution.py +++ b/roar/application/run/execution.py @@ -190,6 +190,8 @@ def execute_and_report( config_start_dir: str | Path | None = None, tracer_mode: str | None = None, tracer_fallback: bool | None = None, + block_tags: list[str] | None = None, + add_tags: list[str] | None = None, ) -> ExecutionReport: """Execute command via selected backend and show the run report.""" hash_algos = cast(list[Literal["blake3", "sha256", "sha512", "md5"]], hash_algorithms) @@ -210,6 +212,8 @@ def execute_and_report( hash_algorithms=hash_algos, tracer_mode=tracer_mode, # type: ignore[arg-type] tracer_fallback=tracer_fallback, + block_tags=block_tags or [], + add_tags=add_tags or [], ) backend = get_execution_backend(backend_name) diff --git a/roar/application/run/requests.py b/roar/application/run/requests.py index 198c7d6a..e74212b2 100644 --- a/roar/application/run/requests.py +++ b/roar/application/run/requests.py @@ -17,6 +17,8 @@ class RunRequest: tracer_mode: str | None = None tracer_fallback: bool | None = None hash_algorithms: tuple[str, ...] = field(default_factory=tuple) + block_tags: tuple[str, ...] = field(default_factory=tuple) + add_tags: tuple[str, ...] = field(default_factory=tuple) @dataclass(frozen=True) diff --git a/roar/application/run/service.py b/roar/application/run/service.py index ad28d99d..b9efe929 100644 --- a/roar/application/run/service.py +++ b/roar/application/run/service.py @@ -61,6 +61,8 @@ def run_command(request: RunRequest) -> int: hash_algorithms=algorithms, tracer_mode=request.tracer_mode, tracer_fallback=request.tracer_fallback, + block_tags=list(request.block_tags), + add_tags=list(request.add_tags), ) @@ -107,6 +109,8 @@ def _execute_tracked_command( hash_algorithms: list[str], tracer_mode: str | None, tracer_fallback: bool | None, + block_tags: list[str] | None = None, + add_tags: list[str] | None = None, ) -> int: resolved_config_start_dir = config_start_dir or _config_start_dir(roar_dir) try: @@ -132,6 +136,8 @@ def _execute_tracked_command( config_start_dir=resolved_config_start_dir, tracer_mode=tracer_mode, tracer_fallback=tracer_fallback, + block_tags=block_tags, + add_tags=add_tags, ) ) except Exception: diff --git a/roar/application/system_labels.py b/roar/application/system_labels.py index 65872d14..3899fe75 100644 --- a/roar/application/system_labels.py +++ b/roar/application/system_labels.py @@ -11,7 +11,13 @@ from ..db.context import optional_repo JOB_SYSTEM_LABEL_ROOT = "roar" -SYSTEM_LABEL_ROOT_PREFIXES = frozenset({JOB_SYSTEM_LABEL_ROOT}) +# tag.*/attach.* are reserved for `roar tag`/`roar attach` — the namespace +# itself is the hereditary-propagation contract, so the generic `roar label` +# path must not be able to write there (see design-docs/20260519 roar +# audit.md, "Storage: no schema changes"). This also protects the tag.bind +# ledger's append-only integrity: TagService bypasses this check via its own +# direct label_repo writes (see application/tags.py). +SYSTEM_LABEL_ROOT_PREFIXES = frozenset({JOB_SYSTEM_LABEL_ROOT, "tag", "attach"}) SYSTEM_LABEL_EXACT_PATHS = frozenset(AUTO_DATASET_LABEL_KEYS) DISPLAY_FILTER_ROOT_PREFIXES = frozenset({JOB_SYSTEM_LABEL_ROOT}) diff --git a/roar/application/tags.py b/roar/application/tags.py new file mode 100644 index 00000000..e153c6d1 --- /dev/null +++ b/roar/application/tags.py @@ -0,0 +1,746 @@ +""" +Application-layer tag service for hereditary compliance tags. + +Tags live under the ``tag.*`` namespace inside the existing versioned label +documents. Each kind stores a **list of provenance records** (set semantics +over each record's ``value`` — no duplicate values within a kind): + + tag.license -> {"values": [{"value": "MIT", "origin": "user"}, + {"value": "Apache-2.0", "origin": "system", "job": ""}]} + tag.contains_pii -> {"values": [{"value": "present", "origin": "user"}]} + +``origin`` is ``"user"`` (an explicit human act) or ``"system"`` (inherited +via propagation at job-record time). ``job`` is the producing job's UID — +present whenever a job was involved (system-derived values, and user-origin +values stamped via ``roar run --add-tag``); a bare CLI ``tag add`` has no job +and omits it. ``job`` is what the scope check (below) uses to resolve which +session a value belongs to. + +**Scope and the bind ledger.** Tags propagate automatically and fully within +one session (over-approximate — false positives are contained and cheap). +Crossing a session boundary requires an explicit **bind**: a human act +naming the artifact whose tags are of record. Bound-ness is a ledger lookup, +not a flag on the value — ``tag.bind`` holds an append-only list of +bind/unbind events, each recording the ``(kind, value)`` pairs it covers: + + tag.bind -> {"events": [{"action": "bind", "covers": {"contains_pii": ["present"]}}]} + +A user-origin ``tag add`` writes an implicit bind event for the value it +just added — "one mechanism, no special cases" (see ``TagService.add``). + +TagService wraps the raw label repository directly (not ``LabelService``) so +its writes aren't blocked by the ``tag.*``/``attach.*`` reservation that +protects the generic ``roar label`` path — see ``system_labels.py``. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass, field +from typing import Any, Protocol + +from ..core.label_constants import TAG_NAMESPACE +from ..core.label_origins import LABEL_ORIGIN_SYSTEM, LABEL_ORIGIN_USER +from ..db.context import DatabaseContext +from .labels import LabelService, LabelTargetRef + +# Reserved kind name within the tag.* namespace: the bind ledger itself, never +# a real hereditary kind. Excluded from get_tags()/propagation/covers-all. +BIND_KIND = "bind" + + +@dataclass(frozen=True) +class BindResult: + """Outcome of a bind/unbind call — what the CLI echoes.""" + + changed: bool + promoted: dict[str, list[str]] = field(default_factory=dict) + + +@dataclass(frozen=True) +class WhyNode: + """One hop in a ``roar tag why`` provenance walk (a render-agnostic tree). + + Each node's ``label`` describes one step back toward a human act; leaves are + a user ``tag add`` / ``run --add-tag`` (or an unresolved origin). + """ + + label: str + children: list[WhyNode] = field(default_factory=list) + + +class TagService: + """Set-accumulation semantics over the tag.* label namespace.""" + + def __init__(self, db_ctx: DatabaseContext, cwd: Any) -> None: + self._svc = LabelService(db_ctx, cwd) + self._label_repo = db_ctx.labels + # Read-only handles used by `why` to walk job -> inputs and name artifacts. + self._jobs = db_ctx.jobs + self._artifacts = db_ctx.artifacts + + # ------------------------------------------------------------------ + # Target resolution + # ------------------------------------------------------------------ + + def resolve_target(self, ref: str) -> LabelTargetRef: + """Auto-detect entity type from the reference format. + + @N -> job step N in the active session + -> artifact by hash prefix + @BN -> raises ValueError (unsupported in P1) + @session/@latest -> raises ValueError (unsupported in P1) + """ + if ref.startswith("@"): + inner = ref[1:] + if inner.upper().startswith("B"): + raise ValueError( + "Build-step targets (@BN) are not yet supported by 'roar tag'. " + "Use the job UID directly instead." + ) + if inner.lower() in ("session", "latest"): + raise ValueError( + "Session targets (@session / @latest) are not yet supported by 'roar tag'." + ) + return self._svc.resolve_target("job", ref) + + return self._svc.resolve_target("artifact", ref) + + # ------------------------------------------------------------------ + # Mutations + # ------------------------------------------------------------------ + + def add(self, resolved: LabelTargetRef, kind: str, value: str) -> bool: + """Append *value* to the ``tag.{kind}`` set. + + Returns True when the document was actually changed (value was absent). + Idempotent — adding a value already present is a no-op. + + Writes an implicit bind event covering exactly ``(kind, value)`` in the + same version — a user-origin ``tag add`` is "born bound" (the + named-artifact rule: this act names a concrete, inspectable artifact). + """ + subtree = self._current_tag_subtree(resolved) + records = _as_value_records(subtree.get(kind)) + if any(record["value"] == value for record in records): + return False + + new_subtree = dict(subtree) + new_subtree[kind] = {"values": [*records, {"value": value, "origin": LABEL_ORIGIN_USER}]} + new_subtree[BIND_KIND] = _with_appended_bind_event( + subtree.get(BIND_KIND), action="bind", covers={kind: [value]} + ) + self._write_tag_subtree(resolved, new_subtree) + return True + + def remove(self, resolved: LabelTargetRef, kind: str, value: str | None) -> bool: + """Remove *value* from ``tag.{kind}`` (or delete the entire kind if value is None). + + Returns True when the document changed. No-ops silently return False. + Does not touch the bind ledger — a removed value simply won't be + present for future propagation; past bind events remain as history, + matching the append-only, never-destructive record. + """ + subtree = self._current_tag_subtree(resolved) + if kind not in subtree: + return False + + if value is None: + new_subtree = {k: v for k, v in subtree.items() if k != kind} + self._write_tag_subtree(resolved, new_subtree) + return True + + records = _as_value_records(subtree.get(kind)) + if not any(record["value"] == value for record in records): + return False + + remaining = [record for record in records if record["value"] != value] + new_subtree = dict(subtree) + if remaining: + new_subtree[kind] = {"values": remaining} + else: + new_subtree.pop(kind, None) + self._write_tag_subtree(resolved, new_subtree) + return True + + def bind(self, resolved: LabelTargetRef) -> BindResult: + """Promote every tag value currently on *resolved* to cross-session scope. + + Snapshot semantics: covers exactly the current `(kind, value)` set at + bind time. Later within-session derivations don't ride an old bind. + A repeat bind that covers exactly the same set as the most recent + bind event is a no-op — it doesn't add a redundant ledger entry. + """ + subtree = self._current_tag_subtree(resolved) + covers = _covers_all_current_values(subtree) + if not covers: + return BindResult(changed=False) + + events = _as_bind_events(subtree.get(BIND_KIND)) + if events and events[-1].get("action") == "bind" and events[-1].get("covers") == covers: + return BindResult(changed=False, promoted=covers) + + new_subtree = dict(subtree) + new_subtree[BIND_KIND] = _with_appended_bind_event( + subtree.get(BIND_KIND), action="bind", covers=covers + ) + self._write_tag_subtree(resolved, new_subtree) + return BindResult(changed=True, promoted=covers) + + def unbind(self, resolved: LabelTargetRef) -> BindResult: + """Revoke every `(kind, value)` pair currently bound on *resolved*. + + One call heals the whole cone: everything that inherited through a + revoked bind is mechanically identifiable (superseded), matching the + append-only revocation model — this writes a new event, never deletes + the bind it revokes. + """ + subtree = self._current_tag_subtree(resolved) + events = _as_bind_events(subtree.get(BIND_KIND)) + covers = _currently_bound_pairs(events) + if not covers: + return BindResult(changed=False) + + new_subtree = dict(subtree) + new_subtree[BIND_KIND] = {"events": [*events, {"action": "unbind", "covers": covers}]} + self._write_tag_subtree(resolved, new_subtree) + return BindResult(changed=True, promoted=covers) + + # ------------------------------------------------------------------ + # Queries + # ------------------------------------------------------------------ + + def get_tags(self, resolved: LabelTargetRef) -> dict[str, Any]: + """Return only the ``tag.*`` subtree of the current label document, excluding the bind ledger.""" + subtree = self._current_tag_subtree(resolved) + return {kind: value for kind, value in subtree.items() if kind != BIND_KIND} + + def history(self, resolved: LabelTargetRef) -> list[dict[str, Any]]: + """Return full label-version history for the target.""" + return self._svc.history(resolved) + + def why(self, resolved: LabelTargetRef, kind: str, value: str | None = None) -> list[WhyNode]: + """Explain how *resolved* acquired ``tag.{kind}`` — one tree per value. + + Read-only traversal over the stored ``{value, origin, job}`` records and + the bind ledger (no writes, no schema). Each tree bottoms out at a human + act (a bare ``tag add`` or a ``run --add-tag``), annotating any + cross-session hop with the explicit ``bind`` that authorized it. + """ + if resolved.entity_type == "job": + raise ValueError( + "`roar tag why` explains an artifact's tag, not a job's. `@N` targets " + "job step N — trace one of its output artifacts instead (by hash or " + "path), or use `roar tag show @N` to list the job's tags." + ) + if resolved.entity_type != "artifact" or not resolved.artifact_id: + raise ValueError( + "`roar tag why` explains an artifact's tag — target a tracked artifact " + "by hash or path." + ) + subtree = self._current_tag_subtree(resolved) + stored = [record["value"] for record in _as_value_records(subtree.get(kind))] + wanted = [v for v in stored if value is None or v == value] + return [self._explain(resolved.artifact_id, kind, v, frozenset()) for v in wanted] + + def _explain(self, artifact_id: str, kind: str, value: str, visited: frozenset[str]) -> WhyNode: + name = self._artifact_display(artifact_id) + subtree = _current_tag_subtree(self._label_repo, artifact_id) + record = next( + (r for r in _as_value_records(subtree.get(kind)) if r["value"] == value), None + ) + if record is None: + return WhyNode(f"{name}: no {kind}={value} recorded") + + origin = record.get("origin") + job_uid = record.get("job") + if origin == LABEL_ORIGIN_USER and not job_uid: + return WhyNode(f"{name}: {kind}={value} — user `roar tag add` (born bound)") + if origin == LABEL_ORIGIN_USER and job_uid: + return WhyNode( + f"{name}: {kind}={value} — user `roar run --add-tag` " + f"in job {job_uid[:8]} (session-scoped)" + ) + + header = f"{name}: {kind}={value} — inherited" + header += f" via job {job_uid[:8]}" if job_uid else "" + if artifact_id in visited: + return WhyNode(header + " (cycle)") + if not job_uid: + return WhyNode(header + " (no producing job recorded — origin unknown)") + job = self._jobs.get_by_uid(job_uid) + if not job: + return WhyNode(header + " (producing job not found)") + + visited = visited | {artifact_id} + children: list[WhyNode] = [] + for inp in self._jobs.get_inputs(job["id"]): + input_id = inp.get("artifact_id") + if not input_id: + continue + input_subtree = _current_tag_subtree(self._label_repo, input_id) + input_record = next( + (r for r in _as_value_records(input_subtree.get(kind)) if r["value"] == value), + None, + ) + if input_record is None: + continue # this input doesn't carry the value — not on the path + node = self._explain(input_id, kind, value, visited) + # A *derived* (system) value made cross-session by an explicit bind is a + # human act worth naming; a born-bound user tag already reads as one. + covered = _is_covered_by_bind( + _as_bind_events(input_subtree.get(BIND_KIND)), kind, value + ) + if covered and input_record.get("origin") == LABEL_ORIGIN_SYSTEM: + node = WhyNode( + f"`roar tag bind` on {self._artifact_display(input_id)} " + f"authorized this across sessions", + [node], + ) + children.append(node) + + if not children: + return WhyNode(header + " (no input carries this value — origin unclear)") + return WhyNode(header, children) + + def _artifact_display(self, artifact_id: str) -> str: + artifact = self._artifacts.get(artifact_id) + if not artifact: + return artifact_id[:12] + path = artifact.get("path") or artifact.get("first_seen_path") + if path: + return str(path).rsplit("/", 1)[-1] + hashes = artifact.get("hashes") or [] + digest = hashes[0]["digest"] if hashes else artifact_id + return str(digest)[:12] + + # ------------------------------------------------------------------ + # Internal read/write — bypasses LabelService.set_metadata so writes + # aren't rejected by the tag.*/attach.* reservation (system_labels.py) + # that protects the generic `roar label` path. + # ------------------------------------------------------------------ + + def _current_tag_subtree(self, resolved: LabelTargetRef) -> dict[str, Any]: + current = self._label_repo.get_current( + resolved.entity_type, + session_id=resolved.session_id, + job_id=resolved.job_id, + artifact_id=resolved.artifact_id, + ) + metadata = current.get("metadata") if isinstance(current, dict) else None + return _tag_subtree_from_metadata(metadata) + + def _write_tag_subtree(self, resolved: LabelTargetRef, new_subtree: dict[str, Any]) -> None: + current = self._label_repo.get_current( + resolved.entity_type, + session_id=resolved.session_id, + job_id=resolved.job_id, + artifact_id=resolved.artifact_id, + ) + metadata = current.get("metadata") if isinstance(current, dict) else None + full_metadata = dict(metadata) if isinstance(metadata, dict) else {} + if new_subtree: + full_metadata[TAG_NAMESPACE] = new_subtree + else: + full_metadata.pop(TAG_NAMESPACE, None) + self._label_repo.create_version( + resolved.entity_type, + full_metadata, + session_id=resolved.session_id, + job_id=resolved.job_id, + artifact_id=resolved.artifact_id, + write_origin=LABEL_ORIGIN_USER, + ) + + +def tag_display_values(kind_data: Any) -> list[str]: + """Extract just the ``value`` strings from a stored tag kind's record list, for display. + + Drops provenance (``origin``/``job``) — callers that want that detail + (a future `roar tag why`) should read the records directly via + ``_as_value_records``. + """ + return [record["value"] for record in _as_value_records(kind_data)] + + +def tag_display_pairs(tag_subtree: Any) -> list[tuple[str, str]]: + """``(kind, "v1, v2")`` display pairs for a ``tag.*`` subtree. + + Sorted by kind, skips the internal ``bind`` ledger and empty kinds. The one + shared source of truth for how tags render in both ``roar tag show`` and + ``roar show`` — so the two can't drift. + """ + pairs: list[tuple[str, str]] = [] + if not isinstance(tag_subtree, dict): + return pairs + for kind in sorted(tag_subtree): + if kind == BIND_KIND: + continue + values = tag_display_values(tag_subtree[kind]) + if values: + pairs.append((kind, ", ".join(values))) + return pairs + + +def barrier_items(run_modifiers: Any) -> list[str]: + """The ``--block-tag`` items (barriers) recorded on a job, for display. + + Reads the ``run_modifiers.block_tags`` metadata; empty/absent yields ``[]``. + """ + if not isinstance(run_modifiers, dict): + return [] + return [str(item) for item in (run_modifiers.get("block_tags") or []) if str(item).strip()] + + +class _TagLabelRepo(Protocol): + """Minimal label repo surface needed to propagate tags between artifacts.""" + + def get_current( + self, entity_type: str, *, artifact_id: str | None = None + ) -> dict[str, Any] | None: ... + + def create_version( + self, + entity_type: str, + metadata: dict[str, Any], + *, + artifact_id: str | None = None, + write_origin: str | None = None, + ) -> dict[str, Any]: ... + + +def propagate_tags( + label_repo: _TagLabelRepo, + *, + input_artifact_ids: Iterable[str], + output_artifact_ids: Iterable[str], + current_session_id: int | None, + resolve_job_session_id: Callable[[str], int | None], + job_uid: str | None = None, + blocked_kinds: frozenset[str] = frozenset(), + blocked_values: Mapping[str, frozenset[str]] | None = None, +) -> None: + """Union ``tag.*`` values from a job's input artifacts onto its outputs. + + Every kind present on any input is merged (set semantics — no duplicate + values) into every output's current tag namespace, except kinds listed in + *blocked_kinds* (whole-kind barriers, ``--block-tag KIND``) and individual + ``(kind, value)`` pairs in *blocked_values* (value barriers, + ``--block-tag KIND=VALUE`` — e.g. filtering ``license=GPL-3.0`` off a + relicensing step's outputs while keeping the rest of the set). Writes are + stamped with a system write-origin since the inheriting document is + machine-derived, not user-asserted on that target. A no-op output write + (nothing new to add) does not create a label version. + + **Scope-gated**: a candidate value only joins the union if it's in scope + for *this* session — either it was produced by a job in the current + session (resolved via *resolve_job_session_id*), or it's covered by a + bind on the input artifact (see ``TagService.bind``). This is what keeps + tags from riding bare content-hash identity across unrelated sessions + (e.g. every 0-byte file in existence sharing one hash). + """ + input_ids = list(dict.fromkeys(i for i in input_artifact_ids if i)) + output_ids = list(dict.fromkeys(o for o in output_artifact_ids if o)) + if not input_ids or not output_ids: + return + + session_cache: dict[str, int | None] = {} + + def _job_session(job: str) -> int | None: + if job not in session_cache: + session_cache[job] = resolve_job_session_id(job) + return session_cache[job] + + inherited: dict[str, list[str]] = {} + for artifact_id in input_ids: + subtree = _current_tag_subtree(label_repo, artifact_id) + bind_events = _as_bind_events(subtree.get(BIND_KIND)) + for kind, kind_data in subtree.items(): + if kind == BIND_KIND or kind in blocked_kinds: + continue + blocked_vals = blocked_values.get(kind) if blocked_values else None + bucket = inherited.setdefault(kind, []) + for record in _as_value_records(kind_data): + value = record["value"] + if value in bucket: + continue + if blocked_vals and value in blocked_vals: + continue # value barrier: --block-tag KIND=VALUE filters this one value + if _value_in_scope(record, current_session_id, _job_session) or _is_covered_by_bind( + bind_events, kind, value + ): + bucket.append(value) + + inherited = {kind: values for kind, values in inherited.items() if values} + if not inherited: + return + + for artifact_id in output_ids: + _merge_tags_into_artifact( + label_repo, artifact_id, inherited, LABEL_ORIGIN_SYSTEM, job_uid=job_uid + ) + + +def stamp_tags( + label_repo: _TagLabelRepo, + *, + output_artifact_ids: Iterable[str], + tags: dict[str, list[str]], + job_uid: str | None = None, +) -> None: + """Stamp explicit ``KIND=VALUE`` tags directly onto a job's output artifacts. + + Unlike ``propagate_tags`` (inherited from inputs), these values were + explicitly requested by the user at record time (e.g. via + ``roar run --add-tag license=MIT``), so writes use the user write-origin + — the same origin as a manual ``roar tag add``. Per the named-artifact + rule, this does **not** imply a bind: it quantifies over the job's whole + output set sight-unseen, so the values stay session-scoped (via the + stamped ``job_uid``) until a named artifact is explicitly bound. + """ + if not tags: + return + output_ids = list(dict.fromkeys(o for o in output_artifact_ids if o)) + if not output_ids: + return + + for artifact_id in output_ids: + _merge_tags_into_artifact(label_repo, artifact_id, tags, LABEL_ORIGIN_USER, job_uid=job_uid) + + +def parse_tag_kv(kv: str) -> tuple[str, str]: + """Parse ``kind=value``. Raises ValueError on bad format.""" + if "=" not in kv: + raise ValueError(f"Expected KIND=VALUE (e.g. license=MIT), got: {kv!r}") + kind, _, value = kv.partition("=") + kind = kind.strip() + value = value.strip() + if not kind: + raise ValueError(f"Kind cannot be empty in: {kv!r}") + if not value: + raise ValueError(f"Value cannot be empty in: {kv!r}") + return kind, value + + +def parse_block_tags(pairs: Iterable[str]) -> tuple[frozenset[str], dict[str, frozenset[str]]]: + """Split ``--block-tag`` items into whole-kind and per-value barriers. + + ``KIND`` blocks the whole kind; ``KIND=VALUE`` filters a single value from + the inherited set. Returns ``(blocked_kinds, blocked_values)``. A whole-kind + block wins over a value-level one for the same kind (the kind is dropped + entirely, so its value-level entries are irrelevant and omitted). ``KIND=`` + with an empty value is treated as a whole-kind block. + """ + whole: set[str] = set() + values: dict[str, set[str]] = {} + for pair in pairs: + item = pair.strip() + if not item: + continue + if "=" in item: + kind, _, value = item.partition("=") + kind, value = kind.strip(), value.strip() + if kind and value: + values.setdefault(kind, set()).add(value) + elif kind: + whole.add(kind) + else: + whole.add(item) + blocked_values = {k: frozenset(v) for k, v in values.items() if k not in whole} + return frozenset(whole), blocked_values + + +def parse_add_tags(pairs: Iterable[str]) -> dict[str, list[str]]: + """Parse repeated ``KIND=VALUE`` strings into a grouped, deduped tag dict.""" + grouped: dict[str, list[str]] = {} + for pair in pairs: + kind, value = parse_tag_kv(pair) + bucket = grouped.setdefault(kind, []) + if value not in bucket: + bucket.append(value) + return grouped + + +# --------------------------------------------------------------------------- +# Run modifiers — roar-side ``roar run`` flags that shaped the recorded lineage +# (currently the tag flags). Persisted on the job so ``roar reproduce`` can +# replay them; without this the reproduced tag/barrier layer diverges from the +# original (a bare ``roar run`` re-inherits blocked tags and drops --add-tags). +# --------------------------------------------------------------------------- + + +def build_run_modifiers( + block_tags: Iterable[str], add_tags: Iterable[str] +) -> dict[str, list[str]] | None: + """Build the ``run_modifiers`` metadata block, or None if there's nothing to record. + + Stores the raw flag strings verbatim (e.g. ``"license=GPL-3.0"``) so reproduce + replays exactly what was passed. + """ + blocks = [b.strip() for b in block_tags if b.strip()] + adds = [a.strip() for a in add_tags if a.strip()] + modifiers: dict[str, list[str]] = {} + if blocks: + modifiers["block_tags"] = blocks + if adds: + modifiers["add_tags"] = adds + return modifiers or None + + +def run_modifier_flags(run_modifiers: Any) -> str: + """Render a ``run_modifiers`` block back to shell-safe ``roar run`` flags. + + Returns e.g. ``--block-tag contains_pii --add-tag license=Apache-2.0``. + Empty/absent modifiers yield an empty string. + """ + import shlex + + if not isinstance(run_modifiers, dict): + return "" + parts: list[str] = [] + for item in run_modifiers.get("block_tags") or []: + parts.append(f"--block-tag {shlex.quote(str(item))}") + for item in run_modifiers.get("add_tags") or []: + parts.append(f"--add-tag {shlex.quote(str(item))}") + return " ".join(parts) + + +# --------------------------------------------------------------------------- +# Shared value/ledger primitives +# --------------------------------------------------------------------------- + + +def _tag_subtree_from_metadata(metadata: Any) -> dict[str, Any]: + if not isinstance(metadata, dict): + return {} + subtree = metadata.get(TAG_NAMESPACE) + return subtree if isinstance(subtree, dict) else {} + + +def _as_value_records(existing: Any) -> list[dict[str, Any]]: + """Normalize a tag kind's stored value into its list of provenance records.""" + if isinstance(existing, dict) and isinstance(existing.get("values"), list): + return [ + record + for record in existing["values"] + if isinstance(record, dict) and "value" in record + ] + return [] + + +def _as_bind_events(bind_doc: Any) -> list[dict[str, Any]]: + if isinstance(bind_doc, dict) and isinstance(bind_doc.get("events"), list): + return [event for event in bind_doc["events"] if isinstance(event, dict)] + return [] + + +def _with_appended_bind_event( + bind_doc: Any, *, action: str, covers: dict[str, list[str]] +) -> dict[str, Any]: + events = _as_bind_events(bind_doc) + return {"events": [*events, {"action": action, "covers": covers}]} + + +def _is_covered_by_bind(events: list[dict[str, Any]], kind: str, value: str) -> bool: + """Newest event covering (kind, value) wins; True iff that event is a bind.""" + for event in reversed(events): + covers = event.get("covers") or {} + if value in (covers.get(kind) or []): + return event.get("action") == "bind" + return False + + +def _covers_all_current_values(subtree: dict[str, Any]) -> dict[str, list[str]]: + """Every `(kind, value)` pair currently stored on the subtree (excluding the bind ledger itself).""" + covers: dict[str, list[str]] = {} + for kind, kind_data in subtree.items(): + if kind == BIND_KIND: + continue + values = [record["value"] for record in _as_value_records(kind_data)] + if values: + covers[kind] = values + return covers + + +def _currently_bound_pairs(events: list[dict[str, Any]]) -> dict[str, list[str]]: + """Every `(kind, value)` pair ever mentioned whose newest covering event is a bind.""" + all_pairs: set[tuple[str, str]] = set() + for event in events: + covers = event.get("covers") or {} + for kind, values in covers.items(): + for value in values: + all_pairs.add((kind, value)) + + bound: dict[str, list[str]] = {} + for kind, value in sorted(all_pairs): + if _is_covered_by_bind(events, kind, value): + bound.setdefault(kind, []).append(value) + return bound + + +def _value_in_scope( + record: dict[str, Any], + current_session_id: int | None, + job_session: Callable[[str], int | None], +) -> bool: + """A record with no job pointer (a bare user tag) always needs a bind. + + Otherwise, in-scope iff the producing job's session matches the current + one — including both being unassigned (``None``), which keeps + session-less job recording (``assign_to_session=False``, used outside + normal pipeline runs) working the same as before scope-gating existed. + """ + job = record.get("job") + if not isinstance(job, str) or not job: + return False + return job_session(job) == current_session_id + + +def _current_tag_subtree(label_repo: _TagLabelRepo, artifact_id: str) -> dict[str, Any]: + current = label_repo.get_current("artifact", artifact_id=artifact_id) + metadata = current.get("metadata") if isinstance(current, dict) else None + return _tag_subtree_from_metadata(metadata) + + +def _merge_tags_into_artifact( + label_repo: _TagLabelRepo, + artifact_id: str, + incoming: dict[str, list[str]], + write_origin: str, + *, + job_uid: str | None = None, +) -> None: + """Union *incoming* kind/value pairs into one artifact's current tag namespace.""" + tag_subtree = dict(_current_tag_subtree(label_repo, artifact_id)) + current = label_repo.get_current("artifact", artifact_id=artifact_id) + current_metadata = current.get("metadata") if isinstance(current, dict) else {} + if not isinstance(current_metadata, dict): + current_metadata = {} + + changed = False + for kind, incoming_values in incoming.items(): + existing_records = _as_value_records(tag_subtree.get(kind)) + existing_values = {record["value"] for record in existing_records} + new_records = list(existing_records) + for value in incoming_values: + if value in existing_values: + continue + record: dict[str, Any] = {"value": value, "origin": write_origin} + if job_uid: + record["job"] = job_uid + new_records.append(record) + existing_values.add(value) + changed = True + tag_subtree[kind] = {"values": new_records} + + if not changed: + return + + merged_metadata = dict(current_metadata) + merged_metadata[TAG_NAMESPACE] = tag_subtree + label_repo.create_version( + "artifact", + merged_metadata, + artifact_id=artifact_id, + write_origin=write_origin, + ) diff --git a/roar/cli/_tag_kinds.py b/roar/cli/_tag_kinds.py new file mode 100644 index 00000000..55a15af7 --- /dev/null +++ b/roar/cli/_tag_kinds.py @@ -0,0 +1,69 @@ +"""Validation for hereditary ``roar tag`` kinds. + +The built-in canonical kinds are compliance-mapped and rendered by GLaaS. A +project extends the *allowed* set with its own hereditary kinds via +``[tags] custom_kinds`` in ``.roarconfig`` (committed, so a team shares it). + +A non-allowed kind is **rejected** (not merely warned) — a typo like +``licence`` or ``contains_pil`` would otherwise propagate silently as a phantom +kind that never lands in the compliance report. The rejection prints a +copy-pasteable, append-aware hint that preserves any existing ``custom_kinds``. +""" + +from __future__ import annotations + +import click + +from ..core.label_constants import CANONICAL_TAG_KINDS + + +def configured_custom_kinds(start_dir: str | None = None) -> list[str]: + """Project-configured extra kinds from ``[tags] custom_kinds`` (deduped, ordered).""" + from ..integrations.config import config_get + + raw = config_get("tags.custom_kinds", start_dir=start_dir) + if not isinstance(raw, (list, tuple)): + return [] + ordered: dict[str, None] = {} + for item in raw: + kind = str(item).strip() + if kind: + ordered.setdefault(kind, None) + return list(ordered) + + +def allowed_tag_kinds(start_dir: str | None = None) -> frozenset[str]: + """Canonical kinds plus any configured custom kinds.""" + return CANONICAL_TAG_KINDS | frozenset(configured_custom_kinds(start_dir)) + + +def _hint_lines(kind: str, start_dir: str | None) -> list[str]: + """A spelled-out, append-aware ``.roarconfig`` snippet that allows *kind*.""" + existing = configured_custom_kinds(start_dir) + merged = existing if kind in existing else [*existing, kind] + rendered = ", ".join(f'"{k}"' for k in merged) + verb = "update" if existing else "add" + return [ + f"to allow '{kind}', {verb} [tags] custom_kinds in .roarconfig:", + " [tags]", + f" custom_kinds = [{rendered}]", + ] + + +def enforce_tag_kind(kind: str, *, start_dir: str | None = None) -> None: + """Reject *kind* (with an actionable hint) unless it's allowed. + + A canonical kind, or one listed in ``[tags] custom_kinds``, passes silently. + Otherwise prints ``Error:`` + a ``hint:`` snippet and exits non-zero. + """ + if kind in allowed_tag_kinds(start_dir): + return + + from ._format import hints_should_print, make_hint_printer + + click.echo(f"Error: '{kind}' is not a canonical tag kind.", err=True) + if hints_should_print(): + _, hint = make_hint_printer() + for line in _hint_lines(kind, start_dir): + hint(line) + raise SystemExit(1) diff --git a/roar/cli/command_registry.py b/roar/cli/command_registry.py index a2ed4181..4a855c41 100644 --- a/roar/cli/command_registry.py +++ b/roar/cli/command_registry.py @@ -150,6 +150,13 @@ class CommandSpec: CommandSpec( "label", "roar.cli.commands.label", "label", "Manage local labels", "Share and Publish" ), + CommandSpec( + "tag", + "roar.cli.commands.tag", + "tag", + "Manage hereditary compliance tags", + "Share and Publish", + ), CommandSpec( "auth", "roar.cli.commands.auth", diff --git a/roar/cli/commands/__init__.py b/roar/cli/commands/__init__.py index 9681cf91..3da9bf68 100644 --- a/roar/cli/commands/__init__.py +++ b/roar/cli/commands/__init__.py @@ -35,6 +35,7 @@ "run": (".run", "run"), "show": (".show", "show"), "status": (".status", "status"), + "tag": (".tag", "tag"), "tracer": (".tracer", "tracer"), "workflow": (".workflow", "workflow"), } diff --git a/roar/cli/commands/register.py b/roar/cli/commands/register.py index 2007a83c..1c414f59 100644 --- a/roar/cli/commands/register.py +++ b/roar/cli/commands/register.py @@ -12,7 +12,8 @@ from ...application.publish.requests import RegisterLineageRequest from ...application.publish.results import RegisterLineageResponse, RegisterTagSummary -from ...application.publish.service import register_lineage_target +from ...application.publish.service import register_lineage_target, resolve_register_lineage_target +from ...application.tags import TagService from ..context import RoarContext from ..decorators import require_init from ..publish_intent import ( @@ -163,6 +164,65 @@ def _render_tag_summary(summary: RegisterTagSummary | None) -> None: click.echo("") +def _apply_register_binds( + ctx: RoarContext, + *, + target: str, + response: RegisterLineageResponse, + bind_targets: tuple[str, ...], + no_bind: bool, +) -> None: + """Bind tags after a successful register — the "register implies bind" rule. + + Only a target that itself names a concrete artifact (a path or hash, not a + step/job/session reference) gets the implicit bind — the named-artifact + rule requires an act naming an artifact a human can inspect, and a + step/job may have several outputs with no single one "the" target. + `--bind ARTIFACT` binds specific outputs regardless of what TARGET was. + Best-effort: registration already succeeded, so a bind failure here warns + rather than failing the command. + """ + refs: list[str] = [] + if not no_bind and response.artifact_hash: + resolved_target = resolve_register_lineage_target( + target, cwd=ctx.cwd, roar_dir=ctx.roar_dir + ) + if resolved_target.kind in ("artifact_hash", "artifact_path"): + refs.append(response.artifact_hash) + refs.extend(bind_targets) + if not refs: + return + + from ...application.query.results import TagBindArtifactSummary + from ...db.context import create_database_context + + with create_database_context(ctx.roar_dir) as db_ctx: + svc = TagService(db_ctx, ctx.cwd) + for ref in refs: + try: + resolved = svc.resolve_target(ref) + result = svc.bind(resolved) + except ValueError as exc: + click.echo(f"Warning: could not bind {ref!r}: {exc}", err=True) + continue + + size = None + if resolved.entity_type == "artifact" and resolved.artifact_id: + artifact = db_ctx.artifacts.get(resolved.artifact_id) + if artifact: + size = artifact.get("size") + + click.echo( + TagBindArtifactSummary( + display_target=resolved.display_target or ref, + action="bind", + changed=result.changed, + promoted=result.promoted, + size=size, + ).render() + ) + + def _confirm_secrets(detected_secrets: list[str]) -> bool: """Prompt user to confirm registration with secrets.""" click.echo("") @@ -277,6 +337,25 @@ def _render_register_checklist( is_flag=True, help="Force public anonymous registration even when local GLaaS auth is configured.", ) +@click.option( + "--bind", + "bind_targets", + multiple=True, + metavar="ARTIFACT", + help=( + "Also bind this artifact's current tags to cross-session scope after " + "registering (repeatable). Use alongside a session-wide register to " + "promote specific outputs in one step." + ), +) +@click.option( + "--no-bind", + is_flag=True, + help=( + "When TARGET names an artifact, skip the implicit bind that " + "`roar register ` normally performs." + ), +) @click.pass_obj @require_init def register( @@ -287,6 +366,8 @@ def register( as_blake3: bool, public: bool | None, anonymous: bool, + bind_targets: tuple[str, ...], + no_bind: bool, ) -> None: """Register lineage with GLaaS. @@ -317,12 +398,23 @@ def register( anonymous, you will also be prompted before publishing public anonymous lineage unless --yes is provided. + When TARGET names an artifact (a path or hash), registering it implies + binding its current tags to cross-session scope — the same effect as a + separate `roar tag bind`. Registering a session (no target, a step, or a + job) does not auto-bind anything; use `--bind ARTIFACT` (repeatable) to + bind specific outputs in the same command, or `--no-bind` to skip the + artifact-target implicit bind. + \b Examples: roar register # Register the whole active session - roar register model.pt # Register model lineage + roar register model.pt # Register model lineage (implies bind) + + roar register --no-bind model.pt # Register without binding + + roar register --bind model.pt # Register the session, bind model.pt roar register --dry-run model.pt # Preview without registering @@ -479,6 +571,14 @@ def register( click.echo(f" roar reproduce {response.artifact_hash}") if not dry_run: + _apply_register_binds( + ctx, + target=target, + response=response, + bind_targets=bind_targets, + no_bind=no_bind, + ) + from ...telemetry.hooks import record_action_trigger record_action_trigger("register", start_dir=ctx.cwd) diff --git a/roar/cli/commands/run.py b/roar/cli/commands/run.py index d7a38550..f0b1b633 100644 --- a/roar/cli/commands/run.py +++ b/roar/cli/commands/run.py @@ -8,11 +8,25 @@ import click from ...application.run import RunRequest, run_command +from ...application.tags import parse_tag_kv from ...core.tracer_modes import TRACER_MODE_VALUES +from .._tag_kinds import enforce_tag_kind from ..context import RoarContext from ..decorators import require_init +def _validate_add_tags( + ctx: click.Context, param: click.Parameter, value: tuple[str, ...] +) -> tuple[str, ...]: + for item in value: + try: + kind, _value = parse_tag_kv(item) + except ValueError as exc: + raise click.BadParameter(str(exc)) from exc + enforce_tag_kind(kind) + return value + + @click.command( "run", context_settings={ @@ -51,6 +65,25 @@ help="Allow runtime fallback to another tracer backend", ) @click.option("--hash", "hash_algorithms", multiple=True, help="Add hash algorithm") +@click.option( + "--block-tag", + "block_tags", + multiple=True, + metavar="KIND[=VALUE]", + help=( + "Stop a compliance tag from being inherited for this run (repeatable). " + "KIND blocks the whole kind; KIND=VALUE filters just that value " + "(e.g. license=GPL-3.0 for a relicensing step)." + ), +) +@click.option( + "--add-tag", + "add_tags", + multiple=True, + metavar="KIND=VALUE", + callback=_validate_add_tags, + help="Stamp KIND=VALUE onto this run's output artifacts (repeatable).", +) @click.pass_obj @require_init def run( @@ -62,6 +95,8 @@ def run( tracer_mode: str | None, tracer_fallback: bool | None, hash_algorithms: tuple[str, ...], + block_tags: tuple[str, ...], + add_tags: tuple[str, ...], ) -> None: """Run a command with provenance tracking. @@ -101,6 +136,8 @@ def run( tracer_mode=tracer_mode, tracer_fallback=tracer_fallback, hash_algorithms=tuple(hash_algorithms), + block_tags=tuple(block_tags), + add_tags=tuple(add_tags), ) ) except ValueError as exc: @@ -139,6 +176,8 @@ def _get_help_text() -> str: --no-tracer-fallback Disable runtime tracer fallback --hash Add hash algorithm (can be repeated) -n, --name Set the name label for this step + --block-tag Stop a tag kind (or one value) from being inherited (repeatable) + --add-tag Stamp a tag onto this run's outputs (repeatable) Hash algorithms: blake3 (default), sha256, sha512, md5 diff --git a/roar/cli/commands/tag.py b/roar/cli/commands/tag.py new file mode 100644 index 00000000..7b1ade73 --- /dev/null +++ b/roar/cli/commands/tag.py @@ -0,0 +1,241 @@ +""" +Compliance tag command group. + +Usage: + roar tag add = + roar tag rm [=] + roar tag show + roar tag history + roar tag why [=] + roar tag bind ... + roar tag unbind ... + +Targets: + @N Job step N in the active session + Artifact by hash prefix or path + +Canonical tag kinds: + license contains_pii jurisdiction classification special_category + +Scope: tags propagate automatically within a session. Crossing a session +boundary requires an explicit bind — `roar tag bind ` promotes +that artifact's current tags to cross-session scope. `roar tag add` on a +named artifact is "born bound" (writes an implicit bind); `roar run +--add-tag` stays session-scoped until the artifact is explicitly bound. +""" + +from __future__ import annotations + +import click + +from ...application.query.requests import ( + TagAddRequest, + TagBindRequest, + TagHistoryRequest, + TagRmRequest, + TagShowRequest, + TagUnbindRequest, + TagWhyRequest, +) +from ...application.query.tag import ( + tag_add, + tag_bind, + tag_history, + tag_rm, + tag_show, + tag_unbind, + tag_why, +) +from .._tag_kinds import enforce_tag_kind +from ..context import RoarContext +from ..decorators import require_init + + +@click.group("tag", invoke_without_command=True) +@click.pass_context +def tag(ctx: click.Context) -> None: + """Manage hereditary compliance tags on artifacts and jobs. + + Tags are stored under the tag.* label namespace and propagate to + downstream artifacts through the lineage graph. + + \b + Canonical kinds: + license contains_pii jurisdiction + classification special_category + + \b + Target references: + @N Job step N in the active session + Artifact by hash prefix + + \b + Examples: + roar tag add license=GPL-3.0 @1 + roar tag add contains_pii=present @1 + roar tag rm license=GPL-3.0 @1 + roar tag rm license @1 + roar tag show @1 + roar tag history @1 + roar tag why contains_pii model.pt + roar tag bind model.pt + roar tag unbind model.pt + """ + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +@tag.command("add") +@click.argument("kv") +@click.argument("target") +@click.pass_obj +@require_init +def tag_add_cmd(ctx: RoarContext, kv: str, target: str) -> None: + """Add a value to a tag set. + + \b + Examples: + roar tag add license=GPL-3.0 @1 + roar tag add contains_pii=present @2 + roar tag add jurisdiction=EU a1b2c3d4 + """ + if "=" in kv: + kind = kv.split("=", 1)[0].strip() + if kind: + enforce_tag_kind(kind, start_dir=str(ctx.cwd)) + try: + rendered = tag_add(TagAddRequest(roar_dir=ctx.roar_dir, cwd=ctx.cwd, kv=kv, target=target)) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + click.echo(rendered) + + +@tag.command("rm") +@click.argument("key_or_kv") +@click.argument("target") +@click.pass_obj +@require_init +def tag_rm_cmd(ctx: RoarContext, key_or_kv: str, target: str) -> None: + """Remove a value (or entire kind) from a tag. + + Pass KIND=VALUE to remove one value; pass just KIND to remove the whole key. + + \b + Examples: + roar tag rm license=GPL-3.0 @1 + roar tag rm license @1 + """ + try: + rendered = tag_rm( + TagRmRequest(roar_dir=ctx.roar_dir, cwd=ctx.cwd, key_or_kv=key_or_kv, target=target) + ) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + click.echo(rendered) + + +@tag.command("show") +@click.argument("target") +@click.pass_obj +@require_init +def tag_show_cmd(ctx: RoarContext, target: str) -> None: + """Show current tags for a target. + + \b + Examples: + roar tag show @1 + roar tag show a1b2c3d4 + """ + try: + rendered = tag_show(TagShowRequest(roar_dir=ctx.roar_dir, cwd=ctx.cwd, target=target)) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + click.echo(rendered) + + +@tag.command("history") +@click.argument("target") +@click.pass_obj +@require_init +def tag_history_cmd(ctx: RoarContext, target: str) -> None: + """Show all label versions for a target. + + \b + Examples: + roar tag history @1 + roar tag history a1b2c3d4 + """ + try: + rendered = tag_history(TagHistoryRequest(roar_dir=ctx.roar_dir, cwd=ctx.cwd, target=target)) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + click.echo(rendered) + + +@tag.command("bind") +@click.argument("targets", nargs=-1, required=True) +@click.pass_obj +@require_init +def tag_bind_cmd(ctx: RoarContext, targets: tuple[str, ...]) -> None: + """Promote artifacts' current tags to cross-session scope. + + Snapshot semantics: covers exactly each artifact's tag set at bind time. + Echoes what it promotes — path, size, and the tag set — so binding junk + means reading past a line that says the artifact is empty. + + \b + Examples: + roar tag bind model.pt + roar tag bind a1b2c3d4 e5f6a7b8 + """ + try: + rendered = tag_bind(TagBindRequest(roar_dir=ctx.roar_dir, cwd=ctx.cwd, targets=targets)) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + click.echo(rendered) + + +@tag.command("unbind") +@click.argument("targets", nargs=-1, required=True) +@click.pass_obj +@require_init +def tag_unbind_cmd(ctx: RoarContext, targets: tuple[str, ...]) -> None: + """Revoke artifacts' currently-bound tags. + + Append-only revocation: writes a new event, doesn't delete the bind it + revokes. Everything that inherited through it is mechanically + identifiable as superseded — one unbind heals the whole cone. + + \b + Examples: + roar tag unbind model.pt + """ + try: + rendered = tag_unbind(TagUnbindRequest(roar_dir=ctx.roar_dir, cwd=ctx.cwd, targets=targets)) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + click.echo(rendered) + + +@tag.command("why") +@click.argument("key") +@click.argument("target") +@click.pass_obj +@require_init +def tag_why_cmd(ctx: RoarContext, key: str, target: str) -> None: + """Explain how TARGET acquired a tag — walk the inheritance path to the human act. + + KEY is KIND or KIND=VALUE (a value narrows the explanation to one value). + + \b + Examples: + roar tag why contains_pii model.pkl + roar tag why license=GPL-3.0 @2 + """ + try: + rendered = tag_why( + TagWhyRequest(roar_dir=ctx.roar_dir, cwd=ctx.cwd, key=key, target=target) + ) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + click.echo(rendered) diff --git a/roar/core/label_constants.py b/roar/core/label_constants.py index 3611c434..7caadecc 100644 --- a/roar/core/label_constants.py +++ b/roar/core/label_constants.py @@ -13,3 +13,15 @@ "dataset.modality", } ) + +TAG_NAMESPACE = "tag" + +CANONICAL_TAG_KINDS: frozenset[str] = frozenset( + { + "license", + "contains_pii", + "jurisdiction", + "classification", + "special_category", + } +) diff --git a/roar/core/models/run.py b/roar/core/models/run.py index 50d872be..87f82b47 100644 --- a/roar/core/models/run.py +++ b/roar/core/models/run.py @@ -94,6 +94,8 @@ class RunContext(RoarBaseModel): git_commit: str | None = None git_branch: str | None = None git_repo: str | None = None + block_tags: list[str] = Field(default_factory=list) + add_tags: list[str] = Field(default_factory=list) @field_validator("roar_dir", mode="before") @classmethod diff --git a/roar/db/services/job_recording.py b/roar/db/services/job_recording.py index 131bb4e7..6b1cd35a 100644 --- a/roar/db/services/job_recording.py +++ b/roar/db/services/job_recording.py @@ -5,11 +5,20 @@ outputs, and session associations. """ +import json import os +from typing import Any from sqlalchemy.orm import Session as SASession from ...application.system_labels import refresh_job_system_labels +from ...application.tags import ( + build_run_modifiers, + parse_add_tags, + parse_block_tags, + propagate_tags, + stamp_tags, +) from ...core.label_origins import LABEL_ORIGIN_USER from ...core.step_name import STEP_NAME_LABEL_KEY, get_step_name_label from ..repositories import ( @@ -95,6 +104,8 @@ def record_job( repo_root: str | None = None, telemetry: str | None = None, hash_algorithms: list[str] | None = None, + block_tags: tuple[str, ...] = (), + add_tags: tuple[str, ...] = (), ) -> tuple[int, str]: """ Record a job with its inputs and outputs. @@ -118,6 +129,8 @@ def record_job( job_type: Job type ('run', 'build', etc.) repo_root: Repository root path for path normalization telemetry: JSON telemetry data (external service links) + block_tags: Tag kinds exempted from automatic inheritance from inputs + add_tags: Extra "KIND=VALUE" tags to stamp onto this job's outputs hash_algorithms: Hash algorithms to use (default: ['blake3']) Returns: @@ -157,6 +170,11 @@ def record_job( assign_to_session, step_identity, git_commit, git_repo ) + # Persist the roar-side run modifiers (--block-tag / --add-tag) on the job + # metadata so `roar reproduce` can replay them; a bare re-run would + # otherwise re-inherit blocked tags and drop stamped ones. + metadata = self._with_run_modifiers(metadata, block_tags, add_tags) + # Create the job record job_id, job_uid = self._job_repo.create( command=command, @@ -187,7 +205,7 @@ def record_job( self._record_step_name_label(job_id, step_name) # Register and link input artifacts - self._register_artifacts( + input_artifact_ids = self._register_artifacts( job_id, hashable_inputs, hashes_by_path, @@ -196,7 +214,7 @@ def record_job( ) # Register and link output artifacts - self._register_artifacts( + output_artifact_ids = self._register_artifacts( job_id, hashable_outputs, hashes_by_path, @@ -204,6 +222,25 @@ def record_job( is_input=False, ) + blocked_kinds, blocked_values = parse_block_tags(block_tags) + propagate_tags( + self._label_repo, + input_artifact_ids=input_artifact_ids, + output_artifact_ids=output_artifact_ids, + current_session_id=session_id, + resolve_job_session_id=self._resolve_job_session_id, + job_uid=job_uid, + blocked_kinds=blocked_kinds, + blocked_values=blocked_values, + ) + if add_tags: + stamp_tags( + self._label_repo, + output_artifact_ids=output_artifact_ids, + tags=parse_add_tags(add_tags), + job_uid=job_uid, + ) + # Commit transaction self._session.commit() @@ -213,6 +250,31 @@ def record_job( return job_id, job_uid + @staticmethod + def _with_run_modifiers( + metadata: str | None, block_tags: tuple[str, ...], add_tags: tuple[str, ...] + ) -> str | None: + """Add a ``run_modifiers`` block to the job metadata JSON when flags were used.""" + modifiers = build_run_modifiers(block_tags, add_tags) + if modifiers is None: + return metadata + data: dict[str, Any] = {} + if metadata: + try: + parsed = json.loads(metadata) + except (ValueError, TypeError): + parsed = None + if isinstance(parsed, dict): + data = parsed + data["run_modifiers"] = modifiers + return json.dumps(data) + + def _resolve_job_session_id(self, job_uid: str) -> int | None: + """Look up which local session produced *job_uid* (for tag scope checks).""" + job = self._job_repo.get_by_uid(job_uid) + session_id = job.get("session_id") if job else None + return int(session_id) if isinstance(session_id, int) else None + def _record_step_name_label(self, job_id: int, step_name: str) -> None: """Store the canonical step name as current job label metadata.""" current = self._label_repo.get_current("job", job_id=job_id) @@ -270,8 +332,14 @@ def _register_artifacts( hashes_by_path: dict[str, dict[str, str]], hash_algorithms: list[str], is_input: bool, - ) -> None: - """Register artifacts and link them to the job.""" + ) -> list[str]: + """Register artifacts and link them to the job. + + Returns the artifact ids just linked (empty if none were new/hashable). + Note: within a single ``record_job()`` call, ``job_id`` was just + created, so there can be no pre-existing edges — the dedup check below + only matters if this is ever called again for an existing job. + """ # Batch-check which paths already have edges for this job if is_input: already_linked = self._job_repo.existing_input_paths(job_id, file_paths) @@ -299,7 +367,7 @@ def _register_artifacts( valid_paths.append(path) if not batch_items: - return + return [] # Batch register artifacts artifact_ids = self._artifact_repo.register_batch(batch_items) @@ -311,6 +379,8 @@ def _register_artifacts( else: self._job_repo.add_outputs_batch(job_id, edges) + return list(artifact_ids) + @staticmethod def _unique_paths(paths: list[str]) -> list[str]: """Return unique paths preserving input order.""" diff --git a/roar/execution/recording/job_recording.py b/roar/execution/recording/job_recording.py index 2dff8275..f7e5a05c 100644 --- a/roar/execution/recording/job_recording.py +++ b/roar/execution/recording/job_recording.py @@ -340,6 +340,8 @@ def record( repo_root=ctx.repo_root, telemetry=telemetry_json, hash_algorithms=list(ctx.hash_algorithms), + block_tags=tuple(ctx.block_tags), + add_tags=tuple(ctx.add_tags), ) # Register proxy artifacts first so downstream output/input queries include them. diff --git a/roar/execution/reproduction/pipeline_executor.py b/roar/execution/reproduction/pipeline_executor.py index 4360aee4..342b84f1 100644 --- a/roar/execution/reproduction/pipeline_executor.py +++ b/roar/execution/reproduction/pipeline_executor.py @@ -5,12 +5,14 @@ This service handles executing pipeline steps during reproduction. """ +import json import os import shutil import subprocess import sys from typing import TYPE_CHECKING +from ...application.tags import run_modifier_flags from ...presenters import NullPresenter if TYPE_CHECKING: @@ -125,25 +127,28 @@ def _run_step( self._print(" No command found for step, skipping.") return True - # Wrap with roar for provenance tracking - roar_cmd = "build" if is_build else "run" - wrapped_command = self._wrap_with_roar(command, roar_cmd, environment) - - self._print(f" Command: roar {roar_cmd} {command}") - - # Extract env vars from step metadata - step_env_vars: dict[str, str] = {} + # Parse step metadata once: env vars + the recorded `roar run` modifiers + # (--block-tag / --add-tag) that shaped the original tag/barrier layer. metadata = step.get("metadata") - if metadata: - import json as _json + if isinstance(metadata, str): + try: + metadata = json.loads(metadata) + except (ValueError, TypeError): + metadata = {} + if not isinstance(metadata, dict): + metadata = {} + step_env_vars: dict[str, str] = metadata.get("env_vars", {}) + modifier_flags = run_modifier_flags(metadata.get("run_modifiers")) + + # Wrap with roar for provenance tracking, replaying recorded modifiers so + # the reproduced run reproduces the same tags/barriers, not just bytes. + roar_cmd = "build" if is_build else "run" + wrapped_command = self._wrap_with_roar( + command, roar_cmd, environment, modifiers=modifier_flags + ) - if isinstance(metadata, str): - try: - metadata = _json.loads(metadata) - except (ValueError, TypeError): - metadata = {} - if isinstance(metadata, dict): - step_env_vars = metadata.get("env_vars", {}) + shown = f"{modifier_flags} {command}".strip() + self._print(f" Command: roar {roar_cmd} {shown}") # Set up environment env = self._prepare_environment(environment, env_vars=step_env_vars) @@ -178,14 +183,19 @@ def _wrap_with_roar( command: str, roar_cmd: str, environment: "EnvironmentInfo", + modifiers: str = "", ) -> str: """Wrap a command with roar build/run. Uses the external roar executable (from the parent process) instead of installing roar in the reproduce venv. This prevents roar from being - deleted if a build step runs 'uv sync'. + deleted if a build step runs 'uv sync'. *modifiers* are recorded + ``roar run`` flags (e.g. ``--block-tag …``) replayed for fidelity. """ - return f"{self._roar_executable} {roar_cmd} {command}" + prefix = f"{self._roar_executable} {roar_cmd}" + if modifiers: + prefix = f"{prefix} {modifiers}" + return f"{prefix} {command}" def _detect_roar_executable(self) -> str: """Get path to the currently running roar executable. diff --git a/roar/integrations/config/access.py b/roar/integrations/config/access.py index 1d3073fc..db67cb5f 100644 --- a/roar/integrations/config/access.py +++ b/roar/integrations/config/access.py @@ -144,6 +144,14 @@ "default": True, "description": "Create git tag on successful registration", }, + "tags.custom_kinds": { + "type": list, + "default": [], + "description": ( + "Extra hereditary `roar tag` kinds to allow beyond the built-in " + "canonical kinds (comma-separated). Commit in .roarconfig to share." + ), + }, "reversible.enabled": { "type": bool, "default": False, diff --git a/roar/integrations/config/schema.py b/roar/integrations/config/schema.py index 622d610a..ab29c617 100644 --- a/roar/integrations/config/schema.py +++ b/roar/integrations/config/schema.py @@ -143,6 +143,17 @@ class TaggingConfig(ConfigBaseModel): enabled: bool = True +class TagsConfig(ConfigBaseModel): + """Hereditary compliance-tag (``roar tag``) configuration. + + ``custom_kinds`` extends the built-in canonical kinds with project-specific + hereditary kinds. Committed in ``.roarconfig`` so a team shares the same + allowed set. Distinct from ``registration.tagging`` (git tags at register). + """ + + custom_kinds: list[str] = Field(default_factory=list) + + class RegisterConfig(ConfigBaseModel): """Register/publish defaults and filtering configuration.""" @@ -252,6 +263,7 @@ class RoarConfig(ConfigBaseModel): cleanup: CleanupConfig = Field(default_factory=CleanupConfig) glaas: GlaasConfig = Field(default_factory=GlaasConfig) registration: RegisterConfig = Field(default_factory=RegisterConfig) + tags: TagsConfig = Field(default_factory=TagsConfig) hash: HashConfig = Field(default_factory=HashConfig) proxy: ProxyConfig = Field(default_factory=ProxyConfig) tracer: TracerConfig = Field(default_factory=TracerConfig) diff --git a/roar/presenters/show_renderer.py b/roar/presenters/show_renderer.py index 874476e7..cfbb309c 100644 --- a/roar/presenters/show_renderer.py +++ b/roar/presenters/show_renderer.py @@ -10,6 +10,8 @@ import json from ..application.label_rendering import render_label_lines +from ..application.tags import barrier_items, tag_display_pairs +from ..core.label_constants import TAG_NAMESPACE from ..core.step_name import omit_step_name_label from .formatting import format_duration, format_size, format_timestamp @@ -67,10 +69,32 @@ def _render_composite_provenance(lines: list[str], artifact: dict) -> None: @staticmethod def _render_labels(lines: list[str], metadata: dict | None) -> None: - if not metadata: + # Tags render in their own clean section (see _render_tags); keep the + # tag.* subtree — including the internal bind ledger — out of the raw + # key=value Labels dump. + other = {k: v for k, v in metadata.items() if k != TAG_NAMESPACE} if metadata else None + if not other: return lines.append("\nLabels:") - lines.extend(render_label_lines(metadata, indent=" ")) + lines.extend(render_label_lines(other, indent=" ")) + + @staticmethod + def _render_tags(lines: list[str], metadata: dict | None) -> None: + """Render the ``tag.*`` subtree as a clean Tags section (shared with `roar tag show`).""" + pairs = tag_display_pairs((metadata or {}).get(TAG_NAMESPACE)) + if not pairs: + return + lines.append("\nTags:") + lines.extend(f" {kind}={value}" for kind, value in pairs) + + @staticmethod + def _render_barriers(lines: list[str], metadata: dict | None) -> None: + """Render a job's declared barriers (recorded `--block-tag` run modifiers).""" + items = barrier_items((metadata or {}).get("run_modifiers")) + if not items: + return + lines.append("\nBarriers:") + lines.extend(f" {item} (--block-tag)" for item in items) def render_session(self, session: dict, jobs: list[dict], labels: dict | None = None) -> str: """Render session overview with job listing. @@ -91,6 +115,7 @@ def render_session(self, session: dict, jobs: list[dict], labels: dict | None = if session.get("git_commit_start"): lines.append(f"Commit: {session['git_commit_start']}") + self._render_tags(lines, labels) self._render_labels(lines, labels) if not jobs: @@ -185,6 +210,8 @@ def render_job( if source_parts: lines.append("Source: " + " ".join(source_parts)) + self._render_barriers(lines, job.get("metadata")) + self._render_tags(lines, labels) self._render_labels(lines, omit_step_name_label(labels, step_name=job.get("step_name"))) if job.get("command"): @@ -467,6 +494,7 @@ def render_artifact( lines.append(f"Path: {first_seen_path}{missing}") lines.append(f"First seen: {format_timestamp(artifact['first_seen_at'])}") + self._render_tags(lines, labels) self._render_labels(lines, labels) # ---- locations ------------------------------------------------------- diff --git a/tests/application/get/test_fake_hf_e2e.py b/tests/application/get/test_fake_hf_e2e.py index 575ad75c..5736b795 100644 --- a/tests/application/get/test_fake_hf_e2e.py +++ b/tests/application/get/test_fake_hf_e2e.py @@ -180,7 +180,9 @@ def test_view_edge_resolution_from_real_get_db(tmp_path: Path) -> None: with create_database_context(roar_dir) as ctx: art = ctx.artifacts.get_by_path(str(tmp_path / "data" / "shard_00000.parquet")) shard0_blake3 = next(h["digest"] for h in art["hashes"] if h["algorithm"] == "blake3") - edges, prune = resolve_view_edges_for_job(db_ctx=ctx, input_hashes=[shard0_blake3]) + edges, prune, _leaf_hashes = resolve_view_edges_for_job( + db_ctx=ctx, input_hashes=[shard0_blake3] + ) assert len(edges) == 1 edge = edges[0] diff --git a/tests/application/publish/test_composite_large_dataset.py b/tests/application/publish/test_composite_large_dataset.py index c3e224ad..e3d2ffd3 100644 --- a/tests/application/publish/test_composite_large_dataset.py +++ b/tests/application/publish/test_composite_large_dataset.py @@ -66,10 +66,13 @@ def test_tail_leaf_beyond_upload_cap_resolves_and_prunes(tmp_path: Path): # A run consumed a single tail chunk (recorded as a plain input artifact). ctx.artifacts.register(hashes={"blake3": tail_digest}, size=8, path="data/big.zarr/array/x") - edges, prune = resolve_view_edges_for_job(db_ctx=ctx, input_hashes=[tail_digest]) + edges, prune, leaf_hashes = resolve_view_edges_for_job( + db_ctx=ctx, input_hashes=[tail_digest] + ) assert len(edges) == 1 assert edges[0]["relation"] == "consumes" assert edges[0]["target_hash"] == result.digest assert edges[0]["parent_total"] == _N assert prune == {tail_digest} + assert leaf_hashes == {tail_digest} diff --git a/tests/application/publish/test_view_edges.py b/tests/application/publish/test_view_edges.py index 013dc8a7..fb2fbefa 100644 --- a/tests/application/publish/test_view_edges.py +++ b/tests/application/publish/test_view_edges.py @@ -67,13 +67,14 @@ def test_resolve_consumes_view_edge_via_sha256_crosswalk_and_prunes(): find=lambda digest, algo="sha256": ["anchor-1"] if digest == shard_sha else [], ) - edges, prune = resolve_view_edges_for_job(db_ctx=db, input_hashes=[shard_blake3]) + edges, prune, leaf_hashes = resolve_view_edges_for_job(db_ctx=db, input_hashes=[shard_blake3]) assert len(edges) == 1 assert edges[0]["relation"] == "consumes" assert edges[0]["target_hash"] == "d" * 64 assert edges[0]["parent_total"] == 6543 assert edges[0]["selected_count"] == 1 assert prune == {shard_blake3} + assert leaf_hashes == {shard_blake3} assert _bloom_member(edges[0]["bloom"], "sha256", shard_sha) is True @@ -95,11 +96,12 @@ def test_resolve_blake3_leaf_for_local_put_composite(): ), ) - edges, prune = resolve_view_edges_for_job(db_ctx=db, input_hashes=[leaf_blake3]) + edges, prune, leaf_hashes = resolve_view_edges_for_job(db_ctx=db, input_hashes=[leaf_blake3]) assert len(edges) == 1 assert edges[0]["target_hash"] == "c" * 64 assert edges[0]["parent_total"] == 8 assert prune == {leaf_blake3} + assert leaf_hashes == {leaf_blake3} # Bloom is keyed by blake3, not sha256. assert _bloom_member(edges[0]["bloom"], "blake3", leaf_blake3) is True assert _bloom_member(edges[0]["bloom"], "sha256", leaf_blake3) is False @@ -132,7 +134,7 @@ def _get_by_hash(digest, algorithm=None): ["zarr-anchor"] if digest in (leaf_a, leaf_b) and algo == "blake3" else [] ) - edges, prune = resolve_view_edges_for_job( + edges, prune, leaf_hashes = resolve_view_edges_for_job( db_ctx=db, input_hashes=[leaf_a, leaf_b, anchor], relation="produces" ) assert len(edges) == 1 @@ -142,6 +144,10 @@ def _get_by_hash(digest, algorithm=None): assert edges[0]["selected_count"] == 2 # Both produced leaves and the directly-linked anchor collapse out of the bundle. assert prune == {leaf_a, leaf_b, anchor} + # But only the true leaves are reported as composite-member hashes — the anchor's + # own hash must never be excluded from label sync just because it was also pruned + # from the plain job edges. + assert leaf_hashes == {leaf_a, leaf_b} assert _bloom_member(edges[0]["bloom"], "blake3", leaf_a) is True @@ -152,9 +158,10 @@ def test_resolve_no_crosswalk_means_no_view_edges(): anchors_by_id={}, find=lambda digest, algo="sha256": [], ) - edges, prune = resolve_view_edges_for_job(db_ctx=db, input_hashes=[plain]) + edges, prune, leaf_hashes = resolve_view_edges_for_job(db_ctx=db, input_hashes=[plain]) assert edges == [] assert prune == set() + assert leaf_hashes == set() def _bloom_member_raw(bloom: dict, key: bytes) -> bool: diff --git a/tests/application/query/test_tag.py b/tests/application/query/test_tag.py new file mode 100644 index 00000000..7b52dc49 --- /dev/null +++ b/tests/application/query/test_tag.py @@ -0,0 +1,377 @@ +"""Tests for roar.application.query.tag orchestration layer.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from roar.application.query.requests import ( + TagAddRequest, + TagBindRequest, + TagHistoryRequest, + TagRmRequest, + TagShowRequest, + TagUnbindRequest, +) +from roar.application.query.tag import ( + build_tag_add_summary, + build_tag_bind_summary, + build_tag_history_summary, + build_tag_rm_summary, + build_tag_show_summary, + build_tag_unbind_summary, +) +from roar.application.tags import BindResult + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _kind(*values: str, origin: str = "user") -> dict: + return {"values": [{"value": v, "origin": origin} for v in values]} + + +def _add_request(tmp_path: Path, **overrides) -> TagAddRequest: + return TagAddRequest( + roar_dir=overrides.pop("roar_dir", tmp_path / ".roar"), + cwd=overrides.pop("cwd", tmp_path), + kv=overrides.pop("kv", "license=MIT"), + target=overrides.pop("target", "@1"), + **overrides, + ) + + +def _rm_request(tmp_path: Path, **overrides) -> TagRmRequest: + return TagRmRequest( + roar_dir=overrides.pop("roar_dir", tmp_path / ".roar"), + cwd=overrides.pop("cwd", tmp_path), + key_or_kv=overrides.pop("key_or_kv", "license=MIT"), + target=overrides.pop("target", "@1"), + **overrides, + ) + + +def _show_request(tmp_path: Path, **overrides) -> TagShowRequest: + return TagShowRequest( + roar_dir=overrides.pop("roar_dir", tmp_path / ".roar"), + cwd=overrides.pop("cwd", tmp_path), + target=overrides.pop("target", "@1"), + **overrides, + ) + + +def _history_request(tmp_path: Path, **overrides) -> TagHistoryRequest: + return TagHistoryRequest( + roar_dir=overrides.pop("roar_dir", tmp_path / ".roar"), + cwd=overrides.pop("cwd", tmp_path), + target=overrides.pop("target", "@1"), + **overrides, + ) + + +def _bind_request(tmp_path: Path, **overrides) -> TagBindRequest: + return TagBindRequest( + roar_dir=overrides.pop("roar_dir", tmp_path / ".roar"), + cwd=overrides.pop("cwd", tmp_path), + targets=overrides.pop("targets", ("model.pt",)), + **overrides, + ) + + +def _unbind_request(tmp_path: Path, **overrides) -> TagUnbindRequest: + return TagUnbindRequest( + roar_dir=overrides.pop("roar_dir", tmp_path / ".roar"), + cwd=overrides.pop("cwd", tmp_path), + targets=overrides.pop("targets", ("model.pt",)), + **overrides, + ) + + +def _mock_db_and_svc(tags: dict, *, changed: bool = True, history: list | None = None): + """Return (db_ctx_mock, tag_svc_mock) with sensible defaults.""" + db_ctx = MagicMock() + db_ctx.__enter__.return_value = db_ctx + db_ctx.__exit__.return_value = None + + svc = MagicMock() + svc.resolve_target.return_value = object() + svc.add.return_value = changed + svc.remove.return_value = changed + svc.get_tags.return_value = tags + svc.history.return_value = history or [] + return db_ctx, svc + + +def _patch(db_ctx, svc): + return ( + patch("roar.application.query.tag.create_database_context", return_value=db_ctx), + patch("roar.application.query.tag.TagService", return_value=svc), + ) + + +# --------------------------------------------------------------------------- +# tag add +# --------------------------------------------------------------------------- + + +class TestTagAdd: + def test_reports_tagged_when_value_added(self, tmp_path: Path) -> None: + db_ctx, svc = _mock_db_and_svc({"license": _kind("MIT")}, changed=True) + with _patch(db_ctx, svc)[0], _patch(db_ctx, svc)[1]: + summary = build_tag_add_summary(_add_request(tmp_path)) + assert "Tagged @1" in summary.heading + assert "license" in summary.heading + + def test_reports_no_change_when_value_already_present(self, tmp_path: Path) -> None: + db_ctx, svc = _mock_db_and_svc({"license": _kind("MIT")}, changed=False) + with _patch(db_ctx, svc)[0], _patch(db_ctx, svc)[1]: + summary = build_tag_add_summary(_add_request(tmp_path)) + assert "No change" in summary.heading + assert "MIT" in summary.heading + + def test_renders_current_tag_entries(self, tmp_path: Path) -> None: + db_ctx, svc = _mock_db_and_svc({"license": _kind("MIT", "Apache-2.0")}) + with _patch(db_ctx, svc)[0], _patch(db_ctx, svc)[1]: + summary = build_tag_add_summary(_add_request(tmp_path)) + entry = next(e for e in summary.entries if e.key == "license") + assert entry.display_value == "MIT, Apache-2.0" + + def test_raises_for_missing_equals(self, tmp_path: Path) -> None: + import pytest + + db_ctx, svc = _mock_db_and_svc({}) + with ( + _patch(db_ctx, svc)[0], + _patch(db_ctx, svc)[1], + pytest.raises(ValueError, match="Expected KIND=VALUE"), + ): + build_tag_add_summary(_add_request(tmp_path, kv="license")) + + def test_raises_for_empty_value(self, tmp_path: Path) -> None: + import pytest + + db_ctx, svc = _mock_db_and_svc({}) + with ( + _patch(db_ctx, svc)[0], + _patch(db_ctx, svc)[1], + pytest.raises(ValueError, match="Value cannot be empty"), + ): + build_tag_add_summary(_add_request(tmp_path, kv="license=")) + + def test_show_empty_message_when_no_tags_after_add(self, tmp_path: Path) -> None: + db_ctx, svc = _mock_db_and_svc({}, changed=False) + with _patch(db_ctx, svc)[0], _patch(db_ctx, svc)[1]: + summary = build_tag_add_summary(_add_request(tmp_path)) + assert summary.entries == [] + + +# --------------------------------------------------------------------------- +# tag rm +# --------------------------------------------------------------------------- + + +class TestTagRm: + def test_reports_removed_value(self, tmp_path: Path) -> None: + db_ctx, svc = _mock_db_and_svc({}, changed=True) + with _patch(db_ctx, svc)[0], _patch(db_ctx, svc)[1]: + summary = build_tag_rm_summary(_rm_request(tmp_path)) + assert "Removed" in summary.heading + assert "MIT" in summary.heading + + def test_reports_removed_kind_when_no_value(self, tmp_path: Path) -> None: + db_ctx, svc = _mock_db_and_svc({}, changed=True) + with _patch(db_ctx, svc)[0], _patch(db_ctx, svc)[1]: + summary = build_tag_rm_summary(_rm_request(tmp_path, key_or_kv="license")) + assert "Removed" in summary.heading + assert "tag.license" in summary.heading + + def test_reports_no_change_when_value_absent(self, tmp_path: Path) -> None: + db_ctx, svc = _mock_db_and_svc({}, changed=False) + with _patch(db_ctx, svc)[0], _patch(db_ctx, svc)[1]: + summary = build_tag_rm_summary(_rm_request(tmp_path)) + assert "No change" in summary.heading + + def test_renders_remaining_tags(self, tmp_path: Path) -> None: + db_ctx, svc = _mock_db_and_svc({"license": _kind("Apache-2.0")}, changed=True) + with _patch(db_ctx, svc)[0], _patch(db_ctx, svc)[1]: + summary = build_tag_rm_summary(_rm_request(tmp_path)) + assert any("Apache" in e.display_value for e in summary.entries) + + +# --------------------------------------------------------------------------- +# tag show +# --------------------------------------------------------------------------- + + +class TestTagShow: + def test_renders_current_tags(self, tmp_path: Path) -> None: + db_ctx, svc = _mock_db_and_svc({"license": _kind("MIT"), "contains_pii": _kind("absent")}) + with _patch(db_ctx, svc)[0], _patch(db_ctx, svc)[1]: + summary = build_tag_show_summary(_show_request(tmp_path)) + keys = [e.key for e in summary.entries] + assert "license" in keys + assert "contains_pii" in keys + + def test_renders_no_tags_message_when_empty(self, tmp_path: Path) -> None: + db_ctx, svc = _mock_db_and_svc({}) + with _patch(db_ctx, svc)[0], _patch(db_ctx, svc)[1]: + summary = build_tag_show_summary(_show_request(tmp_path)) + assert summary.entries == [] + assert "no tags" in summary.render() + + def test_heading_includes_target(self, tmp_path: Path) -> None: + db_ctx, svc = _mock_db_and_svc({}) + with _patch(db_ctx, svc)[0], _patch(db_ctx, svc)[1]: + summary = build_tag_show_summary(_show_request(tmp_path, target="@2")) + assert "@2" in summary.heading + + +# --------------------------------------------------------------------------- +# tag history +# --------------------------------------------------------------------------- + + +class TestTagHistory: + def test_renders_version_history(self, tmp_path: Path) -> None: + db_ctx, svc = _mock_db_and_svc( + {}, + history=[ + {"version": 1, "metadata": {"tag": {"license": _kind("MIT")}}}, + {"version": 2, "metadata": {"tag": {"license": _kind("MIT", "Apache-2.0")}}}, + ], + ) + with _patch(db_ctx, svc)[0], _patch(db_ctx, svc)[1]: + summary = build_tag_history_summary(_history_request(tmp_path)) + assert len(summary.versions) == 2 + assert summary.versions[0].version == 1 + assert summary.versions[1].version == 2 + + def test_skips_versions_without_tag_namespace(self, tmp_path: Path) -> None: + db_ctx, svc = _mock_db_and_svc( + {}, + history=[ + {"version": 1, "metadata": {"other": "value"}}, + {"version": 2, "metadata": {"tag": {"license": _kind("MIT")}}}, + ], + ) + with _patch(db_ctx, svc)[0], _patch(db_ctx, svc)[1]: + summary = build_tag_history_summary(_history_request(tmp_path)) + assert len(summary.versions) == 2 + assert summary.versions[0].entries == [] + + def test_renders_no_labels_when_empty_history(self, tmp_path: Path) -> None: + db_ctx, svc = _mock_db_and_svc({}, history=[]) + with _patch(db_ctx, svc)[0], _patch(db_ctx, svc)[1]: + summary = build_tag_history_summary(_history_request(tmp_path)) + assert summary.render() == "No labels." + + def test_bind_ledger_is_excluded_from_history_entries(self, tmp_path: Path) -> None: + db_ctx, svc = _mock_db_and_svc( + {}, + history=[ + { + "version": 1, + "metadata": { + "tag": { + "license": _kind("MIT"), + "bind": { + "events": [{"action": "bind", "covers": {"license": ["MIT"]}}] + }, + } + }, + }, + ], + ) + with _patch(db_ctx, svc)[0], _patch(db_ctx, svc)[1]: + summary = build_tag_history_summary(_history_request(tmp_path)) + keys = [e.key for e in summary.versions[0].entries] + assert keys == ["license"] + + +# --------------------------------------------------------------------------- +# tag bind / unbind +# --------------------------------------------------------------------------- + + +class TestTagBind: + def test_binds_each_target_and_echoes_promoted_tags(self, tmp_path: Path) -> None: + db_ctx = MagicMock() + db_ctx.__enter__.return_value = db_ctx + db_ctx.__exit__.return_value = None + db_ctx.artifacts.get.return_value = {"size": 1024} + + svc = MagicMock() + resolved = MagicMock(entity_type="artifact", artifact_id="a1", display_target="model.pt") + svc.resolve_target.return_value = resolved + svc.bind.return_value = BindResult(changed=True, promoted={"license": ["MIT"]}) + + with _patch(db_ctx, svc)[0], _patch(db_ctx, svc)[1]: + summary = build_tag_bind_summary(_bind_request(tmp_path, targets=("model.pt",))) + + assert len(summary.artifacts) == 1 + entry = summary.artifacts[0] + assert entry.display_target == "model.pt" + assert entry.action == "bind" + assert entry.changed is True + assert entry.promoted == {"license": ["MIT"]} + assert entry.size == 1024 + + def test_binds_multiple_targets_in_order(self, tmp_path: Path) -> None: + db_ctx = MagicMock() + db_ctx.__enter__.return_value = db_ctx + db_ctx.__exit__.return_value = None + db_ctx.artifacts.get.return_value = None + + svc = MagicMock() + svc.resolve_target.side_effect = [ + MagicMock(entity_type="artifact", artifact_id="a1", display_target="one.pt"), + MagicMock(entity_type="artifact", artifact_id="a2", display_target="two.pt"), + ] + svc.bind.return_value = BindResult(changed=False, promoted={}) + + with _patch(db_ctx, svc)[0], _patch(db_ctx, svc)[1]: + summary = build_tag_bind_summary(_bind_request(tmp_path, targets=("one.pt", "two.pt"))) + + assert [a.display_target for a in summary.artifacts] == ["one.pt", "two.pt"] + + def test_no_change_renders_a_no_op_line(self, tmp_path: Path) -> None: + db_ctx = MagicMock() + db_ctx.__enter__.return_value = db_ctx + db_ctx.__exit__.return_value = None + db_ctx.artifacts.get.return_value = {"size": 0} + + svc = MagicMock() + svc.resolve_target.return_value = MagicMock( + entity_type="artifact", artifact_id="a1", display_target="empty.bin" + ) + svc.bind.return_value = BindResult(changed=False, promoted={}) + + with _patch(db_ctx, svc)[0], _patch(db_ctx, svc)[1]: + summary = build_tag_bind_summary(_bind_request(tmp_path)) + + rendered = summary.render() + assert "no change" in rendered + assert "empty-content hash" in rendered # size == 0 warning + + +class TestTagUnbind: + def test_unbinds_each_target(self, tmp_path: Path) -> None: + db_ctx = MagicMock() + db_ctx.__enter__.return_value = db_ctx + db_ctx.__exit__.return_value = None + db_ctx.artifacts.get.return_value = {"size": 2048} + + svc = MagicMock() + svc.resolve_target.return_value = MagicMock( + entity_type="artifact", artifact_id="a1", display_target="model.pt" + ) + svc.unbind.return_value = BindResult(changed=True, promoted={"license": ["MIT"]}) + + with _patch(db_ctx, svc)[0], _patch(db_ctx, svc)[1]: + summary = build_tag_unbind_summary(_unbind_request(tmp_path)) + + assert summary.artifacts[0].action == "unbind" + assert "Unbound" in summary.render() + svc.unbind.assert_called_once() + svc.bind.assert_not_called() diff --git a/tests/application/run/test_service.py b/tests/application/run/test_service.py index 3a44d55d..88162bf0 100644 --- a/tests/application/run/test_service.py +++ b/tests/application/run/test_service.py @@ -73,6 +73,36 @@ def test_run_command_resolves_dag_reference_and_can_abort_on_stale_upstream( presenter.print.assert_called_with("Aborted.") +def test_run_command_forwards_block_and_add_tags_to_execute_and_report(tmp_path: Path) -> None: + planned = SimpleNamespace( + backend_name="local", + command=["python", "train.py"], + execution_role="host", + finalize_run=None, + ) + mock_report = MagicMock(exit_code=0) + + with ( + patch("roar.application.run.service.validate_git_clean", return_value=str(tmp_path)), + patch("roar.application.run.service.resolve_verbosity", return_value="normal"), + patch("roar.application.run.service.get_hash_algorithms", return_value=["blake3"]), + patch("roar.application.run.service.plan_execution_command", return_value=planned), + patch( + "roar.application.run.service.execute_and_report", return_value=mock_report + ) as mock_exec, + ): + run_command( + _run_request( + tmp_path, + block_tags=("license",), + add_tags=("jurisdiction=EU",), + ) + ) + + assert mock_exec.call_args.kwargs["block_tags"] == ["license"] + assert mock_exec.call_args.kwargs["add_tags"] == ["jurisdiction=EU"] + + def test_run_command_raises_when_backend_does_not_provide_execution_role(tmp_path: Path) -> None: planned = SimpleNamespace( backend_name="local", diff --git a/tests/presenters/test_show_tags.py b/tests/presenters/test_show_tags.py new file mode 100644 index 00000000..f98f2eaf --- /dev/null +++ b/tests/presenters/test_show_tags.py @@ -0,0 +1,99 @@ +"""Tests for tag/barrier rendering in `roar show` (and its parity with `roar tag show`). + +Tags render in a clean ``Tags:`` section (shared ``tag_display_pairs`` with +`roar tag show`), the internal bind ledger never leaks, and a job's recorded +``--block-tag`` modifiers surface as ``Barriers:``. +""" + +from __future__ import annotations + +from roar.application.tags import barrier_items, tag_display_pairs +from roar.presenters.show_renderer import ShowRenderer + +_TAG_LABELS = { + "tag": { + "contains_pii": {"values": [{"value": "present", "origin": "user"}]}, + "license": { + "values": [ + {"value": "MIT", "origin": "user"}, + {"value": "GPL-3.0", "origin": "system", "job": "j"}, + ] + }, + "bind": {"events": [{"action": "bind", "covers": {"contains_pii": ["present"]}}]}, + }, + "other_label": "keep-me", +} + + +class TestTagDisplayPairs: + def test_sorted_skips_bind_and_joins_values(self) -> None: + assert tag_display_pairs(_TAG_LABELS["tag"]) == [ + ("contains_pii", "present"), + ("license", "MIT, GPL-3.0"), + ] + + def test_non_dict_is_empty(self) -> None: + assert tag_display_pairs(None) == [] + assert tag_display_pairs("nope") == [] + + +class TestBarrierItems: + def test_from_run_modifiers(self) -> None: + assert barrier_items({"block_tags": ["contains_pii", "license=GPL-3.0"]}) == [ + "contains_pii", + "license=GPL-3.0", + ] + + def test_non_dict_or_empty(self) -> None: + assert barrier_items(None) == [] + assert barrier_items({}) == [] + assert barrier_items({"add_tags": ["x=y"]}) == [] + + +class TestRenderTags: + def test_clean_section_no_internals(self) -> None: + lines: list[str] = [] + ShowRenderer._render_tags(lines, _TAG_LABELS) + out = "\n".join(lines) + assert "Tags:" in out + assert "contains_pii=present" in out + assert "license=MIT, GPL-3.0" in out + # neither the bind ledger nor the raw value-record structure leaks + assert "bind" not in out + assert "values" not in out + assert "origin" not in out + + def test_no_tags_no_section(self) -> None: + lines: list[str] = [] + ShowRenderer._render_tags(lines, {"other_label": "x"}) + assert lines == [] + + +class TestRenderLabelsExcludesTags: + def test_tag_subtree_kept_out_of_raw_labels(self) -> None: + lines: list[str] = [] + ShowRenderer._render_labels(lines, _TAG_LABELS) + out = "\n".join(lines) + assert "Labels:" in out + assert "other_label=keep-me" in out + assert "tag." not in out # incl. the bind ledger — no raw tag.* dump + + def test_tags_only_yields_no_labels_section(self) -> None: + lines: list[str] = [] + ShowRenderer._render_labels(lines, {"tag": _TAG_LABELS["tag"]}) + assert lines == [] + + +class TestRenderBarriers: + def test_barriers_section(self) -> None: + lines: list[str] = [] + ShowRenderer._render_barriers(lines, {"run_modifiers": {"block_tags": ["contains_pii"]}}) + out = "\n".join(lines) + assert "Barriers:" in out + assert "contains_pii" in out + assert "--block-tag" in out + + def test_no_run_modifiers_no_section(self) -> None: + lines: list[str] = [] + ShowRenderer._render_barriers(lines, {"runtime": {}}) + assert lines == [] diff --git a/tests/unit/test_cli_registry.py b/tests/unit/test_cli_registry.py index fc8d80b8..b39e738c 100644 --- a/tests/unit/test_cli_registry.py +++ b/tests/unit/test_cli_registry.py @@ -1,11 +1,13 @@ """Unit tests for top-level CLI command registry behavior.""" +from importlib import import_module from unittest.mock import patch +import click from click.testing import CliRunner from roar.cli import LAZY_COMMANDS, cli -from roar.cli.command_registry import build_help_groups +from roar.cli.command_registry import _COMMAND_SPECS, build_help_groups def test_help_groups_are_built_from_command_specs() -> None: @@ -23,6 +25,7 @@ def test_help_groups_are_built_from_command_specs() -> None: "register", "get", "label", + "tag", ) assert help_groups["Setup and Admin"] == ( "config", @@ -111,6 +114,39 @@ def test_cli_allows_account_commands_by_default() -> None: assert "Inspect and manage GLaaS projects." in projects_result.output +def test_tag_command_is_registered_and_reachable() -> None: + runner = CliRunner() + + top_level_help = runner.invoke(cli, ["--help"]) + assert top_level_help.exit_code == 0, top_level_help.output + assert "tag" in top_level_help.output + assert "Manage hereditary compliance tags" in top_level_help.output + + tag_help = runner.invoke(cli, ["tag", "--help"]) + assert tag_help.exit_code == 0, tag_help.output + assert "add" in tag_help.output + assert "rm" in tag_help.output + assert "show" in tag_help.output + assert "history" in tag_help.output + + +def test_every_registered_command_spec_resolves_to_a_click_command() -> None: + """Every CommandSpec must point at a real, importable click Command. + + A command module can exist and be fully tested in isolation while still + being unreachable from the CLI if nobody adds its CommandSpec to + `_COMMAND_SPECS` (this happened with `roar tag`). This walks the + registry itself rather than the rendered --help text, so it fails loudly + for any future command in the same situation. + """ + for spec in _COMMAND_SPECS: + module = import_module(spec.module_path) + command = getattr(module, spec.attr_name) + assert isinstance(command, click.BaseCommand), ( + f"{spec.name!r} ({spec.module_path}.{spec.attr_name}) is not a click command" + ) + + def test_subcommand_help_reports_import_errors_cleanly() -> None: runner = CliRunner() missing = ModuleNotFoundError("No module named 'pydantic'") diff --git a/tests/unit/test_job_recording.py b/tests/unit/test_job_recording.py index 8ac80426..ed50ff7f 100644 --- a/tests/unit/test_job_recording.py +++ b/tests/unit/test_job_recording.py @@ -50,6 +50,8 @@ def get_outputs(*args, **kwargs): step_name="train", job_type="run", hash_algorithms=["blake3"], + block_tags=[], + add_tags=[], ) prov = { "data": {"read_files": [], "written_files": []}, diff --git a/tests/unit/test_job_recording_tag_propagation.py b/tests/unit/test_job_recording_tag_propagation.py new file mode 100644 index 00000000..05f5d67f --- /dev/null +++ b/tests/unit/test_job_recording_tag_propagation.py @@ -0,0 +1,291 @@ +"""Integration test: JobRecordingService.record_job propagates tags end-to-end. + +Exercises the real hook point in roar/db/services/job_recording.py against a +real SQLite-backed DatabaseContext, rather than mocking propagate_tags, so a +regression in the wiring (wrong repo, wrong artifact ids, wrong timing +relative to commit, wrong session_id/job_uid threading) would actually fail +this test. + +All jobs here use `assign_to_session=False` (no real production caller does +this — it's test-only isolation), so every job's session_id is None. The +scope check treats "both sides unassigned" as in-scope (see +`_value_in_scope`'s docstring), so plain same-session-shaped propagation +still works without bootstrapping a session for every test. +""" + +from __future__ import annotations + +from pathlib import Path + +from roar.core.label_origins import LABEL_ORIGIN_SYSTEM, LABEL_ORIGIN_USER +from roar.db.context import create_database_context + + +def _write(path: Path, content: bytes) -> str: + path.write_bytes(content) + return str(path) + + +def _tag_doc(**kinds: list[str]) -> dict: + """A `{"tag": {...}}` doc simulating a prior `roar tag add` for each value — + user-origin, job-less records, each with its own implicit bind event (the + "one mechanism, no special cases" rule `TagService.add` implements).""" + return { + "tag": { + **{ + kind: {"values": [{"value": v, "origin": LABEL_ORIGIN_USER} for v in values]} + for kind, values in kinds.items() + }, + "bind": { + "events": [ + {"action": "bind", "covers": {kind: values}} for kind, values in kinds.items() + ] + }, + } + } + + +def _values(metadata: dict, kind: str) -> list[str]: + return [record["value"] for record in metadata["tag"][kind]["values"]] + + +def test_output_inherits_tags_from_input(tmp_path: Path) -> None: + repo_root = tmp_path / "repo" + roar_dir = repo_root / ".roar" + roar_dir.mkdir(parents=True) + + dataset = _write(repo_root / "dataset.csv", b"a,b,c\n1,2,3\n") + model = _write(repo_root / "model.bin", b"weights") + + with create_database_context(roar_dir) as db_ctx: + # Job 1: produces the dataset (no inputs). + db_ctx.job_recording.record_job( + command="prepare_data.py", + timestamp=1_700_000_000.0, + output_files=[dataset], + assign_to_session=False, + ) + + # Tag the dataset artifact directly (simulating a prior `roar tag add`). + with create_database_context(roar_dir) as db_ctx: + job1 = db_ctx.jobs.get_recent(1)[0] + dataset_artifact_id = db_ctx.jobs.get_outputs(job1["id"])[0]["artifact_id"] + db_ctx.labels.create_version( + "artifact", + _tag_doc(license=["MIT"]), + artifact_id=dataset_artifact_id, + write_origin=LABEL_ORIGIN_USER, + ) + + # Job 2: consumes the dataset, produces a model. + with create_database_context(roar_dir) as db_ctx: + job2_id, job2_uid = db_ctx.job_recording.record_job( + command="train.py", + timestamp=1_700_000_100.0, + input_files=[dataset], + output_files=[model], + assign_to_session=False, + ) + model_artifact_id = db_ctx.jobs.get_outputs(job2_id)[0]["artifact_id"] + + with create_database_context(roar_dir) as db_ctx: + current = db_ctx.labels.get_current("artifact", artifact_id=model_artifact_id) + assert current is not None + assert _values(current["metadata"], "license") == ["MIT"] + assert current["write_origin"] == LABEL_ORIGIN_SYSTEM + # The propagated record is stamped with the job that derived it. + assert current["metadata"]["tag"]["license"]["values"][0]["job"] == job2_uid + + +def test_block_tags_exempts_kind_from_propagation(tmp_path: Path) -> None: + repo_root = tmp_path / "repo" + roar_dir = repo_root / ".roar" + roar_dir.mkdir(parents=True) + + dataset = _write(repo_root / "dataset.csv", b"a,b,c\n1,2,3\n") + model = _write(repo_root / "model.bin", b"weights") + + with create_database_context(roar_dir) as db_ctx: + db_ctx.job_recording.record_job( + command="prepare_data.py", + timestamp=1_700_000_000.0, + output_files=[dataset], + assign_to_session=False, + ) + + with create_database_context(roar_dir) as db_ctx: + job1 = db_ctx.jobs.get_recent(1)[0] + dataset_artifact_id = db_ctx.jobs.get_outputs(job1["id"])[0]["artifact_id"] + db_ctx.labels.create_version( + "artifact", + _tag_doc(license=["GPL-3.0"], jurisdiction=["EU"]), + artifact_id=dataset_artifact_id, + write_origin=LABEL_ORIGIN_USER, + ) + + with create_database_context(roar_dir) as db_ctx: + job2_id, _job2_uid = db_ctx.job_recording.record_job( + command="relicense.py", + timestamp=1_700_000_100.0, + input_files=[dataset], + output_files=[model], + assign_to_session=False, + block_tags=("license",), + ) + model_artifact_id = db_ctx.jobs.get_outputs(job2_id)[0]["artifact_id"] + + with create_database_context(roar_dir) as db_ctx: + current = db_ctx.labels.get_current("artifact", artifact_id=model_artifact_id) + assert current is not None + assert "license" not in current["metadata"]["tag"] + assert _values(current["metadata"], "jurisdiction") == ["EU"] + + +def test_add_tags_stamps_output_with_user_origin(tmp_path: Path) -> None: + repo_root = tmp_path / "repo" + roar_dir = repo_root / ".roar" + roar_dir.mkdir(parents=True) + model = _write(repo_root / "model.bin", b"weights") + + with create_database_context(roar_dir) as db_ctx: + job_id, job_uid = db_ctx.job_recording.record_job( + command="train.py", + timestamp=1_700_000_000.0, + output_files=[model], + assign_to_session=False, + add_tags=("license=MIT", "jurisdiction=EU"), + ) + artifact_id = db_ctx.jobs.get_outputs(job_id)[0]["artifact_id"] + + with create_database_context(roar_dir) as db_ctx: + current = db_ctx.labels.get_current("artifact", artifact_id=artifact_id) + assert current is not None + assert _values(current["metadata"], "license") == ["MIT"] + assert _values(current["metadata"], "jurisdiction") == ["EU"] + assert current["write_origin"] == LABEL_ORIGIN_USER + # --add-tag is user-origin but still job-stamped (session-scoped, not + # auto-bound — the named-artifact rule: it quantifies over the job's + # whole output set, not a specifically inspected artifact). + assert current["metadata"]["tag"]["license"]["values"][0]["job"] == job_uid + assert "bind" not in current["metadata"]["tag"] + + +def test_add_tags_and_propagation_combine(tmp_path: Path) -> None: + repo_root = tmp_path / "repo" + roar_dir = repo_root / ".roar" + roar_dir.mkdir(parents=True) + + dataset = _write(repo_root / "dataset.csv", b"a,b,c\n1,2,3\n") + model = _write(repo_root / "model.bin", b"weights") + + with create_database_context(roar_dir) as db_ctx: + db_ctx.job_recording.record_job( + command="prepare_data.py", + timestamp=1_700_000_000.0, + output_files=[dataset], + assign_to_session=False, + ) + + with create_database_context(roar_dir) as db_ctx: + job1 = db_ctx.jobs.get_recent(1)[0] + dataset_artifact_id = db_ctx.jobs.get_outputs(job1["id"])[0]["artifact_id"] + db_ctx.labels.create_version( + "artifact", + _tag_doc(license=["MIT"]), + artifact_id=dataset_artifact_id, + write_origin=LABEL_ORIGIN_USER, + ) + + with create_database_context(roar_dir) as db_ctx: + job2_id, _job2_uid = db_ctx.job_recording.record_job( + command="train.py", + timestamp=1_700_000_100.0, + input_files=[dataset], + output_files=[model], + assign_to_session=False, + add_tags=("jurisdiction=US",), + ) + model_artifact_id = db_ctx.jobs.get_outputs(job2_id)[0]["artifact_id"] + + with create_database_context(roar_dir) as db_ctx: + current = db_ctx.labels.get_current("artifact", artifact_id=model_artifact_id) + assert current is not None + assert _values(current["metadata"], "license") == ["MIT"] + assert _values(current["metadata"], "jurisdiction") == ["US"] + + +def test_no_inputs_means_no_propagation(tmp_path: Path) -> None: + repo_root = tmp_path / "repo" + roar_dir = repo_root / ".roar" + roar_dir.mkdir(parents=True) + model = _write(repo_root / "model.bin", b"weights") + + with create_database_context(roar_dir) as db_ctx: + job_id, _job_uid = db_ctx.job_recording.record_job( + command="train.py", + timestamp=1_700_000_000.0, + output_files=[model], + assign_to_session=False, + ) + artifact_id = db_ctx.jobs.get_outputs(job_id)[0]["artifact_id"] + + with create_database_context(roar_dir) as db_ctx: + current = db_ctx.labels.get_current("artifact", artifact_id=artifact_id) + assert current is None + + +def test_cross_session_input_does_not_propagate_without_a_bind(tmp_path: Path) -> None: + """The core scope-gating behavior, exercised through the real job-recording path. + + Job 1 runs under real session A; its output is tagged. Job 2 runs under a + *different* real session B and reads that same artifact — without a bind, + the tag must not cross the session boundary. + """ + repo_root = tmp_path / "repo" + roar_dir = repo_root / ".roar" + roar_dir.mkdir(parents=True) + + dataset = _write(repo_root / "dataset.csv", b"a,b,c\n1,2,3\n") + model = _write(repo_root / "model.bin", b"weights") + + with create_database_context(roar_dir) as db_ctx: + db_ctx.job_recording.record_job( + command="prepare_data.py", + timestamp=1_700_000_000.0, + output_files=[dataset], + ) # assign_to_session defaults True -> real session A + + with create_database_context(roar_dir) as db_ctx: + job1 = db_ctx.jobs.get_recent(1)[0] + dataset_artifact_id = db_ctx.jobs.get_outputs(job1["id"])[0]["artifact_id"] + # System-origin, job-stamped — as if this came from an earlier propagation + # in session A, not a manual (auto-bound) `tag add`. + db_ctx.labels.create_version( + "artifact", + { + "tag": { + "license": { + "values": [ + {"value": "MIT", "origin": LABEL_ORIGIN_SYSTEM, "job": job1["job_uid"]} + ] + } + } + }, + artifact_id=dataset_artifact_id, + write_origin=LABEL_ORIGIN_SYSTEM, + ) + # Start a fresh session B (deactivates A) for the next record_job. + db_ctx.sessions.create() + + with create_database_context(roar_dir) as db_ctx: + job2_id, _job2_uid = db_ctx.job_recording.record_job( + command="train.py", + timestamp=1_700_000_100.0, + input_files=[dataset], + output_files=[model], + ) # a new active session B + model_artifact_id = db_ctx.jobs.get_outputs(job2_id)[0]["artifact_id"] + + with create_database_context(roar_dir) as db_ctx: + current = db_ctx.labels.get_current("artifact", artifact_id=model_artifact_id) + assert current is None diff --git a/tests/unit/test_label_service.py b/tests/unit/test_label_service.py index 8226e17d..73c36753 100644 --- a/tests/unit/test_label_service.py +++ b/tests/unit/test_label_service.py @@ -62,6 +62,19 @@ def test_reject_reserved_keys_blocks_system_managed_roar_labels() -> None: ) +def test_reject_reserved_keys_blocks_generic_writes_to_the_tag_namespace() -> None: + """`tag.*` is reserved for `roar tag` — the namespace is the hereditary-propagation + contract, and the tag.bind ledger's append-only integrity depends on the generic + `roar label set` path never being able to clobber it wholesale.""" + with pytest.raises(ValueError, match="Reserved label keys cannot be set manually"): + LabelService._reject_reserved_keys({"tag": {"license": {"values": [{"value": "MIT"}]}}}) + + +def test_reject_reserved_keys_blocks_generic_writes_to_the_attach_namespace() -> None: + with pytest.raises(ValueError, match="Reserved label keys cannot be set manually"): + LabelService._reject_reserved_keys({"attach": {"bias_study": "artifact:abc123"}}) + + def test_build_current_key_origins_replays_user_and_system_versions() -> None: history = [ { diff --git a/tests/unit/test_register_cli_bind.py b/tests/unit/test_register_cli_bind.py new file mode 100644 index 00000000..5201e435 --- /dev/null +++ b/tests/unit/test_register_cli_bind.py @@ -0,0 +1,118 @@ +"""CLI tests for `roar register --bind` / `--no-bind` — the "register implies bind" rule. + +Uses a real local roar DB (via job_recording) for the artifact + tag data, and +mocks only register_lineage_target (the actual GLaaS network call) — so the +implicit-bind target classification and the real TagService.bind path are +genuinely exercised end to end through the CLI. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +from roar.application.publish.results import RegisterLineageResponse +from roar.cli.commands.register import register +from roar.core.label_origins import LABEL_ORIGIN_SYSTEM +from roar.db.context import create_database_context + + +def _seed_tagged_artifact(roar_dir: Path, model_path: Path) -> str: + """Record a job producing model_path, tag it (system-origin, no bind), return its hash.""" + model_path.write_bytes(b"weights") + with create_database_context(roar_dir) as db_ctx: + job_id, job_uid = db_ctx.job_recording.record_job( + command="train.py", + timestamp=1_700_000_000.0, + output_files=[str(model_path)], + ) + artifact_id = db_ctx.jobs.get_outputs(job_id)[0]["artifact_id"] + db_ctx.labels.create_version( + "artifact", + { + "tag": { + "license": { + "values": [{"value": "MIT", "origin": LABEL_ORIGIN_SYSTEM, "job": job_uid}] + } + } + }, + artifact_id=artifact_id, + write_origin=LABEL_ORIGIN_SYSTEM, + ) + artifact = db_ctx.artifacts.get(artifact_id) + return next(h["digest"] for h in artifact["hashes"] if h["algorithm"] == "blake3") + + +def _ctx(tmp_path: Path) -> MagicMock: + ctx = MagicMock() + ctx.roar_dir = tmp_path / ".roar" + ctx.cwd = tmp_path + ctx.is_initialized = True + return ctx + + +def _response(artifact_hash: str, **overrides) -> RegisterLineageResponse: + defaults = { + "success": True, + "session_hash": "a" * 64, + "artifact_hash": artifact_hash, + "jobs_registered": 1, + "artifacts_registered": 1, + "links_created": 0, + } + defaults.update(overrides) + return RegisterLineageResponse(**defaults) + + +def _invoke(tmp_path: Path, args: list[str], response: RegisterLineageResponse): + runner = CliRunner() + with ( + patch("roar.cli.publish_intent._is_logged_in", return_value=True), + patch("roar.cli.commands.register.register_lineage_target", return_value=response), + patch( + "roar.cli.commands.register._resolve_glaas_web_url", + return_value="https://glaas.example", + ), + ): + return runner.invoke(register, args, obj=_ctx(tmp_path)) + + +def test_register_by_artifact_hash_implicitly_binds(tmp_path: Path) -> None: + artifact_hash = _seed_tagged_artifact(tmp_path / ".roar", tmp_path / "model.pt") + result = _invoke(tmp_path, [artifact_hash], _response(artifact_hash)) + assert result.exit_code == 0, result.output + assert "Bound:" in result.output + assert "license=MIT" in result.output + + +def test_no_bind_skips_the_implicit_bind(tmp_path: Path) -> None: + artifact_hash = _seed_tagged_artifact(tmp_path / ".roar", tmp_path / "model.pt") + result = _invoke(tmp_path, [artifact_hash, "--no-bind"], _response(artifact_hash)) + assert result.exit_code == 0, result.output + assert "Bound:" not in result.output + + +def test_bind_flag_binds_an_artifact_unrelated_to_the_registered_target(tmp_path: Path) -> None: + """`register --bind X` binds X even when the response has no artifact_hash of its own + (a session-wide register) — --bind is independent of the implicit target rule.""" + artifact_hash = _seed_tagged_artifact(tmp_path / ".roar", tmp_path / "model.pt") + response = _response("", jobs_registered=2, artifacts_registered=2, links_created=1) + # -y: a session-wide register (no target) prompts for confirmation since + # register learned to confirm a defaulted active-session publish (#224). + result = _invoke(tmp_path, ["--bind", artifact_hash, "-y"], response) + assert result.exit_code == 0, result.output + assert "Bound:" in result.output + assert "license=MIT" in result.output + + +def test_already_bound_target_reports_no_change(tmp_path: Path) -> None: + artifact_hash = _seed_tagged_artifact(tmp_path / ".roar", tmp_path / "model.pt") + response = _response(artifact_hash) + first = _invoke(tmp_path, [artifact_hash], response) + assert "Bound:" in first.output + + second = _invoke(tmp_path, [artifact_hash], response) + assert second.exit_code == 0, second.output + assert "no change" in second.output diff --git a/tests/unit/test_register_service.py b/tests/unit/test_register_service.py index b67fa603..0d5e9880 100644 --- a/tests/unit/test_register_service.py +++ b/tests/unit/test_register_service.py @@ -294,6 +294,86 @@ def test_register_prepared_lineage_preregisters_composites_before_batch_registra } ] + def test_register_prepared_lineage_excludes_composite_leaf_hashes_from_label_sync( + self, tmp_path: Path + ) -> None: + """A composite leaf/component has no session-scoped edge on GLaaS (see + ``view_edges.resolve_view_edges_for_job``), so syncing labels for it 404s + (``Artifact not found in session``). ``composite_leaf_hashes`` must be excluded + from the label sync payload while remaining in ordinary artifact registration. + """ + plain_digest = "a" * 64 + leaf_digest = "b" * 64 + + lineage = _lineage_data( + jobs=[{"id": 1, "job_uid": "job-1", "step_number": 1, "timestamp": 1.0}], + artifacts=[ + { + "id": "plain-1", + "hash": plain_digest, + "hashes": [{"algorithm": "blake3", "digest": plain_digest}], + "size": 7, + "source_type": "local", + }, + { + "id": "leaf-1", + "hash": leaf_digest, + "hashes": [{"algorithm": "blake3", "digest": leaf_digest}], + "size": 3, + "source_type": "local", + }, + ], + artifact_hashes={plain_digest, leaf_digest}, + ) + + with ( + patch( + "roar.application.publish.register_execution.create_database_context" + ) as mock_ctx, + patch("roar.application.publish.register_execution.config_get", return_value=False), + patch( + "roar.application.publish.register_execution.register_publish_lineage" + ) as mock_register_publish_lineage, + ): + mock_db = MagicMock() + mock_db.__enter__ = MagicMock(return_value=mock_db) + mock_db.__exit__ = MagicMock(return_value=None) + mock_ctx.return_value = mock_db + mock_register_publish_lineage.return_value = BatchRegistrationResult( + session_registered=True, + jobs_created=1, + jobs_failed=0, + artifacts_registered=2, + artifacts_failed=0, + links_created=1, + links_failed=0, + errors=[], + ) + + result = self.service.register_prepared_lineage( + lineage=lineage, + roar_dir=tmp_path / ".roar", + artifact_hash=plain_digest, + dry_run=False, + as_blake3=False, + skip_confirmation=False, + confirm_callback=None, + prepared=_prepared_execution(tmp_path), + composite_leaf_hashes=frozenset({leaf_digest}), + ) + + assert result.success is True + mock_register_publish_lineage.assert_called_once() + call_kwargs = mock_register_publish_lineage.call_args.kwargs + + label_hashes = {a["hash"] for a in call_kwargs["label_artifacts"]} + assert label_hashes == {plain_digest} + + staged_digests = { + hash_row["digest"] for a in call_kwargs["artifacts"] for hash_row in a["hashes"] + } + assert staged_digests == {plain_digest, leaf_digest} + class TestRegisterServiceGitUrlRedaction: """The session-level git URL must be redacted before it reaches GLaaS.""" diff --git a/tests/unit/test_run_cli_tags.py b/tests/unit/test_run_cli_tags.py new file mode 100644 index 00000000..609989fc --- /dev/null +++ b/tests/unit/test_run_cli_tags.py @@ -0,0 +1,47 @@ +"""Tests for `roar run`'s --block-tag / --add-tag option parsing. + +Click validates repeatable options (callbacks) during argument parsing, +before the command body (and require_init) ever runs — so these can be +exercised without a real RoarContext/roar_dir. +""" + +from __future__ import annotations + +import importlib + +from click.testing import CliRunner + +run_cli_module = importlib.import_module("roar.cli.commands.run") + + +class TestAddTagValidation: + def test_malformed_pair_is_rejected(self) -> None: + runner = CliRunner() + result = runner.invoke(run_cli_module.run, ["--add-tag", "badpair", "echo", "hi"]) + assert result.exit_code != 0 + assert "Expected KIND=VALUE" in result.output + + def test_empty_value_is_rejected(self) -> None: + runner = CliRunner() + result = runner.invoke(run_cli_module.run, ["--add-tag", "license=", "echo", "hi"]) + assert result.exit_code != 0 + assert "Value cannot be empty" in result.output + + def test_noncanonical_kind_warns_but_does_not_reject(self) -> None: + runner = CliRunner() + result = runner.invoke( + run_cli_module.run, + ["--add-tag", "not_a_real_kind=value", "echo", "hi"], + obj=None, + ) + # Fails later (no RoarContext), but the callback itself must not raise. + assert "not a canonical tag kind" in result.output + + def test_canonical_kind_does_not_warn(self) -> None: + runner = CliRunner() + result = runner.invoke( + run_cli_module.run, + ["--add-tag", "license=MIT", "echo", "hi"], + obj=None, + ) + assert "not a canonical tag kind" not in result.output diff --git a/tests/unit/test_tag_cli_bind.py b/tests/unit/test_tag_cli_bind.py new file mode 100644 index 00000000..cf9f01c3 --- /dev/null +++ b/tests/unit/test_tag_cli_bind.py @@ -0,0 +1,87 @@ +"""CLI wiring tests for `roar tag bind` / `roar tag unbind`. + +Mocks the orchestration layer (roar.application.query.tag.tag_bind/tag_unbind) +so these exercise argument parsing, request construction, and error handling +without a real DB — the underlying mechanics are covered by +tests/unit/test_tag_service.py and tests/application/query/test_tag.py. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from click.testing import CliRunner + +from roar.cli.commands.tag import tag as tag_group +from roar.cli.context import RoarContext + + +def _ctx(tmp_path: Path) -> RoarContext: + roar_dir = tmp_path / ".roar" + roar_dir.mkdir() + return RoarContext(roar_dir=roar_dir, repo_root=None, cwd=tmp_path, is_interactive=False) + + +class TestTagBindCli: + def test_binds_a_single_target(self, tmp_path: Path) -> None: + runner = CliRunner() + with patch("roar.cli.commands.tag.tag_bind", return_value="Bound: model.pt") as mock_bind: + result = runner.invoke(tag_group, ["bind", "model.pt"], obj=_ctx(tmp_path)) + assert result.exit_code == 0, result.output + assert "Bound: model.pt" in result.output + request = mock_bind.call_args.args[0] + assert request.targets == ("model.pt",) + + def test_binds_multiple_targets_in_one_call(self, tmp_path: Path) -> None: + runner = CliRunner() + with patch("roar.cli.commands.tag.tag_bind", return_value="") as mock_bind: + result = runner.invoke(tag_group, ["bind", "one.pt", "two.pt"], obj=_ctx(tmp_path)) + assert result.exit_code == 0, result.output + request = mock_bind.call_args.args[0] + assert request.targets == ("one.pt", "two.pt") + + def test_requires_at_least_one_target(self, tmp_path: Path) -> None: + runner = CliRunner() + result = runner.invoke(tag_group, ["bind"], obj=_ctx(tmp_path)) + assert result.exit_code != 0 + + def test_value_error_becomes_click_exception(self, tmp_path: Path) -> None: + runner = CliRunner() + with patch( + "roar.cli.commands.tag.tag_bind", side_effect=ValueError("Artifact not found: x") + ): + result = runner.invoke(tag_group, ["bind", "x"], obj=_ctx(tmp_path)) + assert result.exit_code != 0 + assert "Artifact not found: x" in result.output + + +class TestTagUnbindCli: + def test_unbinds_a_single_target(self, tmp_path: Path) -> None: + runner = CliRunner() + with patch( + "roar.cli.commands.tag.tag_unbind", return_value="Unbound: model.pt" + ) as mock_unbind: + result = runner.invoke(tag_group, ["unbind", "model.pt"], obj=_ctx(tmp_path)) + assert result.exit_code == 0, result.output + assert "Unbound: model.pt" in result.output + request = mock_unbind.call_args.args[0] + assert request.targets == ("model.pt",) + + def test_value_error_becomes_click_exception(self, tmp_path: Path) -> None: + runner = CliRunner() + with patch( + "roar.cli.commands.tag.tag_unbind", side_effect=ValueError("Artifact not found: x") + ): + result = runner.invoke(tag_group, ["unbind", "x"], obj=_ctx(tmp_path)) + assert result.exit_code != 0 + assert "Artifact not found: x" in result.output + + +class TestTagGroupHelp: + def test_bind_and_unbind_are_listed(self, tmp_path: Path) -> None: + runner = CliRunner() + result = runner.invoke(tag_group, ["--help"]) + assert result.exit_code == 0 + assert "bind" in result.output + assert "unbind" in result.output diff --git a/tests/unit/test_tag_custom_kinds.py b/tests/unit/test_tag_custom_kinds.py new file mode 100644 index 00000000..667ea109 --- /dev/null +++ b/tests/unit/test_tag_custom_kinds.py @@ -0,0 +1,95 @@ +"""Unit tests for tag-kind enforcement (roar/cli/_tag_kinds). + +Non-canonical kinds are rejected unless allowed via `[tags] custom_kinds` in +.roarconfig; the rejection prints a spelled-out, append-aware hint. config_get +is patched so these tests don't depend on a real config file on disk. +""" + +from __future__ import annotations + +import pytest + +from roar.cli import _tag_kinds +from roar.core.label_constants import CANONICAL_TAG_KINDS + + +def _patch_config(monkeypatch, custom_kinds): + def fake_config_get(key, start_dir=None): + if key == "tags.custom_kinds": + return custom_kinds + if key == "hints.enabled": + return True + if key == "output.verbosity": + return "normal" + return None + + monkeypatch.setattr("roar.integrations.config.config_get", fake_config_get) + + +class TestResolve: + def test_canonical_kinds_always_allowed(self, monkeypatch): + _patch_config(monkeypatch, []) + assert _tag_kinds.allowed_tag_kinds() >= CANONICAL_TAG_KINDS + + def test_configured_custom_kinds_are_allowed(self, monkeypatch): + _patch_config(monkeypatch, ["export_control", "data_retention"]) + allowed = _tag_kinds.allowed_tag_kinds() + assert {"export_control", "data_retention"} <= allowed + + def test_custom_kinds_are_deduped_and_stripped(self, monkeypatch): + _patch_config(monkeypatch, [" export_control ", "export_control", "", " "]) + assert _tag_kinds.configured_custom_kinds() == ["export_control"] + + def test_non_list_config_is_ignored(self, monkeypatch): + _patch_config(monkeypatch, None) + assert _tag_kinds.configured_custom_kinds() == [] + + +class TestEnforce: + def test_canonical_passes_silently(self, monkeypatch): + _patch_config(monkeypatch, []) + _tag_kinds.enforce_tag_kind("license") # no raise + + def test_configured_custom_passes_silently(self, monkeypatch): + _patch_config(monkeypatch, ["export_control"]) + _tag_kinds.enforce_tag_kind("export_control") # no raise + + def test_unknown_kind_exits_nonzero_with_spelled_out_hint(self, monkeypatch, capsys): + _patch_config(monkeypatch, []) + with pytest.raises(SystemExit) as exc_info: + _tag_kinds.enforce_tag_kind("risk_tier") + assert exc_info.value.code == 1 + err = capsys.readouterr().err + assert "'risk_tier' is not a canonical tag kind" in err + assert "[tags]" in err + assert 'custom_kinds = ["risk_tier"]' in err + + def test_hints_suppressed_still_errors(self, monkeypatch, capsys): + def fake(key, start_dir=None): + if key == "output.verbosity": + return "quiet" # suppresses hints + if key == "tags.custom_kinds": + return [] + return None + + monkeypatch.setattr("roar.integrations.config.config_get", fake) + with pytest.raises(SystemExit): + _tag_kinds.enforce_tag_kind("risk_tier") + err = capsys.readouterr().err + assert "not a canonical tag kind" in err + assert "hint:" not in err # hints gated off, but the error still shows + + +class TestHint: + def test_add_verb_and_snippet_when_no_existing(self, monkeypatch): + _patch_config(monkeypatch, []) + lines = _tag_kinds._hint_lines("risk_tier", None) + assert lines[0].startswith("to allow 'risk_tier', add ") + assert lines[1] == " [tags]" + assert lines[2] == ' custom_kinds = ["risk_tier"]' + + def test_appends_to_existing_custom_kinds(self, monkeypatch): + _patch_config(monkeypatch, ["data_retention"]) + lines = _tag_kinds._hint_lines("export_control", None) + assert "update" in lines[0] + assert lines[2] == ' custom_kinds = ["data_retention", "export_control"]' diff --git a/tests/unit/test_tag_propagation.py b/tests/unit/test_tag_propagation.py new file mode 100644 index 00000000..683396fe --- /dev/null +++ b/tests/unit/test_tag_propagation.py @@ -0,0 +1,454 @@ +"""Unit tests for propagate_tags — hereditary tag inheritance at job-record time. + +Uses a small in-memory fake label repo (rather than mocks) since propagation +reads and writes several artifacts across a single call and the interactions +between those reads/writes are exactly what's under test. + +Values are stored as provenance records (`{value, origin, job}`), and +propagation is scope-gated: a candidate value only joins the union if it was +produced by a job in the *current* session, or is covered by a bind on the +input artifact. Most tests below use a single shared session (SESSION and +JOB, wired through `resolve_job_session_id`) so the scope gate trivially +passes and the original union/preservation/no-op mechanics are what's under +test; `TestScopeGating` exercises the gate itself. +""" + +from __future__ import annotations + +from typing import Any + +from roar.application.tags import parse_block_tags, propagate_tags +from roar.core.label_origins import LABEL_ORIGIN_SYSTEM, LABEL_ORIGIN_USER + +SESSION = 1 +JOB = "job-a" + + +def _resolve_same_session(_job: str) -> int | None: + return SESSION + + +def _tag(**kinds: list[str]) -> dict[str, Any]: + """Build a `{"tag": {...}}` doc — every value system-origin, from JOB (in SESSION).""" + return { + "tag": { + kind: { + "values": [ + {"value": value, "origin": LABEL_ORIGIN_SYSTEM, "job": JOB} for value in values + ] + } + for kind, values in kinds.items() + } + } + + +def _values(doc: dict[str, Any], kind: str) -> list[str]: + return [record["value"] for record in doc["tag"][kind]["values"]] + + +class FakeLabelRepo: + """In-memory stand-in for SQLAlchemyLabelRepository's artifact-label surface.""" + + def __init__(self) -> None: + self.docs: dict[str, dict[str, Any]] = {} + self.versions: dict[str, int] = {} + self.write_calls: list[tuple[str, str | None]] = [] + + def seed(self, artifact_id: str, metadata: dict[str, Any]) -> None: + self.docs[artifact_id] = metadata + self.versions[artifact_id] = 1 + + def seed_bind(self, artifact_id: str, *, action: str, covers: dict[str, list[str]]) -> None: + doc = self.docs.setdefault(artifact_id, {}) + tag = dict(doc.get("tag", {})) + bind_doc = dict(tag.get("bind") or {"events": []}) + events = [*bind_doc.get("events", []), {"action": action, "covers": covers}] + tag["bind"] = {"events": events} + doc["tag"] = tag + self.versions.setdefault(artifact_id, 1) + + def get_current( + self, entity_type: str, *, artifact_id: str | None = None + ) -> dict[str, Any] | None: + assert entity_type == "artifact" + if artifact_id not in self.docs: + return None + return {"metadata": self.docs[artifact_id], "version": self.versions[artifact_id]} + + def create_version( + self, + entity_type: str, + metadata: dict[str, Any], + *, + artifact_id: str | None = None, + write_origin: str | None = None, + ) -> dict[str, Any]: + assert entity_type == "artifact" + assert artifact_id is not None + self.docs[artifact_id] = metadata + self.versions[artifact_id] = self.versions.get(artifact_id, 0) + 1 + self.write_calls.append((artifact_id, write_origin)) + return {"metadata": metadata, "version": self.versions[artifact_id]} + + +def _propagate( + repo: FakeLabelRepo, + *, + input_artifact_ids: list[str], + output_artifact_ids: list[str], + current_session_id: int | None = SESSION, + resolve_job_session_id: Any = _resolve_same_session, + job_uid: str | None = JOB, + blocked_kinds: frozenset[str] = frozenset(), + blocked_values: dict[str, frozenset[str]] | None = None, +) -> None: + propagate_tags( + repo, + input_artifact_ids=input_artifact_ids, + output_artifact_ids=output_artifact_ids, + current_session_id=current_session_id, + resolve_job_session_id=resolve_job_session_id, + job_uid=job_uid, + blocked_kinds=blocked_kinds, + blocked_values=blocked_values, + ) + + +class TestBasicPropagation: + def test_single_input_tags_single_output(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["MIT"])) + _propagate(repo, input_artifact_ids=["in1"], output_artifact_ids=["out1"]) + assert _values(repo.docs["out1"], "license") == ["MIT"] + + def test_unions_values_from_multiple_inputs(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["MIT"])) + repo.seed("in2", _tag(license=["Apache-2.0"])) + _propagate(repo, input_artifact_ids=["in1", "in2"], output_artifact_ids=["out1"]) + assert set(_values(repo.docs["out1"], "license")) == {"MIT", "Apache-2.0"} + + def test_dedupes_identical_values_across_inputs(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["MIT"])) + repo.seed("in2", _tag(license=["MIT"])) + _propagate(repo, input_artifact_ids=["in1", "in2"], output_artifact_ids=["out1"]) + assert _values(repo.docs["out1"], "license") == ["MIT"] + + def test_propagates_to_all_outputs(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["MIT"])) + _propagate(repo, input_artifact_ids=["in1"], output_artifact_ids=["out1", "out2"]) + assert _values(repo.docs["out1"], "license") == ["MIT"] + assert _values(repo.docs["out2"], "license") == ["MIT"] + + def test_merges_multiple_kinds_independently(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["MIT"], jurisdiction=["EU"])) + _propagate(repo, input_artifact_ids=["in1"], output_artifact_ids=["out1"]) + assert _values(repo.docs["out1"], "license") == ["MIT"] + assert _values(repo.docs["out1"], "jurisdiction") == ["EU"] + + +class TestExistingOutputTags: + def test_preserves_and_unions_with_existing_output_tags(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["Apache-2.0"])) + repo.seed( + "out1", + {"tag": {"license": {"values": [{"value": "MIT", "origin": LABEL_ORIGIN_USER}]}}}, + ) + _propagate(repo, input_artifact_ids=["in1"], output_artifact_ids=["out1"]) + assert set(_values(repo.docs["out1"], "license")) == {"MIT", "Apache-2.0"} + + def test_preserves_non_tag_metadata_on_output(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["MIT"])) + repo.seed("out1", {"owner": "ml-team"}) + _propagate(repo, input_artifact_ids=["in1"], output_artifact_ids=["out1"]) + assert repo.docs["out1"]["owner"] == "ml-team" + assert _values(repo.docs["out1"], "license") == ["MIT"] + + def test_preserves_output_kind_not_present_on_any_input(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["MIT"])) + repo.seed("out1", _tag(jurisdiction=["US"])) + _propagate(repo, input_artifact_ids=["in1"], output_artifact_ids=["out1"]) + assert _values(repo.docs["out1"], "jurisdiction") == ["US"] + assert _values(repo.docs["out1"], "license") == ["MIT"] + + +class TestBlockedKinds: + def test_blocked_kind_is_not_propagated(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["GPL-3.0"], jurisdiction=["EU"])) + _propagate( + repo, + input_artifact_ids=["in1"], + output_artifact_ids=["out1"], + blocked_kinds=frozenset({"license"}), + ) + assert "license" not in repo.docs["out1"]["tag"] + assert _values(repo.docs["out1"], "jurisdiction") == ["EU"] + + def test_all_kinds_blocked_is_a_full_noop(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["GPL-3.0"])) + _propagate( + repo, + input_artifact_ids=["in1"], + output_artifact_ids=["out1"], + blocked_kinds=frozenset({"license"}), + ) + assert "out1" not in repo.docs + assert repo.write_calls == [] + + +class TestNoOpCases: + def test_no_inputs_is_a_noop(self) -> None: + repo = FakeLabelRepo() + _propagate(repo, input_artifact_ids=[], output_artifact_ids=["out1"]) + assert repo.write_calls == [] + + def test_no_outputs_is_a_noop(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["MIT"])) + _propagate(repo, input_artifact_ids=["in1"], output_artifact_ids=[]) + assert repo.write_calls == [] + + def test_inputs_with_no_tags_is_a_noop(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", {"owner": "ml-team"}) + _propagate(repo, input_artifact_ids=["in1"], output_artifact_ids=["out1"]) + assert repo.write_calls == [] + + def test_output_already_has_all_inherited_values_is_a_noop(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["MIT"])) + repo.seed("out1", _tag(license=["MIT"])) + _propagate(repo, input_artifact_ids=["in1"], output_artifact_ids=["out1"]) + assert repo.write_calls == [] + + def test_input_with_no_label_document_at_all_is_handled(self) -> None: + repo = FakeLabelRepo() + _propagate(repo, input_artifact_ids=["in1"], output_artifact_ids=["out1"]) + assert repo.write_calls == [] + + +class TestWriteOrigin: + def test_stamps_system_write_origin(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["MIT"])) + _propagate(repo, input_artifact_ids=["in1"], output_artifact_ids=["out1"]) + assert repo.write_calls == [("out1", LABEL_ORIGIN_SYSTEM)] + + def test_only_writes_outputs_that_actually_changed(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["MIT"])) + repo.seed("out1", _tag(license=["MIT"])) # already up to date + _propagate(repo, input_artifact_ids=["in1"], output_artifact_ids=["out1", "out2"]) + assert repo.write_calls == [("out2", LABEL_ORIGIN_SYSTEM)] + + def test_newly_propagated_records_carry_the_producing_job(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["MIT"])) + _propagate(repo, input_artifact_ids=["in1"], output_artifact_ids=["out1"], job_uid="job-b") + record = repo.docs["out1"]["tag"]["license"]["values"][0] + assert record == {"value": "MIT", "origin": LABEL_ORIGIN_SYSTEM, "job": "job-b"} + + +class TestDuplicateIds: + def test_duplicate_input_ids_are_deduped(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["MIT"])) + _propagate(repo, input_artifact_ids=["in1", "in1"], output_artifact_ids=["out1"]) + assert _values(repo.docs["out1"], "license") == ["MIT"] + + def test_duplicate_output_ids_write_once(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["MIT"])) + _propagate(repo, input_artifact_ids=["in1"], output_artifact_ids=["out1", "out1"]) + assert len(repo.write_calls) == 1 + + +class TestScopeGating: + """The core draft4 change: propagation only crosses a session boundary via a bind.""" + + def test_same_session_value_propagates_without_bind(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["MIT"])) # job JOB, resolves to SESSION + _propagate(repo, input_artifact_ids=["in1"], output_artifact_ids=["out1"]) + assert _values(repo.docs["out1"], "license") == ["MIT"] + + def test_cross_session_value_without_bind_is_excluded(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["MIT"])) # produced in SESSION + _propagate( + repo, + input_artifact_ids=["in1"], + output_artifact_ids=["out1"], + current_session_id=SESSION + 1, # a different session reads it + ) + assert repo.write_calls == [] + assert "out1" not in repo.docs + + def test_cross_session_value_with_bind_propagates(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["MIT"])) + repo.seed_bind("in1", action="bind", covers={"license": ["MIT"]}) + _propagate( + repo, + input_artifact_ids=["in1"], + output_artifact_ids=["out1"], + current_session_id=SESSION + 1, + ) + assert _values(repo.docs["out1"], "license") == ["MIT"] + + def test_unbind_revokes_cross_session_propagation(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["MIT"])) + repo.seed_bind("in1", action="bind", covers={"license": ["MIT"]}) + repo.seed_bind("in1", action="unbind", covers={"license": ["MIT"]}) + _propagate( + repo, + input_artifact_ids=["in1"], + output_artifact_ids=["out1"], + current_session_id=SESSION + 1, + ) + assert repo.write_calls == [] + assert "out1" not in repo.docs + + def test_bind_only_covers_the_exact_value_it_named(self) -> None: + repo = FakeLabelRepo() + repo.seed( + "in1", + { + "tag": { + "license": { + "values": [ + {"value": "MIT", "origin": LABEL_ORIGIN_SYSTEM, "job": JOB}, + {"value": "GPL-3.0", "origin": LABEL_ORIGIN_SYSTEM, "job": JOB}, + ] + } + } + }, + ) + repo.seed_bind("in1", action="bind", covers={"license": ["MIT"]}) + _propagate( + repo, + input_artifact_ids=["in1"], + output_artifact_ids=["out1"], + current_session_id=SESSION + 1, + ) + assert _values(repo.docs["out1"], "license") == ["MIT"] + + def test_user_origin_value_with_no_job_needs_a_bind_even_within_session(self) -> None: + """A record with no `job` can't be scope-matched by session; it needs a bind. + + In practice `TagService.add()` always pairs a user-origin write with an + implicit bind, so this only matters as a defensive characterization of + propagate_tags's contract in isolation. + """ + repo = FakeLabelRepo() + repo.seed( + "in1", {"tag": {"license": {"values": [{"value": "MIT", "origin": LABEL_ORIGIN_USER}]}}} + ) + _propagate(repo, input_artifact_ids=["in1"], output_artifact_ids=["out1"]) + assert repo.write_calls == [] + assert "out1" not in repo.docs + + def test_resolve_job_session_id_is_memoized_per_call(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["MIT"])) + repo.seed("in2", _tag(jurisdiction=["EU"])) + calls: list[str] = [] + + def _tracking_resolver(job: str) -> int | None: + calls.append(job) + return SESSION + + _propagate( + repo, + input_artifact_ids=["in1", "in2"], + output_artifact_ids=["out1"], + resolve_job_session_id=_tracking_resolver, + ) + assert calls == [JOB] # both inputs share the same job -> resolved once + + +class TestValueBarriers: + """`--block-tag KIND=VALUE` filters a single value; whole-kind still drops all.""" + + def test_value_barrier_filters_one_value_keeps_others(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["MIT", "GPL-3.0"])) + _propagate( + repo, + input_artifact_ids=["in1"], + output_artifact_ids=["out1"], + blocked_values={"license": frozenset({"GPL-3.0"})}, + ) + assert _values(repo.docs["out1"], "license") == ["MIT"] + + def test_value_barrier_dropping_all_values_writes_nothing(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["GPL-3.0"])) + _propagate( + repo, + input_artifact_ids=["in1"], + output_artifact_ids=["out1"], + blocked_values={"license": frozenset({"GPL-3.0"})}, + ) + assert "out1" not in repo.docs # nothing inherited -> no version created + + def test_value_barrier_leaves_other_kinds_untouched(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["GPL-3.0"], contains_pii=["present"])) + _propagate( + repo, + input_artifact_ids=["in1"], + output_artifact_ids=["out1"], + blocked_values={"license": frozenset({"GPL-3.0"})}, + ) + assert "license" not in repo.docs["out1"].get("tag", {}) + assert _values(repo.docs["out1"], "contains_pii") == ["present"] + + def test_whole_kind_block_still_drops_every_value(self) -> None: + repo = FakeLabelRepo() + repo.seed("in1", _tag(license=["MIT", "GPL-3.0"])) + _propagate( + repo, + input_artifact_ids=["in1"], + output_artifact_ids=["out1"], + blocked_kinds=frozenset({"license"}), + ) + assert "out1" not in repo.docs + + +class TestParseBlockTags: + def test_whole_kind(self) -> None: + kinds, values = parse_block_tags(["contains_pii"]) + assert kinds == frozenset({"contains_pii"}) + assert values == {} + + def test_value_level(self) -> None: + kinds, values = parse_block_tags(["license=GPL-3.0"]) + assert kinds == frozenset() + assert values == {"license": frozenset({"GPL-3.0"})} + + def test_multiple_values_same_kind_accumulate(self) -> None: + _kinds, values = parse_block_tags(["license=GPL-3.0", "license=AGPL-3.0"]) + assert values == {"license": frozenset({"GPL-3.0", "AGPL-3.0"})} + + def test_whole_kind_wins_over_value_level(self) -> None: + kinds, values = parse_block_tags(["license", "license=GPL-3.0"]) + assert kinds == frozenset({"license"}) + assert values == {} # value entry dropped — the whole kind is blocked + + def test_empty_value_is_treated_as_whole_kind(self) -> None: + kinds, values = parse_block_tags(["license="]) + assert kinds == frozenset({"license"}) + assert values == {} + + def test_blanks_are_ignored(self) -> None: + assert parse_block_tags(["", " "]) == (frozenset(), {}) diff --git a/tests/unit/test_tag_run_modifiers.py b/tests/unit/test_tag_run_modifiers.py new file mode 100644 index 00000000..36e5f127 --- /dev/null +++ b/tests/unit/test_tag_run_modifiers.py @@ -0,0 +1,92 @@ +"""Unit tests for run-modifier record & replay. + +`roar run --block-tag/--add-tag` are recorded on the job metadata as +``run_modifiers`` so `roar reproduce` can replay them — otherwise the +reproduced tag/barrier layer diverges from the original. +""" + +from __future__ import annotations + +import json + +from roar.application.tags import build_run_modifiers, run_modifier_flags +from roar.db.services.job_recording import JobRecordingService +from roar.execution.reproduction.pipeline_executor import PipelineExecutor + + +class TestBuildRunModifiers: + def test_both_flag_kinds(self) -> None: + assert build_run_modifiers(["contains_pii", "license=GPL-3.0"], ["license=Apache-2.0"]) == { + "block_tags": ["contains_pii", "license=GPL-3.0"], + "add_tags": ["license=Apache-2.0"], + } + + def test_only_block(self) -> None: + assert build_run_modifiers(["contains_pii"], []) == {"block_tags": ["contains_pii"]} + + def test_only_add(self) -> None: + assert build_run_modifiers([], ["license=MIT"]) == {"add_tags": ["license=MIT"]} + + def test_empty_is_none(self) -> None: + assert build_run_modifiers([], []) is None + assert build_run_modifiers(["", " "], [""]) is None + + +class TestRunModifierFlags: + def test_renders_flags(self) -> None: + flags = run_modifier_flags( + {"block_tags": ["contains_pii", "license=GPL-3.0"], "add_tags": ["license=Apache-2.0"]} + ) + assert flags == ( + "--block-tag contains_pii --block-tag license=GPL-3.0 --add-tag license=Apache-2.0" + ) + + def test_shell_quotes_values_with_spaces(self) -> None: + assert run_modifier_flags({"add_tags": ["note=has space"]}) == "--add-tag 'note=has space'" + + def test_none_or_non_dict_is_empty(self) -> None: + assert run_modifier_flags(None) == "" + assert run_modifier_flags("nope") == "" + assert run_modifier_flags({}) == "" + + def test_roundtrip(self) -> None: + mods = build_run_modifiers(["contains_pii"], ["license=MIT"]) + assert run_modifier_flags(mods) == "--block-tag contains_pii --add-tag license=MIT" + + +class TestWithRunModifiersInjection: + def test_injects_and_preserves_existing_metadata(self) -> None: + base = json.dumps({"roar_version": "0.4.0", "runtime": {"command": ["python3", "s.py"]}}) + out = JobRecordingService._with_run_modifiers(base, ("contains_pii",), ("license=MIT",)) + parsed = json.loads(out) + assert parsed["roar_version"] == "0.4.0" # untouched + assert parsed["runtime"] == {"command": ["python3", "s.py"]} # untouched + assert parsed["run_modifiers"] == { + "block_tags": ["contains_pii"], + "add_tags": ["license=MIT"], + } + + def test_no_flags_returns_metadata_unchanged(self) -> None: + base = json.dumps({"roar_version": "0.4.0"}) + assert JobRecordingService._with_run_modifiers(base, (), ()) == base + + def test_none_metadata_still_records_modifiers(self) -> None: + out = JobRecordingService._with_run_modifiers(None, ("contains_pii",), ()) + assert json.loads(out) == {"run_modifiers": {"block_tags": ["contains_pii"]}} + + def test_malformed_metadata_is_not_fatal(self) -> None: + out = JobRecordingService._with_run_modifiers("not json", ("contains_pii",), ()) + assert json.loads(out) == {"run_modifiers": {"block_tags": ["contains_pii"]}} + + +class TestWrapWithRoarReplaysModifiers: + def test_modifiers_inserted_between_run_and_command(self) -> None: + ex = PipelineExecutor(roar_executable="roar") + wrapped = ex._wrap_with_roar( + "python3 redact.py", "run", None, modifiers="--block-tag contains_pii" + ) + assert wrapped == "roar run --block-tag contains_pii python3 redact.py" + + def test_no_modifiers_is_unchanged(self) -> None: + ex = PipelineExecutor(roar_executable="roar") + assert ex._wrap_with_roar("python3 x.py", "run", None) == "roar run python3 x.py" diff --git a/tests/unit/test_tag_service.py b/tests/unit/test_tag_service.py new file mode 100644 index 00000000..1757bae1 --- /dev/null +++ b/tests/unit/test_tag_service.py @@ -0,0 +1,408 @@ +"""Unit tests for TagService set-accumulation + bind-ledger semantics. + +TagService writes directly through the raw label repository (db_ctx.labels), +not LabelService — its own reserved-namespace writes must bypass the +tag.*/attach.* reservation that protects the generic `roar label` path (see +system_labels.py). These tests mock that repository directly rather than +requiring the full project dependency chain (blake3, SQLAlchemy, etc.). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from roar.application.labels import LabelTargetRef +from roar.application.tags import TagService +from roar.core.label_origins import LABEL_ORIGIN_USER + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_RESOLVED = LabelTargetRef(entity_type="job", job_id=1, display_target="@1") + + +def _make_service(current_metadata: dict[str, Any] | None = None): + """Return (TagService, mock_label_repo) with stubbed get_current/create_version.""" + db_ctx = MagicMock() + mock_label_repo = db_ctx.labels + mock_label_repo.get_current.return_value = ( + {"metadata": current_metadata, "version": 1} if current_metadata is not None else None + ) + svc = TagService(db_ctx, Path(".")) + return svc, mock_label_repo + + +def _written_tag_subtree(mock_label_repo: MagicMock) -> dict[str, Any]: + """The `tag` subtree from the most recent create_version call's metadata.""" + metadata = mock_label_repo.create_version.call_args.args[1] + return metadata.get("tag", {}) + + +def _values(kind_data: dict[str, Any]) -> list[str]: + return [record["value"] for record in kind_data["values"]] + + +# --------------------------------------------------------------------------- +# add +# --------------------------------------------------------------------------- + + +class TestAdd: + def test_add_first_value_writes_user_origin_and_implicit_bind(self) -> None: + svc, label_repo = _make_service() + changed = svc.add(_RESOLVED, "license", "MIT") + assert changed is True + tag = _written_tag_subtree(label_repo) + assert tag["license"] == {"values": [{"value": "MIT", "origin": LABEL_ORIGIN_USER}]} + assert tag["bind"] == {"events": [{"action": "bind", "covers": {"license": ["MIT"]}}]} + + def test_add_second_value_appends_to_existing_list(self) -> None: + svc, label_repo = _make_service( + {"tag": {"license": {"values": [{"value": "MIT", "origin": "user"}]}}} + ) + changed = svc.add(_RESOLVED, "license", "Apache-2.0") + assert changed is True + tag = _written_tag_subtree(label_repo) + assert _values(tag["license"]) == ["MIT", "Apache-2.0"] + # Implicit bind covers only the value just added, not the pre-existing one. + assert tag["bind"]["events"][-1] == { + "action": "bind", + "covers": {"license": ["Apache-2.0"]}, + } + + def test_add_duplicate_returns_false_without_writing(self) -> None: + svc, label_repo = _make_service( + {"tag": {"license": {"values": [{"value": "MIT", "origin": "user"}]}}} + ) + changed = svc.add(_RESOLVED, "license", "MIT") + assert changed is False + label_repo.create_version.assert_not_called() + + def test_add_different_kind_does_not_touch_others(self) -> None: + svc, label_repo = _make_service( + {"tag": {"license": {"values": [{"value": "MIT", "origin": "user"}]}}} + ) + svc.add(_RESOLVED, "contains_pii", "absent") + tag = _written_tag_subtree(label_repo) + assert _values(tag["license"]) == ["MIT"] + assert _values(tag["contains_pii"]) == ["absent"] + + def test_add_when_no_tag_namespace_yet_preserves_other_metadata(self) -> None: + svc, label_repo = _make_service({"owner": "ml"}) + changed = svc.add(_RESOLVED, "license", "MIT") + assert changed is True + metadata = label_repo.create_version.call_args.args[1] + assert metadata["owner"] == "ml" + assert _values(metadata["tag"]["license"]) == ["MIT"] + + def test_add_appends_to_an_existing_bind_ledger_rather_than_overwriting(self) -> None: + svc, label_repo = _make_service( + { + "tag": { + "bind": {"events": [{"action": "bind", "covers": {"jurisdiction": ["EU"]}}]}, + } + } + ) + svc.add(_RESOLVED, "license", "MIT") + tag = _written_tag_subtree(label_repo) + assert tag["bind"]["events"] == [ + {"action": "bind", "covers": {"jurisdiction": ["EU"]}}, + {"action": "bind", "covers": {"license": ["MIT"]}}, + ] + + +# --------------------------------------------------------------------------- +# remove +# --------------------------------------------------------------------------- + + +class TestRemove: + def test_remove_specific_value(self) -> None: + svc, label_repo = _make_service( + { + "tag": { + "license": { + "values": [ + {"value": "MIT", "origin": "user"}, + {"value": "Apache-2.0", "origin": "user"}, + ] + } + } + } + ) + changed = svc.remove(_RESOLVED, "license", "MIT") + assert changed is True + tag = _written_tag_subtree(label_repo) + assert _values(tag["license"]) == ["Apache-2.0"] + + def test_remove_last_value_drops_the_kind(self) -> None: + svc, label_repo = _make_service( + {"tag": {"license": {"values": [{"value": "MIT", "origin": "user"}]}}} + ) + changed = svc.remove(_RESOLVED, "license", "MIT") + assert changed is True + tag = _written_tag_subtree(label_repo) + assert "license" not in tag + + def test_remove_whole_kind(self) -> None: + svc, label_repo = _make_service( + { + "tag": { + "license": { + "values": [ + {"value": "MIT", "origin": "user"}, + {"value": "Apache-2.0", "origin": "user"}, + ] + } + } + } + ) + changed = svc.remove(_RESOLVED, "license", None) + assert changed is True + tag = _written_tag_subtree(label_repo) + assert "license" not in tag + + def test_remove_absent_kind_returns_false(self) -> None: + svc, label_repo = _make_service({}) + changed = svc.remove(_RESOLVED, "license", None) + assert changed is False + label_repo.create_version.assert_not_called() + + def test_remove_absent_value_returns_false(self) -> None: + svc, label_repo = _make_service( + {"tag": {"license": {"values": [{"value": "MIT", "origin": "user"}]}}} + ) + changed = svc.remove(_RESOLVED, "license", "GPL-3.0") + assert changed is False + label_repo.create_version.assert_not_called() + + def test_remove_does_not_touch_the_bind_ledger(self) -> None: + """rm is a history event, not a hard delete — past binds stay, append-only.""" + svc, label_repo = _make_service( + { + "tag": { + "license": {"values": [{"value": "MIT", "origin": "user"}]}, + "bind": {"events": [{"action": "bind", "covers": {"license": ["MIT"]}}]}, + } + } + ) + svc.remove(_RESOLVED, "license", "MIT") + tag = _written_tag_subtree(label_repo) + assert tag["bind"] == {"events": [{"action": "bind", "covers": {"license": ["MIT"]}}]} + + +# --------------------------------------------------------------------------- +# bind / unbind +# --------------------------------------------------------------------------- + + +class TestBind: + def test_bind_covers_every_current_kind_and_value(self) -> None: + svc, label_repo = _make_service( + { + "tag": { + "license": {"values": [{"value": "MIT", "origin": "user"}]}, + "jurisdiction": {"values": [{"value": "EU", "origin": "system", "job": "j1"}]}, + } + } + ) + result = svc.bind(_RESOLVED) + assert result.changed is True + assert result.promoted == {"license": ["MIT"], "jurisdiction": ["EU"]} + tag = _written_tag_subtree(label_repo) + assert tag["bind"]["events"][-1] == { + "action": "bind", + "covers": {"license": ["MIT"], "jurisdiction": ["EU"]}, + } + + def test_bind_appends_to_an_existing_ledger_when_the_covered_set_grew(self) -> None: + svc, label_repo = _make_service( + { + "tag": { + "license": {"values": [{"value": "MIT", "origin": "user"}]}, + "jurisdiction": {"values": [{"value": "EU", "origin": "user"}]}, + "bind": {"events": [{"action": "bind", "covers": {"license": ["MIT"]}}]}, + } + } + ) + result = svc.bind(_RESOLVED) + assert result.changed is True + tag = _written_tag_subtree(label_repo) + assert len(tag["bind"]["events"]) == 2 + + def test_rebinding_the_exact_same_set_is_a_noop(self) -> None: + """Re-binding an artifact whose tags haven't changed since the last bind + shouldn't add a redundant ledger entry every time `roar register` runs.""" + svc, label_repo = _make_service( + { + "tag": { + "license": {"values": [{"value": "MIT", "origin": "user"}]}, + "bind": {"events": [{"action": "bind", "covers": {"license": ["MIT"]}}]}, + } + } + ) + result = svc.bind(_RESOLVED) + assert result.changed is False + assert result.promoted == {"license": ["MIT"]} + label_repo.create_version.assert_not_called() + + def test_bind_with_no_tags_is_a_noop(self) -> None: + svc, label_repo = _make_service({}) + result = svc.bind(_RESOLVED) + assert result.changed is False + assert result.promoted == {} + label_repo.create_version.assert_not_called() + + +class TestUnbind: + def test_unbind_revokes_currently_bound_pairs(self) -> None: + svc, label_repo = _make_service( + { + "tag": { + "license": {"values": [{"value": "MIT", "origin": "user"}]}, + "bind": {"events": [{"action": "bind", "covers": {"license": ["MIT"]}}]}, + } + } + ) + result = svc.unbind(_RESOLVED) + assert result.changed is True + assert result.promoted == {"license": ["MIT"]} + tag = _written_tag_subtree(label_repo) + assert tag["bind"]["events"][-1] == {"action": "unbind", "covers": {"license": ["MIT"]}} + + def test_unbind_with_nothing_ever_bound_is_a_noop(self) -> None: + svc, label_repo = _make_service( + {"tag": {"license": {"values": [{"value": "MIT", "origin": "user"}]}}} + ) + result = svc.unbind(_RESOLVED) + assert result.changed is False + label_repo.create_version.assert_not_called() + + def test_unbind_after_unbind_is_a_noop(self) -> None: + svc, label_repo = _make_service( + { + "tag": { + "license": {"values": [{"value": "MIT", "origin": "user"}]}, + "bind": { + "events": [ + {"action": "bind", "covers": {"license": ["MIT"]}}, + {"action": "unbind", "covers": {"license": ["MIT"]}}, + ] + }, + } + } + ) + result = svc.unbind(_RESOLVED) + assert result.changed is False + label_repo.create_version.assert_not_called() + + def test_unbind_does_not_delete_the_revoked_event(self) -> None: + """Append-only revocation: unbind writes a new event, never deletes the bind.""" + svc, label_repo = _make_service( + { + "tag": { + "license": {"values": [{"value": "MIT", "origin": "user"}]}, + "bind": {"events": [{"action": "bind", "covers": {"license": ["MIT"]}}]}, + } + } + ) + svc.unbind(_RESOLVED) + tag = _written_tag_subtree(label_repo) + assert tag["bind"]["events"][0] == {"action": "bind", "covers": {"license": ["MIT"]}} + assert len(tag["bind"]["events"]) == 2 + + +# --------------------------------------------------------------------------- +# get_tags +# --------------------------------------------------------------------------- + + +class TestGetTags: + def test_returns_tag_namespace_subtree_excluding_the_bind_ledger(self) -> None: + svc, _ = _make_service( + { + "tag": { + "license": {"values": [{"value": "MIT", "origin": "user"}]}, + "bind": {"events": [{"action": "bind", "covers": {"license": ["MIT"]}}]}, + }, + "owner": "ml", + } + ) + assert svc.get_tags(_RESOLVED) == { + "license": {"values": [{"value": "MIT", "origin": "user"}]} + } + + def test_returns_empty_dict_when_no_tags(self) -> None: + svc, _ = _make_service({"owner": "ml"}) + assert svc.get_tags(_RESOLVED) == {} + + def test_returns_empty_dict_when_no_metadata(self) -> None: + svc, _ = _make_service() + assert svc.get_tags(_RESOLVED) == {} + + +# --------------------------------------------------------------------------- +# history +# --------------------------------------------------------------------------- + + +class TestHistory: + def test_delegates_to_label_service(self) -> None: + db_ctx = MagicMock() + svc = TagService(db_ctx, Path(".")) + mock_label_svc = MagicMock() + expected = [{"version": 1, "metadata": {}}] + mock_label_svc.history.return_value = expected + svc._svc = mock_label_svc + result = svc.history(_RESOLVED) + mock_label_svc.history.assert_called_once_with(_RESOLVED) + assert result is expected + + +# --------------------------------------------------------------------------- +# resolve_target — error cases only (happy path needs DB repos) +# --------------------------------------------------------------------------- + + +class TestResolveTargetErrors: + def test_rejects_build_step_reference(self) -> None: + db_ctx = MagicMock() + svc = TagService(db_ctx, Path(".")) + with pytest.raises(ValueError, match="Build-step targets"): + svc.resolve_target("@B1") + + def test_rejects_session_reference(self) -> None: + db_ctx = MagicMock() + svc = TagService(db_ctx, Path(".")) + with pytest.raises(ValueError, match="Session targets"): + svc.resolve_target("@session") + + def test_rejects_latest_reference(self) -> None: + db_ctx = MagicMock() + svc = TagService(db_ctx, Path(".")) + with pytest.raises(ValueError, match="Session targets"): + svc.resolve_target("@latest") + + def test_at_n_delegates_to_label_service_as_job(self) -> None: + db_ctx = MagicMock() + svc = TagService(db_ctx, Path(".")) + svc._svc = MagicMock() + svc._svc.resolve_target.return_value = _RESOLVED + result = svc.resolve_target("@1") + svc._svc.resolve_target.assert_called_once_with("job", "@1") + assert result is _RESOLVED + + def test_hex_string_delegates_to_label_service_as_artifact(self) -> None: + db_ctx = MagicMock() + svc = TagService(db_ctx, Path(".")) + resolved = LabelTargetRef(entity_type="artifact", artifact_id="abc", display_target="abc") + svc._svc = MagicMock() + svc._svc.resolve_target.return_value = resolved + result = svc.resolve_target("a1b2c3d4") + svc._svc.resolve_target.assert_called_once_with("artifact", "a1b2c3d4") + assert result is resolved diff --git a/tests/unit/test_tag_stamping.py b/tests/unit/test_tag_stamping.py new file mode 100644 index 00000000..6e9ecb6e --- /dev/null +++ b/tests/unit/test_tag_stamping.py @@ -0,0 +1,151 @@ +"""Unit tests for explicit tag stamping (`roar run --add-tag`) and its parsing helpers.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from roar.application.tags import parse_add_tags, parse_tag_kv, stamp_tags +from roar.core.label_origins import LABEL_ORIGIN_USER + + +class FakeLabelRepo: + """In-memory stand-in for SQLAlchemyLabelRepository's artifact-label surface.""" + + def __init__(self) -> None: + self.docs: dict[str, dict[str, Any]] = {} + self.versions: dict[str, int] = {} + self.write_calls: list[tuple[str, str | None]] = [] + + def seed(self, artifact_id: str, metadata: dict[str, Any]) -> None: + self.docs[artifact_id] = metadata + self.versions[artifact_id] = 1 + + def get_current( + self, entity_type: str, *, artifact_id: str | None = None + ) -> dict[str, Any] | None: + assert entity_type == "artifact" + if artifact_id not in self.docs: + return None + return {"metadata": self.docs[artifact_id], "version": self.versions[artifact_id]} + + def create_version( + self, + entity_type: str, + metadata: dict[str, Any], + *, + artifact_id: str | None = None, + write_origin: str | None = None, + ) -> dict[str, Any]: + assert entity_type == "artifact" + assert artifact_id is not None + self.docs[artifact_id] = metadata + self.versions[artifact_id] = self.versions.get(artifact_id, 0) + 1 + self.write_calls.append((artifact_id, write_origin)) + return {"metadata": metadata, "version": self.versions[artifact_id]} + + +class TestParseTagKv: + def test_parses_kind_and_value(self) -> None: + assert parse_tag_kv("license=MIT") == ("license", "MIT") + + def test_strips_whitespace(self) -> None: + assert parse_tag_kv(" license = MIT ") == ("license", "MIT") + + def test_raises_when_no_equals(self) -> None: + with pytest.raises(ValueError, match="Expected KIND=VALUE"): + parse_tag_kv("license") + + def test_raises_when_kind_empty(self) -> None: + with pytest.raises(ValueError, match="Kind cannot be empty"): + parse_tag_kv("=MIT") + + def test_raises_when_value_empty(self) -> None: + with pytest.raises(ValueError, match="Value cannot be empty"): + parse_tag_kv("license=") + + +class TestParseAddTags: + def test_groups_single_pair(self) -> None: + assert parse_add_tags(["license=MIT"]) == {"license": ["MIT"]} + + def test_groups_multiple_values_for_same_kind(self) -> None: + result = parse_add_tags(["license=MIT", "license=Apache-2.0"]) + assert result == {"license": ["MIT", "Apache-2.0"]} + + def test_dedupes_identical_values(self) -> None: + result = parse_add_tags(["license=MIT", "license=MIT"]) + assert result == {"license": ["MIT"]} + + def test_groups_independent_kinds(self) -> None: + result = parse_add_tags(["license=MIT", "jurisdiction=EU"]) + assert result == {"license": ["MIT"], "jurisdiction": ["EU"]} + + def test_empty_input_yields_empty_dict(self) -> None: + assert parse_add_tags([]) == {} + + def test_raises_on_malformed_pair(self) -> None: + with pytest.raises(ValueError, match="Expected KIND=VALUE"): + parse_add_tags(["license=MIT", "badpair"]) + + +def _values(doc: dict[str, Any], kind: str) -> list[str]: + return [record["value"] for record in doc["tag"][kind]["values"]] + + +class TestStampTags: + def test_stamps_new_kind_onto_output(self) -> None: + repo = FakeLabelRepo() + stamp_tags(repo, output_artifact_ids=["out1"], tags={"license": ["MIT"]}) + assert _values(repo.docs["out1"], "license") == ["MIT"] + + def test_unions_with_existing_tags(self) -> None: + repo = FakeLabelRepo() + repo.seed("out1", {"tag": {"license": {"values": [{"value": "MIT", "origin": "user"}]}}}) + stamp_tags(repo, output_artifact_ids=["out1"], tags={"license": ["Apache-2.0"]}) + assert set(_values(repo.docs["out1"], "license")) == {"MIT", "Apache-2.0"} + + def test_preserves_non_tag_metadata(self) -> None: + repo = FakeLabelRepo() + repo.seed("out1", {"owner": "ml-team"}) + stamp_tags(repo, output_artifact_ids=["out1"], tags={"license": ["MIT"]}) + assert repo.docs["out1"]["owner"] == "ml-team" + assert _values(repo.docs["out1"], "license") == ["MIT"] + + def test_stamps_all_outputs(self) -> None: + repo = FakeLabelRepo() + stamp_tags(repo, output_artifact_ids=["out1", "out2"], tags={"license": ["MIT"]}) + assert _values(repo.docs["out1"], "license") == ["MIT"] + assert _values(repo.docs["out2"], "license") == ["MIT"] + + def test_uses_user_write_origin(self) -> None: + repo = FakeLabelRepo() + stamp_tags(repo, output_artifact_ids=["out1"], tags={"license": ["MIT"]}) + assert repo.write_calls == [("out1", LABEL_ORIGIN_USER)] + + def test_noop_when_value_already_present(self) -> None: + repo = FakeLabelRepo() + repo.seed("out1", {"tag": {"license": {"values": [{"value": "MIT", "origin": "user"}]}}}) + stamp_tags(repo, output_artifact_ids=["out1"], tags={"license": ["MIT"]}) + assert repo.write_calls == [] + + def test_empty_tags_is_a_noop(self) -> None: + repo = FakeLabelRepo() + stamp_tags(repo, output_artifact_ids=["out1"], tags={}) + assert repo.write_calls == [] + + def test_empty_outputs_is_a_noop(self) -> None: + repo = FakeLabelRepo() + stamp_tags(repo, output_artifact_ids=[], tags={"license": ["MIT"]}) + assert repo.write_calls == [] + + def test_stamped_record_carries_the_job_but_origin_stays_user(self) -> None: + """--add-tag is user-origin (an explicit assertion) but still job-stamped, so + it propagates normally within the same session — it just doesn't imply a bind + (the named-artifact rule: it quantifies over the whole output set).""" + repo = FakeLabelRepo() + stamp_tags(repo, output_artifact_ids=["out1"], tags={"license": ["MIT"]}, job_uid="job-x") + record = repo.docs["out1"]["tag"]["license"]["values"][0] + assert record == {"value": "MIT", "origin": LABEL_ORIGIN_USER, "job": "job-x"} + assert "bind" not in repo.docs["out1"]["tag"] diff --git a/tests/unit/test_tag_why.py b/tests/unit/test_tag_why.py new file mode 100644 index 00000000..3fbe78e4 --- /dev/null +++ b/tests/unit/test_tag_why.py @@ -0,0 +1,204 @@ +"""Unit tests for TagService.why — the provenance walk behind `roar tag why`. + +`why` is a read-only traversal over the stored `{value, origin, job}` records, +the bind ledger, and job->inputs. These tests mock the db_ctx surface it reads +(labels.get_current, jobs.get_by_uid/get_inputs, artifacts.get) directly rather +than standing up the full project dependency chain, matching test_tag_service.py. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from roar.application.labels import LabelTargetRef +from roar.application.tags import TagService +from roar.core.label_origins import LABEL_ORIGIN_SYSTEM, LABEL_ORIGIN_USER + + +def _doc(tag_subtree: dict[str, Any]) -> dict[str, Any]: + return {"metadata": {"tag": tag_subtree}, "version": 1} + + +def _val(value: str, origin: str, job: str | None = None) -> dict[str, Any]: + record: dict[str, Any] = {"value": value, "origin": origin} + if job is not None: + record["job"] = job + return record + + +def _make_service( + *, + labels: dict[str, dict[str, Any]], + jobs_by_uid: dict[str, dict[str, Any]] | None = None, + inputs_by_job_id: dict[int, list[dict[str, Any]]] | None = None, + artifacts: dict[str, dict[str, Any]] | None = None, +) -> TagService: + jobs_by_uid = jobs_by_uid or {} + inputs_by_job_id = inputs_by_job_id or {} + artifacts = artifacts or {} + + db_ctx = MagicMock() + + def get_current( + entity_type: str, + *, + session_id: Any = None, + job_id: Any = None, + artifact_id: str | None = None, + ) -> dict[str, Any] | None: + return labels.get(artifact_id) if artifact_id is not None else None + + db_ctx.labels.get_current.side_effect = get_current + db_ctx.jobs.get_by_uid.side_effect = lambda uid: jobs_by_uid.get(uid) + db_ctx.jobs.get_inputs.side_effect = lambda job_id: inputs_by_job_id.get(job_id, []) + db_ctx.artifacts.get.side_effect = lambda aid: artifacts.get(aid) + return TagService(db_ctx, Path(".")) + + +def _artifact(artifact_id: str) -> LabelTargetRef: + return LabelTargetRef(entity_type="artifact", artifact_id=artifact_id) + + +class TestWhy: + def test_user_tag_add_is_a_leaf(self) -> None: + svc = _make_service( + labels={ + "raw": _doc({"contains_pii": {"values": [_val("present", LABEL_ORIGIN_USER)]}}) + }, + artifacts={"raw": {"path": "data/raw.csv"}}, + ) + roots = svc.why(_artifact("raw"), "contains_pii") + assert len(roots) == 1 + assert "raw.csv" in roots[0].label + assert "user `roar tag add`" in roots[0].label + assert roots[0].children == [] + + def test_system_value_walks_one_hop_to_the_user_act(self) -> None: + svc = _make_service( + labels={ + "out": _doc( + {"contains_pii": {"values": [_val("present", LABEL_ORIGIN_SYSTEM, "jobA")]}} + ), + "raw": _doc({"contains_pii": {"values": [_val("present", LABEL_ORIGIN_USER)]}}), + }, + jobs_by_uid={"jobA": {"id": 1}}, + inputs_by_job_id={1: [{"artifact_id": "raw"}]}, + artifacts={"out": {"path": "out.txt"}, "raw": {"path": "raw.csv"}}, + ) + roots = svc.why(_artifact("out"), "contains_pii") + assert len(roots) == 1 + assert "inherited" in roots[0].label and "jobA" in roots[0].label + assert len(roots[0].children) == 1 + assert "user `roar tag add`" in roots[0].children[0].label + + def test_cross_session_hop_is_annotated_with_the_bind(self) -> None: + svc = _make_service( + labels={ + "out": _doc( + {"contains_pii": {"values": [_val("present", LABEL_ORIGIN_SYSTEM, "jobB")]}} + ), + "done": _doc( + { + "contains_pii": {"values": [_val("present", LABEL_ORIGIN_SYSTEM, "jobA")]}, + "bind": { + "events": [{"action": "bind", "covers": {"contains_pii": ["present"]}}] + }, + } + ), + "raw": _doc({"contains_pii": {"values": [_val("present", LABEL_ORIGIN_USER)]}}), + }, + jobs_by_uid={"jobB": {"id": 2}, "jobA": {"id": 1}}, + inputs_by_job_id={2: [{"artifact_id": "done"}], 1: [{"artifact_id": "raw"}]}, + artifacts={ + "out": {"path": "out.txt"}, + "done": {"path": "done.marker"}, + "raw": {"path": "raw.csv"}, + }, + ) + roots = svc.why(_artifact("out"), "contains_pii") + bind_node = roots[0].children[0] + assert "roar tag bind" in bind_node.label and "done.marker" in bind_node.label + # the bind wrapper still leads down to the originating human act + derived = bind_node.children[0] + assert "inherited" in derived.label + assert "user `roar tag add`" in derived.children[0].label + + def test_add_tag_run_value_is_a_session_scoped_leaf(self) -> None: + svc = _make_service( + labels={"out": _doc({"license": {"values": [_val("MIT", LABEL_ORIGIN_USER, "jobA")]}})}, + artifacts={"out": {"path": "out.txt"}}, + ) + roots = svc.why(_artifact("out"), "license") + assert "run --add-tag" in roots[0].label and "session-scoped" in roots[0].label + assert roots[0].children == [] + + def test_value_filter_narrows_to_one_value(self) -> None: + svc = _make_service( + labels={ + "raw": _doc( + { + "license": { + "values": [ + _val("MIT", LABEL_ORIGIN_USER), + _val("GPL-3.0", LABEL_ORIGIN_USER), + ] + } + } + ) + }, + artifacts={"raw": {"path": "raw.csv"}}, + ) + assert len(svc.why(_artifact("raw"), "license")) == 2 + narrowed = svc.why(_artifact("raw"), "license", "MIT") + assert len(narrowed) == 1 + assert "MIT" in narrowed[0].label + + def test_absent_tag_returns_empty(self) -> None: + svc = _make_service(labels={"out": _doc({})}, artifacts={"out": {"path": "out.txt"}}) + assert svc.why(_artifact("out"), "contains_pii") == [] + + def test_missing_producer_job_is_a_graceful_leaf(self) -> None: + svc = _make_service( + labels={ + "out": _doc( + {"contains_pii": {"values": [_val("present", LABEL_ORIGIN_SYSTEM, "ghost")]}} + ) + }, + jobs_by_uid={}, # producing job not found + artifacts={"out": {"path": "out.txt"}}, + ) + roots = svc.why(_artifact("out"), "contains_pii") + assert "producing job not found" in roots[0].label + + def test_cycle_is_guarded(self) -> None: + # out is (contrived) produced by a job that lists out itself as an input. + svc = _make_service( + labels={ + "out": _doc( + {"contains_pii": {"values": [_val("present", LABEL_ORIGIN_SYSTEM, "jobA")]}} + ) + }, + jobs_by_uid={"jobA": {"id": 1}}, + inputs_by_job_id={1: [{"artifact_id": "out"}]}, + artifacts={"out": {"path": "out.txt"}}, + ) + roots = svc.why(_artifact("out"), "contains_pii") + # terminates (no infinite recursion); the self-edge is marked a cycle + assert "(cycle)" in roots[0].children[0].label + + def test_why_rejects_job_target_with_actionable_message(self) -> None: + svc = _make_service(labels={}) + # A job is a valid target elsewhere, so the error must name the job case + # and point at output artifacts / `tag show` rather than implying the + # reference was untracked. + with pytest.raises(ValueError, match="not a job's"): + svc.why(LabelTargetRef(entity_type="job", job_id=1), "contains_pii") + + def test_why_rejects_untracked_target(self) -> None: + svc = _make_service(labels={}) + with pytest.raises(ValueError, match="tracked artifact"): + svc.why(LabelTargetRef(entity_type="artifact", artifact_id=None), "contains_pii") diff --git a/tests/unit/test_telemetry_cli.py b/tests/unit/test_telemetry_cli.py index 6d272d0e..e6b07e10 100644 --- a/tests/unit/test_telemetry_cli.py +++ b/tests/unit/test_telemetry_cli.py @@ -16,6 +16,12 @@ def _env(tmp_path: Path) -> dict[str, str]: "HOME": str(tmp_path / "home"), "XDG_CONFIG_HOME": str(state_root / "config"), "XDG_CACHE_HOME": str(state_root / "cache"), + # Neutralize telemetry opt-outs that may be set in the ambient env + # (e.g. DO_NOT_TRACK=1 on a dev box) so these tests control the + # enabled/disabled state themselves rather than short-circuiting on + # an inherited opt-out reason. + "DO_NOT_TRACK": "", + "ROAR_NO_TELEMETRY": "", "CI": "", "GITHUB_ACTIONS": "", "GITLAB_CI": "",