diff --git a/openwisp_utils/releaser/config.py b/openwisp_utils/releaser/config.py index af2256e8..a6d4980e 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", @@ -58,27 +67,54 @@ def _handle_python_version(config): netjsonconfig, netdiff). """ project_name = get_package_name_from_setup() - if not project_name: + if project_name: + 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 + _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("-", "_") - 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 + 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 + 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): @@ -203,6 +239,7 @@ def _handle_generic_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 a41f351b..a4317299 100644 --- a/openwisp_utils/releaser/tests/test_config.py +++ b/openwisp_utils/releaser/tests/test_config.py @@ -587,3 +587,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 323a52cc..6b3853f8 100644 --- a/openwisp_utils/releaser/tests/test_version_bumping.py +++ b/openwisp_utils/releaser/tests/test_version_bumping.py @@ -383,4 +383,87 @@ def test_bump_version_generic(): result = bump_version(config, "1.2.4") assert result is True written_content = m_open().write.call_args[0][0] - assert written_content == "1.2.4\n" + 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 18596358..196e9515 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,28 @@ 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) + 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 +149,7 @@ def _bump_generic_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",