Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for poetry export #61

Merged
merged 6 commits into from
Feb 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading