Skip to content

Commit

Permalink
Support for poetry export
Browse files Browse the repository at this point in the history
  • Loading branch information
ag14774 committed Feb 6, 2025
1 parent 67371ca commit 2b11ca7
Show file tree
Hide file tree
Showing 33 changed files with 529 additions and 1,228 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.
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
137 changes: 137 additions & 0 deletions poetry_monoranger_plugin/export_modifier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""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

from typing import TYPE_CHECKING, Any

from poetry.config.config import Config
from poetry.core.packages.dependency_group import MAIN_GROUP
from poetry.core.packages.directory_dependency import DirectoryDependency
from poetry.core.packages.project_package import ProjectPackage
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


class PathDepPinningLocker(Locker):
"""A modified Locker that pins path dependencies in the lock file to specific versions"""

_pinner: PathDepPinner
_io: IO

@classmethod
def from_locker(cls, locker: Locker, pinner: PathDepPinner, io: IO) -> PathDepPinningLocker:
"""Creates a new PathDepPinningLocker from an existing Locker"""
new_locker = cls.__new__(cls)
new_locker.__dict__.update(locker.__dict__)

new_locker._io = io
new_locker._pinner = pinner
return new_locker

def _get_locked_package(self, info: dict[str, Any], with_dependencies: bool = True) -> Package:
package = super()._get_locked_package(info, with_dependencies)

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
self._pinner._pin_dep_grp(main_deps_group, self._io)

return package


class PathDepPinningPackage(ProjectPackage):
"""A modified ProjectPackage that pins path dependencies to specific versions"""

_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 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>")

# 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
)

pinner = PathDepPinner(self.plugin_conf)

# 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__)
pinning_package = PathDepPinningPackage.from_package(poetry.package, pinner)
poetry._package = pinning_package

pinning_locker = PathDepPinningLocker.from_locker(monorepo_root_poetry.locker, pinner, io)
poetry.set_locker(pinning_locker)

command.set_poetry(poetry)
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
"""Copyright (C) 2024 GlaxoSmithKline plc
This module defines the PathRewriter class, which modifies the behavior of the Poetry build command to
rewrite directory dependencies to their pinned versions.
This module defines the PathDepPinner class, which modifies the behavior of the Poetry build command to
remove the path component of directory dependencies and replace it with a pinned version.
"""

from __future__ import annotations

from collections import defaultdict
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING, cast

import poetry.__version__
import poetry.__version__ as poetry_version
from poetry.console.commands.build import BuildCommand
from poetry.core.constraints.version import Version
from poetry.core.packages.dependency import Dependency
Expand All @@ -23,22 +24,22 @@

if TYPE_CHECKING:
from cleo.events.console_command_event import ConsoleCommandEvent
from cleo.io.io import IO
from poetry.core.packages.dependency_group import DependencyGroup
from poetry.poetry import Poetry

from poetry_monoranger_plugin.config import MonorangerConfig

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


class PathRewriter:
"""A class to handle the rewriting of directory dependencies in a Poetry project."""
class PathDepPinner:
"""A class to handle the pinning of directory dependencies in a Poetry project."""

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

def execute(self, event: ConsoleCommandEvent):
"""Executes the path rewriting process during the Poetry build command.
"""Executes the version pinning process of path dependencies during the Poetry build command.
Args:
event (ConsoleCommandEvent): The triggering event.
Expand All @@ -52,17 +53,20 @@ def execute(self, event: ConsoleCommandEvent):
poetry = command.poetry

main_deps_group = poetry.package.dependency_group(MAIN_GROUP)
directory_deps = self._get_directory_deps(main_deps_group)
self._pin_dep_grp(main_deps_group, io)

def _pin_dep_grp(self, dep_gpr: DependencyGroup, io: IO):
directory_deps = self._get_directory_deps(dep_gpr)

for dependency in directory_deps:
try:
pinned = self._pin_dependency(poetry, dependency)
pinned = self._pin_dependency(dependency)
except (RuntimeError, ValueError) as e:
io.write_line(f"<fg=yellow>Could not pin dependency {dependency.name}: {e!s}</>")
continue

main_deps_group.remove_dependency(dependency.name)
main_deps_group.add_dependency(pinned)
dep_gpr.remove_dependency(dependency.name)
dep_gpr.add_dependency(pinned)

@staticmethod
def _get_directory_deps(dep_grp: DependencyGroup) -> list[DirectoryDependency]:
Expand Down Expand Up @@ -95,8 +99,10 @@ def _get_directory_deps(dep_grp: DependencyGroup) -> list[DirectoryDependency]:

return directory_deps

def _get_dependency_pyproject(self, poetry: Poetry, dependency: DirectoryDependency) -> PyProjectTOML:
pyproject_file = poetry.pyproject_path.parent / dependency.path / "pyproject.toml"
@staticmethod
def _get_dependency_pyproject(dependency: DirectoryDependency) -> PyProjectTOML:
assert dependency.source_url is not None
pyproject_file = Path(dependency.source_url) / "pyproject.toml"

if not pyproject_file.exists():
raise RuntimeError(f"Could not find pyproject.toml in {dependency.path}")
Expand All @@ -108,21 +114,20 @@ def _get_dependency_pyproject(self, poetry: Poetry, dependency: DirectoryDepende

return dep_pyproject

def _pin_dependency(self, poetry: Poetry, dependency: DirectoryDependency):
def _pin_dependency(self, dependency: DirectoryDependency):
"""Pins a directory dependency to a specific version based on the plugin configuration.
Args:
poetry (Poetry): The Poetry instance.
dependency (DirectoryDependency): The directory dependency to pin.
Returns:
Dependency: The pinned dependency.
Raises:
RuntimeError: If the pyproject.toml file is not found or is not a valid Poetry project.
ValueError: If the version rewrite rule is invalid.
ValueError: If the version pinning rule is invalid.
"""
dep_pyproject: PyProjectTOML = self._get_dependency_pyproject(poetry, dependency)
dep_pyproject: PyProjectTOML = self._get_dependency_pyproject(dependency)

try:
name = cast(str, dep_pyproject.poetry_config["name"])
Expand All @@ -132,16 +137,16 @@ def _pin_dependency(self, poetry: Poetry, dependency: DirectoryDependency):
name = cast(str, dep_pyproject.data["project"]["name"])
version = cast(str, dep_pyproject.data["project"]["version"])

if self.plugin_conf.version_rewrite_rule in ["~", "^"]:
pinned_version = f"{self.plugin_conf.version_rewrite_rule}{version}"
elif self.plugin_conf.version_rewrite_rule == "==":
if self.plugin_conf.version_pinning_rule in ["~", "^"]:
pinned_version = f"{self.plugin_conf.version_pinning_rule}{version}"
elif self.plugin_conf.version_pinning_rule == "==":
pinned_version = version
elif self.plugin_conf.version_rewrite_rule == ">=,<":
elif self.plugin_conf.version_pinning_rule == ">=,<":
parsed_version = Version.parse(version)
next_patch_version = parsed_version.replace(dev=None, pre=None).next_patch()
pinned_version = f">={version},<{next_patch_version}"
else:
raise ValueError(f"Invalid version rewrite rule: {self.plugin_conf.version_rewrite_rule}")
raise ValueError(f"Invalid version pinning rule: {self.plugin_conf.version_pinning_rule}")

return Dependency(
name,
Expand Down
14 changes: 12 additions & 2 deletions poetry_monoranger_plugin/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
from poetry.console.commands.update import UpdateCommand
from poetry.plugins.application_plugin import ApplicationPlugin

try:
from poetry_plugin_export.command import ExportCommand
except ImportError:
ExportCommand = None

from poetry_monoranger_plugin.config import MonorangerConfig

if TYPE_CHECKING:
Expand Down Expand Up @@ -120,9 +125,14 @@ def console_command_event_listener(self, event: Event, event_name: str, dispatch
adder_remover.execute(event)

if isinstance(command, BuildCommand):
from poetry_monoranger_plugin.path_rewriter import PathRewriter
from poetry_monoranger_plugin.path_dep_pinner import PathDepPinner

PathDepPinner(self.plugin_conf).execute(event)

if ExportCommand is not None and isinstance(command, ExportCommand):
from poetry_monoranger_plugin.export_modifier import ExportModifier

PathRewriter(self.plugin_conf).execute(event)
ExportModifier(self.plugin_conf).execute(event)

def post_console_command_event_listener(self, event: Event, event_name: str, dispatcher: EventDispatcher):
"""The event listener for console commands. This is executed after the command is run.
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/v1/pkg_one/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ withfsspec = ["fsspec"]
[tool.poetry-monoranger-plugin]
enabled = true
monorepo-root = "../"
version-rewrite-rule = '=='
version-pinning-rule = '=='

[build-system]
requires = ["poetry-core"]
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/v1/pkg_three/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ pkg-one = { path = '../pkg_one', develop = true, extras = ["withfsspec"] }
[tool.poetry-monoranger-plugin]
enabled = true
monorepo-root = "../"
version-rewrite-rule = '=='
version-pinning-rule = '=='

[build-system]
requires = ["poetry-core"]
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/v1/pkg_two/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ tqdm = "^4.67.1"
[tool.poetry-monoranger-plugin]
enabled = true
monorepo-root = "../"
version-rewrite-rule = '=='
version-pinning-rule = '=='

[build-system]
requires = ["poetry-core"]
Expand Down
Loading

0 comments on commit 2b11ca7

Please sign in to comment.