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 +8 -0
- repak-0.1.0/pyproject.toml +25 -0
- repak-0.1.0/setup.cfg +4 -0
- repak-0.1.0/src/repak/__init__.py +5 -0
- repak-0.1.0/src/repak/archive.py +64 -0
- repak-0.1.0/src/repak/cli.py +143 -0
- repak-0.1.0/src/repak/naming.py +43 -0
- repak-0.1.0/src/repak/pypi.py +75 -0
- repak-0.1.0/src/repak/unpak_template.py +102 -0
- repak-0.1.0/src/repak/versioning.py +47 -0
- repak-0.1.0/src/repak/wheel.py +108 -0
- repak-0.1.0/src/repak.egg-info/PKG-INFO +8 -0
- repak-0.1.0/src/repak.egg-info/SOURCES.txt +22 -0
- repak-0.1.0/src/repak.egg-info/dependency_links.txt +1 -0
- repak-0.1.0/src/repak.egg-info/entry_points.txt +2 -0
- repak-0.1.0/src/repak.egg-info/requires.txt +4 -0
- repak-0.1.0/src/repak.egg-info/top_level.txt +1 -0
- repak-0.1.0/tests/test_archive.py +32 -0
- repak-0.1.0/tests/test_e2e.py +85 -0
- repak-0.1.0/tests/test_extract.py +53 -0
- repak-0.1.0/tests/test_naming.py +29 -0
- repak-0.1.0/tests/test_pypi.py +95 -0
- repak-0.1.0/tests/test_versioning.py +31 -0
- repak-0.1.0/tests/test_wheel.py +68 -0
repak-0.1.0/PKG-INFO
ADDED
|
@@ -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,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,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 @@
|
|
|
1
|
+
|
|
@@ -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)
|