Skip to content

Commit

Permalink
Support optional @ and beef up unit tests.
Browse files Browse the repository at this point in the history
  • Loading branch information
jsirois committed Oct 29, 2024
1 parent cdd7f49 commit 21e90fc
Show file tree
Hide file tree
Showing 2 changed files with 143 additions and 44 deletions.
43 changes: 22 additions & 21 deletions pex/resolve/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,17 +164,13 @@ class DependencyGroup(object):
def parse(cls, spec):
# type: (str) -> DependencyGroup
group, sep, project_dir = spec.partition("@")
if sep != "@":
raise ValueError(
"Dependency group specifiers must be in the form "
"`<group name>@<project dir path>`, given: {spec}".format(spec=spec)
)
if not os.path.isdir(project_dir):
abs_project_dir = os.path.realpath(project_dir)
if not os.path.isdir(abs_project_dir):
raise ValueError(
"The project directory specified by '{spec}' is not a directory".format(spec=spec)
)

pyproject_toml = os.path.join(project_dir, "pyproject.toml")
pyproject_toml = os.path.join(abs_project_dir, "pyproject.toml")
if not os.path.isfile(pyproject_toml):
raise ValueError(
"The project directory specified by '{spec}' does not contain a pyproject.toml "
Expand All @@ -200,7 +196,7 @@ def parse(cls, spec):
"{pyproject_toml}".format(group=group, spec=spec, pyproject_toml=pyproject_toml)
)

return cls(project_dir=project_dir, name=group_name, groups=dependency_groups)
return cls(project_dir=abs_project_dir, name=group_name, groups=dependency_groups)

project_dir = attr.ib() # type: str
name = attr.ib() # type: GroupName
Expand Down Expand Up @@ -230,8 +226,9 @@ def _parse_group_items(

if not isinstance(members, list):
raise ValueError(
"Invalid dependency group '{group}' in the project at {project_dir}. The value "
"must be a list containing dependency specifiers or dependency group includes.\n"
"Invalid dependency group '{group}' in the project at {project_dir}.\n"
"The value must be a list containing dependency specifiers or dependency group "
"includes.\n"
"See https://peps.python.org/pep-0735/#specification for the specification "
"of [dependency-groups] syntax."
)
Expand All @@ -242,8 +239,8 @@ def _parse_group_items(
yield Requirement.parse(item)
except RequirementParseError as e:
raise ValueError(
"Invalid [dependency-group] entry '{name}'. Item {index} of '{req}' is "
"an invalid dependency specifier: {err}".format(
"Invalid [dependency-group] entry '{name}'.\n"
"Item {index}: '{req}', is an invalid dependency specifier: {err}".format(
name=group.raw, index=index, req=item, err=e
)
)
Expand All @@ -252,17 +249,18 @@ def _parse_group_items(
yield GroupName(item["include-group"])
except KeyError:
raise ValueError(
"Invalid [dependency-group] entry '{name}'. Item {index} is a non "
"'include-group' table and only dependency specifiers and single entry "
"'include-group' tables are allowed in group dependency lists.\n"
"Invalid [dependency-group] entry '{name}'.\n"
"Item {index} is a non 'include-group' table and only dependency "
"specifiers and single entry 'include-group' tables are allowed in group "
"dependency lists.\n"
"See https://peps.python.org/pep-0735/#specification for the specification "
"of [dependency-groups] syntax.\n"
"Given: {item}".format(name=group.raw, index=index, item=item)
)
else:
raise ValueError(
"Invalid [dependency-group] entry '{name}'. Item {index} is not a "
"dependency specifier or a dependency group include.\n"
"Invalid [dependency-group] entry '{name}'.\n"
"Item {index} is not a dependency specifier or a dependency group include.\n"
"See https://peps.python.org/pep-0735/#specification for the specification "
"of [dependency-groups] syntax.\n"
"Given: {item}".format(name=group.raw, index=index, item=item)
Expand Down Expand Up @@ -310,16 +308,19 @@ def register_options(
"--group",
"--dependency-group",
dest="dependency_groups",
metavar="GROUP@DIR",
metavar="GROUP[@DIR]",
default=[],
type=DependencyGroup.parse,
action="append",
help=(
"Pull requirements from the specified PEP-735 dependency group. Dependency groups are "
"specified by referencing the group name in a given project's pyproject.toml in the "
"form `<group name>@<project directory>`; e.g.: `test@local/project/directory`."
"Multiple dependency groups across any number of projects can be specified. Read more "
"about dependency groups at https://peps.python.org/pep-0735/."
"form `<group name>@<project directory>`; e.g.: `test@local/project/directory`. If "
"either the `@<project directory>` suffix is not present or the suffix is just `@`, "
"the current working directory is assumed to be the project directory to read the "
"dependency group information from. Multiple dependency groups across any number of "
"projects can be specified. Read more about dependency groups at "
"https://peps.python.org/pep-0735/."
),
)

Expand Down
144 changes: 121 additions & 23 deletions tests/resolve/test_dependency_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,19 @@
from __future__ import absolute_import

import os.path
import re
import sys
from argparse import ArgumentParser, Namespace
from textwrap import dedent
from typing import List
from typing import List, Optional, Sequence

import pytest

from pex.common import safe_open
from pex.dist_metadata import Requirement
from pex.resolve import project
from pex.typing import cast
from testing import pushd
from testing.pytest.tmp import Tempdir


Expand Down Expand Up @@ -42,6 +44,13 @@ def project_dir1(tmpdir):
basic = ["foo", "bar>2"]
include1 = [{include-group = "basic"}]
include2 = ["spam", {include-group = "include1"}, "bar", "foo"]
# Per the spec, parsing should be lazy; so we should never "see" the bogus
# `set-phasers-to` inline table element.
bar = [{set-phasers-to = "stun"}]
bad-req = ["meaning-of-life=42"]
missing-include = [{include-group = "does-not-exist"}]
"""
),
)
Expand All @@ -64,57 +73,146 @@ def project_dir2(tmpdir):
)


def parse_args(*args):
# type: (*str) -> Namespace
parser = ArgumentParser()
project.register_options(parser, project_help="test")
return parser.parse_args(args=args)
def parse_args(
args, # type: Sequence[str]
cwd=None, # type: Optional[str]
):
# type: (...) -> Namespace
with pushd(cwd or os.getcwd()):
parser = ArgumentParser()
project.register_options(parser, project_help="test")
return parser.parse_args(args=args)


def parse_groups(*args):
# type: (*str) -> List[Requirement]
return list(project.get_group_requirements(parse_args(*args)))
def parse_groups(
args, # type: Sequence[str]
cwd=None, # type: Optional[str]
):
# type: (...) -> List[Requirement]
return list(project.get_group_requirements(parse_args(args, cwd=cwd)))


req = Requirement.parse


def test_nominal(project_dir1):
# type: (str) -> None
assert [req("foo"), req("bar>2")] == parse_groups(
"--group", "basic@{project_dir}".format(project_dir=project_dir1)
expected_reqs = [req("foo"), req("bar>2")]
assert expected_reqs == parse_groups(
["--group", "basic@{project_dir}".format(project_dir=project_dir1)]
)
assert expected_reqs == parse_groups(["--group", "basic"], cwd=project_dir1)


def test_include(project_dir1):
# type: (str) -> None
assert [req("foo"), req("bar>2")] == parse_groups(
"--group", "include1@{project_dir}".format(project_dir=project_dir1)
expected_reqs = [req("foo"), req("bar>2")]
assert expected_reqs == parse_groups(
["--group", "include1@{project_dir}".format(project_dir=project_dir1)]
)
assert expected_reqs == parse_groups(["--group", "include1"], cwd=project_dir1)


def test_include_multi(project_dir1):
# type: (str) -> None
assert [req("spam"), req("foo"), req("bar>2"), req("bar")] == parse_groups(
"--group", "include2@{project_dir}".format(project_dir=project_dir1)
expected_reqs = [req("spam"), req("foo"), req("bar>2"), req("bar")]
assert expected_reqs == parse_groups(
["--group", "include2@{project_dir}".format(project_dir=project_dir1)]
)
assert expected_reqs == parse_groups(["--group", "include2@."], cwd=project_dir1)


def test_multiple_projects(
project_dir1, # type: str
project_dir2, # type: str
):
# type: (...) -> None
assert [
expected_reqs = [
req("foo"),
req("bar>2"),
req("baz<3; python_version < '3.9'"),
req("baz; python_version >= '3.9'"),
] == parse_groups(
"--group",
"include1@{project_dir}".format(project_dir=project_dir1),
"--group",
"basic@{project_dir}".format(project_dir=project_dir2),
"--group",
"basic@{project_dir}".format(project_dir=project_dir1),
]
assert expected_reqs == parse_groups(
[
"--group",
"include1@{project_dir}".format(project_dir=project_dir1),
"--group",
"basic@{project_dir}".format(project_dir=project_dir2),
"--group",
"basic@{project_dir}".format(project_dir=project_dir1),
]
)
assert expected_reqs == parse_groups(
[
"--group",
"include1",
"--group",
"basic@{project_dir}".format(project_dir=project_dir2),
"--group",
"basic@",
],
cwd=project_dir1,
)


def test_missing_group(project_dir1):
# type: (str) -> None

with pytest.raises(
KeyError,
match=re.escape(
"The dependency group 'does-not-exist' specified by 'does-not-exist@{project}' does "
"not exist in {project}".format(project=project_dir1)
),
):
parse_args(["--group", "does-not-exist@{project}".format(project=project_dir1)])


def test_invalid_group_bad_req(project_dir1):
# type: (str) -> None

options = parse_args(["--group", "bad-req"], cwd=project_dir1)
with pytest.raises(
ValueError,
match=re.escape(
"Invalid [dependency-group] entry 'bad-req'.\n"
"Item 1: 'meaning-of-life=42', is an invalid dependency specifier: Expected end or "
"semicolon (after name and no valid version specifier)\n"
" meaning-of-life=42\n"
" ^"
),
):
project.get_group_requirements(options)


def test_invalid_group_bad_inline_table(project_dir1):
# type: (str) -> None

options = parse_args(["--group", "bar"], cwd=project_dir1)
with pytest.raises(
ValueError,
match=re.escape(
"Invalid [dependency-group] entry 'bar'.\n"
"Item 1 is a non 'include-group' table and only dependency specifiers and single entry "
"'include-group' tables are allowed in group dependency lists.\n"
"See https://peps.python.org/pep-0735/#specification for the specification of "
"[dependency-groups] syntax.\n"
"Given: {'set-phasers-to': 'stun'}"
),
):
project.get_group_requirements(options)


def test_invalid_group_missing_include(project_dir1):
# type: (str) -> None

options = parse_args(["--group", "missing-include"], cwd=project_dir1)
with pytest.raises(
KeyError,
match=re.escape(
"The dependency group 'does-not-exist' required by dependency group 'missing-include' "
"does not exist in the project at {project}.".format(project=project_dir1)
),
):
project.get_group_requirements(options)

0 comments on commit 21e90fc

Please sign in to comment.