From 2d35d609110a9f0038d2b2f743ffa710bdf05c91 Mon Sep 17 00:00:00 2001 From: BHARATH0153 Date: Wed, 24 Jun 2026 11:49:37 +0530 Subject: [PATCH 1/3] [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 --- openwisp_utils/releaser/config.py | 65 +++++++++++---- openwisp_utils/releaser/tests/test_config.py | 80 ++++++++++++++++++ .../releaser/tests/test_version_bumping.py | 83 +++++++++++++++++++ openwisp_utils/releaser/version.py | 32 +++++++ setup.py | 1 + 5 files changed, 247 insertions(+), 14 deletions(-) diff --git a/openwisp_utils/releaser/config.py b/openwisp_utils/releaser/config.py index 5faed6ab..cb47b49b 100644 --- a/openwisp_utils/releaser/config.py +++ b/openwisp_utils/releaser/config.py @@ -4,6 +4,14 @@ import re import subprocess +try: + import tomllib # pragma: no cover +except ImportError: # pragma: no cover + try: + import tomli as tomllib # pragma: no cover + except ImportError: # pragma: no cover + tomllib = None # pragma: no cover + def get_package_name_from_setup(): """Parses setup.py to find the package name without raising an error.""" @@ -22,6 +30,7 @@ def get_package_type_from_setup(): """Detects package type based on config files present in the project.""" package_type_files = { "setup.py": "python", + "pyproject.toml": "python", "package.json": "npm", "docker-compose.yml": "docker", ".ansible-lint": "ansible", @@ -52,22 +61,49 @@ def detect_changelog_style(changelog_path): def _handle_python_version(config): """Handles version detection for Python packages.""" project_name = get_package_name_from_setup() - if not project_name: + if project_name: + package_directory = project_name.replace("-", "_") + init_py_path = os.path.join(package_directory, "__init__.py") + if os.path.exists(init_py_path): + with open(init_py_path, "r") as f: + content = f.read() + version_match = re.search(r"^VERSION\s*=\s*\((.*)\)", content, re.M) + if version_match: + config["version_path"] = init_py_path + try: + version_tuple = ast.literal_eval(f"({version_match.group(1)})") + config["CURRENT_VERSION"] = list(version_tuple) + except (ValueError, SyntaxError): + config["CURRENT_VERSION"] = None + return + _handle_pyproject_toml_version(config) + + +def _handle_pyproject_toml_version(config): + """Handles version detection from pyproject.toml.""" + if not os.path.exists("pyproject.toml"): return - package_directory = project_name.replace("-", "_") - init_py_path = os.path.join(package_directory, "__init__.py") - if not os.path.exists(init_py_path): + if tomllib is None: + return # pragma: no cover + with open("pyproject.toml", "rb") as f: + try: + data = tomllib.load(f) + except Exception: + return + project = data.get("project", {}) + version_str = project.get("version") + if not version_str: return - with open(init_py_path, "r") as f: - content = f.read() - version_match = re.search(r"^VERSION\s*=\s*\((.*)\)", content, re.M) - if version_match: - config["version_path"] = init_py_path - try: - version_tuple = ast.literal_eval(f"({version_match.group(1)})") - config["CURRENT_VERSION"] = list(version_tuple) - except (ValueError, SyntaxError): - config["CURRENT_VERSION"] = None + try: + parts = version_str.split(".") + if len(parts) != 3: + return + current_version = [int(parts[0]), int(parts[1]), int(parts[2]), "final"] + except (ValueError, TypeError): + return + config["package_type"] = "pyproject" + config["version_path"] = "pyproject.toml" + config["CURRENT_VERSION"] = current_version def _handle_npm_version(config): @@ -194,6 +230,7 @@ def _handle_openwrt_version(config): # Maps package types to their version detection handlers PACKAGE_VERSION_HANDLERS = { "python": _handle_python_version, + "pyproject": _handle_pyproject_toml_version, "npm": _handle_npm_version, "docker": _handle_docker_version, "ansible": _handle_ansible_version, diff --git a/openwisp_utils/releaser/tests/test_config.py b/openwisp_utils/releaser/tests/test_config.py index 6b9843f4..772d2734 100644 --- a/openwisp_utils/releaser/tests/test_config.py +++ b/openwisp_utils/releaser/tests/test_config.py @@ -424,3 +424,83 @@ def test_no_package_type_detected(project_dir, create_changelog, init_git_repo): assert config["package_type"] is None assert config["version_path"] is None assert config["CURRENT_VERSION"] is None + + +def test_pyproject_toml_package_detection(project_dir, create_changelog, init_git_repo): + """Tests that python package type is detected when pyproject.toml exists.""" + (project_dir / "pyproject.toml").write_text( + '[project]\nname = "my-package"\nversion = "1.2.3"\n' + ) + create_changelog(project_dir) + init_git_repo(project_dir) + config = load_config() + assert config["package_type"] == "pyproject" + assert config["version_path"] == "pyproject.toml" + assert config["CURRENT_VERSION"] == [1, 2, 3, "final"] + + +def test_pyproject_toml_missing_version(project_dir, create_changelog, init_git_repo): + """Tests pyproject.toml without a version field.""" + (project_dir / "pyproject.toml").write_text('[project]\nname = "my-package"\n') + create_changelog(project_dir) + init_git_repo(project_dir) + config = load_config() + assert config["version_path"] is None + assert config["CURRENT_VERSION"] is None + + +def test_pyproject_toml_invalid_version(project_dir, create_changelog, init_git_repo): + """Tests pyproject.toml with an invalid version format.""" + (project_dir / "pyproject.toml").write_text( + '[project]\nname = "my-package"\nversion = "1.2"\n' + ) + create_changelog(project_dir) + init_git_repo(project_dir) + config = load_config() + assert config["version_path"] is None + assert config["CURRENT_VERSION"] is None + + +def test_pyproject_toml_malformed(project_dir, create_changelog, init_git_repo): + """Tests pyproject.toml with malformed TOML content.""" + (project_dir / "pyproject.toml").write_text("@invalid toml\n") + create_changelog(project_dir) + init_git_repo(project_dir) + config = load_config() + assert config["version_path"] is None + assert config["CURRENT_VERSION"] is None + + +def test_pyproject_toml_non_numeric_version( + project_dir, create_changelog, init_git_repo +): + """Tests pyproject.toml with a non-numeric version component.""" + (project_dir / "pyproject.toml").write_text( + '[project]\nname = "my-package"\nversion = "1.2.a"\n' + ) + create_changelog(project_dir) + init_git_repo(project_dir) + config = load_config() + assert config["version_path"] is None + assert config["CURRENT_VERSION"] is None + + +def test_setup_py_takes_priority_over_pyproject_toml( + project_dir, + create_setup_py, + create_package_dir_with_version, + create_changelog, + init_git_repo, +): + """Tests that setup.py takes priority over pyproject.toml.""" + create_setup_py(project_dir) + create_package_dir_with_version(project_dir) + (project_dir / "pyproject.toml").write_text( + '[project]\nname = "my-package"\nversion = "9.9.9"\n' + ) + create_changelog(project_dir) + init_git_repo(project_dir) + config = load_config() + assert config["package_type"] == "python" + assert config["version_path"] == "my_test_package/__init__.py" + assert config["CURRENT_VERSION"] == [1, 2, 3, "final"] diff --git a/openwisp_utils/releaser/tests/test_version_bumping.py b/openwisp_utils/releaser/tests/test_version_bumping.py index 2f96fe27..53d1e0a8 100644 --- a/openwisp_utils/releaser/tests/test_version_bumping.py +++ b/openwisp_utils/releaser/tests/test_version_bumping.py @@ -368,3 +368,86 @@ def test_bump_version_openwrt(): assert result is True written_content = m_open().write.call_args[0][0] assert "1.2.4" in written_content + + +PYPROJECT_TOML_CONTENT = """[build-system] +requires = ["setuptools"] +build-backend = "setuptools.backends._legacy:_Backend" + +[project] +name = "my-package" +version = "1.2.3" +description = "A test package" +""" + +PYPROJECT_TOML_BUMPED = """[build-system] +requires = ["setuptools"] +build-backend = "setuptools.backends._legacy:_Backend" + +[project] +name = "my-package" +version = "1.2.4" +description = "A test package" +""" + + +def test_get_current_version_pyproject(): + """Tests getting current version from pyproject.toml.""" + config = { + "package_type": "pyproject", + "version_path": "pyproject.toml", + "CURRENT_VERSION": [1, 2, 3, "final"], + } + version, version_type = get_current_version(config) + assert version == "1.2.3" + assert version_type == "final" + + +def test_bump_version_pyproject_toml(): + """Tests bumping version in pyproject.toml.""" + config = { + "package_type": "pyproject", + "version_path": "pyproject.toml", + "CURRENT_VERSION": [1, 2, 3, "final"], + } + m_open = mock_open(read_data=PYPROJECT_TOML_CONTENT) + with patch("os.path.exists", return_value=True), patch("builtins.open", m_open): + result = bump_version(config, "1.2.4") + assert result is True + written_content = m_open().write.call_args[0][0] + assert 'version = "1.2.4"' in written_content + + +def test_bump_version_pyproject_toml_not_found(): + """Tests error when version field is missing in pyproject.toml.""" + config = { + "package_type": "pyproject", + "version_path": "pyproject.toml", + "CURRENT_VERSION": [1, 2, 3, "final"], + } + content_no_version = """[build-system] +requires = ["setuptools"] +build-backend = "setuptools.backends._legacy:_Backend" + +[project] +name = "my-package" +description = "No version here" +""" + m_open = mock_open(read_data=content_no_version) + with patch("os.path.exists", return_value=True), patch("builtins.open", m_open): + with pytest.raises(RuntimeError, match="Failed to find"): + bump_version(config, "1.2.4") + + +def test_bump_version_pyproject_toml_malformed(): + """Tests bumping with malformed TOML content (falls through to regex).""" + config = { + "package_type": "pyproject", + "version_path": "pyproject.toml", + "CURRENT_VERSION": [1, 2, 3, "final"], + } + malformed_content = "@invalid toml\n" + m_open = mock_open(read_data=malformed_content) + with patch("os.path.exists", return_value=True), patch("builtins.open", m_open): + with pytest.raises(RuntimeError, match="Failed to find"): + bump_version(config, "1.2.4") diff --git a/openwisp_utils/releaser/version.py b/openwisp_utils/releaser/version.py index cfcd2fbf..c14fc095 100644 --- a/openwisp_utils/releaser/version.py +++ b/openwisp_utils/releaser/version.py @@ -3,6 +3,14 @@ import subprocess import sys +try: + import tomllib # pragma: no cover +except ImportError: # pragma: no cover + try: + import tomli as tomllib # pragma: no cover + except ImportError: # pragma: no cover + tomllib = None # pragma: no cover + import questionary @@ -54,6 +62,29 @@ def _bump_python_version(content, new_version, version_path): ) +def _bump_pyproject_toml_version(content, new_version, version_path): + """Handles version bumping in pyproject.toml.""" + if tomllib is not None: + try: + data = tomllib.loads(content) + except Exception: + pass + else: + current = data.get("project", {}).get("version", "") + if current: + old = f'version = "{current}"' + if old in content: + return content.replace(old, f'version = "{new_version}"', 1) + # fall through to regex if exact match not found + return _bump_with_regex( + content, + r'^version\s*=\s*"([^"]+)"', + f'version = "{new_version}"', + version_path, + "version in pyproject.toml", + ) + + def _bump_npm_version(content, new_version, version_path): """Handles version bumping for NPM packages.""" try: @@ -119,6 +150,7 @@ def _bump_openwrt_version(content, new_version, version_path): # Maps package types to their version bump handlers VERSION_BUMP_HANDLERS = { "python": _bump_python_version, + "pyproject": _bump_pyproject_toml_version, "npm": _bump_npm_version, "docker": _bump_docker_version, "ansible": _bump_ansible_version, diff --git a/setup.py b/setup.py index bc7809c2..1b4acf67 100644 --- a/setup.py +++ b/setup.py @@ -75,6 +75,7 @@ "questionary~=2.1.0", "pypandoc~=1.15", "pypandoc-binary~=1.15", + "tomli>=1.1.0; python_version < '3.11'", ], "github_actions": [ "google-genai>=1.62.0,<3.0.0", From 9b080a6d6e8b4ce761ff728f030fc232ad99f11f Mon Sep 17 00:00:00 2001 From: BHARATH0153 Date: Sat, 27 Jun 2026 10:12:57 +0530 Subject: [PATCH 2/3] [fix] Resolved F821 undefined name and version.py fallback errors Moved version.py candidate-files check from _handle_pyproject_toml_version to _handle_python_version so it runs regardless of pyproject.toml existence. Added TypeError to except clause in _handle_python_version list() call. --- openwisp_utils/releaser/config.py | 34 ++++++------------- .../releaser/tests/test_version_bumping.py | 1 - 2 files changed, 10 insertions(+), 25 deletions(-) diff --git a/openwisp_utils/releaser/config.py b/openwisp_utils/releaser/config.py index 2a9d752b..a6d4980e 100644 --- a/openwisp_utils/releaser/config.py +++ b/openwisp_utils/releaser/config.py @@ -69,17 +69,22 @@ def _handle_python_version(config): project_name = get_package_name_from_setup() if project_name: package_directory = project_name.replace("-", "_") - init_py_path = os.path.join(package_directory, "__init__.py") - if os.path.exists(init_py_path): - with open(init_py_path, "r") as f: + candidate_files = [ + os.path.join(package_directory, "__init__.py"), + os.path.join(package_directory, "version.py"), + ] + for version_path in candidate_files: + if not os.path.exists(version_path): + continue + with open(version_path, "r") as f: content = f.read() version_match = re.search(r"^VERSION\s*=\s*\((.*)\)", content, re.M) if version_match: - config["version_path"] = init_py_path + config["version_path"] = version_path try: version_tuple = ast.literal_eval(f"({version_match.group(1)})") config["CURRENT_VERSION"] = list(version_tuple) - except (ValueError, SyntaxError): + except (ValueError, SyntaxError, TypeError): config["CURRENT_VERSION"] = None return _handle_pyproject_toml_version(config) @@ -110,25 +115,6 @@ def _handle_pyproject_toml_version(config): config["package_type"] = "pyproject" config["version_path"] = "pyproject.toml" config["CURRENT_VERSION"] = current_version - package_directory = project_name.replace("-", "_") - candidate_files = [ - os.path.join(package_directory, "__init__.py"), - os.path.join(package_directory, "version.py"), - ] - for version_path in candidate_files: - if not os.path.exists(version_path): - continue - with open(version_path, "r") as f: - content = f.read() - version_match = re.search(r"^VERSION\s*=\s*\((.*)\)", content, re.M) - if version_match: - config["version_path"] = version_path - try: - version_tuple = ast.literal_eval(f"({version_match.group(1)})") - config["CURRENT_VERSION"] = list(version_tuple) - except (ValueError, SyntaxError, TypeError): - config["CURRENT_VERSION"] = None - return def _handle_npm_version(config): diff --git a/openwisp_utils/releaser/tests/test_version_bumping.py b/openwisp_utils/releaser/tests/test_version_bumping.py index b5f3ac61..6b3853f8 100644 --- a/openwisp_utils/releaser/tests/test_version_bumping.py +++ b/openwisp_utils/releaser/tests/test_version_bumping.py @@ -467,4 +467,3 @@ def test_bump_version_pyproject_toml_malformed(): with patch("os.path.exists", return_value=True), patch("builtins.open", m_open): with pytest.raises(RuntimeError, match="Failed to find"): bump_version(config, "1.2.4") - assert written_content == "1.2.4\n" From 9759f4def5e064b947d466a4109c9a9e4c91db75 Mon Sep 17 00:00:00 2001 From: BHARATH0153 Date: Sat, 27 Jun 2026 10:22:01 +0530 Subject: [PATCH 3/3] [chores] Removed inline comment --- openwisp_utils/releaser/version.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openwisp_utils/releaser/version.py b/openwisp_utils/releaser/version.py index 18812c6f..196e9515 100644 --- a/openwisp_utils/releaser/version.py +++ b/openwisp_utils/releaser/version.py @@ -75,7 +75,6 @@ def _bump_pyproject_toml_version(content, new_version, version_path): old = f'version = "{current}"' if old in content: return content.replace(old, f'version = "{new_version}"', 1) - # fall through to regex if exact match not found return _bump_with_regex( content, r'^version\s*=\s*"([^"]+)"',