pep723-to-wheel 0.1.0__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.
@@ -0,0 +1,5 @@
1
+ """Library for converting PEP 723 scripts to wheels and back."""
2
+
3
+ from pep723_to_wheel.core import build_script_to_wheel, import_wheel_to_script
4
+
5
+ __all__ = ["build_script_to_wheel", "import_wheel_to_script"]
pep723_to_wheel/cli.py ADDED
@@ -0,0 +1,45 @@
1
+ """Typer-based CLI entry points."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from pep723_to_wheel.core import build_script_to_wheel, import_wheel_to_script
10
+
11
+ app = typer.Typer(add_completion=False, no_args_is_help=True)
12
+
13
+
14
+ @app.command("build")
15
+ def build_command(
16
+ script_path: Path = typer.Argument(..., help="Path to the PEP 723 script."),
17
+ output_dir: Path | None = typer.Option(
18
+ None, "--output-dir", "-o", help="Directory for the built wheel."
19
+ ),
20
+ version: str | None = typer.Option(
21
+ None,
22
+ "--version",
23
+ "-v",
24
+ help="Wheel version (defaults to calendar versioning).",
25
+ ),
26
+ ) -> None:
27
+ """Build a wheel from a PEP 723 script."""
28
+
29
+ result = build_script_to_wheel(script_path, output_dir, version)
30
+ typer.echo(str(result.wheel_path))
31
+
32
+
33
+ @app.command("import")
34
+ def import_command(
35
+ wheel_or_package: str = typer.Argument(
36
+ ..., help="Wheel path or package name to import."
37
+ ),
38
+ output_path: Path = typer.Option(
39
+ ..., "--output", "-o", help="Path to write the reconstructed script."
40
+ ),
41
+ ) -> None:
42
+ """Reconstruct a script from a wheel or package name."""
43
+
44
+ result = import_wheel_to_script(wheel_or_package, output_path)
45
+ typer.echo(str(result.script_path))
@@ -0,0 +1,352 @@
1
+ """Core implementation for the pep723-to-wheel CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import tempfile
12
+ import tomllib
13
+ import zipfile
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+ PEP723_START = "# /// script"
18
+ PEP723_END = "# ///"
19
+ UTC = timezone.utc
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class BuildResult:
24
+ """Result metadata for a build operation."""
25
+
26
+ wheel_path: Path
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ImportResult:
31
+ """Result metadata for an import operation."""
32
+
33
+ script_path: Path
34
+
35
+
36
+ class Pep723Header(BaseModel):
37
+ """PEP 723 script metadata."""
38
+
39
+ model_config = ConfigDict(populate_by_name=True)
40
+
41
+ requires_python: str | None = Field(default=None, alias="requires-python")
42
+ dependencies: list[str] = Field(default_factory=list)
43
+
44
+ @classmethod
45
+ def from_script(cls, script_path: Path) -> "Pep723Header":
46
+ """Extract and parse a PEP 723 header from a script file."""
47
+
48
+ text = script_path.read_text(encoding="utf-8")
49
+ block = _extract_pep723_block(text)
50
+ data = _parse_pep723_kv(block)
51
+ return cls.model_validate(data)
52
+
53
+ def render_block(self) -> str:
54
+ """Render the PEP 723 header block."""
55
+
56
+ body_lines: list[str] = []
57
+ if self.requires_python:
58
+ body_lines.append(f'requires-python = "{self.requires_python}"')
59
+ if self.dependencies:
60
+ deps_formatted = ", ".join(f'"{dep}"' for dep in self.dependencies)
61
+ body_lines.append(f"dependencies = [{deps_formatted}]")
62
+ return "\n".join(
63
+ [
64
+ PEP723_START,
65
+ *[f"# {line}" for line in body_lines],
66
+ PEP723_END,
67
+ ]
68
+ )
69
+
70
+
71
+ def _extract_pep723_block(text: str) -> str:
72
+ lines = text.splitlines()
73
+ inside_block = False
74
+ block_lines: list[str] = []
75
+ for line in lines:
76
+ if line.strip() == PEP723_START:
77
+ inside_block = True
78
+ continue
79
+ if line.strip() == PEP723_END and inside_block:
80
+ break
81
+ if inside_block:
82
+ block_lines.append(line)
83
+ return "\n".join(block_lines)
84
+
85
+
86
+ def _parse_pep723_kv(block: str) -> dict:
87
+ if not block.strip():
88
+ return {}
89
+ cleaned = "\n".join(
90
+ line.lstrip("#").lstrip() for line in block.splitlines()
91
+ )
92
+ return tomllib.loads(cleaned)
93
+
94
+
95
+ def _normalize_project_name(name: str) -> str:
96
+ normalized = re.sub(r"[^a-zA-Z0-9]+", "-", name).strip("-").lower()
97
+ return normalized or "pep723-script"
98
+
99
+
100
+ def _normalize_module_name(name: str) -> str:
101
+ module_name = re.sub(r"[^a-zA-Z0-9_]+", "_", name).strip("_").lower()
102
+ if not module_name or module_name[0].isdigit():
103
+ module_name = f"pkg_{module_name}"
104
+ return module_name
105
+
106
+
107
+ def _format_dependencies_block(dependencies: list[str]) -> list[str]:
108
+ lines = ["dependencies = ["]
109
+ if dependencies:
110
+ deps_formatted = ",\n ".join(f"\"{dep}\"" for dep in dependencies)
111
+ lines.append(f" {deps_formatted}")
112
+ lines.append("]")
113
+ return lines
114
+
115
+
116
+ def _extract_requires_dist(metadata_text: str) -> list[str]:
117
+ requirements: list[str] = []
118
+ for line in metadata_text.splitlines():
119
+ if line.startswith("Requires-Dist:"):
120
+ requirements.append(line.replace("Requires-Dist:", "", 1).strip())
121
+ return requirements
122
+
123
+
124
+ def _extract_requires_python(metadata_text: str) -> str | None:
125
+ for line in metadata_text.splitlines():
126
+ if line.startswith("Requires-Python:"):
127
+ return line.replace("Requires-Python:", "", 1).strip()
128
+ return None
129
+
130
+
131
+ def _calendar_version(script_path: Path) -> str:
132
+ mtime = script_path.stat().st_mtime
133
+ timestamp = datetime.fromtimestamp(mtime, tz=UTC)
134
+ return f"{timestamp.year}.{timestamp.month:02d}.{int(mtime)}"
135
+
136
+
137
+ def _build_temp_project(
138
+ script_path: Path,
139
+ output_dir: Path,
140
+ version: str,
141
+ ) -> Path:
142
+ pep723 = Pep723Header.from_script(script_path)
143
+ dependencies = pep723.dependencies
144
+ requires_python = pep723.requires_python
145
+
146
+ project_name = _normalize_project_name(script_path.stem)
147
+ module_name = _normalize_module_name(project_name)
148
+
149
+ with tempfile.TemporaryDirectory() as temp_dir:
150
+ temp_path = Path(temp_dir)
151
+ package_dir = temp_path / "src" / module_name
152
+ package_dir.mkdir(parents=True)
153
+
154
+ (package_dir / "__init__.py").write_text(
155
+ f'"""Package for {project_name}."""\n',
156
+ encoding="utf-8",
157
+ )
158
+ (package_dir / "script.py").write_text(
159
+ script_path.read_text(encoding="utf-8"),
160
+ encoding="utf-8",
161
+ )
162
+
163
+ pyproject = temp_path / "pyproject.toml"
164
+ toml_lines = [
165
+ "[project]",
166
+ f'name = "{project_name}"',
167
+ f'version = "{version}"',
168
+ 'description = "PEP 723 script bundle"',
169
+ ]
170
+ if requires_python:
171
+ toml_lines.append(f'requires-python = "{requires_python}"')
172
+ toml_lines.extend(_format_dependencies_block(dependencies))
173
+ toml_lines.extend(
174
+ [
175
+ "",
176
+ "[build-system]",
177
+ 'requires = ["hatchling>=1.27.0"]',
178
+ 'build-backend = "hatchling.build"',
179
+ "",
180
+ "[tool.hatch.build.targets.wheel]",
181
+ f'packages = ["src/{module_name}"]',
182
+ "",
183
+ ]
184
+ )
185
+ pyproject.write_text(
186
+ "\n".join(toml_lines),
187
+ encoding="utf-8",
188
+ )
189
+
190
+ temp_output_dir = temp_path / "dist"
191
+ temp_output_dir.mkdir(parents=True, exist_ok=True)
192
+ subprocess.run(
193
+ ["uv", "build", "--wheel", "--out-dir", str(temp_output_dir)],
194
+ check=True,
195
+ cwd=temp_path,
196
+ )
197
+
198
+ wheels = list(temp_output_dir.glob("*.whl"))
199
+ if not wheels:
200
+ raise FileNotFoundError(f"No wheel produced in {temp_output_dir}")
201
+ if len(wheels) > 1:
202
+ raise RuntimeError(
203
+ f"Expected exactly one wheel in {temp_output_dir}, found {len(wheels)}: "
204
+ f"{', '.join(str(w) for w in wheels)}"
205
+ )
206
+ output_dir.mkdir(parents=True, exist_ok=True)
207
+ built_wheel = wheels[0]
208
+ dest_wheel = output_dir / built_wheel.name
209
+ shutil.copy2(built_wheel, dest_wheel)
210
+ return dest_wheel
211
+
212
+
213
+ def _find_import_name(wheel: zipfile.ZipFile, package_name: str) -> str | None:
214
+ normalized_package = package_name.replace("-", "_")
215
+ candidates: set[str] = set()
216
+ for name in wheel.namelist():
217
+ if name.endswith("/"):
218
+ continue
219
+ parts = name.split("/")
220
+ if len(parts) == 1 and name.endswith(".py"):
221
+ candidates.add(Path(name).stem)
222
+ continue
223
+ if len(parts) >= 2 and parts[-1] == "__init__.py":
224
+ top_level = parts[0]
225
+ if top_level.endswith(".dist-info") or top_level.endswith(".data"):
226
+ continue
227
+ if top_level.startswith("__"):
228
+ continue
229
+ candidates.add(top_level)
230
+ if normalized_package in candidates:
231
+ return normalized_package
232
+ if len(candidates) == 1:
233
+ return next(iter(candidates))
234
+ return None
235
+
236
+
237
+ def build_script_to_wheel(
238
+ script_path: Path,
239
+ output_dir: Path | None = None,
240
+ version: str | None = None,
241
+ ) -> BuildResult:
242
+ """Build a wheel from a PEP 723 script.
243
+
244
+ Args:
245
+ script_path: Path to the PEP 723 script.
246
+ output_dir: Directory to write the wheel into.
247
+
248
+ Returns:
249
+ BuildResult containing the wheel location.
250
+ """
251
+
252
+ if not script_path.exists():
253
+ raise FileNotFoundError(script_path)
254
+ target_dir = output_dir or script_path.parent / "dist"
255
+ resolved_version = version or _calendar_version(script_path)
256
+ wheel_path = _build_temp_project(script_path, target_dir, resolved_version)
257
+ return BuildResult(wheel_path=wheel_path)
258
+
259
+
260
+ def _download_wheel(package: str, dest: Path) -> Path:
261
+ subprocess.run(
262
+ [
263
+ "uv",
264
+ "pip",
265
+ "download",
266
+ "--only-binary",
267
+ ":all:",
268
+ "--dest",
269
+ str(dest),
270
+ package,
271
+ ],
272
+ check=True,
273
+ )
274
+ wheels = sorted(dest.glob("*.whl"))
275
+ if not wheels:
276
+ raise FileNotFoundError(f"No wheel downloaded for {package}")
277
+ return wheels[-1]
278
+
279
+
280
+ def _read_script_from_wheel(wheel_path: Path) -> str | None:
281
+ with zipfile.ZipFile(wheel_path) as wheel:
282
+ for name in wheel.namelist():
283
+ if name.endswith("/script.py"):
284
+ return wheel.read(name).decode("utf-8")
285
+ return None
286
+
287
+
288
+ def _build_script_from_metadata(wheel_path: Path) -> str:
289
+ with zipfile.ZipFile(wheel_path) as wheel:
290
+ metadata_name = next(
291
+ (name for name in wheel.namelist() if name.endswith(".dist-info/METADATA")),
292
+ None,
293
+ )
294
+ if metadata_name is None:
295
+ raise ValueError("Wheel metadata not found.")
296
+ metadata_text = wheel.read(metadata_name).decode("utf-8")
297
+ name_line = next(
298
+ (line for line in metadata_text.splitlines() if line.startswith("Name: ")),
299
+ None,
300
+ )
301
+ if name_line is None:
302
+ raise ValueError("Wheel metadata missing Name field.")
303
+ package_name = name_line.replace("Name: ", "", 1).strip()
304
+ import_name = _find_import_name(wheel, package_name)
305
+ requires = _extract_requires_dist(metadata_text)
306
+ requires_python = _extract_requires_python(metadata_text)
307
+ all_packages = [package_name]
308
+ all_packages.extend(dep for dep in requires if dep not in all_packages)
309
+ header = Pep723Header(
310
+ requires_python=requires_python,
311
+ dependencies=all_packages,
312
+ )
313
+ lines = [header.render_block()]
314
+ if import_name:
315
+ lines.append(f"import {import_name}")
316
+ lines.append("")
317
+ return "\n".join(lines)
318
+
319
+
320
+ def _script_text_from_wheel(wheel_path: Path) -> str:
321
+ script_text = _read_script_from_wheel(wheel_path)
322
+ if script_text is None:
323
+ script_text = _build_script_from_metadata(wheel_path)
324
+ return script_text
325
+
326
+
327
+ def import_wheel_to_script(
328
+ wheel_or_package: str,
329
+ output_path: Path,
330
+ ) -> ImportResult:
331
+ """Reconstruct a script from a wheel or package name.
332
+
333
+ Args:
334
+ wheel_or_package: Wheel filename/path or a package name to install with uv.
335
+ output_path: Destination path for the reconstructed script.
336
+
337
+ Returns:
338
+ ImportResult containing the script location.
339
+ """
340
+
341
+ wheel_path = Path(wheel_or_package)
342
+ if wheel_path.exists():
343
+ script_text = _script_text_from_wheel(wheel_path)
344
+ else:
345
+ with tempfile.TemporaryDirectory() as temp_dir:
346
+ temp_path = Path(temp_dir)
347
+ wheel_to_read = _download_wheel(wheel_or_package, temp_path)
348
+ script_text = _script_text_from_wheel(wheel_to_read)
349
+
350
+ output_path.parent.mkdir(parents=True, exist_ok=True)
351
+ output_path.write_text(script_text, encoding="utf-8")
352
+ return ImportResult(script_path=output_path)
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: pep723-to-wheel
3
+ Version: 0.1.0
4
+ Summary: PoC for pep-723 script to wheel and back
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: pydantic>=2.5
8
+ Requires-Dist: tomli-w>=1.1.0
9
+ Requires-Dist: typer>=0.21.1
10
+ Description-Content-Type: text/markdown
11
+
12
+ # pep723-to-wheel
13
+
14
+ [![CI](https://github.com/jooh/pep723-to-wheel/actions/workflows/ci.yml/badge.svg)](https://github.com/jooh/pep723-to-wheel/actions/workflows/ci.yml)
15
+ [![codecov](https://codecov.io/gh/jooh/pep723-to-wheel/branch/main/graph/badge.svg?token=PS0IS5TVBV)](https://codecov.io/gh/jooh/pep723-to-wheel)
16
+
17
+ A small utility for converting [PEP 723](https://peps.python.org/pep-0723/) inline dependency scripts into wheels and reconstructing scripts from wheels. Especially useful for taking [reproducible Marimo notebooks](https://marimo.io/blog/sandboxed-notebooks) to production environments.
18
+
19
+ ## CLI
20
+
21
+ Build a wheel from a script that has a PEP 723 inline block:
22
+
23
+ ```bash
24
+ pep723-to-wheel build path/to/script.py --output-dir dist
25
+ ```
26
+
27
+ Set an explicit wheel version (defaults to calendar versioning using the script mtime as the patch segment):
28
+
29
+ ```bash
30
+ pep723-to-wheel build path/to/script.py --version 2024.12.25
31
+ ```
32
+
33
+ Reconstruct a script from a wheel or package name:
34
+
35
+ ```bash
36
+ pep723-to-wheel import path/to/package.whl --output reconstructed.py
37
+ pep723-to-wheel import requests --output reconstructed.py
38
+ ```
39
+
40
+ ## Library
41
+
42
+ ```python
43
+ from pathlib import Path
44
+ from pep723_to_wheel import build_script_to_wheel, import_wheel_to_script
45
+
46
+ result = build_script_to_wheel(Path("script.py"))
47
+ print(result.wheel_path)
48
+
49
+ import_result = import_wheel_to_script("requests", Path("reconstructed.py"))
50
+ print(import_result.script_path)
51
+ ```
52
+
53
+ ## Development
54
+
55
+ ```bash
56
+ make test
57
+ make typecheck
58
+ make ruff
59
+ ```
60
+
61
+ ## Release process
62
+
63
+ Releases are automated on pushes to `main` by the CD workflow in `.github/workflows/cd.yml`.
64
+
65
+ 1. The workflow determines the latest `v*` Git tag and runs `.github/workflows/cd_version.py` to
66
+ resolve the next version. If the latest tag matches the current major/minor, it bumps the patch
67
+ to one higher than the max of the current patch and the tag patch.
68
+ 2. If `pyproject.toml` changes, the workflow commits the version bump back to `main`.
69
+ 3. It tags the release as `v<version>`, builds the wheel with `uv build --wheel`, publishes to
70
+ PyPI, and creates a GitHub release with the wheel attached.
71
+
72
+ To trigger a release, merge or push changes to `main` and ensure `PYPI_API_TOKEN` is configured in
73
+ the repository secrets.
@@ -0,0 +1,8 @@
1
+ pep723_to_wheel/__init__.py,sha256=UZEM6bNBmh5CrdVeyf-5Eka70a98SD6BEmJoGaAg6ws,208
2
+ pep723_to_wheel/cli.py,sha256=rBwdcrrMo0zJednHoGPXMdO595gpTjJcgp0YLXlV_ks,1289
3
+ pep723_to_wheel/core.py,sha256=rXfjhafw-y0OvV8gBuQAVc8ejHt3Q5cU7zOTFwigI9w,11130
4
+ pep723_to_wheel-0.1.0.dist-info/METADATA,sha256=LC-9-VxtTtnB0BIJde3P837vzocTmgSbLE5C89HJhpM,2530
5
+ pep723_to_wheel-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
6
+ pep723_to_wheel-0.1.0.dist-info/entry_points.txt,sha256=bi6cqb9WEwR2xT249k8wYQv9lkhwzdqpvezJwFp1zmo,60
7
+ pep723_to_wheel-0.1.0.dist-info/licenses/LICENSE,sha256=R1voKzqfBhEnGOTKHENGjQjn2qkeSm-TLziX2Vat2V8,1069
8
+ pep723_to_wheel-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pep723-to-wheel = pep723_to_wheel.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Johan Carlin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.