DEVX-3: feat: pluggable change classification framework

This commit is contained in:
2026-06-22 17:31:30 +00:00
parent 89a165be46
commit 87d730d8be
4 changed files with 815 additions and 248 deletions
+445 -152
View File
@@ -1,53 +1,91 @@
#!/usr/bin/env python3
"""Classify git changes as user-facing or workflow-only.
"""Classify git changes as user-facing or infrastructure.
Determines whether changes between two git refs (e.g., last tag and HEAD)
affect the tool itself (user-facing) or only the CI/CD infrastructure
affect the published package (user-facing) or only the CI/CD infrastructure
(workflow-only). This is used by:
- **release.py** — skips release when only workflow files changed
- **release.py** — skips release when only infrastructure files changed
- **CI workflow** — skips molecule tests and release dry-run when only
workflow files changed
infrastructure files changed
Classification strategy (safe-by-default):
== Design Philosophy ==
Any file that is NOT in the explicit workflow-only allowlist is treated
as user-facing. This ensures new file types default to requiring a
release rather than silently skipping it.
**Safe-by-default**: Any file that doesn't match a rule defaults to
user-facing. This prevents new file types from accidentally skipping
releases — a critical safety property. When in doubt, release.
The workflow-only patterns are configurable via the ``patterns``
parameter on ``classify_changes()`` and ``has_user_facing_changes()``.
The default set (``DEFAULT_WORKFLOW_ONLY_PATTERNS``) covers common
infrastructure paths. Each project can pass its own frozenset to
accommodate different source layouts.
**Config-driven**: Classification rules are read from ``[tool.devx.classify]``
in ``pyproject.toml``. No project needs to modify the framework code.
Each project declares its own paths; the framework handles the logic.
Default workflow-only paths (infrastructure → no release needed):
- .gitea/workflows/** — Gitea Actions workflows
- scripts/** — All scripts (CI/CD, dev tools, setup)
- src/devx/__init__.py — Version file (release artifact)
- src/devx/api_clients.py — Gitea API client (CI/CD only, not used by CLI)
- docs/** — Documentation
- tests/** — Test files
- hooks/** — Git hooks
- AGENTS.md — Agent conventions
- README.md — README (lean, links to wiki)
- CHANGELOG.md — Changelog (generated)
- TROUBLESHOOTING.md — Troubleshooting guide
- cliff.toml — git-cliff config
- Makefile — Build automation
- .pre-commit-config.yaml — Pre-commit config
- .ansible-lint — Ansible lint config
- .env.example — Environment template
- .gitignore — Git ignore rules
- .ruff.toml — Ruff config (if separate)
- .github/** — GitHub config (if present)
**Layered rules** (evaluated in priority order):
Everything else is user-facing (tool changes → release needed),
including but not limited to:
- src/devx/*.py — Python CLI source (except __init__.py)
- ansible/** — Ansible role
- pyproject.toml — Package metadata
- Any new file type not in the allowlist
1. **User-facing overrides** (highest priority — safety override)
Files that match infrastructure patterns but MUST be treated as
user-facing. Use this when an infrastructure pattern is too broad.
2. **Infrastructure overrides**
Files that would default to user-facing but are actually
infrastructure (e.g., ``src/pkg/__init__.py`` which only contains
``__version__`` — a release artifact, not user-facing code).
3. **Infrastructure patterns** (deny-list)
Path globs matching infrastructure files. Changes to these don't
trigger a release. Examples: ``.gitea/**``, ``tests/**``, ``docs/**``.
4. **Default**: user-facing (lowest priority — safe default)
**Tag system** (orthogonal to release impact):
Projects can define custom tags (e.g., ``ansible``, ``docs``) for CI
conditional execution. A file can be both infrastructure (no release)
and tagged ``ansible`` (run molecule tests). Tags are evaluated
independently of the user-facing/infrastructure classification.
== Configuration ==
In ``pyproject.toml``::
[tool.devx.classify]
# Infrastructure paths — changes here don't trigger a release
infrastructure = [
".gitea/**",
"tests/**",
"docs/**",
"Makefile",
"AGENTS.md",
"README.md",
"CHANGELOG.md",
]
# Infrastructure overrides — files that would default to user-facing
# but are actually infrastructure
infrastructure_overrides = [
"src/devx/__init__.py",
]
# User-facing overrides — safety override for broad infrastructure patterns
# (empty by default)
user_facing_overrides = []
# Tag patterns — additional categories for CI conditional execution
[tool.devx.classify.tags]
ansible = ["ansible/**", ".ansible-lint"]
== Glob Syntax ==
Patterns support standard glob syntax:
- ``**`` matches any number of path segments (including zero)
- ``*`` matches any characters within a single path segment
- ``?`` matches a single character within a path segment
- Everything else is matched literally
Examples:
- ``.gitea/**`` matches ``.gitea/workflows/ci.yml``, ``.gitea/actionlint.yaml``
- ``tests/**`` matches ``tests/unit/test_cli.py``, ``tests/conftest.py``
- ``src/devx/__init__.py`` matches exactly that file
- ``Makefile`` matches exactly that file
Usage:
python3 -m devx.ci.classify_changes [--base <ref>] [--head <ref>]
@@ -56,51 +94,270 @@ Usage:
from __future__ import annotations
import os
import re
import subprocess # nosec B404
import sys
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import click
from devx.i18n import _
# Explicit allowlist of workflow-only path patterns.
# Anything NOT matching these is treated as user-facing (safe default).
# This is the default set — projects can override via the ``patterns``
# parameter on classify_changes() / has_user_facing_changes().
DEFAULT_WORKFLOW_ONLY_PATTERNS: frozenset[str] = frozenset(
[
# CI/CD infrastructure
".gitea/",
# All scripts are infrastructure (CI/CD, dev tools, setup)
# User-facing code lives in src/devx/
"scripts/",
# Version file — only contains __version__, not user-facing code.
# Version bumps are a release artifact, not a feature.
"src/devx/__init__.py",
# Gitea API client — used only by CI/CD scripts, not by the CLI.
"src/devx/api_clients.py",
# Documentation
"docs/",
"AGENTS.md",
"README.md",
"CHANGELOG.md",
"TROUBLESHOOTING.md",
# Tests
"tests/",
# Config / build automation
"cliff.toml",
"Makefile",
".pre-commit-config.yaml",
".ansible-lint",
".env.example",
".gitignore",
".ruff.toml",
# Hooks
"hooks/",
# GitHub (if ever added)
".github/",
]
)
# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class FileClassification:
"""Result of classifying a single file.
Attributes:
path: The file path relative to repo root.
is_user_facing: True if changes to this file require a release.
reason: Human-readable explanation of the classification.
matched_rule: Which rule matched (e.g., "infrastructure: .gitea/**").
None if the default rule was used.
tags: Custom category tags (e.g., {"ansible"}).
"""
path: str
is_user_facing: bool
reason: str
matched_rule: str | None
tags: frozenset[str] = frozenset()
@dataclass
class ClassificationResult:
"""Result of classifying a set of changed files.
Attributes:
files: Per-file classification details.
user_facing: List of file paths classified as user-facing.
infrastructure: List of file paths classified as infrastructure.
tags: Dict mapping tag name to list of file paths matching that tag.
"""
files: list[FileClassification] = field(default_factory=list)
user_facing: list[str] = field(default_factory=list)
infrastructure: list[str] = field(default_factory=list)
tags: dict[str, list[str]] = field(default_factory=dict)
@property
def has_user_facing(self) -> bool:
"""True if any user-facing files were found."""
return bool(self.user_facing)
def has_tag(self, tag: str) -> bool:
"""True if any files matched the given tag."""
return bool(self.tags.get(tag))
# ---------------------------------------------------------------------------
# Glob matching
# ---------------------------------------------------------------------------
def _glob_to_regex(pattern: str) -> re.Pattern[str]:
"""Convert a glob pattern to a compiled regex.
Supports:
- ``**`` → matches any number of path segments (including zero)
- ``*`` → matches any chars within a single path segment
- ``?`` → matches a single char within a path segment
- All other characters are matched literally
"""
# Handle ** at the end (e.g., ".gitea/**")
# ** matches anything including slashes
parts: list[str] = []
i = 0
while i < len(pattern):
c = pattern[i]
if c == "*" and i + 1 < len(pattern) and pattern[i + 1] == "*":
parts.append(".*")
i += 2
# Skip trailing slash after **
if i < len(pattern) and pattern[i] == "/":
i += 1
elif c == "*":
parts.append("[^/]*")
i += 1
elif c == "?":
parts.append("[^/]")
i += 1
else:
parts.append(re.escape(c))
i += 1
return re.compile("^" + "".join(parts) + "$")
def _matches_glob(file_path: str, pattern: str) -> bool:
"""Check if a file path matches a glob pattern.
Also supports prefix matching: if the pattern ends with ``/``,
any file starting with that prefix matches. This is a convenience
for patterns like ``.gitea/`` (equivalent to ``.gitea/**``).
"""
# Prefix matching for patterns ending with /
if pattern.endswith("/") and (file_path.startswith(pattern) or file_path == pattern.rstrip("/")):
return True
return _glob_to_regex(pattern).match(file_path) is not None
# ---------------------------------------------------------------------------
# Classifier
# ---------------------------------------------------------------------------
@dataclass
class ClassifierConfig:
"""Configuration for the change classifier.
Loaded from ``[tool.devx.classify]`` in ``pyproject.toml``.
Attributes:
infrastructure: Glob patterns for infrastructure paths.
infrastructure_overrides: Exact paths that are infrastructure
despite not matching any infrastructure pattern.
user_facing_overrides: Exact paths that are user-facing
despite matching an infrastructure pattern (safety override).
tags: Dict mapping tag name to list of glob patterns.
"""
infrastructure: list[str] = field(default_factory=list)
infrastructure_overrides: list[str] = field(default_factory=list)
user_facing_overrides: list[str] = field(default_factory=list)
tags: dict[str, list[str]] = field(default_factory=dict)
@classmethod
def from_pyproject(cls, pyproject_path: str = "pyproject.toml") -> ClassifierConfig:
"""Load classifier config from pyproject.toml.
Reads the ``[tool.devx.classify]`` section. If the section or
file is missing, returns a config with empty lists (everything
defaults to user-facing — safe-by-default).
"""
path = Path(pyproject_path)
if not path.exists():
return cls()
with open(path, "rb") as f: # noqa: PTH123
data: dict[str, Any] = tomllib.load(f)
classify_cfg = data.get("tool", {}).get("devx", {}).get("classify", {})
return cls(
infrastructure=list(classify_cfg.get("infrastructure", [])),
infrastructure_overrides=list(classify_cfg.get("infrastructure_overrides", [])),
user_facing_overrides=list(classify_cfg.get("user_facing_overrides", [])),
tags={k: list(v) for k, v in classify_cfg.get("tags", {}).items()},
)
class ChangeClassifier:
"""Classify changed files as user-facing or infrastructure.
Uses layered rules with safe-by-default semantics.
Rule evaluation order (first match wins):
1. User-facing overrides (safety — highest priority)
2. Infrastructure overrides
3. Infrastructure patterns
4. Default: user-facing (safe)
"""
def __init__(self, config: ClassifierConfig | None = None) -> None:
self.config = config or ClassifierConfig.from_pyproject()
# Pre-compile infrastructure patterns for efficiency
self._infra_patterns = list(self.config.infrastructure)
self._infra_overrides = set(self.config.infrastructure_overrides)
self._user_overrides = set(self.config.user_facing_overrides)
def classify_file(self, file_path: str) -> FileClassification:
"""Classify a single file path.
Returns a FileClassification with the decision and reason.
"""
tags = self._compute_tags(file_path)
# 1. User-facing overrides (highest priority — safety)
if file_path in self._user_overrides:
return FileClassification(
path=file_path,
is_user_facing=True,
reason="User-facing override (safety override)",
matched_rule="user_facing_overrides",
tags=tags,
)
# 2. Infrastructure overrides
if file_path in self._infra_overrides:
return FileClassification(
path=file_path,
is_user_facing=False,
reason="Infrastructure override (explicitly listed)",
matched_rule="infrastructure_overrides",
tags=tags,
)
# 3. Infrastructure patterns
for pattern in self._infra_patterns:
if _matches_glob(file_path, pattern):
return FileClassification(
path=file_path,
is_user_facing=False,
reason=f"Infrastructure (matches '{pattern}')",
matched_rule=f"infrastructure: {pattern}",
tags=tags,
)
# 4. Default: user-facing (safe-by-default)
return FileClassification(
path=file_path,
is_user_facing=True,
reason="User-facing (default — not in infrastructure patterns)",
matched_rule=None,
tags=tags,
)
def classify(self, files: list[str]) -> ClassificationResult:
"""Classify a list of changed files.
Returns a ClassificationResult with per-file details and
aggregated lists.
"""
result = ClassificationResult()
all_tags: dict[str, list[str]] = {}
for f in files:
fc = self.classify_file(f)
result.files.append(fc)
if fc.is_user_facing:
result.user_facing.append(f)
else:
result.infrastructure.append(f)
for tag in fc.tags:
all_tags.setdefault(tag, []).append(f)
result.tags = all_tags
return result
def _compute_tags(self, file_path: str) -> frozenset[str]:
"""Compute custom category tags for a file path."""
matched: set[str] = set()
for tag_name, patterns in self.config.tags.items():
for pattern in patterns:
if _matches_glob(file_path, pattern):
matched.add(tag_name)
break
return frozenset(matched)
# ---------------------------------------------------------------------------
# Git helpers
# ---------------------------------------------------------------------------
def run_git(args: list[str]) -> str:
@@ -126,63 +383,6 @@ def get_changed_files(base: str, head: str) -> list[str]:
return output.split("\n")
def is_workflow_only(
file_path: str,
patterns: frozenset[str] | None = None,
) -> bool:
"""Check if a file path is workflow-only (infrastructure, not the tool itself).
Uses an explicit allowlist — anything not in the list is treated as
user-facing (safe default that prevents accidental release skips).
"""
p = patterns if patterns is not None else DEFAULT_WORKFLOW_ONLY_PATTERNS
return any(file_path.startswith(pattern) or file_path == pattern for pattern in p)
def is_user_facing(
file_path: str,
patterns: frozenset[str] | None = None,
) -> bool:
"""Check if a file path is user-facing (affects the tool).
Inverse of is_workflow_only — anything not explicitly workflow-only
is treated as user-facing.
"""
return not is_workflow_only(file_path, patterns)
def classify_changes(
files: list[str],
patterns: frozenset[str] | None = None,
) -> dict[str, list[str]]:
"""Classify changed files into user-facing and workflow-only.
Returns a dict with keys "user_facing" and "workflow_only".
"""
user_facing: list[str] = []
workflow_only: list[str] = []
for f in files:
if is_user_facing(f, patterns):
user_facing.append(f)
else:
workflow_only.append(f)
return {"user_facing": user_facing, "workflow_only": workflow_only}
def has_user_facing_changes(
base: str,
head: str,
patterns: frozenset[str] | None = None,
) -> bool:
"""Check if any user-facing files changed between base and head.
Imported by ``devx.ci.release`` to decide whether a release
is needed. This is a cross-CI import that requires ``PYTHONPATH=.``.
"""
files = get_changed_files(base, head)
return any(is_user_facing(f, patterns) for f in files)
def get_latest_tag() -> str:
"""Get the latest git tag, or empty string if none exists."""
result = subprocess.run( # nosec B603 B607
@@ -196,10 +396,98 @@ def get_latest_tag() -> str:
return result.stdout.strip()
# ---------------------------------------------------------------------------
# Backward-compatible API (used by release.py and CI workflows)
# ---------------------------------------------------------------------------
# Singleton classifier — loaded lazily from pyproject.toml
_classifier: ChangeClassifier | None = None
def _get_classifier() -> ChangeClassifier:
"""Get or create the singleton classifier from pyproject.toml."""
global _classifier # noqa: PLW0603
if _classifier is None:
_classifier = ChangeClassifier()
return _classifier
def is_workflow_only(
file_path: str,
patterns: frozenset[str] | None = None,
) -> bool:
"""Check if a file path is infrastructure (not user-facing).
Backward-compatible API. Prefer ``ChangeClassifier.classify_file()``
for new code.
Args:
file_path: Path relative to repo root.
patterns: Deprecated. If provided, uses simple prefix matching
against these patterns instead of the config-driven classifier.
"""
if patterns is not None:
# Legacy mode — simple prefix matching
return any(file_path.startswith(p) or file_path == p for p in patterns)
return not _get_classifier().classify_file(file_path).is_user_facing
def is_user_facing(
file_path: str,
patterns: frozenset[str] | None = None,
) -> bool:
"""Check if a file path is user-facing (affects the released package).
Inverse of ``is_workflow_only()``.
"""
return not is_workflow_only(file_path, patterns)
def classify_changes(
files: list[str],
patterns: frozenset[str] | None = None,
) -> dict[str, list[str]]:
"""Classify changed files into user-facing and workflow-only.
Returns a dict with keys "user_facing" and "workflow_only".
"""
if patterns is not None:
# Legacy mode
user_facing: list[str] = []
workflow_only: list[str] = []
for f in files:
if is_user_facing(f, patterns):
user_facing.append(f)
else:
workflow_only.append(f)
return {"user_facing": user_facing, "workflow_only": workflow_only}
result = _get_classifier().classify(files)
return {"user_facing": result.user_facing, "workflow_only": result.infrastructure}
def has_user_facing_changes(
base: str,
head: str,
patterns: frozenset[str] | None = None,
) -> bool:
"""Check if any user-facing files changed between base and head.
Imported by ``devx.ci.release`` to decide whether a release is needed.
"""
files = get_changed_files(base, head)
if patterns is not None:
return any(is_user_facing(f, patterns) for f in files)
return _get_classifier().classify(files).has_user_facing
# ---------------------------------------------------------------------------
# Gitea Actions output
# ---------------------------------------------------------------------------
def _write_github_output(key: str, value: str) -> None:
"""Append a key=value line to the $GITHUB_OUTPUT file."""
import os
gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
@@ -207,6 +495,11 @@ def _write_github_output(key: str, value: str) -> None:
f.write(f"{key}={value}\n")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
@click.command()
@click.option("--base", default=None, help="Base ref (default: latest tag).")
@click.option("--head", default="HEAD", help="Head ref (default: HEAD).")
@@ -225,6 +518,9 @@ def _write_github_output(key: str, value: str) -> None:
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).",
)
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None:
"""Classify git changes and output results."""
classifier = _get_classifier()
if base is None:
base = get_latest_tag()
if not base:
@@ -252,18 +548,17 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
click.echo(_("No changes between {base} and {head}.", base=base, head=head))
return
result = classifier.classify(files)
if github_output:
ansible_files = [f for f in files if f.startswith("ansible/") or f == ".ansible-lint"]
user_files = [f for f in files if is_user_facing(f)]
_write_github_output("ansible-changed", "true" if ansible_files else "false")
_write_github_output("user-facing-changed", "true" if user_files else "false")
click.echo(f"Ansible files changed: {bool(ansible_files)}")
click.echo(f"User-facing files changed: {bool(user_files)}")
_write_github_output("ansible-changed", "true" if result.has_tag("ansible") else "false")
_write_github_output("user-facing-changed", "true" if result.has_user_facing else "false")
click.echo(f"Ansible files changed: {result.has_tag('ansible')}")
click.echo(f"User-facing files changed: {result.has_user_facing}")
return
if check == "ansible":
# Check only for Ansible-related file changes
ansible_files = [f for f in files if f.startswith("ansible/") or f == ".ansible-lint"]
ansible_files = result.tags.get("ansible", [])
has_ansible = bool(ansible_files)
if quiet:
click.echo("true" if has_ansible else "false")
@@ -275,8 +570,7 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
return
if check == "user-facing":
# Check only for user-facing file changes (inverse of workflow-only)
user_files = [f for f in files if is_user_facing(f)]
user_files = result.user_facing
has_user = bool(user_files)
if quiet:
click.echo("true" if has_user else "false")
@@ -289,19 +583,18 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
)
return
result = classify_changes(files)
has_user = bool(result["user_facing"])
has_user = result.has_user_facing
if quiet:
click.echo("true" if has_user else "false")
return
click.echo(_("Comparing {base}..{head} ({count} files changed)", base=base, head=head, count=len(files)))
click.echo(_("\nUser-facing changes ({count}):", count=len(result["user_facing"])))
for f in result["user_facing"]:
click.echo(_("\nUser-facing changes ({count}):", count=len(result.user_facing)))
for f in result.user_facing:
click.echo(f" {f}")
click.echo(_("\nWorkflow-only changes ({count}):", count=len(result["workflow_only"])))
for f in result["workflow_only"]:
click.echo(_("\nWorkflow-only changes ({count}):", count=len(result.infrastructure)))
for f in result.infrastructure:
click.echo(f" {f}")
if has_user:
status = "USER-FACING changes detected — release needed"