Skip to content

Commit 2d35d60

Browse files
committed
[feature] Support Python projects using pyproject.toml #706
Closes #706 - Detect pyproject.toml as package type python - Read version from [project] version field - Bump version using exact string replacement + regex fallback - Add tomli dependency for Python < 3.11 - Add tests for malformed TOML, non-numeric version, and priority - Add pragma: no cover to version-specific import branches
1 parent 595ddbf commit 2d35d60

5 files changed

Lines changed: 247 additions & 14 deletions

File tree

openwisp_utils/releaser/config.py

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44
import re
55
import subprocess
66

7+
try:
8+
import tomllib # pragma: no cover
9+
except ImportError: # pragma: no cover
10+
try:
11+
import tomli as tomllib # pragma: no cover
12+
except ImportError: # pragma: no cover
13+
tomllib = None # pragma: no cover
14+
715

816
def get_package_name_from_setup():
917
"""Parses setup.py to find the package name without raising an error."""
@@ -22,6 +30,7 @@ def get_package_type_from_setup():
2230
"""Detects package type based on config files present in the project."""
2331
package_type_files = {
2432
"setup.py": "python",
33+
"pyproject.toml": "python",
2534
"package.json": "npm",
2635
"docker-compose.yml": "docker",
2736
".ansible-lint": "ansible",
@@ -52,22 +61,49 @@ def detect_changelog_style(changelog_path):
5261
def _handle_python_version(config):
5362
"""Handles version detection for Python packages."""
5463
project_name = get_package_name_from_setup()
55-
if not project_name:
64+
if project_name:
65+
package_directory = project_name.replace("-", "_")
66+
init_py_path = os.path.join(package_directory, "__init__.py")
67+
if os.path.exists(init_py_path):
68+
with open(init_py_path, "r") as f:
69+
content = f.read()
70+
version_match = re.search(r"^VERSION\s*=\s*\((.*)\)", content, re.M)
71+
if version_match:
72+
config["version_path"] = init_py_path
73+
try:
74+
version_tuple = ast.literal_eval(f"({version_match.group(1)})")
75+
config["CURRENT_VERSION"] = list(version_tuple)
76+
except (ValueError, SyntaxError):
77+
config["CURRENT_VERSION"] = None
78+
return
79+
_handle_pyproject_toml_version(config)
80+
81+
82+
def _handle_pyproject_toml_version(config):
83+
"""Handles version detection from pyproject.toml."""
84+
if not os.path.exists("pyproject.toml"):
5685
return
57-
package_directory = project_name.replace("-", "_")
58-
init_py_path = os.path.join(package_directory, "__init__.py")
59-
if not os.path.exists(init_py_path):
86+
if tomllib is None:
87+
return # pragma: no cover
88+
with open("pyproject.toml", "rb") as f:
89+
try:
90+
data = tomllib.load(f)
91+
except Exception:
92+
return
93+
project = data.get("project", {})
94+
version_str = project.get("version")
95+
if not version_str:
6096
return
61-
with open(init_py_path, "r") as f:
62-
content = f.read()
63-
version_match = re.search(r"^VERSION\s*=\s*\((.*)\)", content, re.M)
64-
if version_match:
65-
config["version_path"] = init_py_path
66-
try:
67-
version_tuple = ast.literal_eval(f"({version_match.group(1)})")
68-
config["CURRENT_VERSION"] = list(version_tuple)
69-
except (ValueError, SyntaxError):
70-
config["CURRENT_VERSION"] = None
97+
try:
98+
parts = version_str.split(".")
99+
if len(parts) != 3:
100+
return
101+
current_version = [int(parts[0]), int(parts[1]), int(parts[2]), "final"]
102+
except (ValueError, TypeError):
103+
return
104+
config["package_type"] = "pyproject"
105+
config["version_path"] = "pyproject.toml"
106+
config["CURRENT_VERSION"] = current_version
71107

72108

73109
def _handle_npm_version(config):
@@ -194,6 +230,7 @@ def _handle_openwrt_version(config):
194230
# Maps package types to their version detection handlers
195231
PACKAGE_VERSION_HANDLERS = {
196232
"python": _handle_python_version,
233+
"pyproject": _handle_pyproject_toml_version,
197234
"npm": _handle_npm_version,
198235
"docker": _handle_docker_version,
199236
"ansible": _handle_ansible_version,

openwisp_utils/releaser/tests/test_config.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,3 +424,83 @@ def test_no_package_type_detected(project_dir, create_changelog, init_git_repo):
424424
assert config["package_type"] is None
425425
assert config["version_path"] is None
426426
assert config["CURRENT_VERSION"] is None
427+
428+
429+
def test_pyproject_toml_package_detection(project_dir, create_changelog, init_git_repo):
430+
"""Tests that python package type is detected when pyproject.toml exists."""
431+
(project_dir / "pyproject.toml").write_text(
432+
'[project]\nname = "my-package"\nversion = "1.2.3"\n'
433+
)
434+
create_changelog(project_dir)
435+
init_git_repo(project_dir)
436+
config = load_config()
437+
assert config["package_type"] == "pyproject"
438+
assert config["version_path"] == "pyproject.toml"
439+
assert config["CURRENT_VERSION"] == [1, 2, 3, "final"]
440+
441+
442+
def test_pyproject_toml_missing_version(project_dir, create_changelog, init_git_repo):
443+
"""Tests pyproject.toml without a version field."""
444+
(project_dir / "pyproject.toml").write_text('[project]\nname = "my-package"\n')
445+
create_changelog(project_dir)
446+
init_git_repo(project_dir)
447+
config = load_config()
448+
assert config["version_path"] is None
449+
assert config["CURRENT_VERSION"] is None
450+
451+
452+
def test_pyproject_toml_invalid_version(project_dir, create_changelog, init_git_repo):
453+
"""Tests pyproject.toml with an invalid version format."""
454+
(project_dir / "pyproject.toml").write_text(
455+
'[project]\nname = "my-package"\nversion = "1.2"\n'
456+
)
457+
create_changelog(project_dir)
458+
init_git_repo(project_dir)
459+
config = load_config()
460+
assert config["version_path"] is None
461+
assert config["CURRENT_VERSION"] is None
462+
463+
464+
def test_pyproject_toml_malformed(project_dir, create_changelog, init_git_repo):
465+
"""Tests pyproject.toml with malformed TOML content."""
466+
(project_dir / "pyproject.toml").write_text("@invalid toml\n")
467+
create_changelog(project_dir)
468+
init_git_repo(project_dir)
469+
config = load_config()
470+
assert config["version_path"] is None
471+
assert config["CURRENT_VERSION"] is None
472+
473+
474+
def test_pyproject_toml_non_numeric_version(
475+
project_dir, create_changelog, init_git_repo
476+
):
477+
"""Tests pyproject.toml with a non-numeric version component."""
478+
(project_dir / "pyproject.toml").write_text(
479+
'[project]\nname = "my-package"\nversion = "1.2.a"\n'
480+
)
481+
create_changelog(project_dir)
482+
init_git_repo(project_dir)
483+
config = load_config()
484+
assert config["version_path"] is None
485+
assert config["CURRENT_VERSION"] is None
486+
487+
488+
def test_setup_py_takes_priority_over_pyproject_toml(
489+
project_dir,
490+
create_setup_py,
491+
create_package_dir_with_version,
492+
create_changelog,
493+
init_git_repo,
494+
):
495+
"""Tests that setup.py takes priority over pyproject.toml."""
496+
create_setup_py(project_dir)
497+
create_package_dir_with_version(project_dir)
498+
(project_dir / "pyproject.toml").write_text(
499+
'[project]\nname = "my-package"\nversion = "9.9.9"\n'
500+
)
501+
create_changelog(project_dir)
502+
init_git_repo(project_dir)
503+
config = load_config()
504+
assert config["package_type"] == "python"
505+
assert config["version_path"] == "my_test_package/__init__.py"
506+
assert config["CURRENT_VERSION"] == [1, 2, 3, "final"]

openwisp_utils/releaser/tests/test_version_bumping.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,3 +368,86 @@ def test_bump_version_openwrt():
368368
assert result is True
369369
written_content = m_open().write.call_args[0][0]
370370
assert "1.2.4" in written_content
371+
372+
373+
PYPROJECT_TOML_CONTENT = """[build-system]
374+
requires = ["setuptools"]
375+
build-backend = "setuptools.backends._legacy:_Backend"
376+
377+
[project]
378+
name = "my-package"
379+
version = "1.2.3"
380+
description = "A test package"
381+
"""
382+
383+
PYPROJECT_TOML_BUMPED = """[build-system]
384+
requires = ["setuptools"]
385+
build-backend = "setuptools.backends._legacy:_Backend"
386+
387+
[project]
388+
name = "my-package"
389+
version = "1.2.4"
390+
description = "A test package"
391+
"""
392+
393+
394+
def test_get_current_version_pyproject():
395+
"""Tests getting current version from pyproject.toml."""
396+
config = {
397+
"package_type": "pyproject",
398+
"version_path": "pyproject.toml",
399+
"CURRENT_VERSION": [1, 2, 3, "final"],
400+
}
401+
version, version_type = get_current_version(config)
402+
assert version == "1.2.3"
403+
assert version_type == "final"
404+
405+
406+
def test_bump_version_pyproject_toml():
407+
"""Tests bumping version in pyproject.toml."""
408+
config = {
409+
"package_type": "pyproject",
410+
"version_path": "pyproject.toml",
411+
"CURRENT_VERSION": [1, 2, 3, "final"],
412+
}
413+
m_open = mock_open(read_data=PYPROJECT_TOML_CONTENT)
414+
with patch("os.path.exists", return_value=True), patch("builtins.open", m_open):
415+
result = bump_version(config, "1.2.4")
416+
assert result is True
417+
written_content = m_open().write.call_args[0][0]
418+
assert 'version = "1.2.4"' in written_content
419+
420+
421+
def test_bump_version_pyproject_toml_not_found():
422+
"""Tests error when version field is missing in pyproject.toml."""
423+
config = {
424+
"package_type": "pyproject",
425+
"version_path": "pyproject.toml",
426+
"CURRENT_VERSION": [1, 2, 3, "final"],
427+
}
428+
content_no_version = """[build-system]
429+
requires = ["setuptools"]
430+
build-backend = "setuptools.backends._legacy:_Backend"
431+
432+
[project]
433+
name = "my-package"
434+
description = "No version here"
435+
"""
436+
m_open = mock_open(read_data=content_no_version)
437+
with patch("os.path.exists", return_value=True), patch("builtins.open", m_open):
438+
with pytest.raises(RuntimeError, match="Failed to find"):
439+
bump_version(config, "1.2.4")
440+
441+
442+
def test_bump_version_pyproject_toml_malformed():
443+
"""Tests bumping with malformed TOML content (falls through to regex)."""
444+
config = {
445+
"package_type": "pyproject",
446+
"version_path": "pyproject.toml",
447+
"CURRENT_VERSION": [1, 2, 3, "final"],
448+
}
449+
malformed_content = "@invalid toml\n"
450+
m_open = mock_open(read_data=malformed_content)
451+
with patch("os.path.exists", return_value=True), patch("builtins.open", m_open):
452+
with pytest.raises(RuntimeError, match="Failed to find"):
453+
bump_version(config, "1.2.4")

openwisp_utils/releaser/version.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33
import subprocess
44
import sys
55

6+
try:
7+
import tomllib # pragma: no cover
8+
except ImportError: # pragma: no cover
9+
try:
10+
import tomli as tomllib # pragma: no cover
11+
except ImportError: # pragma: no cover
12+
tomllib = None # pragma: no cover
13+
614
import questionary
715

816

@@ -54,6 +62,29 @@ def _bump_python_version(content, new_version, version_path):
5462
)
5563

5664

65+
def _bump_pyproject_toml_version(content, new_version, version_path):
66+
"""Handles version bumping in pyproject.toml."""
67+
if tomllib is not None:
68+
try:
69+
data = tomllib.loads(content)
70+
except Exception:
71+
pass
72+
else:
73+
current = data.get("project", {}).get("version", "")
74+
if current:
75+
old = f'version = "{current}"'
76+
if old in content:
77+
return content.replace(old, f'version = "{new_version}"', 1)
78+
# fall through to regex if exact match not found
79+
return _bump_with_regex(
80+
content,
81+
r'^version\s*=\s*"([^"]+)"',
82+
f'version = "{new_version}"',
83+
version_path,
84+
"version in pyproject.toml",
85+
)
86+
87+
5788
def _bump_npm_version(content, new_version, version_path):
5889
"""Handles version bumping for NPM packages."""
5990
try:
@@ -119,6 +150,7 @@ def _bump_openwrt_version(content, new_version, version_path):
119150
# Maps package types to their version bump handlers
120151
VERSION_BUMP_HANDLERS = {
121152
"python": _bump_python_version,
153+
"pyproject": _bump_pyproject_toml_version,
122154
"npm": _bump_npm_version,
123155
"docker": _bump_docker_version,
124156
"ansible": _bump_ansible_version,

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
"questionary~=2.1.0",
7676
"pypandoc~=1.15",
7777
"pypandoc-binary~=1.15",
78+
"tomli>=1.1.0; python_version < '3.11'",
7879
],
7980
"github_actions": [
8081
"google-genai>=1.62.0,<3.0.0",

0 commit comments

Comments
 (0)