Skip to content

Commit 576011c

Browse files
feat: add PyPI distribution via platform-specific wheels
Adds python/ directory with wheel-building infrastructure for `pip install shelfctl`. Platform-specific wheels contain the pre-built Go binary with a thin Python entry point that exec's it. Supports: macOS (arm64, x86_64), Linux (arm64, x86_64), Windows (arm64, x86_64). Requires PYPI_API_TOKEN secret to publish on release.
1 parent 45a3724 commit 576011c

4 files changed

Lines changed: 315 additions & 0 deletions

File tree

.github/workflows/release.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,42 @@ jobs:
4545
max-versions-to-keep: 5
4646
fork-user: blackwell-systems
4747
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
48+
49+
pypi:
50+
name: Publish to PyPI
51+
runs-on: ubuntu-latest
52+
needs: release
53+
steps:
54+
- name: Check out code
55+
uses: actions/checkout@v4
56+
57+
- name: Set up Python
58+
uses: actions/setup-python@v5
59+
with:
60+
python-version: '3.12'
61+
62+
- name: Download release archives
63+
env:
64+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
65+
run: |
66+
VERSION="${GITHUB_REF_NAME#v}"
67+
mkdir -p dist/archives
68+
for suffix in Darwin_x86_64.tar.gz Darwin_arm64.tar.gz Linux_x86_64.tar.gz Linux_arm64.tar.gz Windows_x86_64.zip Windows_arm64.zip; do
69+
gh release download "${GITHUB_REF_NAME}" \
70+
--pattern "shelfctl_${VERSION}_${suffix}" \
71+
--dir dist/archives
72+
done
73+
74+
- name: Build platform wheels
75+
run: |
76+
VERSION="${GITHUB_REF_NAME#v}"
77+
python python/_build_wheels.py \
78+
--version "$VERSION" \
79+
--archives-dir dist/archives \
80+
--output-dir dist/wheels
81+
82+
- name: Publish to PyPI
83+
uses: pypa/gh-action-pypi-publish@release/v1
84+
with:
85+
password: ${{ secrets.PYPI_API_TOKEN }}
86+
packages-dir: dist/wheels/

python/_build_wheels.py

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
#!/usr/bin/env python3
2+
"""Build platform-specific wheels for shelfctl from GoReleaser archives.
3+
4+
Usage:
5+
python _build_wheels.py --version 0.4.11 --archives-dir dist/archives --output-dir dist/wheels
6+
"""
7+
8+
import argparse
9+
import hashlib
10+
import os
11+
import stat
12+
import tarfile
13+
import zipfile
14+
from pathlib import Path
15+
16+
PACKAGE_NAME = "shelfctl"
17+
18+
# Maps GoReleaser archive naming to wheel platform tags.
19+
# GoReleaser produces: shelfctl_VERSION_OS_ARCH.{tar.gz,zip}
20+
PLATFORM_MAP = {
21+
"Darwin_x86_64": {
22+
"wheel_tag": "macosx_11_0_x86_64",
23+
"ext": ".tar.gz",
24+
"binary": "shelfctl",
25+
},
26+
"Darwin_arm64": {
27+
"wheel_tag": "macosx_11_0_arm64",
28+
"ext": ".tar.gz",
29+
"binary": "shelfctl",
30+
},
31+
"Linux_x86_64": {
32+
"wheel_tag": "manylinux_2_17_x86_64.manylinux2014_x86_64",
33+
"ext": ".tar.gz",
34+
"binary": "shelfctl",
35+
},
36+
"Linux_arm64": {
37+
"wheel_tag": "manylinux_2_17_aarch64.manylinux2014_aarch64",
38+
"ext": ".tar.gz",
39+
"binary": "shelfctl",
40+
},
41+
"Windows_x86_64": {
42+
"wheel_tag": "win_amd64",
43+
"ext": ".zip",
44+
"binary": "shelfctl.exe",
45+
},
46+
"Windows_arm64": {
47+
"wheel_tag": "win_arm64",
48+
"ext": ".zip",
49+
"binary": "shelfctl.exe",
50+
},
51+
}
52+
53+
INIT_PY = '''\
54+
"""shelfctl - Personal library manager for PDFs using GitHub Release assets."""
55+
56+
__version__ = "{version}"
57+
'''
58+
59+
MAIN_PY = Path(__file__).parent / "shelfctl" / "__main__.py"
60+
61+
METADATA_TEMPLATE = """\
62+
Metadata-Version: 2.1
63+
Name: {name}
64+
Version: {version}
65+
Summary: Personal library manager for PDFs using GitHub Release assets
66+
Home-page: https://github.com/blackwell-systems/shelfctl
67+
Author: Dayna Blackwell
68+
License: MIT
69+
Classifier: License :: OSI Approved :: MIT License
70+
Classifier: Operating System :: MacOS
71+
Classifier: Operating System :: POSIX :: Linux
72+
Classifier: Operating System :: Microsoft :: Windows
73+
Classifier: Environment :: Console
74+
Classifier: Topic :: Utilities
75+
Requires-Python: >=3.8
76+
Description-Content-Type: text/markdown
77+
78+
# shelfctl
79+
80+
CLI tool for organizing PDF and book libraries using GitHub Release assets.
81+
82+
Install: `pip install shelfctl`
83+
84+
Usage: `shelfctl --help`
85+
86+
Full documentation: https://github.com/blackwell-systems/shelfctl
87+
"""
88+
89+
ENTRY_POINTS = """\
90+
[console_scripts]
91+
shelfctl = shelfctl.__main__:main
92+
"""
93+
94+
95+
def sha256_digest(data: bytes) -> str:
96+
return hashlib.sha256(data).hexdigest()
97+
98+
99+
def record_hash(data: bytes) -> str:
100+
"""Return the hash string for RECORD: sha256=<urlsafe-base64-no-padding>."""
101+
import base64
102+
103+
digest = hashlib.sha256(data).digest()
104+
return "sha256=" + base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
105+
106+
107+
def extract_binary(archive_path: str, binary_name: str) -> bytes:
108+
"""Extract the binary from a GoReleaser archive."""
109+
if archive_path.endswith(".tar.gz"):
110+
with tarfile.open(archive_path, "r:gz") as tf:
111+
for member in tf.getmembers():
112+
if os.path.basename(member.name) == binary_name:
113+
f = tf.extractfile(member)
114+
if f is None:
115+
raise ValueError(f"Cannot read {member.name} from {archive_path}")
116+
return f.read()
117+
elif archive_path.endswith(".zip"):
118+
with zipfile.ZipFile(archive_path, "r") as zf:
119+
for name in zf.namelist():
120+
if os.path.basename(name) == binary_name:
121+
return zf.read(name)
122+
123+
raise FileNotFoundError(f"Binary {binary_name} not found in {archive_path}")
124+
125+
126+
def build_wheel(
127+
version: str,
128+
platform_key: str,
129+
platform_info: dict,
130+
archives_dir: str,
131+
output_dir: str,
132+
) -> str:
133+
"""Build a single platform wheel. Returns the output path."""
134+
wheel_tag = platform_info["wheel_tag"]
135+
binary_name = platform_info["binary"]
136+
ext = platform_info["ext"]
137+
138+
# Find the archive
139+
archive_name = f"{PACKAGE_NAME}_{version}_{platform_key}{ext}"
140+
archive_path = os.path.join(archives_dir, archive_name)
141+
if not os.path.exists(archive_path):
142+
raise FileNotFoundError(f"Archive not found: {archive_path}")
143+
144+
# Extract binary
145+
binary_data = extract_binary(archive_path, binary_name)
146+
147+
# Prepare wheel contents
148+
dist_info_dir = f"{PACKAGE_NAME}-{version}.dist-info"
149+
pkg_dir = PACKAGE_NAME
150+
151+
# File contents
152+
init_content = INIT_PY.format(version=version).encode()
153+
main_content = MAIN_PY.read_bytes()
154+
metadata_content = METADATA_TEMPLATE.format(name=PACKAGE_NAME, version=version).encode()
155+
wheel_content = (
156+
f"Wheel-Version: 1.0\n"
157+
f"Generator: shelfctl-build\n"
158+
f"Root-Is-Purelib: false\n"
159+
f"Tag: py3-none-{wheel_tag}\n"
160+
).encode()
161+
entry_points_content = ENTRY_POINTS.encode()
162+
163+
# Build RECORD
164+
files = [
165+
(f"{pkg_dir}/__init__.py", init_content),
166+
(f"{pkg_dir}/__main__.py", main_content),
167+
(f"{pkg_dir}/{binary_name}", binary_data),
168+
(f"{dist_info_dir}/METADATA", metadata_content),
169+
(f"{dist_info_dir}/WHEEL", wheel_content),
170+
(f"{dist_info_dir}/entry_points.txt", entry_points_content),
171+
]
172+
173+
record_lines = []
174+
for path, data in files:
175+
record_lines.append(f"{path},{record_hash(data)},{len(data)}")
176+
record_lines.append(f"{dist_info_dir}/RECORD,,")
177+
record_content = "\n".join(record_lines).encode()
178+
179+
# Build the wheel zip
180+
wheel_filename = f"{PACKAGE_NAME}-{version}-py3-none-{wheel_tag}.whl"
181+
wheel_path = os.path.join(output_dir, wheel_filename)
182+
183+
with zipfile.ZipFile(wheel_path, "w", compression=zipfile.ZIP_DEFLATED) as whl:
184+
for path, data in files:
185+
info = zipfile.ZipInfo(path)
186+
info.compress_type = zipfile.ZIP_DEFLATED
187+
188+
# Set executable permission for the binary
189+
if path == f"{pkg_dir}/{binary_name}":
190+
info.external_attr = (stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) << 16
191+
else:
192+
info.external_attr = (stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) << 16
193+
194+
whl.writestr(info, data)
195+
196+
# Write RECORD last
197+
record_info = zipfile.ZipInfo(f"{dist_info_dir}/RECORD")
198+
record_info.compress_type = zipfile.ZIP_DEFLATED
199+
record_info.external_attr = (stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) << 16
200+
whl.writestr(record_info, record_content)
201+
202+
return wheel_path
203+
204+
205+
def main():
206+
parser = argparse.ArgumentParser(description="Build platform wheels for shelfctl")
207+
parser.add_argument("--version", required=True, help="Version string (e.g. 0.4.11)")
208+
parser.add_argument("--archives-dir", required=True, help="Directory containing GoReleaser archives")
209+
parser.add_argument("--output-dir", required=True, help="Output directory for .whl files")
210+
args = parser.parse_args()
211+
212+
os.makedirs(args.output_dir, exist_ok=True)
213+
214+
built = []
215+
for platform_key, platform_info in PLATFORM_MAP.items():
216+
try:
217+
path = build_wheel(
218+
version=args.version,
219+
platform_key=platform_key,
220+
platform_info=platform_info,
221+
archives_dir=args.archives_dir,
222+
output_dir=args.output_dir,
223+
)
224+
built.append(path)
225+
print(f" Built: {os.path.basename(path)}")
226+
except FileNotFoundError as e:
227+
print(f" Skip: {platform_key} ({e})")
228+
229+
print(f"\n{len(built)} wheel(s) built in {args.output_dir}/")
230+
231+
232+
if __name__ == "__main__":
233+
main()

python/shelfctl/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""shelfctl - Personal library manager for PDFs using GitHub Release assets."""
2+
3+
__version__ = "0.0.0" # Replaced at build time

python/shelfctl/__main__.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Thin entry point that exec's the shelfctl binary."""
2+
3+
import os
4+
import subprocess
5+
import sys
6+
7+
8+
def _find_binary() -> str:
9+
"""Locate the shelfctl binary bundled in this package."""
10+
package_dir = os.path.dirname(os.path.abspath(__file__))
11+
12+
if sys.platform == "win32":
13+
binary_name = "shelfctl.exe"
14+
else:
15+
binary_name = "shelfctl"
16+
17+
candidate = os.path.join(package_dir, binary_name)
18+
if os.path.isfile(candidate):
19+
# Ensure the binary is executable (pip may not preserve permissions)
20+
if sys.platform != "win32" and not os.access(candidate, os.X_OK):
21+
os.chmod(candidate, 0o755)
22+
return candidate
23+
24+
raise FileNotFoundError(
25+
f"Could not find the shelfctl binary. Searched: {candidate}"
26+
)
27+
28+
29+
def main() -> None:
30+
binary = _find_binary()
31+
32+
if sys.platform == "win32":
33+
completed = subprocess.run([binary] + sys.argv[1:])
34+
sys.exit(completed.returncode)
35+
else:
36+
os.execvp(binary, [binary] + sys.argv[1:])
37+
38+
39+
if __name__ == "__main__":
40+
main()

0 commit comments

Comments
 (0)