repak 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.
- repak/__init__.py +5 -0
- repak/archive.py +64 -0
- repak/cli.py +143 -0
- repak/naming.py +43 -0
- repak/pypi.py +75 -0
- repak/unpak_template.py +102 -0
- repak/versioning.py +47 -0
- repak/wheel.py +108 -0
- repak-0.1.0.dist-info/METADATA +8 -0
- repak-0.1.0.dist-info/RECORD +13 -0
- repak-0.1.0.dist-info/WHEEL +5 -0
- repak-0.1.0.dist-info/entry_points.txt +2 -0
- repak-0.1.0.dist-info/top_level.txt +1 -0
repak/__init__.py
ADDED
repak/archive.py
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))
|
repak/cli.py
ADDED
|
@@ -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())
|
repak/naming.py
ADDED
|
@@ -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)}"
|
repak/pypi.py
ADDED
|
@@ -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
|
+
)
|
repak/unpak_template.py
ADDED
|
@@ -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())
|
repak/versioning.py
ADDED
|
@@ -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}"
|
repak/wheel.py
ADDED
|
@@ -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,13 @@
|
|
|
1
|
+
repak/__init__.py,sha256=QMI24uAFXyzQ-GOGdXllmLjcMDcDR-6SCJ0QR-G464Q,130
|
|
2
|
+
repak/archive.py,sha256=3xjdlMqNKI4knYV9dAYoI-QqzhPfQvEWLw1NEX-Qw1I,2054
|
|
3
|
+
repak/cli.py,sha256=GErQjRB6gBTymh4-uAZCTylaRSrdKy9Bb3EYy1F1MSk,4221
|
|
4
|
+
repak/naming.py,sha256=5vO369XEuQBbYTDZOqmsPWMEbiX9Wo-_EhEDOAdynSE,1356
|
|
5
|
+
repak/pypi.py,sha256=nKr4vn4Qjwizl159dw3pa7vQDvJVqoYVYPrD1iNMgdo,2328
|
|
6
|
+
repak/unpak_template.py,sha256=AkBMA5uMXtsdiuTGur3mSzvUWX0qcNLCFNgS3Q004Bo,3299
|
|
7
|
+
repak/versioning.py,sha256=1IAOuswvr-h7qiMsjJfmVsqbr1KkBIDsJ6So_zdjTgs,1284
|
|
8
|
+
repak/wheel.py,sha256=uLstphenb8NOnYokHilv4AOQSQjUTJ3G3grhaMgMhnQ,3206
|
|
9
|
+
repak-0.1.0.dist-info/METADATA,sha256=LnVCCUfxyOANOGMHF9e8vsMa4DQBdDjbf4qohlcX5kQ,250
|
|
10
|
+
repak-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
repak-0.1.0.dist-info/entry_points.txt,sha256=lX2p3GeQHHPMu5s5G6Z5Dv9PzGPv9GFAngXlcVOM6ho,41
|
|
12
|
+
repak-0.1.0.dist-info/top_level.txt,sha256=jDYOl_fY_uwpgjJPzWs4GdZ4G34NItkq9fXYMMtVPK8,6
|
|
13
|
+
repak-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
repak
|