Skip to content

Commit

Permalink
feat: add a build:wheel task to Taskfile.yml
Browse files Browse the repository at this point in the history
Adds a `build` sub-target called `build:wheel`, that will assemble
Python wheels for all artifacts built under dist/, according to the
PEP 491 specification.

The script does the absolute bare minimum to generate a package
that will allow `pip install` and `pip uninstall` to place the gilt
binary under bin/, using packages that should be entirely available
via the Python stdlib.  i.e., a minimalist Python runtime should
be able to do the work, with no venv or anything required.

It does not make sdists.

It does not upload to PyPI.

Drive-by: change the name of the Goreleaser `name_template` so that
snapshot builds will make/use version numbers that are compatible
with wheel versions.  Mostly.  Linux still gets grumpy about them,
but installs them anyways.

Fixes: Issue #127
  • Loading branch information
nisimond committed Jan 23, 2024
1 parent 97e88dd commit 8494ba6
Show file tree
Hide file tree
Showing 3 changed files with 152 additions and 1 deletion.
2 changes: 1 addition & 1 deletion .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ archives:
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ incpatch .Version }}-next"
name_template: "{{ incpatch .Version }}-0next"
changelog:
sort: asc
filters:
Expand Down
6 changes: 6 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ tasks:
cmds:
- goreleaser release --snapshot --clean

build:wheel:
desc: Build ARCH Python wheel.
cmds:
- task: build
- scripts/dist2wheel.py

mockgen:
desc: Generate mock for interface
cmds:
Expand Down
145 changes: 145 additions & 0 deletions scripts/dist2wheel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
#!/usr/bin/env python3

# Assemble Python wheels for all artifacts built under dist/, according to the
# PEP 491 specification. Does the absolute bare minimum to get
# `pip install` and `pip uninstall` to place the gilt binary under bin/.
#
# Extraordinarily opinionated about where things live, and not
# expected to be usable by anything other than Gilt.

# These packages should be included in the Python stdlib. Nothing
# other than a basic Python interpreter should be needed.
import base64
import hashlib
import json
import os
import zipfile


# The METADATA file can likely be made even smaller, but explicit
# is better than implicit. ;-)
WHEEL_TEMPLATE = """Wheel-Version: 1.0
Generator: dist2wheel.py
Root-Is-Purelib: false
Tag: {py_tag}-{abi_tag}-{platform_tag}
"""
METADATA_TEMPLATE = """Metadata-Version: 2.1
Name: {distribution}
Version: {version}
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Go
Summary: {description}
License: {license}
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
{readme}
"""


class Wheels:
def __init__(self):
# Pull in all the project metadata from known locations.
# No this isn't customizable. Cope.
with open("dist/metadata.json") as f:
self.metadata = json.load(f)

with open("dist/artifacts.json") as f:
self.artifacts = json.load(f)

with open("README.md") as f:
self.readme = f.read()

self.distribution = self.metadata["project_name"]
self.version = self.metadata["version"]
self.py_tag = "py3"
self.abi_tag = "none"

def create_all(self):
"""Generate a Python wheel for every artifact in artifacts.json."""

# Burn a pass through the list to steal some useful bits from the Brew config
for artifact in self.artifacts:
if "BrewConfig" in artifact["extra"]:
self.description = artifact["extra"]["BrewConfig"]["description"]
self.license = artifact["extra"]["BrewConfig"]["license"]

# We're looking for "internal_type: 2" artifacts, but being an internal
# type, we'll avoid leaning on implementation details if we don't have to
for artifact in self.artifacts:
try:
self.path = artifact["path"]
self.platform_tag = self._fixup_platform_tag(artifact)
self.checksum = self._fix_checksum(artifact["extra"]["Checksum"])
self.size = str(os.path.getsize(self.path))
except KeyError:
continue

self.wheel_file = WHEEL_TEMPLATE.format(**self.__dict__).encode()
self.metadata_file = METADATA_TEMPLATE.format(**self.__dict__).encode()
self._emit()

@staticmethod
def _fixup_platform_tag(artifact):
"""Convert Go binary nomenclature to Python wheel nomenclature."""

# Go 1.21 will require macOS 10.15 or later
_map = dict(darwin="macosx_10_15", linux="manylinux2014")
platform = _map[artifact["goos"]]

arch = artifact["goarch"]
if arch == "arm64" and platform == "manylinux2014":
arch = "aarch64"
elif arch == "amd64":
arch = "x86_64"
elif arch == "386":
arch = "i686"
goos = artifact["goos"]

return f"{platform}_{arch}"

@staticmethod
def _fix_checksum(checksum):
"""Re-encode the checksum as base64, with no trailing = characters."""

if checksum.startswith("sha256:"):
checksum = checksum[7:]
return base64.urlsafe_b64encode(bytes.fromhex(checksum)).decode().rstrip("=")

def _emit(self):
name_ver = f"{self.distribution}-{self.version}"
filename = "dist/{}-{}-{}-{}.whl".format(
name_ver, self.py_tag, self.abi_tag, self.platform_tag
)
with zipfile.ZipFile(filename, "w", compression=zipfile.ZIP_DEFLATED) as zip:
record = []
print(f"writing {zip.filename}")

# The actual binary on-disk, and the recorded checksum from artifacts.json
arcname = f"{name_ver}.data/scripts/{os.path.basename(self.path)}"
zip.write(self.path, arcname=arcname)
record.append((arcname, f"sha256={self.checksum}", self.size))

# The project metadata
arcname = f"{name_ver}.dist-info/METADATA"
zip.writestr(arcname, self.metadata_file)
digest = hashlib.sha256(self.metadata_file).hexdigest()
record.append((arcname, f"sha256={self._fix_checksum(digest)}", str(len(self.metadata_file))))

# The platform tags
arcname = f"{name_ver}.dist-info/WHEEL"
zip.writestr(arcname, self.wheel_file)
digest = hashlib.sha256(self.wheel_file).hexdigest()
record.append((arcname, f"sha256={self._fix_checksum(digest)}", str(len(self.wheel_file))))

# Write out the manifest last. The record of itself contains no checksum or size info
arcname = f"{name_ver}.dist-info/RECORD"
zip.writestr(arcname, ("\n".join(",".join(r) for r in record) + f"\n{arcname},,"))


if __name__ == "__main__":
Wheels().create_all()

0 comments on commit 8494ba6

Please sign in to comment.