reproducibly 0.0.11__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.11
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,15 +25,15 @@ 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
34
35
  from cibuildwheel.__main__ import build_in_directory
35
36
  from cibuildwheel.options import CommandLineArguments
36
- from packaging.requirements import Requirement
37
37
  from pyproject_hooks import default_subprocess_runner
38
38
 
39
39
  # [[[cog import cog ; from pathlib import Path ]]]
@@ -52,12 +52,12 @@ from pyproject_hooks import default_subprocess_runner
52
52
  # cog.outl("# ///")
53
53
  # ]]]
54
54
  # /// script
55
- # requires-python = ">=3.11"
55
+ # requires-python = ">=3.13"
56
56
  # dependencies = [
57
- # "build==1.2.1",
58
- # "cibuildwheel==2.20.0",
59
- # "packaging==24.1",
60
- # "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",
61
61
  # ]
62
62
  # ///
63
63
  # [[[end]]]
@@ -68,20 +68,11 @@ from pyproject_hooks import default_subprocess_runner
68
68
  # - Built distributions are typically zip files
69
69
  # - The default date for this script is the earliest date supported by both
70
70
  # - The minimum date value supported by zip files, is documented in
71
- # <https://github.com/python/cpython/blob/3.11/Lib/zipfile.py>.
71
+ # <https://github.com/python/cpython/blob/3.13/Lib/zipfile.py>.
72
72
  EARLIEST = datetime(1980, 1, 1, 0, 0, 0).timestamp() # 315532800.0
73
73
 
74
74
 
75
- CONSTRAINTS = {
76
- # [[[cog
77
- # for line in Path("constraints.txt").read_text().splitlines():
78
- # cog.outl(f'"{line}",')
79
- # ]]]
80
- "wheel==0.44.0",
81
- # [[[end]]]
82
- }
83
-
84
- __version__ = "0.0.11"
75
+ __version__ = "0.0.15"
85
76
 
86
77
 
87
78
  def _build(
@@ -96,15 +87,15 @@ def _build(
96
87
  srcdir,
97
88
  runner=default_subprocess_runner,
98
89
  )
99
- env.install(override(builder.build_system_requires))
100
- env.install(override(builder.get_requires_for_build(distribution)))
90
+ env.install(builder.build_system_requires)
91
+ env.install(builder.get_requires_for_build(distribution))
101
92
  built = builder.build(distribution, output)
102
93
  return output / built
103
94
 
104
95
 
105
96
  def _extract_to_empty_directory(sdist: Path, directory: str) -> Path:
106
- with tarfile.open(sdist) as t:
107
- t.extractall(directory)
97
+ with TarFile.open(sdist) as t:
98
+ t.extractall(directory, filter="data")
108
99
  return next(Path(directory).iterdir())
109
100
 
110
101
 
@@ -112,14 +103,12 @@ def _cibuildwheel(sdist: Path, output: Path) -> Path:
112
103
  """Call the cibuildwheel API
113
104
 
114
105
  Returns the path to the built distribution"""
115
- filename = Path("constraints.txt")
116
106
  with (
117
107
  ModifiedEnvironment(
118
- CIBW_DEPENDENCY_VERSIONS=str(filename),
119
108
  CIBW_BUILD_FRONTEND="build",
120
109
  CIBW_CONTAINER_ENGINE="podman",
121
110
  CIBW_ENVIRONMENT_PASS_LINUX="SOURCE_DATE_EPOCH",
122
- CIBW_ENVIRONMENT=f"PIP_TIMEOUT=150 PIP_CONSTRAINT=/{filename}",
111
+ CIBW_ENVIRONMENT="PIP_TIMEOUT=150",
123
112
  ),
124
113
  TemporaryDirectory() as directory,
125
114
  ):
@@ -129,7 +118,6 @@ def _cibuildwheel(sdist: Path, output: Path) -> Path:
129
118
  args.output_dir = Path(directory).resolve()
130
119
  args.platform = None
131
120
  with chdir(directory): # output maybe a relative path
132
- filename.write_text("\n".join(CONSTRAINTS) + "\n")
133
121
  build_in_directory(args)
134
122
  wheel = next(args.output_dir.glob("*.whl"))
135
123
  output.joinpath(wheel.name).unlink(missing_ok=True)
@@ -172,13 +160,13 @@ class Builder(Enum):
172
160
  @nonmember
173
161
  @staticmethod
174
162
  def which(archive: Path) -> "Builder":
175
- with tarfile.open(archive, "r:gz") as tar:
163
+ with TarFile.open(archive, "r:gz") as tar:
176
164
  c = any(i.name.endswith(".c") for i in tar.getmembers())
177
165
  return Builder.cibuildwheel if c else Builder.build
178
166
 
179
167
 
180
- def cleanse_metadata(path_: Path, mtime: float) -> int:
181
- """Cleanse metadata from a single source distribution
168
+ def cleanse_sdist(path_: Path, mtime: float) -> int:
169
+ """Cleanse a single source distribution
182
170
 
183
171
  - Set all uids and gids to zero
184
172
  - Set all unames and gnames to root
@@ -186,22 +174,22 @@ def cleanse_metadata(path_: Path, mtime: float) -> int:
186
174
  - Set modified time for .tar inside .gz
187
175
  - Set modified time for files inside the .tar
188
176
  - Remove group and other write permissions for files inside the .tar
177
+ - Set the compression level to zero i.e. no compression
189
178
  """
190
- path = path_.absolute()
179
+ filename = path_.absolute()
191
180
 
192
181
  mtime = max(mtime, EARLIEST)
193
182
 
194
- with TemporaryDirectory() as directory:
195
- with tarfile.open(path) as tar:
196
- tar.extractall(path=directory)
183
+ with TemporaryDirectory() as path:
184
+ with TarFile.open(filename) as tar:
185
+ tar.extractall(path=path, filter="data")
197
186
 
198
- path.unlink(missing_ok=True)
199
- (extracted,) = Path(directory).iterdir()
200
- uncompressed = f"{extracted}.tar"
187
+ filename.unlink(missing_ok=True)
188
+ (extracted,) = Path(path).iterdir()
201
189
 
202
- prefix = directory.removeprefix("/") + "/"
190
+ prefix = path.removeprefix("/") + "/"
203
191
 
204
- def filter_(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo:
192
+ def filter_(tarinfo: TarInfo) -> TarInfo:
205
193
  tarinfo.mtime = int(mtime)
206
194
  tarinfo.uid = tarinfo.gid = 0
207
195
  tarinfo.uname = tarinfo.gname = "root"
@@ -209,19 +197,25 @@ def cleanse_metadata(path_: Path, mtime: float) -> int:
209
197
  tarinfo.path = tarinfo.path.removeprefix(prefix)
210
198
  return tarinfo
211
199
 
212
- with tarfile.open(uncompressed, "w") as tar:
213
- tar.add(extracted, filter=filter_)
214
-
215
- with gzip.GzipFile(filename=path, mode="wb", mtime=mtime) as file:
216
- 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:
217
211
  copyfileobj(tar, file)
218
- utime(path, (mtime, mtime))
212
+ utime(filename, (mtime, mtime))
219
213
  return 0
220
214
 
221
215
 
222
216
  def latest_modification_time(archive: Path) -> str:
223
217
  """Latest modification time for a gzipped tarfile as a string"""
224
- with tarfile.open(archive, "r:gz") as tar:
218
+ with TarFile.open(archive, "r:gz") as tar:
225
219
  latest = max(member.mtime for member in tar.getmembers())
226
220
  return "{:.0f}".format(latest)
227
221
 
@@ -257,21 +251,24 @@ def key(input_: bytes | ZipInfo) -> tuple[int, list[str | list]]:
257
251
  return (group, breadth_first_key(item))
258
252
 
259
253
 
260
- def override(before: set[str], constraints: set[str] = CONSTRAINTS) -> set[str]:
261
- """Replace certain requirements from constraints"""
262
- after = set()
263
- for replacement in constraints:
264
- name = Requirement(replacement).name
265
- for i in before:
266
- after.add(replacement if Requirement(i).name == name else i)
267
- return after
254
+ def fix_zip_members(path: Path, umask: int = 0o022) -> Path:
255
+ """Apply fixes to members in a zip file
268
256
 
257
+ Processes the zip file in place. Path is both the source and destination, a
258
+ temporary working copy is made.
269
259
 
270
- def zipumask(path: Path, umask: int = 0o022) -> Path:
271
- """Apply a umask to a zip file at path
260
+ - Apply a umask to each member
261
+ - Change to compression level zero
272
262
 
273
- Path is both the source and destination, a temporary working copy is
274
- 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
+ """
275
272
  operand = ~(umask << 16)
276
273
 
277
274
  with TemporaryDirectory() as directory:
@@ -280,6 +277,8 @@ def zipumask(path: Path, umask: int = 0o022) -> Path:
280
277
  for member in original.infolist():
281
278
  data = original.read(member)
282
279
  member.external_attr = member.external_attr & operand
280
+ member.compress_type = ZIP_DEFLATED
281
+ member.compress_level = 0
283
282
  destination.writestr(member, data)
284
283
  path.unlink()
285
284
  move(copy, path) # can't rename as /tmp may be a different device
@@ -377,7 +376,7 @@ def main(arguments: list[str] | None = None) -> int:
377
376
  date = float(environ["SOURCE_DATE_EPOCH"])
378
377
  else:
379
378
  date = latest_commit_time(repository)
380
- cleanse_metadata(sdist, date)
379
+ cleanse_sdist(sdist, date)
381
380
  for sdist in parsed["sdists"]:
382
381
  with ModifiedEnvironment(SOURCE_DATE_EPOCH=latest_modification_time(sdist)):
383
382
  if Builder.which(sdist) == Builder.cibuildwheel:
@@ -386,7 +385,7 @@ def main(arguments: list[str] | None = None) -> int:
386
385
  with TemporaryDirectory() as directory:
387
386
  srcdir = _extract_to_empty_directory(sdist, directory)
388
387
  built = _build(srcdir, parsed["output"], "wheel")
389
- _sortwheel(zipumask(built))
388
+ fix_zip_members(_sortwheel(built))
390
389
  return 0
391
390
 
392
391
 
@@ -1,5 +0,0 @@
1
- reproducibly.py,sha256=0HzRBIu4mk68d9cl3IB3tXsjOLfKySOtckG43wF4DHU,13407
2
- reproducibly-0.0.11.dist-info/METADATA,sha256=TsvqUUQUMC_nEy4lZr48izwic1aV2ckp061JN8NP9Io,2852
3
- reproducibly-0.0.11.dist-info/WHEEL,sha256=EZbGkh7Ie4PoZfRQ8I0ZuP9VklN_TvcZ6DSE5Uar4z4,81
4
- reproducibly-0.0.11.dist-info/entry_points.txt,sha256=J4fRzmY7XffHnoo9etzvgeph8np598MPpIy3jpnGlfk,50
5
- reproducibly-0.0.11.dist-info/RECORD,,