diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 981ae6f..352c8f4 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -27,6 +27,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Setup Python uses: actions/setup-python@v6 diff --git a/.github/workflows/python-lint.yml b/.github/workflows/python-lint.yml index 15f7c48..aa81399 100644 --- a/.github/workflows/python-lint.yml +++ b/.github/workflows/python-lint.yml @@ -22,6 +22,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Setup Python uses: actions/setup-python@v6 diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index b31e4a1..2ca67c8 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -11,6 +11,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Create GitHub release uses: softprops/action-gh-release@v3 diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 5a3e90a..7202c85 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -52,6 +52,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Setup Python uses: actions/setup-python@v6 diff --git a/.github/workflows/python-typecheck.yml b/.github/workflows/python-typecheck.yml index 0d59764..7b68619 100644 --- a/.github/workflows/python-typecheck.yml +++ b/.github/workflows/python-typecheck.yml @@ -22,6 +22,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Setup Python uses: actions/setup-python@v6 diff --git a/.gitignore b/.gitignore index 3c29731..3c20409 100644 --- a/.gitignore +++ b/.gitignore @@ -222,3 +222,6 @@ sandbox/ site/ docs/architecture/ + +# hatch-vcs generated +src/haclient/_version.py diff --git a/pyproject.toml b/pyproject.toml index 100e93c..5e8e9e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["hatchling"] +requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" [project] name = "haclient" -version = "1.1.2" +dynamic = ["version"] description = "Async-first, high-level Python client for Home Assistant (REST + WebSocket)." readme = "README.md" license = { file = "LICENSE" } @@ -45,9 +45,18 @@ docs = [ [project.urls] Homepage = "https://github.com/graphras-com/HaClient" +[tool.hatch.version] +source = "vcs" + +[tool.hatch.build.hooks.vcs] +version-file = "src/haclient/_version.py" + [tool.hatch.build.targets.wheel] packages = ["src/haclient"] +[tool.hatch.build.targets.sdist] +include = ["src/haclient", "README.md", "LICENSE"] + [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] @@ -71,6 +80,7 @@ show_missing = true [tool.ruff] line-length = 100 target-version = "py311" +extend-exclude = ["src/haclient/_version.py"] [tool.ruff.lint] select = [ @@ -94,6 +104,7 @@ strict = true warn_unused_ignores = true disallow_untyped_defs = true ignore_missing_imports = false +exclude = ["src/haclient/_version\\.py$"] [[tool.mypy.overrides]] module = "tests.*" diff --git a/src/haclient/__init__.py b/src/haclient/__init__.py index d4b9853..a233dca 100644 --- a/src/haclient/__init__.py +++ b/src/haclient/__init__.py @@ -71,6 +71,9 @@ ] try: - __version__ = _pkg_version("haclient") -except PackageNotFoundError: # pragma: no cover - only hit when package not installed - __version__ = "0.0.0+unknown" + from haclient._version import __version__ +except ImportError: # pragma: no cover - fallback when _version.py is absent (editable/source) + try: + __version__ = _pkg_version("haclient") + except PackageNotFoundError: # pragma: no cover - only hit when package not installed + __version__ = "0.0.0+unknown" diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 0fd888f..374868e 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -1,26 +1,54 @@ """Packaging metadata tests. -Ensures the package version is single-sourced from installed metadata -(see issue #78). +Ensures the package version is single-sourced via ``hatch-vcs`` and not +hand-maintained in multiple places (see issue #78). """ from __future__ import annotations +import re from importlib.metadata import version as pkg_version +from pathlib import Path import haclient -def test_version_matches_package_metadata() -> None: - """``haclient.__version__`` must match installed package metadata. +def test_version_is_single_sourced_from_vcs() -> None: + """``haclient.__version__`` must come from the generated ``_version.py``. - This guards against the previous drift where ``pyproject.toml`` and - ``haclient/__init__.py`` declared different versions. + With ``hatch-vcs`` the version is derived from git tags at build time and + written into ``src/haclient/_version.py``. ``pyproject.toml`` must not + declare a static ``[project].version`` and ``__init__.py`` must not embed + a literal version string. """ - assert haclient.__version__ == pkg_version("haclient") + pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml" + contents = pyproject.read_text(encoding="utf-8") + assert 'dynamic = ["version"]' in contents + # No static ``version = "x.y.z"`` line under [project]. + assert '\nversion = "' not in contents + + init_file = Path(haclient.__file__) + init_text = init_file.read_text(encoding="utf-8") + # No hand-maintained release version literal (e.g. ``__version__ = "1.2.3"``). + assert not re.search(r'__version__\s*=\s*"\d+\.\d+\.\d+"', init_text) def test_version_is_non_empty_string() -> None: - """``__version__`` must be a non-empty string.""" + """``__version__`` must be a non-empty PEP 440-ish string.""" assert isinstance(haclient.__version__, str) assert haclient.__version__ + # Must at least start with a digit (PEP 440 release segment). + assert haclient.__version__[0].isdigit() + + +def test_installed_metadata_is_available() -> None: + """The package must expose a version via ``importlib.metadata``. + + This does not require equality with ``__version__`` because an editable + install records the version at install time while ``_version.py`` is + regenerated on every build; the two can legitimately differ between + rebuilds. We only assert that metadata is present and non-empty. + """ + meta_version = pkg_version("haclient") + assert isinstance(meta_version, str) + assert meta_version