Skip to content

Commit

Permalink
feat: per-package source configuration (pdm-project#2323)
Browse files Browse the repository at this point in the history
  • Loading branch information
frostming authored Oct 20, 2023
1 parent 8a1c5b2 commit e40a300
Show file tree
Hide file tree
Showing 5 changed files with 73 additions and 5 deletions.
16 changes: 16 additions & 0 deletions docs/docs/usage/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,22 @@ In some cases you may want to return packages from the preferred source, and sea
respect-source-order = true
```

### Specify index for individual packages

You can bind packages to specific sources with `include_packages` and `exclude_packages` config under `tool.pdm.source` table.

```toml
[[tool.pdm.source]]
name = "private"
url = "https://private.pypi.org/simple"
include_packages = ["foo", "foo-*"]
exclude_packages = ["bar-*"]
```

With the above configuration, any package matching `foo` or `foo-*` will only be searched from the `private` index, and any package matching `bar-*` will be searched from all indexes except `private`.

Both `include_packages` and `exclude_packages` are optional and accept a list of glob patterns, and `include_packages` takes effect exclusively when the pattern matches.

### Store credentials with the index

You can specify credentials in the URL with `${ENV_VAR}` variable expansion and these variables will be read from the environment variables:
Expand Down
1 change: 1 addition & 0 deletions news/1645.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Allow binding packages to specific sources with `include_packages` and `exclude_packages` config under `tool.pdm.source` table.
11 changes: 11 additions & 0 deletions src/pdm/_types.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
from __future__ import annotations

import dataclasses as dc
import re
from typing import TYPE_CHECKING, Any, Dict, List, NamedTuple, Tuple, TypeVar, Union

if TYPE_CHECKING:
from typing import Protocol


def _normalize_pattern(pattern: str) -> str:
return re.sub(r"[^A-Za-z0-9?*\[\]-]+", "-", pattern).lower()


@dc.dataclass
class _RepositoryConfig:
"""Private dataclass to be subclassed"""
Expand All @@ -20,6 +25,12 @@ class _RepositoryConfig:
verify_ssl: bool | None = None
type: str | None = None
ca_certs: str | None = None
include_packages: list[str] = dc.field(default_factory=list)
exclude_packages: list[str] = dc.field(default_factory=list)

def __post_init__(self) -> None:
self.include_packages = [_normalize_pattern(p) for p in self.include_packages]
self.exclude_packages = [_normalize_pattern(p) for p in self.exclude_packages]


class RepositoryConfig(_RepositoryConfig):
Expand Down
24 changes: 19 additions & 5 deletions src/pdm/models/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@

CandidateKey = tuple[str, str | None, str | None, bool]

ALLOW_ALL_PYTHON = PySpecSet()
T = TypeVar("T", bound="BaseRepository")


Expand Down Expand Up @@ -77,7 +76,22 @@ def __init__(

def get_filtered_sources(self, req: Requirement) -> list[RepositoryConfig]:
"""Get matching sources based on the index attribute."""
return self.sources
source_preferences = [(s, self.source_preference(req, s)) for s in self.sources]
included_by = [s for s, p in source_preferences if p is True]
if included_by:
return included_by
return [s for s, p in source_preferences if p is None]

@staticmethod
def source_preference(req: Requirement, source: RepositoryConfig) -> bool | None:
key = req.key
if key is None:
return None
if any(fnmatch.fnmatch(key, pat) for pat in source.include_packages):
return True
if any(fnmatch.fnmatch(key, pat) for pat in source.exclude_packages):
return False
return None

def get_dependencies(self, candidate: Candidate) -> tuple[list[Requirement], PySpecSet, str]:
"""Get (dependencies, python_specifier, summary) of the candidate."""
Expand Down Expand Up @@ -336,10 +350,10 @@ def get_hashes(self, candidate: Candidate) -> list[FileHash]:
respect_source_order = self.environment.project.pyproject.settings.get("resolution", {}).get(
"respect-source-order", False
)
sources = self.get_filtered_sources(candidate.req)
if req.is_named and respect_source_order and comes_from:
sources = [s for s in self.sources if comes_from.startswith(s.url)]
else:
sources = self.sources
sources = [s for s in sources if comes_from.startswith(s.url)]

with self.environment.get_finder(sources, self.ignore_compatibility) as finder:
if req.is_file_or_url:
this_link = cast("Link", candidate.prepare(self.environment).link)
Expand Down
26 changes: 26 additions & 0 deletions tests/test_project.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import os
import sys
import venv
Expand Down Expand Up @@ -363,3 +365,27 @@ def test_ignore_package_warning(pdm, project, recwarn, pattern, suppressed):

assert result.exit_code == 0
assert (len(recwarn) == 0) is suppressed


def test_filter_sources_with_config(project):
project.pyproject.settings["source"] = [
{"name": "source1", "url": "https://source1.org/simple", "include_packages": ["foo", "foo-*"]},
{
"name": "source2",
"url": "https://source2.org/simple",
"include_packages": ["foo-bar", "bar*"],
"exclude_packages": ["baz-*"],
},
{"name": "pypi", "url": "https://pypi.org/simple"},
]
repository = project.get_repository()

def expect_sources(requirement: str, expected: list[str]) -> bool:
sources = repository.get_filtered_sources(parse_requirement(requirement))
assert sorted([source.name for source in sources]) == sorted(expected)

expect_sources("foo", ["source1"])
expect_sources("foo-baz", ["source1"])
expect_sources("foo-bar", ["source1", "source2"])
expect_sources("bar-extra", ["source2"])
expect_sources("baz-extra", ["source1", "pypi"])

0 comments on commit e40a300

Please sign in to comment.