Skip to content

Commit 55ee05d

Browse files
authored
Support for poetry export (#61)
* 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
1 parent 67371ca commit 55ee05d

35 files changed

+616
-1230
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
**Main Features**:
1010
- Shared `poetry.lock` file across multiple projects in a monorepo
1111
- Shared virtual environment across multiple projects in a monorepo
12-
- Rewrite path dependencies during `poetry build`
12+
- Replace path dependencies during `poetry build` with pinned versions
1313
- Compatible with both Poetry v1 and v2
1414

1515
## Installation
@@ -55,7 +55,7 @@ library-two = { path = "../library-two", develop = true }
5555
[tool.poetry-monoranger-plugin]
5656
enabled = true
5757
monorepo-root = "../"
58-
version-rewrite-rule = '==' # Choose between "==", "~", "^", ">=,<"
58+
version-pinning-rule = '==' # Choose between "==", "~", "^", ">=,<"
5959
# ...
6060
```
6161
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.
@@ -97,7 +97,7 @@ The plugin can be configured in the `pyproject.toml` file of each project. The f
9797

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

102102
## License
103103
This project is licensed under the Apache 2.0 license - see the [LICENSE](LICENSE) file for details.

poetry.lock

Lines changed: 16 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

poetry_monoranger_plugin/config.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from __future__ import annotations
77

8+
import warnings
89
from dataclasses import dataclass
910
from typing import Any, Literal
1011

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

2223
enabled: bool = False
2324
monorepo_root: str = "../"
24-
version_rewrite_rule: Literal["==", "~", "^", ">=,<"] = "^"
25+
version_pinning_rule: Literal["==", "~", "^", ">=,<"] = None # type: ignore[assignment]
26+
version_rewrite_rule: Literal["==", "~", "^", ">=,<", None] = None
27+
28+
def __post_init__(self):
29+
if self.version_pinning_rule is None and self.version_rewrite_rule is None:
30+
self.version_pinning_rule = "^" # default value
31+
elif self.version_rewrite_rule is not None and self.version_pinning_rule is not None:
32+
raise ValueError(
33+
"Cannot specify both `version_pinning_rule` and `version_rewrite_rule`. "
34+
"`version_rewrite_rule` is deprecated. Please use version_pinning_rule instead."
35+
)
36+
elif self.version_rewrite_rule is not None:
37+
with warnings.catch_warnings():
38+
warnings.filterwarnings("default", category=DeprecationWarning)
39+
warnings.warn(
40+
"`version_rewrite_rule` is deprecated. Please use `version_pinning_rule` instead.",
41+
DeprecationWarning,
42+
stacklevel=2,
43+
)
44+
self.version_pinning_rule = self.version_rewrite_rule
2545

2646
@classmethod
2747
def from_dict(cls, d: dict[str, Any]):
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"""Copyright (C) 2024 GlaxoSmithKline plc
2+
3+
This module defines the ExportModifier class, which modifies the `poetry export` command. Similarly to
4+
`poetry build` this exports the dependencies of a subproject to an alternative format while ensuring path
5+
dependencies are pinned to specific versions.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import shutil
11+
import tempfile
12+
import weakref
13+
from pathlib import Path
14+
from typing import TYPE_CHECKING, Any, TypeVar
15+
16+
import poetry.__version__ as poetry_version
17+
from poetry.config.config import Config
18+
from poetry.core.packages.dependency_group import MAIN_GROUP
19+
from poetry.factory import Factory
20+
from poetry.packages.locker import Locker
21+
from poetry.poetry import Poetry
22+
from poetry_plugin_export.command import ExportCommand
23+
24+
from poetry_monoranger_plugin.path_dep_pinner import PathDepPinner
25+
26+
if TYPE_CHECKING:
27+
from cleo.events.console_command_event import ConsoleCommandEvent
28+
from cleo.io.io import IO
29+
from poetry.core.packages.dependency import Dependency
30+
from poetry.core.packages.package import Package
31+
32+
from poetry_monoranger_plugin.config import MonorangerConfig
33+
34+
POETRY_V2 = poetry_version.__version__.startswith("2.")
35+
36+
TemporaryLockerT = TypeVar("TemporaryLockerT", bound="TemporaryLocker")
37+
38+
if not POETRY_V2:
39+
from poetry.core.packages.directory_dependency import DirectoryDependency
40+
from poetry.core.packages.project_package import ProjectPackage
41+
42+
class PathDepPinningPackage(ProjectPackage):
43+
"""A modified ProjectPackage that pins path dependencies to specific versions
44+
45+
*NOTE*: Only required for Poetry V1
46+
"""
47+
48+
_pinner: PathDepPinner
49+
50+
@classmethod
51+
def from_package(cls, package: Package, pinner: PathDepPinner) -> PathDepPinningPackage:
52+
"""Creates a new PathDepPinningPackage from an existing Package"""
53+
new_package = cls.__new__(cls)
54+
new_package.__dict__.update(package.__dict__)
55+
56+
new_package._pinner = pinner
57+
return new_package
58+
59+
@property
60+
def all_requires(self) -> list[Dependency]:
61+
"""Returns the main dependencies and group dependencies
62+
enriched with Poetry-specific information for locking while ensuring
63+
path dependencies are pinned to specific versions.
64+
"""
65+
deps = super().all_requires
66+
# noinspection PyProtectedMember
67+
deps = [self._pinner._pin_dependency(dep) if isinstance(dep, DirectoryDependency) else dep for dep in deps]
68+
return deps
69+
70+
71+
class TemporaryLocker(Locker):
72+
"""A temporary locker that is used to store the lock file in a temporary file."""
73+
74+
@classmethod
75+
def from_locker(cls: type[TemporaryLockerT], locker: Locker, data: dict[str, Any] | None) -> TemporaryLockerT:
76+
"""Creates a temporary locker from an existing locker."""
77+
temp_file = tempfile.NamedTemporaryFile(prefix="poetry_lock_", delete=False) # noqa: SIM115
78+
temp_file_path = Path(temp_file.name)
79+
temp_file.close()
80+
81+
shutil.copy(locker.lock, temp_file_path)
82+
83+
if data is None:
84+
data = locker._pyproject_data if POETRY_V2 else locker._local_config # type: ignore[attr-defined]
85+
86+
new_locker: TemporaryLockerT = cls(temp_file_path, data)
87+
weakref.finalize(new_locker, temp_file_path.unlink)
88+
89+
return new_locker
90+
91+
92+
def _pin_package(package: Package, pinner: PathDepPinner, io: IO) -> Package:
93+
"""Pins a package to a specific version if it is a path dependency"""
94+
if package.source_type == "directory":
95+
package._source_type = None
96+
package._source_url = None
97+
98+
# noinspection PyProtectedMember
99+
if package._dependency_groups and MAIN_GROUP in package._dependency_groups:
100+
# noinspection PyProtectedMember
101+
main_deps_group = package._dependency_groups[MAIN_GROUP]
102+
# noinspection PyProtectedMember
103+
pinner._pin_dep_grp(main_deps_group, io)
104+
return package
105+
106+
107+
class ExportModifier:
108+
"""Modifies Poetry commands (`lock`, `install`, `update`) for monorepo support.
109+
110+
Ensures these commands behave as if they were run from the monorepo root directory
111+
even when run from a subdirectory, thus maintaining a shared lockfile.
112+
"""
113+
114+
def __init__(self, plugin_conf: MonorangerConfig):
115+
self.plugin_conf = plugin_conf
116+
117+
def execute(self, event: ConsoleCommandEvent):
118+
"""Modifies the command to run from the monorepo root.
119+
120+
Ensures the command is one of `LockCommand`, `InstallCommand`, or `UpdateCommand`.
121+
Sets up the necessary Poetry instance and installer for the monorepo root so that
122+
the command behaves as if it was executed from within the root directory.
123+
124+
Args:
125+
event (ConsoleCommandEvent): The triggering event.
126+
"""
127+
command = event.command
128+
assert isinstance(command, ExportCommand), (
129+
f"{self.__class__.__name__} can only be used for `poetry export` command"
130+
)
131+
132+
io = event.io
133+
io.write_line("<info>Running command from monorepo root directory</info>")
134+
135+
# Create a copy of the poetry object to prevent the command from modifying the original poetry object
136+
poetry = Poetry.__new__(Poetry)
137+
poetry.__dict__.update(command.poetry.__dict__)
138+
139+
# Force reload global config in order to undo changes that happened due to subproject's poetry.toml configs
140+
_ = Config.create(reload=True)
141+
monorepo_root = (command.poetry.pyproject_path.parent / self.plugin_conf.monorepo_root).resolve()
142+
monorepo_root_poetry = Factory().create_poetry(
143+
cwd=monorepo_root, io=io, disable_cache=command.poetry.disable_cache
144+
)
145+
146+
if POETRY_V2:
147+
temp_locker = TemporaryLocker.from_locker(monorepo_root_poetry.locker, poetry.pyproject.data)
148+
else:
149+
temp_locker = TemporaryLocker.from_locker(monorepo_root_poetry.locker, poetry.pyproject.poetry_config)
150+
151+
from poetry.puzzle.solver import Solver
152+
153+
locked_repository = monorepo_root_poetry.locker.locked_repository()
154+
solver = Solver(
155+
poetry.package,
156+
poetry.pool,
157+
locked_repository.packages,
158+
locked_repository.packages,
159+
io,
160+
)
161+
162+
# Always re-solve directory dependencies, otherwise we can't determine
163+
# if anything has changed (and the lock file contains an invalid version).
164+
use_latest = [p.name for p in locked_repository.packages if p.source_type == "directory"]
165+
pinner = PathDepPinner(self.plugin_conf)
166+
packages: list[Package] | dict[Package, Any]
167+
if POETRY_V2:
168+
solved_packages: dict[Package, Any] = solver.solve(use_latest=use_latest).get_solved_packages() # type: ignore[attr-defined]
169+
packages = {_pin_package(pak, pinner, io): info for pak, info in solved_packages.items()}
170+
else:
171+
from poetry.installation.operations import Uninstall, Update
172+
from poetry.repositories.lockfile_repository import LockfileRepository
173+
174+
ops = solver.solve(use_latest=use_latest).calculate_operations()
175+
packages = [
176+
op.target_package if isinstance(op, Update) else op.package
177+
for op in ops
178+
if not isinstance(op, Uninstall)
179+
]
180+
181+
lockfile_repo = LockfileRepository()
182+
for package in packages:
183+
if not lockfile_repo.has_package(package):
184+
lockfile_repo.add_package(package)
185+
186+
packages = [_pin_package(pak, pinner, io) for pak in lockfile_repo.packages]
187+
188+
if not POETRY_V2:
189+
poetry._package = PathDepPinningPackage.from_package(poetry.package, pinner)
190+
191+
temp_locker.set_lock_data(poetry.package, packages) # type: ignore[arg-type]
192+
poetry.set_locker(temp_locker)
193+
command.set_poetry(poetry)

0 commit comments

Comments
 (0)