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/_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 requests
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
- LATEST_RELEASE_API = (
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 find_latest_python_build(
29
- python_version: str,
30
- target_arch: str,
8
+ def download_third_party_wheels(
31
9
  *,
32
- target_format: Literal["install_only", "install_only_stripped"] = "install_only_stripped",
33
- session: requests.Session | None = None,
34
- ) -> str:
35
- session = session or requests.Session()
36
- release_api = os.getenv("UV_PYTHON_INSTALL_MIRROR", LATEST_RELEASE_API)
37
-
38
- resp = session.get(release_api, timeout=10)
39
- resp.raise_for_status()
40
- release = resp.json()
41
-
42
- py_pattern = re.compile(
43
- rf"^cpython-{re.escape(python_version)}(\.\d+)?",
44
- re.IGNORECASE,
45
- )
46
-
47
- for asset in release.get("assets", []):
48
- name = asset["name"]
49
- if (
50
- py_pattern.search(name)
51
- and target_arch in name
52
- and name.endswith(f"{target_format}.tar.gz")
53
- ):
54
- return asset["browser_download_url"]
55
-
56
- msg = f"No asset found for Python {python_version} on {target_arch}"
57
- raise RuntimeError(msg)
58
-
59
- def download_with_progress(
60
- url: str,
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
- filename: str | None = None,
64
- console: Console | None = None,
65
- session: requests.Session | None = None,
66
- ) -> Path:
67
- """Download a file with retries and a Rich progress bar.
68
-
69
- Returns the final file path.
70
- """
71
- session = session or requests.Session()
72
- console = console or Console()
73
- dest_dir.mkdir(parents=True, exist_ok=True)
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
- def run_cmd(cmd: list[str], *, cmd_name: str) -> CommandOutput:
22
- """Execute a subprocess command and capture output."""
23
- with Progress(
24
- SpinnerColumn(),
25
- TextColumn("[progress.description]{task.description}"),
26
- ) as progress:
27
- progress.add_task(f"Running '{cmd_name}'...", total=None)
28
- proc = subprocess.run(
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, console: Console) -> None:
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
- console.print(
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: shutil.copyfile(str(src), str(output_directory / src.name)) # noqa: E731 # type: ignore
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: