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.
Files changed (44) hide show
  1. pyxbuild/__init__.py +3 -0
  2. pyxbuild/__main__.py +5 -0
  3. pyxbuild/_boot/_pyxbuild_boot.py +169 -0
  4. pyxbuild/_boot/_pyxbuild_patch.py +211 -0
  5. pyxbuild/_boot/_pyxbuild_streams.py +64 -0
  6. pyxbuild/_boot/posix_script_head.txt +3 -0
  7. pyxbuild/_cross_hook/sitecustomize.py +152 -0
  8. pyxbuild/_stubs/launcher-console-linux_aarch64 +0 -0
  9. pyxbuild/_stubs/launcher-console-linux_x86_64 +0 -0
  10. pyxbuild/_stubs/launcher-console-win_amd64.exe +0 -0
  11. pyxbuild/_stubs/launcher-gui-win_amd64.exe +0 -0
  12. pyxbuild/_stubs/launcher-onefile-console-linux_aarch64 +0 -0
  13. pyxbuild/_stubs/launcher-onefile-console-linux_x86_64 +0 -0
  14. pyxbuild/_stubs/launcher-onefile-console-win_amd64.exe +0 -0
  15. pyxbuild/_stubs/launcher-onefile-gui-win_amd64.exe +0 -0
  16. pyxbuild/builder.py +793 -0
  17. pyxbuild/cli.py +185 -0
  18. pyxbuild/config.py +396 -0
  19. pyxbuild/crossbuild.py +181 -0
  20. pyxbuild/doctor.py +282 -0
  21. pyxbuild/download.py +180 -0
  22. pyxbuild/errors.py +27 -0
  23. pyxbuild/fallbacks.py +77 -0
  24. pyxbuild/imports.py +225 -0
  25. pyxbuild/install.py +473 -0
  26. pyxbuild/launcher.py +172 -0
  27. pyxbuild/local.py +192 -0
  28. pyxbuild/lock.py +443 -0
  29. pyxbuild/onefile.py +179 -0
  30. pyxbuild/patch.py +203 -0
  31. pyxbuild/pe.py +133 -0
  32. pyxbuild/project.py +74 -0
  33. pyxbuild/purewheel.py +125 -0
  34. pyxbuild/resolve.py +309 -0
  35. pyxbuild/resources.py +304 -0
  36. pyxbuild/runtime.py +120 -0
  37. pyxbuild/runtimes.json +74 -0
  38. pyxbuild/sign.py +84 -0
  39. pyxbuild/targets.py +200 -0
  40. pyxbuild-0.1.0.dist-info/METADATA +344 -0
  41. pyxbuild-0.1.0.dist-info/RECORD +44 -0
  42. pyxbuild-0.1.0.dist-info/WHEEL +4 -0
  43. pyxbuild-0.1.0.dist-info/entry_points.txt +2 -0
  44. pyxbuild-0.1.0.dist-info/licenses/LICENSE +201 -0
pyxbuild/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """pyxbuild: build Windows x64 applications from a Python project on any OS."""
2
+
3
+ __version__ = "0.1.0"
pyxbuild/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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,3 @@
1
+ #!/bin/sh
2
+ '''exec' "$(dirname -- "$(readlink -f -- "$0")")/python{python}" "$0" "$@"
3
+ ' '''
@@ -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()