conftest.py 13.4 KB
Newer Older
1 2
from __future__ import annotations

F
Frost Ming 已提交
3
import collections
4
import functools
F
frostming 已提交
5
import json
F
Frost Ming 已提交
6 7
import os
import shutil
F
Frost Ming 已提交
8
import sys
9 10
from dataclasses import dataclass
from io import BytesIO, StringIO
F
frostming 已提交
11
from pathlib import Path
12
from typing import Callable, Iterable, Mapping
13
from urllib.parse import unquote, urlparse
F
frostming 已提交
14

F
Frost Ming 已提交
15
import pytest
16 17 18
import requests
from packaging.version import parse as parse_version
from unearth.vcs import Git, vcs_support
F
Frost Ming 已提交
19

F
linting  
Frost Ming 已提交
20
from pdm._types import CandidateInfo
21
from pdm.cli.actions import do_init
22
from pdm.cli.hooks import HookManager
23
from pdm.core import Core
F
frostming 已提交
24
from pdm.exceptions import CandidateInfoNotFound
F
frostming 已提交
25
from pdm.models.candidates import Candidate
F
Frost Ming 已提交
26
from pdm.models.environment import Environment
F
frostming 已提交
27
from pdm.models.repositories import BaseRepository
28 29 30 31 32
from pdm.models.requirements import (
    Requirement,
    filter_requirements_with_extras,
    parse_requirement,
)
33
from pdm.models.session import PDMSession
F
frostming 已提交
34
from pdm.project.config import Config
35
from pdm.project.core import Project
F
Frost Ming 已提交
36
from pdm.utils import find_python_in_path, normalize_name, path_to_url
F
frostming 已提交
37 38
from tests import FIXTURES

39
os.environ.update(CI="1", PDM_CHECK_UPDATE="0")
40 41


F
frostming 已提交
42
class LocalFileAdapter(requests.adapters.BaseAdapter):
43
    def __init__(self, aliases, overrides=None, strip_suffix=False):
F
frostming 已提交
44
        super().__init__()
45 46 47 48 49
        self.aliases = sorted(
            aliases.items(), key=lambda item: len(item[0]), reverse=True
        )
        self.overrides = overrides if overrides is not None else {}
        self.strip_suffix = strip_suffix
F
frostming 已提交
50 51
        self._opened_files = []

52 53 54 55 56 57 58 59 60 61 62 63
    def get_file_path(self, path):
        for prefix, base_path in self.aliases:
            if path.startswith(prefix):
                file_path = base_path / path[len(prefix) :].lstrip("/")
                if not self.strip_suffix:
                    return file_path
                return next(
                    (p for p in file_path.parent.iterdir() if p.stem == file_path.name),
                    None,
                )
        return None

F
Frost Ming 已提交
64 65 66
    def send(
        self, request, stream=False, timeout=None, verify=True, cert=None, proxies=None
    ):
67 68
        request_path = urlparse(request.url).path
        file_path = self.get_file_path(request_path)
F
Frost Ming 已提交
69
        response = requests.models.Response()
F
Frost Ming 已提交
70
        response.url = request.url
F
frostming 已提交
71
        response.request = request
72 73 74 75 76 77
        if request_path in self.overrides:
            response.status_code = 200
            response.reason = "OK"
            response.raw = BytesIO(self.overrides[request_path])
            response.headers["Content-Type"] = "text/html"
        elif file_path is None or not file_path.exists():
F
frostming 已提交
78 79 80 81 82 83 84
            response.status_code = 404
            response.reason = "Not Found"
            response.raw = BytesIO(b"Not Found")
        else:
            response.status_code = 200
            response.reason = "OK"
            response.raw = file_path.open("rb")
F
Frost Ming 已提交
85 86
            if file_path.suffix == ".html":
                response.headers["Content-Type"] = "text/html"
F
frostming 已提交
87 88 89 90 91 92 93 94 95
        self._opened_files.append(response.raw)
        return response

    def close(self):
        for fp in self._opened_files:
            fp.close()
        self._opened_files.clear()


96 97 98
class MockGit(Git):
    def fetch_new(self, location, url, rev, args):
        path = os.path.splitext(os.path.basename(unquote(urlparse(str(url)).path)))[0]
F
Frost Ming 已提交
99
        mocked_path = FIXTURES / "projects" / path
100
        shutil.copytree(mocked_path, location)
F
Frost Ming 已提交
101

102
    def get_revision(self, location: Path) -> str:
F
frostming 已提交
103 104
        return "1234567890abcdef"

105 106 107
    def is_immutable_revision(self, location, link) -> bool:
        rev = self.get_url_and_rev_options(link)[1]
        return rev == "1234567890abcdef"
108

F
Frost Ming 已提交
109

110 111 112 113
class _FakeLink:
    is_wheel = False


F
frostming 已提交
114
class TestRepository(BaseRepository):
F
frostming 已提交
115 116
    def __init__(self, sources, environment):
        super().__init__(sources, environment)
F
frostming 已提交
117 118 119 120
        self._pypi_data = {}
        self.load_fixtures()

    def add_candidate(self, name, version, requires_python=""):
121
        pypi_data = self._pypi_data.setdefault(normalize_name(name), {}).setdefault(
F
frostming 已提交
122 123 124 125 126
            version, {}
        )
        pypi_data["requires_python"] = requires_python

    def add_dependencies(self, name, version, requirements):
127
        pypi_data = self._pypi_data[normalize_name(name)][version]
F
frostming 已提交
128 129 130
        pypi_data.setdefault("dependencies", []).extend(requirements)

    def _get_dependencies_from_fixture(
F
Frost Ming 已提交
131
        self, candidate: Candidate
132
    ) -> tuple[list[str], str, str]:
F
frostming 已提交
133 134 135 136 137
        try:
            pypi_data = self._pypi_data[candidate.req.key][candidate.version]
        except KeyError:
            raise CandidateInfoNotFound(candidate)
        deps = pypi_data.get("dependencies", [])
138 139 140
        deps = filter_requirements_with_extras(
            candidate.req.name, deps, candidate.req.extras or ()
        )
F
frostming 已提交
141 142 143 144 145 146 147 148 149
        return deps, pypi_data.get("requires_python", ""), ""

    def dependency_generators(self) -> Iterable[Callable[[Candidate], CandidateInfo]]:
        return (
            self._get_dependencies_from_cache,
            self._get_dependencies_from_fixture,
            self._get_dependencies_from_metadata,
        )

150
    def get_hashes(self, candidate: Candidate) -> dict[str, str] | None:
151
        return {}
F
frostming 已提交
152

153
    def _find_candidates(self, requirement: Requirement) -> Iterable[Candidate]:
154 155 156 157 158
        for version, candidate in sorted(
            self._pypi_data.get(requirement.key, {}).items(),
            key=lambda item: parse_version(item[0]),
            reverse=True,
        ):
F
frostming 已提交
159
            c = Candidate(
F
frostming 已提交
160 161 162
                requirement,
                name=requirement.project_name,
                version=version,
F
frostming 已提交
163
            )
F
Frost Ming 已提交
164
            c.requires_python = candidate.get("requires_python", "")
165 166
            c.link = _FakeLink()
            yield c
F
frostming 已提交
167

F
frostming 已提交
168 169 170 171
    def load_fixtures(self):
        json_file = FIXTURES / "pypi.json"
        self._pypi_data = json.loads(json_file.read_text())

F
frostming 已提交
172

F
frostming 已提交
173
class Distribution:
174
    def __init__(self, key, version, editable=False):
F
frostming 已提交
175
        self.version = version
176
        self.link_file = "editable" if editable else None
F
frostming 已提交
177
        self.dependencies = []
178
        self.metadata = {"Name": key}
179
        self.name = key
F
frostming 已提交
180

F
Frost Ming 已提交
181
    def as_req(self):
182
        return parse_requirement(f"{self.name}=={self.version}")
F
Frost Ming 已提交
183

184 185
    @property
    def requires(self):
F
frostming 已提交
186
        return self.dependencies
F
Frost Ming 已提交
187

188 189 190
    def read_text(self, path):
        return None

F
Frost Ming 已提交
191 192 193 194 195

class MockWorkingSet(collections.abc.MutableMapping):
    def __init__(self, *args, **kwargs):
        self._data = {}

F
frostming 已提交
196
    def add_distribution(self, dist):
197
        self._data[dist.name] = dist
F
Frost Ming 已提交
198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215

    def __getitem__(self, key):
        return self._data[key]

    def __len__(self):
        return len(self._data)

    def __iter__(self):
        return iter(self._data)

    def __setitem__(self, key, value):
        self._data[key] = value

    def __delitem__(self, key):
        del self._data[key]


@pytest.fixture()
F
frostming 已提交
216
def working_set(mocker, repository):
F
Frost Ming 已提交
217

F
Frost Ming 已提交
218 219 220 221
    rv = MockWorkingSet()
    mocker.patch.object(Environment, "get_working_set", return_value=rv)

    def install(candidate):
F
frostming 已提交
222
        dependencies = repository.get_dependencies(candidate)[0]
223
        key = normalize_name(candidate.name)
224
        dist = Distribution(key, candidate.version, candidate.req.editable)
225
        dist.dependencies = [dep.as_line() for dep in dependencies]
F
frostming 已提交
226
        rv.add_distribution(dist)
F
Frost Ming 已提交
227

F
Frost Ming 已提交
228
    def uninstall(dist):
229
        del rv[dist.name]
F
Frost Ming 已提交
230

F
Frost Ming 已提交
231 232 233 234 235 236
    install_manager = mocker.MagicMock()
    install_manager.install.side_effect = install
    install_manager.uninstall.side_effect = uninstall
    mocker.patch(
        "pdm.installers.Synchronizer.get_manager", return_value=install_manager
    )
F
Frost Ming 已提交
237 238

    yield rv
F
frostming 已提交
239 240


241 242 243 244 245 246 247 248
def get_pypi_session(*args, overrides=None, **kwargs):
    session = PDMSession(*args, **kwargs)
    session.mount("http://fixtures.test/", LocalFileAdapter({"/": FIXTURES}))
    session.mount(
        "https://my.pypi.org/",
        LocalFileAdapter({"/simple": FIXTURES / "index"}, overrides, strip_suffix=True),
    )
    return session
F
frostming 已提交
249 250


251 252 253 254 255 256 257
def remove_pep582_path_from_pythonpath(pythonpath):
    """Remove all pep582 paths of PDM from PYTHONPATH"""
    paths = pythonpath.split(os.pathsep)
    paths = [path for path in paths if "pdm/pep582" not in path]
    return os.pathsep.join(paths)


F
Frost Ming 已提交
258
@pytest.fixture()
259
def core():
F
Frost Ming 已提交
260
    old_config_map = Config._config_map.copy()
F
Frost Ming 已提交
261 262
    # Turn off use_venv by default, for testing
    Config._config_map["python.use_venv"].default = False
263
    main = Core()
F
Frost Ming 已提交
264 265 266
    yield main
    # Restore the config items
    Config._config_map = old_config_map
267 268


F
frostming 已提交
269
@pytest.fixture()
270 271 272 273 274
def index():
    return {}


@pytest.fixture()
F
Frost Ming 已提交
275
def project_no_init(tmp_path, mocker, core, index, monkeypatch):
276 277 278 279 280 281 282 283 284 285
    test_home = tmp_path / ".pdm-home"
    test_home.mkdir(parents=True)
    test_home.joinpath("config.toml").write_text(
        '[global_project]\npath = "{}"\n'.format(
            test_home.joinpath("global-project").as_posix()
        )
    )
    p = core.create_project(
        tmp_path, global_config=test_home.joinpath("config.toml").as_posix()
    )
F
Frost Ming 已提交
286
    p.global_config["venv.location"] = str(tmp_path / "venvs")
287 288 289 290
    mocker.patch(
        "pdm.models.environment.PDMSession",
        functools.partial(get_pypi_session, overrides=index),
    )
291
    tmp_path.joinpath("caches").mkdir(parents=True)
F
frostming 已提交
292
    p.global_config["cache_dir"] = tmp_path.joinpath("caches").as_posix()
F
Frost Ming 已提交
293
    p.project_config["python.path"] = find_python_in_path(sys.base_prefix).as_posix()
F
Frost Ming 已提交
294 295 296 297 298 299 300 301 302
    monkeypatch.delenv("VIRTUAL_ENV", raising=False)
    monkeypatch.delenv("CONDA_PREFIX", raising=False)
    monkeypatch.delenv("PEP582_PACKAGES", raising=False)
    monkeypatch.delenv("NO_SITE_PACKAGES", raising=False)
    pythonpath = os.getenv("PYTHONPATH", "")
    pythonpath = remove_pep582_path_from_pythonpath(pythonpath)
    if pythonpath:
        monkeypatch.setenv("PYTHONPATH", pythonpath)
    yield p
F
Frost Ming 已提交
303 304


305 306
@pytest.fixture()
def local_finder(project_no_init, mocker):
307 308
    artifacts_dir = str(FIXTURES / "artifacts")
    return_value = ["--no-index", "--find-links", artifacts_dir]
309
    mocker.patch("pdm.builders.base.prepare_pip_source_args", return_value=return_value)
310 311 312 313 314
    project_no_init.tool_settings["source"] = [
        {
            "type": "find_links",
            "verify_ssl": False,
            "url": path_to_url(artifacts_dir),
315
            "name": "pypi",
316 317 318
        }
    ]
    project_no_init.write_pyproject()
319 320


F
Frost Ming 已提交
321
@pytest.fixture()
F
frostming 已提交
322
def project(project_no_init):
323 324
    hooks = HookManager(project_no_init, ["post_init"])
    do_init(project_no_init, "test_project", "0.0.0", hooks=hooks)
F
Frost Ming 已提交
325
    # Clean the cached property
326
    project_no_init._environment = None
F
frostming 已提交
327
    return project_no_init
F
Frost Ming 已提交
328 329


330 331 332 333 334 335 336 337 338 339
def copytree(src: Path, dst: Path) -> None:
    if not dst.exists():
        dst.mkdir(parents=True)
    for subpath in src.iterdir():
        if subpath.is_dir():
            copytree(subpath, dst / subpath.name)
        else:
            shutil.copy2(subpath, dst)


F
frostming 已提交
340 341
@pytest.fixture()
def fixture_project(project_no_init):
T
Timothée Mazzucotelli 已提交
342
    """Initialize a project from a fixture project"""
F
frostming 已提交
343 344 345

    def func(project_name):
        source = FIXTURES / "projects" / project_name
346
        copytree(source, project_no_init.root)
F
Frost Ming 已提交
347
        project_no_init._pyproject = None
F
frostming 已提交
348 349 350 351 352
        return project_no_init

    return func


F
frostming 已提交
353
@pytest.fixture()
354
def repository(project, mocker, local_finder):
F
frostming 已提交
355
    rv = TestRepository([], project.environment)
356
    mocker.patch.object(project, "get_repository", return_value=rv)
F
frostming 已提交
357 358 359
    return rv


F
Frost Ming 已提交
360
@pytest.fixture()
361 362 363
def vcs(monkeypatch):
    monkeypatch.setattr(vcs_support, "_registry", {"git": MockGit})
    return
F
Frost Ming 已提交
364 365 366 367 368


@pytest.fixture(params=[False, True])
def is_editable(request):
    return request.param
F
frostming 已提交
369 370 371 372 373


@pytest.fixture(params=[False, True])
def is_dev(request):
    return request.param
F
frostming 已提交
374 375


376 377 378 379 380
@dataclass
class RunResult:
    exit_code: int
    stdout: str
    stderr: str
381
    exception: Exception | None = None
382 383 384 385 386 387 388 389 390 391 392 393 394

    @property
    def output(self) -> str:
        return self.stdout

    @property
    def outputs(self) -> str:
        return self.stdout + self.stderr

    def print(self):
        print("# exit code:", self.exit_code)
        print("# stdout:", self.stdout, sep="\n")
        print("# stderr:", self.stderr, sep="\n")
395

396 397 398 399 400 401

@pytest.fixture()
def invoke(core, monkeypatch):
    def caller(
        args,
        strict: bool = False,
402 403 404
        input: str | None = None,
        obj: Project | None = None,
        env: Mapping[str, str] | None = None,
405 406
        **kwargs,
    ):
407
        __tracebackhide__ = True
408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430

        stdin = StringIO(input)
        stdout = StringIO()
        stderr = StringIO()
        exit_code = 0
        exception = None

        with monkeypatch.context() as m:
            m.setattr("sys.stdin", stdin)
            m.setattr("sys.stdout", stdout)
            m.setattr("sys.stderr", stderr)
            for key, value in (env or {}).items():
                m.setenv(key, value)
            try:
                core.main(args, "pdm", obj=obj, **kwargs)
            except SystemExit as e:
                exit_code = e.code
            except Exception as e:
                exit_code = 1
                exception = e

        result = RunResult(exit_code, stdout.getvalue(), stderr.getvalue(), exception)

431 432 433 434 435 436 437
        if strict and result.exit_code != 0:
            raise RuntimeError(
                f"Call command {args} failed({result.exit_code}): {result.stderr}"
            )
        return result

    return caller
F
Frost Ming 已提交
438 439 440 441 442 443 444 445 446 447


BACKENDS = ["virtualenv", "venv"]


@pytest.fixture(params=BACKENDS)
def venv_backends(project, request):
    project.project_config["venv.backend"] = request.param
    project.project_config["python.use_venv"] = True
    shutil.rmtree(project.root / "__pypackages__", ignore_errors=True)