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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<token>`, `<digest>`), 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.


---
Expand Down
34 changes: 34 additions & 0 deletions docs/agent/tasks/task-248-pyproject-version-single-source.md
Original file line number Diff line number Diff line change
@@ -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**: [#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`

---

## 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`).
43 changes: 9 additions & 34 deletions src/devops_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 11 additions & 3 deletions src/devops_cli/commands/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -275,6 +277,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

Expand All @@ -287,8 +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:
# Dynamically derived from pyproject.toml
if "__version__" not in content:
return False
match = re.search(r'(__version__\s*=\s*["\'])[^"\']+(["\'])', content)
if not match:
# Dynamically derived from pyproject.toml (e.g. __version__ = get_version())
return True
Comment thread
dan-petty marked this conversation as resolved.
new_content, count = re.subn(
r'(__version__\s*=\s*["\'])[^"\']+(["\'])',
Expand Down
2 changes: 1 addition & 1 deletion src/devops_cli/config/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/devops_cli/telemetry/tracer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
55 changes: 55 additions & 0 deletions tests/test_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,61 @@ 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_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)])
Expand Down
Loading