diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index f128051..746fd15 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -18,6 +18,10 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v5 + with: + # Full history: hatch-vcs derives the version from git tags — a + # shallow checkout would silently build a fallback version. + fetch-depth: 0 - uses: astral-sh/setup-uv@v6 - run: uv sync --python 3.13 --locked # Exercise the same packaging command as release.yml so MSI/WiX diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39e57f1..ad41b22 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,31 +15,19 @@ jobs: guard: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - name: Verify tag is SemVer and matches [project].version + - name: Verify tag is SemVer env: TAG_VERSION: ${{ github.ref_name }} run: | python3 - <<'PY' import os - import pathlib import re import sys - import tomllib tag = os.environ["TAG_VERSION"] if not re.fullmatch(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", tag): sys.exit(f"Tag {tag!r} is not SemVer (expected vMAJOR.MINOR.PATCH[...])") - - data = tomllib.loads( - pathlib.Path("pyproject.toml").read_text(encoding="utf-8") - ) - project = data["project"]["version"] - version = tag.removeprefix("v") - assert version == project, ( - f"Version mismatch: tag={version!r} pyproject={project!r}" - ) - print(f"Version guard OK: {version}") + print(f"Tag guard OK: {tag}") PY windows: @@ -47,6 +35,10 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v5 + with: + # Full history: hatch-vcs derives the version from the tag — a + # shallow checkout would silently build a fallback version. + fetch-depth: 0 - uses: astral-sh/setup-uv@v6 # Pin the sync interpreter: briefcase embeds the running Python's # major.minor into the bundle, and 3.15 (the newest interpreter @@ -66,6 +58,10 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v5 + with: + # Full history: hatch-vcs derives the version from the tag — a + # shallow checkout would silently build a fallback version. + fetch-depth: 0 - uses: astral-sh/setup-uv@v6 - run: uv sync --python 3.13 --locked # --no-input: CI has no TTY; without it briefcase prompts for a @@ -83,6 +79,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 + with: + # Full history: hatch-vcs derives the version from the tag — a + # shallow checkout would silently build a fallback version. + fetch-depth: 0 - uses: astral-sh/setup-uv@v6 # The deb (system) backend requires briefcase to run under the # system python3 — pin the sync interpreter to it. Briefcase 0.4.x diff --git a/.gitignore b/.gitignore index 6b6c6e8..72515c2 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,9 @@ wheels/ src/profiles.dist-info/ share/python-wheels/ *.egg-info/ + +# hatch-vcs generated version module (build-time artifact) +src/profiles/_version.py .installed.cfg *.egg MANIFEST diff --git a/docs/superpowers/plans/2026-11-05-hatch-vcs-versioning.md b/docs/superpowers/plans/2026-11-05-hatch-vcs-versioning.md new file mode 100644 index 0000000..828589e --- /dev/null +++ b/docs/superpowers/plans/2026-11-05-hatch-vcs-versioning.md @@ -0,0 +1,326 @@ +# hatch-vcs Tag-Derived Versioning Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the git tag the single source of truth for the version: no manual bump in `pyproject.toml`, lineage versions on non-tag builds, release flow (manual tag push) unchanged. + +**Architecture:** hatch-vcs build hook derives a PEP 440 version from `git describe` at build time and generates `src/profiles/_version.py`. `pyproject.toml` declares `dynamic = ["version"]`. Runtime `__version__` keeps `importlib.metadata` as primary and swaps its pyproject-regex fallback for the generated module. CI builds get full git history (`fetch-depth: 0`) so the tag is visible. + +**Tech Stack:** hatch-vcs (hatchling build hook wrapping setuptools-scm), GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-11-05-hatch-vcs-versioning-design.md` + +## Global Constraints + +- Build backend stays hatchling; `[tool.hatch.build.hooks.custom]` (web dist) is untouched and must keep working. +- Tag naming convention unchanged: `vMAJOR.MINOR.PATCH[-pre]`. setuptools-scm's default tag regex already matches `v`-prefixed tags — no `tag-pattern` config needed. +- Config lives entirely in `pyproject.toml`; no new config files. +- Manual tag push stays the release trigger; no Commitizen. +- Branch: `feat/hatch-vcs-versioning`, PR against `main`. +- Project quality gates per AGENTS.md: `ruff format .`, `ruff check .`, `pylint src/profiles`, `pyright src/profiles`, `pytest`. + +## Review Focus + +- **Git-less source tree** (e.g. GitHub source-zip download, no `.git`, no build hook run): `import profiles` must not crash and must return `0.0.0-dev` as last resort. Pinned by `test_version_last_resort_when_no_vcs_module` (Task 3). +- **Missing/corrupted package metadata** (dist-info unreadable): the existing swallow-all behavior is preserved — no exception escapes `import profiles`. Pinned by `test_version_uses_vcs_fallback_when_metadata_missing` (Task 3). +- **Shallow CI checkout** (`fetch-depth: 1`, the Actions default): builds would silently produce a fallback version instead of the tag version. Pinned by grep verification over both workflow files (Task 4). +- **Guard must still reject non-SemVer tags**: `v1.0`, `1.0.0`, `v1.0.0-extra+meta`-style malformed refs. Pinned by the kept regex in `release.yml` (Task 4). +- **Editable-install staleness** (the old stale-egg-info bug): a fresh `uv sync` must yield a version matching the current commit lineage. Pinned by the end-to-end `uv build` wheel-name check (Task 5). + +--- + +### Task 1: New version contract in tests + +Rewrite `test_version` first so the suite states the target contract and stays green before any config change (old version `0.11.0-rc.2` is valid PEP 440). + +**Files:** +- Modify: `tests/test_app.py:19-35` (`_canonical_version`, `TestPackageMetadata.test_version`) + +**Interfaces:** +- Produces: the version contract all later tasks satisfy — `profiles.__version__` is (a) a valid PEP 440 string, (b) equal to `profiles._version.version` whenever the hatch-vcs-generated module is importable. + +- [ ] **Step 1: Rewrite the version test** + +Replace `_canonical_version()` and `test_version` with: + +```python +def _vcs_version() -> str | None: + """Return the hatch-vcs generated version, or None if not built.""" + try: + from profiles._version import version + + return str(version) + except ImportError: + return None + + +class TestPackageMetadata: + """profiles.__init__ exports version, author, license.""" + + def test_version(self) -> None: + vcs = _vcs_version() + if vcs is not None: + assert profiles.__version__ == vcs + assert Version(profiles.__version__) # valid PEP 440 +``` + +with `from packaging.version import Version` added to the imports (packaging is a pytest dependency, always present). + +- [ ] **Step 2: Run test to verify it passes (PEP 440 branch, no `_version.py` yet)** + +Run: `uv run pytest tests/test_app.py::TestPackageMetadata::test_version -v` +Expected: PASS (old version `0.11.0-rc.2` is valid PEP 440; `_vcs_version()` returns None) + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_app.py +git commit -m "test: version contract — PEP 440 valid, matches generated _version.py" +``` + +### Task 2: pyproject.toml migration + .gitignore + pylint exclusion + +**Files:** +- Modify: `pyproject.toml:1-7` (build-system requires, project version), `pyproject.toml:77-79` (Briefcase comment), `pyproject.toml:142-155` (pylint config) +- Modify: `.gitignore` (near `*.egg-info/` at line 86) + +**Interfaces:** +- Produces: `src/profiles/_version.py` generated by the build hook, exposing `version` (module attribute, str). `[project].version` no longer exists. + +- [ ] **Step 1: Edit pyproject.toml** + +- `[build-system]` → `requires = ["hatchling", "hatch-vcs", "nodeenv>=1.8.0"]` +- `[project]`: delete `version = "0.11.0-rc.2"`, add `dynamic = ["version"]` +- Update the Briefcase comment block (lines 77–79) to say the **git tag** is the single source of truth, resolved by hatch-vcs at build time via PEP 621 metadata merge. +- Add alongside the existing hatch config: + +```toml +[tool.hatch.version] +source = "vcs" +fallback-version = "0.0.0-dev" + +[tool.hatch.build.hooks.vcs] +version-file = "src/profiles/_version.py" +``` + +- `[tool.pylint.MASTER]`: add `ignore-paths = ["src/profiles/_version.py"]` (pylint does not respect `.gitignore`; ruff does by default, so no ruff change). + +- [ ] **Step 2: Add `.gitignore` entry** + +Append near the egg-info line: + +``` +src/profiles/_version.py +``` + +- [ ] **Step 3: Regenerate the environment and verify the derived version** + +Run: +```bash +uv sync +uv run python -c "import profiles; print(profiles.__version__)" +``` +Expected: a lineage version like `0.11.1.dev3+g` (or `...+dirty`), and `src/profiles/_version.py` now exists. + +- [ ] **Step 4: Run the version test to verify it passes via the equality branch** + +Run: `uv run pytest tests/test_app.py::TestPackageMetadata::test_version -v` +Expected: PASS (equality branch now active) + +- [ ] **Step 5: Commit** + +```bash +git add pyproject.toml .gitignore +git commit -m "build: derive version from git tags via hatch-vcs" +``` + +(`src/profiles/_version.py` is gitignored — not committed.) + +### Task 3: Runtime fallback refactor in `__init__.py` + +**Files:** +- Modify: `src/profiles/__init__.py:10-56` (`_resolve_version`, module imports) +- Test: `tests/test_app.py` (extend `TestPackageMetadata`) + +**Interfaces:** +- Consumes: `profiles._version.version` (generated by Task 2's build hook). +- Produces: `_resolve_version() -> str` with unchanged signature and unchanged resolution order: `importlib.metadata` → generated `_version.py` → `"0.0.0-dev"`. + +- [ ] **Step 1: Write the failing fallback tests** + +Add to `TestPackageMetadata`: + +```python +def test_version_uses_vcs_fallback_when_metadata_missing( + self, monkeypatch: pytest.MonkeyPatch +) -> None: + def _missing(dist: str) -> str: + raise PackageNotFoundError(dist) + + monkeypatch.setattr("importlib.metadata.version", _missing) + assert profiles._resolve_version() == _vcs_version() + + +def test_version_last_resort_when_no_vcs_module(self, monkeypatch: pytest.MonkeyPatch) -> None: + def _missing(dist: str) -> str: + raise PackageNotFoundError(dist) + + monkeypatch.setattr("importlib.metadata.version", _missing) + monkeypatch.setitem(sys.modules, "profiles._version", None) + assert profiles._resolve_version() == "0.0.0-dev" +``` + +with `from importlib.metadata import PackageNotFoundError` in the test imports. (Setting `sys.modules["profiles._version"] = None` makes the `from profiles._version import …` raise `ImportError` — stdlib-guaranteed.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_app.py::TestPackageMetadata -v` +Expected: the two new tests FAIL (current fallback regexes `pyproject.toml`, whose version still exists but the patch path returns something else; and `0.0.0-dev` last resort not reached — the current code would return the pyproject value, not the sentinel). + +- [ ] **Step 3: Rewrite `_resolve_version`** + +New body — resolution order `importlib.metadata` → `profiles._version` → `"0.0.0-dev"`, same swallow-broad-errors behavior for the metadata stage: + +```python +def _resolve_version() -> str: + """Return the package version. + + Resolution order: installed package metadata (dist-info), the + hatch-vcs generated ``profiles._version`` module (editable/dev + builds), then a development sentinel for git-less source trees. + """ + from importlib.metadata import PackageNotFoundError, version + + try: + try: + return version("profiles-app") + except PackageNotFoundError: + return version("profiles") + except Exception: + pass + + try: + from profiles._version import version as vcs_version + except ImportError: + return "0.0.0-dev" + return str(vcs_version) +``` + +Docstring updated as shown (replaces the pyproject-parsing explanation). Remove the now-unused `from pathlib import Path` and `import re` module imports — verified used only by the old fallback. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_app.py -v` +Expected: PASS (whole file — `test_version` still green) + +- [ ] **Step 5: Commit** + +```bash +git add src/profiles/__init__.py tests/test_app.py +git commit -m "refactor: version fallback reads hatch-vcs generated module" +``` + +### Task 4: Workflow updates — guard simplification + full fetch depth + +**Files:** +- Modify: `.github/workflows/release.yml` (guard job lines 15–43; checkout steps at lines 49, 68, 85) +- Modify: `.github/workflows/build-windows.yml:20` (checkout) + +**Interfaces:** +- Produces: guard job that only validates tag format; every package-building checkout with `fetch-depth: 0`. + +- [ ] **Step 1: Simplify the guard job** + +Keep only the SemVer-format check — the pyproject comparison is deleted (tautological now). Drop the checkout (nothing is read from the repo anymore): + +```yaml + guard: + runs-on: ubuntu-latest + steps: + - name: Verify tag is SemVer + env: + TAG_VERSION: ${{ github.ref_name }} + run: | + python3 - <<'PY' + import os + import re + import sys + + tag = os.environ["TAG_VERSION"] + if not re.fullmatch(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", tag): + sys.exit(f"Tag {tag!r} is not SemVer (expected vMAJOR.MINOR.PATCH[...])") + print(f"Tag guard OK: {tag}") + PY +``` + +The regex is copied verbatim from the existing guard (Review Focus: malformed tags still rejected). + +- [ ] **Step 2: Add full fetch depth to every build checkout** + +Each remaining `actions/checkout@v5` in both files becomes: + +```yaml + - uses: actions/checkout@v5 + with: + fetch-depth: 0 +``` + +(3 in `release.yml`, 1 in `build-windows.yml`.) + +- [ ] **Step 3: Verify both files parse and the pins hold** + +Run: +```bash +python3 -c "import yaml, pathlib; [yaml.safe_load(p.read_text()) for p in pathlib.Path('.github/workflows').glob('*.yml')]; print('yaml ok')" +grep -c "fetch-depth: 0" .github/workflows/release.yml .github/workflows/build-windows.yml +``` +Expected: `yaml ok`; counts `3` and `1`. + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/release.yml .github/workflows/build-windows.yml +git commit -m "ci: tag-only version guard; fetch full history for hatch-vcs builds" +``` + +### Task 5: End-to-end verification, quality gates, PR + +**Files:** +- No source changes expected; PR creation. + +**Interfaces:** +- Consumes: everything above. + +- [ ] **Step 1: Build a wheel and verify the derived version end-to-end** + +Run: +```bash +uv build --wheel +ls dist/profiles_app-*.whl +python3 -m zipfile -l dist/profiles_app-*.whl | grep _version.py +rm dist/profiles_app-*.whl +``` +Expected: wheel filename contains the derived lineage version; the wheel contains `profiles/_version.py` (hatch-vcs includes it). `dist/` must be left clean of new artifacts. + +- [ ] **Step 2: Full quality battery** + +Run: +```bash +ruff format . +ruff check . +uv run pylint src/profiles +uv run pyright src/profiles +uv run pytest +``` +Expected: all green (pylint > 8.0, suite passes — baseline was 100% green on `main` @ 791f581). + +- [ ] **Step 3: Push and open the PR** + +```bash +git push -u origin feat/hatch-vcs-versioning +gh pr create --title "feat: tag-derived versioning via hatch-vcs" --body "Closes the versioning-pain goal: git tag becomes the single source of truth..." +``` + +PR body: summarize the spec (link `docs/superpowers/specs/2026-11-05-hatch-vcs-versioning-design.md`), note the `fetch-depth: 0` CI requirement, and flag that the **next release tag must be pushed manually as before** — no pyproject edit. + +No `AGENTS.md`/`tests/AGENTS.md` sync needed: no module added, moved, or removed; tests stay in their existing files. diff --git a/docs/superpowers/specs/2026-11-05-hatch-vcs-versioning-design.md b/docs/superpowers/specs/2026-11-05-hatch-vcs-versioning-design.md new file mode 100644 index 0000000..16291c1 --- /dev/null +++ b/docs/superpowers/specs/2026-11-05-hatch-vcs-versioning-design.md @@ -0,0 +1,100 @@ +# Design — hatch-vcs tag-derived versioning + +Date: 2026-11-05 · Status: awaiting approval · Size: small change + +## Intent & why + +Kill the general versioning pain: the manual version-bump edit in +`pyproject.toml`, the tag↔pyproject drift risk (currently defended by a CI +guard), the `0.0.0-dev` fallback ambiguity, and the stale-egg-info shadowing +bug class. Adopt **hatch-vcs**: the git tag becomes the single source of +truth, every build self-describes its exact commit, and the release flow +(manual tag push → pipeline) is unchanged by decision of the maintainer. + +## Goals + +- `git tag v0.12.0 && git push origin v0.12.0` is the only release act; zero + file edits per release. +- Non-tag builds report PEP 440 lineage versions, e.g. + `0.11.0rc2.post3+g791f581` (scheme configurable), `+dirty` when the tree is. +- Installed copies and diagnostics logs carry exact provenance. + +## Non-goals + +- No Commitizen / conventional-commit tag automation (explicitly declined for + now; layerable later). +- No change to the tag naming convention (`vMAJOR.MINOR.PATCH[-pre]` stays). +- No PyPI publishing pipeline change (if publishing ever happens, add + `local-scheme = "no-local-version"` then). + +## Constraints + +- Build backend stays hatchling; existing `[tool.hatch.build.hooks.custom]` + (web dist) must keep working — build hooks compose. +- Briefcase merges PEP 621 metadata; it reads the resolved version at build + time. Release installers are built on tag checkouts → clean version. +- `release.yml` currently guards tag↔pyproject equality; that check becomes + tautological and is removed. The SemVer-format check on the tag stays. + +## Approach + +**Chosen**: hatch-vcs (hatchling-native wrapper of setuptools-scm). Rejected +alternatives: GitVersion (branch-model tool, external binary, wrong ecosystem +for a trunk-based Python repo); setuptools-scm directly (would drag in a +setuptools frontend); uv-dynamic-versioning (extra plugin for no gain); +static version + `uv version`/Commitizen (keeps the two-artifact drift +surface we are trying to delete). + +Config lives entirely in `pyproject.toml` — no dedicated file: + +```toml +[build-system] +requires = ["hatchling", "hatch-vcs", "nodeenv>=1.8.0"] + +[project] +name = "profiles-app" +dynamic = ["version"] + +[tool.hatch.version] +source = "vcs" + +[tool.hatch.build.hooks.vcs] +version-file = "src/profiles/_version.py" +``` + +## Touch points + +1. `pyproject.toml` — `version = "..."` → `dynamic = ["version"]` + the two + `[tool.hatch.*]` tables above. +2. `.gitignore` — add `src/profiles/_version.py` (generated at build time). +3. `src/profiles/__init__.py` — `_resolve_version()` keeps + `importlib.metadata` as primary; the pyproject-regex fallback is replaced + by importing the generated `profiles._version` (guarded); `0.0.0-dev` + stays as last resort for git-less source trees without a built hook. +4. `.github/workflows/release.yml` — drop the tag↔pyproject match check from + the guard job (keep the SemVer-format check); add `fetch-depth: 0` to + **every** build job checkout so the tag is visible at build time (classic + hatch-vcs CI trap — without it artifacts silently build a fallback + version). Same for any other workflow that builds the package. +5. `tests/test_app.py` — `_canonical_version()` reads `pyproject["project"] + ["version"]`, which no longer exists. Rewrite: assert `profiles.__version__` + is a valid PEP 440 string and matches the generated `_version.py` when + present. `tests/core/config/test_models.py` consumes `__version__` + dynamically — no change. +6. `src/profiles/core/config/models.py`, `desktop/lifecycle.py`, + `server/routes/instance.py` — read `__version__` only; no change. + +## Verification + +- `uv sync` then `python -c "import profiles; print(profiles.__version__)"` + in a clean clone → lineage version derived from the last tag. +- Editable reinstall per commit updates the version (stale-metadata bug gone). +- CI green on a PR touching this; release flow exercised on the next real + tag (manual push, as decided). + +## Left to the builder + +- Exact `version_scheme` (default `guess-next-dev`) — default is fine unless + review says otherwise. +- Whether other workflows (`build-windows.yml`) check out shallow and build + the package — audit and apply `fetch-depth: 0` as needed. diff --git a/pyproject.toml b/pyproject.toml index aa96837..e247bf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["hatchling", "nodeenv>=1.8.0"] +requires = ["hatchling", "hatch-vcs", "nodeenv>=1.8.0"] build-backend = "hatchling.build" [project] name = "profiles-app" -version = "0.11.0-rc.2" +dynamic = ["version"] description = "ProFiles - A modern GUI launcher for production test programs" readme = "README.md" requires-python = ">=3.11" @@ -74,9 +74,16 @@ artifacts = ["src/profiles/web/dist"] # ============================================ # Briefcase — native installer config # ============================================ -# Note: the app version is deliberately NOT repeated here. Briefcase -# merges PEP 621 metadata (merge_pep621_config in briefcase.config), so -# [project].version above is the single source of truth for installers. +# Note: the app version is deliberately NOT repeated here. The git tag is +# the single source of truth: hatch-vcs derives the version from it at +# build time, and Briefcase picks it up via the PEP 621 metadata merge +# (merge_pep621_config in briefcase.config). +[tool.hatch.version] +source = "vcs" +fallback-version = "0.0.0-dev" + +[tool.hatch.build.hooks.vcs] +version-file = "src/profiles/_version.py" [tool.briefcase] project_name = "ProFiles" bundle = "com.github.falbany" @@ -141,6 +148,7 @@ camera = "ProFiles uses the camera to scan configuration barcodes and QR codes." [tool.pylint.MASTER] init-import = "yes" +ignore-paths = ["src/profiles/_version.py"] max-line-length = 100 output-format = "colorized" reports = false diff --git a/src/profiles/__init__.py b/src/profiles/__init__.py index 26c95dc..0fd64d9 100644 --- a/src/profiles/__init__.py +++ b/src/profiles/__init__.py @@ -6,9 +6,6 @@ from __future__ import annotations -import re -from pathlib import Path - __author__ = "Florent ALBANY" __license__ = "MIT" @@ -16,14 +13,26 @@ def _resolve_version() -> str: """Return the package version. - Source of truth is ``pyproject.toml`` at the repo root. We first try - ``importlib.metadata`` for installed packages, then fall back to parsing - ``pyproject.toml`` for editable installs or development environments. + Resolution order: the hatch-vcs generated ``profiles._version`` + module (regenerated by every build and editable install, so it + always matches the current tree), then installed package metadata + (dist-info, for legacy installs without a version module), then a + development sentinel for git-less source trees. """ - # Primary: use importlib.metadata for installed packages. The PEP 621 - # distribution name is "profiles-app"; "profiles" covers legacy - # setuptools installs. The import stays function-local (importlib.metadata - # is stdlib on every supported Python) to keep ``import profiles`` cheap. + # The version module is regenerated by the build hook on every + # build/editable install — it is never stale relative to the tree, + # unlike dist-info which only updates on (re)install. + try: + from profiles._version import version as vcs_version + except ImportError: + pass + else: + return str(vcs_version) + + # Fallback: installed package metadata. The PEP 621 distribution name + # is "profiles-app"; "profiles" covers legacy setuptools installs. The + # import stays function-local (importlib.metadata is stdlib on every + # supported Python) to keep ``import profiles`` cheap. from importlib.metadata import PackageNotFoundError, version try: @@ -31,25 +40,11 @@ def _resolve_version() -> str: return version("profiles-app") except PackageNotFoundError: return version("profiles") - except PackageNotFoundError: - # Package not installed via pip, fall back to pyproject.toml - pass except Exception: - # Any other error (permissions, corrupted install, etc.) + # Not installed via pip, or metadata unreadable (permissions, + # corrupted install, etc.) pass - # Fallback: parse pyproject.toml for editable installs - pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml" - if pyproject.exists(): - match = re.search( - r'^version\s*=\s*"([^"]+)"', - pyproject.read_text(encoding="utf-8"), - re.MULTILINE, - ) - if match: - return match.group(1) - - # Last resort: return a development version string return "0.0.0-dev" diff --git a/tests/test_app.py b/tests/test_app.py index e77e0dc..cd0bb57 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -6,10 +6,11 @@ from __future__ import annotations import sys -import tomllib +from importlib.metadata import PackageNotFoundError from pathlib import Path import pytest +from packaging.version import Version import profiles from profiles.app import ProFileApp, init_default_config, main @@ -21,18 +22,39 @@ # ── Package metadata (__init__.py) ────────────────────────────────────────── -def _canonical_version() -> str: - """Return [project].version — the single source of truth.""" - root = Path(__file__).resolve().parents[1] - pyproject = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) - return pyproject["project"]["version"] +def _vcs_version() -> str | None: + """Return the hatch-vcs generated version, or None if not built.""" + try: + from profiles._version import version + + return str(version) + except ImportError: + return None class TestPackageMetadata: """profiles.__init__ exports version, author, license.""" def test_version(self) -> None: - assert profiles.__version__ == _canonical_version() + vcs = _vcs_version() + if vcs is not None: + assert profiles.__version__ == vcs + assert Version(profiles.__version__) # valid PEP 440 + + def test_version_uses_metadata_when_no_vcs_module( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setitem(sys.modules, "profiles._version", None) + monkeypatch.setattr("importlib.metadata.version", lambda dist: "9.9.9-metadata") + assert profiles._resolve_version() == "9.9.9-metadata" + + def test_version_last_resort_when_no_vcs_module(self, monkeypatch: pytest.MonkeyPatch) -> None: + def _missing(dist: str) -> str: + raise PackageNotFoundError(dist) + + monkeypatch.setattr("importlib.metadata.version", _missing) + monkeypatch.setitem(sys.modules, "profiles._version", None) + assert profiles._resolve_version() == "0.0.0-dev" def test_author(self) -> None: assert profiles.__author__ == "Florent ALBANY" diff --git a/uv.lock b/uv.lock index 449de23..87e5035 100644 --- a/uv.lock +++ b/uv.lock @@ -1290,7 +1290,6 @@ wheels = [ [[package]] name = "profiles-app" -version = "0.11.0rc2" source = { editable = "." } dependencies = [ { name = "fastapi" },