Skip to content

Commit 9e4cb05

Browse files
committed
Initial commit.
0 parents  commit 9e4cb05

File tree

6 files changed

+258
-0
lines changed

6 files changed

+258
-0
lines changed

LICENSE.txt

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
MIT License
2+
3+
Copyright (c) 2024-present D Hobbs <[email protected]>
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6+
7+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8+
9+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

README.md

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# hatch-build-isolated-binary
2+
3+
[`hatch`](https://hatch.pypa.io/latest/) isn't currently able to create a distributable binary that can
4+
bootstrap itself on first run without having a network connection, so I wrote this script
5+
to automate building that sort of binary.
6+
7+
## Requirements
8+
9+
- `hatch` is being used for the project
10+
- the script depends on the python version being set in `pyproject.toml` (from [hatch docs](https://hatch.pypa.io/1.13/plugins/builder/binary/#configuration)), e.g.:
11+
12+
```toml
13+
[tool.hatch.build.targets.binary]
14+
python-version = "3.13"
15+
```
16+
17+
- a network connection is required during the build process
18+
19+
## Instructions
20+
21+
Put the `build_binary.py` script somewhere and run it like:
22+
23+
`hatch run python <path-to>/build_binary.py`
24+
25+
Or the package can be installed and then run via the `build-binary` command.
26+
27+
Here's a breakdown of what the script will do:
28+
29+
- use `hatch` to download a Python distribution into a directory named `<project-name>-<project-version>` within the directory returned by `tempfile.gettempdir()`
30+
- build a wheel of the project
31+
- install the project wheel into the Python dist
32+
- make a bzip archive of the dist named `python.bz2` in the aforementioned temp dir
33+
- use hatch to build a binary with the archived dist embedded in it
34+
35+
## Caveats
36+
37+
I did try to make it platform-agnostic, and it works for me on Windows, but I haven't needed it on Linux or Mac, so I haven't tried them yet.
38+
39+
## TODO
40+
41+
- It can be helpful to keep the temp files (the Python distribution) around for faster rebuilds, but maybe add an easy way to remove them.

pyproject.toml

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
[build-system]
2+
requires = ["hatchling"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "hatch-build-isolated-binary"
7+
dynamic = ["version"]
8+
description = ''
9+
readme = "README.md"
10+
requires-python = ">=3.8"
11+
license = "MIT"
12+
keywords = []
13+
authors = [
14+
{ name = "David Hobbs", email = "[email protected]" },
15+
]
16+
classifiers = [
17+
"Development Status :: 4 - Beta",
18+
"Programming Language :: Python",
19+
"Programming Language :: Python :: 3.8",
20+
"Programming Language :: Python :: 3.9",
21+
"Programming Language :: Python :: 3.10",
22+
"Programming Language :: Python :: 3.11",
23+
"Programming Language :: Python :: 3.12",
24+
"Programming Language :: Python :: 3.13",
25+
"Programming Language :: Python :: Implementation :: CPython",
26+
"Programming Language :: Python :: Implementation :: PyPy",
27+
]
28+
dependencies = []
29+
30+
[project.urls]
31+
Documentation = "https://github.com/hobbsd/hatch-build-isolated-binary#readme"
32+
Issues = "https://github.com/hobbsd/hatch-build-isolated-binary/issues"
33+
Source = "https://github.com/hobbsd/hatch-build-isolated-binary"
34+
35+
[project.scripts]
36+
build-binary = "hatch_build_isolated_binary.build_binary:main"
37+
38+
[tool.hatch.version]
39+
path = "src/hatch_build_isolated_binary/__init__.py"
40+
41+
[tool.hatch.envs.types]
42+
extra-dependencies = [
43+
"mypy>=1.0.0",
44+
]
45+
[tool.hatch.envs.types.scripts]
46+
check = "mypy --install-types --non-interactive {args:src/hatch_build_isolated_binary tests}"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__version__ = "0.9.0"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
if __name__ == "__main__":
2+
from .build_binary import main
3+
main()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""
2+
Use hatch to build a binary that doesn't require any network connection.
3+
4+
Installs a Python distribution to a dir in TMP; makes and installs the project
5+
wheel into that distribution; makes a bzipped archive of the distribution; then
6+
builds the binary with the distribution embedded.
7+
8+
Run this script from the project root dir.
9+
"""
10+
11+
import contextlib
12+
import json
13+
import os
14+
import subprocess
15+
import tarfile
16+
import tempfile
17+
from pathlib import Path
18+
19+
import tomllib
20+
21+
22+
class ProjectSettings:
23+
"""Class for holding dynamically determined build settings."""
24+
25+
def __init__(self) -> None:
26+
data: dict = self.get_pyproject_data()
27+
self.pyproject_data = data
28+
self.project_name: str = data["project"]["name"]
29+
self.python_version: str = data["tool"]["hatch"]["build"]["targets"]["binary"][
30+
"python-version"
31+
]
32+
self.project_version: str = self.get_project_version()
33+
self.python_tmp_dir = Path(
34+
tempfile.gettempdir(), f"{self.project_name}-{self.project_version}"
35+
)
36+
self.python_dist_root_version = Path(self.python_tmp_dir / self.python_version)
37+
# This is the path to the python executable within the distribution archive
38+
self.__python_path_within_archive: Path | None = None
39+
40+
@staticmethod
41+
def get_project_version() -> str:
42+
"""Use hatch to get the project version."""
43+
completed_proc = subprocess.run(["hatch", "version"], capture_output=True)
44+
return completed_proc.stdout.decode().strip()
45+
46+
@staticmethod
47+
def get_pyproject_data() -> dict:
48+
"""Retrieve the pyproject.toml data."""
49+
with open("pyproject.toml", mode="rb") as fp:
50+
data = tomllib.load(fp)
51+
return data
52+
53+
@property
54+
def python_path_within_archive(self) -> Path:
55+
"""Returns the path to the root of the Python dist that we'll bundle."""
56+
if self.__python_path_within_archive is None:
57+
with (self.python_dist_root_version / "hatch-dist.json").open() as fp:
58+
hatch_json = json.load(fp)
59+
self.__python_path_within_archive = hatch_json["python_path"]
60+
return self.__python_path_within_archive
61+
62+
@property
63+
def python_dist_exe(self) -> Path:
64+
"""The full path to the distribution's python executable.
65+
66+
Used for running 'pip install'.
67+
"""
68+
return self.python_dist_root_version / self.python_path_within_archive
69+
70+
71+
def make_project_wheel() -> Path:
72+
"""Return path to the project's wheel build."""
73+
completed_proc = subprocess.run(
74+
["hatch", "build", "-t", "wheel"], capture_output=True
75+
)
76+
return Path(completed_proc.stderr.decode().strip())
77+
78+
79+
def make_dist_archive(python_tmp_dir: Path, dist_path: Path) -> Path:
80+
"""Make and return path to tar-bzipped Python distribution."""
81+
archive = python_tmp_dir / "python.bz2"
82+
with contextlib.chdir(dist_path):
83+
with tarfile.open(archive, mode="w:bz2") as tar:
84+
tar.add(".")
85+
return archive
86+
87+
88+
def hatch_install_python(python_tmp_dir: Path, python_version: str) -> bool:
89+
"""Install Python dist into temp dir for bundling."""
90+
completed_proc = subprocess.run(
91+
[
92+
"hatch",
93+
"python",
94+
"install",
95+
"--private",
96+
"--dir",
97+
python_tmp_dir,
98+
python_version,
99+
]
100+
)
101+
return not completed_proc.returncode
102+
103+
104+
def pip_install_project(python_exe: str, project_whl: Path) -> bool:
105+
"""Install the project into the Python distribution."""
106+
completed_proc = subprocess.run(
107+
[python_exe, "-m", "pip", "install", "-U", str(project_whl)],
108+
capture_output=True,
109+
)
110+
return not completed_proc.returncode
111+
112+
113+
def hatch_build_binary(archive_path: Path, python_path: Path) -> Path | None:
114+
"""Use hatch to build the binary."""
115+
os.environ["PYAPP_SKIP_INSTALL"] = "1"
116+
os.environ["PYAPP_DISTRIBUTION_PATH"] = str(archive_path)
117+
os.environ["PYAPP_FULL_ISOLATION"] = "1"
118+
os.environ["PYAPP_DISTRIBUTION_PYTHON_PATH"] = str(python_path)
119+
completed_proc = subprocess.run(
120+
["hatch", "build", "-t", "binary"], capture_output=True
121+
)
122+
if completed_proc.returncode:
123+
print(completed_proc.stderr)
124+
return None
125+
# The binary location is the last line of stderr
126+
return Path(completed_proc.stderr.decode().split()[-1])
127+
128+
129+
def main():
130+
settings = ProjectSettings()
131+
print("Installing Python distribution to TMP dir...")
132+
hatch_install_python(settings.python_tmp_dir, settings.python_version)
133+
print("-> installed")
134+
135+
print("Building wheel...")
136+
project_wheel = make_project_wheel()
137+
print("->", project_wheel)
138+
139+
print(f"Installing {project_wheel} into Python distribution...")
140+
pip_install_project(str(settings.python_dist_exe), project_wheel)
141+
print("-> installed")
142+
143+
print("Making distribution archive...")
144+
archive_path = make_dist_archive(
145+
settings.python_tmp_dir, settings.python_dist_root_version
146+
)
147+
print("->", archive_path)
148+
149+
print(f"Building '{settings.project_name}' binary...")
150+
binary_location = hatch_build_binary(
151+
archive_path, settings.python_path_within_archive
152+
)
153+
if binary_location:
154+
print("-> binary location:", binary_location)
155+
156+
157+
if __name__ == "__main__":
158+
main()

0 commit comments

Comments
 (0)