pyxbuild 0.1.0__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.
- pyxbuild/__init__.py +3 -0
- pyxbuild/__main__.py +5 -0
- pyxbuild/_boot/_pyxbuild_boot.py +169 -0
- pyxbuild/_boot/_pyxbuild_patch.py +211 -0
- pyxbuild/_boot/_pyxbuild_streams.py +64 -0
- pyxbuild/_boot/posix_script_head.txt +3 -0
- pyxbuild/_cross_hook/sitecustomize.py +152 -0
- pyxbuild/_stubs/launcher-console-linux_aarch64 +0 -0
- pyxbuild/_stubs/launcher-console-linux_x86_64 +0 -0
- pyxbuild/_stubs/launcher-console-win_amd64.exe +0 -0
- pyxbuild/_stubs/launcher-gui-win_amd64.exe +0 -0
- pyxbuild/_stubs/launcher-onefile-console-linux_aarch64 +0 -0
- pyxbuild/_stubs/launcher-onefile-console-linux_x86_64 +0 -0
- pyxbuild/_stubs/launcher-onefile-console-win_amd64.exe +0 -0
- pyxbuild/_stubs/launcher-onefile-gui-win_amd64.exe +0 -0
- pyxbuild/builder.py +793 -0
- pyxbuild/cli.py +185 -0
- pyxbuild/config.py +396 -0
- pyxbuild/crossbuild.py +181 -0
- pyxbuild/doctor.py +282 -0
- pyxbuild/download.py +180 -0
- pyxbuild/errors.py +27 -0
- pyxbuild/fallbacks.py +77 -0
- pyxbuild/imports.py +225 -0
- pyxbuild/install.py +473 -0
- pyxbuild/launcher.py +172 -0
- pyxbuild/local.py +192 -0
- pyxbuild/lock.py +443 -0
- pyxbuild/onefile.py +179 -0
- pyxbuild/patch.py +203 -0
- pyxbuild/pe.py +133 -0
- pyxbuild/project.py +74 -0
- pyxbuild/purewheel.py +125 -0
- pyxbuild/resolve.py +309 -0
- pyxbuild/resources.py +304 -0
- pyxbuild/runtime.py +120 -0
- pyxbuild/runtimes.json +74 -0
- pyxbuild/sign.py +84 -0
- pyxbuild/targets.py +200 -0
- pyxbuild-0.1.0.dist-info/METADATA +344 -0
- pyxbuild-0.1.0.dist-info/RECORD +44 -0
- pyxbuild-0.1.0.dist-info/WHEEL +4 -0
- pyxbuild-0.1.0.dist-info/entry_points.txt +2 -0
- pyxbuild-0.1.0.dist-info/licenses/LICENSE +201 -0
pyxbuild/__init__.py
ADDED
pyxbuild/__main__.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Start-up shim of applications built by pyxbuild (copied to runtime/Lib).
|
|
2
|
+
|
|
3
|
+
The launcher runs ``python -I -c "import _pyxbuild_boot;_pyxbuild_boot.main('<script>')"``.
|
|
4
|
+
This module reads ``runtime/pyxbuild.json`` and prepares the process before
|
|
5
|
+
calling the entry point:
|
|
6
|
+
|
|
7
|
+
* removes environment variables that would point the app at another
|
|
8
|
+
installation (``clean-env``, e.g. TCL_LIBRARY, QT_PLUGIN_PATH);
|
|
9
|
+
* sets variables from ``[tool.pyxbuild.env]`` (``{app_dir}``, ``{exe_dir}``, ``{data_dir}``
|
|
10
|
+
and ``{localappdata}`` are expanded);
|
|
11
|
+
* for GUI scripts: gives ``print``/``logging`` a log file instead of
|
|
12
|
+
``sys.stdout = None``, shows unhandled exceptions in a message box instead
|
|
13
|
+
of exiting silently, sets the AppUserModelID and per-monitor DPI awareness;
|
|
14
|
+
* optionally lets ``ssl`` use the Windows certificate store (``truststore``).
|
|
15
|
+
|
|
16
|
+
Only the standard library is used; failures here never prevent the app from
|
|
17
|
+
starting.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import importlib
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
_LOG_LIMIT = 1 << 20
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _config():
|
|
29
|
+
with open(os.path.join(sys.prefix, "pyxbuild.json"), encoding="utf-8") as f:
|
|
30
|
+
return json.load(f)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _user_data_home():
|
|
34
|
+
"""Per-user data folder when LOCALAPPDATA is not set: the home folder on
|
|
35
|
+
Windows (as before), $XDG_DATA_HOME or ~/.local/share elsewhere."""
|
|
36
|
+
if os.name == "nt":
|
|
37
|
+
return os.path.expanduser("~")
|
|
38
|
+
xdg = os.environ.get("XDG_DATA_HOME", "")
|
|
39
|
+
return xdg if os.path.isabs(xdg) else os.path.expanduser("~/.local/share")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _expand(value, names):
|
|
43
|
+
for key, path in names.items():
|
|
44
|
+
value = value.replace("{" + key + "}", path)
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _gui_streams(log_path):
|
|
49
|
+
"""pythonw.exe has no console: sys.stdout/stderr are None and print() fails.
|
|
50
|
+
|
|
51
|
+
``_pyxbuild_streams`` (run from a .pth file before this) may already have
|
|
52
|
+
put a devnull placeholder there; it is replaced by the log, and child
|
|
53
|
+
processes find the log through PYXBUILD_GUI_LOG.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
from _pyxbuild_streams import PLACEHOLDER, open_append
|
|
57
|
+
except ImportError:
|
|
58
|
+
PLACEHOLDER = None
|
|
59
|
+
|
|
60
|
+
def open_append(path):
|
|
61
|
+
return open(path, "a", encoding="utf-8", buffering=1, # noqa: SIM115
|
|
62
|
+
errors="backslashreplace")
|
|
63
|
+
missing = [s for s in ("stdout", "stderr")
|
|
64
|
+
if getattr(sys, s) is None or getattr(sys, s) is PLACEHOLDER]
|
|
65
|
+
if not missing:
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
|
69
|
+
if os.path.exists(log_path) and os.path.getsize(log_path) > _LOG_LIMIT:
|
|
70
|
+
os.replace(log_path, log_path + ".1")
|
|
71
|
+
stream = open_append(log_path) # shared with child processes
|
|
72
|
+
except OSError:
|
|
73
|
+
stream = open(os.devnull, "w", encoding="utf-8") # noqa: SIM115
|
|
74
|
+
log_path = None # do not point the user at a file that was not written
|
|
75
|
+
for name in missing:
|
|
76
|
+
setattr(sys, name, stream)
|
|
77
|
+
if log_path:
|
|
78
|
+
os.environ["PYXBUILD_GUI_LOG"] = log_path # for child processes
|
|
79
|
+
return log_path
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _message_box(title, text):
|
|
83
|
+
if os.environ.get("PYXBUILD_BOOT_NO_DIALOG"):
|
|
84
|
+
return
|
|
85
|
+
try:
|
|
86
|
+
import ctypes
|
|
87
|
+
|
|
88
|
+
ctypes.windll.user32.MessageBoxW(None, text, title, 0x10) # MB_ICONERROR
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _gui_excepthook(app_name, log_path):
|
|
94
|
+
import traceback
|
|
95
|
+
|
|
96
|
+
def hook(exc_type, exc, tb):
|
|
97
|
+
text = "".join(traceback.format_exception(exc_type, exc, tb))
|
|
98
|
+
try:
|
|
99
|
+
sys.stderr.write(text)
|
|
100
|
+
sys.stderr.flush()
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
lines = text.strip().splitlines()[-12:]
|
|
104
|
+
where = f"\n\nDetails were written to:\n{log_path}" if log_path else ""
|
|
105
|
+
_message_box(f"{app_name} error", "\n".join(lines) + where)
|
|
106
|
+
|
|
107
|
+
sys.excepthook = hook
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _gui_process(app_id, dpi_aware):
|
|
111
|
+
try:
|
|
112
|
+
import ctypes
|
|
113
|
+
|
|
114
|
+
if app_id:
|
|
115
|
+
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
|
|
116
|
+
if dpi_aware:
|
|
117
|
+
# DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2; pythonw.exe declares
|
|
118
|
+
# none, so the UI would be bitmap-scaled (blurry) on high-DPI screens
|
|
119
|
+
ctypes.windll.user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4))
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def main(script):
|
|
125
|
+
cfg = _config()
|
|
126
|
+
launcher = cfg["launchers"][script]
|
|
127
|
+
boot = cfg.get("boot", {})
|
|
128
|
+
# the launcher passes its own path first; it becomes sys.argv[0]
|
|
129
|
+
sys.argv[0:2] = sys.argv[1:2]
|
|
130
|
+
# the bundle's root (the folder holding runtime\): the launcher's folder,
|
|
131
|
+
# or the extracted copy of a onefile executable; exe_dir is where the
|
|
132
|
+
# .exe the user started lives
|
|
133
|
+
app_dir = os.path.dirname(os.path.abspath(sys.prefix))
|
|
134
|
+
exe_dir = os.path.dirname(os.path.abspath(sys.argv[0]))
|
|
135
|
+
app_name = cfg.get("project", {}).get("name", script)
|
|
136
|
+
localappdata = os.environ.get("LOCALAPPDATA") or _user_data_home()
|
|
137
|
+
names = {"app_dir": app_dir, "exe_dir": exe_dir, "localappdata": localappdata,
|
|
138
|
+
"data_dir": os.path.join(localappdata, app_name)}
|
|
139
|
+
|
|
140
|
+
for var in boot.get("clean-env", []):
|
|
141
|
+
os.environ.pop(var, None)
|
|
142
|
+
for key, value in boot.get("env", {}).items():
|
|
143
|
+
os.environ[key] = _expand(value, names)
|
|
144
|
+
|
|
145
|
+
if launcher.get("gui"):
|
|
146
|
+
log_path = None
|
|
147
|
+
gui_log = boot.get("gui-log", True)
|
|
148
|
+
if gui_log is True:
|
|
149
|
+
log_path = _gui_streams(os.path.join(names["data_dir"], "logs", script + ".log"))
|
|
150
|
+
elif gui_log: # a template: {data_dir}-style names and strftime codes
|
|
151
|
+
import time
|
|
152
|
+
|
|
153
|
+
log_path = _gui_streams(os.path.normpath(time.strftime(_expand(gui_log, names))))
|
|
154
|
+
_gui_excepthook(app_name, log_path)
|
|
155
|
+
_gui_process(boot.get("app-id"), boot.get("dpi-aware", True))
|
|
156
|
+
|
|
157
|
+
if boot.get("truststore"):
|
|
158
|
+
try:
|
|
159
|
+
import truststore
|
|
160
|
+
|
|
161
|
+
truststore.inject_into_ssl()
|
|
162
|
+
except ImportError:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
module, _, attr = launcher["entry-point"].partition(":")
|
|
166
|
+
target = importlib.import_module(module)
|
|
167
|
+
for part in attr.split("."):
|
|
168
|
+
target = getattr(target, part)
|
|
169
|
+
sys.exit(target())
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Apply a pyxbuild patch (see src/pyxbuild/patch.py).
|
|
2
|
+
|
|
3
|
+
A patch executable extracts its payload (this module, ``patch.json`` and the
|
|
4
|
+
changed files under ``blobs/``) to a temporary folder and runs this module
|
|
5
|
+
with the Python of the installed version the patch was made for. It
|
|
6
|
+
assembles the new version in the onefile cache, next to the old one:
|
|
7
|
+
|
|
8
|
+
* files whose content the old version already has are hard-linked (or
|
|
9
|
+
copied) from it after checking their sha256 (the cache is user-writable);
|
|
10
|
+
* new files come from ``blobs/<sha256>``, or, for files of public wheels
|
|
11
|
+
when the patch was made with ``--fetch-public``, from the wheel downloaded
|
|
12
|
+
from the URL in the lock file and checked against its sha256;
|
|
13
|
+
* every file written is checked against the hash the new version expects;
|
|
14
|
+
* the folder is renamed into place only when complete (``.pyx-complete``),
|
|
15
|
+
and ``.pyx-next`` in the old folder points older executables at it.
|
|
16
|
+
|
|
17
|
+
The result is the folder the new full executable would extract itself.
|
|
18
|
+
Standard library only.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
import urllib.request
|
|
28
|
+
import zipfile
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PatchError(Exception):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _sha256(path, cache={}): # noqa: B006 - deliberate per-run memo
|
|
36
|
+
if path not in cache:
|
|
37
|
+
h = hashlib.sha256()
|
|
38
|
+
with open(path, "rb") as f:
|
|
39
|
+
while block := f.read(1 << 20):
|
|
40
|
+
h.update(block)
|
|
41
|
+
cache[path] = h.hexdigest()
|
|
42
|
+
return cache[path]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _mutex(name):
|
|
46
|
+
"""The launcher's extraction mutex, so a full executable of the same
|
|
47
|
+
version never extracts into the folder while we assemble it."""
|
|
48
|
+
try:
|
|
49
|
+
import ctypes
|
|
50
|
+
|
|
51
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
52
|
+
kernel32.CreateMutexW.restype = ctypes.c_void_p
|
|
53
|
+
handle = kernel32.CreateMutexW(None, False, name)
|
|
54
|
+
if handle:
|
|
55
|
+
kernel32.WaitForSingleObject(ctypes.c_void_p(handle), 0xFFFFFFFF)
|
|
56
|
+
return kernel32, handle
|
|
57
|
+
except (AttributeError, OSError): # not Windows (tests)
|
|
58
|
+
return None, None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _read12(path):
|
|
62
|
+
with open(path, encoding="ascii", errors="replace") as f:
|
|
63
|
+
return f.read(12)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _release(mutex):
|
|
67
|
+
kernel32, handle = mutex
|
|
68
|
+
if handle:
|
|
69
|
+
import ctypes
|
|
70
|
+
|
|
71
|
+
kernel32.ReleaseMutex(ctypes.c_void_p(handle))
|
|
72
|
+
kernel32.CloseHandle(ctypes.c_void_p(handle))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _download(url, sha256, size, dest):
|
|
76
|
+
print(f" downloading {url.rsplit('/', 1)[-1]}", flush=True)
|
|
77
|
+
h = hashlib.sha256()
|
|
78
|
+
got = 0
|
|
79
|
+
with urllib.request.urlopen(url, timeout=120) as resp, open(dest, "wb") as out:
|
|
80
|
+
while block := resp.read(1 << 20):
|
|
81
|
+
got += len(block)
|
|
82
|
+
if size is not None and got > size:
|
|
83
|
+
raise PatchError(f"{url}: larger than expected")
|
|
84
|
+
h.update(block)
|
|
85
|
+
out.write(block)
|
|
86
|
+
if h.hexdigest() != sha256:
|
|
87
|
+
raise PatchError(f"{url}: sha256 mismatch")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _place(src, dst, link):
|
|
91
|
+
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
|
92
|
+
if link:
|
|
93
|
+
try:
|
|
94
|
+
os.link(src, dst)
|
|
95
|
+
return
|
|
96
|
+
except OSError:
|
|
97
|
+
pass # other volume, FAT, no permission: copy
|
|
98
|
+
shutil.copyfile(src, dst)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def apply(payload, appdir, log=print):
|
|
102
|
+
with open(os.path.join(payload, "patch.json"), encoding="utf-8") as f:
|
|
103
|
+
patch = json.load(f)
|
|
104
|
+
if patch.get("format") != 1:
|
|
105
|
+
raise PatchError("unsupported patch format")
|
|
106
|
+
base = os.path.join(appdir, patch["base"][:12])
|
|
107
|
+
target12 = patch["target"][:12]
|
|
108
|
+
target = os.path.join(appdir, target12)
|
|
109
|
+
if not os.path.isfile(os.path.join(base, ".pyx-complete")):
|
|
110
|
+
raise PatchError(f"the version this patch is for is not installed: {base}")
|
|
111
|
+
|
|
112
|
+
mutex = _mutex("Local\\pyx-onefile-" + target12)
|
|
113
|
+
try:
|
|
114
|
+
installed = os.path.isfile(os.path.join(target, ".pyx-complete"))
|
|
115
|
+
if installed:
|
|
116
|
+
log(f"{patch['app']} {patch['version']} is already installed")
|
|
117
|
+
else:
|
|
118
|
+
_assemble(patch, payload, base, target, log)
|
|
119
|
+
finally:
|
|
120
|
+
_release(mutex)
|
|
121
|
+
|
|
122
|
+
# older executables follow .pyx-next to the newest version; also re-point
|
|
123
|
+
# versions that pointed at our base so that chains stay one hop long
|
|
124
|
+
base12 = patch["base"][:12]
|
|
125
|
+
for name in os.listdir(appdir):
|
|
126
|
+
nxt = os.path.join(appdir, name, ".pyx-next")
|
|
127
|
+
if name != base12 and not (os.path.isfile(nxt) and _read12(nxt) == base12):
|
|
128
|
+
continue
|
|
129
|
+
with open(nxt, "w", encoding="ascii") as f:
|
|
130
|
+
f.write(target12 + "\n")
|
|
131
|
+
if not installed:
|
|
132
|
+
log(f"updated {patch['app']} to {patch['version']}")
|
|
133
|
+
return target
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _assemble(patch, payload, base, target, log):
|
|
137
|
+
files = patch["files"]
|
|
138
|
+
work = f"{target}.tmp-{os.getpid():x}-patch"
|
|
139
|
+
if os.path.exists(work):
|
|
140
|
+
shutil.rmtree(work)
|
|
141
|
+
downloads = tempfile.mkdtemp(prefix="pyx-patch-wheels-")
|
|
142
|
+
try:
|
|
143
|
+
# 1. the old version's files we reuse must still be what we expect
|
|
144
|
+
keep = patch["keep"]
|
|
145
|
+
moved = [e for e in files if e["from"] == "base"]
|
|
146
|
+
log(f" checking {len(keep) + len(moved)} unchanged file(s)")
|
|
147
|
+
digest = hashlib.sha256()
|
|
148
|
+
for path in keep:
|
|
149
|
+
src = os.path.join(base, *path.split("/"))
|
|
150
|
+
if not os.path.isfile(src):
|
|
151
|
+
raise PatchError(f"the installed version is damaged ({path} is missing); "
|
|
152
|
+
"install the full version")
|
|
153
|
+
digest.update(_sha256(src).encode())
|
|
154
|
+
if digest.hexdigest() != patch["keep_sha256"]:
|
|
155
|
+
raise PatchError("the installed version is damaged; install the full version")
|
|
156
|
+
for e in moved:
|
|
157
|
+
src = os.path.join(base, *e["src"].split("/"))
|
|
158
|
+
if not os.path.isfile(src) or _sha256(src) != e["sha256"]:
|
|
159
|
+
raise PatchError(f"the installed version is damaged ({e['src']}); "
|
|
160
|
+
"install the full version")
|
|
161
|
+
# 2. wheels with public files (--fetch-public)
|
|
162
|
+
wheels = {}
|
|
163
|
+
for i, w in enumerate(patch.get("wheels", [])):
|
|
164
|
+
path = os.path.join(downloads, w["url"].rsplit("/", 1)[-1])
|
|
165
|
+
_download(w["url"], w["sha256"], w.get("size"), path)
|
|
166
|
+
wheels[i] = zipfile.ZipFile(path)
|
|
167
|
+
# 3. assemble
|
|
168
|
+
log(f" writing {len(keep) + len(files)} file(s)")
|
|
169
|
+
for path in keep:
|
|
170
|
+
_place(os.path.join(base, *path.split("/")),
|
|
171
|
+
os.path.join(work, *path.split("/")), link=True)
|
|
172
|
+
for e in files:
|
|
173
|
+
dst = os.path.join(work, *e["path"].split("/"))
|
|
174
|
+
if e["from"] == "base":
|
|
175
|
+
_place(os.path.join(base, *e["src"].split("/")), dst, link=True)
|
|
176
|
+
continue
|
|
177
|
+
if e["from"] == "payload":
|
|
178
|
+
_place(os.path.join(payload, "blobs", e["sha256"]), dst, link=False)
|
|
179
|
+
else:
|
|
180
|
+
index, member = e["src"]
|
|
181
|
+
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
|
182
|
+
with wheels[index].open(member) as src, open(dst, "wb") as out:
|
|
183
|
+
shutil.copyfileobj(src, out)
|
|
184
|
+
if os.path.getsize(dst) != e["size"] or _sha256(dst) != e["sha256"]:
|
|
185
|
+
raise PatchError(f"{e['path']}: content does not match the new version")
|
|
186
|
+
for z in wheels.values():
|
|
187
|
+
z.close()
|
|
188
|
+
with open(os.path.join(work, ".pyx-complete"), "w", encoding="utf-8") as f:
|
|
189
|
+
f.write(patch["target"] + "\n")
|
|
190
|
+
try:
|
|
191
|
+
os.rename(work, target)
|
|
192
|
+
except OSError:
|
|
193
|
+
if not os.path.isfile(os.path.join(target, ".pyx-complete")):
|
|
194
|
+
if os.path.exists(target): # an incomplete earlier attempt
|
|
195
|
+
shutil.rmtree(target)
|
|
196
|
+
os.rename(work, target)
|
|
197
|
+
else:
|
|
198
|
+
raise
|
|
199
|
+
finally:
|
|
200
|
+
shutil.rmtree(downloads, ignore_errors=True)
|
|
201
|
+
if os.path.exists(work):
|
|
202
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def main():
|
|
206
|
+
try:
|
|
207
|
+
apply(os.environ["PYX_PATCH_PAYLOAD"], os.environ["PYX_PATCH_APPDIR"])
|
|
208
|
+
except (PatchError, OSError) as exc:
|
|
209
|
+
print(f"update failed: {exc}", file=sys.stderr, flush=True)
|
|
210
|
+
return 1
|
|
211
|
+
return 0
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Standard streams for windowless processes of applications built by pyxbuild.
|
|
2
|
+
|
|
3
|
+
pythonw.exe starts with ``sys.stdout``/``sys.stderr`` set to None: ``print()``
|
|
4
|
+
is silently dropped, but ``sys.stdout.write()``, ``logging`` handlers created
|
|
5
|
+
early or libraries that flush the streams raise AttributeError. That also
|
|
6
|
+
hits every child process (multiprocessing, ProcessPoolExecutor), which never
|
|
7
|
+
runs ``_pyxbuild_boot``. ``_pyxbuild_streams.pth`` in site-packages imports
|
|
8
|
+
this module at the start of every interpreter of the bundle; it does nothing
|
|
9
|
+
when the streams exist. Output goes to the GUI log the main process chose
|
|
10
|
+
(``PYXBUILD_GUI_LOG``, inherited), otherwise nowhere.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
PLACEHOLDER = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def open_append(path):
|
|
20
|
+
"""A line-buffered text stream that several processes can append to at once.
|
|
21
|
+
|
|
22
|
+
The C runtime's "a" mode seeks to the end before every write, so on
|
|
23
|
+
Windows concurrent writers overwrite each other's lines. A handle opened
|
|
24
|
+
with FILE_APPEND_DATA but without FILE_WRITE_DATA makes the system append
|
|
25
|
+
every write atomically at the end of the file.
|
|
26
|
+
"""
|
|
27
|
+
if sys.platform == "win32":
|
|
28
|
+
try:
|
|
29
|
+
import ctypes
|
|
30
|
+
import msvcrt
|
|
31
|
+
from ctypes import wintypes
|
|
32
|
+
|
|
33
|
+
create = ctypes.WinDLL("kernel32", use_last_error=True).CreateFileW
|
|
34
|
+
create.restype = wintypes.HANDLE
|
|
35
|
+
create.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
|
|
36
|
+
wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD,
|
|
37
|
+
wintypes.HANDLE]
|
|
38
|
+
# FILE_APPEND_DATA; share read/write/delete; OPEN_ALWAYS; normal file
|
|
39
|
+
handle = create(path, 0x0004, 0x0007, None, 4, 0x80, None)
|
|
40
|
+
if handle and handle != wintypes.HANDLE(-1).value:
|
|
41
|
+
fd = msvcrt.open_osfhandle(handle, os.O_WRONLY | os.O_BINARY)
|
|
42
|
+
stream = open(fd, "w", encoding="utf-8", buffering=1, # noqa: SIM115
|
|
43
|
+
errors="backslashreplace")
|
|
44
|
+
stream.buffer.raw.name = path # not the descriptor number
|
|
45
|
+
return stream
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
return open(path, "a", encoding="utf-8", buffering=1, # noqa: SIM115
|
|
49
|
+
errors="backslashreplace")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if sys.stdout is None or sys.stderr is None:
|
|
53
|
+
try:
|
|
54
|
+
_log = os.environ.get("PYXBUILD_GUI_LOG")
|
|
55
|
+
if _log:
|
|
56
|
+
PLACEHOLDER = open_append(_log)
|
|
57
|
+
except OSError:
|
|
58
|
+
PLACEHOLDER = None
|
|
59
|
+
if PLACEHOLDER is None:
|
|
60
|
+
PLACEHOLDER = open(os.devnull, "w", encoding="utf-8") # noqa: SIM115
|
|
61
|
+
if sys.stdout is None:
|
|
62
|
+
sys.stdout = PLACEHOLDER
|
|
63
|
+
if sys.stderr is None:
|
|
64
|
+
sys.stderr = PLACEHOLDER
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Loaded into the PEP 517 build process by ``pyxbuild.crossbuild`` (via PYTHONPATH).
|
|
2
|
+
|
|
3
|
+
Replaces setuptools' ``build_ext.build_extension`` and ``build_clib.build_libraries``
|
|
4
|
+
with versions that compile C/C++ sources for Windows x64 with ``zig cc``
|
|
5
|
+
against the headers and import library of the *target* CPython. Everything
|
|
6
|
+
else (setup.py logic, Cython code generation, packaging) runs unchanged on
|
|
7
|
+
the host. Only active when PYXBUILD_CROSS is set.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _install() -> None:
|
|
15
|
+
cfg = json.loads(os.environ["PYXBUILD_CROSS"])
|
|
16
|
+
zig = cfg["zig"]
|
|
17
|
+
target = cfg["target"]
|
|
18
|
+
include = cfg["include"]
|
|
19
|
+
libdir = cfg["libdir"]
|
|
20
|
+
pylib = cfg["pylib"]
|
|
21
|
+
ext_suffix = cfg["ext_suffix"]
|
|
22
|
+
|
|
23
|
+
import subprocess
|
|
24
|
+
import sysconfig
|
|
25
|
+
|
|
26
|
+
host_includes = {sysconfig.get_paths()[k] for k in ("include", "platinclude")}
|
|
27
|
+
case_shims = cfg["case_shims"]
|
|
28
|
+
cxx_suffixes = (".cpp", ".cc", ".cxx", ".c++")
|
|
29
|
+
|
|
30
|
+
def portable(args):
|
|
31
|
+
# MSVC-style switches ("/O2", "/EHsc") make no sense for clang; keep
|
|
32
|
+
# absolute POSIX paths (extra objects, static libraries)
|
|
33
|
+
return [a for a in (args or [])
|
|
34
|
+
if not (a.startswith("/") and len(a) < 16 and not os.path.exists(a))]
|
|
35
|
+
|
|
36
|
+
def run(cmd):
|
|
37
|
+
print("pyxbuild-cross:", " ".join(cmd), flush=True)
|
|
38
|
+
subprocess.run(cmd, check=True)
|
|
39
|
+
|
|
40
|
+
def compile_sources(sources, build_temp, macros, undef, include_dirs, extra):
|
|
41
|
+
objects = []
|
|
42
|
+
for src in sources:
|
|
43
|
+
obj = os.path.join(build_temp, os.path.splitdrive(src)[1].lstrip("/\\") + ".obj")
|
|
44
|
+
os.makedirs(os.path.dirname(obj), exist_ok=True)
|
|
45
|
+
tool = "c++" if src.lower().endswith(cxx_suffixes) else "cc"
|
|
46
|
+
# MS_WIN64: PC/pyconfig.h sets it for MinGW only since 3.12
|
|
47
|
+
cmd = [*zig, tool, "-target", target, "-c", "-O2", "-DNDEBUG", "-DMS_WIN64"]
|
|
48
|
+
for name, value in macros or []:
|
|
49
|
+
cmd.append(f"-D{name}" if value is None else f"-D{name}={value}")
|
|
50
|
+
cmd += [f"-U{name}" for name in undef or []]
|
|
51
|
+
cmd += [f"-I{d}" for d in [*(include_dirs or []), include]]
|
|
52
|
+
cmd += ["-idirafter", case_shims]
|
|
53
|
+
cmd += portable(extra)
|
|
54
|
+
cmd += [src, "-o", obj]
|
|
55
|
+
run(cmd)
|
|
56
|
+
objects.append(obj)
|
|
57
|
+
return objects
|
|
58
|
+
|
|
59
|
+
def build_extension(self, ext):
|
|
60
|
+
sources = self.swig_sources(list(ext.sources), ext)
|
|
61
|
+
ext_path = self.get_ext_fullpath(ext.name)
|
|
62
|
+
os.makedirs(os.path.dirname(ext_path) or ".", exist_ok=True)
|
|
63
|
+
# build_ext-level include dirs, minus the host interpreter's headers
|
|
64
|
+
host = {os.path.normcase(os.path.abspath(p)) for p in host_includes}
|
|
65
|
+
includes = [*(ext.include_dirs or []),
|
|
66
|
+
*[d for d in (self.include_dirs or [])
|
|
67
|
+
if os.path.normcase(os.path.abspath(d)) not in host]]
|
|
68
|
+
objects = compile_sources(sources, self.build_temp, ext.define_macros,
|
|
69
|
+
ext.undef_macros, includes, ext.extra_compile_args)
|
|
70
|
+
is_cxx = any(s.lower().endswith(cxx_suffixes) for s in sources) or ext.language == "c++"
|
|
71
|
+
cmd = [*zig, "c++" if is_cxx else "cc", "-target", target, "-shared", "-o", ext_path]
|
|
72
|
+
cmd += objects + list(ext.extra_objects or [])
|
|
73
|
+
# self.libraries / self.library_dirs carry build_clib output
|
|
74
|
+
cmd += [f"-L{d}" for d in [*(ext.library_dirs or []), *(self.library_dirs or []),
|
|
75
|
+
self.build_temp, libdir]]
|
|
76
|
+
cmd += [f"-l{lib}" for lib in [*(ext.libraries or []), *(self.libraries or [])]]
|
|
77
|
+
cmd += [os.path.join(libdir, pylib + ".lib")]
|
|
78
|
+
cmd += portable(ext.extra_link_args)
|
|
79
|
+
cmd.append("-s")
|
|
80
|
+
out_dir = os.path.dirname(ext_path) or "."
|
|
81
|
+
before = set(os.listdir(out_dir))
|
|
82
|
+
run(cmd)
|
|
83
|
+
# the linker leaves an import library and a .pdb next to the .pyd
|
|
84
|
+
for junk in set(os.listdir(out_dir)) - before:
|
|
85
|
+
if junk.lower().endswith((".lib", ".pdb")):
|
|
86
|
+
os.remove(os.path.join(out_dir, junk))
|
|
87
|
+
|
|
88
|
+
def build_libraries(self, libraries):
|
|
89
|
+
for name, info in libraries:
|
|
90
|
+
objects = compile_sources(info.get("sources", []), self.build_temp,
|
|
91
|
+
info.get("macros"), None, info.get("include_dirs"),
|
|
92
|
+
info.get("cflags"))
|
|
93
|
+
out = os.path.join(self.build_clib, f"lib{name}.a")
|
|
94
|
+
os.makedirs(self.build_clib, exist_ok=True)
|
|
95
|
+
run([*zig, "ar", "rcs", out, *objects])
|
|
96
|
+
|
|
97
|
+
def get_ext_filename(self, fullname):
|
|
98
|
+
ext = getattr(self, "ext_map", {}).get(fullname)
|
|
99
|
+
# abi3 extensions on Windows are plain ".pyd"
|
|
100
|
+
suffix = ".pyd" if ext is not None and getattr(ext, "py_limited_api", False) \
|
|
101
|
+
else ext_suffix
|
|
102
|
+
return os.path.join(*fullname.split(".")) + suffix
|
|
103
|
+
|
|
104
|
+
# Patch the command classes when the build imports them, not earlier:
|
|
105
|
+
# importing setuptools from sitecustomize changes how its distutils shim
|
|
106
|
+
# initialises and breaks some setup.py files (zstandard). Projects that
|
|
107
|
+
# subclass distutils' commands directly (PyYAML) need the base classes
|
|
108
|
+
# patched as well.
|
|
109
|
+
patches = {
|
|
110
|
+
"setuptools.command.build_ext": {"build_ext": {"build_extension": build_extension,
|
|
111
|
+
"get_ext_filename": get_ext_filename}},
|
|
112
|
+
"setuptools.command.build_clib": {"build_clib": {"build_libraries": build_libraries}},
|
|
113
|
+
"setuptools._distutils.command.build_ext": {
|
|
114
|
+
"build_ext": {"build_extension": build_extension,
|
|
115
|
+
"get_ext_filename": get_ext_filename}},
|
|
116
|
+
"setuptools._distutils.command.build_clib": {
|
|
117
|
+
"build_clib": {"build_libraries": build_libraries}},
|
|
118
|
+
}
|
|
119
|
+
# setuptools' _distutils_hack serves "distutils" from setuptools/_distutils
|
|
120
|
+
# under the "distutils.*" names: separate module objects (Cython's build_ext)
|
|
121
|
+
patches["distutils.command.build_ext"] = patches["setuptools._distutils.command.build_ext"]
|
|
122
|
+
patches["distutils.command.build_clib"] = patches["setuptools._distutils.command.build_clib"]
|
|
123
|
+
|
|
124
|
+
import importlib.abc
|
|
125
|
+
import importlib.machinery
|
|
126
|
+
import sys
|
|
127
|
+
|
|
128
|
+
class _Patcher(importlib.abc.MetaPathFinder):
|
|
129
|
+
def find_spec(self, name, path, target=None):
|
|
130
|
+
if name not in patches:
|
|
131
|
+
return None
|
|
132
|
+
spec = importlib.machinery.PathFinder.find_spec(name, path)
|
|
133
|
+
if spec is None or spec.loader is None:
|
|
134
|
+
return None
|
|
135
|
+
loader = spec.loader
|
|
136
|
+
exec_module = loader.exec_module
|
|
137
|
+
|
|
138
|
+
def patched_exec(module, _exec=exec_module, _name=name):
|
|
139
|
+
_exec(module)
|
|
140
|
+
for cls_name, attrs in patches[_name].items():
|
|
141
|
+
cls = getattr(module, cls_name)
|
|
142
|
+
for attr, func in attrs.items():
|
|
143
|
+
setattr(cls, attr, func)
|
|
144
|
+
|
|
145
|
+
loader.exec_module = patched_exec
|
|
146
|
+
return spec
|
|
147
|
+
|
|
148
|
+
sys.meta_path.insert(0, _Patcher())
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if os.environ.get("PYXBUILD_CROSS"):
|
|
152
|
+
_install()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|