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 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()