uv-pack 0.0.1__py3-none-any.whl → 0.1.1__py3-none-any.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.
- uv_pack/__init__.py +195 -239
- uv_pack/_build.py +46 -0
- uv_pack/_download.py +44 -150
- uv_pack/_export.py +48 -0
- uv_pack/_files.py +56 -0
- uv_pack/_logging.py +46 -0
- uv_pack/_process.py +33 -17
- uv_pack/_python.py +203 -0
- uv_pack/{_unpack.py → _scripts.py} +19 -2
- uv_pack-0.1.1.dist-info/METADATA +210 -0
- uv_pack-0.1.1.dist-info/RECORD +19 -0
- {uv_pack-0.0.1.dist-info → uv_pack-0.1.1.dist-info}/WHEEL +1 -1
- uv_pack-0.0.1.dist-info/METADATA +0 -140
- uv_pack-0.0.1.dist-info/RECORD +0 -14
- {uv_pack-0.0.1.dist-info → uv_pack-0.1.1.dist-info}/entry_points.txt +0 -0
uv_pack/_download.py
CHANGED
|
@@ -1,158 +1,52 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import platform
|
|
3
|
-
import re
|
|
4
1
|
from pathlib import Path
|
|
5
|
-
from typing import Literal
|
|
6
|
-
from urllib.parse import unquote
|
|
7
2
|
|
|
8
|
-
import
|
|
9
|
-
import typer
|
|
10
|
-
from requests.adapters import HTTPAdapter
|
|
11
|
-
from rich.console import Console
|
|
12
|
-
from rich.progress import (
|
|
13
|
-
BarColumn,
|
|
14
|
-
DownloadColumn,
|
|
15
|
-
Progress,
|
|
16
|
-
SpinnerColumn,
|
|
17
|
-
TextColumn,
|
|
18
|
-
TimeRemainingColumn,
|
|
19
|
-
TransferSpeedColumn,
|
|
20
|
-
)
|
|
21
|
-
from urllib3.util.retry import Retry
|
|
3
|
+
from packaging.utils import parse_wheel_filename
|
|
22
4
|
|
|
23
|
-
|
|
24
|
-
"https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest"
|
|
25
|
-
)
|
|
5
|
+
from uv_pack._process import exit_on_error, run_cmd
|
|
26
6
|
|
|
27
7
|
|
|
28
|
-
def
|
|
29
|
-
python_version: str,
|
|
30
|
-
target_arch: str,
|
|
8
|
+
def download_third_party_wheels(
|
|
31
9
|
*,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
dest_dir: Path,
|
|
10
|
+
requirements_file: Path,
|
|
11
|
+
wheels_directory: Path,
|
|
12
|
+
other_args: str,
|
|
13
|
+
) -> None:
|
|
14
|
+
wheels_directory.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
|
|
16
|
+
cmd = [
|
|
17
|
+
"uv",
|
|
18
|
+
"run",
|
|
19
|
+
"--with",
|
|
20
|
+
"pip",
|
|
21
|
+
"python",
|
|
22
|
+
"-m",
|
|
23
|
+
"pip",
|
|
24
|
+
"download",
|
|
25
|
+
"--prefer-binary",
|
|
26
|
+
"--no-deps",
|
|
27
|
+
"--disable-pip-version-check",
|
|
28
|
+
"-r",
|
|
29
|
+
str(requirements_file),
|
|
30
|
+
"-d",
|
|
31
|
+
str(wheels_directory),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
cmd.extend(other_args.split())
|
|
35
|
+
exit_on_error(run_cmd(cmd, "pip download"))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def determine_download_requirements(
|
|
62
39
|
*,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
with session.get(url, stream=True, timeout=30) as resp:
|
|
76
|
-
resp.raise_for_status()
|
|
77
|
-
total = int(resp.headers.get("Content-Length", 0))
|
|
78
|
-
# try to get the filename from headers, otherwise fall back to the file name in the URL
|
|
79
|
-
filename = resp.headers.get("Content-Disposition", unquote(url.rsplit("/", 1)[-1])).split("filename=")[1]
|
|
80
|
-
final_path = dest_dir / filename
|
|
81
|
-
temp_path = final_path.with_suffix(final_path.suffix + ".part")
|
|
82
|
-
|
|
83
|
-
progress = Progress(
|
|
84
|
-
SpinnerColumn(),
|
|
85
|
-
TextColumn("{task.description}"),
|
|
86
|
-
BarColumn(),
|
|
87
|
-
DownloadColumn(),
|
|
88
|
-
TransferSpeedColumn(),
|
|
89
|
-
TimeRemainingColumn(),
|
|
90
|
-
console=console,
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
with progress:
|
|
94
|
-
task = progress.add_task(
|
|
95
|
-
"Downloading...",
|
|
96
|
-
total=total if total > 0 else None,
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
with temp_path.open("wb") as f:
|
|
100
|
-
for chunk in resp.iter_content(chunk_size=1024 * 64):
|
|
101
|
-
if chunk:
|
|
102
|
-
f.write(chunk)
|
|
103
|
-
progress.update(task, advance=len(chunk))
|
|
104
|
-
|
|
105
|
-
temp_path.replace(final_path)
|
|
106
|
-
return final_path
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def session_with_retries() -> requests.Session:
|
|
110
|
-
session = requests.Session()
|
|
111
|
-
session.headers.update({
|
|
112
|
-
"User-Agent": "venv-pack-download",
|
|
113
|
-
"Accept": "application/vnd.github+json",
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
# authenticate to GitHub API to prevent rate-limiting
|
|
117
|
-
if token := os.getenv("GITHUB_TOKEN"):
|
|
118
|
-
session.headers["Authorization"] = f"Bearer {token}"
|
|
119
|
-
|
|
120
|
-
retries = Retry(
|
|
121
|
-
total=5,
|
|
122
|
-
connect=5,
|
|
123
|
-
read=5,
|
|
124
|
-
backoff_factor=0.5,
|
|
125
|
-
status_forcelist=(502, 503, 504),
|
|
126
|
-
allowed_methods=("GET",),
|
|
40
|
+
requirements_file: Path,
|
|
41
|
+
wheels_directory: Path,
|
|
42
|
+
) -> None:
|
|
43
|
+
available_wheels: list[str] = []
|
|
44
|
+
|
|
45
|
+
for wheel in wheels_directory.glob("*.whl"):
|
|
46
|
+
name, version, *_ = parse_wheel_filename(wheel.name)
|
|
47
|
+
available_wheels.append(f"{name}=={version}")
|
|
48
|
+
|
|
49
|
+
requirements_file.write_text(
|
|
50
|
+
"\n".join(sorted(set(available_wheels))),
|
|
51
|
+
encoding="utf-8",
|
|
127
52
|
)
|
|
128
|
-
|
|
129
|
-
adapter = HTTPAdapter(max_retries=retries)
|
|
130
|
-
session.mount("https://", adapter)
|
|
131
|
-
return session
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def resolve_platform(console: Console | None = None) -> str:
|
|
135
|
-
console = console or Console()
|
|
136
|
-
system = platform.system().lower()
|
|
137
|
-
machine = platform.machine().lower()
|
|
138
|
-
|
|
139
|
-
# Normalize architecture
|
|
140
|
-
arch_map = {
|
|
141
|
-
"x86_64": "x86_64",
|
|
142
|
-
"amd64": "x86_64",
|
|
143
|
-
"arm64": "aarch64",
|
|
144
|
-
"aarch64": "aarch64",
|
|
145
|
-
}
|
|
146
|
-
arch = arch_map.get(machine, machine)
|
|
147
|
-
|
|
148
|
-
if system == "windows":
|
|
149
|
-
return f"{arch}-pc-windows-msvc"
|
|
150
|
-
|
|
151
|
-
if system == "linux":
|
|
152
|
-
return f"{arch}-unknown-linux-gnu"
|
|
153
|
-
|
|
154
|
-
if system == "darwin":
|
|
155
|
-
return f"{arch}-apple-darwin"
|
|
156
|
-
|
|
157
|
-
console.print(f"[bold red]Found unsupported platform:[/bold red] {system}")
|
|
158
|
-
raise typer.Exit(code=1)
|
uv_pack/_export.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from uv_pack._process import exit_on_error, run_cmd
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def export_requirements(
|
|
7
|
+
*,
|
|
8
|
+
requirements_file: Path,
|
|
9
|
+
other_args: str,
|
|
10
|
+
) -> None:
|
|
11
|
+
cmd = [
|
|
12
|
+
"uv",
|
|
13
|
+
"export",
|
|
14
|
+
"--quiet",
|
|
15
|
+
"--no-dev",
|
|
16
|
+
"--no-header",
|
|
17
|
+
"--no-hashes",
|
|
18
|
+
"--no-emit-local",
|
|
19
|
+
"--format=requirements.txt",
|
|
20
|
+
f"--output-file={requirements_file}",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
cmd.extend(other_args.split())
|
|
24
|
+
exit_on_error(run_cmd(cmd, "uv export"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def export_local_requirements(
|
|
28
|
+
*,
|
|
29
|
+
requirements_file: Path,
|
|
30
|
+
other_args: str,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Export only local packages to a plain requirements.txt file (each line is a local requirement path)."""
|
|
33
|
+
cmd = [
|
|
34
|
+
"uv",
|
|
35
|
+
"export",
|
|
36
|
+
"--quiet",
|
|
37
|
+
"--no-dev",
|
|
38
|
+
"--no-header",
|
|
39
|
+
"--no-hashes",
|
|
40
|
+
"--no-annotate",
|
|
41
|
+
"--no-editable",
|
|
42
|
+
"--only-emit-local",
|
|
43
|
+
"--format=requirements.txt",
|
|
44
|
+
f"--output-file={requirements_file}",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
cmd.extend(other_args.split())
|
|
48
|
+
exit_on_error(run_cmd(cmd, "uv export"))
|
uv_pack/_files.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"PackLayout",
|
|
6
|
+
]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class PackLayout:
|
|
11
|
+
"""Layout of the output directory for a uv-pack."""
|
|
12
|
+
|
|
13
|
+
output_directory: Path
|
|
14
|
+
gitignore_file: Path
|
|
15
|
+
requirements_txt: Path
|
|
16
|
+
requirements_export_txt: Path
|
|
17
|
+
requirements_local_txt: Path
|
|
18
|
+
_wheels_dir: Path
|
|
19
|
+
_vendor_dir: Path
|
|
20
|
+
_python_dir: Path
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def wheels_dir(self) -> Path:
|
|
24
|
+
self._wheels_dir.mkdir(exist_ok=True)
|
|
25
|
+
return self._wheels_dir
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def vendor_dir(self) -> Path:
|
|
29
|
+
self._vendor_dir.mkdir(exist_ok=True)
|
|
30
|
+
return self._vendor_dir
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def python_dir(self) -> Path:
|
|
34
|
+
self._python_dir.mkdir(exist_ok=True)
|
|
35
|
+
return self._python_dir
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def create(cls, output_directory: Path) -> "PackLayout":
|
|
39
|
+
output_directory.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
wheels_dir = output_directory / "wheels"
|
|
41
|
+
vendor_dir = output_directory / "vendor"
|
|
42
|
+
python_dir = output_directory / "python"
|
|
43
|
+
|
|
44
|
+
gitignore_file = output_directory / ".gitignore"
|
|
45
|
+
gitignore_file.write_text("*", encoding="utf-8")
|
|
46
|
+
|
|
47
|
+
return cls(
|
|
48
|
+
output_directory,
|
|
49
|
+
gitignore_file=gitignore_file,
|
|
50
|
+
requirements_txt=output_directory / "requirements.txt",
|
|
51
|
+
requirements_export_txt=wheels_dir / "requirements.txt",
|
|
52
|
+
requirements_local_txt=vendor_dir / "requirements.txt",
|
|
53
|
+
_wheels_dir=wheels_dir,
|
|
54
|
+
_vendor_dir=vendor_dir,
|
|
55
|
+
_python_dir=python_dir,
|
|
56
|
+
)
|
uv_pack/_logging.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from typer import Exit
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ConsoleError",
|
|
9
|
+
"Verbosity",
|
|
10
|
+
"console_print",
|
|
11
|
+
"set_verbosity",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
console = Console(legacy_windows=False)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Verbosity(Enum):
|
|
18
|
+
quiet = 0
|
|
19
|
+
normal = 1
|
|
20
|
+
verbose = 2
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_internal_verbosity = Verbosity.normal
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def set_verbosity(mode: Verbosity) -> None:
|
|
27
|
+
"""Globally set the verbosity level for console printing."""
|
|
28
|
+
global _internal_verbosity # noqa: PLW0603
|
|
29
|
+
_internal_verbosity = mode
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def console_print(
|
|
33
|
+
*objects: Any,
|
|
34
|
+
level: Verbosity = Verbosity.normal,
|
|
35
|
+
**kwargs: Any,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Print to console if the current verbosity level is sufficient."""
|
|
38
|
+
if _internal_verbosity.value >= level.value:
|
|
39
|
+
console.print(*objects, **kwargs)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ConsoleError(Exit):
|
|
43
|
+
def __init__(self, *objects: Any, **kwargs: Any) -> None:
|
|
44
|
+
"""Raise a user-friendly console error and print the message to the console."""
|
|
45
|
+
console.print(*objects, **kwargs)
|
|
46
|
+
super().__init__(code=1)
|
uv_pack/_process.py
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import subprocess
|
|
2
2
|
import sys
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from contextlib import contextmanager, nullcontext
|
|
3
5
|
from dataclasses import dataclass
|
|
4
6
|
|
|
5
7
|
import typer
|
|
6
|
-
from rich.console import Console
|
|
7
8
|
from rich.panel import Panel
|
|
8
9
|
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
9
10
|
|
|
11
|
+
from uv_pack._logging import console_print
|
|
12
|
+
|
|
10
13
|
|
|
11
14
|
@dataclass(frozen=True)
|
|
12
15
|
class CommandOutput:
|
|
13
|
-
|
|
14
16
|
"""Captured subprocess execution result."""
|
|
15
17
|
|
|
16
18
|
name: str
|
|
@@ -18,19 +20,33 @@ class CommandOutput:
|
|
|
18
20
|
stderr: str
|
|
19
21
|
returncode: int
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
cmd,
|
|
30
|
-
text=True,
|
|
31
|
-
capture_output=True,
|
|
32
|
-
check=False,
|
|
23
|
+
|
|
24
|
+
@contextmanager
|
|
25
|
+
def run_step(cmd_name: str, *, should_run: bool = True) -> Iterator[Progress | None]:
|
|
26
|
+
"""Context manager to show a progress display during step execution."""
|
|
27
|
+
ctx = (
|
|
28
|
+
Progress(
|
|
29
|
+
SpinnerColumn(),
|
|
30
|
+
TextColumn("[progress.description]{task.description}"),
|
|
33
31
|
)
|
|
32
|
+
if should_run
|
|
33
|
+
else nullcontext()
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
with ctx as progress:
|
|
37
|
+
if progress is not None:
|
|
38
|
+
progress.add_task(f"Running '{cmd_name}'...", total=None)
|
|
39
|
+
yield progress
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def run_cmd(cmd: list[str], cmd_name: str) -> CommandOutput:
|
|
43
|
+
"""Execute a subprocess command and capture output."""
|
|
44
|
+
proc = subprocess.run(
|
|
45
|
+
cmd,
|
|
46
|
+
text=True,
|
|
47
|
+
capture_output=True,
|
|
48
|
+
check=False,
|
|
49
|
+
)
|
|
34
50
|
return CommandOutput(
|
|
35
51
|
name=cmd_name,
|
|
36
52
|
stdout=proc.stdout,
|
|
@@ -39,17 +55,17 @@ def run_cmd(cmd: list[str], *, cmd_name: str) -> CommandOutput:
|
|
|
39
55
|
)
|
|
40
56
|
|
|
41
57
|
|
|
42
|
-
def exit_on_error(result: CommandOutput
|
|
58
|
+
def exit_on_error(result: CommandOutput) -> None:
|
|
43
59
|
"""Exit the CLI if a subprocess failed."""
|
|
44
60
|
if result.returncode == 0:
|
|
45
61
|
return
|
|
46
62
|
|
|
47
63
|
if result.stderr:
|
|
48
64
|
if sys.stderr.isatty():
|
|
49
|
-
|
|
65
|
+
console_print(
|
|
50
66
|
Panel.fit(
|
|
51
67
|
result.stderr.rstrip(),
|
|
52
|
-
title=f"[bold red]{result.name} failed[/bold red]",
|
|
68
|
+
title=f"[bold red]✘ '{result.name}' failed[/bold red]",
|
|
53
69
|
border_style="red",
|
|
54
70
|
),
|
|
55
71
|
)
|
uv_pack/_python.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import platform
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
from urllib.parse import unquote
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
from requests.adapters import HTTPAdapter
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.progress import (
|
|
13
|
+
BarColumn,
|
|
14
|
+
DownloadColumn,
|
|
15
|
+
Progress,
|
|
16
|
+
SpinnerColumn,
|
|
17
|
+
TextColumn,
|
|
18
|
+
TimeRemainingColumn,
|
|
19
|
+
TransferSpeedColumn,
|
|
20
|
+
)
|
|
21
|
+
from urllib3.util.retry import Retry
|
|
22
|
+
|
|
23
|
+
from uv_pack._logging import ConsoleError, Verbosity, console_print
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"download_latest_python_build",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
LATEST_RELEASE_API = (
|
|
30
|
+
"https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def download_latest_python_build(
|
|
35
|
+
*,
|
|
36
|
+
dest_dir: Path,
|
|
37
|
+
target_format: Literal[
|
|
38
|
+
"install_only",
|
|
39
|
+
"install_only_stripped",
|
|
40
|
+
] = "install_only_stripped",
|
|
41
|
+
) -> Path:
|
|
42
|
+
"""Resolve and download the latest python-build-standalone artifact.
|
|
43
|
+
|
|
44
|
+
Returns the downloaded file path.
|
|
45
|
+
"""
|
|
46
|
+
session = session_with_retries()
|
|
47
|
+
url = find_latest_python_build(
|
|
48
|
+
python_version=f"{sys.version_info.major}.{sys.version_info.minor}",
|
|
49
|
+
target_arch=resolve_platform(),
|
|
50
|
+
target_format=target_format,
|
|
51
|
+
session=session,
|
|
52
|
+
)
|
|
53
|
+
console_print(f"[dim]Resolved asset:[/dim] {url}", level=Verbosity.verbose)
|
|
54
|
+
return download_with_progress(
|
|
55
|
+
url=url,
|
|
56
|
+
dest_dir=dest_dir,
|
|
57
|
+
session=session,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def find_latest_python_build(
|
|
62
|
+
python_version: str,
|
|
63
|
+
target_arch: str,
|
|
64
|
+
*,
|
|
65
|
+
target_format: Literal[
|
|
66
|
+
"install_only",
|
|
67
|
+
"install_only_stripped",
|
|
68
|
+
] = "install_only_stripped",
|
|
69
|
+
session: requests.Session | None = None,
|
|
70
|
+
) -> str:
|
|
71
|
+
session = session or requests.Session()
|
|
72
|
+
release_api = os.getenv("UV_PYTHON_INSTALL_MIRROR", LATEST_RELEASE_API)
|
|
73
|
+
|
|
74
|
+
resp = session.get(release_api, timeout=10)
|
|
75
|
+
resp.raise_for_status()
|
|
76
|
+
release = resp.json()
|
|
77
|
+
|
|
78
|
+
py_pattern = re.compile(
|
|
79
|
+
rf"^cpython-{re.escape(python_version)}(\.\d+)?",
|
|
80
|
+
re.IGNORECASE,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
for asset in release.get("assets", []):
|
|
84
|
+
name = asset["name"]
|
|
85
|
+
if (
|
|
86
|
+
py_pattern.search(name)
|
|
87
|
+
and target_arch in name
|
|
88
|
+
and name.endswith(f"{target_format}.tar.gz")
|
|
89
|
+
):
|
|
90
|
+
return asset["browser_download_url"]
|
|
91
|
+
|
|
92
|
+
msg = f"No asset found for Python {python_version} on {target_arch}"
|
|
93
|
+
raise RuntimeError(msg)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def download_with_progress(
|
|
97
|
+
url: str,
|
|
98
|
+
dest_dir: Path,
|
|
99
|
+
*,
|
|
100
|
+
filename: str | None = None,
|
|
101
|
+
console: Console | None = None,
|
|
102
|
+
session: requests.Session | None = None,
|
|
103
|
+
) -> Path:
|
|
104
|
+
"""Download a file with retries and a Rich progress bar.
|
|
105
|
+
|
|
106
|
+
Returns the final file path.
|
|
107
|
+
"""
|
|
108
|
+
session = session or requests.Session()
|
|
109
|
+
console = console or Console()
|
|
110
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
|
|
112
|
+
with session.get(url, stream=True, timeout=30) as resp:
|
|
113
|
+
resp.raise_for_status()
|
|
114
|
+
total = int(resp.headers.get("Content-Length", 0))
|
|
115
|
+
# try to get the filename from headers, otherwise fall back to the file name in the URL
|
|
116
|
+
filename = resp.headers.get(
|
|
117
|
+
"Content-Disposition",
|
|
118
|
+
unquote(url.rsplit("/", 1)[-1]),
|
|
119
|
+
).split("filename=")[1]
|
|
120
|
+
final_path = dest_dir / filename
|
|
121
|
+
temp_path = final_path.with_suffix(final_path.suffix + ".part")
|
|
122
|
+
|
|
123
|
+
# shortcut if the file already exists
|
|
124
|
+
if final_path.is_file():
|
|
125
|
+
console_print(f"[dim]Using cached file:[/dim] {final_path}")
|
|
126
|
+
return final_path
|
|
127
|
+
|
|
128
|
+
progress = Progress(
|
|
129
|
+
SpinnerColumn(),
|
|
130
|
+
TextColumn("{task.description}"),
|
|
131
|
+
BarColumn(),
|
|
132
|
+
DownloadColumn(),
|
|
133
|
+
TransferSpeedColumn(),
|
|
134
|
+
TimeRemainingColumn(),
|
|
135
|
+
console=console,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
with progress:
|
|
139
|
+
task = progress.add_task(
|
|
140
|
+
"Running 'python'...",
|
|
141
|
+
total=total if total > 0 else None,
|
|
142
|
+
)
|
|
143
|
+
with temp_path.open("wb") as f:
|
|
144
|
+
for chunk in resp.iter_content(chunk_size=1024 * 64):
|
|
145
|
+
if chunk:
|
|
146
|
+
f.write(chunk)
|
|
147
|
+
progress.update(task, advance=len(chunk))
|
|
148
|
+
|
|
149
|
+
temp_path.replace(final_path)
|
|
150
|
+
return final_path
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def session_with_retries() -> requests.Session:
|
|
154
|
+
session = requests.Session()
|
|
155
|
+
session.headers.update(
|
|
156
|
+
{
|
|
157
|
+
"User-Agent": "venv-pack-download",
|
|
158
|
+
"Accept": "application/vnd.github+json",
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# authenticate to GitHub API to prevent rate-limiting
|
|
163
|
+
if token := os.getenv("GITHUB_TOKEN"):
|
|
164
|
+
session.headers["Authorization"] = f"Bearer {token}"
|
|
165
|
+
|
|
166
|
+
retries = Retry(
|
|
167
|
+
total=5,
|
|
168
|
+
connect=5,
|
|
169
|
+
read=5,
|
|
170
|
+
backoff_factor=0.5,
|
|
171
|
+
status_forcelist=(502, 503, 504),
|
|
172
|
+
allowed_methods=("GET",),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
adapter = HTTPAdapter(max_retries=retries)
|
|
176
|
+
session.mount("https://", adapter)
|
|
177
|
+
return session
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def resolve_platform() -> str:
|
|
181
|
+
system = platform.system().lower()
|
|
182
|
+
machine = platform.machine().lower()
|
|
183
|
+
|
|
184
|
+
# Normalize architecture
|
|
185
|
+
arch_map = {
|
|
186
|
+
"x86_64": "x86_64",
|
|
187
|
+
"amd64": "x86_64",
|
|
188
|
+
"arm64": "aarch64",
|
|
189
|
+
"aarch64": "aarch64",
|
|
190
|
+
}
|
|
191
|
+
arch = arch_map.get(machine, machine)
|
|
192
|
+
|
|
193
|
+
if system == "windows":
|
|
194
|
+
return f"{arch}-pc-windows-msvc"
|
|
195
|
+
|
|
196
|
+
if system == "linux":
|
|
197
|
+
return f"{arch}-unknown-linux-gnu"
|
|
198
|
+
|
|
199
|
+
if system == "darwin":
|
|
200
|
+
return f"{arch}-apple-darwin"
|
|
201
|
+
|
|
202
|
+
msg = f"[bold red]✘ Found unsupported platform:[/bold red] {system}"
|
|
203
|
+
raise ConsoleError(msg)
|
|
@@ -5,6 +5,10 @@ import stat
|
|
|
5
5
|
from importlib.resources import files
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
|
+
__all__ = [
|
|
9
|
+
"copy_unpack_scripts",
|
|
10
|
+
]
|
|
11
|
+
|
|
8
12
|
|
|
9
13
|
def copy_unpack_scripts(
|
|
10
14
|
*,
|
|
@@ -12,16 +16,29 @@ def copy_unpack_scripts(
|
|
|
12
16
|
) -> None:
|
|
13
17
|
"""Write unpack scripts into the pack directory."""
|
|
14
18
|
scripts_dir = files("uv_pack") / "scripts"
|
|
15
|
-
copy_file = lambda src:
|
|
19
|
+
copy_file = lambda src, force_lf=False: _copy_file(src, output_directory, force_lf=force_lf) # type: ignore
|
|
16
20
|
|
|
17
21
|
output_directory.mkdir(parents=True, exist_ok=True)
|
|
18
|
-
copy_file(scripts_dir / "unpack.sh")
|
|
22
|
+
copy_file(scripts_dir / "unpack.sh", force_lf=True)
|
|
19
23
|
copy_file(scripts_dir / "unpack.ps1")
|
|
20
24
|
copy_file(scripts_dir / "unpack.cmd")
|
|
21
25
|
copy_file(scripts_dir / "README.md")
|
|
22
26
|
_make_executable(output_directory / "unpack.sh")
|
|
23
27
|
|
|
24
28
|
|
|
29
|
+
def _copy_file(src: Path, dst_dir: Path, *, force_lf: bool = False) -> None:
|
|
30
|
+
"""Copy a file from src to dst_dir, optionally normalizing line endings to LF."""
|
|
31
|
+
dst = dst_dir / src.name
|
|
32
|
+
|
|
33
|
+
if force_lf:
|
|
34
|
+
# Normalize line endings to LF regardless of how Git stored it
|
|
35
|
+
data = src.read_bytes()
|
|
36
|
+
data = data.replace(b"\r\n", b"\n")
|
|
37
|
+
dst.write_bytes(data)
|
|
38
|
+
else:
|
|
39
|
+
shutil.copyfile(str(src), str(dst))
|
|
40
|
+
|
|
41
|
+
|
|
25
42
|
def _make_executable(path: Path) -> None:
|
|
26
43
|
"""Best-effort make a script executable (POSIX)."""
|
|
27
44
|
try:
|