diff --git a/docs/usage/advanced.md b/docs/usage/advanced.md index 2c3f15a4c4..cc4d2d65f1 100644 --- a/docs/usage/advanced.md +++ b/docs/usage/advanced.md @@ -186,7 +186,7 @@ With PDM, you can have multiple sub-packages within a single project, each with `project/pyproject.toml`: ```toml -[tool.pdm.dev-dependencies] +[dependency-groups] dev = [ "-e file:///${PROJECT_ROOT}/packages/foo-core", "-e file:///${PROJECT_ROOT}/packages/foo-cli", @@ -244,7 +244,7 @@ This hook wraps the command `pdm lock --check` along with any valid argument. It ### Sync current working set with `pdm.lock` -This hook wraps the command `pdm sync` along with any valid argument. It can be used as a hook to ensure that your current working set is synced with `pdm.lock` whenever you checkout or merge a branch. Add *keyring* to `additional_dependencies` if you want to use your systems credential store. +This hook wraps the command `pdm sync` along with any valid argument. It can be used as a hook to ensure that your current working set is synced with `pdm.lock` whenever you checkout or merge a branch. Add _keyring_ to `additional_dependencies` if you want to use your systems credential store. ```yaml - repo: https://github.com/pdm-project/pdm diff --git a/docs/usage/dependency.md b/docs/usage/dependency.md index 33f4bdbe2c..5fc8fef349 100644 --- a/docs/usage/dependency.md +++ b/docs/usage/dependency.md @@ -137,7 +137,7 @@ pdm add -dG test pytest This will result in a pyproject.toml as following: ```toml -[tool.pdm.dev-dependencies] +[dependency-groups] test = ["pytest"] ``` @@ -145,7 +145,7 @@ You can have several groups of development only dependencies. Unlike `optional-d The package index won't be aware of these dependencies. The schema is similar to that of `optional-dependencies`, except that it is in `tool.pdm` table. ```toml -[tool.pdm.dev-dependencies] +[dependency-groups] lint = [ "flake8", "black" @@ -154,10 +154,10 @@ test = ["pytest", "pytest-cov"] doc = ["mkdocs"] ``` -For backward-compatibility, if only `-d` or `--dev` is specified, dependencies will go to `dev` group under `[tool.pdm.dev-dependencies]` by default. +For backward-compatibility, if only `-d` or `--dev` is specified, dependencies will go to `dev` group under `[dependency-groups]` by default. !!! NOTE - The same group name MUST NOT appear in both `[tool.pdm.dev-dependencies]` and `[project.optional-dependencies]`. + The same group name MUST NOT appear in both `[dependency-groups]` and `[project.optional-dependencies]`. ### Editable dependencies @@ -258,7 +258,7 @@ To remove existing dependencies from project file and the library directory: pdm remove requests # Remove h11 from the 'web' group of optional-dependencies pdm remove -G web h11 -# Remove pytest-cov from the `test` group of dev-dependencies +# Remove pytest-cov from the `test` group of dependency-groups pdm remove -dG test pytest-cov ``` @@ -290,7 +290,7 @@ dependencies = ["requests"] extra1 = ["flask"] extra2 = ["django"] -[tool.pdm.dev-dependencies] # This is dev dependencies +[dependency-groups] # This is dev dependencies dev1 = ["pytest"] dev2 = ["mkdocs"] ``` diff --git a/docs/usage/lockfile.md b/docs/usage/lockfile.md index 78d6fddb69..b909e9e599 100644 --- a/docs/usage/lockfile.md +++ b/docs/usage/lockfile.md @@ -25,8 +25,7 @@ There are a few similar commands to do this job with slight differences: - `--clean`: will remove packages no longer in the lockfile - `--clean-unselected` (or `--only-keep`): more thorough version of `--clean` that will also remove packages not in the groups specified by the `-G`, `-d`, and `--prod` options. -Note: by default, `pdm sync` selects all groups from the lockfile, so `--clean-unselected` is identical to `--clean` unless `-G`, `-d`, and `--prod` are used. - + Note: by default, `pdm sync` selects all groups from the lockfile, so `--clean-unselected` is identical to `--clean` unless `-G`, `-d`, and `--prod` are used. ## Hashes in the lock file @@ -44,7 +43,7 @@ If you want to refresh the lock file without changing the dependencies, you can pdm lock --refresh ``` -This command also refreshes *all* file hashes recorded in the lock file. +This command also refreshes _all_ file hashes recorded in the lock file. ## Specify another lock file to use @@ -65,7 +64,7 @@ For a realistic example, your project depends on a release version of `werkzeug` requires-python = ">=3.7" dependencies = ["werkzeug"] -[tool.pdm.dev-dependencies] +[dependency-groups] dev = ["werkzeug @ file:///${PROJECT_ROOT}/dev/werkzeug"] ``` diff --git a/news/3230.feature.md b/news/3230.feature.md new file mode 100644 index 0000000000..c91485f28b --- /dev/null +++ b/news/3230.feature.md @@ -0,0 +1 @@ +Support dependency groups as standardized by [PEP 735](https://peps.python.org/pep-0735/). By default, dev dependencies will be written to `[dependency-groups]` table. diff --git a/src/pdm/cli/commands/add.py b/src/pdm/cli/commands/add.py index 804a8d8d90..cef31b9c35 100644 --- a/src/pdm/cli/commands/add.py +++ b/src/pdm/cli/commands/add.py @@ -122,11 +122,7 @@ def do_add( if project.enable_write_lockfile: project.core.ui.info(f"Adding group [success]{group}[/] to lockfile") lock_groups.append(group) - if ( - group == "default" - or not selection.dev - and group not in project.pyproject.settings.get("dev-dependencies", {}) - ): + if group == "default" or not selection.dev and group not in project.pyproject.dev_dependencies: if editables: raise PdmUsageError("Cannot add editables to the default or optional dependency group") for r in [parse_requirement(line, True) for line in editables] + [parse_requirement(line) for line in packages]: diff --git a/src/pdm/cli/commands/remove.py b/src/pdm/cli/commands/remove.py index 13ae5b5e3a..8785a3341b 100644 --- a/src/pdm/cli/commands/remove.py +++ b/src/pdm/cli/commands/remove.py @@ -1,7 +1,7 @@ from __future__ import annotations import argparse -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING from pdm.cli.commands.base import BaseCommand from pdm.cli.filters import GroupSelection @@ -82,7 +82,6 @@ def do_remove( hooks: HookManager | None = None, ) -> None: """Remove packages from working set and pyproject.toml""" - from tomlkit.items import Array from pdm.cli.actions import do_lock, do_sync from pdm.cli.utils import check_project_file @@ -111,7 +110,7 @@ def do_remove( for i in matched_indexes: del deps[i] tracked_names.add(normalize_name(name)) - setter(cast(Array, deps).multiline(True)) + setter(deps) if not dry_run: project.pyproject.write() diff --git a/src/pdm/cli/filters.py b/src/pdm/cli/filters.py index 91abfcd2ce..b8e6f217bf 100644 --- a/src/pdm/cli/filters.py +++ b/src/pdm/cli/filters.py @@ -79,7 +79,7 @@ def _translated_groups(self) -> list[str]: dev = True project = self.project optional_groups = set(project.pyproject.metadata.get("optional-dependencies", {})) - dev_groups = set(project.pyproject.settings.get("dev-dependencies", {})) + dev_groups = set(project.pyproject.dev_dependencies) groups_set = set(groups) if groups_set & dev_groups: if not dev: diff --git a/src/pdm/cli/options.py b/src/pdm/cli/options.py index 87c137d8d3..1a7ab3f124 100644 --- a/src/pdm/cli/options.py +++ b/src/pdm/cli/options.py @@ -256,7 +256,7 @@ def no_isolation_option( metavar="GROUP", action=split_lists(","), help="Select group of optional-dependencies separated by comma " - "or dev-dependencies (with `-d`). Can be supplied multiple times, " + "or dependency-groups (with `-d`). Can be supplied multiple times, " 'use ":all" to include all groups under the same species.', default=[], ) @@ -265,7 +265,7 @@ def no_isolation_option( dest="excluded_groups", metavar="", action=split_lists(","), - help="Exclude groups of optional-dependencies or dev-dependencies", + help="Exclude groups of optional-dependencies or dependency-groups", default=[], ) groups_group.add_argument( diff --git a/src/pdm/installers/core.py b/src/pdm/installers/core.py index 44caa6da5e..db8afbfcd7 100644 --- a/src/pdm/installers/core.py +++ b/src/pdm/installers/core.py @@ -5,7 +5,6 @@ from pdm.environments import BaseEnvironment from pdm.models.requirements import Requirement from pdm.resolver.reporters import LockReporter -from pdm.resolver.resolvelib import RLResolver def install_requirements( @@ -29,8 +28,6 @@ def install_requirements( keep_self=True, reporter=reporter, ) - if isinstance(resolver, RLResolver): - resolver.provider.repository.find_dependencies_from_local = False resolved = resolver.resolve().packages syncer = environment.project.get_synchronizer(quiet=True)( environment, diff --git a/src/pdm/models/repositories/base.py b/src/pdm/models/repositories/base.py index 5fc2fed23c..e1e9f3d95f 100644 --- a/src/pdm/models/repositories/base.py +++ b/src/pdm/models/repositories/base.py @@ -72,8 +72,6 @@ def __init__( self._candidate_info_cache = environment.project.make_candidate_info_cache() self._hash_cache = environment.project.make_hash_cache() self.has_warnings = False - self.collected_groups: set[str] = set() - self.find_dependencies_from_local = True if ignore_compatibility is not NotSet: # pragma: no cover deprecation_warning( "The ignore_compatibility argument is deprecated and will be removed in the future. " @@ -284,26 +282,6 @@ def _get_dependencies_from_metadata(self, candidate: Candidate) -> CandidateMeta summary = prepared.metadata.metadata.get("Summary", "") return CandidateMetadata(deps, requires_python, summary) - def _get_dependencies_from_local_package(self, candidate: Candidate) -> CandidateMetadata: - """Adds the local package as a candidate only if the candidate - name is the same as the local package.""" - project = self.environment.project - if not project.is_distribution or candidate.name != project.name: - raise CandidateInfoNotFound(candidate) from None - - reqs: list[Requirement] = [] - if candidate.req.extras is not None: - all_groups = set(project.iter_groups()) - for extra in candidate.req.extras: - if extra in all_groups: - reqs.extend(project.get_dependencies(extra)) - self.collected_groups.add(extra) - return CandidateMetadata( - reqs, - str(self.environment.python_requires), - project.pyproject.metadata.get("description", "UNKNOWN"), - ) - def get_hashes(self, candidate: Candidate) -> list[FileHash]: """Get hashes of all possible installable candidates of a given package version. diff --git a/src/pdm/models/repositories/lock.py b/src/pdm/models/repositories/lock.py index 3ef65c29ec..17606af88f 100644 --- a/src/pdm/models/repositories/lock.py +++ b/src/pdm/models/repositories/lock.py @@ -136,10 +136,7 @@ def _get_dependencies_from_lockfile(self, candidate: Candidate) -> CandidateMeta return CandidateMetadata(deps, candidate.requires_python, entry.summary) def dependency_generators(self) -> Iterable[Callable[[Candidate], CandidateMetadata]]: - return ( - self._get_dependencies_from_local_package, - self._get_dependencies_from_lockfile, - ) + return (self._get_dependencies_from_lockfile,) def _matching_entries(self, requirement: Requirement) -> Iterable[Package]: for key, entry in self.packages.items(): diff --git a/src/pdm/models/repositories/pypi.py b/src/pdm/models/repositories/pypi.py index 16e2592acd..5018d9ecf6 100644 --- a/src/pdm/models/repositories/pypi.py +++ b/src/pdm/models/repositories/pypi.py @@ -51,8 +51,6 @@ def _get_dependencies_from_json(self, candidate: Candidate) -> CandidateMetadata def dependency_generators(self) -> Iterable[Callable[[Candidate], CandidateMetadata]]: yield self._get_dependencies_from_cache - if self.find_dependencies_from_local: - yield self._get_dependencies_from_local_package if self.environment.project.config["pypi.json_api"]: yield self._get_dependencies_from_json yield self._get_dependencies_from_metadata diff --git a/src/pdm/project/core.py b/src/pdm/project/core.py index 1f09ecf6f6..114bd9b9d7 100644 --- a/src/pdm/project/core.py +++ b/src/pdm/project/core.py @@ -13,7 +13,6 @@ import tomlkit from pbs_installer import PythonVersion -from tomlkit.items import Array from pdm._types import NotSet, NotSetType, RepositoryConfig from pdm.compat import CompatibleSequence @@ -39,6 +38,7 @@ is_conda_base, is_conda_base_python, is_path_relative_to, + normalize_name, path_to_url, ) @@ -58,6 +58,81 @@ PYENV_ROOT = os.path.expanduser(os.getenv("PYENV_ROOT", "~/.pyenv")) +def _resolve_dependency_group( + project_name: str | None, + dependency_groups: dict[str, list[str]], + group: str, + past_groups: tuple[str, ...] = (), + optional_dependencies: dict[str, list[str]] | None = None, + allow_optional: bool = False, +) -> tuple[list[str], dict[str, set[str]]]: + optional_dependencies = optional_dependencies or {} + if group in past_groups: + raise ProjectError(f"Cyclic dependency group include: {group} -> {past_groups}") + + if group in dependency_groups: + raw_group = dependency_groups[group] + # dependency groups are allowed to refer to other dependency groups + child_dependencies = dependency_groups + elif allow_optional and group in optional_dependencies: + raw_group = optional_dependencies[group] + # optional dependencies are not allowed to refer to dependency groups + child_dependencies = optional_dependencies + else: + raise ProjectError(f"Dependency group '{group}' not found") + + if not isinstance(raw_group, list): + raise ProjectError(f"Dependency group '{group}' is not a list") + + realized_group: list[str] = [] + referred_groups: dict[str, set[str]] = {} + for item in raw_group: + if isinstance(item, str): + try: + req, extras = strip_extras(item) + except AssertionError: + pass + else: + if normalize_name(req) == project_name: + for extra in extras or (): + resolved, referred = _resolve_dependency_group( + project_name, + child_dependencies, + extra, + (*past_groups, group), + optional_dependencies, + # `self[extra]` is allowed to refer to optional dependency groups + allow_optional=True, + ) + realized_group.extend(resolved) + for k, v in referred.items(): + referred_groups.setdefault(k, {group}).update(v) + continue + realized_group.append(item) + referred_groups.setdefault(item, {group}) + elif isinstance(item, dict): + if tuple(item.keys()) != ("include-group",): + raise ProjectError(f"Invalid dependency group item: {item}") + + include_group = normalize_name(next(iter(item.values()))) + resolved, referred = _resolve_dependency_group( + project_name, + child_dependencies, + include_group, + (*past_groups, group), + optional_dependencies, + # `include-group` is not allowed to refer to optional dependency groups + allow_optional=False, + ) + realized_group.extend(resolved) + for k, v in referred.items(): + referred_groups.setdefault(k, {group}).update(v) + else: + raise ProjectError(f"Invalid dependency group item: {item}") + + return realized_group, referred_groups + + class Project: """Core project class. @@ -326,20 +401,24 @@ def get_dependencies(self, group: str | None = None) -> Sequence[Requirement]: metadata = self.pyproject.metadata group = group or "default" optional_dependencies = metadata.get("optional-dependencies", {}) - dev_dependencies = self.pyproject.settings.get("dev-dependencies", {}) + dev_dependencies = self.pyproject.dev_dependencies in_metadata = group == "default" or group in optional_dependencies + referred_groups: dict[str, set[str]] = {} + project_name = normalize_name(self.name) if self.name else None if group == "default": deps = metadata.get("dependencies", []) else: if group in optional_dependencies and group in dev_dependencies: self.core.ui.info( f"The {group} group exists in both \\[optional-dependencies] " - "and \\[dev-dependencies], the former is taken." + "and \\[dependency-groups], the former is taken." ) if group in optional_dependencies: - deps = optional_dependencies[group] + deps, referred_groups = _resolve_dependency_group(project_name, optional_dependencies, group, ()) elif group in dev_dependencies: - deps = dev_dependencies[group] + deps, referred_groups = _resolve_dependency_group( + project_name, dev_dependencies, group, (), optional_dependencies + ) else: raise PdmUsageError(f"Non-exist group {group}") result = [] @@ -353,7 +432,7 @@ def get_dependencies(self, group: str | None = None) -> Sequence[Requirement]: ) continue req = parse_line(line) - req.groups = [group] + req.groups = list(referred_groups.get(line, [group])) # make editable packages behind normal ones to override correctly. result.append(req) return CompatibleSequence(result) @@ -362,8 +441,7 @@ def iter_groups(self) -> Iterable[str]: groups = {"default"} if self.pyproject.metadata.get("optional-dependencies"): groups.update(self.pyproject.metadata["optional-dependencies"].keys()) - if self.pyproject.settings.get("dev-dependencies"): - groups.update(self.pyproject.settings["dev-dependencies"].keys()) + groups.update(self.pyproject.dev_dependencies.keys()) return groups @property @@ -553,12 +631,24 @@ def use_pyproject_dependencies( Return a tuple of two elements, the first is the dependencies array, and the second value is a callable to set the dependencies array back. """ + from pdm.formats.base import make_array def update_dev_dependencies(deps: list[str]) -> None: from tomlkit.container import OutOfOrderTableProxy - if deps: - settings.setdefault("dev-dependencies", {})[group] = deps + dependency_groups: list[str | dict[str, str]] = [] + dev_dependencies: list[str] = [] + for dep in deps: + if dep.startswith("-e"): + dev_dependencies.append(dep) + continue + dependency_groups.append(dep) + if dependency_groups: + self.pyproject.dependency_groups[group] = dependency_groups + else: + self.pyproject.dependency_groups.pop(group, None) + if dev_dependencies: + settings.setdefault("dev-dependencies", {})[group] = dev_dependencies else: settings.setdefault("dev-dependencies", {}).pop(group, None) if isinstance(self.pyproject._data["tool"], OutOfOrderTableProxy): @@ -578,11 +668,11 @@ def update_dev_dependencies(deps: list[str]) -> None: if x else metadata.setdefault("optional-dependencies", {}).pop(group, None), ), - (settings.get("dev-dependencies", {}), update_dev_dependencies), + (self.pyproject.dev_dependencies, update_dev_dependencies), ] for deps, setter in deps_setter: if group in deps: - return deps[group], setter + return make_array(deps[group], True), setter # If not found, return an empty list and a setter to add the group return tomlkit.array(), deps_setter[int(dev)][1] @@ -622,7 +712,7 @@ def add_dependencies( deps[matched_index] = dep parsed_deps[matched_index] = req updated_indices.add(matched_index) - setter(cast(Array, deps).multiline(True)) + setter(deps) if write: self.pyproject.write(show_message) for r in parsed_deps: diff --git a/src/pdm/project/project_file.py b/src/pdm/project/project_file.py index 3b10cd2f6b..dd353aaefd 100644 --- a/src/pdm/project/project_file.py +++ b/src/pdm/project/project_file.py @@ -7,7 +7,9 @@ from tomlkit import TOMLDocument, items from pdm import termui +from pdm.exceptions import ProjectError from pdm.project.toml_file import TOMLBase +from pdm.utils import normalize_name def _remove_empty_tables(doc: dict) -> None: @@ -40,6 +42,8 @@ def write(self, show_message: bool = True) -> None: """Write the TOMLDocument to the file.""" _remove_empty_tables(self._data.get("project", {})) _remove_empty_tables(self._data.get("tool", {}).get("pdm", {})) + if "dependency-groups" in self._data and not self.dependency_groups: + del self._data["dependency-groups"] super().write() if show_message: self.ui.echo("Changes are written to [success]pyproject.toml[/].", verbosity=termui.Verbosity.NORMAL) @@ -52,6 +56,23 @@ def is_valid(self) -> bool: def metadata(self) -> items.Table: return self._data.setdefault("project", {}) + @property + def dependency_groups(self) -> items.Table: + return self._data.setdefault("dependency-groups", {}) + + @property + def dev_dependencies(self) -> dict[str, list[Any]]: + groups: dict[str, list[Any]] = {} + for group, deps in self._data.get("dependency-groups", {}).items(): + group = normalize_name(group) + if group in groups: + raise ProjectError(f"The group {group} is duplicated in dependency-groups") + groups[group] = deps.unwrap() if hasattr(deps, "unwrap") else deps + for group, deps in self.settings.get("dev-dependencies", {}).items(): + group = normalize_name(group) + groups.setdefault(group, []).extend(deps.unwrap() if hasattr(deps, "unwrap") else deps) + return groups + @property def settings(self) -> items.Table: return self._data.setdefault("tool", {}).setdefault("pdm", {}) @@ -78,7 +99,7 @@ def content_hash(self, algo: str = "sha256") -> str: dump_data = { "sources": self.settings.get("source", []), "dependencies": self.metadata.get("dependencies", []), - "dev-dependencies": self.settings.get("dev-dependencies", {}), + "dev-dependencies": self.dev_dependencies, "optional-dependencies": self.metadata.get("optional-dependencies", {}), "requires-python": self.metadata.get("requires-python", ""), "resolution": self.resolution, diff --git a/src/pdm/pytest.py b/src/pdm/pytest.py index 4ee13b3eb7..d8dfd9b2d8 100644 --- a/src/pdm/pytest.py +++ b/src/pdm/pytest.py @@ -186,7 +186,6 @@ def _get_dependencies_from_fixture(self, candidate: Candidate) -> CandidateMetad def dependency_generators(self) -> Iterable[Callable[[Candidate], CandidateMetadata]]: return ( self._get_dependencies_from_cache, - self._get_dependencies_from_local_package, self._get_dependencies_from_fixture, self._get_dependencies_from_metadata, ) diff --git a/src/pdm/resolver/base.py b/src/pdm/resolver/base.py index c3ad2e6c3e..0f395f7073 100644 --- a/src/pdm/resolver/base.py +++ b/src/pdm/resolver/base.py @@ -52,6 +52,11 @@ class Resolver(abc.ABC): """The repository with all locked dependencies.""" reporter: BaseReporter = field(default_factory=BaseReporter) """The reporter to use.""" + requested_groups: set[str] = field(default_factory=set, init=False) + """The list of requested groups.""" + + def __post_init__(self) -> None: + self.requested_groups = {g for r in self.requirements for g in r.groups} @abc.abstractmethod def resolve(self) -> Resolution: diff --git a/src/pdm/resolver/resolvelib.py b/src/pdm/resolver/resolvelib.py index 32743e147a..8112d2c333 100644 --- a/src/pdm/resolver/resolvelib.py +++ b/src/pdm/resolver/resolvelib.py @@ -22,6 +22,7 @@ @dataclass class RLResolver(Resolver): def __post_init__(self) -> None: + super().__post_init__() if self.locked_repository is None: self.locked_repository = self.project.get_locked_repository() supports_env_spec = "env_spec" in inspect.signature(self.project.get_provider).parameters @@ -72,7 +73,7 @@ def resolve(self) -> Resolution: r.url = backend.relative_path_to_url(r.path.as_posix()) deps.append(r.as_line()) packages.append(Package(candidate, deps, candidate.summary)) - return Resolution(packages, self.provider.repository.collected_groups) + return Resolution(packages, self.requested_groups) def _do_resolve(self) -> dict[str, Candidate]: from resolvelib import Resolver as _Resolver diff --git a/src/pdm/resolver/uv.py b/src/pdm/resolver/uv.py index a7bcd2e387..f3dbabb66b 100644 --- a/src/pdm/resolver/uv.py +++ b/src/pdm/resolver/uv.py @@ -30,17 +30,10 @@ @dataclass class UvResolver(Resolver): def __post_init__(self) -> None: + super().__post_init__() self.default_source = self.project.sources[0].url if self.locked_repository is None: self.locked_repository = self.project.get_locked_repository() - self.requested_groups = {g for r in self.requirements for g in r.groups} - for r in self.requirements: - if self.project.name and r.key == normalize_name(self.project.name): - groups = r.extras or ["default"] - for group in groups: - if group not in self.requested_groups: - self.requirements.extend(self.project.get_dependencies(group)) - self.requested_groups.add(group) if self.update_strategy not in {"reuse", "all"}: self.project.core.ui.warn( f"{self.update_strategy} update strategy is not supported by uv, using 'reuse' instead" diff --git a/tests/cli/test_add.py b/tests/cli/test_add.py index b0b3ffa691..8da5d9610a 100644 --- a/tests/cli/test_add.py +++ b/tests/cli/test_add.py @@ -12,11 +12,7 @@ def test_add_package(project, working_set, dev_option, pdm): pdm(["add", *dev_option, "requests"], obj=project, strict=True) - group = ( - project.pyproject.settings["dev-dependencies"]["dev"] - if dev_option - else project.pyproject.metadata["dependencies"] - ) + group = project.pyproject.dependency_groups["dev"] if dev_option else project.pyproject.metadata["dependencies"] assert group[0] == "requests>=2.19.1" locked_candidates = project.get_locked_repository().candidates @@ -27,11 +23,7 @@ def test_add_package(project, working_set, dev_option, pdm): def test_add_package_no_lock(project, working_set, dev_option, pdm): pdm(["add", *dev_option, "--frozen-lockfile", "-v", "requests"], obj=project, strict=True) - group = ( - project.pyproject.settings["dev-dependencies"]["dev"] - if dev_option - else project.pyproject.metadata["dependencies"] - ) + group = project.pyproject.dependency_groups["dev"] if dev_option else project.pyproject.metadata["dependencies"] assert group[0] == "requests>=2.19.1" assert not project.lockfile.exists() @@ -58,7 +50,7 @@ def test_add_package_to_custom_group(project, working_set, pdm): def test_add_package_to_custom_dev_group(project, working_set, pdm): pdm(["add", "requests", "--group", "test", "--dev"], obj=project, strict=True) - dependencies = project.pyproject.settings["dev-dependencies"]["test"] + dependencies = project.pyproject.dependency_groups["test"] assert "requests" in dependencies[0] locked_candidates = project.get_locked_repository().candidates assert locked_candidates["idna"].version == "2.7" @@ -73,8 +65,9 @@ def test_add_editable_package(project, working_set, pdm): pdm(["add", "--dev", "demo"], obj=project, strict=True) pdm(["add", "-de", "git+https://github.com/test-root/demo.git#egg=demo"], obj=project, strict=True) - group = project.pyproject.settings["dev-dependencies"]["dev"] + group = project.pyproject.dev_dependencies["dev"] assert group == ["-e git+https://github.com/test-root/demo.git#egg=demo"] + assert not project.pyproject.dependency_groups locked_candidates = project.get_locked_repository().candidates assert locked_candidates["demo"].prepare(project.environment).revision == "1234567890abcdef" assert working_set["demo"].link_file @@ -108,11 +101,7 @@ def test_add_remote_package_url(project, dev_option, pdm): project.environment.python_requires = PySpecSet(">=3.6") url = "http://fixtures.test/artifacts/demo-0.0.1-py2.py3-none-any.whl" pdm(["add", *dev_option, url], obj=project, strict=True) - group = ( - project.pyproject.settings["dev-dependencies"]["dev"] - if dev_option - else project.pyproject.metadata["dependencies"] - ) + group = project.pyproject.dependency_groups["dev"] if dev_option else project.pyproject.metadata["dependencies"] assert group[0] == f"demo @ {url}" diff --git a/tests/cli/test_lock.py b/tests/cli/test_lock.py index 1286097985..f7aeff7e5f 100644 --- a/tests/cli/test_lock.py +++ b/tests/cli/test_lock.py @@ -142,8 +142,8 @@ def test_lock_selected_groups(project, pdm): @pytest.mark.usefixtures("repository") -@pytest.mark.parametrize("to_dev", [False, True]) -def test_lock_self_referencing_groups(project, pdm, to_dev): +@pytest.mark.parametrize("to_dev", [True, False]) +def test_lock_self_referencing_dev_groups(project, pdm, to_dev): name = project.name project.add_dependencies(["requests"], to_group="http", dev=to_dev) project.add_dependencies( @@ -162,6 +162,46 @@ def test_lock_self_referencing_groups(project, pdm, to_dev): assert idna["groups"] == ["dev", "http"] +@pytest.mark.usefixtures("repository") +def test_lock_self_referencing_optional_groups(project, pdm): + name = project.name + project.add_dependencies(["requests"], to_group="http") + project.add_dependencies( + {"pytz": parse_requirement("pytz"), f"{name}[http]": parse_requirement(f"{name}[http]")}, + to_group="all", + ) + pdm(["lock", "-G", "all"], obj=project, strict=True) + assert project.lockfile.groups == ["default", "all", "http"] + packages = project.lockfile["package"] + pytz = next(p for p in packages if p["name"] == "pytz") + assert pytz["groups"] == ["all"] + requests = next(p for p in packages if p["name"] == "requests") + assert requests["groups"] == ["all", "http"] + idna = next(p for p in packages if p["name"] == "idna") + assert idna["groups"] == ["all", "http"] + + +@pytest.mark.usefixtures("repository") +def test_lock_include_groups_not_allowed(project, pdm): + project.pyproject.metadata["optional-dependencies"] = {"http": ["requests"]} + project.pyproject.dependency_groups.update({"dev": ["pytest", {"include-group": "http"}]}) + project.pyproject.write() + result = pdm(["lock", "-G", "all"], obj=project) + assert result.exit_code != 0 + assert "Dependency group 'http' not found" in result.stderr + + +@pytest.mark.usefixtures("repository") +def test_lock_optional_referencing_dev_group_not_allowed(project, pdm): + name = project.name + project.pyproject.metadata["optional-dependencies"] = {"http": ["requests", f"{name}[dev]"]} + project.pyproject.dependency_groups.update({"dev": ["pytest"]}) + project.pyproject.write() + result = pdm(["lock", "-G", "http"], obj=project) + assert result.exit_code != 0 + assert "Dependency group 'dev' not found" in result.stderr + + @pytest.mark.usefixtures("local_finder") def test_lock_multiple_platform_wheels(project, pdm): project.environment.python_requires = PySpecSet(">=3.7") diff --git a/tests/cli/test_remove.py b/tests/cli/test_remove.py index faa07492c1..eefe1340fa 100644 --- a/tests/cli/test_remove.py +++ b/tests/cli/test_remove.py @@ -15,9 +15,9 @@ def test_remove_editable_packages_while_keeping_normal(project, pdm): project.environment.python_requires = PySpecSet(">=3.6") pdm(["add", "demo"], obj=project, strict=True) pdm(["add", "-d", "-e", "git+https://github.com/test-root/demo.git#egg=demo"], obj=project, strict=True) - dev_group = project.pyproject.settings["dev-dependencies"]["dev"] - default_group = project.pyproject.metadata["dependencies"] pdm(["remove", "-d", "demo"], obj=project, strict=True) + default_group = project.pyproject.metadata["dependencies"] + dev_group = project.pyproject.dev_dependencies.get("dev") assert not dev_group assert len(default_group) == 1 assert not project.get_locked_repository().candidates["demo"].req.editable @@ -69,7 +69,7 @@ def test_remove_package_exist_in_multi_groups(project, working_set, pdm): pdm(["add", "requests"], obj=project, strict=True) pdm(["add", "--dev", "urllib3"], obj=project, strict=True) pdm(["remove", "--dev", "urllib3"], obj=project, strict=True) - assert "dev-dependencies" not in project.pyproject.settings + assert "dependency-groups" not in project.pyproject._data assert "urllib3" in working_set assert "requests" in working_set diff --git a/tests/fixtures/projects/test-package-type-fixer/pyproject.toml b/tests/fixtures/projects/test-package-type-fixer/pyproject.toml index 550eaf68c6..93779e5946 100644 --- a/tests/fixtures/projects/test-package-type-fixer/pyproject.toml +++ b/tests/fixtures/projects/test-package-type-fixer/pyproject.toml @@ -3,21 +3,19 @@ requires = ["pdm-backend"] build-backend = "pdm.backend" [project] - version = "0.0.1" dependencies = [] name = "test-package-type-fixer" requires-python = ">=3.8" +[dependency-groups] +dev = [ + "requests==2.19.1" +] + [tool.pdm.version] source = "file" path = "src/test_package_type_fixer/__init__.py" [tool.pdm] package-type = "application" - -[tool.pdm.dev-dependencies] - -dev = [ - "requests==2.19.1" -] \ No newline at end of file diff --git a/tests/resolver/test_uv_resolver.py b/tests/resolver/test_uv_resolver.py index dd6812d1b3..57c9d1aaca 100644 --- a/tests/resolver/test_uv_resolver.py +++ b/tests/resolver/test_uv_resolver.py @@ -63,7 +63,7 @@ def test_resolve_dependencies_with_nested_extras(project): project.add_dependencies([f"{name}[extra1,extra2]"], "all") dependencies = [*project.get_dependencies(), *project.get_dependencies("all")] - assert len(dependencies) == 2 + assert len(dependencies) == 4 resolution = resolve(project.environment, dependencies) assert resolution.collected_groups == {"default", "extra1", "extra2", "all"} mapping = {p.candidate.identify(): p.candidate for p in resolution.packages} diff --git a/tests/test_project.py b/tests/test_project.py index 02e0d6ae2e..bb9a96698f 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -11,7 +11,7 @@ from pytest_httpserver import HTTPServer from pdm.environments import PythonEnvironment -from pdm.exceptions import PdmException +from pdm.exceptions import PdmException, ProjectError from pdm.models.requirements import parse_requirement from pdm.models.specifiers import PySpecSet from pdm.models.venv import get_venv_python @@ -180,16 +180,19 @@ def test_select_dependencies(project): "security": ["cryptography"], "venv": ["virtualenv"], } - project.pyproject.settings["dev-dependencies"] = { - "test": ["pytest"], - "doc": ["mkdocs"], - } + project.pyproject.dependency_groups.update( + { + "test": ["pytest"], + "doc": ["mkdocs"], + "all": [{"include-group": "test"}, {"include-group": "doc"}], + } + ) assert sorted([r.key for r in project.get_dependencies()]) == ["requests"] - assert sorted([r.key for r in project.get_dependencies("security")]) == ["cryptography"] assert sorted([r.key for r in project.get_dependencies("test")]) == ["pytest"] - + assert sorted([r.key for r in project.get_dependencies("all")]) == ["mkdocs", "pytest"] assert sorted(project.iter_groups()) == [ + "all", "default", "doc", "security", @@ -198,6 +201,21 @@ def test_select_dependencies(project): ] +def test_invalid_dependency_group(project): + project.pyproject.dependency_groups.update( + { + "invalid": [{"invalid-key": True}], + "missing": [{"include-group": "missing-group"}], + "doc": ["mkdocs"], + } + ) + assert sorted([r.key for r in project.get_dependencies("doc")]) == ["mkdocs"] + with pytest.raises(ProjectError, match="Invalid dependency group item"): + project.get_dependencies("invalid") + with pytest.raises(ProjectError, match="Dependency group 'missing-group' not found"): + project.get_dependencies("missing") + + @pytest.mark.path def test_set_non_exist_python_path(project_no_init): project_no_init._saved_python = "non-exist-python" diff --git a/tests/test_utils.py b/tests/test_utils.py index a2122fb5a0..2ebc26215f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -431,7 +431,7 @@ def setup_dependencies(project): "optional-dependencies": {"web": ["flask"], "auth": ["passlib"]}, } ) - project.pyproject.settings.update({"dev-dependencies": {"test": ["pytest"], "doc": ["mkdocs"]}}) + project.pyproject.dependency_groups.update({"test": ["pytest"], "doc": ["mkdocs"]}) project.pyproject.write()