|
| 1 | +import shlex |
1 | 2 | from collections.abc import Mapping, MutableMapping, Sequence |
| 3 | +from dataclasses import dataclass, field |
2 | 4 | from pathlib import Path, PurePath |
3 | 5 | from typing import Any, Literal, Self, TypeVar |
4 | 6 |
|
5 | 7 | from packaging.utils import parse_wheel_filename |
6 | 8 |
|
7 | 9 | from . import resources |
8 | 10 | from .cmd import call |
| 11 | +from .helpers import parse_key_value_string, unwrap |
9 | 12 |
|
10 | 13 |
|
| 14 | +@dataclass() |
11 | 15 | class DependencyConstraints: |
12 | | - def __init__(self, base_file_path: Path): |
13 | | - assert base_file_path.exists() |
14 | | - self.base_file_path = base_file_path.resolve() |
| 16 | + base_file_path: Path | None = None |
| 17 | + packages: list[str] = field(default_factory=list) |
| 18 | + |
| 19 | + def __post_init__(self) -> None: |
| 20 | + if self.packages and self.base_file_path is not None: |
| 21 | + msg = "Cannot specify both a file and packages in the dependency constraints" |
| 22 | + raise ValueError(msg) |
| 23 | + |
| 24 | + if self.base_file_path is not None: |
| 25 | + if not self.base_file_path.exists(): |
| 26 | + msg = f"Dependency constraints file not found: {self.base_file_path}" |
| 27 | + raise FileNotFoundError(msg) |
| 28 | + self.base_file_path = self.base_file_path.resolve() |
15 | 29 |
|
16 | 30 | @classmethod |
17 | | - def with_defaults(cls) -> Self: |
| 31 | + def pinned(cls) -> Self: |
18 | 32 | return cls(base_file_path=resources.CONSTRAINTS) |
19 | 33 |
|
20 | | - def get_for_python_version( |
21 | | - self, version: str, *, variant: Literal["python", "pyodide"] = "python" |
22 | | - ) -> Path: |
23 | | - version_parts = version.split(".") |
24 | | - |
25 | | - # try to find a version-specific dependency file e.g. if |
26 | | - # ./constraints.txt is the base, look for ./constraints-python36.txt |
27 | | - specific_stem = self.base_file_path.stem + f"-{variant}{version_parts[0]}{version_parts[1]}" |
28 | | - specific_name = specific_stem + self.base_file_path.suffix |
29 | | - specific_file_path = self.base_file_path.with_name(specific_name) |
30 | | - |
31 | | - if specific_file_path.exists(): |
32 | | - return specific_file_path |
33 | | - else: |
34 | | - return self.base_file_path |
| 34 | + @classmethod |
| 35 | + def latest(cls) -> Self: |
| 36 | + return cls() |
35 | 37 |
|
36 | | - def __repr__(self) -> str: |
37 | | - return f"{self.__class__.__name__}({self.base_file_path!r})" |
| 38 | + @classmethod |
| 39 | + def from_config_string(cls, config_string: str) -> Self: |
| 40 | + if config_string == "pinned": |
| 41 | + return cls.pinned() |
| 42 | + |
| 43 | + if config_string == "latest" or not config_string: |
| 44 | + return cls.latest() |
| 45 | + |
| 46 | + if config_string.startswith(("file:", "packages:")): |
| 47 | + # we only do the table-style parsing if it looks like a table, |
| 48 | + # because this option used to be only a file path. We don't want |
| 49 | + # to break existing configurations, whose file paths might include |
| 50 | + # special characters like ':' or ' ', which would require quoting |
| 51 | + # if they were to be passed as a parse_key_value_string positional |
| 52 | + # argument. |
| 53 | + return cls.from_table_style_config_string(config_string) |
38 | 54 |
|
39 | | - def __eq__(self, o: object) -> bool: |
40 | | - if not isinstance(o, DependencyConstraints): |
41 | | - return False |
| 55 | + return cls(base_file_path=Path(config_string)) |
| 56 | + |
| 57 | + @classmethod |
| 58 | + def from_table_style_config_string(cls, config_string: str) -> Self: |
| 59 | + config_dict = parse_key_value_string(config_string, kw_arg_names=["file", "packages"]) |
| 60 | + files = config_dict.get("file") |
| 61 | + packages = config_dict.get("packages") or [] |
42 | 62 |
|
43 | | - return self.base_file_path == o.base_file_path |
| 63 | + if files and packages: |
| 64 | + msg = "Cannot specify both a file and packages in dependency-versions" |
| 65 | + raise ValueError(msg) |
| 66 | + |
| 67 | + if files: |
| 68 | + if len(files) > 1: |
| 69 | + msg = unwrap(""" |
| 70 | + Only one file can be specified in dependency-versions. |
| 71 | + If you intended to pass only one, perhaps you need to quote the path? |
| 72 | + """) |
| 73 | + raise ValueError(msg) |
| 74 | + |
| 75 | + return cls(base_file_path=Path(files[0])) |
| 76 | + |
| 77 | + return cls(packages=packages) |
| 78 | + |
| 79 | + def get_for_python_version( |
| 80 | + self, *, version: str, variant: Literal["python", "pyodide"] = "python", tmp_dir: Path |
| 81 | + ) -> Path | None: |
| 82 | + if self.packages: |
| 83 | + constraint_file = tmp_dir / "constraints.txt" |
| 84 | + constraint_file.write_text("\n".join(self.packages)) |
| 85 | + return constraint_file |
| 86 | + |
| 87 | + if self.base_file_path is not None: |
| 88 | + version_parts = version.split(".") |
| 89 | + |
| 90 | + # try to find a version-specific dependency file e.g. if |
| 91 | + # ./constraints.txt is the base, look for ./constraints-python36.txt |
| 92 | + specific_stem = ( |
| 93 | + self.base_file_path.stem + f"-{variant}{version_parts[0]}{version_parts[1]}" |
| 94 | + ) |
| 95 | + specific_name = specific_stem + self.base_file_path.suffix |
| 96 | + specific_file_path = self.base_file_path.with_name(specific_name) |
| 97 | + |
| 98 | + if specific_file_path.exists(): |
| 99 | + return specific_file_path |
| 100 | + else: |
| 101 | + return self.base_file_path |
| 102 | + |
| 103 | + return None |
44 | 104 |
|
45 | 105 | def options_summary(self) -> Any: |
46 | | - if self == DependencyConstraints.with_defaults(): |
| 106 | + if self == DependencyConstraints.pinned(): |
47 | 107 | return "pinned" |
48 | | - else: |
| 108 | + elif self.packages: |
| 109 | + return {"packages": " ".join(shlex.quote(p) for p in self.packages)} |
| 110 | + elif self.base_file_path is not None: |
49 | 111 | return self.base_file_path.name |
| 112 | + else: |
| 113 | + return "latest" |
50 | 114 |
|
51 | 115 |
|
52 | 116 | def get_pip_version(env: Mapping[str, str]) -> str: |
|
0 commit comments