Skip to content

Commit

Permalink
Support for poetry export (#61)
Browse files Browse the repository at this point in the history
* Support for poetry export

* New implementation using temporary lockfiles

* Added poetry-plugin-export as dev dependency

* ExportModifier for Poetry 1.8.5

* Fix mypy issues

* Bump version to 0.4.0
  • Loading branch information
ag14774 authored Feb 7, 2025
1 parent 67371ca commit 55ee05d
Show file tree
Hide file tree
Showing 35 changed files with 616 additions and 1,230 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
**Main Features**:
- Shared `poetry.lock` file across multiple projects in a monorepo
- Shared virtual environment across multiple projects in a monorepo
- Rewrite path dependencies during `poetry build`
- Replace path dependencies during `poetry build` with pinned versions
- Compatible with both Poetry v1 and v2

## Installation
Expand Down Expand Up @@ -55,7 +55,7 @@ library-two = { path = "../library-two", develop = true }
[tool.poetry-monoranger-plugin]
enabled = true
monorepo-root = "../"
version-rewrite-rule = '==' # Choose between "==", "~", "^", ">=,<"
version-pinning-rule = '==' # Choose between "==", "~", "^", ">=,<"
# ...
```
The plugin by default is disabled in order to avoid having undesired consequences on other projects (as plugins are installed globally). To enable it, set `enabled = true` in each project's `pyproject.toml` file.
Expand Down Expand Up @@ -97,7 +97,7 @@ The plugin can be configured in the `pyproject.toml` file of each project. The f

- `enabled` (bool): Whether the plugin is enabled for the current project. Default: `false`
- `monorepo-root` (str): The path to the root of the monorepo. Default: `../`
- `version-rewrite-rule` (str): The version rewrite rule to apply when building the project. Default: `==`
- `version-pinning-rule` (str): The version pinning rule to apply when building the project. Default: `==`

## License
This project is licensed under the Apache 2.0 license - see the [LICENSE](LICENSE) file for details.
17 changes: 16 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 22 additions & 2 deletions poetry_monoranger_plugin/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from __future__ import annotations

import warnings
from dataclasses import dataclass
from typing import Any, Literal

Expand All @@ -16,12 +17,31 @@ class MonorangerConfig:
Attributes:
enabled (bool): Flag to enable or disable Monoranger. Defaults to False.
monorepo_root (str): Path to the root of the monorepo. Defaults to "../".
version_rewrite_rule (Literal['==', '~', '^', '>=,<']): Rule for version rewriting. Defaults to "^".
version_pinning_rule (Literal['==', '~', '^', '>=,<']): Rule for pinning version of path dependencies. Defaults to "^".
"""

enabled: bool = False
monorepo_root: str = "../"
version_rewrite_rule: Literal["==", "~", "^", ">=,<"] = "^"
version_pinning_rule: Literal["==", "~", "^", ">=,<"] = None # type: ignore[assignment]
version_rewrite_rule: Literal["==", "~", "^", ">=,<", None] = None

def __post_init__(self):
if self.version_pinning_rule is None and self.version_rewrite_rule is None:
self.version_pinning_rule = "^" # default value
elif self.version_rewrite_rule is not None and self.version_pinning_rule is not None:
raise ValueError(
"Cannot specify both `version_pinning_rule` and `version_rewrite_rule`. "
"`version_rewrite_rule` is deprecated. Please use version_pinning_rule instead."
)
elif self.version_rewrite_rule is not None:
with warnings.catch_warnings():
warnings.filterwarnings("default", category=DeprecationWarning)
warnings.warn(
"`version_rewrite_rule` is deprecated. Please use `version_pinning_rule` instead.",
DeprecationWarning,
stacklevel=2,
)
self.version_pinning_rule = self.version_rewrite_rule

@classmethod
def from_dict(cls, d: dict[str, Any]):
Expand Down
193 changes: 193 additions & 0 deletions poetry_monoranger_plugin/export_modifier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
"""Copyright (C) 2024 GlaxoSmithKline plc
This module defines the ExportModifier class, which modifies the `poetry export` command. Similarly to
`poetry build` this exports the dependencies of a subproject to an alternative format while ensuring path
dependencies are pinned to specific versions.
"""

from __future__ import annotations

import shutil
import tempfile
import weakref
from pathlib import Path
from typing import TYPE_CHECKING, Any, TypeVar

import poetry.__version__ as poetry_version
from poetry.config.config import Config
from poetry.core.packages.dependency_group import MAIN_GROUP
from poetry.factory import Factory
from poetry.packages.locker import Locker
from poetry.poetry import Poetry
from poetry_plugin_export.command import ExportCommand

from poetry_monoranger_plugin.path_dep_pinner import PathDepPinner

if TYPE_CHECKING:
from cleo.events.console_command_event import ConsoleCommandEvent
from cleo.io.io import IO
from poetry.core.packages.dependency import Dependency
from poetry.core.packages.package import Package

from poetry_monoranger_plugin.config import MonorangerConfig

POETRY_V2 = poetry_version.__version__.startswith("2.")

TemporaryLockerT = TypeVar("TemporaryLockerT", bound="TemporaryLocker")

if not POETRY_V2:
from poetry.core.packages.directory_dependency import DirectoryDependency
from poetry.core.packages.project_package import ProjectPackage

class PathDepPinningPackage(ProjectPackage):
"""A modified ProjectPackage that pins path dependencies to specific versions
*NOTE*: Only required for Poetry V1
"""

_pinner: PathDepPinner

@classmethod
def from_package(cls, package: Package, pinner: PathDepPinner) -> PathDepPinningPackage:
"""Creates a new PathDepPinningPackage from an existing Package"""
new_package = cls.__new__(cls)
new_package.__dict__.update(package.__dict__)

new_package._pinner = pinner
return new_package

@property
def all_requires(self) -> list[Dependency]:
"""Returns the main dependencies and group dependencies
enriched with Poetry-specific information for locking while ensuring
path dependencies are pinned to specific versions.
"""
deps = super().all_requires
# noinspection PyProtectedMember
deps = [self._pinner._pin_dependency(dep) if isinstance(dep, DirectoryDependency) else dep for dep in deps]
return deps


class TemporaryLocker(Locker):
"""A temporary locker that is used to store the lock file in a temporary file."""

@classmethod
def from_locker(cls: type[TemporaryLockerT], locker: Locker, data: dict[str, Any] | None) -> TemporaryLockerT:
"""Creates a temporary locker from an existing locker."""
temp_file = tempfile.NamedTemporaryFile(prefix="poetry_lock_", delete=False) # noqa: SIM115
temp_file_path = Path(temp_file.name)
temp_file.close()

shutil.copy(locker.lock, temp_file_path)

if data is None:
data = locker._pyproject_data if POETRY_V2 else locker._local_config # type: ignore[attr-defined]

new_locker: TemporaryLockerT = cls(temp_file_path, data)
weakref.finalize(new_locker, temp_file_path.unlink)

return new_locker


def _pin_package(package: Package, pinner: PathDepPinner, io: IO) -> Package:
"""Pins a package to a specific version if it is a path dependency"""
if package.source_type == "directory":
package._source_type = None
package._source_url = None

# noinspection PyProtectedMember
if package._dependency_groups and MAIN_GROUP in package._dependency_groups:
# noinspection PyProtectedMember
main_deps_group = package._dependency_groups[MAIN_GROUP]
# noinspection PyProtectedMember
pinner._pin_dep_grp(main_deps_group, io)
return package


class ExportModifier:
"""Modifies Poetry commands (`lock`, `install`, `update`) for monorepo support.
Ensures these commands behave as if they were run from the monorepo root directory
even when run from a subdirectory, thus maintaining a shared lockfile.
"""

def __init__(self, plugin_conf: MonorangerConfig):
self.plugin_conf = plugin_conf

def execute(self, event: ConsoleCommandEvent):
"""Modifies the command to run from the monorepo root.
Ensures the command is one of `LockCommand`, `InstallCommand`, or `UpdateCommand`.
Sets up the necessary Poetry instance and installer for the monorepo root so that
the command behaves as if it was executed from within the root directory.
Args:
event (ConsoleCommandEvent): The triggering event.
"""
command = event.command
assert isinstance(command, ExportCommand), (
f"{self.__class__.__name__} can only be used for `poetry export` command"
)

io = event.io
io.write_line("<info>Running command from monorepo root directory</info>")

# Create a copy of the poetry object to prevent the command from modifying the original poetry object
poetry = Poetry.__new__(Poetry)
poetry.__dict__.update(command.poetry.__dict__)

# Force reload global config in order to undo changes that happened due to subproject's poetry.toml configs
_ = Config.create(reload=True)
monorepo_root = (command.poetry.pyproject_path.parent / self.plugin_conf.monorepo_root).resolve()
monorepo_root_poetry = Factory().create_poetry(
cwd=monorepo_root, io=io, disable_cache=command.poetry.disable_cache
)

if POETRY_V2:
temp_locker = TemporaryLocker.from_locker(monorepo_root_poetry.locker, poetry.pyproject.data)
else:
temp_locker = TemporaryLocker.from_locker(monorepo_root_poetry.locker, poetry.pyproject.poetry_config)

from poetry.puzzle.solver import Solver

locked_repository = monorepo_root_poetry.locker.locked_repository()
solver = Solver(
poetry.package,
poetry.pool,
locked_repository.packages,
locked_repository.packages,
io,
)

# Always re-solve directory dependencies, otherwise we can't determine
# if anything has changed (and the lock file contains an invalid version).
use_latest = [p.name for p in locked_repository.packages if p.source_type == "directory"]
pinner = PathDepPinner(self.plugin_conf)
packages: list[Package] | dict[Package, Any]
if POETRY_V2:
solved_packages: dict[Package, Any] = solver.solve(use_latest=use_latest).get_solved_packages() # type: ignore[attr-defined]
packages = {_pin_package(pak, pinner, io): info for pak, info in solved_packages.items()}
else:
from poetry.installation.operations import Uninstall, Update
from poetry.repositories.lockfile_repository import LockfileRepository

ops = solver.solve(use_latest=use_latest).calculate_operations()
packages = [
op.target_package if isinstance(op, Update) else op.package
for op in ops
if not isinstance(op, Uninstall)
]

lockfile_repo = LockfileRepository()
for package in packages:
if not lockfile_repo.has_package(package):
lockfile_repo.add_package(package)

packages = [_pin_package(pak, pinner, io) for pak in lockfile_repo.packages]

if not POETRY_V2:
poetry._package = PathDepPinningPackage.from_package(poetry.package, pinner)

temp_locker.set_lock_data(poetry.package, packages) # type: ignore[arg-type]
poetry.set_locker(temp_locker)
command.set_poetry(poetry)
Loading

0 comments on commit 55ee05d

Please sign in to comment.