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
2 changes: 2 additions & 0 deletions .github/workflows/python-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Setup Python
uses: actions/setup-python@v6
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/python-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Setup Python
uses: actions/setup-python@v6
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/python-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Setup Python
uses: actions/setup-python@v6
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/python-typecheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Setup Python
uses: actions/setup-python@v6
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,6 @@ sandbox/
site/
docs/architecture/


# hatch-vcs generated
src/haclient/_version.py
15 changes: 13 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down Expand Up @@ -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"]
Expand All @@ -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 = [
Expand All @@ -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.*"
Expand Down
9 changes: 6 additions & 3 deletions src/haclient/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
44 changes: 36 additions & 8 deletions tests/test_packaging.py
Original file line number Diff line number Diff line change
@@ -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
Loading