Skip to content

Commit c3eb86e

Browse files
authored
Merge pull request #98 from graphras-com/chore/dynamic-versioning
chore: switch to hatch-vcs for tag-driven dynamic versioning
2 parents 40a987f + dcafbae commit c3eb86e

9 files changed

Lines changed: 68 additions & 13 deletions

File tree

.github/workflows/python-ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ jobs:
2727
steps:
2828
- name: Checkout
2929
uses: actions/checkout@v6
30+
with:
31+
fetch-depth: 0
3032

3133
- name: Setup Python
3234
uses: actions/setup-python@v6

.github/workflows/python-lint.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ jobs:
2222
steps:
2323
- name: Checkout
2424
uses: actions/checkout@v6
25+
with:
26+
fetch-depth: 0
2527

2628
- name: Setup Python
2729
uses: actions/setup-python@v6

.github/workflows/python-release.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ jobs:
1111
steps:
1212
- name: Checkout
1313
uses: actions/checkout@v6
14+
with:
15+
fetch-depth: 0
1416

1517
- name: Create GitHub release
1618
uses: softprops/action-gh-release@v3

.github/workflows/python-tests.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ jobs:
5252
steps:
5353
- name: Checkout
5454
uses: actions/checkout@v6
55+
with:
56+
fetch-depth: 0
5557

5658
- name: Setup Python
5759
uses: actions/setup-python@v6

.github/workflows/python-typecheck.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ jobs:
2222
steps:
2323
- name: Checkout
2424
uses: actions/checkout@v6
25+
with:
26+
fetch-depth: 0
2527

2628
- name: Setup Python
2729
uses: actions/setup-python@v6

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,3 +222,6 @@ sandbox/
222222
site/
223223
docs/architecture/
224224

225+
226+
# hatch-vcs generated
227+
src/haclient/_version.py

pyproject.toml

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
[build-system]
2-
requires = ["hatchling"]
2+
requires = ["hatchling", "hatch-vcs"]
33
build-backend = "hatchling.build"
44

55
[project]
66
name = "haclient"
7-
version = "1.1.2"
7+
dynamic = ["version"]
88
description = "Async-first, high-level Python client for Home Assistant (REST + WebSocket)."
99
readme = "README.md"
1010
license = { file = "LICENSE" }
@@ -45,9 +45,18 @@ docs = [
4545
[project.urls]
4646
Homepage = "https://github.com/graphras-com/HaClient"
4747

48+
[tool.hatch.version]
49+
source = "vcs"
50+
51+
[tool.hatch.build.hooks.vcs]
52+
version-file = "src/haclient/_version.py"
53+
4854
[tool.hatch.build.targets.wheel]
4955
packages = ["src/haclient"]
5056

57+
[tool.hatch.build.targets.sdist]
58+
include = ["src/haclient", "README.md", "LICENSE"]
59+
5160
[tool.pytest.ini_options]
5261
asyncio_mode = "auto"
5362
testpaths = ["tests"]
@@ -71,6 +80,7 @@ show_missing = true
7180
[tool.ruff]
7281
line-length = 100
7382
target-version = "py311"
83+
extend-exclude = ["src/haclient/_version.py"]
7484

7585
[tool.ruff.lint]
7686
select = [
@@ -94,6 +104,7 @@ strict = true
94104
warn_unused_ignores = true
95105
disallow_untyped_defs = true
96106
ignore_missing_imports = false
107+
exclude = ["src/haclient/_version\\.py$"]
97108

98109
[[tool.mypy.overrides]]
99110
module = "tests.*"

src/haclient/__init__.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@
7171
]
7272

7373
try:
74-
__version__ = _pkg_version("haclient")
75-
except PackageNotFoundError: # pragma: no cover - only hit when package not installed
76-
__version__ = "0.0.0+unknown"
74+
from haclient._version import __version__
75+
except ImportError: # pragma: no cover - fallback when _version.py is absent (editable/source)
76+
try:
77+
__version__ = _pkg_version("haclient")
78+
except PackageNotFoundError: # pragma: no cover - only hit when package not installed
79+
__version__ = "0.0.0+unknown"

tests/test_packaging.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,54 @@
11
"""Packaging metadata tests.
22
3-
Ensures the package version is single-sourced from installed metadata
4-
(see issue #78).
3+
Ensures the package version is single-sourced via ``hatch-vcs`` and not
4+
hand-maintained in multiple places (see issue #78).
55
"""
66

77
from __future__ import annotations
88

9+
import re
910
from importlib.metadata import version as pkg_version
11+
from pathlib import Path
1012

1113
import haclient
1214

1315

14-
def test_version_matches_package_metadata() -> None:
15-
"""``haclient.__version__`` must match installed package metadata.
16+
def test_version_is_single_sourced_from_vcs() -> None:
17+
"""``haclient.__version__`` must come from the generated ``_version.py``.
1618
17-
This guards against the previous drift where ``pyproject.toml`` and
18-
``haclient/__init__.py`` declared different versions.
19+
With ``hatch-vcs`` the version is derived from git tags at build time and
20+
written into ``src/haclient/_version.py``. ``pyproject.toml`` must not
21+
declare a static ``[project].version`` and ``__init__.py`` must not embed
22+
a literal version string.
1923
"""
20-
assert haclient.__version__ == pkg_version("haclient")
24+
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
25+
contents = pyproject.read_text(encoding="utf-8")
26+
assert 'dynamic = ["version"]' in contents
27+
# No static ``version = "x.y.z"`` line under [project].
28+
assert '\nversion = "' not in contents
29+
30+
init_file = Path(haclient.__file__)
31+
init_text = init_file.read_text(encoding="utf-8")
32+
# No hand-maintained release version literal (e.g. ``__version__ = "1.2.3"``).
33+
assert not re.search(r'__version__\s*=\s*"\d+\.\d+\.\d+"', init_text)
2134

2235

2336
def test_version_is_non_empty_string() -> None:
24-
"""``__version__`` must be a non-empty string."""
37+
"""``__version__`` must be a non-empty PEP 440-ish string."""
2538
assert isinstance(haclient.__version__, str)
2639
assert haclient.__version__
40+
# Must at least start with a digit (PEP 440 release segment).
41+
assert haclient.__version__[0].isdigit()
42+
43+
44+
def test_installed_metadata_is_available() -> None:
45+
"""The package must expose a version via ``importlib.metadata``.
46+
47+
This does not require equality with ``__version__`` because an editable
48+
install records the version at install time while ``_version.py`` is
49+
regenerated on every build; the two can legitimately differ between
50+
rebuilds. We only assert that metadata is present and non-empty.
51+
"""
52+
meta_version = pkg_version("haclient")
53+
assert isinstance(meta_version, str)
54+
assert meta_version

0 commit comments

Comments
 (0)