pypilot-cli 1.3.0__py3-none-win_amd64.whl
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.
- pypilot/__init__.py +34 -0
- pypilot/_engine/pypilot.exe +0 -0
- pypilot/api.py +151 -0
- pypilot/binary.py +90 -0
- pypilot/cli.py +117 -0
- pypilot/engine.py +548 -0
- pypilot/py.typed +0 -0
- pypilot_cli-1.3.0.dist-info/METADATA +284 -0
- pypilot_cli-1.3.0.dist-info/RECORD +12 -0
- pypilot_cli-1.3.0.dist-info/WHEEL +4 -0
- pypilot_cli-1.3.0.dist-info/entry_points.txt +2 -0
- pypilot_cli-1.3.0.dist-info/licenses/LICENSE +661 -0
pypilot/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyPilot — Deterministic Python environment doctor and dependency manager.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
__version__ = "1.3.0"
|
|
8
|
+
|
|
9
|
+
from .api import RunResult, arun, run # noqa: E402
|
|
10
|
+
from .engine import ( # noqa: E402
|
|
11
|
+
EngineDownloadError,
|
|
12
|
+
EngineError,
|
|
13
|
+
EngineInfo,
|
|
14
|
+
EngineNotFoundError,
|
|
15
|
+
EngineVerificationError,
|
|
16
|
+
ensure_binary,
|
|
17
|
+
get_cache_dir,
|
|
18
|
+
locate_engine,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"EngineDownloadError",
|
|
23
|
+
"EngineError",
|
|
24
|
+
"EngineInfo",
|
|
25
|
+
"EngineNotFoundError",
|
|
26
|
+
"EngineVerificationError",
|
|
27
|
+
"RunResult",
|
|
28
|
+
"__version__",
|
|
29
|
+
"arun",
|
|
30
|
+
"ensure_binary",
|
|
31
|
+
"get_cache_dir",
|
|
32
|
+
"locate_engine",
|
|
33
|
+
"run",
|
|
34
|
+
]
|
|
Binary file
|
pypilot/api.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pypilot.api: run the engine from Python.
|
|
3
|
+
|
|
4
|
+
`run` keeps its old behaviour: it returns a `subprocess.CompletedProcess`
|
|
5
|
+
(now the `RunResult` subclass, which adds `.ok` and `.json()`), never raises
|
|
6
|
+
for a non-zero exit unless `check=True`, and leaves output on the terminal
|
|
7
|
+
unless `capture_output=True`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json as _json
|
|
14
|
+
import subprocess
|
|
15
|
+
from typing import TYPE_CHECKING, Any, List, Mapping, Optional, Sequence
|
|
16
|
+
|
|
17
|
+
from .engine import StrPath, ensure_binary
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
_CompletedProcess = subprocess.CompletedProcess[str]
|
|
21
|
+
else:
|
|
22
|
+
# Not subscriptable at runtime before Python 3.9.
|
|
23
|
+
_CompletedProcess = subprocess.CompletedProcess
|
|
24
|
+
|
|
25
|
+
# The engine always writes UTF-8, whatever the locale says.
|
|
26
|
+
_ENCODING = "utf-8"
|
|
27
|
+
_ERRORS = "replace"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RunResult(_CompletedProcess):
|
|
31
|
+
"""The outcome of one engine invocation.
|
|
32
|
+
|
|
33
|
+
A `subprocess.CompletedProcess`, so existing code using `.returncode`,
|
|
34
|
+
`.stdout`, `.stderr`, `.args` or `.check_returncode()` keeps working.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def ok(self) -> bool:
|
|
39
|
+
return self.returncode == 0
|
|
40
|
+
|
|
41
|
+
def json(self) -> Any:
|
|
42
|
+
"""Parse the `--json` output of a command.
|
|
43
|
+
|
|
44
|
+
Every command's JSON has the shape
|
|
45
|
+
`{"pypilot": ..., "command": ..., "ok": ..., ...}`. Requires
|
|
46
|
+
`capture_output=True`.
|
|
47
|
+
"""
|
|
48
|
+
if self.stdout is None:
|
|
49
|
+
raise ValueError("stdout was not captured; call run(..., capture_output=True)")
|
|
50
|
+
return _json.loads(self.stdout)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _command(args: Optional[Sequence[str]]) -> List[str]:
|
|
54
|
+
if isinstance(args, str):
|
|
55
|
+
raise TypeError("args must be a list of strings, e.g. ['doctor', '--json']")
|
|
56
|
+
return [str(ensure_binary()), *(args or [])]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def _kill(proc: asyncio.subprocess.Process) -> None:
|
|
60
|
+
if proc.returncode is None:
|
|
61
|
+
try:
|
|
62
|
+
proc.kill()
|
|
63
|
+
except ProcessLookupError:
|
|
64
|
+
pass
|
|
65
|
+
# Read the pipes to EOF, not just wait(): otherwise the loop can close
|
|
66
|
+
# with their transports still open (a ResourceWarning on Windows 3.8).
|
|
67
|
+
await proc.communicate()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run(
|
|
71
|
+
args: Optional[Sequence[str]] = None,
|
|
72
|
+
capture_output: bool = False,
|
|
73
|
+
check: bool = False,
|
|
74
|
+
*,
|
|
75
|
+
cwd: Optional[StrPath] = None,
|
|
76
|
+
env: Optional[Mapping[str, str]] = None,
|
|
77
|
+
input: Optional[str] = None,
|
|
78
|
+
timeout: Optional[float] = None,
|
|
79
|
+
) -> RunResult:
|
|
80
|
+
"""
|
|
81
|
+
Run PyPilot programmatically.
|
|
82
|
+
|
|
83
|
+
Example:
|
|
84
|
+
import pypilot
|
|
85
|
+
res = pypilot.run(["doctor", "--json"], capture_output=True)
|
|
86
|
+
report = res.json()
|
|
87
|
+
|
|
88
|
+
`check=True` raises `subprocess.CalledProcessError` on a non-zero exit.
|
|
89
|
+
"""
|
|
90
|
+
cmd = _command(args)
|
|
91
|
+
proc = subprocess.run(
|
|
92
|
+
cmd,
|
|
93
|
+
capture_output=capture_output,
|
|
94
|
+
encoding=_ENCODING,
|
|
95
|
+
errors=_ERRORS,
|
|
96
|
+
check=False,
|
|
97
|
+
cwd=cwd,
|
|
98
|
+
env=dict(env) if env is not None else None,
|
|
99
|
+
input=input,
|
|
100
|
+
timeout=timeout,
|
|
101
|
+
)
|
|
102
|
+
result = RunResult(proc.args, proc.returncode, proc.stdout, proc.stderr)
|
|
103
|
+
if check:
|
|
104
|
+
result.check_returncode()
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def arun(
|
|
109
|
+
args: Optional[Sequence[str]] = None,
|
|
110
|
+
capture_output: bool = False,
|
|
111
|
+
check: bool = False,
|
|
112
|
+
*,
|
|
113
|
+
cwd: Optional[StrPath] = None,
|
|
114
|
+
env: Optional[Mapping[str, str]] = None,
|
|
115
|
+
input: Optional[str] = None,
|
|
116
|
+
timeout: Optional[float] = None,
|
|
117
|
+
) -> RunResult:
|
|
118
|
+
"""`run`, without blocking the event loop.
|
|
119
|
+
|
|
120
|
+
Locating (and possibly downloading) the engine happens in a worker thread.
|
|
121
|
+
On timeout the engine is killed and `subprocess.TimeoutExpired` raised.
|
|
122
|
+
"""
|
|
123
|
+
loop = asyncio.get_running_loop()
|
|
124
|
+
cmd = await loop.run_in_executor(None, _command, args)
|
|
125
|
+
pipe = asyncio.subprocess.PIPE if capture_output else None
|
|
126
|
+
proc = await asyncio.create_subprocess_exec(
|
|
127
|
+
*cmd,
|
|
128
|
+
stdin=asyncio.subprocess.PIPE if input is not None else None,
|
|
129
|
+
stdout=pipe,
|
|
130
|
+
stderr=pipe,
|
|
131
|
+
cwd=cwd,
|
|
132
|
+
env=dict(env) if env is not None else None,
|
|
133
|
+
)
|
|
134
|
+
data = input.encode(_ENCODING) if input is not None else None
|
|
135
|
+
try:
|
|
136
|
+
out, err = await asyncio.wait_for(proc.communicate(data), timeout)
|
|
137
|
+
except asyncio.TimeoutError:
|
|
138
|
+
await _kill(proc)
|
|
139
|
+
raise subprocess.TimeoutExpired(cmd, timeout or 0) from None
|
|
140
|
+
except asyncio.CancelledError:
|
|
141
|
+
await _kill(proc)
|
|
142
|
+
raise
|
|
143
|
+
|
|
144
|
+
def decode(raw: Optional[bytes]) -> Optional[str]:
|
|
145
|
+
return raw.decode(_ENCODING, _ERRORS) if raw is not None else None
|
|
146
|
+
|
|
147
|
+
assert proc.returncode is not None
|
|
148
|
+
result = RunResult(cmd, proc.returncode, decode(out), decode(err))
|
|
149
|
+
if check:
|
|
150
|
+
result.check_returncode()
|
|
151
|
+
return result
|
pypilot/binary.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pypilot.binary: the engine pin, plus the names this module used to define.
|
|
3
|
+
|
|
4
|
+
`DEFAULT_TAG` and `ENGINE_SHA256` are a contract with the release-coordination
|
|
5
|
+
workflow, which bumps exactly these two assignments in this file when a new
|
|
6
|
+
engine ships. Keep them literal, one asset per line, so a plain text edit can
|
|
7
|
+
update them.
|
|
8
|
+
|
|
9
|
+
The locate/download code moved to `pypilot.engine`; the old names stay
|
|
10
|
+
importable from here (`from pypilot.binary import ensure_binary` still works).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Dict, Tuple
|
|
16
|
+
|
|
17
|
+
GITHUB_REPO = "pypilot-dev/pypilot"
|
|
18
|
+
|
|
19
|
+
# The engine release this package is pinned to.
|
|
20
|
+
DEFAULT_TAG = "v1.3.0"
|
|
21
|
+
|
|
22
|
+
# SHA-256 of every release asset of DEFAULT_TAG. A download is checked against
|
|
23
|
+
# this before it is extracted or run.
|
|
24
|
+
ENGINE_SHA256: Dict[str, str] = {
|
|
25
|
+
"pypilot-darwin-arm64.tar.gz": "7e8c76180d704b3f887a078e8225ee9c9b26720607234afe4cdaab637a8b8ba7",
|
|
26
|
+
"pypilot-darwin-x64.tar.gz": "0ea5d56780500ba19edb94c992629bed5304d4bc53084434e98ed4747d6a4432",
|
|
27
|
+
"pypilot-linux-x64.tar.gz": "9b225dd1015ce32f329413e455f593b6090eb0e6f6a2d85ef8edd597bf1afe32",
|
|
28
|
+
"pypilot-windows-x64.zip": "aab0efddd8abf984696055a8608624c68309909e8aaddd21784a8dc4f3db5a58",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Oldest helper accepted from ~/.cargo/bin or PATH.
|
|
32
|
+
MIN_VERSION: Tuple[int, int, int] = (1, 0, 2)
|
|
33
|
+
|
|
34
|
+
# The release this package ships with. A cached download older than this is
|
|
35
|
+
# replaced, so upgrading the pip package also upgrades the engine.
|
|
36
|
+
_major, _minor, _patch = (int(p) for p in DEFAULT_TAG.lstrip("v").split("."))
|
|
37
|
+
BUNDLED_VERSION: Tuple[int, int, int] = (_major, _minor, _patch)
|
|
38
|
+
|
|
39
|
+
_MOVED = (
|
|
40
|
+
"binary_name",
|
|
41
|
+
"download_binary",
|
|
42
|
+
"ensure_binary",
|
|
43
|
+
"extract_archive",
|
|
44
|
+
"get_cache_dir",
|
|
45
|
+
"get_platform_slug",
|
|
46
|
+
"install_binary",
|
|
47
|
+
"parse_version",
|
|
48
|
+
"probe_binary",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if TYPE_CHECKING:
|
|
52
|
+
from .engine import (
|
|
53
|
+
binary_name,
|
|
54
|
+
download_binary,
|
|
55
|
+
ensure_binary,
|
|
56
|
+
extract_archive,
|
|
57
|
+
get_cache_dir,
|
|
58
|
+
get_platform_slug,
|
|
59
|
+
install_binary,
|
|
60
|
+
parse_version,
|
|
61
|
+
probe_binary,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def __getattr__(name: str) -> Any:
|
|
66
|
+
# Resolved lazily: `pypilot.engine` imports the pin from this module, so an
|
|
67
|
+
# eager import here would be circular.
|
|
68
|
+
if name in _MOVED:
|
|
69
|
+
from . import engine
|
|
70
|
+
|
|
71
|
+
return getattr(engine, name)
|
|
72
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"BUNDLED_VERSION",
|
|
77
|
+
"DEFAULT_TAG",
|
|
78
|
+
"ENGINE_SHA256",
|
|
79
|
+
"GITHUB_REPO",
|
|
80
|
+
"MIN_VERSION",
|
|
81
|
+
"binary_name",
|
|
82
|
+
"download_binary",
|
|
83
|
+
"ensure_binary",
|
|
84
|
+
"extract_archive",
|
|
85
|
+
"get_cache_dir",
|
|
86
|
+
"get_platform_slug",
|
|
87
|
+
"install_binary",
|
|
88
|
+
"parse_version",
|
|
89
|
+
"probe_binary",
|
|
90
|
+
]
|
pypilot/cli.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pypilot.cli: the `pypilot` command.
|
|
3
|
+
|
|
4
|
+
Every argument is passed to the native engine unchanged (including
|
|
5
|
+
`pypilot completions <shell>`), except a lone `--version` / `-V`, which the
|
|
6
|
+
wrapper answers itself so it can also report its own version and any skew
|
|
7
|
+
between the wrapper and the engine it found.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from typing import List, Optional, Sequence
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .binary import BUNDLED_VERSION, DEFAULT_TAG
|
|
18
|
+
from .engine import (
|
|
19
|
+
SOURCE_CARGO,
|
|
20
|
+
SOURCE_OVERRIDE,
|
|
21
|
+
SOURCE_PATH,
|
|
22
|
+
EngineInfo,
|
|
23
|
+
Version,
|
|
24
|
+
ensure_binary,
|
|
25
|
+
locate_engine,
|
|
26
|
+
parse_version,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_VERSION_FLAGS = (["--version"], ["-V"])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _fmt(version: Version) -> str:
|
|
33
|
+
return ".".join(map(str, version))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def skew_warnings(info: EngineInfo, version: Optional[Version]) -> List[str]:
|
|
37
|
+
"""Why the engine in use may not be the one this package was released with."""
|
|
38
|
+
if info.source == SOURCE_OVERRIDE:
|
|
39
|
+
return []
|
|
40
|
+
if version is None:
|
|
41
|
+
return [f"could not read a version from the engine at {info.path}"]
|
|
42
|
+
where = {SOURCE_CARGO: "~/.cargo/bin", SOURCE_PATH: "PATH"}.get(info.source)
|
|
43
|
+
warnings: List[str] = []
|
|
44
|
+
if version < BUNDLED_VERSION:
|
|
45
|
+
warnings.append(
|
|
46
|
+
f"engine {_fmt(version)} is older than {DEFAULT_TAG}, the release "
|
|
47
|
+
f"pypilot-cli {__version__} pins; newer commands may be missing"
|
|
48
|
+
)
|
|
49
|
+
if where is not None and version != BUNDLED_VERSION:
|
|
50
|
+
warnings.append(
|
|
51
|
+
f"using the engine from {where} ({info.path}) instead of the pinned "
|
|
52
|
+
f"{DEFAULT_TAG}; update or remove it to use the pinned engine"
|
|
53
|
+
)
|
|
54
|
+
return warnings
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def print_version() -> int:
|
|
58
|
+
"""`pypilot --version`.
|
|
59
|
+
|
|
60
|
+
stdout carries only the engine's own `pypilot X.Y.Z` line, because editor
|
|
61
|
+
extensions run `pypilot --version` and parse exactly that. The wrapper's
|
|
62
|
+
version and any warnings go to stderr.
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
info = locate_engine()
|
|
66
|
+
except Exception as e:
|
|
67
|
+
print(f"pypilot-cli {__version__}", file=sys.stderr)
|
|
68
|
+
print(f"Error: failed to locate or download PyPilot native engine: {e}", file=sys.stderr)
|
|
69
|
+
return 1
|
|
70
|
+
try:
|
|
71
|
+
res = subprocess.run(
|
|
72
|
+
[str(info.path), "--version"],
|
|
73
|
+
capture_output=True,
|
|
74
|
+
encoding="utf-8",
|
|
75
|
+
errors="replace",
|
|
76
|
+
timeout=30,
|
|
77
|
+
check=False,
|
|
78
|
+
)
|
|
79
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
80
|
+
print(f"pypilot-cli {__version__}", file=sys.stderr)
|
|
81
|
+
print(f"Error: could not run the engine at {info.path}: {e}", file=sys.stderr)
|
|
82
|
+
return 1
|
|
83
|
+
|
|
84
|
+
line = res.stdout.strip()
|
|
85
|
+
if res.returncode != 0 or not line:
|
|
86
|
+
sys.stderr.write(res.stderr)
|
|
87
|
+
print(f"pypilot-cli {__version__}", file=sys.stderr)
|
|
88
|
+
print(f"Error: `{info.path} --version` failed", file=sys.stderr)
|
|
89
|
+
return res.returncode or 1
|
|
90
|
+
|
|
91
|
+
print(line, flush=True)
|
|
92
|
+
print(f"pypilot-cli {__version__} (engine: {info.source}, {info.path})", file=sys.stderr)
|
|
93
|
+
for warning in skew_warnings(info, parse_version(line)):
|
|
94
|
+
print(f"warning: {warning}", file=sys.stderr)
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def main(argv: Optional[Sequence[str]] = None) -> None:
|
|
99
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
100
|
+
if args in _VERSION_FLAGS:
|
|
101
|
+
sys.exit(print_version())
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
bin_path = ensure_binary()
|
|
105
|
+
except Exception as e:
|
|
106
|
+
print(f"Error: failed to locate or download PyPilot native engine: {e}", file=sys.stderr)
|
|
107
|
+
sys.exit(1)
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
proc = subprocess.run([str(bin_path), *args])
|
|
111
|
+
sys.exit(proc.returncode)
|
|
112
|
+
except KeyboardInterrupt:
|
|
113
|
+
sys.exit(130)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
main()
|