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 CHANGED
@@ -1,218 +1,113 @@
1
1
  """uv-pack: Bundle a locked uv environment into an offline-installable bundle.
2
2
 
3
3
  Pipeline:
4
- 1. Export locked requirements via uv
5
- 2. Download third-party wheels into ./wheels
6
- 3. Build local workspace packages into ./vendor
7
- 4. Finalize requirements.txt for offline installation
4
+ 1. Clean the output directory
5
+ 2. Export locked requirements via uv
6
+ 3. Download third-party wheels into ./wheels
7
+ 4. Build local workspace packages into ./vendor
8
8
  5. Download a python interpreter to ./python
9
9
 
10
10
  Result:
11
- pack/
12
- ├── requirements.txt
13
- ├── wheels/ # third-party wheels
14
- └── vendor/ # locally built wheels
15
- └── python/ # python interpreter
11
+ pack/
12
+ ├── requirements.txt
13
+ ├── wheels/ # third-party wheels
14
+ └── requirements.txt # index packages
15
+ ├── vendor/ # locally built wheels
16
+ │ └── requirements.txt # local packages
17
+ ├── python/ # python interpreter
18
+ ├── unpack.sh
19
+ ├── unpack.ps1
20
+ ├── unpack.bat
21
+ ├── .gitignore
22
+ └── README.md
16
23
  """
17
24
 
18
25
  import shutil
19
- import sys
26
+ from collections.abc import Iterable
27
+ from enum import Enum
20
28
  from pathlib import Path
21
- from typing import Literal
22
29
 
23
30
  import typer
24
- from packaging.utils import parse_wheel_filename
25
- from rich.console import Console
26
31
 
27
- from ._download import download_with_progress, find_latest_python_build, resolve_platform, session_with_retries
28
- from ._process import exit_on_error, run_cmd
29
- from ._unpack import copy_unpack_scripts
32
+ from uv_pack._build import build_requirements, build_src_wheel
33
+ from uv_pack._download import download_third_party_wheels
34
+ from uv_pack._export import export_local_requirements, export_requirements
35
+ from uv_pack._files import PackLayout
36
+ from uv_pack._logging import ConsoleError, Verbosity, console_print, set_verbosity
37
+ from uv_pack._process import run_step
38
+ from uv_pack._python import download_latest_python_build
39
+ from uv_pack._scripts import copy_unpack_scripts
30
40
 
31
41
  # -----------------------------------------------------------------------------
32
42
  # CLI setup
33
43
  # -----------------------------------------------------------------------------
34
44
 
35
- app: typer.Typer = typer.Typer()
36
- console = Console(force_terminal=True, legacy_windows=False)
45
+ app: typer.Typer = typer.Typer(add_completion=True)
37
46
 
38
47
 
39
48
  def main() -> None:
40
- """Main entry point. Defaults to `pack`."""
41
- app()
42
-
43
-
44
- def get_version() -> str:
45
- """Return the package version or "unknown" if no version can be found."""
46
- from importlib import metadata # noqa: PLC0415
47
-
49
+ """Main entry point for uv-pack CLI."""
48
50
  try:
49
- return metadata.version(__name__)
50
- except metadata.PackageNotFoundError: # pragma: no cover
51
- return "unknown"
51
+ app()
52
+ except ConsoleError:
53
+ raise # already formatted for user
54
+ except KeyboardInterrupt as err:
55
+ console_print("[yellow]Interrupted by user[/yellow]")
56
+ raise typer.Exit(130) from err
57
+ except Exception as err:
58
+ msg = (
59
+ "[bold red]✘ An unexpected internal error occurred, please try again or"
60
+ " open an issue https://github.com/davnn/uv-pack/issues.[/bold red]"
61
+ )
62
+ raise ConsoleError(msg) from err
52
63
 
53
64
 
54
65
  # -----------------------------------------------------------------------------
55
- # Core operations
66
+ # Step model
56
67
  # -----------------------------------------------------------------------------
57
68
 
58
69
 
59
- def export_requirements(
60
- *,
61
- output_directory: Path,
62
- include_dev: bool,
63
- other_args: str,
64
- ) -> Path:
65
- """Export a frozen requirements.txt using uv and prepend index URLs."""
66
- output_directory.mkdir(parents=True, exist_ok=True)
67
- requirements_file = output_directory / "requirements.txt"
68
-
69
- cmd: list[str] = [
70
- "uv",
71
- "export",
72
- "--quiet",
73
- "--no-hashes",
74
- "--no-emit-local",
75
- "--format=requirements.txt",
76
- f"--output-file={requirements_file}",
77
- ]
78
-
79
- if not include_dev:
80
- cmd.append("--no-dev")
81
- for arg in other_args.split():
82
- cmd.append(arg)
83
-
84
- result = run_cmd(cmd, cmd_name="uv export")
85
- exit_on_error(result, console)
86
-
87
- return requirements_file
88
-
89
-
90
- def download_third_party_wheels(
91
- *,
92
- requirements_file: Path,
93
- wheels_directory: Path,
94
- other_args: str,
95
- ) -> None:
96
- """Download third-party binary wheels using pip via uv.
97
-
98
- Wheels are stored in ./wheels.
99
- """
100
- wheels_directory.mkdir(parents=True, exist_ok=True)
101
-
102
- cmd = [
103
- "uv", "run", "--with", "pip",
104
- "python", "-m", "pip", "download",
105
- "--prefer-binary",
106
- "--no-deps",
107
- "--disable-pip-version-check",
108
- "-r", str(requirements_file),
109
- "-d", str(wheels_directory),
110
- ]
111
-
112
- for arg in other_args.split():
113
- cmd.append(arg)
114
-
115
- result = run_cmd(cmd, cmd_name="pip download")
116
- exit_on_error(result, console)
70
+ class Step(str, Enum):
71
+ """Steps to be performed in the pack pipeline."""
117
72
 
73
+ clean = "clean"
74
+ export = "export"
75
+ download = "download"
76
+ build = "build"
77
+ python = "python"
118
78
 
119
- def build_vendor_wheels(*, vendor_directory: Path, other_args: str) -> None:
120
- """Build wheels for all local workspace packages.
121
79
 
122
- Wheels are stored in ./vendor.
123
- """
124
- vendor_directory.mkdir(parents=True, exist_ok=True)
80
+ PIPELINE_ORDER: tuple[Step, ...] = (
81
+ Step.clean,
82
+ Step.export,
83
+ Step.download,
84
+ Step.build,
85
+ Step.python,
86
+ )
125
87
 
126
- cmd = [
127
- "uv",
128
- "build",
129
- "--all-packages",
130
- "--wheel",
131
- "--out-dir",
132
- str(vendor_directory),
133
- ]
134
88
 
135
- for arg in other_args.split():
136
- cmd.append(arg)
137
-
138
- result = run_cmd(cmd, cmd_name="uv build")
139
- exit_on_error(result, console)
140
-
141
-
142
- def build_src_wheel(*, sdist_file: Path, other_args: str) -> None:
143
- """Build wheel for all identified source distribution.
89
+ # -----------------------------------------------------------------------------
90
+ # Helper operations
91
+ # -----------------------------------------------------------------------------
144
92
 
145
- Wheels is stored in ./wheels.
146
- """
147
- wheels_dir = sdist_file.parent
148
- cmd = [
149
- "uv",
150
- "build",
151
- str(sdist_file),
152
- "--wheel",
153
- "--out-dir",
154
- str(wheels_dir),
155
- ]
156
93
 
157
- for arg in other_args.split():
158
- cmd.append(arg)
94
+ def _additional_cli_args(cmd_name: str) -> str:
95
+ return f"Additional command line arguments to be provided to '{cmd_name}'"
159
96
 
160
- result = run_cmd(cmd, cmd_name="uv build")
161
- exit_on_error(result, console)
162
97
 
98
+ def _normalize_steps(
99
+ steps: Iterable[Step] | None,
100
+ skip: Iterable[Step] | None,
101
+ ) -> list[Step]:
102
+ selected = set(PIPELINE_ORDER) if steps is None else set(steps)
103
+ selected = selected.difference([] if skip is None else set(skip))
104
+ return [step for step in PIPELINE_ORDER if step in selected]
163
105
 
164
- def finalize_requirements(
165
- *,
166
- requirements_file: Path,
167
- vendor_directory: Path,
168
- ) -> None:
169
- """Finalize requirements.txt to pin vendor wheels."""
170
- vendor_pins: list[str] = []
171
-
172
- for wheel in vendor_directory.glob("*.whl"):
173
- name, version, *_ = parse_wheel_filename(wheel.name)
174
- vendor_pins.append(f"{name}=={version}")
175
-
176
- original = requirements_file.read_text(encoding="utf-8")
177
- footer = "\n".join(
178
- [
179
- "# This part was autogenerated by uv-pack via the following command:",
180
- f"# uv-pack {' '.join(sys.argv[1:])}",
181
- *sorted(set(vendor_pins)),
182
- ],
183
- )
184
106
 
185
- requirements_file.write_text(
186
- original + footer,
187
- encoding="utf-8",
188
- )
189
-
190
-
191
- def download_latest_python_build(
192
- *,
193
- python_version: str,
194
- target_arch: str,
195
- dest_dir: Path,
196
- target_format: Literal["install_only", "install_only_stripped"] = "install_only_stripped",
197
- ) -> Path:
198
- """Resolve and download the latest python-build-standalone artifact.
199
-
200
- Returns the downloaded file path.
201
- """
202
- session = session_with_retries()
203
- url = find_latest_python_build(
204
- python_version=python_version,
205
- target_arch=target_arch,
206
- target_format=target_format,
207
- session=session,
208
- )
209
- console.print(f"[dim]Resolved asset:[/dim] {url}")
210
- return download_with_progress(
211
- url=url,
212
- dest_dir=dest_dir,
213
- console=console,
214
- session=session,
215
- )
107
+ def _raise_requirement_txt_missing(path: Path) -> None:
108
+ if not path.exists():
109
+ msg = f"[bold red]✘ No requirements file found:[/bold red] '{path}', did you skip the 'export' step?"
110
+ raise ConsoleError(msg)
216
111
 
217
112
 
218
113
  # -----------------------------------------------------------------------------
@@ -223,80 +118,141 @@ def download_latest_python_build(
223
118
  @app.command()
224
119
  def pack(
225
120
  *,
226
- output_directory: Path = typer.Argument(Path("./pack"), help="Directory to store packed environment"),
227
- uv_export: str = typer.Option("", help="Extra arguments passed to `uv export`"),
228
- pip_download: str = typer.Option("", help="Extra arguments passed to `pip download`"),
229
- uv_build_sdist: str = typer.Option("", help="Extra arguments passed to `uv build` for downloaded sdists"),
230
- uv_build_pkg: str = typer.Option("", help="Extra arguments passed to `uv build` for local packages"),
231
- clean: bool = typer.Option(True, help="Remove the build directory"),
232
- system: bool = typer.Option(False, help="Use system interpreter on target machine"),
233
- include_dev: bool = typer.Option(False, help="Include development dependencies"),
121
+ steps: list[Step] | None = typer.Argument(
122
+ PIPELINE_ORDER,
123
+ help="Pipeline steps to run (multiple can be whitespace-separated)",
124
+ ),
125
+ skip: list[Step] | None = typer.Option(
126
+ None,
127
+ "--skip",
128
+ "-s",
129
+ help="Pipeline steps to skip (can be supplied multiple times)",
130
+ ),
131
+ output_directory: Path = typer.Option(
132
+ Path("./pack"),
133
+ "--output-directory",
134
+ "-o",
135
+ help="Path to output directory",
136
+ ),
137
+ uv_export: str = typer.Option(
138
+ default="",
139
+ help=_additional_cli_args("uv export"),
140
+ ),
141
+ pip_download: str = typer.Option(
142
+ default="",
143
+ help=_additional_cli_args("pip download"),
144
+ ),
145
+ uv_build: str = typer.Option(
146
+ default="",
147
+ help=_additional_cli_args("uv build"),
148
+ ),
149
+ verbose: bool = typer.Option(
150
+ False,
151
+ "--verbose",
152
+ "-v",
153
+ help="Enable verbose output",
154
+ ),
234
155
  ) -> None:
235
156
  """Pack a locked uv environment into an offline-installable bundle."""
236
- if clean:
237
- shutil.rmtree(output_directory, ignore_errors=True)
238
- console.print(f"[green]✔ Cleaned[/green] output directory '{output_directory}'")
157
+ set_verbosity(Verbosity.verbose if verbose else Verbosity.normal)
158
+ selected_steps = _normalize_steps(steps, skip)
159
+ console_print(
160
+ f"[dim]Running steps:[/dim] {[step.value for step in selected_steps]}",
161
+ )
239
162
 
240
- output_directory.mkdir(exist_ok=True, parents=True)
241
- (output_directory / ".gitignore").write_text("*", encoding="utf-8")
163
+ if Step.clean in selected_steps:
164
+ with run_step("clean"):
165
+ shutil.rmtree(output_directory, ignore_errors=True)
242
166
 
243
- requirements_file = export_requirements(
244
- output_directory=output_directory,
245
- include_dev=include_dev,
246
- other_args=uv_export,
247
- )
248
- console.print(f"[green]✔ Exported[/green] requirements file to '{requirements_file}'")
167
+ console_print(
168
+ f"[green]✔ Cleaned[/green] output directory '{output_directory}'",
169
+ level=Verbosity.verbose,
170
+ )
249
171
 
250
- wheels_dir = output_directory / "wheels"
251
- vendor_dir = output_directory / "vendor"
252
- python_dir = output_directory / "python"
172
+ # initialize pack directory structure and copy unpack scripts
173
+ pack = PackLayout.create(output_directory=output_directory)
174
+ copy_unpack_scripts(output_directory=output_directory)
253
175
 
254
- download_third_party_wheels(
255
- requirements_file=requirements_file,
256
- wheels_directory=wheels_dir,
257
- other_args=pip_download,
258
- )
259
- console.print(f"[green]✔ Downloaded[/green] third party wheels to '{wheels_dir}'")
260
-
261
- sdist_files = list(wheels_dir.glob("*.tar.gz"))
262
- if (n_src := len(sdist_files)) > 0:
263
- console.print(f"[dim]Identified {n_src} source distributions to build...[/dim]'")
264
- for file in sdist_files:
265
- build_src_wheel(
266
- sdist_file=file,
267
- other_args=uv_build_sdist,
176
+ if Step.export in selected_steps:
177
+ with run_step("export"):
178
+ export_requirements(
179
+ requirements_file=pack.requirements_export_txt,
180
+ other_args=uv_export,
268
181
  )
269
- file.unlink()
270
- console.print(f"[green]✔ Built[/green] wheel for source '{file}'")
271
-
272
- build_vendor_wheels(
273
- vendor_directory=vendor_dir,
274
- other_args=uv_build_pkg,
275
- )
276
- console.print(f"[green]✔ Built[/green] vendor wheels to '{vendor_dir}'")
182
+ export_local_requirements(
183
+ requirements_file=pack.requirements_local_txt,
184
+ other_args=uv_export,
185
+ )
186
+ console_print(
187
+ f"[green]✔ Exported[/green] requirements '{pack.requirements_export_txt}'",
188
+ level=Verbosity.verbose,
189
+ )
190
+ console_print(
191
+ f"[green]✔ Exported[/green] requirements '{pack.requirements_local_txt}'",
192
+ level=Verbosity.verbose,
193
+ )
277
194
 
278
- finalize_requirements(
279
- requirements_file=requirements_file,
280
- vendor_directory=vendor_dir,
281
- )
195
+ if Step.download in selected_steps:
196
+ _raise_requirement_txt_missing(pack.requirements_export_txt)
282
197
 
283
- platform = resolve_platform(console)
284
- console.print(f"[dim]Resolved target platform:[/dim] {platform}")
198
+ with run_step("download"):
199
+ download_third_party_wheels(
200
+ requirements_file=pack.requirements_export_txt,
201
+ wheels_directory=pack.wheels_dir,
202
+ other_args=pip_download,
203
+ )
285
204
 
286
- python_version = f"{sys.version_info.major}.{sys.version_info.minor}"
287
- console.print(f"[dim]Resolved python version:[/dim] {python_version}")
205
+ if Step.build in selected_steps:
206
+ _raise_requirement_txt_missing(pack.requirements_local_txt)
207
+ _raise_requirement_txt_missing(pack.requirements_export_txt)
208
+
209
+ # show the progress for each package in verbose mode, otherwise a single progress report is shown
210
+ with run_step("build", should_run=not verbose):
211
+ for line in pack.requirements_local_txt.read_text(
212
+ encoding="utf-8",
213
+ ).splitlines():
214
+ with run_step("build", should_run=verbose):
215
+ build_src_wheel(
216
+ source_path=Path(line),
217
+ out_path=pack.vendor_dir,
218
+ other_args=uv_build,
219
+ )
220
+ console_print(
221
+ f"[green]✔ Built[/green] wheel: '{line}'",
222
+ level=Verbosity.verbose,
223
+ )
224
+
225
+ for sdist in pack.wheels_dir.glob("*.tar.gz"):
226
+ with run_step("build", should_run=verbose):
227
+ build_src_wheel(
228
+ source_path=sdist,
229
+ out_path=pack.wheels_dir,
230
+ other_args=uv_build,
231
+ )
232
+ sdist.unlink(missing_ok=True)
233
+ console_print(
234
+ f"[green]✔ Built[/green] wheel: '{sdist}'",
235
+ level=Verbosity.verbose,
236
+ )
237
+
238
+ build_requirements(
239
+ requirements_txt=pack.requirements_txt,
240
+ requirements_export_txt=pack.requirements_export_txt,
241
+ vendor_directory=pack.vendor_dir,
242
+ )
243
+ console_print(
244
+ f"[green]✔ Built[/green] requirements: '{pack.requirements_txt}'",
245
+ level=Verbosity.verbose,
246
+ )
288
247
 
289
- python_path = None
290
- if system:
291
- console.print("[dim]Found '--system', skipping Python interpreter download[/dim]")
292
- else:
248
+ if Step.python in selected_steps:
249
+ pack.python_dir.mkdir(exist_ok=True)
293
250
  python_path = download_latest_python_build(
294
- python_version=python_version,
295
- target_arch=platform,
296
- dest_dir=python_dir,
251
+ dest_dir=pack.python_dir,
252
+ )
253
+ console_print(
254
+ f"[green]✔ Python[/green] archive: '{python_path}'",
255
+ level=Verbosity.verbose,
297
256
  )
298
- console.print(f"[green]✔ Downloaded[/green] interpreter to '{python_path}'")
299
257
 
300
- copy_unpack_scripts(output_directory=output_directory)
301
- console.print(f"[green]✔ Unpack[/green] scripts copied to '{output_directory}'")
302
- console.print(f"[green]✔ Packed[/green] environment '{output_directory}'")
258
+ console_print("[green]✔ Done[/green]")
uv_pack/_build.py ADDED
@@ -0,0 +1,46 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ from packaging.utils import parse_wheel_filename
5
+
6
+ from uv_pack._process import exit_on_error, run_cmd
7
+
8
+
9
+ def build_src_wheel(*, source_path: Path, out_path: Path, other_args: str) -> None:
10
+ cmd = [
11
+ "uv",
12
+ "build",
13
+ str(source_path),
14
+ "--wheel",
15
+ "--out-dir",
16
+ str(out_path),
17
+ ]
18
+
19
+ cmd.extend(other_args.split())
20
+ exit_on_error(run_cmd(cmd, "uv build"))
21
+
22
+
23
+ def build_requirements(
24
+ *,
25
+ requirements_txt: Path,
26
+ requirements_export_txt: Path,
27
+ vendor_directory: Path,
28
+ ) -> None:
29
+ vendor_pins: list[str] = []
30
+
31
+ for wheel in vendor_directory.glob("*.whl"):
32
+ name, version, *_ = parse_wheel_filename(wheel.name)
33
+ vendor_pins.append(f"{name}=={version}")
34
+
35
+ header = "\n".join(
36
+ [
37
+ "# This file was autogenerated by uv-pack via the following command:",
38
+ f"# uv-pack {' '.join(sys.argv[1:])}",
39
+ *sorted(set(vendor_pins)),
40
+ ],
41
+ )
42
+
43
+ requirements_txt.write_text(
44
+ header + "\n" + requirements_export_txt.read_text(encoding="utf-8"),
45
+ encoding="utf-8",
46
+ )