reproducibly 0.0.15__tar.gz → 0.0.16__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.
@@ -1,16 +1,15 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: reproducibly
|
3
|
-
Version: 0.0.
|
4
|
-
Summary: Reproducibly build Python packages
|
3
|
+
Version: 0.0.16
|
4
|
+
Summary: Reproducibly build Python packages.
|
5
5
|
Author-email: Keith Maxwell <keith.maxwell@gmail.com>
|
6
6
|
Requires-Python: >=3.13
|
7
7
|
Description-Content-Type: text/markdown
|
8
8
|
Classifier: Programming Language :: Python :: 3
|
9
|
-
Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
|
10
9
|
Requires-Dist: build==1.2.2.post1
|
11
|
-
Requires-Dist: cibuildwheel==
|
12
|
-
Requires-Dist: packaging==
|
13
|
-
Requires-Dist:
|
10
|
+
Requires-Dist: cibuildwheel==3.0.1
|
11
|
+
Requires-Dist: packaging==25.0
|
12
|
+
Requires-Dist: pyproject-hooks==1.2.0
|
14
13
|
Project-URL: Homepage, https://github.com/maxwell-k/reproducibly/
|
15
14
|
Project-URL: Issues, https://github.com/maxwell-k/reproducibly/issues
|
16
15
|
|
@@ -47,7 +46,7 @@ cog.out("\n```\n" + RESULT.stdout + "```\n\n")
|
|
47
46
|
```
|
48
47
|
usage: reproducibly.py [-h] [--version] input [input ...] output
|
49
48
|
|
50
|
-
Reproducibly build Python packages
|
49
|
+
Reproducibly build Python packages.
|
51
50
|
|
52
51
|
features:
|
53
52
|
|
@@ -15,14 +15,14 @@ readme = "README.md"
|
|
15
15
|
requires-python = ">=3.13"
|
16
16
|
classifiers = [
|
17
17
|
"Programming Language :: Python :: 3",
|
18
|
-
"License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)",
|
19
18
|
]
|
20
19
|
dependencies = [
|
21
20
|
"build==1.2.2.post1",
|
22
|
-
"cibuildwheel==
|
23
|
-
"packaging==
|
24
|
-
"
|
21
|
+
"cibuildwheel==3.0.1",
|
22
|
+
"packaging==25.0",
|
23
|
+
"pyproject-hooks==1.2.0",
|
25
24
|
]
|
25
|
+
licence = "MPL-2.0"
|
26
26
|
|
27
27
|
[project.urls]
|
28
28
|
Homepage = "https://github.com/maxwell-k/reproducibly/"
|
@@ -33,3 +33,16 @@ reproducibly = "reproducibly:main"
|
|
33
33
|
|
34
34
|
[tool.codespell]
|
35
35
|
skip = './htmlcov'
|
36
|
+
|
37
|
+
[tool.ruff.lint]
|
38
|
+
select = ["ALL"]
|
39
|
+
ignore = [
|
40
|
+
"D203", # incompatible with D211
|
41
|
+
"D213", # incompatible with D212
|
42
|
+
"I", # prefer usort to ruff isort implementation
|
43
|
+
"PT", # prefer unittest style
|
44
|
+
"S310", # the rule errors on the "use instead" code from `ruff rule S310`
|
45
|
+
"S602", # assume arguments to subprocess.run are validated
|
46
|
+
"S603", # assume trusted input to subprocess.run
|
47
|
+
"T201", # print is used for output in command line scripts
|
48
|
+
]
|
@@ -1,4 +1,4 @@
|
|
1
|
-
"""Reproducibly build Python packages
|
1
|
+
"""Reproducibly build Python packages.
|
2
2
|
|
3
3
|
features:
|
4
4
|
|
@@ -17,8 +17,8 @@ features:
|
|
17
17
|
import gzip
|
18
18
|
from argparse import ArgumentParser, RawDescriptionHelpFormatter
|
19
19
|
from contextlib import chdir
|
20
|
-
from datetime import datetime
|
21
|
-
from enum import auto, Enum
|
20
|
+
from datetime import datetime, UTC
|
21
|
+
from enum import auto, Enum
|
22
22
|
from os import environ, utime
|
23
23
|
from pathlib import Path
|
24
24
|
from shutil import copyfileobj, move
|
@@ -27,6 +27,7 @@ from subprocess import CalledProcessError, run
|
|
27
27
|
from sys import version_info
|
28
28
|
from tarfile import TarFile, TarInfo
|
29
29
|
from tempfile import TemporaryDirectory
|
30
|
+
from types import TracebackType
|
30
31
|
from typing import cast, Literal, TypedDict
|
31
32
|
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
|
32
33
|
|
@@ -36,31 +37,15 @@ from cibuildwheel.__main__ import build_in_directory
|
|
36
37
|
from cibuildwheel.options import CommandLineArguments
|
37
38
|
from pyproject_hooks import default_subprocess_runner
|
38
39
|
|
39
|
-
# [[[cog import cog ; from pathlib import Path ]]]
|
40
|
-
# [[[end]]]
|
41
|
-
|
42
|
-
# [[[cog
|
43
|
-
# import tomllib
|
44
|
-
# with open("pyproject.toml", "rb") as f:
|
45
|
-
# pyproject = tomllib.load(f)
|
46
|
-
# cog.outl("# /// script")
|
47
|
-
# cog.outl(f'# requires-python = "{pyproject["project"]["requires-python"]}"')
|
48
|
-
# cog.outl("# dependencies = [")
|
49
|
-
# for dependency in pyproject["project"]["dependencies"]:
|
50
|
-
# cog.outl(f"# \"{dependency}\",")
|
51
|
-
# cog.outl("# ]")
|
52
|
-
# cog.outl("# ///")
|
53
|
-
# ]]]
|
54
40
|
# /// script
|
55
41
|
# requires-python = ">=3.13"
|
56
42
|
# dependencies = [
|
57
|
-
#
|
58
|
-
#
|
59
|
-
#
|
60
|
-
#
|
43
|
+
# "build==1.2.2.post1",
|
44
|
+
# "cibuildwheel==3.0.1",
|
45
|
+
# "packaging==25.0",
|
46
|
+
# "pyproject-hooks==1.2.0",
|
61
47
|
# ]
|
62
48
|
# ///
|
63
|
-
# [[[end]]]
|
64
49
|
|
65
50
|
|
66
51
|
# - Built distributions are created from source distributions
|
@@ -69,18 +54,21 @@ from pyproject_hooks import default_subprocess_runner
|
|
69
54
|
# - The default date for this script is the earliest date supported by both
|
70
55
|
# - The minimum date value supported by zip files, is documented in
|
71
56
|
# <https://github.com/python/cpython/blob/3.13/Lib/zipfile.py>.
|
72
|
-
EARLIEST = datetime(1980, 1, 1, 0, 0, 0).timestamp() # 315532800.0
|
57
|
+
EARLIEST = datetime(1980, 1, 1, 0, 0, 0, tzinfo=UTC).timestamp() # 315532800.0
|
73
58
|
|
74
59
|
|
75
|
-
__version__ = "0.0.
|
60
|
+
__version__ = "0.0.16"
|
76
61
|
|
77
62
|
|
78
63
|
def _build(
|
79
|
-
srcdir: Path,
|
64
|
+
srcdir: Path,
|
65
|
+
output: Path,
|
66
|
+
distribution: Literal["wheel", "sdist"],
|
80
67
|
) -> Path:
|
81
|
-
"""Call the build API
|
68
|
+
"""Call the build API.
|
82
69
|
|
83
|
-
Returns the path to the built distribution
|
70
|
+
Returns the path to the built distribution
|
71
|
+
"""
|
84
72
|
with DefaultIsolatedEnv() as env:
|
85
73
|
builder = ProjectBuilder.from_isolated_env(
|
86
74
|
env,
|
@@ -100,14 +88,15 @@ def _extract_to_empty_directory(sdist: Path, directory: str) -> Path:
|
|
100
88
|
|
101
89
|
|
102
90
|
def _cibuildwheel(sdist: Path, output: Path) -> Path:
|
103
|
-
"""Call the cibuildwheel API
|
91
|
+
"""Call the cibuildwheel API.
|
104
92
|
|
105
|
-
Returns the path to the built distribution
|
93
|
+
Returns the path to the built distribution
|
94
|
+
"""
|
106
95
|
with (
|
107
96
|
ModifiedEnvironment(
|
108
97
|
CIBW_BUILD_FRONTEND="build",
|
109
98
|
CIBW_CONTAINER_ENGINE="podman",
|
110
|
-
CIBW_ENVIRONMENT_PASS_LINUX="SOURCE_DATE_EPOCH",
|
99
|
+
CIBW_ENVIRONMENT_PASS_LINUX="SOURCE_DATE_EPOCH", # noqa: S106 …_PASS_… is not a password
|
111
100
|
CIBW_ENVIRONMENT="PIP_TIMEOUT=150",
|
112
101
|
),
|
113
102
|
TemporaryDirectory() as directory,
|
@@ -121,30 +110,39 @@ def _cibuildwheel(sdist: Path, output: Path) -> Path:
|
|
121
110
|
build_in_directory(args)
|
122
111
|
wheel = next(args.output_dir.glob("*.whl"))
|
123
112
|
output.joinpath(wheel.name).unlink(missing_ok=True)
|
124
|
-
|
125
|
-
return path
|
113
|
+
return Path(move(wheel, output))
|
126
114
|
|
127
115
|
|
128
116
|
class Arguments(TypedDict):
|
117
|
+
"""Input arguments to reproducibly.py."""
|
118
|
+
|
129
119
|
repositories: list[Path]
|
130
120
|
sdists: list[Path]
|
131
121
|
output: Path
|
132
122
|
|
133
123
|
|
134
124
|
class ModifiedEnvironment:
|
135
|
-
"""A context manager to temporarily change environment variables"""
|
125
|
+
"""A context manager to temporarily change environment variables."""
|
136
126
|
|
137
|
-
def __init__(self, **kwargs: str | None):
|
127
|
+
def __init__(self, **kwargs: str | None) -> None:
|
128
|
+
"""Initialise with all arguments as environment variables."""
|
138
129
|
self.during: dict[str, str | None] = kwargs
|
139
130
|
|
140
|
-
def __enter__(self):
|
141
|
-
|
131
|
+
def __enter__(self) -> None:
|
132
|
+
"""Set the environment variables."""
|
133
|
+
self.before = {key: environ.get(key) for key in self.during}
|
142
134
|
self._update(self.during)
|
143
135
|
|
144
|
-
def __exit__(
|
136
|
+
def __exit__(
|
137
|
+
self,
|
138
|
+
exc_type: type[BaseException] | None,
|
139
|
+
exc_value: BaseException | None,
|
140
|
+
exc_traceback: TracebackType | None,
|
141
|
+
) -> None:
|
142
|
+
"""Reset environment."""
|
145
143
|
self._update(self.before)
|
146
144
|
|
147
|
-
def _update(self, other):
|
145
|
+
def _update(self, other: dict[str, str | None]) -> None:
|
148
146
|
for key, value in other.items():
|
149
147
|
if value is None:
|
150
148
|
if key in environ:
|
@@ -154,19 +152,21 @@ class ModifiedEnvironment:
|
|
154
152
|
|
155
153
|
|
156
154
|
class Builder(Enum):
|
155
|
+
"""Enum to identifier build package."""
|
156
|
+
|
157
157
|
cibuildwheel = auto()
|
158
158
|
build = auto()
|
159
159
|
|
160
|
-
@nonmember
|
161
160
|
@staticmethod
|
162
161
|
def which(archive: Path) -> "Builder":
|
162
|
+
"""Return Builder.cibuildwheel if .c files are present otherwise Build.build."""
|
163
163
|
with TarFile.open(archive, "r:gz") as tar:
|
164
164
|
c = any(i.name.endswith(".c") for i in tar.getmembers())
|
165
165
|
return Builder.cibuildwheel if c else Builder.build
|
166
166
|
|
167
167
|
|
168
168
|
def cleanse_sdist(path_: Path, mtime: float) -> int:
|
169
|
-
"""Cleanse a single source distribution
|
169
|
+
"""Cleanse a single source distribution.
|
170
170
|
|
171
171
|
- Set all uids and gids to zero
|
172
172
|
- Set all unames and gnames to root
|
@@ -181,11 +181,11 @@ def cleanse_sdist(path_: Path, mtime: float) -> int:
|
|
181
181
|
mtime = max(mtime, EARLIEST)
|
182
182
|
|
183
183
|
with TemporaryDirectory() as path:
|
184
|
-
with TarFile.open(filename) as
|
185
|
-
|
184
|
+
with TarFile.open(filename) as tarfile:
|
185
|
+
tarfile.extractall(path=path, filter="data")
|
186
186
|
|
187
187
|
filename.unlink(missing_ok=True)
|
188
|
-
(
|
188
|
+
(bare,) = Path(path).iterdir()
|
189
189
|
|
190
190
|
prefix = path.removeprefix("/") + "/"
|
191
191
|
|
@@ -197,50 +197,63 @@ def cleanse_sdist(path_: Path, mtime: float) -> int:
|
|
197
197
|
tarinfo.path = tarinfo.path.removeprefix(prefix)
|
198
198
|
return tarinfo
|
199
199
|
|
200
|
-
tar = f"{
|
200
|
+
tar = f"{bare}.tar"
|
201
201
|
with TarFile.open(tar, "w") as tarfile:
|
202
|
-
tarfile.add(
|
203
|
-
|
204
|
-
with
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
202
|
+
tarfile.add(bare, filter=filter_)
|
203
|
+
|
204
|
+
with (
|
205
|
+
Path(tar).open("rb") as fsrc,
|
206
|
+
gzip.GzipFile(
|
207
|
+
filename=filename,
|
208
|
+
mode="wb",
|
209
|
+
mtime=mtime,
|
210
|
+
compresslevel=0,
|
211
|
+
) as fdst,
|
212
|
+
):
|
213
|
+
copyfileobj(fsrc, fdst)
|
212
214
|
utime(filename, (mtime, mtime))
|
213
215
|
return 0
|
214
216
|
|
215
217
|
|
216
218
|
def latest_modification_time(archive: Path) -> str:
|
217
|
-
"""Latest modification time for a gzipped tarfile as a string"""
|
219
|
+
"""Latest modification time for a gzipped tarfile as a string."""
|
218
220
|
with TarFile.open(archive, "r:gz") as tar:
|
219
221
|
latest = max(member.mtime for member in tar.getmembers())
|
220
|
-
return "{:.0f}"
|
222
|
+
return f"{latest:.0f}"
|
221
223
|
|
222
224
|
|
223
225
|
def latest_commit_time(repository: Path) -> float:
|
224
|
-
"""Return the time of the last commit to a repository
|
226
|
+
"""Return the time of the last commit to a repository.
|
225
227
|
|
226
228
|
As a UNIX timestamp, defined as the number of seconds, excluding leap
|
227
|
-
seconds, since 01 Jan 1970 00:00:00 UTC.
|
229
|
+
seconds, since 01 Jan 1970 00:00:00 UTC.
|
230
|
+
"""
|
228
231
|
cmd = ("git", "-C", repository, "log", "-1", "--pretty=%ct")
|
229
232
|
output = run(cmd, check=True, capture_output=True, text=True).stdout
|
230
233
|
return float(output.rstrip("\n"))
|
231
234
|
|
232
235
|
|
233
236
|
def breadth_first_key(path: str) -> list[str | list]:
|
237
|
+
"""Key for sorting breadth first.
|
238
|
+
|
239
|
+
An example of breadth first sorted strings:
|
240
|
+
|
241
|
+
1. z
|
242
|
+
2. a/y
|
243
|
+
3. a/b/x
|
244
|
+
|
245
|
+
"""
|
234
246
|
start, sep, end = path.partition("/")
|
235
247
|
return [sep, start, breadth_first_key(end)] if end else [sep, start]
|
236
248
|
|
237
249
|
|
238
250
|
def key(input_: bytes | ZipInfo) -> tuple[int, list[str | list]]:
|
251
|
+
"""Key for reproducibly sorting ZipFiles."""
|
239
252
|
if hasattr(input_, "filename"):
|
240
|
-
item = cast(ZipInfo, input_).filename
|
253
|
+
item = cast("ZipInfo", input_).filename
|
241
254
|
path = item
|
242
255
|
else:
|
243
|
-
item = cast(bytes, input_).decode()
|
256
|
+
item = cast("bytes", input_).decode()
|
244
257
|
path = item.split(",")[0]
|
245
258
|
if "/RECORD" in path:
|
246
259
|
group = 3
|
@@ -252,7 +265,7 @@ def key(input_: bytes | ZipInfo) -> tuple[int, list[str | list]]:
|
|
252
265
|
|
253
266
|
|
254
267
|
def fix_zip_members(path: Path, umask: int = 0o022) -> Path:
|
255
|
-
"""Apply fixes to members in a zip file
|
268
|
+
"""Apply fixes to members in a zip file.
|
256
269
|
|
257
270
|
Processes the zip file in place. Path is both the source and destination, a
|
258
271
|
temporary working copy is made.
|
@@ -292,7 +305,7 @@ def _is_git_repository(path: Path) -> bool:
|
|
292
305
|
|
293
306
|
try:
|
294
307
|
process = run(
|
295
|
-
|
308
|
+
("git", "rev-parse", "--show-toplevel"),
|
296
309
|
cwd=path,
|
297
310
|
check=True,
|
298
311
|
capture_output=True,
|
@@ -307,6 +320,7 @@ def _is_git_repository(path: Path) -> bool:
|
|
307
320
|
|
308
321
|
|
309
322
|
def parse_args(args: list[str] | None) -> Arguments:
|
323
|
+
"""Parse command line arguments."""
|
310
324
|
parser = ArgumentParser(
|
311
325
|
prog="reproducibly.py",
|
312
326
|
formatter_class=RawDescriptionHelpFormatter,
|
@@ -333,7 +347,7 @@ def parse_args(args: list[str] | None) -> Arguments:
|
|
333
347
|
|
334
348
|
|
335
349
|
def _sortwheel(wheel: Path) -> Path:
|
336
|
-
"""Sort the lines in */RECORD and files in a wheel
|
350
|
+
"""Sort the lines in */RECORD and files in a wheel.
|
337
351
|
|
338
352
|
pypa/wheel has had reproducible builds since 0.27.0 (2016-02-05); this
|
339
353
|
script post processes a wheel file to match the ordering that pypa/wheel
|
@@ -351,7 +365,8 @@ def _sortwheel(wheel: Path) -> Path:
|
|
351
365
|
From observation of pypa/wheel output desired order is below. This can be
|
352
366
|
called breadth first. It is easily created recursively. For a directory,
|
353
367
|
list all the files in order then repeat for all of the subdirectories in
|
354
|
-
order.
|
368
|
+
order.
|
369
|
+
"""
|
355
370
|
with TemporaryDirectory() as directory:
|
356
371
|
intermediate = Path(directory) / wheel.name
|
357
372
|
with ZipFile(wheel, "r") as original, ZipFile(intermediate, "w") as destination:
|
@@ -369,6 +384,7 @@ def _sortwheel(wheel: Path) -> Path:
|
|
369
384
|
|
370
385
|
|
371
386
|
def main(arguments: list[str] | None = None) -> int:
|
387
|
+
"""Reproducibly build Python packages."""
|
372
388
|
parsed = parse_args(arguments)
|
373
389
|
for repository in parsed["repositories"]:
|
374
390
|
sdist = _build(repository, parsed["output"], "sdist")
|