Skip to content

Commit f2ec4ff

Browse files
abndimbleby
andcommitted
fix(puzzle): handle self-referential extras
Co-authored-by: David Hotham <[email protected]>
1 parent 0bb0e91 commit f2ec4ff

19 files changed

+980
-11
lines changed

src/poetry/puzzle/provider.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -460,18 +460,29 @@ def complete_package(
460460
dependency = dependency_package.dependency
461461
requires = package.requires
462462

463-
optional_dependencies = []
463+
found_extras = set()
464+
optional_dependencies = set()
464465
_dependencies = []
465466

466-
# If some extras/features were required, we need to
467-
# add a special dependency representing the base package
468-
# to the current package
469467
if dependency.extras:
470-
for extra in dependency.extras:
471-
if extra not in package.extras:
468+
# Find all the optional dependencies that are wanted - taking care to allow
469+
# for self-referential extras.
470+
stack = list(dependency.extras)
471+
while stack:
472+
extra = stack.pop()
473+
if extra in found_extras:
472474
continue
475+
found_extras.add(extra)
473476

474-
optional_dependencies += [d.name for d in package.extras[extra]]
477+
extra_dependencies = package.extras.get(extra, [])
478+
for extra_dependency in extra_dependencies:
479+
if extra_dependency.name == dependency.name:
480+
stack += list(extra_dependency.extras)
481+
else:
482+
optional_dependencies.add(extra_dependency.name)
483+
484+
# If some extras/features were required, we need to add a special dependency
485+
# representing the base package to the current package.
475486

476487
dependency_package = dependency_package.with_features(
477488
list(dependency.extras)
@@ -507,10 +518,7 @@ def complete_package(
507518

508519
if not package.is_root() and (
509520
(dep.is_optional() and dep.name not in optional_dependencies)
510-
or (
511-
dep.in_extras
512-
and not set(dep.in_extras).intersection(dependency.extras)
513-
)
521+
or (dep.in_extras and not set(dep.in_extras).intersection(found_extras))
514522
):
515523
continue
516524

tests/conftest.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
from keyring.credentials import SimpleCredential
2222
from keyring.errors import KeyringError
2323
from keyring.errors import KeyringLocked
24+
from packaging.utils import canonicalize_name
25+
from poetry.core.constraints.version import parse_constraint
26+
from poetry.core.packages.dependency import Dependency
27+
from poetry.core.version.markers import parse_marker
2428
from pytest import FixtureRequest
2529

2630
from poetry.config.config import Config as BaseConfig
@@ -29,7 +33,9 @@
2933
from poetry.factory import Factory
3034
from poetry.layouts import layout
3135
from poetry.packages.direct_origin import _get_package_from_git
36+
from poetry.repositories import Repository
3237
from poetry.repositories import RepositoryPool
38+
from poetry.repositories.exceptions import PackageNotFoundError
3339
from poetry.repositories.installed_repository import InstalledRepository
3440
from poetry.utils.cache import ArtifactCache
3541
from poetry.utils.env import EnvManager
@@ -57,6 +63,8 @@
5763
from cleo.io.inputs.argument import Argument
5864
from cleo.io.inputs.option import Option
5965
from keyring.credentials import Credential
66+
from packaging.utils import NormalizedName
67+
from poetry.core.packages.package import Package
6068
from pytest import Config as PyTestConfig
6169
from pytest import Parser
6270
from pytest import TempPathFactory
@@ -66,6 +74,7 @@
6674
from tests.types import CommandFactory
6775
from tests.types import FixtureCopier
6876
from tests.types import FixtureDirGetter
77+
from tests.types import PackageFactory
6978
from tests.types import ProjectFactory
7079
from tests.types import SetProjectContext
7180

@@ -500,6 +509,80 @@ def _factory(
500509
return _factory
501510

502511

512+
@pytest.fixture
513+
def create_package(repo: Repository) -> PackageFactory:
514+
"""
515+
This function is a pytest fixture that creates a factory function to generate
516+
and customize package objects. These packages are added to the default repository
517+
fixture and configured with specific versions, optional extras, and self-referenced
518+
extras. This helps in setting up package dependencies for testing purposes.
519+
520+
:return: A factory function that can be used to create and configure packages.
521+
"""
522+
523+
def create_new_package(
524+
name: str,
525+
version: str | None = None,
526+
dependencies: list[Dependency] | None = None,
527+
extras: dict[str, list[str]] | None = None,
528+
) -> Package:
529+
version = version or "1.0"
530+
package = get_package(name, version)
531+
532+
package_extras: dict[NormalizedName, list[Dependency]] = {}
533+
534+
for extra, extra_dependencies in (extras or {}).items():
535+
extra = canonicalize_name(extra)
536+
537+
if extra not in package_extras:
538+
package_extras[extra] = []
539+
540+
for extra_dependency_spec in extra_dependencies:
541+
extra_dependency = Dependency.create_from_pep_508(extra_dependency_spec)
542+
extra_dependency._optional = True
543+
extra_dependency.marker = extra_dependency.marker.intersect(
544+
parse_marker(f"extra == '{extra}'")
545+
)
546+
547+
if extra_dependency.name != package.name:
548+
assert extra_dependency.constraint.allows(package.version)
549+
550+
# if it is not a self-referencing dependency, make sure we add it to the repo
551+
try:
552+
pkg = repo.package(extra_dependency.name, package.version)
553+
except PackageNotFoundError:
554+
pkg = get_package(extra_dependency.name, str(package.version))
555+
repo.add_package(pkg)
556+
557+
extra_dependency.constraint = parse_constraint(f"^{pkg.version}")
558+
559+
# if requirement already exists in the package, update the marker
560+
for requirement in package.requires:
561+
if (
562+
requirement.name == extra_dependency.name
563+
and requirement.is_optional()
564+
):
565+
requirement.marker = requirement.marker.union(
566+
extra_dependency.marker
567+
)
568+
break
569+
else:
570+
package.add_dependency(extra_dependency)
571+
572+
package_extras[extra].append(extra_dependency)
573+
574+
package.extras = package_extras
575+
576+
for dependency in dependencies or []:
577+
package.add_dependency(dependency)
578+
579+
repo.add_package(package)
580+
581+
return package
582+
583+
return create_new_package
584+
585+
503586
@pytest.fixture(autouse=True)
504587
def set_simple_log_formatter() -> None:
505588
"""
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand.
2+
3+
[[package]]
4+
name = "A"
5+
version = "1.0"
6+
description = ""
7+
optional = false
8+
python-versions = "*"
9+
groups = ["main"]
10+
files = []
11+
12+
[package.dependencies]
13+
download-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"download\""}
14+
install-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"install\""}
15+
16+
[package.extras]
17+
all = ["a[download,install]"]
18+
download = ["download-package (>=1.0,<2.0)"]
19+
install = ["install-package (>=1.0,<2.0)"]
20+
nested = ["a[all]"]
21+
py = ["a[py310,py38]"]
22+
py310 = ["py310-package (>=1.0,<2.0) ; python_version > \"3.8\""]
23+
py38 = ["py38-package (>=1.0,<2.0) ; python_version == \"3.8\""]
24+
25+
[[package]]
26+
name = "B"
27+
version = "1.0"
28+
description = ""
29+
optional = false
30+
python-versions = "*"
31+
groups = ["main"]
32+
files = []
33+
34+
[package.dependencies]
35+
a = {version = "1.0", extras = ["all"]}
36+
37+
[[package]]
38+
name = "download-package"
39+
version = "1.0"
40+
description = ""
41+
optional = false
42+
python-versions = "*"
43+
groups = ["main"]
44+
files = []
45+
46+
[[package]]
47+
name = "install-package"
48+
version = "1.0"
49+
description = ""
50+
optional = false
51+
python-versions = "*"
52+
groups = ["main"]
53+
files = []
54+
55+
[metadata]
56+
lock-version = "2.1"
57+
python-versions = "*"
58+
content-hash = "123456789"
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand.
2+
3+
[[package]]
4+
name = "A"
5+
version = "1.0"
6+
description = ""
7+
optional = false
8+
python-versions = "*"
9+
groups = ["main"]
10+
files = []
11+
12+
[package.dependencies]
13+
download-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"download\""}
14+
install-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"install\""}
15+
16+
[package.extras]
17+
all = ["a[download,install]"]
18+
download = ["download-package (>=1.0,<2.0)"]
19+
install = ["install-package (>=1.0,<2.0)"]
20+
nested = ["a[all]"]
21+
py = ["a[py310,py38]"]
22+
py310 = ["py310-package (>=1.0,<2.0) ; python_version > \"3.8\""]
23+
py38 = ["py38-package (>=1.0,<2.0) ; python_version == \"3.8\""]
24+
25+
[[package]]
26+
name = "download-package"
27+
version = "1.0"
28+
description = ""
29+
optional = false
30+
python-versions = "*"
31+
groups = ["main"]
32+
files = []
33+
34+
[[package]]
35+
name = "install-package"
36+
version = "1.0"
37+
description = ""
38+
optional = false
39+
python-versions = "*"
40+
groups = ["main"]
41+
files = []
42+
43+
[metadata]
44+
lock-version = "2.1"
45+
python-versions = "*"
46+
content-hash = "123456789"
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand.
2+
3+
[[package]]
4+
name = "A"
5+
version = "1.0"
6+
description = ""
7+
optional = false
8+
python-versions = "*"
9+
groups = ["main"]
10+
files = []
11+
12+
[package.dependencies]
13+
download-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"download\""}
14+
install-package = {version = ">=1.0,<2.0", optional = true, markers = "extra == \"install\""}
15+
16+
[package.extras]
17+
all = ["a[download,install] ; python_version < \"3.9\""]
18+
download = ["download-package (>=1.0,<2.0)"]
19+
install = ["install-package (>=1.0,<2.0)"]
20+
21+
[[package]]
22+
name = "download-package"
23+
version = "1.0"
24+
description = ""
25+
optional = false
26+
python-versions = "*"
27+
groups = ["main"]
28+
files = []
29+
30+
[[package]]
31+
name = "install-package"
32+
version = "1.0"
33+
description = ""
34+
optional = false
35+
python-versions = "*"
36+
groups = ["main"]
37+
files = []
38+
39+
[metadata]
40+
lock-version = "2.1"
41+
python-versions = "*"
42+
content-hash = "123456789"
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand.
2+
3+
[[package]]
4+
name = "A"
5+
version = "1.0"
6+
description = ""
7+
optional = false
8+
python-versions = "*"
9+
groups = ["main"]
10+
files = []
11+
12+
[package.extras]
13+
all = ["a[download,install]"]
14+
download = ["download-package (>=1.0,<2.0)"]
15+
install = ["install-package (>=1.0,<2.0)"]
16+
nested = ["a[all]"]
17+
py = ["a[py310,py38]"]
18+
py310 = ["py310-package (>=1.0,<2.0) ; python_version > \"3.8\""]
19+
py38 = ["py38-package (>=1.0,<2.0) ; python_version == \"3.8\""]
20+
21+
[[package]]
22+
name = "B"
23+
version = "1.0"
24+
description = ""
25+
optional = false
26+
python-versions = "*"
27+
groups = ["main"]
28+
files = []
29+
30+
[package.dependencies]
31+
a = "1.0"
32+
33+
[metadata]
34+
lock-version = "2.1"
35+
python-versions = "*"
36+
content-hash = "123456789"

0 commit comments

Comments
 (0)