From 93bdeacca4cddb931a8d6d19c50c0bdf9d51e2c0 Mon Sep 17 00:00:00 2001 From: Daniel Petty Date: Fri, 18 Sep 2026 15:16:34 +0000 Subject: [PATCH 1/3] refactor(core): use pyproject.toml as single source of truth for version (#248) --- ...ask-248-pyproject-version-single-source.md | 34 +++++++++++++++ src/devops_cli/__init__.py | 43 ++++--------------- src/devops_cli/commands/release.py | 6 ++- src/devops_cli/config/metadata.py | 2 +- src/devops_cli/telemetry/tracer.py | 2 +- tests/test_release.py | 39 +++++++++++++++++ 6 files changed, 89 insertions(+), 37 deletions(-) create mode 100644 docs/agent/tasks/task-248-pyproject-version-single-source.md diff --git a/docs/agent/tasks/task-248-pyproject-version-single-source.md b/docs/agent/tasks/task-248-pyproject-version-single-source.md new file mode 100644 index 00000000..326d1b9e --- /dev/null +++ b/docs/agent/tasks/task-248-pyproject-version-single-source.md @@ -0,0 +1,34 @@ +# Task 248: Use pyproject.toml as Single Source of Truth for Version + +**Issue**: [#248](https://github.com/dan-petty/devops-cli/issues/248) +**PR**: TBD +**Status**: In Progress +**Milestone**: `v0.2.20` +**Priority**: `priority/p1-high` +**Scope**: `scope/cli`, `scope/config` + +--- + +## 1. Description & Objectives + +The application version was duplicated in multiple locations across the codebase, notably hardcoded as `__version__ = "0.2.20"` in `src/devops_cli/__init__.py`, `version="0.1.11"` in `src/devops_cli/config/metadata.py`, and `return "0.1.0"` in `src/devops_cli/telemetry/tracer.py`. This required synchronized manual edits or release automation rewriting across multiple files on every version increment, risking divergence. + +This refactor establishes `pyproject.toml` as the single authoritative source of truth for the application version, with `src/devops_cli/__init__.py` dynamically loading the version via `devops_cli.config.metadata.get_version()`. + +#### Key Deliverables: +1. **Dynamic Version Initialization ([`src/devops_cli/__init__.py`](file:///workspaces/devops-cli/src/devops_cli/__init__.py))**: + - Initialize `__version__` from `get_version()` (derived from `pyproject.toml` with `load_project_metadata()`). + - Maintain public API exports (`__version__`, `get_version`, etc.). +2. **Release Engine Compatibility ([`src/devops_cli/commands/release.py`](file:///workspaces/devops-cli/src/devops_cli/commands/release.py))**: + - Ensure `_get_init_version` returns `pyproject.toml` version when `__init__.py` uses dynamic derivation. + - Ensure `_update_init_version` detects dynamic derivation and returns `True` without modifying `__init__.py`. + - Ensure `_update_pyproject_version` invalidates `load_project_metadata.cache_clear()` upon updating version. +3. **Clean Up Stale Version Fallbacks ([`src/devops_cli/config/metadata.py`](file:///workspaces/devops-cli/src/devops_cli/config/metadata.py), [`src/devops_cli/telemetry/tracer.py`](file:///workspaces/devops-cli/src/devops_cli/telemetry/tracer.py))**: + - Update `_DEFAULT_METADATA.version` in `metadata.py` to `"0.0.0"`. + - Update fallback in `tracer.py` to `"0.0.0"`. +4. **Test Suite Standardization ([`tests/test_instruction_generator.py`](file:///workspaces/devops-cli/tests/test_instruction_generator.py), [`tests/test_release.py`](file:///workspaces/devops-cli/tests/test_release.py))**: + - Replace hardcoded `version="0.2.20"` in `test_instruction_generator.py` with `__version__`. + - Add unit test verifying release commands properly handle dynamically versioned `__init__.py`. +5. **Quality & Architectural Invariant Gates**: + - Strictly enforce cyclomatic complexity $\le 10$ and nesting depth $\le 5$. + - 100% passing across all 10 CI quality gates (`uv run devops ci`). diff --git a/src/devops_cli/__init__.py b/src/devops_cli/__init__.py index 1557e16b..6fd6dc05 100644 --- a/src/devops_cli/__init__.py +++ b/src/devops_cli/__init__.py @@ -4,40 +4,15 @@ from typing import Any -__version__ = "0.2.20" - - -def get_version() -> str: - """Return the current package version string.""" - return __version__ - - -def load_project_metadata(pyproject_path: Any = None) -> Any: - """Load project metadata with pyproject.toml as authoritative source.""" - from devops_cli.config.metadata import load_project_metadata as _load - - return _load(pyproject_path) - - -def get_project_name() -> str: - """Return project name.""" - from devops_cli.config.metadata import get_project_name as _get - - return _get() - - -def get_project_description() -> str: - """Return project description.""" - from devops_cli.config.metadata import get_project_description as _get - - return _get() - - -def get_project_python_version() -> str: - """Return project Python version.""" - from devops_cli.config.metadata import get_project_python_version as _get - - return _get() +from devops_cli.config.metadata import ( + get_project_description, + get_project_name, + get_project_python_version, + get_version, + load_project_metadata, +) + +__version__ = get_version() def __getattr__(name: str) -> Any: diff --git a/src/devops_cli/commands/release.py b/src/devops_cli/commands/release.py index 8bd7cd8c..0e4ddd59 100644 --- a/src/devops_cli/commands/release.py +++ b/src/devops_cli/commands/release.py @@ -275,6 +275,9 @@ def _update_pyproject_version(root: Path, new_version: str) -> bool: ) if count > 0: write_text_file(pyproject_file, new_content) + from devops_cli.config.metadata import load_project_metadata + + load_project_metadata.cache_clear() return True return False @@ -287,7 +290,8 @@ def _update_init_version(root: Path, new_version: str) -> bool: if not init_file.exists(): return False content = init_file.read_text(encoding="utf-8") - if "__version__ = " not in content: + match = re.search(r'(__version__\s*=\s*["\'])[^"\']+(["\'])', content) + if not match: # Dynamically derived from pyproject.toml return True new_content, count = re.subn( diff --git a/src/devops_cli/config/metadata.py b/src/devops_cli/config/metadata.py index 48d04181..509a7bb2 100644 --- a/src/devops_cli/config/metadata.py +++ b/src/devops_cli/config/metadata.py @@ -28,7 +28,7 @@ class ProjectMetadata(BaseModel): _DEFAULT_METADATA = ProjectMetadata( name="devops-cli", - version="0.1.11", + version="0.0.0", description="DevOps CLI for managing repos, SSH keys, Kubernetes, and more", requires_python=">=3.14", python_version="3.14", diff --git a/src/devops_cli/telemetry/tracer.py b/src/devops_cli/telemetry/tracer.py index 9770c941..1e59b0ca 100644 --- a/src/devops_cli/telemetry/tracer.py +++ b/src/devops_cli/telemetry/tracer.py @@ -490,7 +490,7 @@ def _detect_version() -> str: return __version__ except Exception: - return "0.1.0" + return "0.0.0" def _get_resource_attributes(self) -> list[dict[str, Any]]: """Return standardized OpenTelemetry resource attributes.""" diff --git a/tests/test_release.py b/tests/test_release.py index 101124ca..ba0fbd1e 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -83,6 +83,45 @@ def test_update_versions(sample_project_dir: Path) -> None: assert _get_latest_changelog_version(sample_project_dir) == "0.1.8" +def test_dynamic_init_version_handling(tmp_path: Path) -> None: + """Verify release functions handle dynamic __version__ without overwriting __init__.py.""" + src_dir = tmp_path / "src" / "devops_cli" + src_dir.mkdir(parents=True) + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "devops-cli"\nversion = "0.2.0"\n', + encoding="utf-8", + ) + init_file = src_dir / "__init__.py" + init_code = ( + "from devops_cli.config.metadata import get_version\n\n__version__ = get_version()\n" + ) + init_file.write_text(init_code, encoding="utf-8") + + initial_init_ver = _get_init_version(tmp_path) + update_init_res = _update_init_version(tmp_path, "0.2.1") + init_content_after = init_file.read_text(encoding="utf-8") + + update_pyproject_res = _update_pyproject_version(tmp_path, "0.2.1") + pyproject_ver_after = _get_pyproject_version(tmp_path) + init_ver_after = _get_init_version(tmp_path) + + assert ( + initial_init_ver, + update_init_res, + init_content_after, + update_pyproject_res, + pyproject_ver_after, + init_ver_after, + ) == ( + "0.2.0", + True, + init_code, + True, + "0.2.1", + "0.2.1", + ) + + def test_release_status_command(sample_project_dir: Path) -> None: with patch("devops_cli.commands.release.DocGenerator.check_docs", return_value=(True, [])): result = runner.invoke(app, ["status", "--root", str(sample_project_dir)]) From 1c9826e11991a8973b25f4bbf2cd258b210554dd Mon Sep 17 00:00:00 2001 From: Daniel Petty Date: Fri, 18 Sep 2026 15:30:33 +0000 Subject: [PATCH 2/3] fix(release): require __version__ presence in init before dynamic fallback (#249) --- .../task-248-pyproject-version-single-source.md | 4 ++-- src/devops_cli/commands/release.py | 8 ++++++-- tests/test_release.py | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/agent/tasks/task-248-pyproject-version-single-source.md b/docs/agent/tasks/task-248-pyproject-version-single-source.md index 326d1b9e..857f9418 100644 --- a/docs/agent/tasks/task-248-pyproject-version-single-source.md +++ b/docs/agent/tasks/task-248-pyproject-version-single-source.md @@ -1,8 +1,8 @@ # Task 248: Use pyproject.toml as Single Source of Truth for Version **Issue**: [#248](https://github.com/dan-petty/devops-cli/issues/248) -**PR**: TBD -**Status**: In Progress +**PR**: [#249](https://github.com/dan-petty/devops-cli/pull/249) +**Status**: In Review **Milestone**: `v0.2.20` **Priority**: `priority/p1-high` **Scope**: `scope/cli`, `scope/config` diff --git a/src/devops_cli/commands/release.py b/src/devops_cli/commands/release.py index 0e4ddd59..ce387ca5 100644 --- a/src/devops_cli/commands/release.py +++ b/src/devops_cli/commands/release.py @@ -107,7 +107,9 @@ def _get_init_version(root: Path) -> str | None: match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content) if match: return match.group(1) - return _get_pyproject_version(root) + if "__version__" in content: + return _get_pyproject_version(root) + return None def _get_latest_git_tag(root: Path) -> str | None: @@ -290,9 +292,11 @@ def _update_init_version(root: Path, new_version: str) -> bool: if not init_file.exists(): return False content = init_file.read_text(encoding="utf-8") + if "__version__" not in content: + return False match = re.search(r'(__version__\s*=\s*["\'])[^"\']+(["\'])', content) if not match: - # Dynamically derived from pyproject.toml + # Dynamically derived from pyproject.toml (e.g. __version__ = get_version()) return True new_content, count = re.subn( r'(__version__\s*=\s*["\'])[^"\']+(["\'])', diff --git a/tests/test_release.py b/tests/test_release.py index ba0fbd1e..e72a0060 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -122,6 +122,22 @@ def test_dynamic_init_version_handling(tmp_path: Path) -> None: ) +def test_missing_init_version_handling(tmp_path: Path) -> None: + """Verify release functions return None/False when __init__.py lacks __version__.""" + src_dir = tmp_path / "src" / "devops_cli" + src_dir.mkdir(parents=True) + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "devops-cli"\nversion = "0.2.0"\n', + encoding="utf-8", + ) + init_file = src_dir / "__init__.py" + init_file.write_text('"""Package without version."""\n', encoding="utf-8") + + get_res = _get_init_version(tmp_path) + update_res = _update_init_version(tmp_path, "0.2.1") + assert (get_res, update_res) == (None, False) + + def test_release_status_command(sample_project_dir: Path) -> None: with patch("devops_cli.commands.release.DocGenerator.check_docs", return_value=(True, [])): result = runner.invoke(app, ["status", "--root", str(sample_project_dir)]) From 50e09679ee59a702b60887896008b638f5c8477c Mon Sep 17 00:00:00 2001 From: Daniel Petty Date: Fri, 18 Sep 2026 15:40:34 +0000 Subject: [PATCH 3/3] docs(roadmap): add universal subcommand option propagation task --- docs/ROADMAP.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ce914b8a..3ff3b306 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -54,6 +54,7 @@ High-density product roadmap, engineering milestones, and open-source integratio - [ ] **Proportional API Rate Budgeting & GraphQL Circuit Breaker Guard**: Proportional budget allocation per CLI command and automated circuit breaking when external API quota drops below 20%, preventing rapid quota exhaustion. - [ ] **Consolidated AI Review Report Markdown Sanitization & Code Block Hardening**: Systemic normalization and sanitization of `review.md` artifacts—smart detection of existing fenced code blocks (`Fix Recommendation`), automatic balancing of open code fences, escaping of raw placeholder angle brackets (``, ``), and robust theme extraction resilient to bracketed prefixes (`[DRY-RUN]`, `[GITLEAKS]`). - [ ] **Automated Parameter, Schema & CLI Interface Parity Oracle**: Static AST analyzer and runtime validator detecting missing or unpropagated CLI options, asymmetric parameter signatures, and schema discrepancies across Typer commands, FastMCP tools, and orchestrator APIs. +- [ ] **Universal Subcommand Option Propagation (`--dry-run` & `--explain`)**: Enable first-class trailing `--dry-run` across all mutating commands (e.g. `devops release prepare`, `devops repos sync`, `devops tf apply`) and uniform `--explain` option handling across diagnostic and analytical commands via `OTelTyper` context inheritance. ---