diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index ea5a5b2..4f5ea86 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -43,6 +43,9 @@ jobs: poetry-version: '1.7.1' - python-version: '3.7' poetry-version: '1.8.1' + include: + - python-version: '3.11' + poetry-version: 'git+https://github.com/radoering/poetry.git@pep621-support' steps: - uses: actions/checkout@v4 with: @@ -50,9 +53,12 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + - if: ${{ startsWith(matrix.poetry-version, 'git') }} + run: | + echo "USE_PEP621=1" >> $GITHUB_ENV - run: | pip install pipx - pipx install poetry==${{ matrix.poetry-version }} + pipx install ${{ startsWith(matrix.poetry-version, 'git') && matrix.poetry-version || format('poetry=={0}', matrix.poetry-version) }} pipx install invoke poetry install --extras plugin diff --git a/poetry_dynamic_versioning/__init__.py b/poetry_dynamic_versioning/__init__.py index 699fc76..604a799 100644 --- a/poetry_dynamic_versioning/__init__.py +++ b/poetry_dynamic_versioning/__init__.py @@ -8,6 +8,7 @@ import subprocess import sys import textwrap +from enum import Enum from importlib import import_module from pathlib import Path from typing import Mapping, MutableMapping, Optional, Sequence, Tuple, Union @@ -118,17 +119,26 @@ class _Config(Mapping): pass +class _Mode(Enum): + Classic = "classic" + Pep621 = "pep621" + + class _ProjectState: def __init__( self, path: Path, - original_version: str, + original_version: Optional[str], version: str, + mode: _Mode, + dynamic_index: Optional[int], substitutions: Optional[MutableMapping[Path, str]] = None, ) -> None: self.path = path self.original_version = original_version self.version = version + self.mode = mode + self.dynamic_index = dynamic_index self.substitutions = ( {} if substitutions is None else substitutions ) # type: MutableMapping[Path, str] @@ -590,11 +600,21 @@ def _substitute_version_in_text(version: str, content: str, patterns: Sequence[_ def _apply_version( - version: str, instance: Version, config: _Config, pyproject_path: Path, retain: bool = False + name: str, + version: str, + instance: Version, + config: _Config, + pyproject_path: Path, + mode: _Mode, + retain: bool = False, ) -> None: pyproject = tomlkit.parse(pyproject_path.read_bytes().decode("utf-8")) - pyproject["tool"]["poetry"]["version"] = version # type: ignore + if mode == _Mode.Classic: + pyproject["tool"]["poetry"]["version"] = version # type: ignore + elif mode == _Mode.Pep621: + pyproject["project"]["dynamic"].remove("version") # type: ignore + pyproject["project"]["version"] = version # type: ignore # Disable the plugin in case we're building a source distribution, # which won't have access to the VCS info at install time. @@ -604,8 +624,6 @@ def _apply_version( pyproject_path.write_bytes(tomlkit.dumps(pyproject).encode("utf-8")) - name = pyproject["tool"]["poetry"]["name"] # type: ignore - for file_name, file_info in config["files"].items(): full_file = pyproject_path.parent.joinpath(file_name) @@ -635,19 +653,12 @@ def _apply_version( def _get_and_apply_version( - name: Optional[str] = None, - original: Optional[str] = None, pyproject: Optional[Mapping] = None, pyproject_path: Optional[Path] = None, retain: bool = False, force: bool = False, - # fmt: off - io: bool = True - # fmt: on + io: bool = True, ) -> Optional[str]: - if name is not None and name in _state.projects: - return name - if pyproject_path is None: pyproject_path = _get_pyproject_path() if pyproject_path is None: @@ -656,11 +667,32 @@ def _get_and_apply_version( if pyproject is None: pyproject = tomlkit.parse(pyproject_path.read_bytes().decode("utf-8")) - if name is None or original is None: + classic = ( + "tool" in pyproject + and "poetry" in pyproject["tool"] + and "name" in pyproject["tool"]["poetry"] + ) + pep621 = ( + "project" in pyproject + and "name" in pyproject["project"] + and "dynamic" in pyproject["project"] + and "version" in pyproject["project"]["dynamic"] + and "version" not in pyproject["project"] + ) + + if classic: name = pyproject["tool"]["poetry"]["name"] original = pyproject["tool"]["poetry"]["version"] - if name in _state.projects: - return name + dynamic_index = None + elif pep621: + name = pyproject["project"]["name"] + original = pyproject["project"].get("version") + dynamic_index = pyproject["project"]["dynamic"].index("version") + else: + return name if name in _state.projects else None + + if name in _state.projects: + return name config = _get_config(pyproject) if not config["enable"] and not force: @@ -674,11 +706,20 @@ def _get_and_apply_version( finally: os.chdir(str(initial_dir)) - # Condition will always be true, but it makes Mypy happy. - if name is not None and original is not None: - _state.projects[name] = _ProjectState(pyproject_path, original, version) + if classic and name is not None and original is not None: + mode = _Mode.Classic + _state.projects[name] = _ProjectState( + pyproject_path, original, version, mode, dynamic_index + ) + if io: + _apply_version(name, version, instance, config, pyproject_path, mode, retain) + elif pep621 and name is not None: + mode = _Mode.Pep621 + _state.projects[name] = _ProjectState( + pyproject_path, original, version, mode, dynamic_index + ) if io: - _apply_version(version, instance, config, pyproject_path, retain) + _apply_version(name, version, instance, config, pyproject_path, mode, retain) return name @@ -704,7 +745,15 @@ def _revert_version(retain: bool = False) -> None: # Reread pyproject.toml in case the substitutions affected it. pyproject = tomlkit.parse(state.path.read_bytes().decode("utf-8")) - pyproject["tool"]["poetry"]["version"] = state.original_version # type: ignore + if state.mode == _Mode.Classic: + if state.original_version is not None: + pyproject["tool"]["poetry"]["version"] = state.original_version # type: ignore + elif state.mode == _Mode.Pep621: + if state.dynamic_index is not None: + index = state.dynamic_index + pyproject["project"]["dynamic"].insert(index, "version") # type: ignore + if "version" in pyproject["project"]: # type: ignore + pyproject["project"].pop("version") # type: ignore if not retain and not _state.cli_mode: pyproject["tool"]["poetry-dynamic-versioning"]["enable"] = True # type: ignore diff --git a/poetry_dynamic_versioning/patch.py b/poetry_dynamic_versioning/patch.py index 648adb4..9ad6c90 100644 --- a/poetry_dynamic_versioning/patch.py +++ b/poetry_dynamic_versioning/patch.py @@ -24,8 +24,6 @@ def alt_poetry_create(cls, *args, **kwargs): if not _state.cli_mode: name = _get_and_apply_version( - name=instance.local_config["name"], - original=instance.local_config["version"], pyproject=instance.pyproject.data, pyproject_path=_get_pyproject_path_from_poetry(instance.pyproject), ) diff --git a/poetry_dynamic_versioning/plugin.py b/poetry_dynamic_versioning/plugin.py index 55c1077..a46ad4c 100644 --- a/poetry_dynamic_versioning/plugin.py +++ b/poetry_dynamic_versioning/plugin.py @@ -82,8 +82,6 @@ def _apply_version_via_plugin( # fmt: on ) -> None: name = _get_and_apply_version( - name=poetry.local_config["name"], - original=poetry.local_config["version"], pyproject=poetry.pyproject.data, pyproject_path=_get_pyproject_path_from_poetry(poetry.pyproject), retain=retain, diff --git a/tests/project-pep621/project_pep621/__init__.py b/tests/project-pep621/project_pep621/__init__.py new file mode 100644 index 0000000..6c8e6b9 --- /dev/null +++ b/tests/project-pep621/project_pep621/__init__.py @@ -0,0 +1 @@ +__version__ = "0.0.0" diff --git a/tests/project-pep621/pyproject.toml b/tests/project-pep621/pyproject.toml new file mode 100644 index 0000000..eec1826 --- /dev/null +++ b/tests/project-pep621/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "project-pep621" +dynamic = ["version"] + +[tool.poetry] +# The plugin itself doesn't need this, but Poetry does: +# https://github.com/python-poetry/poetry-core/blob/c80dcc53793316104862d2c3ac888dde3c263b08/tests/test_factory.py#L39-L42 +version = "0.0.0" + +[tool.poetry-dynamic-versioning] +enable = true + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/tests/test_integration.py b/tests/test_integration.py index 0030cb5..bdc0886 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Optional, Sequence, Tuple +import dunamai import pytest import tomlkit @@ -16,6 +17,10 @@ DUMMY_DIST = DUMMY / "dist" DUMMY_PYPROJECT = DUMMY / "pyproject.toml" +DUMMY_PEP621 = ROOT / "tests" / "project-pep621" +DUMMY_PEP621_DIST = DUMMY_PEP621 / "dist" +DUMMY_PEP621_PYPROJECT = DUMMY_PEP621 / "pyproject.toml" + DUMMY_VERSION = "0.0.999" DEPENDENCY_DYNAMIC_VERSION = "0.0.888" @@ -74,11 +79,12 @@ def before_all(): @pytest.fixture(autouse=True) def before_each(): - run(f"git checkout -- {DUMMY.as_posix()}") - delete(DUMMY / "dist") - delete(DUMMY / "poetry.lock") - for file in DUMMY.glob("*.whl"): - delete(file) + for project in [DUMMY, DUMMY_PEP621]: + run(f"git checkout -- {project.as_posix()}") + delete(project / "dist") + delete(project / "poetry.lock") + for file in project.glob("*.whl"): + delete(file) def test_plugin_enabled(): @@ -123,7 +129,10 @@ def test_invalid_config_for_vcs(): def test_keep_pyproject_modifications(): package = "cachy" # Using --optional to avoid actually installing the package - run(f"poetry add --optional {package}", where=DUMMY) + if "USE_PEP621" in os.environ: + run(f"poetry add --optional main {package}", where=DUMMY) + else: + run(f"poetry add --optional {package}", where=DUMMY) # Make sure pyproject.toml contains the new package dependency data = DUMMY_PYPROJECT.read_bytes().decode("utf-8") assert package in data @@ -267,3 +276,46 @@ def test_plugin_show(): # Just skip it for now. if "CI" not in os.environ: assert "poetry-dynamic-versioning" in out + + +@pytest.mark.skipif("USE_PEP621" not in os.environ, reason="Requires Poetry with PEP-621 support") +def test_pep621_with_dynamic_version(): + version = dunamai.Version.from_git().serialize() + + run("poetry-dynamic-versioning", where=DUMMY_PEP621) + pyproject = tomlkit.parse(DUMMY_PEP621_PYPROJECT.read_bytes().decode("utf-8")) + assert pyproject["project"]["version"] == version + assert "version" not in pyproject["project"]["dynamic"] + assert f'__version__ = "{version}"' in ( + DUMMY_PEP621 / "project_pep621" / "__init__.py" + ).read_bytes().decode("utf-8") + + +@pytest.mark.skipif("USE_PEP621" not in os.environ, reason="Requires Poetry with PEP-621 support") +def test_pep621_with_dynamic_version_and_cleanup(): + version = dunamai.Version.from_git().serialize() + + run("poetry build", where=DUMMY_PEP621) + pyproject = tomlkit.parse(DUMMY_PEP621_PYPROJECT.read_bytes().decode("utf-8")) + assert "version" not in pyproject["project"] + assert "version" in pyproject["project"]["dynamic"] + assert '__version__ = "0.0.0"' in ( + DUMMY_PEP621 / "project_pep621" / "__init__.py" + ).read_bytes().decode("utf-8") + + artifact = next(DUMMY_PEP621_DIST.glob("*.whl")) + assert f"-{version}-" in artifact.name + + +@pytest.mark.skipif("USE_PEP621" not in os.environ, reason="Requires Poetry with PEP-621 support") +def test_pep621_without_dynamic_version(): + pyproject = tomlkit.parse(DUMMY_PEP621_PYPROJECT.read_bytes().decode("utf-8")) + pyproject["project"]["dynamic"] = [] + DUMMY_PEP621_PYPROJECT.write_bytes(tomlkit.dumps(pyproject).encode("utf-8")) + + run("poetry-dynamic-versioning", codes=[1], where=DUMMY_PEP621) + pyproject = tomlkit.parse(DUMMY_PEP621_PYPROJECT.read_bytes().decode("utf-8")) + assert "version" not in pyproject["project"] + assert '__version__ = "0.0.0"' in ( + DUMMY_PEP621 / "project_pep621" / "__init__.py" + ).read_bytes().decode("utf-8")