repak 0.1.0__tar.gz

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.
repak-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: repak
3
+ Version: 0.1.0
4
+ Summary: Transfer a local directory across a network boundary via a synthetic PyPI wheel
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: twine>=4.0
7
+ Provides-Extra: dev
8
+ Requires-Dist: pytest>=7.0; extra == "dev"
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "repak"
7
+ version = "0.1.0"
8
+ description = "Transfer a local directory across a network boundary via a synthetic PyPI wheel"
9
+ requires-python = ">=3.9"
10
+ dependencies = ["twine>=4.0"]
11
+
12
+ [project.optional-dependencies]
13
+ dev = ["pytest>=7.0"]
14
+
15
+ [project.scripts]
16
+ repak = "repak.cli:main"
17
+
18
+ [tool.setuptools.packages.find]
19
+ where = ["src"]
20
+
21
+ [tool.setuptools.package-data]
22
+ repak = ["unpak_template.py"]
23
+
24
+ [tool.pytest.ini_options]
25
+ markers = ["slow: end-to-end tests that build venvs and install wheels"]
repak-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """repak: package a local directory as a synthetic PyPI wheel for transport."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,64 @@
1
+ """Build a deterministic ``tar.gz`` of a directory's contents + checksum."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gzip
6
+ import hashlib
7
+ import io
8
+ import os
9
+ import tarfile
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import List
13
+
14
+ VCS_DIRS = {".git", ".hg", ".svn", ".bzr"}
15
+
16
+ # Fixed timestamp so identical trees produce identical archives (helps make
17
+ # uploads/tests reproducible). 2020-01-01 UTC.
18
+ _FIXED_MTIME = 1577836800
19
+
20
+
21
+ @dataclass
22
+ class Archive:
23
+ data: bytes
24
+ sha256: str
25
+ size: int # compressed size in bytes
26
+
27
+
28
+ def _iter_files(root: Path) -> List[Path]:
29
+ collected: List[Path] = []
30
+ for dirpath, dirnames, filenames in os.walk(root):
31
+ # Prune VCS directories in-place so os.walk does not descend.
32
+ dirnames[:] = sorted(d for d in dirnames if d not in VCS_DIRS)
33
+ for name in sorted(filenames):
34
+ collected.append(Path(dirpath) / name)
35
+ return collected
36
+
37
+
38
+ def build_archive(source_dir: str | os.PathLike) -> Archive:
39
+ """Tar (gzip) the *contents* of ``source_dir`` at the archive root.
40
+
41
+ VCS directories are excluded. The archive and its SHA-256 are
42
+ deterministic for a given set of file contents and relative paths.
43
+ """
44
+ root = Path(source_dir).resolve()
45
+ if not root.is_dir():
46
+ raise NotADirectoryError(f"{root} is not a directory")
47
+
48
+ raw = io.BytesIO()
49
+ with tarfile.open(fileobj=raw, mode="w") as tar:
50
+ for path in _iter_files(root):
51
+ rel = path.relative_to(root).as_posix()
52
+ info = tar.gettarinfo(str(path), arcname=rel)
53
+ info.mtime = _FIXED_MTIME
54
+ info.uid = info.gid = 0
55
+ info.uname = info.gname = ""
56
+ if info.isreg():
57
+ with open(path, "rb") as fh:
58
+ tar.addfile(info, fh)
59
+ else:
60
+ tar.addfile(info)
61
+
62
+ compressed = gzip.compress(raw.getvalue(), compresslevel=9, mtime=0)
63
+ digest = hashlib.sha256(compressed).hexdigest()
64
+ return Archive(data=compressed, sha256=digest, size=len(compressed))
@@ -0,0 +1,143 @@
1
+ """repak command-line entry point (upload side)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ from . import naming, pypi, versioning
12
+ from .archive import build_archive
13
+ from .wheel import WheelTooLarge, build_wheel
14
+
15
+ TOKEN_ENV_VARS = ("PYPI_TOKEN", "TWINE_PASSWORD")
16
+
17
+
18
+ def _get_token() -> str:
19
+ for var in TOKEN_ENV_VARS:
20
+ val = os.environ.get(var)
21
+ if val:
22
+ return val
23
+ raise SystemExit(
24
+ "ERROR: no PyPI token found. Set the PYPI_TOKEN environment variable "
25
+ "to a PyPI API token (passed via the environment, never as a flag, "
26
+ "so it stays out of shell history and process listings)."
27
+ )
28
+
29
+
30
+ def _confirm(prompt: str, assume_yes: bool) -> bool:
31
+ if assume_yes:
32
+ return True
33
+ try:
34
+ answer = input(f"{prompt} [y/N] ").strip().lower()
35
+ except EOFError:
36
+ return False
37
+ return answer in ("y", "yes")
38
+
39
+
40
+ def _parse_args(argv):
41
+ parser = argparse.ArgumentParser(
42
+ prog="repak",
43
+ description="Package a local directory as a synthetic PyPI wheel and "
44
+ "upload it to public PyPI for transport into an isolated mirror.",
45
+ )
46
+ parser.add_argument(
47
+ "--path",
48
+ default=".",
49
+ help="Directory to package (default: current directory).",
50
+ )
51
+ parser.add_argument(
52
+ "--yes",
53
+ "-y",
54
+ action="store_true",
55
+ help="Skip confirmation prompts (for non-interactive use).",
56
+ )
57
+ return parser.parse_args(argv)
58
+
59
+
60
+ def main(argv=None) -> int:
61
+ args = _parse_args(argv)
62
+
63
+ source = Path(args.path).resolve()
64
+ if not source.is_dir():
65
+ sys.stderr.write(f"ERROR: {source} is not a directory.\n")
66
+ return 1
67
+
68
+ try:
69
+ pypi_name = naming.pypi_name(source.name)
70
+ except naming.NameError_ as exc:
71
+ sys.stderr.write(f"ERROR: {exc}\n")
72
+ return 1
73
+
74
+ token = _get_token()
75
+
76
+ info = pypi.query(pypi_name)
77
+ if info.exists and not info.is_repak:
78
+ sys.stderr.write(
79
+ f"ERROR: '{pypi_name}' already exists on PyPI but was not created "
80
+ "by repak (no repak marker). Refusing to collide with an "
81
+ "unrelated package. Rename the source folder and retry.\n"
82
+ )
83
+ return 1
84
+
85
+ try:
86
+ version = versioning.next_version(info.versions if info.exists else None)
87
+ except versioning.VersionExhausted as exc:
88
+ sys.stderr.write(f"ERROR: {exc}\n")
89
+ return 1
90
+
91
+ if not info.exists:
92
+ print(f"Creating new package {pypi_name} at version {version}")
93
+ else:
94
+ latest = info.versions[-1] if info.versions else "?"
95
+ print(
96
+ f"Warning: {pypi_name} already exists at version {latest}\n"
97
+ f"This upload will create version {version}."
98
+ )
99
+ if not _confirm("Proceed?", args.yes):
100
+ print("Aborted.")
101
+ return 1
102
+
103
+ archive = build_archive(source)
104
+ print(
105
+ f"Archived contents of {source} "
106
+ f"({archive.size / (1024 * 1024):.2f} MiB compressed, "
107
+ f"sha256 {archive.sha256[:16]}...)"
108
+ )
109
+
110
+ with tempfile.TemporaryDirectory() as tmp:
111
+ try:
112
+ wheel = build_wheel(source.name, version, archive, tmp)
113
+ except WheelTooLarge as exc:
114
+ sys.stderr.write(f"ERROR: {exc}\n")
115
+ return 1
116
+
117
+ print(
118
+ f"Built wheel {wheel.filename} "
119
+ f"({wheel.size / (1024 * 1024):.2f} MiB)"
120
+ )
121
+ if not _confirm(f"Upload {wheel.filename} to public PyPI?", args.yes):
122
+ print("Aborted.")
123
+ return 1
124
+
125
+ try:
126
+ pypi.upload(wheel.path, token)
127
+ except pypi.UploadError as exc:
128
+ sys.stderr.write(f"ERROR: {exc}\n")
129
+ return 1
130
+
131
+ script = naming.console_script(source.name)
132
+ print(
133
+ f"\nUploaded {pypi_name} {version} to public PyPI.\n"
134
+ "Note: propagation to the internal mirror is NOT immediate; there is "
135
+ "an unknown sync lag before it becomes installable in the isolated "
136
+ "environment.\n"
137
+ f"Once available: pip install {pypi_name} && {script} /target/folder"
138
+ )
139
+ return 0
140
+
141
+
142
+ if __name__ == "__main__":
143
+ raise SystemExit(main())
@@ -0,0 +1,43 @@
1
+ """Folder name -> PyPI package name / Python module name normalization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ REPAK_PREFIX = "repak"
8
+
9
+
10
+ class NameError_(ValueError):
11
+ """Raised when a folder name cannot be normalized to a valid name."""
12
+
13
+
14
+ def normalize(folder_name: str) -> str:
15
+ """Normalize a folder basename to a PEP 503-style component.
16
+
17
+ Lowercase, runs of non-alphanumeric characters collapse to a single
18
+ hyphen, leading/trailing hyphens stripped.
19
+ """
20
+ lowered = folder_name.strip().lower()
21
+ collapsed = re.sub(r"[^a-z0-9]+", "-", lowered)
22
+ stripped = collapsed.strip("-")
23
+ if not stripped:
24
+ raise NameError_(
25
+ f"folder name {folder_name!r} does not contain any characters "
26
+ "usable in a PyPI package name"
27
+ )
28
+ return stripped
29
+
30
+
31
+ def pypi_name(folder_name: str) -> str:
32
+ """Return the public PyPI distribution name: ``repak-{normalized}``."""
33
+ return f"{REPAK_PREFIX}-{normalize(folder_name)}"
34
+
35
+
36
+ def module_name(folder_name: str) -> str:
37
+ """Return the import package name: ``repak_{normalized_with_underscores}``."""
38
+ return f"{REPAK_PREFIX}_{normalize(folder_name).replace('-', '_')}"
39
+
40
+
41
+ def console_script(folder_name: str) -> str:
42
+ """Return the destination-side console command: ``unpak-{normalized}``."""
43
+ return f"unpak-{normalize(folder_name)}"
@@ -0,0 +1,75 @@
1
+ """PyPI interaction: version query, repak-marker detection, twine upload."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ import urllib.error
9
+ import urllib.request
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import List, Optional
13
+
14
+ from .wheel import MARKER
15
+
16
+ PYPI_JSON_URL = "https://pypi.org/pypi/{name}/json"
17
+
18
+
19
+ @dataclass
20
+ class PackageInfo:
21
+ exists: bool
22
+ versions: List[str]
23
+ is_repak: bool # True only if an existing package carries the repak marker
24
+
25
+
26
+ class UploadError(RuntimeError):
27
+ pass
28
+
29
+
30
+ def query(pypi_name: str, *, timeout: float = 15.0) -> PackageInfo:
31
+ """Look up ``pypi_name`` on public PyPI.
32
+
33
+ A 404 means the name is free. If it exists, the repak marker keyword
34
+ distinguishes a package repak itself created from an unrelated public
35
+ package that merely shares the name.
36
+ """
37
+ url = PYPI_JSON_URL.format(name=pypi_name)
38
+ try:
39
+ with urllib.request.urlopen(url, timeout=timeout) as resp:
40
+ payload = json.loads(resp.read().decode("utf-8"))
41
+ except urllib.error.HTTPError as exc:
42
+ if exc.code == 404:
43
+ return PackageInfo(exists=False, versions=[], is_repak=False)
44
+ raise
45
+
46
+ versions = sorted((payload.get("releases") or {}).keys())
47
+ info = payload.get("info") or {}
48
+ keywords = (info.get("keywords") or "").lower()
49
+ is_repak = MARKER in keywords
50
+ return PackageInfo(exists=True, versions=versions, is_repak=is_repak)
51
+
52
+
53
+ def upload(wheel_path: str | Path, token: str, *, repository_url: Optional[str] = None) -> None:
54
+ """Upload a wheel with ``twine`` using token auth.
55
+
56
+ The token is passed via the environment (``TWINE_PASSWORD`` with
57
+ ``TWINE_USERNAME=__token__``) so it never appears in argv or shell
58
+ history.
59
+ """
60
+ env = {
61
+ "TWINE_USERNAME": "__token__",
62
+ "TWINE_PASSWORD": token,
63
+ }
64
+ import os
65
+
66
+ full_env = {**os.environ, **env}
67
+ cmd = [sys.executable, "-m", "twine", "upload", str(wheel_path)]
68
+ if repository_url:
69
+ cmd[4:4] = ["--repository-url", repository_url]
70
+ result = subprocess.run(cmd, env=full_env, capture_output=True, text=True)
71
+ if result.returncode != 0:
72
+ raise UploadError(
73
+ "twine upload failed:\n"
74
+ f"{result.stdout}\n{result.stderr}".strip()
75
+ )
@@ -0,0 +1,102 @@
1
+ """Self-contained extractor embedded into every repak-generated wheel.
2
+
3
+ This module ships *inside* the synthetic wheel as ``<pkg>/_unpak.py`` and is
4
+ wired to the ``unpak-{name}`` console script. It must depend only on the
5
+ Python standard library: repak is not installed on the destination side.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import hashlib
12
+ import io
13
+ import os
14
+ import sys
15
+ import tarfile
16
+ from importlib import resources
17
+ from pathlib import Path
18
+
19
+ PAYLOAD = "payload.tar.gz"
20
+ CHECKSUM = "payload.sha256"
21
+
22
+
23
+ def _read_resource(name: str) -> bytes:
24
+ return resources.files(__package__).joinpath(name).read_bytes()
25
+
26
+
27
+ def _safe_members(tar: tarfile.TarFile, dest: Path):
28
+ """Yield only members that extract safely under ``dest``.
29
+
30
+ Rejects absolute paths and ``..`` traversal; restricts to regular files
31
+ and directories (mirrors the intent of tarfile's ``data`` filter while
32
+ remaining compatible with Python 3.9).
33
+ """
34
+ dest = dest.resolve()
35
+ for member in tar.getmembers():
36
+ name = member.name
37
+ if name.startswith("/") or os.path.isabs(name):
38
+ raise ValueError(f"unsafe absolute path in archive: {name!r}")
39
+ target = (dest / name).resolve()
40
+ if target != dest and dest not in target.parents:
41
+ raise ValueError(f"unsafe path traversal in archive: {name!r}")
42
+ if member.isdir() or member.isreg():
43
+ yield member
44
+ else:
45
+ raise ValueError(
46
+ f"archive contains unsupported entry {name!r} "
47
+ f"(type {member.type!r})"
48
+ )
49
+
50
+
51
+ def _extract(data: bytes, dest: Path) -> None:
52
+ dest.mkdir(parents=True, exist_ok=True)
53
+ with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
54
+ members = list(_safe_members(tar, dest))
55
+ for member in members:
56
+ out = dest / member.name
57
+ if member.isdir():
58
+ out.mkdir(parents=True, exist_ok=True)
59
+ continue
60
+ out.parent.mkdir(parents=True, exist_ok=True)
61
+ src = tar.extractfile(member)
62
+ if src is None:
63
+ continue
64
+ with open(out, "wb") as fh:
65
+ fh.write(src.read())
66
+ os.chmod(out, member.mode & 0o777)
67
+
68
+
69
+ def main(argv=None) -> int:
70
+ parser = argparse.ArgumentParser(
71
+ prog=f"unpak ({__package__})",
72
+ description="Verify and extract a repak-transported directory.",
73
+ )
74
+ parser.add_argument(
75
+ "target",
76
+ help="Destination folder; created if missing. Existing unrelated "
77
+ "files are left untouched (overwrite/merge).",
78
+ )
79
+ args = parser.parse_args(argv)
80
+
81
+ data = _read_resource(PAYLOAD)
82
+ expected = _read_resource(CHECKSUM).decode("ascii").strip()
83
+ actual = hashlib.sha256(data).hexdigest()
84
+ if actual != expected:
85
+ sys.stderr.write(
86
+ "ERROR: checksum verification failed; the payload is corrupt "
87
+ "and nothing was written.\n"
88
+ f" expected: {expected}\n"
89
+ f" actual: {actual}\n"
90
+ )
91
+ return 1
92
+
93
+ dest = Path(args.target)
94
+ _extract(data, dest)
95
+ sys.stdout.write(
96
+ f"Verified SHA-256 and extracted contents to {dest.resolve()}\n"
97
+ )
98
+ return 0
99
+
100
+
101
+ if __name__ == "__main__":
102
+ raise SystemExit(main())
@@ -0,0 +1,47 @@
1
+ """Version scheme: 0.N where N is an integer 1..99.
2
+
3
+ Sequence: 0.1, 0.2, ... 0.9, 0.10, 0.11, ... 0.99 (99 uploads per package).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from typing import Iterable, Optional
10
+
11
+ _VERSION_RE = re.compile(r"^0\.(\d+)$")
12
+
13
+ MIN_MINOR = 1
14
+ MAX_MINOR = 99
15
+
16
+
17
+ class VersionExhausted(RuntimeError):
18
+ """Raised when the 0.99 ceiling has been reached for a package."""
19
+
20
+
21
+ def next_version(existing: Optional[Iterable[str]]) -> str:
22
+ """Return the next ``0.N`` version string.
23
+
24
+ ``None`` or an empty iterable means the package does not exist yet, so
25
+ versioning starts at ``0.1``. Otherwise the next version is one greater
26
+ than the highest ``0.N`` already published; non-conforming version
27
+ strings are ignored.
28
+ """
29
+ if not existing:
30
+ return f"0.{MIN_MINOR}"
31
+
32
+ minors = [
33
+ int(m.group(1))
34
+ for m in (_VERSION_RE.match(v.strip()) for v in existing)
35
+ if m is not None
36
+ ]
37
+ if not minors:
38
+ return f"0.{MIN_MINOR}"
39
+
40
+ current = max(minors)
41
+ nxt = current + 1
42
+ if nxt > MAX_MINOR:
43
+ raise VersionExhausted(
44
+ f"package has reached the maximum version 0.{MAX_MINOR}; "
45
+ "no further uploads are possible under this package name"
46
+ )
47
+ return f"0.{nxt}"
@@ -0,0 +1,108 @@
1
+ """Hand-built synthetic wheel: a transport container, not a real package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import zipfile
8
+ from dataclasses import dataclass
9
+ from importlib import resources
10
+ from pathlib import Path
11
+ from typing import Dict
12
+
13
+ from . import naming
14
+ from .archive import Archive
15
+ from .unpak_template import CHECKSUM, PAYLOAD
16
+
17
+ MARKER = "repak-transport-container"
18
+
19
+ # PyPI's default per-file upload limit.
20
+ MAX_WHEEL_BYTES = 100 * 1024 * 1024
21
+
22
+
23
+ class WheelTooLarge(RuntimeError):
24
+ def __init__(self, size: int):
25
+ self.size = size
26
+ super().__init__(
27
+ f"generated wheel is {size / (1024 * 1024):.2f} MiB, which "
28
+ f"exceeds PyPI's {MAX_WHEEL_BYTES // (1024 * 1024)} MiB per-file "
29
+ "limit. Reduce the directory size and try again."
30
+ )
31
+
32
+
33
+ @dataclass
34
+ class BuiltWheel:
35
+ path: Path
36
+ size: int
37
+ filename: str
38
+
39
+
40
+ def _record_hash(data: bytes) -> str:
41
+ digest = hashlib.sha256(data).digest()
42
+ b64 = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
43
+ return f"sha256={b64}"
44
+
45
+
46
+ def _unpak_source() -> str:
47
+ return resources.files("repak").joinpath("unpak_template.py").read_text(
48
+ encoding="utf-8"
49
+ )
50
+
51
+
52
+ def build_wheel(
53
+ folder_name: str,
54
+ version: str,
55
+ archive: Archive,
56
+ out_dir: str | Path,
57
+ ) -> BuiltWheel:
58
+ """Write ``repak_{name}-{version}-py3-none-any.whl`` into ``out_dir``."""
59
+ pkg = naming.module_name(folder_name)
60
+ dist_name = naming.pypi_name(folder_name)
61
+ script = naming.console_script(folder_name)
62
+ dist_info = f"{pkg}-{version}.dist-info"
63
+
64
+ metadata = (
65
+ "Metadata-Version: 2.1\n"
66
+ f"Name: {dist_name}\n"
67
+ f"Version: {version}\n"
68
+ "Summary: Directory payload transported by repak.\n"
69
+ f"Keywords: {MARKER}\n"
70
+ )
71
+ wheel_meta = (
72
+ "Wheel-Version: 1.0\n"
73
+ "Generator: repak\n"
74
+ "Root-Is-Purelib: true\n"
75
+ "Tag: py3-none-any\n"
76
+ )
77
+ entry_points = f"[console_scripts]\n{script} = {pkg}._unpak:main\n"
78
+
79
+ files: Dict[str, bytes] = {
80
+ f"{pkg}/__init__.py": b"",
81
+ f"{pkg}/_unpak.py": _unpak_source().encode("utf-8"),
82
+ f"{pkg}/{PAYLOAD}": archive.data,
83
+ f"{pkg}/{CHECKSUM}": archive.sha256.encode("ascii") + b"\n",
84
+ f"{dist_info}/METADATA": metadata.encode("utf-8"),
85
+ f"{dist_info}/WHEEL": wheel_meta.encode("utf-8"),
86
+ f"{dist_info}/entry_points.txt": entry_points.encode("utf-8"),
87
+ }
88
+
89
+ record_lines = [
90
+ f"{name},{_record_hash(data)},{len(data)}"
91
+ for name, data in files.items()
92
+ ]
93
+ record_path = f"{dist_info}/RECORD"
94
+ record_lines.append(f"{record_path},,")
95
+ files[record_path] = ("\n".join(record_lines) + "\n").encode("utf-8")
96
+
97
+ filename = f"{pkg}-{version}-py3-none-any.whl"
98
+ out_path = Path(out_dir) / filename
99
+ with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zf:
100
+ for name, data in files.items():
101
+ zf.writestr(name, data)
102
+
103
+ size = out_path.stat().st_size
104
+ if size > MAX_WHEEL_BYTES:
105
+ out_path.unlink(missing_ok=True)
106
+ raise WheelTooLarge(size)
107
+
108
+ return BuiltWheel(path=out_path, size=size, filename=filename)
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: repak
3
+ Version: 0.1.0
4
+ Summary: Transfer a local directory across a network boundary via a synthetic PyPI wheel
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: twine>=4.0
7
+ Provides-Extra: dev
8
+ Requires-Dist: pytest>=7.0; extra == "dev"
@@ -0,0 +1,22 @@
1
+ pyproject.toml
2
+ src/repak/__init__.py
3
+ src/repak/archive.py
4
+ src/repak/cli.py
5
+ src/repak/naming.py
6
+ src/repak/pypi.py
7
+ src/repak/unpak_template.py
8
+ src/repak/versioning.py
9
+ src/repak/wheel.py
10
+ src/repak.egg-info/PKG-INFO
11
+ src/repak.egg-info/SOURCES.txt
12
+ src/repak.egg-info/dependency_links.txt
13
+ src/repak.egg-info/entry_points.txt
14
+ src/repak.egg-info/requires.txt
15
+ src/repak.egg-info/top_level.txt
16
+ tests/test_archive.py
17
+ tests/test_e2e.py
18
+ tests/test_extract.py
19
+ tests/test_naming.py
20
+ tests/test_pypi.py
21
+ tests/test_versioning.py
22
+ tests/test_wheel.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ repak = repak.cli:main
@@ -0,0 +1,4 @@
1
+ twine>=4.0
2
+
3
+ [dev]
4
+ pytest>=7.0
@@ -0,0 +1 @@
1
+ repak
@@ -0,0 +1,32 @@
1
+ import hashlib
2
+ import io
3
+ import tarfile
4
+
5
+ from repak.archive import build_archive
6
+
7
+
8
+ def _names(data: bytes):
9
+ with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
10
+ return sorted(tar.getnames())
11
+
12
+
13
+ def test_excludes_vcs_and_keeps_contents(sample_tree):
14
+ arc = build_archive(sample_tree)
15
+ names = _names(arc.data)
16
+ assert "a.txt" in names
17
+ assert "sub/b.bin" in names
18
+ assert "sub/nested/c.txt" in names
19
+ assert not any(n.startswith(".git") for n in names)
20
+
21
+
22
+ def test_checksum_matches_data(sample_tree):
23
+ arc = build_archive(sample_tree)
24
+ assert arc.sha256 == hashlib.sha256(arc.data).hexdigest()
25
+ assert arc.size == len(arc.data)
26
+
27
+
28
+ def test_deterministic(sample_tree):
29
+ a = build_archive(sample_tree)
30
+ b = build_archive(sample_tree)
31
+ assert a.sha256 == b.sha256
32
+ assert a.data == b.data
@@ -0,0 +1,85 @@
1
+ """End-to-end: build a wheel, pip install it into a venv, run unpak."""
2
+
3
+ import subprocess
4
+ import sys
5
+ import venv
6
+ import zipfile
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from repak.archive import build_archive
12
+ from repak.wheel import build_wheel
13
+
14
+
15
+ def _venv_bin(env_dir: Path) -> Path:
16
+ scripts = "Scripts" if sys.platform == "win32" else "bin"
17
+ return env_dir / scripts
18
+
19
+
20
+ @pytest.mark.slow
21
+ def test_install_and_unpak_roundtrip(sample_tree, tmp_path):
22
+ arc = build_archive(sample_tree)
23
+ built = build_wheel("MyProject", "0.1", arc, tmp_path)
24
+
25
+ env_dir = tmp_path / "venv"
26
+ venv.create(env_dir, with_pip=True)
27
+ bin_dir = _venv_bin(env_dir)
28
+ py = bin_dir / ("python.exe" if sys.platform == "win32" else "python")
29
+
30
+ subprocess.run(
31
+ [str(py), "-m", "pip", "install", "--no-index", str(built.path)],
32
+ check=True,
33
+ capture_output=True,
34
+ )
35
+
36
+ target = tmp_path / "landing"
37
+ script = bin_dir / "unpak-myproject"
38
+ result = subprocess.run(
39
+ [str(script), str(target)], capture_output=True, text=True
40
+ )
41
+ assert result.returncode == 0, result.stderr
42
+ assert (target / "a.txt").read_text() == "hello\n"
43
+ assert (target / "sub" / "nested" / "c.txt").read_text() == "deep\n"
44
+ assert not (target / ".git").exists()
45
+
46
+ # Idempotent re-run.
47
+ r2 = subprocess.run(
48
+ [str(script), str(target)], capture_output=True, text=True
49
+ )
50
+ assert r2.returncode == 0
51
+ assert (target / "a.txt").read_text() == "hello\n"
52
+
53
+
54
+ @pytest.mark.slow
55
+ def test_tampered_payload_fails_checksum(sample_tree, tmp_path):
56
+ arc = build_archive(sample_tree)
57
+ built = build_wheel("MyProject", "0.1", arc, tmp_path)
58
+
59
+ # Rewrite the wheel with a corrupted payload but original checksum.
60
+ tampered = tmp_path / built.filename
61
+ with zipfile.ZipFile(built.path) as zin:
62
+ items = {n: zin.read(n) for n in zin.namelist()}
63
+ items["repak_myproject/payload.tar.gz"] += b"corruption"
64
+ with zipfile.ZipFile(tampered, "w") as zout:
65
+ for n, d in items.items():
66
+ zout.writestr(n, d)
67
+
68
+ env_dir = tmp_path / "venv"
69
+ venv.create(env_dir, with_pip=True)
70
+ bin_dir = _venv_bin(env_dir)
71
+ py = bin_dir / ("python.exe" if sys.platform == "win32" else "python")
72
+ subprocess.run(
73
+ [str(py), "-m", "pip", "install", "--no-index", str(tampered)],
74
+ check=True,
75
+ capture_output=True,
76
+ )
77
+
78
+ target = tmp_path / "landing"
79
+ script = bin_dir / "unpak-myproject"
80
+ result = subprocess.run(
81
+ [str(script), str(target)], capture_output=True, text=True
82
+ )
83
+ assert result.returncode == 1
84
+ assert "checksum verification failed" in result.stderr
85
+ assert not target.exists() or not any(target.iterdir())
@@ -0,0 +1,53 @@
1
+ import io
2
+ import tarfile
3
+
4
+ import pytest
5
+
6
+ from repak import unpak_template as ut
7
+
8
+
9
+ def _make_tar(entries):
10
+ raw = io.BytesIO()
11
+ with tarfile.open(fileobj=raw, mode="w:gz") as tar:
12
+ for name, data in entries:
13
+ info = tarfile.TarInfo(name)
14
+ info.size = len(data)
15
+ tar.addfile(info, io.BytesIO(data))
16
+ return raw.getvalue()
17
+
18
+
19
+ def test_extract_creates_target_and_files(tmp_path):
20
+ data = _make_tar([("a.txt", b"A"), ("d/b.txt", b"B")])
21
+ dest = tmp_path / "out"
22
+ ut._extract(data, dest)
23
+ assert (dest / "a.txt").read_bytes() == b"A"
24
+ assert (dest / "d" / "b.txt").read_bytes() == b"B"
25
+
26
+
27
+ def test_overwrite_merge_idempotent(tmp_path):
28
+ dest = tmp_path / "out"
29
+ dest.mkdir()
30
+ (dest / "unrelated.txt").write_text("keep me")
31
+
32
+ data = _make_tar([("a.txt", b"v1")])
33
+ ut._extract(data, dest)
34
+ ut._extract(data, dest) # idempotent
35
+ assert (dest / "a.txt").read_bytes() == b"v1"
36
+ assert (dest / "unrelated.txt").read_text() == "keep me"
37
+
38
+ data2 = _make_tar([("a.txt", b"v2")])
39
+ ut._extract(data2, dest) # overwrite in place
40
+ assert (dest / "a.txt").read_bytes() == b"v2"
41
+ assert (dest / "unrelated.txt").read_text() == "keep me"
42
+
43
+
44
+ def test_rejects_path_traversal(tmp_path):
45
+ data = _make_tar([("../evil.txt", b"x")])
46
+ with pytest.raises(ValueError):
47
+ ut._extract(data, tmp_path / "out")
48
+
49
+
50
+ def test_rejects_absolute_path(tmp_path):
51
+ data = _make_tar([("/etc/evil", b"x")])
52
+ with pytest.raises(ValueError):
53
+ ut._extract(data, tmp_path / "out")
@@ -0,0 +1,29 @@
1
+ import pytest
2
+
3
+ from repak import naming
4
+
5
+
6
+ @pytest.mark.parametrize(
7
+ "folder,expected",
8
+ [
9
+ ("MyProject", "myproject"),
10
+ ("my_project", "my-project"),
11
+ ("My.Cool Project!!", "my-cool-project"),
12
+ ("--weird--", "weird"),
13
+ ("a___b...c", "a-b-c"),
14
+ ],
15
+ )
16
+ def test_normalize(folder, expected):
17
+ assert naming.normalize(folder) == expected
18
+
19
+
20
+ def test_pypi_module_script_names():
21
+ assert naming.pypi_name("My_Proj") == "repak-my-proj"
22
+ assert naming.module_name("My_Proj") == "repak_my_proj"
23
+ assert naming.console_script("My_Proj") == "unpak-my-proj"
24
+
25
+
26
+ @pytest.mark.parametrize("bad", ["", " ", "!!!", "---"])
27
+ def test_invalid_names_rejected(bad):
28
+ with pytest.raises(naming.NameError_):
29
+ naming.normalize(bad)
@@ -0,0 +1,95 @@
1
+ import io
2
+ import json
3
+ import urllib.error
4
+
5
+ import pytest
6
+
7
+ from repak import pypi
8
+ from repak.wheel import MARKER
9
+
10
+
11
+ class _Resp(io.BytesIO):
12
+ def __enter__(self):
13
+ return self
14
+
15
+ def __exit__(self, *a):
16
+ self.close()
17
+
18
+
19
+ def _fake_urlopen(payload):
20
+ def _open(url, timeout=0):
21
+ return _Resp(json.dumps(payload).encode())
22
+
23
+ return _open
24
+
25
+
26
+ def test_query_not_found(monkeypatch):
27
+ def _raise(url, timeout=0):
28
+ raise urllib.error.HTTPError(url, 404, "nf", {}, None)
29
+
30
+ monkeypatch.setattr(pypi.urllib.request, "urlopen", _raise)
31
+ info = pypi.query("repak-missing")
32
+ assert info.exists is False and info.is_repak is False
33
+
34
+
35
+ def test_query_existing_repak(monkeypatch):
36
+ payload = {
37
+ "releases": {"0.1": [], "0.2": []},
38
+ "info": {"keywords": f"foo, {MARKER}"},
39
+ }
40
+ monkeypatch.setattr(
41
+ pypi.urllib.request, "urlopen", _fake_urlopen(payload)
42
+ )
43
+ info = pypi.query("repak-thing")
44
+ assert info.exists and info.is_repak
45
+ assert info.versions == ["0.1", "0.2"]
46
+
47
+
48
+ def test_query_existing_unrelated(monkeypatch):
49
+ payload = {"releases": {"1.0": []}, "info": {"keywords": "other"}}
50
+ monkeypatch.setattr(
51
+ pypi.urllib.request, "urlopen", _fake_urlopen(payload)
52
+ )
53
+ info = pypi.query("repak-thing")
54
+ assert info.exists and not info.is_repak
55
+
56
+
57
+ def test_upload_passes_token_via_env(monkeypatch, tmp_path):
58
+ wheel = tmp_path / "x.whl"
59
+ wheel.write_bytes(b"data")
60
+ captured = {}
61
+
62
+ class _Result:
63
+ returncode = 0
64
+ stdout = ""
65
+ stderr = ""
66
+
67
+ def _run(cmd, env=None, capture_output=False, text=False):
68
+ captured["cmd"] = cmd
69
+ captured["env"] = env
70
+ return _Result()
71
+
72
+ monkeypatch.setattr(pypi.subprocess, "run", _run)
73
+ pypi.upload(wheel, "pypi-secret-token")
74
+
75
+ assert "twine" in captured["cmd"]
76
+ assert str(wheel) in captured["cmd"]
77
+ assert "pypi-secret-token" not in captured["cmd"]
78
+ assert captured["env"]["TWINE_USERNAME"] == "__token__"
79
+ assert captured["env"]["TWINE_PASSWORD"] == "pypi-secret-token"
80
+
81
+
82
+ def test_upload_error(monkeypatch, tmp_path):
83
+ wheel = tmp_path / "x.whl"
84
+ wheel.write_bytes(b"data")
85
+
86
+ class _Result:
87
+ returncode = 1
88
+ stdout = "boom"
89
+ stderr = "fail"
90
+
91
+ monkeypatch.setattr(
92
+ pypi.subprocess, "run", lambda *a, **k: _Result()
93
+ )
94
+ with pytest.raises(pypi.UploadError):
95
+ pypi.upload(wheel, "tok")
@@ -0,0 +1,31 @@
1
+ import pytest
2
+
3
+ from repak import versioning
4
+
5
+
6
+ def test_new_package_starts_at_0_1():
7
+ assert versioning.next_version(None) == "0.1"
8
+ assert versioning.next_version([]) == "0.1"
9
+
10
+
11
+ def test_increment_single_digit():
12
+ assert versioning.next_version(["0.1"]) == "0.2"
13
+
14
+
15
+ def test_increment_rolls_into_two_digits():
16
+ assert versioning.next_version(["0.8", "0.9"]) == "0.10"
17
+ assert versioning.next_version(["0.10", "0.11"]) == "0.12"
18
+
19
+
20
+ def test_picks_max_existing():
21
+ assert versioning.next_version(["0.3", "0.1", "0.12", "0.2"]) == "0.13"
22
+
23
+
24
+ def test_non_conforming_versions_ignored():
25
+ assert versioning.next_version(["1.0", "0.0.1", "garbage"]) == "0.1"
26
+ assert versioning.next_version(["1.0", "0.5"]) == "0.6"
27
+
28
+
29
+ def test_ceiling_raises():
30
+ with pytest.raises(versioning.VersionExhausted):
31
+ versioning.next_version(["0.99"])
@@ -0,0 +1,68 @@
1
+ import base64
2
+ import hashlib
3
+ import zipfile
4
+
5
+ import pytest
6
+
7
+ from repak import wheel
8
+ from repak.archive import build_archive
9
+
10
+
11
+ def _build(sample_tree, tmp_path, version="0.1"):
12
+ arc = build_archive(sample_tree)
13
+ return wheel.build_wheel("MyProject", version, arc, tmp_path), arc
14
+
15
+
16
+ def test_wheel_structure(sample_tree, tmp_path):
17
+ built, arc = _build(sample_tree, tmp_path)
18
+ assert built.filename == "repak_myproject-0.1-py3-none-any.whl"
19
+
20
+ with zipfile.ZipFile(built.path) as zf:
21
+ names = set(zf.namelist())
22
+ assert "repak_myproject/_unpak.py" in names
23
+ assert "repak_myproject/payload.tar.gz" in names
24
+ assert "repak_myproject/payload.sha256" in names
25
+
26
+ ep = zf.read(
27
+ "repak_myproject-0.1.dist-info/entry_points.txt"
28
+ ).decode()
29
+ assert ep == "[console_scripts]\nunpak-myproject = repak_myproject._unpak:main\n"
30
+
31
+ meta = zf.read("repak_myproject-0.1.dist-info/METADATA").decode()
32
+ assert "Name: repak-myproject" in meta
33
+ assert f"Keywords: {wheel.MARKER}" in meta
34
+
35
+ payload = zf.read("repak_myproject/payload.tar.gz")
36
+ assert payload == arc.data
37
+ checksum = zf.read("repak_myproject/payload.sha256").decode().strip()
38
+ assert checksum == arc.sha256
39
+
40
+
41
+ def test_record_hashes_valid(sample_tree, tmp_path):
42
+ built, _ = _build(sample_tree, tmp_path)
43
+ with zipfile.ZipFile(built.path) as zf:
44
+ record = zf.read(
45
+ "repak_myproject-0.1.dist-info/RECORD"
46
+ ).decode().splitlines()
47
+ entries = {}
48
+ for line in record:
49
+ path, h, size = line.rsplit(",", 2)
50
+ entries[path] = (h, size)
51
+
52
+ for path, (h, size) in entries.items():
53
+ if path.endswith("RECORD"):
54
+ assert h == "" and size == ""
55
+ continue
56
+ data = zf.read(path)
57
+ expected = base64.urlsafe_b64encode(
58
+ hashlib.sha256(data).digest()
59
+ ).rstrip(b"=").decode()
60
+ assert h == f"sha256={expected}"
61
+ assert size == str(len(data))
62
+
63
+
64
+ def test_size_limit(monkeypatch, sample_tree, tmp_path):
65
+ monkeypatch.setattr(wheel, "MAX_WHEEL_BYTES", 10)
66
+ arc = build_archive(sample_tree)
67
+ with pytest.raises(wheel.WheelTooLarge):
68
+ wheel.build_wheel("MyProject", "0.1", arc, tmp_path)