-
Notifications
You must be signed in to change notification settings - Fork 36
Expand file tree
/
Copy pathcompile_all.py
More file actions
2190 lines (2040 loc) · 104 KB
/
Copy pathcompile_all.py
File metadata and controls
2190 lines (2040 loc) · 104 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
# SPDX-License-Identifier: LGPL-2.1-or-later
# SPDX-FileNotice: Part of the FreeCAD project.
# Every package has its own compilation and installation idiosyncrasies, so we have to use a custom
# build script for each one.
from diff_match_patch import diff_match_patch
from typing import Dict, List, Optional, Tuple
from enum import Enum
import glob
import os
import pathlib
import platform
import re
import shutil
import subprocess
import stat
import sys
# Pip requirements skipped in Debug mode because their PyPI distribution is a release-ABI
# wheel (cp3XX) that cannot install against the Py_DEBUG (cp3XXd) interpreter. These will
# be source-built against the debug Python in a later phase (debug_build_plan.md Phase 3).
# Names are matched case-insensitively against the package portion of each requirement
# specifier in config.json. Trim this set as each package gains a working source-build.
_DEBUG_BUILD_EXCLUDED_REQUIREMENTS = frozenset(
name.lower()
for name in (
# Direct C/C++/Fortran/Rust extensions with no pure-Python distribution.
"av",
"cmake",
"cog",
"ifcopenshell",
"shapely",
)
)
# Build-time tooling that must be present in the LibPack so that pip's --no-build-isolation
# can resolve PEP 517 build backends locally. setuptools covers most packages; meson-python
# covers the modern numerical-Python ecosystem (contourpy, numpy, scipy, matplotlib). meson
# and ninja are the actual build tools meson-python orchestrates; pyproject-metadata is a
# meson-python dependency.
_DEBUG_BUILD_REQUIRED_TOOLING = (
"packaging",
"setuptools",
"wheel",
"meson-python",
"meson",
"ninja",
"pyproject-metadata",
"cppy",
"pybind11",
"Cython",
"pkgconf",
"pythran",
"setuptools_scm",
"maturin",
)
# Packages with C extensions that pip must source-build against the debug Python rather
# than pull from PyPI as a wheel. Windows Py_DEBUG reports both cp3XXd and cp3XX as
# compatible platform tags, so without --no-binary pip happily picks a release wheel that
# then fails to load against python_d.exe at runtime. Names match pip's --no-binary syntax.
_DEBUG_BUILD_FROM_SOURCE = (
"regex",
"PyYAML",
"httptools",
"debugpy",
"numpy",
"scipy",
"contourpy",
"kiwisolver",
"pillow",
"matplotlib",
"pydantic_core",
"watchfiles",
"lxml",
# Force pure-Python mypy; its release wheels are mypyc-compiled and will not load under
# python_d. Keep the pin at 1.18.2, because 1.19.0 added a hard dependency on librt and
# 2.x additionally requires ast-serialize, neither of which ships a pure-Python wheel.
"mypy",
)
# Sitecustomize shim installed at <libpack>/bin/Lib/site-packages/sitecustomize.py for
# Debug LibPacks. Setuptools' build_ext defaults debug=False even when the target Python
# is Py_DEBUG, so source-built C extensions get the release CRT (/MD, VCRUNTIME140.dll)
# instead of the debug CRT (/MDd, VCRUNTIME140D.dll, ucrtbased.dll). The mismatch
# corrupts heap state in any extension that shares allocations across the C/Python
# boundary. This shim forces self.debug = True for every build_ext invocation when
# Py_DEBUG is detected via sysconfig. The unconditional warning at the bottom makes
# silent monkey-patch failure (for example a future setuptools rename) loud rather than
# silent.
_SITECUSTOMIZE_DEBUG_SHIM = '''\
"""Auto-loaded at interpreter startup by site.execsitecustomize().
When this Python is a Py_DEBUG build, force setuptools' build_ext to default
debug=True so MSVC compiles C extensions with /MDd (debug CRT) and links with
/DEBUG:FULL. Setuptools does not consult Py_DEBUG; without this shim, source-
built extensions get the release CRT and silently corrupt heap state in any
package that shares allocations across the C/Python boundary.
Installed by the FreeCAD LibPack build (compile_all.build_python, Debug mode)."""
import sys
import sysconfig
if sysconfig.get_config_var("Py_DEBUG"):
_patched_build_ext = False
_patched_msvc = False
try:
from setuptools.command.build_ext import build_ext as _build_ext
except ImportError:
_build_ext = None
if _build_ext is not None:
_orig_initialize_options = _build_ext.initialize_options
def _initialize_options_force_debug(self):
_orig_initialize_options(self)
self.debug = True
_build_ext.initialize_options = _initialize_options_force_debug
_patched_build_ext = True
try:
from setuptools._distutils import _msvccompiler as _msvc
except ImportError:
try:
import distutils._msvccompiler as _msvc
except ImportError:
_msvc = None
if _msvc is not None:
_orig_msvc_initialize = _msvc.MSVCCompiler.initialize
def _initialize_with_fs(self, plat_name=None):
_orig_msvc_initialize(self, plat_name)
# Replace /Zi with /Z7 so debug info is embedded in each .obj rather than
# written to a shared per-directory .pdb. /FS via mspdbsrv is the documented
# fix for the parallel-build PDB race, but in pip-driven builds (notably
# pillow's parallel-compile setup.py) it does not always reach all child
# cl.exe instances. /Z7 sidesteps the race entirely by removing the shared
# writer. The linker still produces a per-extension .pdb at link time.
for options in (self.compile_options_debug, self.compile_options):
while "/Zi" in options:
options[options.index("/Zi")] = "/Z7"
if "/FS" not in options:
options.append("/FS")
_msvc.MSVCCompiler.initialize = _initialize_with_fs
_patched_msvc = True
if not (_patched_build_ext and _patched_msvc):
sys.stderr.write(
"WARNING: FreeCAD LibPack sitecustomize could not fully patch setuptools/MSVC "
"for Py_DEBUG compilation. C extensions built in this interpreter may use "
"the release CRT or race on parallel PDB writes.\\n"
)
'''
def _requirement_package_name(spec: str) -> str:
"""Extract the lowercased package name from a pip requirement specifier such as
'numpy==2.4.4' or 'shapely==2.1.2; platform_machine != "ARM64"'."""
match = re.match(r"\s*([A-Za-z0-9_.-]+)", spec)
return match.group(1).lower() if match else ""
class BuildMode(Enum):
DEBUG = 1
RELEASE = 2
def __str__(self) -> str:
if self == BuildMode.DEBUG:
return "Debug"
elif self == BuildMode.RELEASE:
return "Release"
else:
return "Unknown"
def remove_readonly(func, path, _) -> None:
"""Remove a read-only file."""
os.chmod(path, stat.S_IWRITE)
func(path)
def patch_single_file(filename, patch_data) -> None:
with open(filename, "r", encoding="utf-8") as f:
original_data = f.read()
dmp = diff_match_patch()
patches = dmp.patch_fromText(patch_data)
new_text, applied = dmp.patch_apply(patches, original_data)
if not all(applied):
print(f"ERROR: Failed to apply some patches to {filename}")
# TODO: Someday actually print out what patches failed?
exit(1)
with open(filename, "w", encoding="utf-8") as f:
f.write(new_text)
def split_patch_data(patch_data: str) -> List[Dict[str, str]]:
filename_regex = re.compile("@@@ ([^@]*) @@@\n")
split_data = re.split(filename_regex, patch_data)
result = []
for index, entry in enumerate(split_data):
if index == 0:
if entry != "":
print("ERROR: Bad patch file, must start with @@@ filename @@@")
exit(1)
continue
if index % 2 == 1:
result.append({"file": entry})
else:
result[-1]["data"] = entry
return result
def apply_patch(patch_file_path: str) -> None:
"""Apply a patch that was generated by the generate_patch.py script"""
# Path is relative to *this* file, not our working directory
absolute_path = os.path.join(pathlib.Path(__file__).parent.absolute(), patch_file_path)
with open(absolute_path, "r", encoding="utf-8") as f:
patch_data = f.read()
patches = split_patch_data(patch_data)
for patch in patches:
patch_single_file(patch["file"], patch["data"])
def patch_files(patches: List[str]) -> None:
"""Given a list of patches, apply them sequentially in the current working directory. The patches themselves are
expected to be given as paths relative to **this** Python script file"""
for patch in patches:
start = len("patches/")
print(f" Applying patch {patch[start:]}")
apply_patch(patch)
def libpack_arch_label() -> str:
"""Architecture suffix used in the LibPack directory and archive names.
'x64' for Windows AMD64 and 'ARM64' for Windows on ARM, matching the
convention used elsewhere in the build."""
return "x64" if platform.machine() == "AMD64" else "ARM64"
def working_dir_name(mode: BuildMode) -> str:
"""Per-mode working directory name, allowing a Debug build and a Release build
to coexist side-by-side instead of stomping on each other's clones and builds."""
return "working-" + str(mode).lower()
def libpack_dir(config: dict, mode: BuildMode):
lp_dir = "LibPack-{}-v{}-{}-{}".format(
config["FreeCAD-version"],
config["LibPack-version"],
libpack_arch_label(),
str(mode),
)
return os.path.join(os.path.dirname(__file__), working_dir_name(mode), lp_dir)
def to_exe(base: str = ""):
"""Append .exe to Windows executables, but not to macOS or Linux. If given no argument, just returns the extension
for the current OS, suitable for appending to an executable's name."""
return base + ".exe" if sys.platform.startswith("win32") else ""
def to_static(base: str = ""):
"""Append .lib to Windows libraries, or .a macOS or Linux. If given no argument, just returns the extension
for the current OS, suitable for appending to a static library's name."""
return base + ".lib" if sys.platform.startswith("win32") else ".a"
def to_dynamic(base: str = ""):
"""Append .dll to Windows libraries, or .so to macOS or Linux. If given no argument, just returns the extension
for the current OS, suitable for appending to a dynamic library's name."""
return base + ".dll" if sys.platform.startswith("win32") else ".so"
class Compiler:
def __init__(
self,
config,
bison_path,
skip_existing: bool = False,
mode: BuildMode = BuildMode.RELEASE,
force_rebuild: set = None,
):
self.config = config
self.bison_path = bison_path
self.base_dir = os.getcwd()
self.skip_existing = skip_existing
self.force_rebuild = set(force_rebuild or [])
self.install_dir = libpack_dir(config, mode)
self.init_script = None
# Full MSVC tools version (for example "14.44.35207") to pass to MSBuild as
# /p:VCToolsVersion. Required when the requested PlatformToolset (v143) lacks
# a matching Microsoft.VCToolsVersion.v<N>.default.props file, in which case
# MSBuild falls back to the newest installed compiler regardless of what the
# environment or -vcvars_ver requested.
self.msvc_tools_version = None
self.mode = mode
self.strict_mode = True
# Boost is the one package where the version number gets coded into the path, so store
# that path separately from all the other paths we have to track
self.boost_include_path = None
def get_cmake_options(self) -> List[str]:
"""Get a comprehensive list of cMake options that can be used in any cMake build. Not all options apply
to all builds, but none conflict."""
pcre_lib = self.install_dir + "/lib/pcre2-8"
if self.mode == BuildMode.DEBUG:
pcre_lib += "d"
pcre_lib += to_static()
base = [
"-D CMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY=FALSE", # Never use system packages, always use only the libpack
"-D CMAKE_FIND_PACKAGE_NO_SYSTEM_PACKAGE_REGISTRY=TRUE", # Same as above?
"-D CMAKE_CXX_STANDARD=20",
f"-D BISON_EXECUTABLE={self.bison_path}",
f"-D BOOST_ROOT={self.install_dir}",
"-D BUILD_DOC=No",
"-D BUILD_DOCS=No",
"-D BUILD_EXAMPLES=No",
"-D BUILD_SHARED=Yes",
"-D BUILD_SHARED_LIB=Yes",
"-D BUILD_SHARED_LIBS=Yes",
"-D BUILD_TEST=No",
"-D BUILD_TESTS=No",
"-D BUILD_TESTING=No",
f"-D BZIP2_DIR={self.install_dir}/lib/cmake/",
f"-D Boost_INCLUDE_DIRS={self.install_dir}/include",
f"-D CMAKE_BUILD_TYPE={self.mode}",
f"-D CMAKE_INSTALL_PATH={self.install_dir}",
f"-D CMAKE_INSTALL_PREFIX={self.install_dir}",
f"-D HarfBuzz_DIR={self.install_dir}/lib/cmake/",
f"-D HDF5_DIR={self.install_dir}/share/cmake/",
f"-D HDF5_LIBRARY_DEBUG={self.install_dir}/lib/hdf5d.lib",
f"-D HDF5_LIBRARY_RELEASE={self.install_dir}/lib/hdf5.lib",
f"-D HDF5_DIFF_EXECUTABLE={self.install_dir}/bin/hdf5diff" + to_exe(),
f"-D INSTALL_DIR={self.install_dir}",
f"-D PCRE2_LIBRARY={pcre_lib}",
f"-D pybind11_DIR={self.install_dir}/share/cmake/pybind11",
f"-D Python_ROOT_DIR={self.install_dir}/bin",
f"-D Python_DIR={self.install_dir}/bin",
f"-D Python3_ROOT_DIR={self.install_dir}/bin",
f"-D Python3_DIR={self.install_dir}/bin",
f"-D Python_EXECUTABLE={self.python_exe()}",
f"-D Python3_EXECUTABLE={self.python_exe()}",
"-D Python_FIND_REGISTRY=NEVER",
"-D Python3_FIND_REGISTRY=NEVER",
f"-D Qt6_DIR={self.install_dir}/lib/cmake/Qt6",
f"-D SWIG_EXECUTABLE={self.install_dir}/bin/swig" + to_exe(),
f"-D ZLIB_DIR={self.install_dir}/lib/cmake/",
]
if self.mode == BuildMode.DEBUG:
python_lib = self._python_lib_path()
if python_lib:
base.append(f"-D Python_LIBRARY={python_lib}")
base.append(f"-D Python3_LIBRARY={python_lib}")
if self.mode == BuildMode.RELEASE and sys.platform.startswith("win32"):
# Force PDB generation in Release for the PDB sidecar archive.
# /OPT:REF /OPT:ICF undo /DEBUG's default of disabling COMDAT folding.
base.extend(
[
"-D CMAKE_POLICY_DEFAULT_CMP0141=NEW",
"-D CMAKE_MSVC_DEBUG_INFORMATION_FORMAT=ProgramDatabase",
"-D CMAKE_EXE_LINKER_FLAGS=/DEBUG /OPT:REF /OPT:ICF",
"-D CMAKE_SHARED_LINKER_FLAGS=/DEBUG /OPT:REF /OPT:ICF",
"-D CMAKE_MODULE_LINKER_FLAGS=/DEBUG /OPT:REF /OPT:ICF",
]
)
if self.boost_include_path:
base.append(f"-D Boost_INCLUDE_DIR={self.boost_include_path}")
if sys.platform.startswith("win32"):
inc_path = self.install_dir.replace("\\", "/")
cxx_flags = (
f"/I{inc_path}/include /EHsc /FS /DWIN32 /DWIN64 /DNOMINMAX /DPy_NO_LINK_LIB"
)
if self.strict_mode:
# NOTE: /permissive- is required with Qt6 but could be disabled for anything that doesn't link against
# Qt. The same is true for /Zc:__cplusplus /std:c++20
cxx_flags += " /Zc:__cplusplus /std:c++20 /permissive-"
else:
cxx_flags = f"-I{self.install_dir}/include"
base.append(f"-D CMAKE_CXX_FLAGS={cxx_flags}")
return base
def compile_all(self):
# This option borks Tcl by making it find the wrong paths: remove it
os.environ.pop("NoDefaultCurrentDirectoryInExePath", None)
# Keep pip's wheel and HTTP cache inside the build tree rather than the user-global
# location (%LOCALAPPDATA%\pip\cache). This isolates the build both ways: wheels built
# here never leak into the developer's machine-wide cache, and a stale wheel from
# unrelated local pip activity can never be pulled into the LibPack. The directory lives
# under working-<mode>/ and so is not part of the shipped LibPack. Setting it in the
# environment covers every pip subprocess uniformly (requirements, tooling, pip self-
# upgrade, individual installs) regardless of how each call constructs its env.
pip_cache_dir = os.path.abspath(
os.path.join(os.path.dirname(self.install_dir), "pip-cache")
)
os.makedirs(pip_cache_dir, exist_ok=True)
os.environ["PIP_CACHE_DIR"] = pip_cache_dir
base_skip_existing = self.skip_existing
for item in self.config["content"]:
# All build methods are named using "build_XXX" where XXX is the name of the package in the config file
# A package named in force_rebuild always rebuilds, even when skip-existing is otherwise in effect.
self.skip_existing = base_skip_existing and item["name"] not in self.force_rebuild
os.chdir(item["name"])
build_function_name = "build_" + item["name"]
if hasattr(self, build_function_name):
print(f"Building {item['name']}")
build_function = getattr(self, build_function_name)
build_function(item)
if item["name"].lower() == "python":
# Check these even if we didn't actually have to build Python
self._build_pip()
if "requirements" in item:
self._install_python_requirements(item["requirements"])
else:
print(
f"No '{build_function_name}' found in compile_all.py -- "
"did you forget to add one when adding a dependency?"
)
exit(2)
os.chdir(self.base_dir)
def build_nonexistent(self, _=None):
"""Used for automated testing to allow easy Mock injection"""
def build_libiconv(self, _=None):
"""Build win-iconv, a small Windows-targeted libiconv implementation. Provides
iconv.lib for source-built lxml in Debug mode. Skipped entirely in Release mode
because PyPI lxml wheels bundle their own iconv."""
if self.mode != BuildMode.DEBUG:
print(" Skipping libiconv build in Release mode (lxml wheel bundles its own iconv).")
return
if self.skip_existing:
sentinel = os.path.join(self.install_dir, "include", "iconv.h")
if os.path.exists(sentinel):
print(" Not rebuilding libiconv, it is already in the LibPack")
return
extra_args = [
"-G",
"Ninja",
"-D BUILD_SHARED_LIBS=ON",
"-D BUILD_TEST=OFF",
"-D CMAKE_POLICY_VERSION_MINIMUM=3.5",
]
self._build_standard_cmake(extra_args=extra_args)
def build_libxml2(self, _=None):
"""Build libxml2, providing the XML parser and tree API for source-built lxml in
Debug mode. Skipped entirely in Release mode because PyPI lxml wheels bundle
their own libxml2.
Uses the Ninja CMake generator for the same reason as the other Debug-only C
packages."""
if self.mode != BuildMode.DEBUG:
print(" Skipping libxml2 build in Release mode (lxml wheel bundles its own libxml2).")
return
if self.skip_existing:
sentinel = os.path.join(
self.install_dir, "include", "libxml2", "libxml", "xmlversion.h"
)
if os.path.exists(sentinel):
print(" Not rebuilding libxml2, it is already in the LibPack")
return
extra_args = [
"-G",
"Ninja",
"-D BUILD_SHARED_LIBS=ON",
"-D LIBXML2_WITH_PYTHON=OFF",
"-D LIBXML2_WITH_TESTS=OFF",
"-D LIBXML2_WITH_ICONV=OFF",
"-D LIBXML2_WITH_LZMA=OFF",
]
self._build_standard_cmake(extra_args=extra_args)
def build_libxslt(self, _=None):
"""Build libxslt, providing the XSLT engine for source-built lxml in Debug
mode. Depends on libxml2 above. Skipped entirely in Release mode because PyPI
lxml wheels bundle their own libxslt."""
if self.mode != BuildMode.DEBUG:
print(" Skipping libxslt build in Release mode (lxml wheel bundles its own libxslt).")
return
if self.skip_existing:
sentinel = os.path.join(self.install_dir, "include", "libxslt", "xslt.h")
if os.path.exists(sentinel):
print(" Not rebuilding libxslt, it is already in the LibPack")
return
extra_args = [
"-G",
"Ninja",
"-D BUILD_SHARED_LIBS=ON",
"-D LIBXSLT_WITH_PYTHON=OFF",
"-D LIBXSLT_WITH_TESTS=OFF",
]
self._build_standard_cmake(extra_args=extra_args)
def build_libjpeg(self, _=None):
"""Build libjpeg-turbo, providing the libjpeg API for source-built Pillow in
Debug mode. Skipped entirely in Release mode because PyPI Pillow wheels bundle
their own libjpeg, and nothing else in the Release LibPack consumes libjpeg.
Uses the Ninja CMake generator for the same reason as OpenBLAS: it sidesteps
the v143 PlatformToolset resolution failure that the default Visual Studio
generator hits on VS 2026 installs missing the
Microsoft.VCToolsVersion.v143.default.props file."""
if self.mode != BuildMode.DEBUG:
print(
" Skipping libjpeg-turbo build in Release mode (Pillow wheel bundles its own libjpeg)."
)
return
if self.skip_existing:
if os.path.exists(os.path.join(self.install_dir, "include", "jpeglib.h")):
print(" Not rebuilding libjpeg-turbo, it is already in the LibPack")
return
extra_args = [
"-G",
"Ninja",
"-D ENABLE_SHARED=ON",
"-D ENABLE_STATIC=OFF",
"-D WITH_TURBOJPEG=ON",
"-D BUILD_TESTING=OFF",
]
self._build_standard_cmake(extra_args=extra_args)
def build_openblas(self, _=None):
"""Build OpenBLAS, providing BLAS and LAPACK for source-built numpy and scipy
in Debug mode. Skipped entirely in Release mode because PyPI numpy and scipy
wheels bundle their own OpenBLAS in numpy/.libs/, and nothing else in the
Release LibPack consumes BLAS.
Uses the Ninja CMake generator rather than the default Visual Studio generator
because (a) the VS generator does not handle Fortran well and OpenBLAS needs
Flang for its Fortran sources, and (b) Ninja sidesteps an MSBuild
PlatformToolset resolution failure on VS 2026 installs that ship the v143
toolset without the matching Microsoft.VCToolsVersion.v143.default.props.
Ninja must be on the build host PATH at the time this runs (typically via
'pip install ninja' in the system Python, or a manual ninja.exe placement)."""
if self.mode != BuildMode.DEBUG:
print(
" Skipping OpenBLAS build in Release mode (numpy/scipy use bundled OpenBLAS from wheels)."
)
return
if self.skip_existing:
if os.path.exists(os.path.join(self.install_dir, "include", "openblas", "cblas.h")):
print(" Not rebuilding OpenBLAS, it is already in the LibPack")
return
extra_args = [
"-G",
"Ninja",
"-D BUILD_SHARED_LIBS=ON",
# DYNAMIC_ARCH=OFF: with DYNAMIC_ARCH=ON, kernel parameters like
# GEMM_UNROLL_MN expand to runtime struct-pointer field accesses
# (gotoblas -> ...). OpenBLAS uses those values as array sizes in C
# files (driver/level3/zherk_kernel.c, others), which produces VLA
# declarations that MSVC's C compiler does not support. In Release the
# optimizer constant-folds them; in Debug /Od does not, and the build
# fails. A single-arch build resolves the macros to compile-time
# constants and side-steps the issue. Debug performance is not a target.
"-D DYNAMIC_ARCH=OFF",
"-D USE_THREAD=ON",
"-D NUM_THREADS=64",
"-D BUILD_WITHOUT_LAPACK=OFF",
"-D NOFORTRAN=OFF",
"-D BUILD_TESTING=OFF",
"-D CMAKE_POLICY_VERSION_MINIMUM=3.5",
]
self._build_standard_cmake(extra_args=extra_args)
def python_exe(self):
if self.mode == BuildMode.RELEASE:
return os.path.join(self.install_dir, "bin", "python") + to_exe()
return os.path.join(self.install_dir, "bin", "python_d") + to_exe()
def _python_lib_path(self) -> Optional[str]:
"""Locate the versioned Python import library in the LibPack libs directory,
for example python314.lib (release) or python314_d.lib (debug). Returns None
if the libs directory or matching file does not yet exist, which is expected
before build_python has run."""
libs_dir = os.path.join(self.install_dir, "bin", "libs")
if not os.path.isdir(libs_dir):
return None
suffix = "_d" if self.mode == BuildMode.DEBUG else ""
pattern = re.compile(rf"^python\d{{2,}}{re.escape(suffix)}\.lib$")
for name in os.listdir(libs_dir):
if pattern.match(name):
return os.path.join(libs_dir, name)
return None
def _python_build_env(self):
"""Environment for the host Python that PCbuild\\build.bat -e launches to fetch
external sources. Some Python installs on Windows ship without a usable CA bundle,
which makes get_external.py fail with SSL: CERTIFICATE_VERIFY_FAILED when
downloading from GitHub. Point SSL_CERT_FILE at the certifi bundle that ships with
requests so the host Python can verify TLS. Honor any value the caller already
set."""
env = os.environ.copy()
if "SSL_CERT_FILE" not in env:
try:
import certifi
ca_bundle = certifi.where()
except ImportError:
ca_bundle = None
if ca_bundle and os.path.exists(ca_bundle):
env["SSL_CERT_FILE"] = ca_bundle
return env
def build_python(self, args=None):
if self.skip_existing:
if os.path.exists(self.python_exe()):
print(" Not rebuilding Python, it is already in the LibPack")
return
if sys.platform.startswith("win32"):
expected_exe_path = self.python_exe()
arch = "x64" if platform.machine() == "AMD64" else "ARM64"
path = "amd64" if platform.machine() == "AMD64" else "arm64"
env = self._python_build_env()
# When MSBuild's PlatformToolset selection chain cannot resolve a default
# VCToolsVersion for v143 (the case on Visual Studio 2026 installs that
# ship the v143 toolset but not Microsoft.VCToolsVersion.v143.default.props),
# MSBuild silently picks the newest installed compiler and then fails the
# toolset compatibility check. Force the version via PCbuild\\msbuild.rsp,
# which build.bat documents as the supported way to inject extra MSBuild
# flags. Command-line /p: cannot be used here because cmd's batch parameter
# parser splits on the '=' before build.bat passes %1..%9 through.
rsp_path = pathlib.Path("PCbuild") / "msbuild.rsp"
if self.msvc_tools_version:
rsp_path.write_text(
f"/p:VCToolsVersion={self.msvc_tools_version}\n", encoding="utf-8"
)
try:
self._run_streaming(
[
*self.init_script,
"&",
"PCbuild\\build.bat",
"-p",
arch,
"-c",
str(self.mode),
"-e",
],
"build_log.txt",
env=env,
)
except subprocess.CalledProcessError as e:
print("Python build failed")
if e.output:
print(e.output.decode("utf-8", errors="replace"))
exit(e.returncode)
except FileNotFoundError as e:
print(f"Could not find file: {e}")
exit(-1)
bin_dir = os.path.join(self.install_dir, "bin")
dll_dir = os.path.join(bin_dir, "DLLs")
lib_dir = os.path.join(bin_dir, "Lib")
libs_dir = os.path.join(bin_dir, "libs")
inc_dir = os.path.join(bin_dir, "Include")
tools_dir = os.path.join(bin_dir, "Tools")
os.makedirs(bin_dir, exist_ok=True)
os.makedirs(dll_dir, exist_ok=True)
os.makedirs(lib_dir, exist_ok=True)
os.makedirs(libs_dir, exist_ok=True)
os.makedirs(bin_dir, exist_ok=True)
os.makedirs(tools_dir, exist_ok=True)
tools_subs = ["i18n", "scripts"]
for sub in tools_subs:
os.makedirs(os.path.join(tools_dir, sub), exist_ok=True)
# NOTES:
# When installed via the Python installer, the top-level Python folder contains:
# python.exe
# python.pdb
# python3.dll
# python3xx.dll
# python3xx.pdb
# python3xx_d.dll
# python3xx_d.pdb
# python3_d.dll
# pythonw.exe
# pythonw.pdb
# pythonw_d.exe
# pythonw_d.pdb
# python_d.exe
# python_d.pdb
# vcruntime140.dll
# vcruntime140_1.dll
# It also contains 5 subdirectories: DLLs, include, Lib, libs, and Tools, plus LICENSE.txt
# DLLS folder contains *.pyd, *.pdb, and *.dll
# include contains the header file directory tree
# Lib contains the Python standard libraries
# libs contains the actual Python *.lib files (python3.lib and python3xx.lib and their debug equivalents
# Tools contains a number of subdirectories with Python scripts: i18n, scripts, and demo
# Finally, we also need the file "pyconfig.h" which is in yet another directory of the Python build, "PC"
shutil.copytree(f"PCBuild\\{path}", dll_dir, dirs_exist_ok=True)
shutil.copytree(f"Lib", lib_dir, dirs_exist_ok=True)
shutil.copytree(f"Include", inc_dir, dirs_exist_ok=True)
for sub in tools_subs:
shutil.copytree(f"Tools\\{sub}", os.path.join(tools_dir, sub), dirs_exist_ok=True)
# Figure out what version of Python we just built:
exe_name = "python.exe" if self.mode == BuildMode.RELEASE else "python_d.exe"
major, minor = self.get_python_version(os.path.join("PCBuild", path, exe_name)).split(
"."
)
# We ship the full stdlib in Lib/, but Windows Python auto-adds
# <bin>/python<major><minor>.zip to sys.path ahead of Lib/ whenever that file
# exists. A stale or partial such zip (for example one left in an incremental
# build tree) would shadow Lib/ and break stdlib imports, so remove it.
stdlib_zip = os.path.join(bin_dir, f"python{major}{minor}.zip")
if os.path.exists(stdlib_zip):
os.remove(stdlib_zip)
# Construct the list of files we expect to exist that need to be placed in the toplevel directory, or in
# libs:
move_to_bin = ["vcruntime"]
for base in ["python", f"python{major}", f"python{major}{minor}", "pythonw"]:
final = base
if self.mode == BuildMode.DEBUG:
final += "_d"
move_to_bin.append(final)
# They are all in the DLLs subdirectory now: move the ones that match:
for file in pathlib.Path(dll_dir).iterdir():
if file.is_file():
if file.stem in move_to_bin:
if file.suffix == ".lib":
target = os.path.join(libs_dir, file.name)
elif file.suffix in [".dll", ".exe", ".pdb"]:
target = os.path.join(bin_dir, file.name)
else:
continue
if os.path.exists(target):
os.unlink(target)
file.rename(target)
pyconfig = os.path.join("PC", "pyconfig.h")
target = os.path.join(inc_dir, "pyconfig.h")
if not os.path.exists(pyconfig):
print("ERROR: Could not locate pyconfig.h, cannot complete installation of Python")
exit(1)
if os.path.exists(target):
os.unlink(target)
print(f"Copying {pyconfig} to {target}")
shutil.copyfile(pyconfig, target)
if self.mode == BuildMode.DEBUG:
# FindPython on Windows searches for the release-named library and runtime
# DLL independently of any debug-variant hint. Without same-named files in
# the LibPack the search escapes to a system Python install and downstream
# find_dependency(Python COMPONENTS Development) calls (boost_python's
# installed config) reject Development.Embed because the debug library and
# release runtime resolve to different installs. Same-content release-named
# copies keep every component lookup inside the LibPack.
versioned = f"python{major}{minor}"
shutil.copy(
os.path.join(libs_dir, f"{versioned}_d.lib"),
os.path.join(libs_dir, f"{versioned}.lib"),
)
shutil.copy(
os.path.join(bin_dir, f"{versioned}_d.dll"),
os.path.join(bin_dir, f"{versioned}.dll"),
)
# FreeCAD's CMake (and CMake's own FindPython) searches for python.exe
# and pythonw.exe by their release names. Provide same-content copies
# next to the debug-suffixed originals so downstream consumers find a
# Python executable inside the LibPack instead of escaping to a system
# install with a different ABI.
for exe_pair in (("python_d.exe", "python.exe"), ("pythonw_d.exe", "pythonw.exe")):
src = os.path.join(bin_dir, exe_pair[0])
dst = os.path.join(bin_dir, exe_pair[1])
if os.path.exists(src):
shutil.copy(src, dst)
# Python's installed import libraries live at <install>/bin/libs/, the
# location FindPython expects. However, anything that transitively
# includes Python.h triggers `#pragma comment(lib, "pythonXY_d.lib")`,
# and that auto-link only finds the file when the linker's search path
# already covers <install>/bin/libs/. Downstream consumers (such as
# FreeCAD's own CMake) typically only add <install>/lib/ to the linker
# search path. Mirror both libs into <install>/lib/ so the auto-link
# resolves without requiring downstream configuration changes.
top_lib_dir = os.path.join(self.install_dir, "lib")
for lib_name in (f"{versioned}_d.lib", f"{versioned}.lib"):
src = os.path.join(libs_dir, lib_name)
if os.path.exists(src):
shutil.copy(src, os.path.join(top_lib_dir, lib_name))
site_packages_dir = os.path.join(lib_dir, "site-packages")
os.makedirs(site_packages_dir, exist_ok=True)
with open(
os.path.join(site_packages_dir, "sitecustomize.py"),
"w",
encoding="utf-8",
) as f:
f.write(_SITECUSTOMIZE_DEBUG_SHIM)
else:
raise NotImplemented("Non-Windows compilation of Python is not implemented yet")
def get_python_version(self, exe: str = None) -> str:
if exe is None:
path_to_python = self.python_exe()
else:
path_to_python = exe
try:
result = subprocess.run([path_to_python, "--version"], capture_output=True, check=True)
_, _, version_number = result.stdout.decode("utf-8").strip().partition(" ")
components = version_number.split(".")
python_version = f"{components[0]}.{components[1]}"
return python_version
except subprocess.CalledProcessError as e:
print("ERROR: Failed to run LibPack's Python executable")
print(e.stdout.decode("utf-8"))
if e.stderr:
print(e.stderr.decode("utf-8"))
exit(1)
def _build_pip(self, _=None):
print(" Installing the latest pip")
path_to_python = self.python_exe()
try:
self._run_streaming([path_to_python, "-m", "ensurepip", "--upgrade"], "pip_log.txt")
self._run_streaming(
[path_to_python, "-m", "pip", "install", "--upgrade", "pip"], "pip_log.txt"
)
except subprocess.CalledProcessError as e:
print("ERROR: Failed to run LibPack's Python executable")
if e.output:
print(e.output.decode("utf-8", errors="replace"))
exit(1)
def _filter_debug_requirements(self, requirements):
kept = [
spec
for spec in requirements
if _requirement_package_name(spec) not in _DEBUG_BUILD_EXCLUDED_REQUIREMENTS
]
kept_names = {_requirement_package_name(spec) for spec in kept}
for tool in _DEBUG_BUILD_REQUIRED_TOOLING:
if tool.lower() not in kept_names:
kept.append(tool)
return kept
def _install_debug_library_aliases(self):
"""Create release-named copies of debug-suffixed import libraries so that
source-built Python C extensions can find them under their conventional names.
Pillow's setup.py looks for 'zlib' and 'libpng16' literally, ignoring CMake's
'd' debug suffix; numpy and scipy search for BLAS by similarly fixed names.
This is a flat list of known-needed aliases rather than a heuristic sweep,
because some legitimate library names happen to end in 'd' for unrelated
reasons. Aliases are only created when both the debug source exists and the
release target does not."""
if self.mode != BuildMode.DEBUG:
return
aliases = (
("lib/zd.lib", "lib/zlib.lib"),
("lib/libpng16d.lib", "lib/libpng16.lib"),
("lib/libxml2d.lib", "lib/libxml2.lib"),
("lib/libxsltd.lib", "lib/libxslt.lib"),
("lib/libexsltd.lib", "lib/libexslt.lib"),
# lxml's setup.py hardcodes 'iconv' in its Windows link line; win-iconv builds with
# the debug 'd' postfix, so expose iconvd.lib under the undecorated name it expects.
("lib/iconvd.lib", "lib/iconv.lib"),
)
for src, dst in aliases:
src_path = os.path.join(self.install_dir, src)
dst_path = os.path.join(self.install_dir, dst)
if os.path.exists(src_path) and not os.path.exists(dst_path):
shutil.copy(src_path, dst_path)
def _install_python_requirements(self, requirements):
if self.mode == BuildMode.DEBUG:
requirements = self._filter_debug_requirements(requirements)
sentinel = "packaging" if self.mode == BuildMode.DEBUG else "PIL"
if self.skip_existing:
if os.path.exists(
os.path.join(self.install_dir, "bin", "Lib", "site-packages", sentinel)
):
print(" Not re-installing Python requirements, they are already in the LibPack")
return
if self.mode == BuildMode.DEBUG:
# The main install below uses --no-build-isolation, which requires PEP 517
# build backends (setuptools, meson-python, etc.) to already be present in the
# LibPack environment. Pip's resolver is single-pass: it cannot install a
# backend in the same install request that needs the backend to fetch metadata
# for some other package. Bootstrap the tooling first via a separate pip call
# with normal isolated builds (the tooling itself is pure-Python or binary, so
# isolation is harmless there).
print(" Installing build-time tooling")
self._run_pip_install(
list(_DEBUG_BUILD_REQUIRED_TOOLING),
no_build_isolation=False,
no_binary_packages=(),
)
self._install_debug_library_aliases()
print(" Installing the following requirements (and their dependencies) using pip:")
for req in requirements:
print(" " + req)
# meson-python defaults to "-Dbuildtype=release -Db_ndebug=if-release -Db_vscrt=md"
# regardless of the target Python's debug-ness. b_vscrt=md forces /MD (release
# CRT) independently of buildtype, so overriding only buildtype leaves extensions
# linked against VCRUNTIME140.dll. Pass both -Dbuildtype=debug (so meson selects
# debug compile flags and no NDEBUG) and -Db_vscrt=mdd (so the linker uses
# ucrtbased.dll and VCRUNTIME140D.dll).
# No explicit blas / lapack option here: numpy and scipy auto-detect OpenBLAS
# via the openblas.pc that pkg-config sees through PKG_CONFIG_PATH set in the
# subprocess env below. Most other meson-python projects (contourpy, etc.) do
# not declare a blas option and would error if we passed one.
config_settings = (
(
("setup-args", "-Dbuildtype=debug"),
("setup-args", "-Db_vscrt=mdd"),
# cpp_std=c++17 is required for pythran-generated C++ in scipy. Pythran's
# generated headers still use std::result_of_t, which C++20 removed.
# MSVC's default standard is newer than C++17 in current toolsets, so we
# pin it explicitly. Numpy's own meson.build already pins c++17, so this
# change is a no-op for numpy and a fix for scipy.
("setup-args", "-Dcpp_std=c++17"),
)
if self.mode == BuildMode.DEBUG
else ()
)
# Scipy needs an extra meson option that other meson-python projects (numpy in
# particular) reject as unknown. Pull it out for a separate pip pass.
scipy_specs: list = []
if self.mode == BuildMode.DEBUG:
scipy_specs = [r for r in requirements if _requirement_package_name(r) == "scipy"]
if scipy_specs:
requirements = [r for r in requirements if _requirement_package_name(r) != "scipy"]
self._run_pip_install(
requirements,
no_build_isolation=(self.mode == BuildMode.DEBUG),
no_binary_packages=(_DEBUG_BUILD_FROM_SOURCE if self.mode == BuildMode.DEBUG else ()),
config_settings=config_settings,
)
if scipy_specs:
print(" Installing scipy with use-pythran=false")
# no_deps is required here: scipy's runtime dependency numpy was already
# source-built and installed in the main pass above. Without --no-deps, this
# separate resolution would pull the latest numpy release wheel (which carries
# no debug _d.pyd), overwriting the source-built debug numpy's Python files and
# leaving the debug C extension and the Python layer at mismatched versions.
self._run_pip_install(
scipy_specs,
no_build_isolation=True,
no_binary_packages=("scipy",),
no_deps=True,
# Pythran 0.18 headers fail to compile under MSVC for scipy's
# pythran-translated modules (a ref-qualifier overload mismatch in
# ndarray.hpp). Disabling pythran skips those modules; scipy provides
# pure-Python fallbacks for each.
config_settings=config_settings + (("setup-args", "-Duse-pythran=false"),),
)
def _native_pkgconf_path(self, env) -> Optional[str]:
"""Return the path to pkgconf-pypi's bundled native pkgconf executable, or None if the
pkgconf package is not yet installed in the LibPack. Uses the package's documented
get_executable() API rather than hardcoding its internal .bin layout."""
try:
result = subprocess.run(
[self.python_exe(), "-c", "import pkgconf; print(pkgconf.get_executable())"],
capture_output=True,
text=True,
env=env,
)
except OSError:
return None
if result.returncode != 0:
return None
path = result.stdout.strip()
return path if path and os.path.exists(path) else None
def _run_pip_install(
self,
requirements,
no_build_isolation,
no_binary_packages,
config_settings=(),
no_deps=False,
):
path_to_python = self.python_exe()
pip_args = [
path_to_python,
"-m",
"pip",
"install",
"--upgrade",
"--ignore-installed",
"--no-warn-script-location",
]
if no_deps:
pip_args.append("--no-deps")
if no_build_isolation:
pip_args.append("--no-build-isolation")
for pkg in no_binary_packages:
pip_args.extend(["--no-binary", pkg])
for key, value in config_settings:
pip_args.append(f"--config-settings={key}={value}")