repositories.py 17.7 KB
Newer Older
F
frostming 已提交
1 2
from __future__ import annotations

F
Frost Ming 已提交
3
import dataclasses
F
Frost Ming 已提交
4
import sys
F
Frost Ming 已提交
5
from functools import lru_cache, wraps
6
from typing import TYPE_CHECKING, Any, Callable, Iterable, Mapping, TypeVar, cast
F
frostming 已提交
7

8 9
from unearth import Link

10
from pdm import termui
11
from pdm.exceptions import CandidateInfoNotFound, CandidateNotFound
12
from pdm.models.candidates import Candidate, make_candidate
F
frostming 已提交
13 14 15 16 17
from pdm.models.requirements import (
    Requirement,
    filter_requirements_with_extras,
    parse_requirement,
)
18
from pdm.models.search import SearchResultParser
19
from pdm.models.specifiers import PySpecSet
20
from pdm.utils import normalize_name, url_without_fragments
F
frostming 已提交
21 22

if TYPE_CHECKING:
23
    from pdm._types import CandidateInfo, SearchResult, Source
F
frostming 已提交
24
    from pdm.models.environment import Environment
F
Frost Ming 已提交
25

F
Frost Ming 已提交
26
ALLOW_ALL_PYTHON = PySpecSet()
27
T = TypeVar("T", bound="BaseRepository")
D
Daniel Eades 已提交
28

F
Frost Ming 已提交
29

F
frostming 已提交
30
def cache_result(
31 32
    func: Callable[[T, Candidate], CandidateInfo]
) -> Callable[[T, Candidate], CandidateInfo]:
F
frostming 已提交
33
    @wraps(func)
34
    def wrapper(self: T, candidate: Candidate) -> CandidateInfo:
F
frostming 已提交
35
        result = func(self, candidate)
36 37
        prepared = candidate.prepared
        if prepared and prepared.should_cache():
38
            self._candidate_info_cache.set(candidate, result)
F
frostming 已提交
39
        return result
F
Frost Ming 已提交
40

F
frostming 已提交
41 42 43
    return wrapper


F
Frost Ming 已提交
44
class BaseRepository:
F
Frost Ming 已提交
45 46
    """A Repository acts as the source of packages and metadata."""

47
    def __init__(self, sources: list[Source], environment: Environment) -> None:
F
Frost Ming 已提交
48 49 50 51
        """
        :param sources: a list of sources to download packages from.
        :param environment: the bound environment instance.
        """
F
frostming 已提交
52
        self.sources = sources
F
frostming 已提交
53
        self.environment = environment
54 55
        self._candidate_info_cache = environment.project.make_candidate_info_cache()
        self._hash_cache = environment.project.make_hash_cache()
F
frostming 已提交
56

57
    def get_filtered_sources(self, req: Requirement) -> list[Source]:
F
Frost Ming 已提交
58
        """Get matching sources based on the index attribute."""
F
Frost Ming 已提交
59
        return self.sources
F
frostming 已提交
60

F
frostming 已提交
61 62
    def get_dependencies(
        self, candidate: Candidate
63
    ) -> tuple[list[Requirement], PySpecSet, str]:
F
Frost Ming 已提交
64
        """Get (dependencies, python_specifier, summary) of the candidate."""
65 66
        requires_python, summary = "", ""
        requirements: list[str] = []
F
frostming 已提交
67 68 69 70 71 72 73 74 75 76
        last_ext_info = None
        for getter in self.dependency_generators():
            try:
                requirements, requires_python, summary = getter(candidate)
            except CandidateInfoNotFound:
                last_ext_info = sys.exc_info()
                continue
            break
        else:
            if last_ext_info is not None:
77 78
                raise last_ext_info[1].with_traceback(last_ext_info[2])  # type: ignore
        reqs = [parse_requirement(line) for line in requirements]
F
frostming 已提交
79
        if candidate.req.extras:
80 81 82
            # XXX: If the requirement has extras, add the original candidate
            # (without extras) as its dependency. This ensures the same package with
            # different extras resolve to the same version.
83 84 85 86 87
            self_req = dataclasses.replace(
                candidate.req.as_pinned_version(candidate.version),
                extras=None,
                marker=None,
            )
88
            reqs.append(self_req)
89
        # Store the metadata on the candidate for caching
90 91
        candidate.requires_python = requires_python
        candidate.summary = summary
92
        return reqs, PySpecSet(requires_python), summary
F
frostming 已提交
93

94 95 96
    def _find_candidates(self, requirement: Requirement) -> Iterable[Candidate]:
        raise NotImplementedError

97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119
    def is_this_package(self, requirement: Requirement) -> bool:
        """Whether the requirement is the same as this package"""
        project = self.environment.project
        return (
            requirement.is_named
            and project.name is not None
            and requirement.key == normalize_name(project.name)
        )

    def make_this_candidate(self, requirement: Requirement) -> Candidate | None:
        """Make a candidate for this package, or None if the requirement doesn't match.
        In this case the finder will look for a candidate from the package sources
        """
        project = self.environment.project
        assert project.name
        link = Link.from_path(project.root)  # type: ignore
        version: str = project.meta.version
        if (
            not version
            or requirement.specifier
            and not requirement.specifier.contains(version, True)
        ):
            return None
120
        return make_candidate(requirement, project.name, version, link)
121

F
Frost Ming 已提交
122
    def find_candidates(
123 124 125 126
        self,
        requirement: Requirement,
        allow_prereleases: bool | None = None,
        ignore_requires_python: bool = False,
F
Frost Ming 已提交
127
    ) -> Iterable[Candidate]:
F
Frost Ming 已提交
128 129 130
        """Find candidates of the given NamedRequirement. Let it to be implemented in
        subclasses.
        """
131 132
        # `allow_prereleases` is None means leave it to specifier to decide whether to
        # include prereleases
133 134 135 136 137

        if self.is_this_package(requirement):
            candidate = self.make_this_candidate(requirement)
            if candidate is not None:
                return [candidate]
138
        requires_python = requirement.requires_python & self.environment.python_requires
139
        cans = list(self._find_candidates(requirement))
140 141 142 143 144 145 146
        applicable_cans = [
            c
            for c in cans
            if requirement.specifier.contains(  # type: ignore
                c.version, allow_prereleases  # type: ignore
            )
        ]
147

F
Frost Ming 已提交
148
        applicable_cans_python_compatible = [
149 150 151
            c
            for c in applicable_cans
            if ignore_requires_python or requires_python.is_subset(c.requires_python)
F
Frost Ming 已提交
152 153 154 155 156 157
        ]
        # Evaluate data-requires-python attr and discard incompatible candidates
        # to reduce the number of candidates to resolve.
        if applicable_cans_python_compatible:
            applicable_cans = applicable_cans_python_compatible

158
        if not applicable_cans:
159 160
            termui.logger.debug("\tCould not find any matching candidates.")

161
        if not applicable_cans and allow_prereleases is None:
162
            # No non-pre-releases is found, force pre-releases now
163 164 165 166 167
            applicable_cans = [
                c
                for c in cans
                if requirement.specifier.contains(c.version, True)  # type: ignore
            ]
F
Frost Ming 已提交
168 169 170
            applicable_cans_python_compatible = [
                c
                for c in applicable_cans
171 172
                if ignore_requires_python
                or requires_python.is_subset(c.requires_python)
F
Frost Ming 已提交
173 174 175
            ]
            if applicable_cans_python_compatible:
                applicable_cans = applicable_cans_python_compatible
176

177
            if not applicable_cans:
178 179 180 181 182 183
                termui.logger.debug(
                    "\tCould not find any matching candidates even when considering "
                    "pre-releases.",
                )

        def print_candidates(
184
            title: str, candidates: list[Candidate], max_lines: int = 10
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199
        ) -> None:
            termui.logger.debug("\t" + title)
            logged_lines = set()
            for can in candidates:
                new_line = f"\t  {can}"
                if new_line not in logged_lines:
                    logged_lines.add(new_line)
                    if len(logged_lines) > max_lines:
                        termui.logger.debug(
                            f"\t  ... [{len(candidates)-max_lines} more candidate(s)]"
                        )
                        break
                    else:
                        termui.logger.debug(new_line)

200 201
        if applicable_cans:
            print_candidates("Found matching candidates:", applicable_cans)
202 203 204
        elif cans:
            print_candidates("Found but non-matching candidates:", cans)

205
        return applicable_cans
F
frostming 已提交
206

F
Frost Ming 已提交
207
    def _get_dependencies_from_cache(self, candidate: Candidate) -> CandidateInfo:
F
frostming 已提交
208 209 210
        try:
            result = self._candidate_info_cache.get(candidate)
        except KeyError:
211
            raise CandidateInfoNotFound(candidate) from None
F
frostming 已提交
212
        return result
F
frostming 已提交
213

F
frostming 已提交
214
    @cache_result
F
Frost Ming 已提交
215
    def _get_dependencies_from_metadata(self, candidate: Candidate) -> CandidateInfo:
216 217
        prepared = candidate.prepare(self.environment)
        deps = prepared.get_dependencies_from_metadata()
F
frostming 已提交
218
        requires_python = candidate.requires_python
219
        summary = prepared.metadata.metadata["Summary"]
F
frostming 已提交
220 221
        return deps, requires_python, summary

222
    def get_hashes(self, candidate: Candidate) -> dict[Link, str] | None:
F
frostming 已提交
223 224
        """Get hashes of all possible installable candidates
        of a given package version.
F
Frost Ming 已提交
225
        """
F
frostming 已提交
226
        if (
227
            candidate.req.is_vcs
F
frostming 已提交
228
            or candidate.req.is_file_or_url
229 230 231
            and candidate.req.is_local  # type: ignore
            or candidate.link
            and candidate.link.is_file
F
frostming 已提交
232
        ):
233
            return None
234 235
        if candidate.hashes:
            return candidate.hashes
236
        req = candidate.req.as_pinned_version(candidate.version)
237
        if candidate.req.is_file_or_url:
238
            matching_candidates: Iterable[Candidate] = [candidate]
239
        else:
240
            matching_candidates = self.find_candidates(req, ignore_requires_python=True)
F
Frost Ming 已提交
241
        result: dict[str, str] = {}
F
frostming 已提交
242
        with self.environment.get_finder(self.sources) as finder:
F
Frost Ming 已提交
243
            for c in matching_candidates:
244
                assert c.link is not None
245
                # Prepare the candidate to replace vars in the link URL
246 247
                prepared_link = c.prepare(self.environment).link
                if not prepared_link or prepared_link.is_vcs:
F
Frost Ming 已提交
248
                    continue
249 250 251
                result[c.link] = self._hash_cache.get_hash(
                    prepared_link, finder.session
                )
F
Frost Ming 已提交
252
        return result or None
F
Frost Ming 已提交
253

F
frostming 已提交
254
    def dependency_generators(self) -> Iterable[Callable[[Candidate], CandidateInfo]]:
F
Frost Ming 已提交
255 256 257
        """Return an iterable of getter functions to get dependencies, which will be
        called one by one.
        """
F
frostming 已提交
258 259
        raise NotImplementedError

F
frostming 已提交
260 261 262 263 264 265 266 267
    def search(self, query: str) -> SearchResult:
        """Search package by name or summary.

        :param query: query string
        :returns: search result, a dictionary of name: package metadata
        """
        raise NotImplementedError

F
Frost Ming 已提交
268 269

class PyPIRepository(BaseRepository):
F
Frost Ming 已提交
270 271
    """Get package and metadata from PyPI source."""

F
Frost Ming 已提交
272 273
    DEFAULT_INDEX_URL = "https://pypi.org"

F
frostming 已提交
274
    @cache_result
F
Frost Ming 已提交
275
    def _get_dependencies_from_json(self, candidate: Candidate) -> CandidateInfo:
F
frostming 已提交
276 277 278 279 280 281 282 283 284 285 286 287
        if not candidate.name or not candidate.version:
            # Only look for json api for named requirements.
            raise CandidateInfoNotFound(candidate)
        sources = self.get_filtered_sources(candidate.req)
        url_prefixes = [
            proc_url[:-7]  # Strip "/simple".
            for proc_url in (
                raw_url.rstrip("/")
                for raw_url in (source.get("url", "") for source in sources)
            )
            if proc_url.endswith("/simple")
        ]
F
frostming 已提交
288
        with self.environment.get_finder(sources) as finder:
289
            session = finder.session
F
frostming 已提交
290 291 292 293 294
            for prefix in url_prefixes:
                json_url = f"{prefix}/pypi/{candidate.name}/{candidate.version}/json"
                resp = session.get(json_url)
                if not resp.ok:
                    continue
F
frostming 已提交
295

F
frostming 已提交
296 297 298 299 300 301 302 303
                info = resp.json()["info"]

                requires_python = info["requires_python"] or ""
                summary = info["summary"] or ""
                try:
                    requirement_lines = info["requires_dist"] or []
                except KeyError:
                    requirement_lines = info["requires"] or []
F
Frost Ming 已提交
304
                requirements = filter_requirements_with_extras(
305 306 307
                    cast(str, candidate.req.project_name),
                    requirement_lines,
                    candidate.req.extras or (),
F
Frost Ming 已提交
308
                )
F
frostming 已提交
309 310 311
                return requirements, requires_python, summary
        raise CandidateInfoNotFound(candidate)

F
frostming 已提交
312
    def dependency_generators(self) -> Iterable[Callable[[Candidate], CandidateInfo]]:
F
frostming 已提交
313 314 315 316
        yield self._get_dependencies_from_cache
        if self.environment.project.config["pypi.json_api"]:
            yield self._get_dependencies_from_json
        yield self._get_dependencies_from_metadata
F
frostming 已提交
317

F
Frost Ming 已提交
318
    @lru_cache()
319
    def _find_candidates(self, requirement: Requirement) -> Iterable[Candidate]:
F
frostming 已提交
320
        sources = self.get_filtered_sources(requirement)
321
        with self.environment.get_finder(sources, True) as finder:
322
            cans = [
323
                Candidate.from_installation_candidate(c, requirement)
324
                for c in finder.find_all_packages(requirement.project_name)
F
Frost Ming 已提交
325
            ]
326 327 328
        if not cans:
            raise CandidateNotFound(
                f"Unable to find candidates for {requirement.project_name}. There may "
329
                "exist some issues with the package name or network condition."
330 331
            )
        return cans
F
frostming 已提交
332 333

    def search(self, query: str) -> SearchResult:
F
Frost Ming 已提交
334 335 336 337 338 339 340
        pypi_simple = self.sources[0]["url"].rstrip("/")

        if pypi_simple.endswith("/simple"):
            search_url = pypi_simple[:-6] + "search"
        else:
            search_url = pypi_simple + "/search"

F
frostming 已提交
341
        with self.environment.get_finder() as finder:
342
            session = finder.session  # type: ignore
F
Frost Ming 已提交
343 344
            resp = session.get(search_url, params={"q": query})
            if resp.status_code == 404:
345
                self.environment.project.core.ui.echo(
346 347 348
                    f"{pypi_simple!r} doesn't support '/search' endpoint, fallback "
                    f"to {self.DEFAULT_INDEX_URL!r} now.\n"
                    "This may take longer depending on your network condition.",
F
Frost Ming 已提交
349
                    err=True,
350
                    style="yellow",
F
Frost Ming 已提交
351 352 353
                )
                resp = session.get(
                    f"{self.DEFAULT_INDEX_URL}/search", params={"q": query}
F
Frost Ming 已提交
354
                )
355
            parser = SearchResultParser()
F
Frost Ming 已提交
356
            resp.raise_for_status()
357 358
            parser.feed(resp.text)
            return parser.results
359 360 361 362 363 364


class LockedRepository(BaseRepository):
    def __init__(
        self,
        lockfile: Mapping[str, Any],
365
        sources: list[Source],
366 367 368
        environment: Environment,
    ) -> None:
        super().__init__(sources, environment)
369
        self.packages: dict[tuple, Candidate] = {}
370
        self.file_hashes: dict[tuple[str, str], dict[Link, str]] = {}
371
        self.candidate_info: dict[tuple, CandidateInfo] = {}
372 373
        self._read_lockfile(lockfile)

F
Frost Ming 已提交
374
    @property
375
    def all_candidates(self) -> dict[str, Candidate]:
F
Frost Ming 已提交
376 377
        return {can.req.identify(): can for can in self.packages.values()}

378 379 380 381 382 383
    def _read_lockfile(self, lockfile: Mapping[str, Any]) -> None:
        for package in lockfile.get("package", []):
            version = package.get("version")
            if version:
                package["version"] = f"=={version}"
            package_name = package.pop("name")
F
Frost Ming 已提交
384 385 386 387 388 389
            req_dict = {
                k: v
                for k, v in package.items()
                if k not in ("dependencies", "requires_python", "summary")
            }
            req = Requirement.from_req_dict(package_name, req_dict)
390
            can = make_candidate(req, name=package_name, version=version)
391 392 393 394 395 396 397 398 399 400
            can_id = self._identify_candidate(can)
            self.packages[can_id] = can
            candidate_info: CandidateInfo = (
                package.get("dependencies", []),
                package.get("requires_python", ""),
                package.get("summary", ""),
            )
            self.candidate_info[can_id] = candidate_info

        for key, hashes in lockfile.get("metadata", {}).get("files", {}).items():
401
            self.file_hashes[tuple(key.split(None, 1))] = {  # type: ignore
402
                Link(item["url"]): item["hash"] for item in hashes if "url" in item
403 404 405
            }

    def _identify_candidate(self, candidate: Candidate) -> tuple:
F
Frost Ming 已提交
406
        url = getattr(candidate.req, "url", None)
407
        return (
F
Frost Ming 已提交
408
            candidate.identify(),
409
            candidate.version if not url else None,
F
Frost Ming 已提交
410
            url_without_fragments(url) if url else None,
411 412 413 414 415 416 417
            candidate.req.editable,
        )

    def _get_dependencies_from_lockfile(self, candidate: Candidate) -> CandidateInfo:
        return self.candidate_info[self._identify_candidate(candidate)]

    def dependency_generators(self) -> Iterable[Callable[[Candidate], CandidateInfo]]:
418
        return (self._get_dependencies_from_lockfile,)
419 420 421

    def get_dependencies(
        self, candidate: Candidate
422
    ) -> tuple[list[Requirement], PySpecSet, str]:
F
Frost Ming 已提交
423
        reqs, python, summary = super().get_dependencies(candidate)
424
        reqs = [
425 426 427 428
            req
            for req in reqs
            if not req.marker
            or req.marker.evaluate(self.environment.marker_environment)
429
        ]
F
Frost Ming 已提交
430
        return reqs, python, summary
431 432

    def find_candidates(
433 434 435 436
        self,
        requirement: Requirement,
        allow_prereleases: bool | None = None,
        ignore_requires_python: bool = False,
437
    ) -> Iterable[Candidate]:
438 439 440 441 442
        if self.is_this_package(requirement):
            candidate = self.make_this_candidate(requirement)
            if candidate is not None:
                yield candidate
                return
443
        for key, info in self.candidate_info.items():
F
Frost Ming 已提交
444
            if key[0] != requirement.identify():
445
                continue
446
            if not PySpecSet(info[1]).contains(
447
                str(self.environment.interpreter.version), True
448 449 450 451
            ):
                continue
            can = self.packages[key]
            can.requires_python = info[1]
452
            can.prepare(self.environment)
453
            can.req = requirement
454 455
            yield can

456
    def get_hashes(self, candidate: Candidate) -> dict[Link, str] | None:
457 458 459 460
        assert candidate.name
        return self.file_hashes.get(
            (normalize_name(candidate.name), candidate.version or "")
        )