Repository navigation
Expand file tree
/
Copy pathunix_builder.py
More file actions
2375 lines (2049 loc) · 107 KB
/
Copy pathunix_builder.py
File metadata and controls
2375 lines (2049 loc) · 107 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
"""
Unix builder for SCLS packages
Builds directly without creating SPEC files
Supports macOS, Linux, and other Unix-like systems
"""
import copy
import os
import sys
import argparse
import platform
import shutil
import subprocess
import tarfile
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Optional
from build_common import (
BuildError, load_recipe, load_flavor, load_description,
get_optimization_flags, download_source, extract_source,
run_command, setup_environment,
get_configure_args, get_cmake_args, get_parallel_jobs,
clean_libtool_files,
check_no_triplet_programs,
should_build_package,
read_extra_packages,
require_buildable,
check_package_installed,
write_registry_entry,
paths_need_sudo, publish_needs_sudo, run_sudo,
sudo_publish_tree, sudo_write_registry_entry,
add_rpath_for_libdirs,
get_package_dependencies,
get_subpackages_for_flavor,
get_subpackage_dependencies,
split_files_by_subpackage,
get_interface_args,
resolve_flavor_key,
resolve_gcc_runtime_lib,
gcc_identity,
NO_AS_NEEDED,
)
from patch_common import (
copy_patches_to_sources,
apply_patches,
apply_configure_environment,
process_env_operations
)
from math_common import ( get_math_link_line, get_math_compile_flags,
compiler_family, openmp_flag,
get_mkl_serial_link_line, get_mkl_mpi_link_line,
get_cuda_path )
class UnixBuilder:
def __init__(self, package: str, flavor: str = "macos"):
self.package = package
self.flavor_name = flavor
# Load configurations. Deepcopy both so platform/flavor/MPI overrides
# below don't mutate cached dicts shared with other builders.
self.recipe = copy.deepcopy(load_recipe(package))
self.flavor = copy.deepcopy(load_flavor(flavor))
# Detect and validate platform from flavor
self.platform = self.flavor.get('platform', 'linux')
# Merge platform-specific recipe sections (linux: or macos:)
# This allows recipes to have different version/source per platform
if self.platform in self.recipe:
platform_section = self.recipe[self.platform]
# Merge version if specified
if 'version' in platform_section:
self.recipe['version'] = platform_section['version']
# Merge source if specified
if 'source' in platform_section:
if 'source' not in self.recipe:
self.recipe['source'] = {}
self.recipe['source'].update(platform_section['source'])
# Apply flavor-specific overrides (e.g., version, source URL).
# self.recipe was deepcopied at load time, so mutations here are safe.
from build_common import apply_flavor_overrides
self.recipe = apply_flavor_overrides(self.recipe, self.flavor)
# Validate platform
supported_platforms = ['macos', 'linux']
if self.platform not in supported_platforms:
raise BuildError(f"Platform {self.platform} not supported. Use: {supported_platforms}")
# Check if package should be built; flavor.conf's extra_packages: can
# opt an excluded recipe in, as on the RPM path.
require_buildable(package, self.recipe, self.flavor, flavor)
# Setup paths - mirror rpmbuild structure
self.prefix = Path(self.flavor['prefix'])
# ['sudo'] once install() finds the live prefix unwritable; the later
# prefix writers (final_post, install_name_tool, registry) read it.
self._sudo: List[str] = []
self.install_prefix = None
self.project_root = Path(__file__).parent.parent # Go up from python/ to project root
self.rpmbuild = self.project_root / "work"
self.sources_dir = self.rpmbuild / "sources"
self.build_dir = self.rpmbuild / "build"
self.work_dir = self.build_dir
self.rpms_dir = self.rpmbuild / "pkgs"
self.srpms_dir = self.rpmbuild / "spkgs"
self.specs_dir = self.rpmbuild / "specs"
self.patches_root = self.project_root / "patches"
# this is set in run after extracting the package
self.source_dir = ""
self.nprocs = get_parallel_jobs() # honours SCLS_JOBS
# Platform-specific settings
if self.platform == 'macos':
# macOS: get SDK path and Darwin version. `xcrun` returns the
# versioned SDK directory (e.g. .../SDKs/MacOSX26.2.sdk), but
# Xcode also ships an unversioned `MacOSX.sdk` alongside it
# (either as the real directory with versioned symlinks, or
# vice versa, depending on the Xcode release). Prefer the
# unversioned path so that paths baked into artifacts —
# notably gcc's `--with-sysroot`, which is compiled into the
# binary — survive a Command Line Tools / Xcode upgrade.
xcrun_sdk = subprocess.check_output(
["xcrun", "--sdk", "macosx", "--show-sdk-path"], text=True).strip()
unversioned = os.path.join(os.path.dirname(xcrun_sdk), "MacOSX.sdk")
self.sdk = unversioned if os.path.exists(unversioned) else xcrun_sdk
uname_r = subprocess.check_output(["uname", "-r"], text=True).strip()
# Detect architecture
machine = platform.machine()
if machine == 'arm64':
self.host = f"aarch64-apple-darwin{uname_r}"
else:
self.host = f"x86_64-apple-darwin{uname_r}"
self.lib_ext = '.dylib'
self.soname_flag = '-install_name'
else:
# Linux and other Unix
self.sdk = "" # No SDK needed
# Use gcc -dumpmachine to get the correct host triplet
try:
result = subprocess.run(['gcc', '-dumpmachine'], capture_output=True, text=True)
self.host = result.stdout.strip()
except Exception:
machine = platform.machine()
self.host = f"{machine}-unknown-linux-gnu"
self.lib_ext = '.so'
self.soname_flag = '-soname'
# System library directory for placeholder substitution. RHEL puts
# 64-bit system libs under /usr/lib64; Debian/Ubuntu uses multiarch
# paths under /usr/lib/<triplet>. Detect by probing for the multiarch
# dir derived from the host triplet.
if self.platform == 'macos':
self.system_libdir = '/usr/lib'
elif Path(f'/usr/lib/{self.host}').is_dir():
self.system_libdir = f'/usr/lib/{self.host}'
else:
self.system_libdir = '/usr/lib64'
# Feature flags
self.openmp = False
self.mpi = False
self.cuda = False
self.math = None # 'reference', 'mkl', 'openblas', or None
# flags to be filled later
self.cflags = ""
self.cxxflags = ""
self.fcflags = ""
self.ldflags = ""
self.math_flags = ""
self.math_ldflags = ""
# Extra source info for recipe references (populated during build)
self.extra_source_info = {}
# Create directories
for d in [self.sources_dir, self.build_dir, self.rpms_dir, self.srpms_dir, self.specs_dir]:
d.mkdir(parents=True, exist_ok=True)
def update_config_scripts(self, source_dir: Path) -> None:
"""Update config.guess and config.sub for macOS compatibility"""
# Look for existing config.guess/config.sub
for name in ['config.guess', 'config.sub']:
# Common locations
for subdir in ['.', 'build-aux', 'config']:
config_file = source_dir / subdir / name
if config_file.exists():
print(f"Found {name} at {config_file}")
# For now, we'll rely on the package's version
# In the future, we could replace with our custom script
if name == 'config.guess':
# Make sure it's executable
config_file.chmod(0o755)
def _prepare_build_env(self, env: Dict[str, str], pkg_config_path: str,
skip_compiler_env: bool = False) -> Dict[str, str]:
"""Set up compiler flags and environment variables for building.
Centralizes the MPI compiler override, optimization flags, ldflags,
math flags, and environment variable assignment that was previously
duplicated across every configure_type branch.
Args:
env: The environment dict to modify.
pkg_config_path: PKG_CONFIG_PATH value to set.
skip_compiler_env: If True, don't set CC/CXX/FC or CFLAGS/LDFLAGS
in env (used by 'custom' type when package manages its own).
Returns:
The modified environment dict.
"""
# Override compilers for MPI
if self.mpi and not skip_compiler_env:
env['CC'] = 'mpicc'
env['CXX'] = 'mpicxx'
env['FC'] = 'mpifort'
env['FF'] = 'mpifort'
env['F77'] = 'mpifort'
# Get optimization flags
self.cflags, self.cxxflags, self.fcflags = get_optimization_flags(
self.recipe, self.flavor, env.get('CC', 'gcc')
)
# Get ldflags from flavor
self.ldflags = self.flavor['flags'].get('ldflags', '').replace('%{prefix}', str(self.prefix))
# Link like el9: see build_common.NO_AS_NEEDED. It goes first so it governs
# every library after it, including the math line appended below.
if self.platform == 'linux':
self.ldflags = f"{NO_AS_NEEDED} {self.ldflags}".strip()
# Add math flags to existing flags
if self.math:
self.cflags += f" {self.math_flags}"
self.cxxflags += f" {self.math_flags}"
self.fcflags += f" {self.math_flags}"
self.ldflags += f" {self.math_ldflags}"
# The OpenMP flag is owned by features.openmp, not features.math —
# see the matching block in rpm_builder for why the distinction
# matters to the cmake standard-libraries line, and why this is
# scoped to cmake rather than every features.openmp recipe.
configure_type_is_cmake = (
self.recipe.get('configure', {}).get('type') == 'cmake')
if self.openmp and configure_type_is_cmake:
omp = openmp_flag(self.flavor)
if omp not in self.cflags:
self.cflags += f" {omp}"
if omp not in self.cxxflags:
self.cxxflags += f" {omp}"
if omp not in self.fcflags:
self.fcflags += f" {omp}"
# Set environment variables
if not skip_compiler_env:
env['CFLAGS'] = self.cflags
env['CXXFLAGS'] = self.cxxflags
env['FFLAGS'] = self.fcflags
env['FCFLAGS'] = self.fcflags
env['LDFLAGS'] = add_rpath_for_libdirs(self.ldflags, self.platform)
env['PKG_CONFIG_PATH'] = pkg_config_path
return env
def configure(self, source_dir: Path, env: Dict[str, str]) -> None:
"""Run configure step"""
configure_type = self.recipe.get('configure', {}).get('type', 'autotools')
pkg_config_path = str(self.prefix / 'lib/pkgconfig') + ':' + "/opt/X11/lib/pkgconfig:/usr/lib/pkgconfig"
# Detect features
features = self.recipe.get('features', {})
self.openmp = features.get('openmp', False)
self.mpi = features.get('mpi', False)
self.math = features.get('math', None)
# Compute math flags once (used by _prepare_build_env via self.math_flags)
if self.math:
self.math_flags = get_math_compile_flags(self.flavor, self.recipe)
self.math_ldflags = get_math_link_line(self.flavor, self.recipe)
# Both strings come out of math_common carrying %{mklroot} and
# %{prefix} placeholders (math_common.py:242,275-276,282,310,326,
# 372,379) and both are appended verbatim to C/CXX/FC/LDFLAGS
# below, so both need expanding. Expanding only math_ldflags'
# %{mklroot} left two live defects: gfortran received a literal
# -I%{mklroot}/include (a -Wmissing-include-dirs warning, and no
# MKL headers), and every ScaLAPACK consumer linked
# -Wl,-rpath,%{prefix}/lib, baking a literal "%{prefix}/lib" into
# the shipped RUNPATH (observed on libmumps_common.so and
# libpetsc.so for the mkl flavor). rpm_builder.py:1349-1350 always
# expanded both, so this was a unix/deb-path-only divergence.
mklroot = os.environ.get('MKLROOT', '/opt/intel/oneapi/mkl/latest')
for macro, value in (('%{mklroot}', mklroot),
('%{prefix}', str(self.prefix))):
self.math_flags = self.math_flags.replace(macro, value)
self.math_ldflags = self.math_ldflags.replace(macro, value)
# Resolve install_prefix (shared across all types)
if 'configure' in self.recipe and 'install_prefix' in self.recipe['configure']:
self.install_prefix = self.prefix / self.recipe['configure']['install_prefix']
else:
self.install_prefix = self.prefix
if configure_type == 'autotools':
# Update config scripts
self.update_config_scripts(source_dir)
# Get configure arguments
args = get_configure_args(self.recipe, self.host, self.flavor, self.prefix, self.install_prefix)
# Add interface-specific arguments (LP64/ILP64)
args.extend(get_interface_args(self.recipe, self.flavor))
env = self._prepare_build_env(env, pkg_config_path)
env = apply_configure_environment(env, self.recipe, self.flavor, self.prefix, source_dir, self.sdk)
env = self.apply_platform_env(env)
# apply special clang hack (macOS only - removes -qversion flag for clang compatibility)
if self.flavor.get('platform') == 'macos':
cmd = "for f in $(find . -name configure); do sed -i '' 's/--version -v -V -qversion/--version -v/g' $f; done"
run_command(['sh', '-c', cmd], source_dir, env, "pre-configure")
# Run any pre-configure commands
if 'configure' in self.recipe and 'pre' in self.recipe['configure']:
for cmd in self.recipe['configure']['pre']:
checked_cmd = self.check_args([cmd])[0]
run_command(['sh', '-c', checked_cmd], source_dir, env, "pre-configure")
# Run platform-specific pre-configure commands
self.run_platform_pre(source_dir, env)
# Run configure. Some packages (notably GCC) must be configured
# from a separate build directory: an in-source GCC build forces
# host_subdir=host-<triplet> (so xgcc lives under that subdir),
# while the target libraries' gcc_objdir stays ../../gcc. The
# result is libatomic_asneeded.so being written where the
# bootstrap xgcc can't find it ("cannot find -latomic_asneeded").
# Configuring out-of-source keeps host_subdir=. and the paths
# consistent. Pre-configure steps still run in source_dir so
# in-tree sources (gmp/mpfr/mpc) land in the source root.
# out_of_source may be a plain bool or a flavor-keyed mapping
# (e.g. {lbl: true}) so a recipe can opt in per flavor.
oos = self.recipe.get('configure', {}).get('out_of_source', False)
if isinstance(oos, dict):
out_of_source = bool(resolve_flavor_key(self.flavor, oos))
else:
out_of_source = bool(oos)
if out_of_source:
build_dir = source_dir / 'build'
build_dir.mkdir(exist_ok=True)
cmd = self.check_args([str(source_dir / 'configure')] + args)
run_command(cmd, build_dir, env, "configure")
else:
build_dir = source_dir
cmd = self.check_args(['./configure'] + args)
run_command(cmd, source_dir, env, "configure")
# Run any post-configure commands
if 'configure' in self.recipe and 'post' in self.recipe['configure']:
for cmd in self.recipe['configure']['post']:
checked_cmd = self.check_args([cmd])[0]
run_command(['sh', '-c', checked_cmd], build_dir, env, "post-configure")
return build_dir
elif configure_type == 'cmake':
# Create build directory
build_dir = source_dir / 'build'
build_dir.mkdir(exist_ok=True)
# Get CMake arguments
args = get_cmake_args(
self.recipe, self.host, self.flavor, self.prefix, self.install_prefix,
build_libdir=build_dir / 'lib',
no_as_needed=(self.platform == 'linux'))
# Add interface-specific arguments (LP64/ILP64)
args.extend(get_interface_args(self.recipe, self.flavor))
env = self._prepare_build_env(env, pkg_config_path)
# apply_configure_environment ONCE, after flags are finalized
env = apply_configure_environment(env, self.recipe, self.flavor, self.prefix, source_dir, self.sdk)
env = self.apply_platform_env(env)
# Run any pre-configure commands
if 'configure' in self.recipe and 'pre' in self.recipe['configure']:
for cmd in self.recipe['configure']['pre']:
checked_cmd = self.check_args([cmd])[0]
run_command(['sh', '-c', checked_cmd], source_dir, env, "pre-configure")
# Run platform-specific pre-configure commands
self.run_platform_pre(source_dir, env)
# Run CMake
cmd = self.check_args(['cmake', '..'] + args)
run_command(cmd, build_dir, env, "cmake configure")
# Run any post-configure commands
if 'configure' in self.recipe and 'post' in self.recipe['configure']:
for cmd in self.recipe['configure']['post']:
checked_cmd = self.check_args([cmd])[0]
run_command(['sh', '-c', checked_cmd], source_dir, env, "post-configure")
# Update source_dir to build_dir for subsequent steps
return build_dir
elif configure_type == 'meson':
# Mirrors the RPM template's meson block: `meson setup build`
# from the source root, with the same argument list the RPM
# builder renders (--prefix first, then args, flavor_args,
# interface args). Meson reads CC/CXX/FC and the *FLAGS from env.
# Returns source_dir, not build/: build() and install() address
# the build tree with `-C build`, so test commands and install
# hooks run from the source root on every builder, as rpmbuild's
# %check and %install do.
env = self._prepare_build_env(env, pkg_config_path)
env = apply_configure_environment(env, self.recipe, self.flavor, self.prefix, source_dir, self.sdk)
env = self.apply_platform_env(env)
if 'configure' in self.recipe and 'pre' in self.recipe['configure']:
for cmd in self.recipe['configure']['pre']:
checked_cmd = self.check_args([cmd])[0]
run_command(['sh', '-c', checked_cmd], source_dir, env, "pre-configure")
self.run_platform_pre(source_dir, env)
args = [f"--prefix={self.install_prefix}"]
args.extend(self.recipe.get('configure', {}).get('args', []))
if 'configure' in self.recipe and 'flavor_args' in self.recipe['configure']:
flavor_specific = resolve_flavor_key(self.flavor, self.recipe['configure']['flavor_args'])
if flavor_specific:
args.extend(flavor_specific)
args.extend(get_interface_args(self.recipe, self.flavor))
cmd = self.check_args(['meson', 'setup', 'build'] + args)
run_command(cmd, source_dir, env, "meson setup")
if 'configure' in self.recipe and 'post' in self.recipe['configure']:
for cmd in self.recipe['configure']['post']:
checked_cmd = self.check_args([cmd])[0]
run_command(['sh', '-c', checked_cmd], source_dir, env, "post-configure")
return source_dir
elif configure_type == 'custom':
# Custom configuration system (like OpenSSL's ./config or PETSc's ./configure)
skip_compiler_env = self.recipe.get('configure', {}).get('skip_compiler_env', False)
# Custom type may need bootstrap compiler detection before _prepare_build_env
if not skip_compiler_env and not self.mpi:
is_bootstrap = self.recipe.get('bootstrap', False)
if is_bootstrap and 'bootstrap_compilers' in self.flavor:
compilers = self.flavor['bootstrap_compilers']
else:
compilers = self.flavor.get('compilers', {})
env['CC'] = compilers.get('cc', 'gcc')
env['CXX'] = compilers.get('cxx', 'g++')
env['FC'] = compilers.get('fc', 'gfortran')
env = self._prepare_build_env(env, pkg_config_path, skip_compiler_env=skip_compiler_env)
env = apply_configure_environment(env, self.recipe, self.flavor, self.prefix, source_dir, self.sdk)
env = self.apply_platform_env(env)
# Run any pre-configure commands
if 'configure' in self.recipe and 'pre' in self.recipe['configure']:
for cmd in self.recipe['configure']['pre']:
checked_cmd = self.check_args([cmd])[0]
run_command(['sh', '-c', checked_cmd], source_dir, env, "pre-configure")
# Run platform-specific pre-configure commands
self.run_platform_pre(source_dir, env)
# Get custom configure command and arguments
configure_cmd = self.recipe.get('configure', {}).get('command', './config')
args = self.recipe.get('configure', {}).get('args', [])
# Add prefix to args if not already present
prefix_arg = f"--prefix={self.install_prefix}"
if not any(arg.startswith('--prefix') for arg in args):
args.insert(0, prefix_arg)
# Apply flavor-specific args
if 'configure' in self.recipe and 'flavor_args' in self.recipe['configure']:
flavor_specific = resolve_flavor_key(self.flavor, self.recipe['configure']['flavor_args'])
if flavor_specific:
args.extend(flavor_specific)
# Run custom configure command
cmd = self.check_args([configure_cmd] + args)
run_command(cmd, source_dir, env, "custom configure")
# Run any post-configure commands
if 'configure' in self.recipe and 'post' in self.recipe['configure']:
for cmd in self.recipe['configure']['post']:
checked_cmd = self.check_args([cmd])[0]
run_command(['sh', '-c', checked_cmd], source_dir, env, "post-configure")
elif configure_type == 'none':
# No configuration step needed (e.g., simple Makefiles)
env = self._prepare_build_env(env, pkg_config_path)
print("Skipping configure step (type: none)")
elif configure_type == 'custom_makefile':
env = self._prepare_build_env(env, pkg_config_path)
env = apply_configure_environment(env, self.recipe, self.flavor, self.prefix, source_dir, self.sdk)
self.process_custom_makefile(source_dir, env)
else:
raise BuildError(f"Unknown configure type: {configure_type}")
return source_dir
def check_args(self, cmd):
if self.platform == 'macos':
cmd = [s.replace('sed -i', 'gsed -i') for s in cmd]
cmd = [s.replace('%{prefix}', str(self.prefix)) for s in cmd]
cmd = [s.replace('%{install_prefix}', str(self.install_prefix)) for s in cmd]
cmd = [s.replace('%{sdk}', self.sdk) for s in cmd]
cmd = [s.replace('%{host}', self.host) for s in cmd]
cmd = [s.replace('%{nprocs}', str(self.nprocs)) for s in cmd]
# RPM's parallel-make macro. rpmbuild expands this inside the
# spec; we're outside rpmbuild's rendering pipeline, so expand
# it ourselves whenever a recipe uses it (test commands in
# blaze, etc.). Both forms (%{_smp_mflags} and %{?_smp_mflags})
# map to `-j<nprocs>`; the `?` is RPM's "expand if defined"
# operator and is moot once we substitute unconditionally.
cmd = [s.replace('%{?_smp_mflags}', f'-j{self.nprocs}') for s in cmd]
cmd = [s.replace('%{_smp_mflags}', f'-j{self.nprocs}') for s in cmd]
cmd = [s.replace('%{srcdir}', str(self.source_dir)) for s in cmd]
cmd = [s.replace('%{cflags}', str(self.cflags)) for s in cmd]
cmd = [s.replace('%{cxxflags}', str(self.cxxflags)) for s in cmd]
cmd = [s.replace('%{fcflags}', str(self.fcflags)) for s in cmd]
cmd = [s.replace('%{ldflags}', str(self.ldflags)) for s in cmd]
cmd = [s.replace('%{math_flags}', str(self.math_flags)) for s in cmd]
cmd = [s.replace('%{math_ldflags}', str(self.math_ldflags)) for s in cmd]
cmd = [s.replace('%{sources}', str(self.sources_dir)) for s in cmd]
cmd = [s.replace('%{version}', str(self.recipe['version'])) for s in cmd]
cmd = [s.replace('%{name}', str(self.recipe['name'])) for s in cmd]
# Compilers (from flavor, with bootstrap fallback)
is_bootstrap = self.recipe.get('bootstrap', False)
if is_bootstrap and 'bootstrap_compilers' in self.flavor:
compilers = self.flavor['bootstrap_compilers']
else:
compilers = self.flavor.get('compilers', {})
cmd = [s.replace('%{cc}', compilers.get('cc', 'gcc')) for s in cmd]
cmd = [s.replace('%{cxx}', compilers.get('cxx', 'g++')) for s in cmd]
cmd = [s.replace('%{fc}', compilers.get('fc', 'gfortran')) for s in cmd]
# MPI compiler wrappers
cmd = [s.replace('%{mpicc}', 'mpicc') for s in cmd]
cmd = [s.replace('%{mpicxx}', 'mpicxx') for s in cmd]
cmd = [s.replace('%{mpifort}', 'mpifort') for s in cmd]
# Library extension
cmd = [s.replace('%{libext}', self.lib_ext) for s in cmd]
# Installed SCLS GCC identity, for recipes that must name GCC's own
# header directory (<prefix>/include/c++/<version>/<machine>). Probed
# lazily so builds that never use the macros never spawn the compiler.
if any('%{gcc_version}' in s or '%{gcc_machine}' in s for s in cmd):
gcc_version, gcc_machine = gcc_identity(self.prefix, self.flavor)
cmd = [s.replace('%{gcc_version}', gcc_version) for s in cmd]
cmd = [s.replace('%{gcc_machine}', gcc_machine) for s in cmd]
# CUDA paths and architectures. Use math_common.get_cuda_path so the
# unix path matches rpm_builder's substitution; the legacy
# nvidia.cuda_path key is not present in any flavor YAML.
if self.flavor.get('nvidia'):
cuda_path = get_cuda_path(self.flavor)
else:
cuda_path = ''
cuda_archs = self.flavor.get('nvidia', {}).get('architectures', '')
cmd = [s.replace('%{cuda}', cuda_path) for s in cmd]
cmd = [s.replace('%{cuda_architectures}', cuda_archs) for s in cmd]
# MKL paths and linker flags (canonical definitions in math_common).
# MKLROOT comes from the environment, not from flavor YAML — no flavor
# carries a 'mklroot' key, so the previous flavor-dict lookup always
# fell through to the default. Aligns with rpm_builder.__init__.
mklroot = os.environ.get('MKLROOT', '/opt/intel/oneapi/mkl/latest')
cmd = [s.replace('%{mklroot}', mklroot) for s in cmd]
mkl_linker = get_mkl_serial_link_line(self.flavor)
mkl_mpi_linker = get_mkl_mpi_link_line(self.flavor).replace('%{prefix}', str(self.prefix))
cmd = [s.replace('%{mkl_linker_flags}', mkl_linker) for s in cmd]
cmd = [s.replace('%{mkl_mpi_linker_flags}', mkl_mpi_linker) for s in cmd]
# Platform (linux or macos)
cmd = [s.replace('%{platform}', self.platform) for s in cmd]
# System library paths (zlib, etc.) - check system_libraries setting in flavor
system_libs = self.flavor.get('system_libraries', {})
if system_libs.get('zlib', False):
# Use system zlib
if self.platform == 'linux':
cmd = [s.replace('%{zlib_include}', '/usr/include') for s in cmd]
cmd = [s.replace('%{zlib_lib}', f'{self.system_libdir}/libz.so') for s in cmd]
else:
cmd = [s.replace('%{zlib_include}', f'{self.sdk}/usr/include') for s in cmd]
cmd = [s.replace('%{zlib_lib}', f'{self.sdk}/usr/lib/libz.tbd') for s in cmd]
else:
# Use our built zlib
cmd = [s.replace('%{zlib_include}', f'{self.prefix}/include') for s in cmd]
cmd = [s.replace('%{zlib_lib}', f'{self.prefix}/lib/libz{self.lib_ext}') for s in cmd]
# libgomp: ask the active gcc, so the path tracks system gcc,
# gcc-toolset/devtoolset, and the SCLS-built gcc under `lbl`.
if any('%{libgomp}' in s for s in cmd):
libgomp = resolve_gcc_runtime_lib('libgomp.so.1', self.prefix)
cmd = [s.replace('%{libgomp}', libgomp) for s in cmd]
# Substitute extra source info (e.g., %{gmp_version}, %{gmp_tarball})
for name, info in self.extra_source_info.items():
cmd = [s.replace(f'%{{{name}_version}}', info['version']) for s in cmd]
cmd = [s.replace(f'%{{{name}_tarball}}', info['tarball']) for s in cmd]
return cmd
def run_platform_pre(self, source_dir: Path, env: Dict[str, str]) -> None:
"""Run platform-specific pre-configure commands"""
if 'configure' not in self.recipe:
return
platform_pre = self.recipe['configure'].get('platform_pre', {})
if self.platform in platform_pre:
for cmd in platform_pre[self.platform]:
checked_cmd = self.check_args([cmd])[0]
run_command(['sh', '-c', checked_cmd], source_dir, env, f"platform-pre ({self.platform})")
def apply_platform_env(self, env: Dict[str, str]) -> Dict[str, str]:
"""Apply platform-specific environment variables"""
if 'configure' not in self.recipe:
return env
platform_env = self.recipe['configure'].get('platform_env', {})
if self.platform in platform_env:
for key, value in platform_env[self.platform].items():
# Apply check_args to the value to expand placeholders
expanded_value = self.check_args([value])[0]
env[key] = expanded_value
return env
def build(self, build_dir: Path, env: Dict[str, str]) -> None:
"""Run build step"""
# Get number of parallel jobs
jobs = get_parallel_jobs()
if not self.recipe.get('build', {}).get('parallel', True):
jobs = 1
# Run any pre-build commands
if 'build' in self.recipe and 'pre' in self.recipe['build']:
for cmd in self.recipe['build']['pre']:
expanded_cmd = self.check_args([cmd])[0]
run_command(['sh', '-c', expanded_cmd], build_dir, env, "pre-build")
# Run LP64/ILP64 interface-specific pre-build commands
if 'build' in self.recipe:
interface = self.flavor.get('math', {}).get('interface', 'lp64')
if interface == 'ilp64' and 'ilp64_pre' in self.recipe['build']:
for cmd in self.recipe['build']['ilp64_pre']:
expanded_cmd = self.check_args([cmd])[0]
run_command(['sh', '-c', expanded_cmd], build_dir, env, "pre-build (ilp64)")
elif interface == 'lp64' and 'lp64_pre' in self.recipe['build']:
for cmd in self.recipe['build']['lp64_pre']:
expanded_cmd = self.check_args([cmd])[0]
run_command(['sh', '-c', expanded_cmd], build_dir, env, "pre-build (lp64)")
# Build command. meson recipes compile the build/ tree configure()
# set up; build.args/flavor_args are make arguments and do not apply.
if self.recipe.get('configure', {}).get('type') == 'meson':
run_command(['meson', 'compile', '-C', 'build', '-j', str(jobs)],
build_dir, env, "build")
if 'build' in self.recipe and 'post' in self.recipe['build']:
for cmd in self.recipe['build']['post']:
expanded_cmd = self.check_args([cmd])[0]
run_command(['sh', '-c', expanded_cmd], build_dir, env, "post-build")
return
make_cmd = ['make', f'-j{jobs}']
if 'build' in self.recipe and 'args' in self.recipe['build']:
make_cmd.extend(self.check_args(self.recipe['build']['args']))
# Add flavor-specific build args
if 'build' in self.recipe and 'flavor_args' in self.recipe['build']:
flavor_specific = resolve_flavor_key(self.flavor, self.recipe['build']['flavor_args'])
if flavor_specific:
make_cmd.extend(self.check_args(flavor_specific))
# Add LP64/ILP64 interface-specific build args
make_cmd.extend(get_interface_args(self.recipe, self.flavor, 'build'))
run_command(make_cmd, build_dir, env, "build")
# Run any post-build commands
if 'build' in self.recipe and 'post' in self.recipe['build']:
for cmd in self.recipe['build']['post']:
expanded_cmd = self.check_args([cmd])[0]
run_command(['sh', '-c', expanded_cmd], build_dir, env, "post-build")
def test(self, build_dir: Path, env: Dict[str, str]) -> None:
"""Run test step"""
if 'test' not in self.recipe:
print("No tests defined for this package")
return
print("\n=== Running tests ===")
# Extract test.sources tarballs into <source>/build/test/ to match
# the %check section in templates/default.spec.j2, which does
# `mkdir -p build/test; tar -xf SOURCE -C build/test/` from the
# source root. Anchoring on self.source_dir (not the build_dir
# argument) keeps the extraction location stable when subclasses
# change the test cwd — DebBuilder.test() runs tests from the
# source root rather than the cmake build dir, but recipes still
# reference matrix data at build/test/<file>.
test_sources = self.recipe.get('test', {}).get('sources', [])
if test_sources:
source_root = (
Path(self.source_dir) if self.source_dir else build_dir
)
test_data_dir = source_root / 'build' / 'test'
test_data_dir.mkdir(parents=True, exist_ok=True)
for tsrc in test_sources:
tarball_name = tsrc['url'].split('/')[-1]
tarball_path = self.sources_dir / tarball_name
if not tarball_path.exists():
raise BuildError(
f"Test source {tarball_path} not found. Run "
"'build' before 'test' so the tarball is "
"downloaded."
)
with tarfile.open(tarball_path, 'r:*') as tar:
if sys.version_info >= (3, 12):
tar.extractall(test_data_dir, filter='data')
else:
from build_common import _safe_extract_tar
_safe_extract_tar(tar, test_data_dir)
# Add prefix lib directory to library path so test executables
# can find shared libraries without requiring rpath in test binaries
# On macOS, use DYLD_FALLBACK_LIBRARY_PATH (not stripped by SIP)
#
# APPENDED, never prepended. The live prefix holds the *previous*
# build of the package under test, and SCLS sonames are major-only
# (libscotch.so.7.0 covers 7.0.11 and 7.0.13 alike), so a prepended
# prefix/lib silently shadows the artifact we just built with the
# copy already installed. Tests then exercise the old library and
# fail on symbols the new one added (scotch 7.0.13's
# SCOTCH_archCmpltws) or on struct-layout skew between new binaries
# and old internals. Callers that need to win over the live prefix
# -- DebBuilder.test() staging its destdir -- prepend before
# delegating here, and that ordering must survive.
lib_dir = str(self.prefix / "lib")
if self.platform == 'macos':
lib_var = 'DYLD_FALLBACK_LIBRARY_PATH'
else:
lib_var = 'LD_LIBRARY_PATH'
if lib_var in env:
env[lib_var] = f"{env[lib_var]}:{lib_dir}"
else:
env[lib_var] = lib_dir
# Run any pre-test commands
if 'pre' in self.recipe['test']:
for cmd in self.recipe['test']['pre']:
cmd = self.check_args([cmd])[0]
run_command(['sh', '-c', cmd], build_dir, env, "pre-test")
# Collect build args string if test.inherit_build_args is set
build_args_str = ''
if self.recipe['test'].get('inherit_build_args', False):
build_args = []
if 'build' in self.recipe and 'args' in self.recipe['build']:
build_args.extend(self.check_args(self.recipe['build']['args']))
if 'build' in self.recipe and 'flavor_args' in self.recipe['build']:
flavor_specific = resolve_flavor_key(self.flavor, self.recipe['build']['flavor_args'])
if flavor_specific:
build_args.extend(self.check_args(flavor_specific))
build_args.extend(get_interface_args(self.recipe, self.flavor, 'build'))
build_args_str = ' '.join(build_args)
# Run test commands
if 'commands' in self.recipe['test']:
for cmd in self.recipe['test']['commands']:
# Expand recipe placeholders (%{prefix}, %{srcdir}, etc.)
cmd = self.check_args([cmd])[0]
# Inject build args into make invocations if inherited
if build_args_str and cmd.strip().startswith('make '):
cmd = cmd.replace('make ', f'make {build_args_str} ', 1)
# Handle shell features like pipes and redirects
run_command(['sh', '-c', cmd], build_dir, env, "test")
# Run any post-test commands
if 'post' in self.recipe['test']:
for cmd in self.recipe['test']['post']:
cmd = self.check_args([cmd])[0]
run_command(['sh', '-c', cmd], build_dir, env, "post-test")
def install(self, build_dir: Path, env: Dict[str, str]) -> None:
"""Run install step and track installed files"""
# Create a temporary DESTDIR for installation
destdir = self.work_dir / "destdir"
if destdir.exists():
shutil.rmtree(destdir)
destdir.mkdir(parents=True)
# Stage lib -> lib64 in the destdir up-front so a package's
# `make install DESTDIR=...` lands files in the same layout we ship
# at the live prefix (LFS convention; the environment package owns
# the symlink on the installed system). Without this, an autotools
# package whose libdir resolves to lib/ would stage into
# <destdir>/<prefix>/lib while one whose libdir resolves to lib64/
# would stage into <destdir>/<prefix>/lib64 — and the rglob copy
# below would yield each file once at its real path and once via
# the live-prefix symlink (or, if the live symlink isn't there yet,
# materialize <prefix>/lib as a real directory). Mirrors
# deb_builder.install. We drop the staging symlink before the copy
# phase so rglob doesn't double-walk lib/ and lib64/.
destdir_prefix = destdir / str(self.prefix).lstrip('/')
destdir_prefix.mkdir(parents=True, exist_ok=True)
is_environment = self.package == 'environment'
staging_lib_symlink: Path | None = None
if platform.system() == 'Linux':
lib_link = destdir_prefix / 'lib'
lib64_dir = destdir_prefix / 'lib64'
if not lib_link.exists() and not lib_link.is_symlink():
lib64_dir.mkdir(parents=True, exist_ok=True)
lib_link.symlink_to('lib64')
if not is_environment:
staging_lib_symlink = lib_link
# Run any pre-install commands
if 'install' in self.recipe and 'pre' in self.recipe['install']:
for cmd in self.recipe['install']['pre']:
expanded_cmd = self.check_args([cmd])[0]
run_command(['sh', '-c', expanded_cmd], build_dir, env, "pre-install")
# special case for Apple zlib - only if the zlib subdirectory exists
if self.package == 'zlib' and (build_dir / 'zlib').exists():
build_dir = build_dir / 'zlib'
print(f"Install build_dir: {build_dir}") # Debug output
if not build_dir.exists():
raise BuildError(f"Build directory does not exist: {build_dir}")
# Check if recipe defines custom install commands
if 'install' in self.recipe and 'commands' in self.recipe['install']:
# Run custom install commands instead of make install
for cmd in self.recipe['install']['commands']:
cmd = cmd.replace('%{buildroot}', str(destdir))
cmd = cmd.replace('%{prefix}', str(self.prefix))
cmd = cmd.replace('%{libext}', self.lib_ext)
expanded_cmd = self.check_args([cmd])[0]
run_command(['sh', '-c', expanded_cmd], build_dir, env, "install")
elif self.recipe.get('configure', {}).get('type') == 'meson':
# install.args / flavor_args go to `meson install`, as the spec
# template appends them to its meson install line.
install_cmd = ['meson', 'install', '-C', 'build', '--no-rebuild',
'--destdir', str(destdir)]
if 'install' in self.recipe and 'args' in self.recipe['install']:
install_cmd.extend(self.check_args(self.recipe['install']['args']))
if 'install' in self.recipe and 'flavor_args' in self.recipe['install']:
flavor_specific = resolve_flavor_key(self.flavor, self.recipe['install']['flavor_args'])
if flavor_specific:
install_cmd.extend(self.check_args(flavor_specific))
run_command(install_cmd, build_dir, env, "install")
else:
# Default: make install with DESTDIR
install_cmd = ['make', 'install', f'DESTDIR={destdir}']
if 'install' in self.recipe and 'args' in self.recipe['install']:
args = self.check_args(self.recipe['install']['args'])
install_cmd.extend(args)
# Add flavor-specific install args
if 'install' in self.recipe and 'flavor_args' in self.recipe['install']:
flavor_specific = resolve_flavor_key(self.flavor, self.recipe['install']['flavor_args'])
if flavor_specific:
install_cmd.extend(self.check_args(flavor_specific))
run_command(install_cmd, build_dir, env, "install")
# Run any post-install commands (with DESTDIR)
# %{prefix} = destdir path (for file operations during staging)
# %{final_prefix} = actual install prefix (for content that needs final paths, like .pc files)
if 'install' in self.recipe and 'post' in self.recipe['install']:
for cmd in self.recipe['install']['post']:
# Apply install-specific replacements FIRST (before check_args replaces %{prefix})
cmd = cmd.replace('%{buildroot}', str(destdir))
cmd = cmd.replace('%{final_prefix}', str(self.prefix))
cmd = cmd.replace('%{prefix}', str(destdir / str(self.prefix).lstrip('/')))
# Then apply check_args for %{host}, %{sdk}, %{version}, etc.
cmd = self.check_args([cmd])[0]
run_command(['sh', '-c', cmd], build_dir, env, "post-install")
# Run flavor-specific post-install commands
if 'install' in self.recipe and 'flavor_post' in self.recipe['install']:
flavor_post = resolve_flavor_key(self.flavor, self.recipe['install']['flavor_post'])
if flavor_post:
for cmd in flavor_post:
# Apply install-specific replacements FIRST (before check_args replaces %{prefix})
cmd = cmd.replace('%{buildroot}', str(destdir))
cmd = cmd.replace('%{final_prefix}', str(self.prefix))
cmd = cmd.replace('%{prefix}', str(destdir / str(self.prefix).lstrip('/')))
# Then apply check_args for %{host}, %{sdk}, %{version}, etc.
cmd = self.check_args([cmd])[0]
run_command(['sh', '-c', cmd], build_dir, env, "flavor-post-install")
# Run platform-specific post-install commands
if 'install' in self.recipe and 'platform_post' in self.recipe['install']:
if self.platform in self.recipe['install']['platform_post']:
for cmd in self.recipe['install']['platform_post'][self.platform]:
# Apply install-specific replacements FIRST (before check_args replaces %{prefix})
cmd = cmd.replace('%{buildroot}', str(destdir))
cmd = cmd.replace('%{final_prefix}', str(self.prefix))
cmd = cmd.replace('%{prefix}', str(destdir / str(self.prefix).lstrip('/')))
# Then apply check_args for %{host}, %{sdk}, %{version}, etc.
cmd = self.check_args([cmd])[0]
run_command(['sh', '-c', cmd], build_dir, env, f"platform-post-install ({self.platform})")
# Clean up .la files in destdir
clean_libtool_files(destdir / str(self.prefix).lstrip('/'))
check_no_triplet_programs(destdir / str(self.prefix).lstrip('/'), self.recipe)
# Drop the staging-only lib -> lib64 symlink before the rglob copy
# so we don't double-walk files (once via lib/, once via lib64/).
# Files staged under it remain at lib64/ where they already live.
if staging_lib_symlink is not None and staging_lib_symlink.is_symlink():
staging_lib_symlink.unlink()
# Now copy from destdir to actual prefix
# This gives us control over what gets installed
src_prefix = destdir / str(self.prefix).lstrip('/')
if src_prefix.exists():
print(f"\nInstalling files from {src_prefix} to {self.prefix}")
# Track installed files
self.installed_files = []
# A prefix this user cannot write (e.g. root-owned /opt/scls) is
# published with sudo, command by command; a writable one keeps
# the in-process copy below. Checked per destination, before
# anything is written, so a prefix with a root-owned subtree does
# not fail halfway through the copy.
registry_file = self.prefix / "share" / "scls" / "registry" / f"{self.recipe['name']}.yaml"
self._sudo = []
if publish_needs_sudo(src_prefix, self.prefix, [registry_file]):
self._sudo = ['sudo']
print(f"{self.prefix} is not writable by the current user; installing with sudo")
self.installed_files = sudo_publish_tree(
src_prefix, self.prefix, also_dirs=[registry_file.parent])
else:
for src_path in src_prefix.rglob('*'):
rel_path = src_path.relative_to(src_prefix)
dest_path = self.prefix / rel_path
# Create parent directory if needed
dest_path.parent.mkdir(parents=True, exist_ok=True)
if src_path.is_symlink():
# Preserve symlink topology (e.g. libfoo.so -> libfoo.so.1)
# instead of flattening to a copy of the target
link_target = os.readlink(src_path)
if dest_path.exists() or dest_path.is_symlink():
dest_path.unlink()
dest_path.symlink_to(link_target)
self.installed_files.append(dest_path)
elif src_path.is_file():
# Regular file — copy preserving metadata
shutil.copy2(src_path, dest_path)
self.installed_files.append(dest_path)
print(f"Installed {len(self.installed_files)} files")
else:
raise BuildError(f"No files found in {src_prefix}")
# Run any final post-install commands (after files are in final location)
self.run_final_post_install_commands()
# Normalize Mach-O install names after the files are in their final
# prefix. This catches CMake projects that default to @rpath IDs and
# any package-specific final_post commands that installed dylibs.
self.fix_macos_dylib_install_names()
# Generate RPM-style file list for use in SPEC files
self.generate_rpm_file_list()
# Write registry entry for this package
if self._sudo:
sudo_write_registry_entry(self.prefix, self.recipe, self.flavor_name)
else:
write_registry_entry(self.prefix, self.recipe, self.flavor_name)
# Add registry file to installed files list so it gets included in PKG
registry_file = self.prefix / "share" / "scls" / "registry" / f"{self.recipe['name']}.yaml"
if registry_file.exists():
self.installed_files.append(registry_file)
# Save installed files list for PKG creation (after registry entry is added)
file_list_path = self.work_dir / "installed_files.txt"
with open(file_list_path, 'w') as f:
for file in self.installed_files:
f.write(f"{file}\n")
def generate_rpm_file_list(self) -> None: