python-discovery 1.0.0__tar.gz → 1.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_discovery-1.1.1/.github/FUNDING.yaml +1 -0
- python_discovery-1.1.1/.github/SECURITY.md +9 -0
- python_discovery-1.1.1/.github/release.yaml +5 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/.github/workflows/check.yaml +3 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/.github/workflows/release.yaml +5 -2
- {python_discovery-1.0.0 → python_discovery-1.1.1}/.pre-commit-config.yaml +3 -3
- {python_discovery-1.0.0 → python_discovery-1.1.1}/.readthedocs.yaml +1 -1
- {python_discovery-1.0.0 → python_discovery-1.1.1}/PKG-INFO +2 -2
- {python_discovery-1.0.0 → python_discovery-1.1.1}/README.md +1 -1
- {python_discovery-1.0.0 → python_discovery-1.1.1}/docs/explanation.rst +1 -1
- {python_discovery-1.0.0 → python_discovery-1.1.1}/docs/index.rst +1 -1
- {python_discovery-1.0.0 → python_discovery-1.1.1}/pyproject.toml +17 -17
- {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/__init__.py +4 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/_cache.py +40 -8
- {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/_cached_py_info.py +5 -2
- {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/_discovery.py +29 -2
- {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/_py_info.py +69 -8
- {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/_py_spec.py +31 -4
- {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/_specifier.py +52 -5
- {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/conftest.py +2 -1
- {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/py_info/test_py_info.py +1 -1
- {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/test_cached_py_info.py +12 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/test_discovery.py +35 -0
- python_discovery-1.0.0/.github/release.yml +0 -5
- /python_discovery-1.0.0/.github/dependabot.yml → /python_discovery-1.1.1/.github/dependabot.yaml +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/.gitignore +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/.readthedocs.yml +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/CODE_OF_CONDUCT.md +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/LICENSE +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/docs/_static/custom.css +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/docs/_static/logo.svg +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/docs/conf.py +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/docs/how-to/standalone-usage.rst +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/docs/reference/api.rst +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/docs/tutorial/getting-started.rst +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/_compat.py +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/_windows/__init__.py +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/_windows/_pep514.py +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/_windows/_propose.py +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/py.typed +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/py_info/test_py_info_exe_based_of.py +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/test_cache.py +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/test_discovery_extra.py +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/test_py_info_extra.py +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/test_py_spec.py +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/test_py_spec_extra.py +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/test_specifier.py +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/windows/conftest.py +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/windows/test_windows.py +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/windows/test_windows_pep514.py +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/windows/winreg_mock_values.py +0 -0
- {python_discovery-1.0.0 → python_discovery-1.1.1}/tox.toml +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
github: gaborbernat
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported Versions
|
|
4
|
+
|
|
5
|
+
Only the latest released version is supported with security updates. Older versions will not receive patches.
|
|
6
|
+
|
|
7
|
+
## Reporting a Vulnerability
|
|
8
|
+
|
|
9
|
+
Please report security vulnerabilities via [GitHub's private vulnerability reporting](../../security/advisories/new).
|
|
@@ -3,6 +3,9 @@ on:
|
|
|
3
3
|
push:
|
|
4
4
|
tags: ["*"]
|
|
5
5
|
|
|
6
|
+
permissions:
|
|
7
|
+
contents: read
|
|
8
|
+
|
|
6
9
|
env:
|
|
7
10
|
dists-artifact-name: python-package-distributions
|
|
8
11
|
|
|
@@ -22,7 +25,7 @@ jobs:
|
|
|
22
25
|
- name: Build package
|
|
23
26
|
run: uv build --python 3.14 --python-preference only-managed --sdist --wheel . --out-dir dist
|
|
24
27
|
- name: Store the distribution packages
|
|
25
|
-
uses: actions/upload-artifact@
|
|
28
|
+
uses: actions/upload-artifact@v7
|
|
26
29
|
with:
|
|
27
30
|
name: ${{ env.dists-artifact-name }}
|
|
28
31
|
path: dist/*
|
|
@@ -38,7 +41,7 @@ jobs:
|
|
|
38
41
|
id-token: write
|
|
39
42
|
steps:
|
|
40
43
|
- name: Download all the dists
|
|
41
|
-
uses: actions/download-artifact@
|
|
44
|
+
uses: actions/download-artifact@v8
|
|
42
45
|
with:
|
|
43
46
|
name: ${{ env.dists-artifact-name }}
|
|
44
47
|
path: dist/
|
|
@@ -5,7 +5,7 @@ repos:
|
|
|
5
5
|
- id: end-of-file-fixer
|
|
6
6
|
- id: trailing-whitespace
|
|
7
7
|
- repo: https://github.com/python-jsonschema/check-jsonschema
|
|
8
|
-
rev: 0.
|
|
8
|
+
rev: 0.37.0
|
|
9
9
|
hooks:
|
|
10
10
|
- id: check-github-workflows
|
|
11
11
|
args: ["--verbose"]
|
|
@@ -15,11 +15,11 @@ repos:
|
|
|
15
15
|
- id: codespell
|
|
16
16
|
additional_dependencies: ["tomli>=2.4"]
|
|
17
17
|
- repo: https://github.com/tox-dev/pyproject-fmt
|
|
18
|
-
rev: "v2.
|
|
18
|
+
rev: "v2.16.2"
|
|
19
19
|
hooks:
|
|
20
20
|
- id: pyproject-fmt
|
|
21
21
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
22
|
-
rev: "v0.
|
|
22
|
+
rev: "v0.15.4"
|
|
23
23
|
hooks:
|
|
24
24
|
- id: ruff-format
|
|
25
25
|
- id: ruff
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-discovery
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.1
|
|
4
4
|
Summary: Python interpreter discovery
|
|
5
5
|
Project-URL: Changelog, https://github.com/tox-dev/python-discovery/releases
|
|
6
6
|
Project-URL: Documentation, https://python-discovery.readthedocs.io
|
|
@@ -67,5 +67,5 @@ Description-Content-Type: text/markdown
|
|
|
67
67
|
[](https://pypi.org/project/python-discovery/)
|
|
69
69
|
[](https://pepy.tech/project/python-discovery)
|
|
70
|
-
[](https://github.com/tox-dev/python-discovery/actions/workflows/check.yaml)
|
|
71
71
|
[](https://python-discovery.readthedocs.io/en/latest/?badge=latest)
|
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
[](https://pypi.org/project/python-discovery/)
|
|
6
6
|
[](https://pepy.tech/project/python-discovery)
|
|
7
|
-
[](https://github.com/tox-dev/python-discovery/actions/workflows/check.yaml)
|
|
8
8
|
[](https://python-discovery.readthedocs.io/en/latest/?badge=latest)
|
|
@@ -2,7 +2,7 @@ How it works
|
|
|
2
2
|
============
|
|
3
3
|
|
|
4
4
|
Where does python-discovery look?
|
|
5
|
-
|
|
5
|
+
---------------------------------
|
|
6
6
|
|
|
7
7
|
When you call :func:`~python_discovery.get_interpreter`, the library checks several locations in
|
|
8
8
|
order. It stops as soon as it finds an interpreter that matches your spec.
|
|
@@ -129,31 +129,20 @@ count = true
|
|
|
129
129
|
[tool.pyproject-fmt]
|
|
130
130
|
max_supported_python = "3.14"
|
|
131
131
|
|
|
132
|
-
[tool.pytest
|
|
133
|
-
markers = [ "graalpy" ]
|
|
132
|
+
[tool.pytest]
|
|
133
|
+
ini_options.markers = [ "graalpy" ]
|
|
134
134
|
|
|
135
135
|
[tool.coverage]
|
|
136
|
-
run.source = [
|
|
137
|
-
"python_discovery",
|
|
138
|
-
"tests",
|
|
139
|
-
]
|
|
140
|
-
run.dynamic_context = "test_function"
|
|
141
136
|
run.branch = true
|
|
137
|
+
run.dynamic_context = "test_function"
|
|
142
138
|
run.parallel = true
|
|
143
139
|
run.plugins = [
|
|
144
140
|
"covdefaults",
|
|
145
141
|
]
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
"assert any\\(",
|
|
150
|
-
]
|
|
151
|
-
report.omit = [
|
|
152
|
-
"src/python_discovery/_windows/*",
|
|
153
|
-
"tests/windows/*",
|
|
142
|
+
run.source = [
|
|
143
|
+
"python_discovery",
|
|
144
|
+
"tests",
|
|
154
145
|
]
|
|
155
|
-
html.show_contexts = true
|
|
156
|
-
html.skip_covered = false
|
|
157
146
|
paths.source = [
|
|
158
147
|
"src",
|
|
159
148
|
".tox*/*/lib/python*/site-packages",
|
|
@@ -162,6 +151,17 @@ paths.source = [
|
|
|
162
151
|
"*/src",
|
|
163
152
|
"*\\src",
|
|
164
153
|
]
|
|
154
|
+
report.fail_under = 100
|
|
155
|
+
report.omit = [
|
|
156
|
+
"src/python_discovery/_windows/*",
|
|
157
|
+
"tests/windows/*",
|
|
158
|
+
]
|
|
159
|
+
report.partial_branches = [
|
|
160
|
+
"assert any\\(",
|
|
161
|
+
]
|
|
162
|
+
report.show_missing = true
|
|
163
|
+
html.show_contexts = true
|
|
164
|
+
html.skip_covered = false
|
|
165
165
|
|
|
166
166
|
[tool.ty]
|
|
167
167
|
src.exclude = [ "tests/windows/winreg_mock_values.py" ]
|
|
@@ -8,6 +8,7 @@ from ._cache import ContentStore, DiskCache, PyInfoCache
|
|
|
8
8
|
from ._discovery import get_interpreter
|
|
9
9
|
from ._py_info import PythonInfo
|
|
10
10
|
from ._py_spec import PythonSpec
|
|
11
|
+
from ._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion
|
|
11
12
|
|
|
12
13
|
__version__ = version("python-discovery")
|
|
13
14
|
|
|
@@ -17,6 +18,9 @@ __all__ = [
|
|
|
17
18
|
"PyInfoCache",
|
|
18
19
|
"PythonInfo",
|
|
19
20
|
"PythonSpec",
|
|
21
|
+
"SimpleSpecifier",
|
|
22
|
+
"SimpleSpecifierSet",
|
|
23
|
+
"SimpleVersion",
|
|
20
24
|
"__version__",
|
|
21
25
|
"get_interpreter",
|
|
22
26
|
]
|
|
@@ -19,25 +19,47 @@ _LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
|
|
|
19
19
|
class ContentStore(Protocol):
|
|
20
20
|
"""A store for reading and writing cached content."""
|
|
21
21
|
|
|
22
|
-
def exists(self) -> bool:
|
|
22
|
+
def exists(self) -> bool:
|
|
23
|
+
"""Return whether the cached content exists."""
|
|
24
|
+
...
|
|
23
25
|
|
|
24
|
-
def read(self) -> dict | None:
|
|
26
|
+
def read(self) -> dict | None:
|
|
27
|
+
"""Read the cached content, or ``None`` if unavailable or corrupt."""
|
|
28
|
+
...
|
|
25
29
|
|
|
26
|
-
def write(self, content: dict) -> None:
|
|
30
|
+
def write(self, content: dict) -> None:
|
|
31
|
+
"""
|
|
32
|
+
Persist *content* to the store.
|
|
27
33
|
|
|
28
|
-
|
|
34
|
+
:param content: interpreter metadata to cache.
|
|
35
|
+
"""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
def remove(self) -> None:
|
|
39
|
+
"""Delete the cached content."""
|
|
40
|
+
...
|
|
29
41
|
|
|
30
42
|
@contextmanager
|
|
31
|
-
def locked(self) -> Generator[None]:
|
|
43
|
+
def locked(self) -> Generator[None]:
|
|
44
|
+
"""Context manager that acquires an exclusive lock on this store."""
|
|
45
|
+
...
|
|
32
46
|
|
|
33
47
|
|
|
34
48
|
@runtime_checkable
|
|
35
49
|
class PyInfoCache(Protocol):
|
|
36
50
|
"""Cache interface for Python interpreter information."""
|
|
37
51
|
|
|
38
|
-
def py_info(self, path: Path) -> ContentStore:
|
|
52
|
+
def py_info(self, path: Path) -> ContentStore:
|
|
53
|
+
"""
|
|
54
|
+
Return the content store for the interpreter at *path*.
|
|
55
|
+
|
|
56
|
+
:param path: absolute path to a Python executable.
|
|
57
|
+
"""
|
|
58
|
+
...
|
|
39
59
|
|
|
40
|
-
def py_info_clear(self) -> None:
|
|
60
|
+
def py_info_clear(self) -> None:
|
|
61
|
+
"""Remove all cached interpreter information."""
|
|
62
|
+
...
|
|
41
63
|
|
|
42
64
|
|
|
43
65
|
class DiskContentStore:
|
|
@@ -91,7 +113,11 @@ class DiskContentStore:
|
|
|
91
113
|
|
|
92
114
|
|
|
93
115
|
class DiskCache:
|
|
94
|
-
"""
|
|
116
|
+
"""
|
|
117
|
+
File-system based Python interpreter info cache (``<root>/py_info/4/<sha256>.json``).
|
|
118
|
+
|
|
119
|
+
:param root: root directory for the on-disk cache.
|
|
120
|
+
"""
|
|
95
121
|
|
|
96
122
|
def __init__(self, root: Path) -> None:
|
|
97
123
|
self._root = root
|
|
@@ -101,10 +127,16 @@ class DiskCache:
|
|
|
101
127
|
return self._root / "py_info" / "4"
|
|
102
128
|
|
|
103
129
|
def py_info(self, path: Path) -> DiskContentStore:
|
|
130
|
+
"""
|
|
131
|
+
Return the content store for the interpreter at *path*.
|
|
132
|
+
|
|
133
|
+
:param path: absolute path to a Python executable.
|
|
134
|
+
"""
|
|
104
135
|
key = sha256(str(path).encode("utf-8")).hexdigest()
|
|
105
136
|
return DiskContentStore(self._py_info_dir, key)
|
|
106
137
|
|
|
107
138
|
def py_info_clear(self) -> None:
|
|
139
|
+
"""Remove all cached interpreter information."""
|
|
108
140
|
folder = self._py_info_dir
|
|
109
141
|
if folder.exists():
|
|
110
142
|
for entry in folder.iterdir():
|
|
@@ -15,7 +15,7 @@ from collections import OrderedDict
|
|
|
15
15
|
from contextlib import contextmanager
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
from shlex import quote
|
|
18
|
-
from subprocess import Popen # noqa: S404
|
|
18
|
+
from subprocess import Popen, TimeoutExpired # noqa: S404
|
|
19
19
|
from typing import TYPE_CHECKING, Final
|
|
20
20
|
|
|
21
21
|
from ._cache import NoOpCache
|
|
@@ -206,8 +206,11 @@ def _run_subprocess(
|
|
|
206
206
|
encoding="utf-8",
|
|
207
207
|
errors="backslashreplace",
|
|
208
208
|
)
|
|
209
|
-
out, err = process.communicate()
|
|
209
|
+
out, err = process.communicate(timeout=5)
|
|
210
210
|
code = process.returncode
|
|
211
|
+
except TimeoutExpired:
|
|
212
|
+
process.kill()
|
|
213
|
+
out, err, code = "", "timed out", -1
|
|
211
214
|
except OSError as os_error:
|
|
212
215
|
out, err, code = "", os_error.strerror, os_error.errno
|
|
213
216
|
if code != 0:
|
|
@@ -27,10 +27,26 @@ def get_interpreter(
|
|
|
27
27
|
try_first_with: Iterable[str] | None = None,
|
|
28
28
|
cache: PyInfoCache | None = None,
|
|
29
29
|
env: Mapping[str, str] | None = None,
|
|
30
|
+
predicate: Callable[[PythonInfo], bool] | None = None,
|
|
30
31
|
) -> PythonInfo | None:
|
|
32
|
+
"""
|
|
33
|
+
Find a Python interpreter matching *key*.
|
|
34
|
+
|
|
35
|
+
Iterates over one or more specification strings and returns the first interpreter that satisfies the spec and passes
|
|
36
|
+
the optional *predicate*.
|
|
37
|
+
|
|
38
|
+
:param key: interpreter specification string(s) — an absolute path, a version (``3.12``), an implementation prefix
|
|
39
|
+
(``cpython3.12``), or a PEP 440 specifier (``>=3.10``). When a sequence is given each entry is tried in order.
|
|
40
|
+
:param try_first_with: executables to probe before the normal discovery search.
|
|
41
|
+
:param cache: interpreter metadata cache; when ``None`` results are not cached.
|
|
42
|
+
:param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
|
|
43
|
+
:param predicate: optional callback applied after an interpreter matches the spec. Return ``True`` to accept the
|
|
44
|
+
interpreter, ``False`` to skip it and continue searching.
|
|
45
|
+
:return: the first matching interpreter, or ``None`` if no match is found.
|
|
46
|
+
"""
|
|
31
47
|
specs = [key] if isinstance(key, str) else key
|
|
32
48
|
for spec_str in specs:
|
|
33
|
-
if result := _find_interpreter(spec_str, try_first_with or (), cache, env):
|
|
49
|
+
if result := _find_interpreter(spec_str, try_first_with or (), cache, env, predicate):
|
|
34
50
|
return result
|
|
35
51
|
return None
|
|
36
52
|
|
|
@@ -40,6 +56,7 @@ def _find_interpreter(
|
|
|
40
56
|
try_first_with: Iterable[str],
|
|
41
57
|
cache: PyInfoCache | None = None,
|
|
42
58
|
env: Mapping[str, str] | None = None,
|
|
59
|
+
predicate: Callable[[PythonInfo], bool] | None = None,
|
|
43
60
|
) -> PythonInfo | None:
|
|
44
61
|
spec = PythonSpec.from_string_spec(key)
|
|
45
62
|
_LOGGER.info("find interpreter for spec %r", spec)
|
|
@@ -52,7 +69,9 @@ def _find_interpreter(
|
|
|
52
69
|
if proposed_key in proposed_paths:
|
|
53
70
|
continue
|
|
54
71
|
_LOGGER.info("proposed %s", interpreter)
|
|
55
|
-
if interpreter.satisfies(spec, impl_must_match=impl_must_match)
|
|
72
|
+
if interpreter.satisfies(spec, impl_must_match=impl_must_match) and (
|
|
73
|
+
predicate is None or predicate(interpreter)
|
|
74
|
+
):
|
|
56
75
|
_LOGGER.debug("accepted %s", interpreter)
|
|
57
76
|
return interpreter
|
|
58
77
|
proposed_paths.add(proposed_key)
|
|
@@ -88,6 +107,14 @@ def propose_interpreters(
|
|
|
88
107
|
cache: PyInfoCache | None = None,
|
|
89
108
|
env: Mapping[str, str] | None = None,
|
|
90
109
|
) -> Generator[tuple[PythonInfo | None, bool], None, None]:
|
|
110
|
+
"""
|
|
111
|
+
Yield ``(interpreter, impl_must_match)`` candidates for *spec*.
|
|
112
|
+
|
|
113
|
+
:param spec: the parsed interpreter specification to match against.
|
|
114
|
+
:param try_first_with: executable paths to probe before the standard search.
|
|
115
|
+
:param cache: interpreter metadata cache; when ``None`` results are not cached.
|
|
116
|
+
:param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
|
|
117
|
+
"""
|
|
91
118
|
env = os.environ if env is None else env
|
|
92
119
|
tested_exes: set[str] = set()
|
|
93
120
|
if spec.is_abs and spec.path is not None:
|
|
@@ -245,7 +245,11 @@ class PythonInfo: # noqa: PLR0904
|
|
|
245
245
|
return None # in this case we just can't tell easily without poking around FS and calling them, bail
|
|
246
246
|
|
|
247
247
|
def install_path(self, key: str) -> str:
|
|
248
|
-
"""
|
|
248
|
+
"""
|
|
249
|
+
Return the relative installation path for a given installation scheme *key*.
|
|
250
|
+
|
|
251
|
+
:param key: sysconfig installation scheme key (e.g. ``"scripts"``, ``"purelib"``).
|
|
252
|
+
"""
|
|
249
253
|
result = self.distutils_install.get(key)
|
|
250
254
|
if result is None: # pragma: >=3.11 cover # distutils is empty when "venv" scheme is available
|
|
251
255
|
# set prefixes to empty => result is relative from cwd
|
|
@@ -308,7 +312,13 @@ class PythonInfo: # noqa: PLR0904
|
|
|
308
312
|
return self.base_prefix is not None
|
|
309
313
|
|
|
310
314
|
def sysconfig_path(self, key: str, config_var: dict[str, str] | None = None, sep: str = os.sep) -> str:
|
|
311
|
-
"""
|
|
315
|
+
"""
|
|
316
|
+
Return the sysconfig install path for a scheme *key*, optionally substituting config variables.
|
|
317
|
+
|
|
318
|
+
:param key: sysconfig path key (e.g. ``"purelib"``, ``"include"``).
|
|
319
|
+
:param config_var: replacement mapping for sysconfig variables; when ``None`` uses the interpreter's own values.
|
|
320
|
+
:param sep: path separator to use in the result.
|
|
321
|
+
"""
|
|
312
322
|
pattern = self.sysconfig_paths.get(key)
|
|
313
323
|
if pattern is None:
|
|
314
324
|
return ""
|
|
@@ -406,14 +416,23 @@ class PythonInfo: # noqa: PLR0904
|
|
|
406
416
|
|
|
407
417
|
@classmethod
|
|
408
418
|
def clear_cache(cls, cache: PyInfoCache) -> None:
|
|
409
|
-
"""
|
|
419
|
+
"""
|
|
420
|
+
Clear all cached interpreter information from *cache*.
|
|
421
|
+
|
|
422
|
+
:param cache: the cache store to clear.
|
|
423
|
+
"""
|
|
410
424
|
from ._cached_py_info import clear # noqa: PLC0415
|
|
411
425
|
|
|
412
426
|
clear(cache)
|
|
413
427
|
cls._cache_exe_discovery.clear()
|
|
414
428
|
|
|
415
429
|
def satisfies(self, spec: PythonSpec, *, impl_must_match: bool) -> bool: # noqa: PLR0911
|
|
416
|
-
"""
|
|
430
|
+
"""
|
|
431
|
+
Check if a given specification can be satisfied by this python interpreter instance.
|
|
432
|
+
|
|
433
|
+
:param spec: the specification to check against.
|
|
434
|
+
:param impl_must_match: when ``True``, the implementation name must match exactly.
|
|
435
|
+
"""
|
|
417
436
|
if spec.path and not self._satisfies_path(spec):
|
|
418
437
|
return False
|
|
419
438
|
if impl_must_match and not self._satisfies_implementation(spec):
|
|
@@ -462,7 +481,11 @@ class PythonInfo: # noqa: PLR0904
|
|
|
462
481
|
|
|
463
482
|
@classmethod
|
|
464
483
|
def current(cls, cache: PyInfoCache | None = None) -> PythonInfo:
|
|
465
|
-
"""
|
|
484
|
+
"""
|
|
485
|
+
Locate the current host interpreter information.
|
|
486
|
+
|
|
487
|
+
:param cache: interpreter metadata cache; when ``None`` results are not cached.
|
|
488
|
+
"""
|
|
466
489
|
if cls._current is None:
|
|
467
490
|
result = cls.from_exe(sys.executable, cache, raise_on_error=True, resolve_to_host=False)
|
|
468
491
|
if result is None:
|
|
@@ -473,7 +496,11 @@ class PythonInfo: # noqa: PLR0904
|
|
|
473
496
|
|
|
474
497
|
@classmethod
|
|
475
498
|
def current_system(cls, cache: PyInfoCache | None = None) -> PythonInfo:
|
|
476
|
-
"""
|
|
499
|
+
"""
|
|
500
|
+
Locate the current system interpreter information, resolving through any virtualenv layers.
|
|
501
|
+
|
|
502
|
+
:param cache: interpreter metadata cache; when ``None`` results are not cached.
|
|
503
|
+
"""
|
|
477
504
|
if cls._current_system is None:
|
|
478
505
|
result = cls.from_exe(sys.executable, cache, raise_on_error=True, resolve_to_host=True)
|
|
479
506
|
if result is None:
|
|
@@ -483,9 +510,11 @@ class PythonInfo: # noqa: PLR0904
|
|
|
483
510
|
return cls._current_system
|
|
484
511
|
|
|
485
512
|
def to_json(self) -> str:
|
|
513
|
+
"""Serialize this interpreter information to a JSON string."""
|
|
486
514
|
return json.dumps(self.to_dict(), indent=2)
|
|
487
515
|
|
|
488
516
|
def to_dict(self) -> dict[str, object]:
|
|
517
|
+
"""Convert this interpreter information to a plain dictionary."""
|
|
489
518
|
data = {var: (getattr(self, var) if var != "_creators" else None) for var in vars(self)}
|
|
490
519
|
version_info = data["version_info"]
|
|
491
520
|
data["version_info"] = version_info._asdict() if hasattr(version_info, "_asdict") else version_info
|
|
@@ -502,7 +531,16 @@ class PythonInfo: # noqa: PLR0904
|
|
|
502
531
|
resolve_to_host: bool = True,
|
|
503
532
|
env: Mapping[str, str] | None = None,
|
|
504
533
|
) -> PythonInfo | None:
|
|
505
|
-
"""
|
|
534
|
+
"""
|
|
535
|
+
Get the python information for a given executable path.
|
|
536
|
+
|
|
537
|
+
:param exe: path to the Python executable.
|
|
538
|
+
:param cache: interpreter metadata cache; when ``None`` results are not cached.
|
|
539
|
+
:param raise_on_error: raise on failure instead of returning ``None``.
|
|
540
|
+
:param ignore_cache: bypass the cache and re-query the interpreter.
|
|
541
|
+
:param resolve_to_host: resolve through virtualenv layers to the system interpreter.
|
|
542
|
+
:param env: environment mapping; defaults to :data:`os.environ`.
|
|
543
|
+
"""
|
|
506
544
|
from ._cached_py_info import from_exe # noqa: PLC0415
|
|
507
545
|
|
|
508
546
|
env = os.environ if env is None else env
|
|
@@ -520,11 +558,21 @@ class PythonInfo: # noqa: PLR0904
|
|
|
520
558
|
|
|
521
559
|
@classmethod
|
|
522
560
|
def from_json(cls, payload: str) -> PythonInfo:
|
|
561
|
+
"""
|
|
562
|
+
Deserialize interpreter information from a JSON string.
|
|
563
|
+
|
|
564
|
+
:param payload: JSON produced by :meth:`to_json`.
|
|
565
|
+
"""
|
|
523
566
|
raw = json.loads(payload)
|
|
524
567
|
return cls.from_dict(raw.copy())
|
|
525
568
|
|
|
526
569
|
@classmethod
|
|
527
570
|
def from_dict(cls, data: dict[str, object]) -> PythonInfo:
|
|
571
|
+
"""
|
|
572
|
+
Reconstruct a :class:`PythonInfo` from a plain dictionary.
|
|
573
|
+
|
|
574
|
+
:param data: dictionary produced by :meth:`to_dict`.
|
|
575
|
+
"""
|
|
528
576
|
data["version_info"] = VersionInfo(**data["version_info"]) # restore this to a named tuple structure
|
|
529
577
|
result = cls()
|
|
530
578
|
result.__dict__ = data.copy()
|
|
@@ -532,6 +580,12 @@ class PythonInfo: # noqa: PLR0904
|
|
|
532
580
|
|
|
533
581
|
@classmethod
|
|
534
582
|
def resolve_to_system(cls, cache: PyInfoCache | None, target: PythonInfo) -> PythonInfo:
|
|
583
|
+
"""
|
|
584
|
+
Walk virtualenv/venv prefix chains to find the underlying system interpreter.
|
|
585
|
+
|
|
586
|
+
:param cache: interpreter metadata cache; when ``None`` results are not cached.
|
|
587
|
+
:param target: the interpreter to resolve.
|
|
588
|
+
"""
|
|
535
589
|
start_executable = target.executable
|
|
536
590
|
prefixes = OrderedDict()
|
|
537
591
|
while target.system_executable is None:
|
|
@@ -565,7 +619,14 @@ class PythonInfo: # noqa: PLR0904
|
|
|
565
619
|
exact: bool = True,
|
|
566
620
|
env: Mapping[str, str] | None = None,
|
|
567
621
|
) -> PythonInfo:
|
|
568
|
-
"""
|
|
622
|
+
"""
|
|
623
|
+
Discover a matching Python executable under a given *prefix* directory.
|
|
624
|
+
|
|
625
|
+
:param cache: interpreter metadata cache.
|
|
626
|
+
:param prefix: directory prefix to search under.
|
|
627
|
+
:param exact: when ``True``, require an exact version match.
|
|
628
|
+
:param env: environment mapping; defaults to :data:`os.environ`.
|
|
629
|
+
"""
|
|
569
630
|
key = prefix, exact
|
|
570
631
|
if key in self._cache_exe_discovery and prefix:
|
|
571
632
|
_LOGGER.debug("discover exe from cache %s - exact %s: %r", prefix, exact, self._cache_exe_discovery[key])
|
|
@@ -99,7 +99,20 @@ def _parse_specifier(string_spec: str) -> PythonSpec | None:
|
|
|
99
99
|
|
|
100
100
|
|
|
101
101
|
class PythonSpec:
|
|
102
|
-
"""
|
|
102
|
+
"""
|
|
103
|
+
Contains specification about a Python Interpreter.
|
|
104
|
+
|
|
105
|
+
:param str_spec: the raw specification string as provided by the caller.
|
|
106
|
+
:param implementation: interpreter implementation name (e.g. ``"cpython"``, ``"pypy"``), or ``None`` for any.
|
|
107
|
+
:param major: required major version, or ``None`` for any.
|
|
108
|
+
:param minor: required minor version, or ``None`` for any.
|
|
109
|
+
:param micro: required micro (patch) version, or ``None`` for any.
|
|
110
|
+
:param architecture: required pointer-size bitness (``32`` or ``64``), or ``None`` for any.
|
|
111
|
+
:param path: filesystem path to a specific interpreter, or ``None``.
|
|
112
|
+
:param free_threaded: whether a free-threaded build is required, or ``None`` for any.
|
|
113
|
+
:param machine: required ISA (e.g. ``"arm64"``), or ``None`` for any.
|
|
114
|
+
:param version_specifier: PEP 440 version constraints, or ``None``.
|
|
115
|
+
"""
|
|
103
116
|
|
|
104
117
|
def __init__( # noqa: PLR0913, PLR0917
|
|
105
118
|
self,
|
|
@@ -128,7 +141,12 @@ class PythonSpec:
|
|
|
128
141
|
|
|
129
142
|
@classmethod
|
|
130
143
|
def from_string_spec(cls, string_spec: str) -> PythonSpec:
|
|
131
|
-
"""
|
|
144
|
+
"""
|
|
145
|
+
Parse a string specification into a :class:`PythonSpec`.
|
|
146
|
+
|
|
147
|
+
:param string_spec: an interpreter spec — an absolute path, a version string, an implementation prefix,
|
|
148
|
+
or a PEP 440 specifier.
|
|
149
|
+
"""
|
|
132
150
|
if pathlib.Path(string_spec).is_absolute():
|
|
133
151
|
return cls(string_spec, None, None, None, None, None, string_spec)
|
|
134
152
|
if result := _parse_spec_pattern(string_spec):
|
|
@@ -138,7 +156,11 @@ class PythonSpec:
|
|
|
138
156
|
return cls(string_spec, None, None, None, None, None, string_spec)
|
|
139
157
|
|
|
140
158
|
def generate_re(self, *, windows: bool) -> re.Pattern:
|
|
141
|
-
"""
|
|
159
|
+
"""
|
|
160
|
+
Generate a regular expression for matching interpreter filenames.
|
|
161
|
+
|
|
162
|
+
:param windows: if ``True``, require a ``.exe`` suffix.
|
|
163
|
+
"""
|
|
142
164
|
version = r"{}(\.{}(\.{})?)?".format(
|
|
143
165
|
*(r"\d+" if v is None else v for v in (self.major, self.minor, self.micro)),
|
|
144
166
|
)
|
|
@@ -153,6 +175,7 @@ class PythonSpec:
|
|
|
153
175
|
|
|
154
176
|
@property
|
|
155
177
|
def is_abs(self) -> bool:
|
|
178
|
+
"""``True`` if the spec refers to an absolute filesystem path."""
|
|
156
179
|
return self.path is not None and pathlib.Path(self.path).is_absolute()
|
|
157
180
|
|
|
158
181
|
def _check_version_specifier(self, spec: PythonSpec) -> bool:
|
|
@@ -188,7 +211,11 @@ class PythonSpec:
|
|
|
188
211
|
return None
|
|
189
212
|
|
|
190
213
|
def satisfies(self, spec: PythonSpec) -> bool: # noqa: PLR0911
|
|
191
|
-
"""
|
|
214
|
+
"""
|
|
215
|
+
Check if this spec is compatible with the given *spec* (e.g. PEP-514 on Windows).
|
|
216
|
+
|
|
217
|
+
:param spec: the requirement to check against.
|
|
218
|
+
"""
|
|
192
219
|
if spec.is_abs and self.is_abs and self.path != spec.path:
|
|
193
220
|
return False
|
|
194
221
|
if (
|
|
@@ -40,7 +40,17 @@ _PRE_ORDER: Final[dict[str, int]] = {"a": 1, "b": 2, "rc": 3}
|
|
|
40
40
|
|
|
41
41
|
@dataclass(**_DC_KW)
|
|
42
42
|
class SimpleVersion:
|
|
43
|
-
"""
|
|
43
|
+
"""
|
|
44
|
+
Simple PEP 440-like version parser using only standard library.
|
|
45
|
+
|
|
46
|
+
:param version_str: the original version string.
|
|
47
|
+
:param major: major version number.
|
|
48
|
+
:param minor: minor version number.
|
|
49
|
+
:param micro: micro (patch) version number.
|
|
50
|
+
:param pre_type: pre-release label (``"a"``, ``"b"``, or ``"rc"``), or ``None``.
|
|
51
|
+
:param pre_num: pre-release sequence number, or ``None``.
|
|
52
|
+
:param release: the ``(major, minor, micro)`` tuple.
|
|
53
|
+
"""
|
|
44
54
|
|
|
45
55
|
version_str: str
|
|
46
56
|
major: int
|
|
@@ -52,6 +62,11 @@ class SimpleVersion:
|
|
|
52
62
|
|
|
53
63
|
@classmethod
|
|
54
64
|
def from_string(cls, version_str: str) -> SimpleVersion:
|
|
65
|
+
"""
|
|
66
|
+
Parse a PEP 440 version string (e.g. ``3.12.1``).
|
|
67
|
+
|
|
68
|
+
:param version_str: the version string to parse.
|
|
69
|
+
"""
|
|
55
70
|
stripped = version_str.strip()
|
|
56
71
|
if not (match := _VERSION_RE.match(stripped)):
|
|
57
72
|
msg = f"Invalid version: {version_str}"
|
|
@@ -112,7 +127,16 @@ class SimpleVersion:
|
|
|
112
127
|
|
|
113
128
|
@dataclass(**_DC_KW)
|
|
114
129
|
class SimpleSpecifier:
|
|
115
|
-
"""
|
|
130
|
+
"""
|
|
131
|
+
Simple PEP 440-like version specifier using only standard library.
|
|
132
|
+
|
|
133
|
+
:param spec_str: the original specifier string (e.g. ``>=3.10``).
|
|
134
|
+
:param operator: the comparison operator (``==``, ``>=``, ``<``, etc.).
|
|
135
|
+
:param version_str: the version portion of the specifier, without the operator.
|
|
136
|
+
:param is_wildcard: ``True`` if the specifier uses a wildcard suffix (``.*``).
|
|
137
|
+
:param wildcard_precision: number of version components before the wildcard, or ``None``.
|
|
138
|
+
:param version: the parsed version, or ``None`` if parsing failed.
|
|
139
|
+
"""
|
|
116
140
|
|
|
117
141
|
spec_str: str
|
|
118
142
|
operator: str
|
|
@@ -123,6 +147,11 @@ class SimpleSpecifier:
|
|
|
123
147
|
|
|
124
148
|
@classmethod
|
|
125
149
|
def from_string(cls, spec_str: str) -> SimpleSpecifier:
|
|
150
|
+
"""
|
|
151
|
+
Parse a single PEP 440 specifier (e.g. ``>=3.10``).
|
|
152
|
+
|
|
153
|
+
:param spec_str: the specifier string to parse.
|
|
154
|
+
"""
|
|
126
155
|
stripped = spec_str.strip()
|
|
127
156
|
if not (match := _SPECIFIER_RE.match(stripped)):
|
|
128
157
|
msg = f"Invalid specifier: {spec_str}"
|
|
@@ -148,7 +177,11 @@ class SimpleSpecifier:
|
|
|
148
177
|
)
|
|
149
178
|
|
|
150
179
|
def contains(self, version_str: str) -> bool:
|
|
151
|
-
"""
|
|
180
|
+
"""
|
|
181
|
+
Check if a version string satisfies this specifier.
|
|
182
|
+
|
|
183
|
+
:param version_str: the version string to test.
|
|
184
|
+
"""
|
|
152
185
|
try:
|
|
153
186
|
candidate = SimpleVersion.from_string(version_str) if isinstance(version_str, str) else version_str
|
|
154
187
|
except ValueError:
|
|
@@ -216,13 +249,23 @@ class SimpleSpecifier:
|
|
|
216
249
|
|
|
217
250
|
@dataclass(**_DC_KW)
|
|
218
251
|
class SimpleSpecifierSet:
|
|
219
|
-
"""
|
|
252
|
+
"""
|
|
253
|
+
Simple PEP 440-like specifier set using only standard library.
|
|
254
|
+
|
|
255
|
+
:param specifiers_str: the original comma-separated specifier string.
|
|
256
|
+
:param specifiers: the parsed individual specifiers.
|
|
257
|
+
"""
|
|
220
258
|
|
|
221
259
|
specifiers_str: str
|
|
222
260
|
specifiers: tuple[SimpleSpecifier, ...]
|
|
223
261
|
|
|
224
262
|
@classmethod
|
|
225
263
|
def from_string(cls, specifiers_str: str = "") -> SimpleSpecifierSet:
|
|
264
|
+
"""
|
|
265
|
+
Parse a comma-separated PEP 440 specifier string (e.g. ``>=3.10,<4``).
|
|
266
|
+
|
|
267
|
+
:param specifiers_str: the specifier string to parse.
|
|
268
|
+
"""
|
|
226
269
|
stripped = specifiers_str.strip()
|
|
227
270
|
specs: list[SimpleSpecifier] = []
|
|
228
271
|
if stripped:
|
|
@@ -234,7 +277,11 @@ class SimpleSpecifierSet:
|
|
|
234
277
|
return cls(specifiers_str=stripped, specifiers=tuple(specs))
|
|
235
278
|
|
|
236
279
|
def contains(self, version_str: str) -> bool:
|
|
237
|
-
"""
|
|
280
|
+
"""
|
|
281
|
+
Check if a version satisfies all specifiers in the set.
|
|
282
|
+
|
|
283
|
+
:param version_str: the version string to test.
|
|
284
|
+
"""
|
|
238
285
|
if not self.specifiers:
|
|
239
286
|
return True
|
|
240
287
|
return all(spec.contains(version_str) for spec in self.specifiers)
|
|
@@ -26,4 +26,5 @@ def _ensure_py_info_cache_empty(session_cache: DiskCache) -> Generator[None]:
|
|
|
26
26
|
def _skip_if_test_in_system(session_cache: DiskCache) -> None:
|
|
27
27
|
current = PythonInfo.current(session_cache)
|
|
28
28
|
if current.system_executable is not None: # pragma: no cover
|
|
29
|
-
|
|
29
|
+
msg = "test not valid if run under system"
|
|
30
|
+
raise pytest.skip.Exception(msg)
|
|
@@ -338,7 +338,7 @@ def test_py_info_machine_property() -> None:
|
|
|
338
338
|
assert machine is not None
|
|
339
339
|
assert isinstance(machine, str)
|
|
340
340
|
assert len(machine) > 0
|
|
341
|
-
known_isas = {"arm64", "
|
|
341
|
+
known_isas = {"arm64", "i686", "ppc64", "ppc64le", "riscv64", "s390x", "x86", "x86_64"}
|
|
342
342
|
assert machine in known_isas, f"unexpected machine value: {machine}"
|
|
343
343
|
|
|
344
344
|
|
|
@@ -5,6 +5,7 @@ import logging
|
|
|
5
5
|
import os
|
|
6
6
|
import sys
|
|
7
7
|
from pathlib import Path
|
|
8
|
+
from subprocess import TimeoutExpired
|
|
8
9
|
from typing import TYPE_CHECKING
|
|
9
10
|
from unittest.mock import MagicMock, patch
|
|
10
11
|
|
|
@@ -110,6 +111,17 @@ def test_run_subprocess_with_cookies(mocker: MockerFixture) -> None:
|
|
|
110
111
|
assert mock_stdout.write.call_count == 2
|
|
111
112
|
|
|
112
113
|
|
|
114
|
+
def test_run_subprocess_timeout(mocker: MockerFixture) -> None:
|
|
115
|
+
mock_process = MagicMock()
|
|
116
|
+
mock_process.communicate.side_effect = TimeoutExpired(cmd="python", timeout=30)
|
|
117
|
+
mocker.patch("python_discovery._cached_py_info.Popen", return_value=mock_process)
|
|
118
|
+
failure, result = _run_subprocess(PythonInfo, sys.executable, dict(os.environ))
|
|
119
|
+
assert failure is not None
|
|
120
|
+
assert "timed out" in str(failure)
|
|
121
|
+
assert result is None
|
|
122
|
+
mock_process.kill.assert_called_once()
|
|
123
|
+
|
|
124
|
+
|
|
113
125
|
def test_run_subprocess_nonzero_exit(mocker: MockerFixture) -> None:
|
|
114
126
|
mock_process = MagicMock()
|
|
115
127
|
mock_process.communicate.return_value = ("some output", "some error")
|
|
@@ -409,3 +409,38 @@ def test_shim_colon_separated_pyenv_version_picks_first_match(
|
|
|
409
409
|
mock_from_exe.return_value = None
|
|
410
410
|
get_interpreter("python2.7", [])
|
|
411
411
|
assert mock_from_exe.call_args_list[0][0][0] == str(second_binary)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def test_predicate_filters_interpreters(session_cache: DiskCache) -> None:
|
|
415
|
+
result = get_interpreter(sys.executable, [], session_cache, predicate=lambda _: False)
|
|
416
|
+
assert result is None
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def test_predicate_accepts_interpreter(session_cache: DiskCache) -> None:
|
|
420
|
+
result = get_interpreter(sys.executable, [], session_cache, predicate=lambda _: True)
|
|
421
|
+
assert result is not None
|
|
422
|
+
assert result.executable == sys.executable
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def test_predicate_none_is_noop(session_cache: DiskCache) -> None:
|
|
426
|
+
result = get_interpreter(sys.executable, [], session_cache, predicate=None)
|
|
427
|
+
assert result is not None
|
|
428
|
+
assert result.executable == sys.executable
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def test_predicate_with_fallback_specs(session_cache: DiskCache) -> None:
|
|
432
|
+
current = PythonInfo.current_system(session_cache)
|
|
433
|
+
major, minor = current.version_info.major, current.version_info.minor
|
|
434
|
+
accepted_exe: str | None = None
|
|
435
|
+
|
|
436
|
+
def reject_first(info: PythonInfo) -> bool:
|
|
437
|
+
nonlocal accepted_exe
|
|
438
|
+
if accepted_exe is None:
|
|
439
|
+
accepted_exe = str(info.executable)
|
|
440
|
+
return False
|
|
441
|
+
return True
|
|
442
|
+
|
|
443
|
+
result = get_interpreter([f"{major}.{minor}", sys.executable], [], session_cache, predicate=reject_first)
|
|
444
|
+
assert accepted_exe is not None
|
|
445
|
+
assert result is not None
|
|
446
|
+
assert str(result.executable) != accepted_exe
|
/python_discovery-1.0.0/.github/dependabot.yml → /python_discovery-1.1.1/.github/dependabot.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_discovery-1.0.0 → python_discovery-1.1.1}/tests/py_info/test_py_info_exe_based_of.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|