reproducibly 0.0.12__py3-none-any.whl → 0.0.15__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.
@@ -1,16 +1,16 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: reproducibly
3
- Version: 0.0.12
3
+ Version: 0.0.15
4
4
  Summary: Reproducibly build Python packages
5
5
  Author-email: Keith Maxwell <keith.maxwell@gmail.com>
6
- Requires-Python: >=3.11
6
+ Requires-Python: >=3.13
7
7
  Description-Content-Type: text/markdown
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
10
- Requires-Dist: build==1.2.1
11
- Requires-Dist: cibuildwheel==2.20.0
12
- Requires-Dist: packaging==24.1
13
- Requires-Dist: pyproject_hooks==1.1.0
10
+ Requires-Dist: build==1.2.2.post1
11
+ Requires-Dist: cibuildwheel==2.23.2
12
+ Requires-Dist: packaging==24.2
13
+ Requires-Dist: pyproject_hooks==1.2.0
14
14
  Project-URL: Homepage, https://github.com/maxwell-k/reproducibly/
15
15
  Project-URL: Issues, https://github.com/maxwell-k/reproducibly/issues
16
16
 
@@ -54,6 +54,7 @@ features:
54
54
  - Builds a source distribution (sdist) from a git repository
55
55
  - Builds a wheel from a sdist
56
56
  - Resets metadata like user and group names and ids to predictable values
57
+ - Uses no compression for predictable file hashes across Linux distributions
57
58
  - By default uses the last commit date and time from git
58
59
  - Respects SOURCE_DATE_EPOCH when building a sdist
59
60
  - Single file script with inline script metadata or PyPI package
@@ -93,5 +94,7 @@ To run unit tests and integration tests:
93
94
  README.md
94
95
  Copyright 2023 Keith Maxwell
95
96
  SPDX-License-Identifier: CC-BY-SA-4.0
97
+
98
+ vim: set filetype=markdown.dprint.cog.htmlCommentNoSpell :
96
99
  -->
97
100
 
@@ -0,0 +1,5 @@
1
+ reproducibly.py,sha256=AgMNoWREhLsRyyXuA9BwMJSI99_i9B_ft4RwwilTobQ,13518
2
+ reproducibly-0.0.15.dist-info/METADATA,sha256=rOEfFbBwOe1F21wBsSSxht4xNYBMd2Hnf3AcO9wvBvI,2995
3
+ reproducibly-0.0.15.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
4
+ reproducibly-0.0.15.dist-info/entry_points.txt,sha256=J4fRzmY7XffHnoo9etzvgeph8np598MPpIy3jpnGlfk,50
5
+ reproducibly-0.0.15.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: flit 3.9.0
2
+ Generator: flit 3.12.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
reproducibly.py CHANGED
@@ -5,6 +5,7 @@ features:
5
5
  - Builds a source distribution (sdist) from a git repository
6
6
  - Builds a wheel from a sdist
7
7
  - Resets metadata like user and group names and ids to predictable values
8
+ - Uses no compression for predictable file hashes across Linux distributions
8
9
  - By default uses the last commit date and time from git
9
10
  - Respects SOURCE_DATE_EPOCH when building a sdist
10
11
  - Single file script with inline script metadata or PyPI package
@@ -14,7 +15,6 @@ features:
14
15
  # Copyright 2024 Keith Maxwell
15
16
  # SPDX-License-Identifier: MPL-2.0
16
17
  import gzip
17
- import tarfile
18
18
  from argparse import ArgumentParser, RawDescriptionHelpFormatter
19
19
  from contextlib import chdir
20
20
  from datetime import datetime
@@ -25,9 +25,10 @@ from shutil import copyfileobj, move
25
25
  from stat import S_IWGRP, S_IWOTH
26
26
  from subprocess import CalledProcessError, run
27
27
  from sys import version_info
28
+ from tarfile import TarFile, TarInfo
28
29
  from tempfile import TemporaryDirectory
29
30
  from typing import cast, Literal, TypedDict
30
- from zipfile import ZipFile, ZipInfo
31
+ from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
31
32
 
32
33
  from build import ProjectBuilder
33
34
  from build.env import DefaultIsolatedEnv
@@ -51,12 +52,12 @@ from pyproject_hooks import default_subprocess_runner
51
52
  # cog.outl("# ///")
52
53
  # ]]]
53
54
  # /// script
54
- # requires-python = ">=3.11"
55
+ # requires-python = ">=3.13"
55
56
  # dependencies = [
56
- # "build==1.2.1",
57
- # "cibuildwheel==2.20.0",
58
- # "packaging==24.1",
59
- # "pyproject_hooks==1.1.0",
57
+ # "build==1.2.2.post1",
58
+ # "cibuildwheel==2.23.2",
59
+ # "packaging==24.2",
60
+ # "pyproject_hooks==1.2.0",
60
61
  # ]
61
62
  # ///
62
63
  # [[[end]]]
@@ -67,11 +68,11 @@ from pyproject_hooks import default_subprocess_runner
67
68
  # - Built distributions are typically zip files
68
69
  # - The default date for this script is the earliest date supported by both
69
70
  # - The minimum date value supported by zip files, is documented in
70
- # <https://github.com/python/cpython/blob/3.11/Lib/zipfile.py>.
71
+ # <https://github.com/python/cpython/blob/3.13/Lib/zipfile.py>.
71
72
  EARLIEST = datetime(1980, 1, 1, 0, 0, 0).timestamp() # 315532800.0
72
73
 
73
74
 
74
- __version__ = "0.0.12"
75
+ __version__ = "0.0.15"
75
76
 
76
77
 
77
78
  def _build(
@@ -93,8 +94,8 @@ def _build(
93
94
 
94
95
 
95
96
  def _extract_to_empty_directory(sdist: Path, directory: str) -> Path:
96
- with tarfile.open(sdist) as t:
97
- t.extractall(directory)
97
+ with TarFile.open(sdist) as t:
98
+ t.extractall(directory, filter="data")
98
99
  return next(Path(directory).iterdir())
99
100
 
100
101
 
@@ -159,13 +160,13 @@ class Builder(Enum):
159
160
  @nonmember
160
161
  @staticmethod
161
162
  def which(archive: Path) -> "Builder":
162
- with tarfile.open(archive, "r:gz") as tar:
163
+ with TarFile.open(archive, "r:gz") as tar:
163
164
  c = any(i.name.endswith(".c") for i in tar.getmembers())
164
165
  return Builder.cibuildwheel if c else Builder.build
165
166
 
166
167
 
167
- def cleanse_metadata(path_: Path, mtime: float) -> int:
168
- """Cleanse metadata from a single source distribution
168
+ def cleanse_sdist(path_: Path, mtime: float) -> int:
169
+ """Cleanse a single source distribution
169
170
 
170
171
  - Set all uids and gids to zero
171
172
  - Set all unames and gnames to root
@@ -173,22 +174,22 @@ def cleanse_metadata(path_: Path, mtime: float) -> int:
173
174
  - Set modified time for .tar inside .gz
174
175
  - Set modified time for files inside the .tar
175
176
  - Remove group and other write permissions for files inside the .tar
177
+ - Set the compression level to zero i.e. no compression
176
178
  """
177
- path = path_.absolute()
179
+ filename = path_.absolute()
178
180
 
179
181
  mtime = max(mtime, EARLIEST)
180
182
 
181
- with TemporaryDirectory() as directory:
182
- with tarfile.open(path) as tar:
183
- tar.extractall(path=directory)
183
+ with TemporaryDirectory() as path:
184
+ with TarFile.open(filename) as tar:
185
+ tar.extractall(path=path, filter="data")
184
186
 
185
- path.unlink(missing_ok=True)
186
- (extracted,) = Path(directory).iterdir()
187
- uncompressed = f"{extracted}.tar"
187
+ filename.unlink(missing_ok=True)
188
+ (extracted,) = Path(path).iterdir()
188
189
 
189
- prefix = directory.removeprefix("/") + "/"
190
+ prefix = path.removeprefix("/") + "/"
190
191
 
191
- def filter_(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo:
192
+ def filter_(tarinfo: TarInfo) -> TarInfo:
192
193
  tarinfo.mtime = int(mtime)
193
194
  tarinfo.uid = tarinfo.gid = 0
194
195
  tarinfo.uname = tarinfo.gname = "root"
@@ -196,19 +197,25 @@ def cleanse_metadata(path_: Path, mtime: float) -> int:
196
197
  tarinfo.path = tarinfo.path.removeprefix(prefix)
197
198
  return tarinfo
198
199
 
199
- with tarfile.open(uncompressed, "w") as tar:
200
- tar.add(extracted, filter=filter_)
201
-
202
- with gzip.GzipFile(filename=path, mode="wb", mtime=mtime) as file:
203
- with open(uncompressed, "rb") as tar:
200
+ tar = f"{extracted}.tar"
201
+ with TarFile.open(tar, "w") as tarfile:
202
+ tarfile.add(extracted, filter=filter_)
203
+
204
+ with gzip.GzipFile(
205
+ filename=filename,
206
+ mode="wb",
207
+ mtime=mtime,
208
+ compresslevel=0,
209
+ ) as file:
210
+ with open(tar, "rb") as tar:
204
211
  copyfileobj(tar, file)
205
- utime(path, (mtime, mtime))
212
+ utime(filename, (mtime, mtime))
206
213
  return 0
207
214
 
208
215
 
209
216
  def latest_modification_time(archive: Path) -> str:
210
217
  """Latest modification time for a gzipped tarfile as a string"""
211
- with tarfile.open(archive, "r:gz") as tar:
218
+ with TarFile.open(archive, "r:gz") as tar:
212
219
  latest = max(member.mtime for member in tar.getmembers())
213
220
  return "{:.0f}".format(latest)
214
221
 
@@ -244,11 +251,24 @@ def key(input_: bytes | ZipInfo) -> tuple[int, list[str | list]]:
244
251
  return (group, breadth_first_key(item))
245
252
 
246
253
 
247
- def zipumask(path: Path, umask: int = 0o022) -> Path:
248
- """Apply a umask to a zip file at path
254
+ def fix_zip_members(path: Path, umask: int = 0o022) -> Path:
255
+ """Apply fixes to members in a zip file
256
+
257
+ Processes the zip file in place. Path is both the source and destination, a
258
+ temporary working copy is made.
259
+
260
+ - Apply a umask to each member
261
+ - Change to compression level zero
249
262
 
250
- Path is both the source and destination, a temporary working copy is
251
- made."""
263
+ When using the default deflate compression and comparing wheels created on
264
+ Ubuntu 24.04 and Fedora 40, minor differences in the size of the compressed
265
+ wheel were observed. For example:
266
+
267
+ │ -112 files, 909030 bytes uncompressed, 272160 bytes compressed: 70.1%
268
+ │ +112 files, 909030 bytes uncompressed, 271653 bytes compressed: 70.1%
269
+
270
+ As a solution this function uses compression level zero i.e. no compression.
271
+ """
252
272
  operand = ~(umask << 16)
253
273
 
254
274
  with TemporaryDirectory() as directory:
@@ -257,6 +277,8 @@ def zipumask(path: Path, umask: int = 0o022) -> Path:
257
277
  for member in original.infolist():
258
278
  data = original.read(member)
259
279
  member.external_attr = member.external_attr & operand
280
+ member.compress_type = ZIP_DEFLATED
281
+ member.compress_level = 0
260
282
  destination.writestr(member, data)
261
283
  path.unlink()
262
284
  move(copy, path) # can't rename as /tmp may be a different device
@@ -354,7 +376,7 @@ def main(arguments: list[str] | None = None) -> int:
354
376
  date = float(environ["SOURCE_DATE_EPOCH"])
355
377
  else:
356
378
  date = latest_commit_time(repository)
357
- cleanse_metadata(sdist, date)
379
+ cleanse_sdist(sdist, date)
358
380
  for sdist in parsed["sdists"]:
359
381
  with ModifiedEnvironment(SOURCE_DATE_EPOCH=latest_modification_time(sdist)):
360
382
  if Builder.which(sdist) == Builder.cibuildwheel:
@@ -363,7 +385,7 @@ def main(arguments: list[str] | None = None) -> int:
363
385
  with TemporaryDirectory() as directory:
364
386
  srcdir = _extract_to_empty_directory(sdist, directory)
365
387
  built = _build(srcdir, parsed["output"], "wheel")
366
- _sortwheel(zipumask(built))
388
+ fix_zip_members(_sortwheel(built))
367
389
  return 0
368
390
 
369
391
 
@@ -1,5 +0,0 @@
1
- reproducibly.py,sha256=tz7vajj6XVutFugNhGm2oTsvxjSHPBlh4iArABMuCmU,12627
2
- reproducibly-0.0.12.dist-info/METADATA,sha256=EriIkwvWD-e0r7lJmaXynD89B-A7L6RWW1KyteJFJoo,2852
3
- reproducibly-0.0.12.dist-info/WHEEL,sha256=EZbGkh7Ie4PoZfRQ8I0ZuP9VklN_TvcZ6DSE5Uar4z4,81
4
- reproducibly-0.0.12.dist-info/entry_points.txt,sha256=J4fRzmY7XffHnoo9etzvgeph8np598MPpIy3jpnGlfk,50
5
- reproducibly-0.0.12.dist-info/RECORD,,