Skip to content

Commit ddf7024

Browse files
committed
[feature] Support Python projects using pyproject.toml #706
Added support for pyproject.toml as a package type indicator and version source for the releaser tool. Changes: - Detect pyproject.toml as a Python package marker (setup.py still takes priority) - Read version from [project] version field in pyproject.toml (PEP 621) - Bump version by updating the version string in pyproject.toml - Graceful fallback when tomllib is not available (Python <3.11) - Added tests for detection, version reading, version bumping, and priority
1 parent 595ddbf commit ddf7024

4 files changed

Lines changed: 221 additions & 14 deletions

File tree

openwisp_utils/releaser/config.py

Lines changed: 57 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
9+
except ImportError:
10+
try:
11+
import tomli as tomllib
12+
except ImportError:
13+
tomllib = None
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,55 @@ 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(
75+
f"({version_match.group(1)})"
76+
)
77+
config["CURRENT_VERSION"] = list(version_tuple)
78+
except (ValueError, SyntaxError):
79+
config["CURRENT_VERSION"] = None
80+
return
81+
_handle_pyproject_toml_version(config)
82+
83+
84+
def _handle_pyproject_toml_version(config):
85+
"""Handles version detection from pyproject.toml."""
86+
if not os.path.exists("pyproject.toml"):
5687
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):
88+
if tomllib is None:
6089
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
90+
with open("pyproject.toml", "rb") as f:
91+
try:
92+
data = tomllib.load(f)
93+
except Exception:
94+
return
95+
project = data.get("project", {})
96+
version_str = project.get("version")
97+
if not version_str:
98+
return
99+
try:
100+
parts = version_str.split(".")
101+
if len(parts) != 3:
102+
return
103+
config["package_type"] = "pyproject"
104+
config["version_path"] = "pyproject.toml"
105+
config["CURRENT_VERSION"] = [
106+
int(parts[0]),
107+
int(parts[1]),
108+
int(parts[2]),
109+
"final",
110+
]
111+
except (ValueError, TypeError):
112+
config["CURRENT_VERSION"] = None
71113

72114

73115
def _handle_npm_version(config):
@@ -194,6 +236,7 @@ def _handle_openwrt_version(config):
194236
# Maps package types to their version detection handlers
195237
PACKAGE_VERSION_HANDLERS = {
196238
"python": _handle_python_version,
239+
"pyproject": _handle_pyproject_toml_version,
197240
"npm": _handle_npm_version,
198241
"docker": _handle_docker_version,
199242
"ansible": _handle_ansible_version,

openwisp_utils/releaser/tests/test_config.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,3 +424,66 @@ 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+
# pyproject.toml Package Tests
430+
def test_pyproject_toml_package_detection(
431+
project_dir, create_changelog, init_git_repo
432+
):
433+
"""Tests that python package type is detected when pyproject.toml exists."""
434+
(project_dir / "pyproject.toml").write_text(
435+
'[project]\nname = "my-package"\nversion = "1.2.3"\n'
436+
)
437+
create_changelog(project_dir)
438+
init_git_repo(project_dir)
439+
config = load_config()
440+
assert config["package_type"] == "pyproject"
441+
assert config["version_path"] == "pyproject.toml"
442+
assert config["CURRENT_VERSION"] == [1, 2, 3, "final"]
443+
444+
445+
def test_pyproject_toml_missing_version(
446+
project_dir, create_changelog, init_git_repo
447+
):
448+
"""Tests pyproject.toml without a version field."""
449+
(project_dir / "pyproject.toml").write_text('[project]\nname = "my-package"\n')
450+
create_changelog(project_dir)
451+
init_git_repo(project_dir)
452+
config = load_config()
453+
assert config["version_path"] is None
454+
assert config["CURRENT_VERSION"] is None
455+
456+
457+
def test_pyproject_toml_invalid_version(
458+
project_dir, create_changelog, init_git_repo
459+
):
460+
"""Tests pyproject.toml with an invalid version format."""
461+
(project_dir / "pyproject.toml").write_text(
462+
'[project]\nname = "my-package"\nversion = "1.2"\n'
463+
)
464+
create_changelog(project_dir)
465+
init_git_repo(project_dir)
466+
config = load_config()
467+
assert config["version_path"] is None
468+
assert config["CURRENT_VERSION"] is None
469+
470+
471+
def test_setup_py_takes_priority_over_pyproject_toml(
472+
project_dir,
473+
create_setup_py,
474+
create_package_dir_with_version,
475+
create_changelog,
476+
init_git_repo,
477+
):
478+
"""Tests that setup.py takes priority over pyproject.toml."""
479+
create_setup_py(project_dir)
480+
create_package_dir_with_version(project_dir)
481+
(project_dir / "pyproject.toml").write_text(
482+
'[project]\nname = "my-package"\nversion = "9.9.9"\n'
483+
)
484+
create_changelog(project_dir)
485+
init_git_repo(project_dir)
486+
config = load_config()
487+
assert config["package_type"] == "python"
488+
assert config["version_path"] == "my_test_package/__init__.py"
489+
assert config["CURRENT_VERSION"] == [1, 2, 3, "final"]

openwisp_utils/releaser/tests/test_version_bumping.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,3 +368,73 @@ 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 Package Version Tests
374+
PYPROJECT_TOML_CONTENT = """[build-system]
375+
requires = ["setuptools"]
376+
build-backend = "setuptools.backends._legacy:_Backend"
377+
378+
[project]
379+
name = "my-package"
380+
version = "1.2.3"
381+
description = "A test package"
382+
"""
383+
384+
PYPROJECT_TOML_BUMPED = """[build-system]
385+
requires = ["setuptools"]
386+
build-backend = "setuptools.backends._legacy:_Backend"
387+
388+
[project]
389+
name = "my-package"
390+
version = "1.2.4"
391+
description = "A test package"
392+
"""
393+
394+
395+
def test_get_current_version_pyproject():
396+
"""Tests getting current version from pyproject.toml."""
397+
config = {
398+
"package_type": "pyproject",
399+
"version_path": "pyproject.toml",
400+
"CURRENT_VERSION": [1, 2, 3, "final"],
401+
}
402+
version, version_type = get_current_version(config)
403+
assert version == "1.2.3"
404+
assert version_type == "final"
405+
406+
407+
def test_bump_version_pyproject_toml():
408+
"""Tests bumping version in pyproject.toml."""
409+
config = {
410+
"package_type": "pyproject",
411+
"version_path": "pyproject.toml",
412+
"CURRENT_VERSION": [1, 2, 3, "final"],
413+
}
414+
m_open = mock_open(read_data=PYPROJECT_TOML_CONTENT)
415+
with patch("os.path.exists", return_value=True), patch("builtins.open", m_open):
416+
result = bump_version(config, "1.2.4")
417+
assert result is True
418+
written_content = m_open().write.call_args[0][0]
419+
assert 'version = "1.2.4"' in written_content
420+
421+
422+
def test_bump_version_pyproject_toml_not_found():
423+
"""Tests error when version field is missing in pyproject.toml."""
424+
config = {
425+
"package_type": "pyproject",
426+
"version_path": "pyproject.toml",
427+
"CURRENT_VERSION": [1, 2, 3, "final"],
428+
}
429+
content_no_version = """[build-system]
430+
requires = ["setuptools"]
431+
build-backend = "setuptools.backends._legacy:_Backend"
432+
433+
[project]
434+
name = "my-package"
435+
description = "No version here"
436+
"""
437+
m_open = mock_open(read_data=content_no_version)
438+
with patch("os.path.exists", return_value=True), patch("builtins.open", m_open):
439+
with pytest.raises(RuntimeError, match="Failed to find"):
440+
bump_version(config, "1.2.4")

openwisp_utils/releaser/version.py

Lines changed: 31 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
8+
except ImportError:
9+
try:
10+
import tomli as tomllib
11+
except ImportError:
12+
tomllib = None
13+
614
import questionary
715

816

@@ -54,6 +62,28 @@ 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+
return content.replace(
76+
f'version = "{current}"', f'version = "{new_version}"'
77+
)
78+
return _bump_with_regex(
79+
content,
80+
r'^version\s*=\s*"([^"]+)"',
81+
f'version = "{new_version}"',
82+
version_path,
83+
"version in pyproject.toml",
84+
)
85+
86+
5787
def _bump_npm_version(content, new_version, version_path):
5888
"""Handles version bumping for NPM packages."""
5989
try:
@@ -119,6 +149,7 @@ def _bump_openwrt_version(content, new_version, version_path):
119149
# Maps package types to their version bump handlers
120150
VERSION_BUMP_HANDLERS = {
121151
"python": _bump_python_version,
152+
"pyproject": _bump_pyproject_toml_version,
122153
"npm": _bump_npm_version,
123154
"docker": _bump_docker_version,
124155
"ansible": _bump_ansible_version,

0 commit comments

Comments
 (0)