Repository navigation
Expand file tree
/
Copy pathbuild_common.py
More file actions
2472 lines (2083 loc) · 94.5 KB
/
Copy pathbuild_common.py
File metadata and controls
2472 lines (2083 loc) · 94.5 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
"""
Common utilities for SCLS build system
Shared between mac_builder.py and rpm_builder.py
"""
import os
import re
import sys
import yaml
import subprocess
import hashlib
import urllib.request
import tarfile
import shutil
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union
class BuildError(Exception):
"""Custom exception for build errors"""
pass
def resolve_gcc_runtime_lib(libname: str, prefix: Path = None) -> str:
"""Ask the active gcc where it would find a runtime library (e.g.
'libgomp.so.1'). Mirrors what the link step would resolve to, so the
result is correct for system gcc, gcc-toolset/devtoolset, and the
SCLS-built gcc under `lbl` — without having to hard-code multiarch
paths or RHEL conventions in recipes.
`prefix`, if given, is prepended to PATH so the SCLS-built gcc is
preferred over the system one. Returns the absolute path; raises
BuildError if gcc can't resolve `libname` (gcc returns the bare name
unchanged on failure).
"""
env = os.environ.copy()
if prefix is not None:
env['PATH'] = f"{prefix}/bin:{env.get('PATH', '')}"
try:
out = subprocess.check_output(
['gcc', f'-print-file-name={libname}'],
env=env, text=True,
).strip()
except (FileNotFoundError, subprocess.CalledProcessError) as e:
raise BuildError(f"Could not invoke gcc to resolve {libname}: {e}")
# gcc returns the input unchanged if it can't find the lib.
if out == libname or not os.path.isabs(out):
raise BuildError(
f"gcc could not resolve {libname} (returned {out!r}). "
f"Check that the gcc on PATH (or under {prefix}/bin) ships {libname}."
)
# gcc's internal lib search often returns paths with `..` segments
# (e.g. /usr/lib/gcc/<host>/<ver>/../../../<host>/libgomp.so.1).
# Collapse them with normpath; don't use realpath, which would also
# resolve the SONAME symlink to the underlying versioned file.
return os.path.normpath(out)
def add_rpath_for_libdirs(ldflags: str, platform: str = 'linux') -> str:
"""
Add rpath entries for every -L directory in ldflags.
This ensures that libraries can be found at runtime without
needing to set LD_LIBRARY_PATH or DYLD_LIBRARY_PATH.
The rpath is inserted immediately after each -L flag, before any -l flags.
Example: "-L/opt/scls/lib -lz" becomes "-L/opt/scls/lib -Wl,-rpath,/opt/scls/lib -lz"
Args:
ldflags: The linker flags string
platform: 'linux' or 'macos' (affects rpath syntax)
Returns:
ldflags with rpath entries added for each -L path
"""
if not ldflags:
return ldflags
import shlex
try:
tokens = shlex.split(ldflags)
except ValueError:
tokens = ldflags.split()
# Collect every rpath we can recognize, including the bare `-rpath /foo`
# two-token form and the `-Xlinker -rpath -Xlinker /foo` four-token form.
# Without these, the dedup pass below misses them and the forward pass
# adds duplicate rpath entries that produce linker warnings.
existing_rpaths = set()
i = 0
while i < len(tokens):
t = tokens[i]
if t.startswith('-Wl,'):
for part in t.split(','):
if part.startswith('/'):
existing_rpaths.add(part)
elif part.startswith('rpath='):
existing_rpaths.add(part[len('rpath='):])
elif t == '-rpath' and i + 1 < len(tokens):
existing_rpaths.add(tokens[i + 1])
elif (t == '-Xlinker' and i + 3 < len(tokens)
and tokens[i + 1] == '-rpath' and tokens[i + 2] == '-Xlinker'):
existing_rpaths.add(tokens[i + 3])
i += 1
# Process tokens and insert rpath after each -L
result_tokens = []
i = 0
while i < len(tokens):
token = tokens[i]
if token.startswith('-L'):
if token == '-L' and i + 1 < len(tokens):
# -L /path/to/lib (space separated)
lib_path = tokens[i + 1]
result_tokens.append(token)
result_tokens.append(lib_path)
if lib_path not in existing_rpaths:
result_tokens.append(f'-Wl,-rpath,{lib_path}')
existing_rpaths.add(lib_path)
i += 2
else:
# -L/path/to/lib (no space)
lib_path = token[2:]
result_tokens.append(token)
if lib_path not in existing_rpaths:
result_tokens.append(f'-Wl,-rpath,{lib_path}')
existing_rpaths.add(lib_path)
i += 1
else:
result_tokens.append(token)
i += 1
return ' '.join(result_tokens)
def load_yaml(filepath: Path) -> Dict:
"""Load a YAML file.
Note: %{version} substitution in source URLs is intentionally deferred
to download time. apply_flavor_overrides may change recipe['version']
after load, and substituting too early would freeze in the original
version, causing flavor-overridden builds to fetch the wrong tarball.
"""
with open(filepath, 'r') as f:
data = yaml.safe_load(f)
# Force version to be a string if present at top level
if isinstance(data, dict) and 'version' in data:
data['version'] = str(data['version'])
return data
def load_recipe(package_name: str, recipes_dir: Path = Path("recipes")) -> Dict:
"""Load a package recipe"""
recipe_path = recipes_dir / f"{package_name}.yaml"
if not recipe_path.exists():
raise BuildError(f"Recipe not found: {recipe_path}")
# load_yaml now handles version conversion
return load_yaml(recipe_path)
def apply_flavor_overrides(recipe: Dict, flavor: Dict) -> Dict:
"""
Apply flavor-specific overrides to a recipe.
Supports overriding version, source URL, and other fields per flavor.
The 'flavor_overrides' section in a recipe looks like:
flavor_overrides:
lbl:
version: 4.1.6
source:
url: https://example.com/package-%{version}.tar.gz
"""
if 'flavor_overrides' not in recipe:
return recipe
overrides = resolve_flavor_key(flavor, recipe['flavor_overrides'])
if not overrides:
return recipe
print(f"Applying flavor overrides for {flavor.get('name', '')}")
# Override version
if 'version' in overrides:
old_version = str(recipe.get('version', ''))
recipe['version'] = str(overrides['version'])
print(f" Version: {old_version} -> {recipe['version']}")
# Override source URL. Keep %{version} as a literal here; it is
# substituted at download time using the post-override recipe['version'],
# so version-only flavor overrides also point at the right tarball.
if 'source' in overrides and 'url' in overrides['source']:
recipe['source'] = dict(recipe.get('source', {}))
recipe['source']['url'] = overrides['source']['url']
print(f" Source URL: {recipe['source']['url']}")
return recipe
def load_flavor(flavor_name: str, flavors_dir: Path = Path("flavors")) -> Dict:
"""Load a flavor configuration"""
flavor_path = flavors_dir / f"{flavor_name}.yaml"
if not flavor_path.exists():
raise BuildError(f"Flavor not found: {flavor_path}")
return load_yaml(flavor_path)
def read_extra_packages(flavor: str) -> list:
"""Return extra_packages from flavor.conf if its `flavor:` matches the
requested flavor, else [].
Used by both the RPM and DEB meta-package builders to include host-extra
foundation packages (e.g. gcc/binutils on RHEL 8) without making them
part of the recipe-level allowlist.
"""
conf_path = Path(__file__).parent.parent / 'flavor.conf'
if not conf_path.exists():
return []
try:
data = yaml.safe_load(conf_path.read_text())
except yaml.YAMLError:
return []
if not isinstance(data, dict):
return []
if data.get('flavor') != flavor:
# Different flavor in flavor.conf — don't apply this host's overrides.
return []
extra = data.get('extra_packages') or []
return [str(p) for p in extra if isinstance(p, str)]
def get_flavor_names(flavor) -> list:
"""
Return a list of flavor names to check, in priority order.
Accepts either a flavor dict (with 'name' and optional 'inherits' keys)
or a plain flavor name string. When given a string, loads the flavor
YAML to check for an 'inherits' field.
If the flavor has an 'inherits' field, the parent name is appended as fallback.
Hyphen-separated components are also added so that generic recipe keys
like 'mkl', 'cuda', or 'debug' match concrete flavors like 'gcc-mkl',
'gcc-mkl-cuda', or 'gcc-debug'.
E.g., for flavor 'gcc-mkl-cuda' inheriting from 'gcc-mkl', returns
['gcc-mkl-cuda', 'gcc-mkl', 'gcc', 'mkl', 'cuda'].
"""
if isinstance(flavor, dict):
name = flavor.get('name', '')
inherits = flavor.get('inherits', None)
elif isinstance(flavor, str) and flavor:
name = flavor
# Load flavor file to check for inheritance
flavor_path = Path("flavors") / f"{flavor}.yaml"
inherits = None
if flavor_path.exists():
with open(flavor_path, 'r') as f:
flavor_data = yaml.safe_load(f)
if isinstance(flavor_data, dict):
inherits = flavor_data.get('inherits', None)
else:
return []
if not name:
return []
names = [name]
if inherits:
names.append(inherits)
# Add hyphen-separated components as fallbacks, rightmost first.
# The rightmost component is the most specific modifier
# (e.g. in 'gcc-mkl-cuda' the math/accelerator modifiers 'cuda' and
# 'mkl' must take precedence over the compiler base 'gcc' so that
# recipes defining flavor_args for both 'gcc:' and 'mkl:'/'cuda:'
# apply the specialized block, not the generic one).
for part in reversed(name.split('-')):
if part and part not in names:
names.append(part)
return names
def resolve_flavor_key(flavor: Dict, mapping: Dict):
"""
Look up a flavor-specific entry in a mapping (e.g., flavor_args, flavor_env).
Checks the flavor name first, then falls back to the parent if 'inherits' is set.
Returns the value if found, or None.
"""
for name in get_flavor_names(flavor):
if name in mapping:
return mapping[name]
return None
def load_description(package_name: str, descriptions_dir: Path = Path("descriptions")) -> str:
"""Load package description"""
desc_path = descriptions_dir / f"{package_name}.txt"
if desc_path.exists():
with open(desc_path, 'r') as f:
return f.read().strip()
return ""
def get_optimization_flags(recipe: Dict, flavor: Dict, compiler: str) -> Tuple[str, str, str]:
"""
Get optimization flags for C, C++, and Fortran
Combines recipe-specific optimization with flavor flags
"""
# Get base flags from flavor
cflags = flavor['flags'].get('cflags', '')
cxxflags = flavor['flags'].get('cxxflags', '')
fflags = flavor['flags'].get('fflags', '')
# Get optimization settings from recipe
if 'configure' in recipe and 'optimization' in recipe['configure']:
opt = recipe['configure']['optimization']
o_level = opt.get('O_level', 2)
strict_aliasing = opt.get('strict_aliasing', True)
# Add optimization level
opt_flag = f"-O{o_level}"
cflags += f" {opt_flag}"
cxxflags += f" {opt_flag}"
fflags += f" {opt_flag}"
# Handle strict aliasing
if not strict_aliasing:
if compiler in ['gcc', 'g++', 'gfortran']:
alias_flag = "-fno-strict-aliasing"
elif compiler in ['icx', 'icpx', 'ifx']:
alias_flag = "-fno-ansi-alias"
elif compiler in ['clang', 'clang++']:
alias_flag = "-fno-strict-aliasing"
else:
alias_flag = ""
if alias_flag:
cflags += f" {alias_flag}"
cxxflags += f" {alias_flag}"
# Add any extra flags from recipe (platform-specific)
platform = flavor.get('platform', 'linux')
if platform in opt:
plat_opt = opt[platform]
if 'cflags' in plat_opt:
cflags += f" {plat_opt['cflags']}"
if 'cxxflags' in plat_opt:
cxxflags += f" {plat_opt['cxxflags']}"
if 'fcflags' in plat_opt:
fflags += f" {plat_opt['fcflags']}"
# special handling for integers
math_config = flavor.get('math', {})
interface = math_config.get('interface', 'lp64')
if interface == 'ilp64':
fflags += " -fdefault-integer-8"
cflags += " -DInt=long"
cxxflags += " -DInt=long"
return cflags.strip(), cxxflags.strip(), fflags.strip()
def get_interface_args(recipe: Dict, flavor: Dict, section: str = 'configure') -> List[str]:
"""
Get arguments specific to LP64 or ILP64 interface.
Checks the recipe for 'lp64_args' and 'ilp64_args' keys under the specified
section and returns the appropriate list based on the flavor's math.interface setting.
Also supports 'lp64_flavor_args' and 'ilp64_flavor_args' for flavor-specific
overrides of interface-dependent settings (e.g., MKL vendor strings).
Args:
recipe: The package recipe dictionary
flavor: The flavor configuration dictionary
section: Which section to look in ('configure' or 'build')
Returns:
List of arguments for the current interface type
"""
args = []
if section not in recipe:
return args
section_config = recipe[section]
# Get interface type from flavor (default to lp64)
math_config = flavor.get('math', {})
interface = math_config.get('interface', 'lp64')
flavor_name = flavor.get('name', '')
# Get interface-specific args
if interface == 'ilp64' and 'ilp64_args' in section_config:
ilp64_args = section_config['ilp64_args']
if isinstance(ilp64_args, list):
args.extend(ilp64_args)
print(f"Adding ILP64 {section} args: {ilp64_args}")
elif interface == 'lp64' and 'lp64_args' in section_config:
lp64_args = section_config['lp64_args']
if isinstance(lp64_args, list):
args.extend(lp64_args)
print(f"Adding LP64 {section} args: {lp64_args}")
# Get interface + flavor specific args (e.g., ilp64_flavor_args for MKL vendor)
if interface == 'ilp64' and 'ilp64_flavor_args' in section_config:
ilp64_flavor_args = section_config['ilp64_flavor_args']
if isinstance(ilp64_flavor_args, dict):
flavor_specific = resolve_flavor_key(flavor, ilp64_flavor_args)
if flavor_specific and isinstance(flavor_specific, list):
args.extend(flavor_specific)
print(f"Adding ILP64 flavor-specific {section} args: {flavor_specific}")
elif interface == 'lp64' and 'lp64_flavor_args' in section_config:
lp64_flavor_args = section_config['lp64_flavor_args']
if isinstance(lp64_flavor_args, dict):
flavor_specific = resolve_flavor_key(flavor, lp64_flavor_args)
if flavor_specific and isinstance(flavor_specific, list):
args.extend(flavor_specific)
print(f"Adding LP64 flavor-specific {section} args: {flavor_specific}")
return args
def _validate_source_archive(path: Path) -> None:
"""Raise BuildError if path is not a readable tar archive."""
try:
with tarfile.open(path, 'r:*') as tar:
tar.next()
except (tarfile.TarError, OSError) as e:
raise BuildError(f"Invalid source archive {path}: {e}")
def download_source(url: str, dest_dir: Path, package_name: str, version: str) -> Path:
"""Download source tarball if not already present"""
dest_dir.mkdir(parents=True, exist_ok=True)
filename = url.split('/')[-1]
dest_path = dest_dir / filename
if dest_path.exists():
try:
_validate_source_archive(dest_path)
print(f"Source already downloaded: {dest_path}")
return dest_path
except BuildError as e:
print(f"Discarding invalid cached source: {e}")
dest_path.unlink()
print(f"Downloading {url}...")
tmp_path = dest_path.with_name(f"{dest_path.name}.part")
if tmp_path.exists():
tmp_path.unlink()
try:
cmd = [
'curl', '-fL',
'--retry', '3',
'--retry-delay', '2',
'-o', tmp_path.name,
url,
]
run_command(cmd, cwd=dest_dir, env={}, phase="Download source")
_validate_source_archive(tmp_path)
shutil.move(str(tmp_path), str(dest_path))
print(f"Downloaded to {dest_path}")
return dest_path
except Exception as e:
if tmp_path.exists():
tmp_path.unlink()
raise BuildError(f"Failed to download source: {e}")
def detect_source_directory(tarball: Path) -> Optional[str]:
"""Peek inside a tarball to discover the top-level directory name.
Returns the directory name if there is exactly one top-level directory,
or None if the archive structure is ambiguous (e.g. loose files at root).
"""
try:
with tarfile.open(tarball, 'r:*') as tar:
# Collect unique top-level path components
top_dirs = set()
for member in tar.getmembers():
top = member.name.split('/')[0]
top_dirs.add(top)
# Early exit: if we find more than one, still check if
# all members share a common prefix directory
if len(top_dirs) > 1:
break
if len(top_dirs) == 1:
name = top_dirs.pop()
# Verify it's actually a directory (not a single loose file)
if name and name != '.':
return name
except (tarfile.TarError, OSError) as e:
print(f"Warning: could not inspect tarball {tarball}: {e}")
return None
def _safe_extract_tar(tar: tarfile.TarFile, dest: Path) -> None:
"""Validate tar members before extraction (Python < 3.12 fallback).
Rejects:
- absolute paths
- paths containing '..' components after normalization
- device nodes, FIFOs, character/block special files
Symlinks and hardlinks are allowed only if their resolved target stays
inside dest.
"""
dest_abs = dest.resolve()
for member in tar.getmembers():
if member.isdev() or member.ischr() or member.isblk() or member.isfifo():
raise BuildError(f"Refusing to extract special file: {member.name}")
target = (dest / member.name).resolve()
try:
target.relative_to(dest_abs)
except ValueError:
raise BuildError(
f"Tar member escapes destination: {member.name} -> {target}"
)
if member.issym() or member.islnk():
link_target = ((dest / member.name).parent / member.linkname).resolve()
try:
link_target.relative_to(dest_abs)
except ValueError:
raise BuildError(
f"Tar link target escapes destination: "
f"{member.name} -> {member.linkname}"
)
tar.extractall(dest)
def extract_source(tarball: Path, work_dir: Path, package_name: str, version: str) -> Path:
"""Extract source tarball and return the extracted directory"""
work_dir.mkdir(parents=True, exist_ok=True)
print(f"Extracting {tarball}...")
with tarfile.open(tarball, 'r:*') as tar:
if sys.version_info >= (3, 12):
tar.extractall(work_dir, filter='data')
else:
_safe_extract_tar(tar, work_dir)
# Find the extracted directory
# Usually it's package-version, but we should check
extracted_dirs = [d for d in work_dir.iterdir() if d.is_dir()]
if len(extracted_dirs) == 1:
return extracted_dirs[0]
# Try common patterns
for pattern in [f"{package_name}-{version}", f"{package_name}_{version}", package_name]:
extracted_dir = work_dir / pattern
if extracted_dir.exists():
return extracted_dir
raise BuildError(f"Could not find extracted source directory in {work_dir}")
def run_command(cmd: List[str], cwd: Path, env: Dict[str, str], phase: str) -> None:
"""Run a command with proper error handling"""
from collections import deque
print(f"\n=== Running {phase} ===")
print(f"Command: {' '.join(cmd)}")
print(f"Directory: {cwd}")
# Start the process
process = subprocess.Popen(
cmd,
cwd=cwd,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
universal_newlines=True
)
# Read output line by line, retaining the tail for failure reporting so
# the actual error survives a long build log scroll.
tail = deque(maxlen=40)
while True:
line = process.stdout.readline()
if not line and process.poll() is not None:
break
if line:
stripped = line.rstrip()
print(stripped)
tail.append(stripped)
# Get the return code
returncode = process.poll()
if returncode != 0:
tail_str = '\n'.join(tail)
raise BuildError(
f"{phase} failed with return code {returncode}\n"
f"Command: {' '.join(cmd)}\n"
f"Last {len(tail)} lines of output:\n{tail_str}"
)
def should_build_package(recipe: Dict, flavor: Dict) -> bool:
"""
Check if a package should be built for a given flavor.
If no 'include_flavors' list in recipe, build for all flavors.
If 'include_flavors' list exists, only build if flavor is in the list
(an empty list means never build by default).
'exclude_flavors' takes precedence over 'include_flavors'.
"""
names = get_flavor_names(flavor)
# Check exclusion first (takes precedence)
if 'exclude_flavors' in recipe:
for name in names:
if name in recipe['exclude_flavors']:
return False
# No include_flavors specified = build for all
if 'include_flavors' not in recipe:
return True
# Check if current flavor or its parent is in the allowed list
for name in names:
if name in recipe['include_flavors']:
return True
return False
def require_buildable(package: str, recipe: Dict, flavor: Dict, flavor_name: str) -> None:
"""Raise unless the recipe builds for the flavor or flavor.conf opts it in.
The one gate all three builders use (rpm_builder, unix_builder and, through
it, deb_builder). A package whose own include_flavors/exclude_flavors
exclude the flavor builds only when flavor.conf lists it under
extra_packages: -- the single supported override, trackable per host.
Until 2026-10-04 only rpm_builder honoured it, so `./scls build <pkg>`
for an opted-in package failed on Ubuntu and macOS while the DEB
meta-package already depended on it.
This covers an explicit build of the named package. `build next`,
`build all` and `order` still follow build_order.py, which does not read
extra_packages on any builder.
"""
if should_build_package(recipe, flavor):
return
if package in read_extra_packages(flavor_name):
print(f"Note: {package} is not officially supported for "
f"flavor {flavor_name}; building anyway because it is "
f"listed in flavor.conf's extra_packages.")
return
raise BuildError(
f"Package {package} not built for {flavor_name}. "
f"To override, add it to extra_packages: in flavor.conf."
)
def get_configure_args(recipe: Dict, host: str, flavor: Dict, prefix: Path, install_prefix: Path) -> List[str]:
"""Get configure arguments for autotools packages"""
# Determine compilers based on MPI feature
features = recipe.get('features', {})
use_mpi = features.get('mpi', False)
if use_mpi:
# Use MPI compiler wrappers
cc = 'mpicc'
cxx = 'mpicxx'
fc = 'mpifort'
else:
# Bootstrap packages build before scls gcc is usable (e.g. on macOS
# the freshly built gcc lands on PATH but can't parse Apple SDK
# headers that use clang blocks). Honor the flavor's
# bootstrap_compilers override, matching _prepare_build_env.
is_bootstrap = recipe.get('bootstrap', False)
if is_bootstrap and 'bootstrap_compilers' in flavor:
compilers = flavor['bootstrap_compilers']
else:
compilers = flavor.get('compilers', {})
cc = compilers.get('cc', 'gcc')
cxx = compilers.get('cxx', 'g++')
fc = compilers.get('fc', 'gfortran')
args = [
f"--prefix={install_prefix}",
# Explicitly set compilers to avoid configure picking up system defaults
f"CC={cc}",
f"CXX={cxx}",
f"FC={fc}",
f"F77={fc}",
]
# Get defaults configuration if it exists
defaults = recipe.get('configure', {}).get('defaults', {})
# Always check the value of 'shared' - default to True if not specified
# This means: if 'defaults' doesn't exist, use True
# if 'defaults' exists but 'shared' is not in it, use True
# if 'defaults' exists and 'shared' is in it, use its value
use_shared = defaults.get('shared', True)
if use_shared:
args.extend(["--enable-shared", "--disable-static"])
# Same logic for host_flags. --host/--build only, as Fedora's %configure
# does: an explicit --target makes every AC_CANONICAL_TARGET package
# install its programs as <triplet>-<name> (hwloc's x86_64-redhat-linux-
# lstopo). --target is for compilers, which opt in below (gcc).
# Keep in step with rpm_builder.get_configure_args_for_rpm. Christian,
# 2026-10-03 (D3).
use_host_flags = defaults.get('host_flags', True)
if use_host_flags:
args.extend([
f"--host={host}",
f"--build={host}",
])
# Compilers opt in to --target (configure.defaults.target_flag), with the
# same host string as --host, so gcc's <triplet>-gcc drivers and
# lib/gcc/<triplet>/ keep the name its install.post links point at.
if defaults.get('target_flag', False):
args.append(f"--target={host}")
# Add flavor-specific configure args from flavor definition
if 'configure' in flavor.get('flags', {}):
args.extend(flavor['flags']['configure'].split())
# Add recipe-specific configure args
if 'configure' in recipe and 'args' in recipe['configure']:
for arg in recipe['configure']['args']:
# Substitute variables
arg = arg.replace('%{prefix}', str(prefix))
arg = arg.replace('%{install_prefix}', str(install_prefix))
args.append(arg)
# Add flavor-specific args from recipe
if 'configure' in recipe and 'flavor_args' in recipe['configure']:
flavor_specific = resolve_flavor_key(flavor, recipe['configure']['flavor_args'])
if flavor_specific:
for arg in flavor_specific:
# Substitute variables in flavor args too
arg = arg.replace('%{prefix}', str(prefix))
arg = arg.replace('%{install_prefix}', str(install_prefix))
args.append(arg)
return args
# Ubuntu's gcc passes --as-needed to the linker by default; el9's does not. Under
# --as-needed an object keeps a NEEDED entry only for libraries whose symbols it
# references directly, so on Ubuntu libpetsc lost libmkl_core/libmkl_gnu_thread and
# ~220 objects per flavor lost libopenblas/libscalapack/libgomp, getting them only
# through some other library's dependency chain. The publishing host checks every
# object's NEEDED against el9's, so the .deb path links the way el9 does. The RPM
# path does not add it: el9 already behaves this way, and its specs stay unchanged.
# This flag alone is NOT sufficient: libtool puts -Wl flags after the libraries.
# The effective fix is the host-level gcc specs override in doc/BUILD_EXECUTION.md
# §1.1a, which deb_builder.assert_links_without_as_needed enforces; this stays as
# a second layer for cmake and plain-LDFLAGS builds.
# Christian, 2026-09-29; see devlog/dl20260929_deb_no_as_needed.md.
NO_AS_NEEDED = '-Wl,--no-as-needed'
def get_cmake_args(recipe: Dict, host: str, flavor: Dict, prefix: Path,
install_prefix: Path, build_libdir: Optional[Union[Path, str]] = None,
no_as_needed: bool = False) -> List[str]:
"""Get CMake arguments.
no_as_needed: prefix the linker flags with NO_AS_NEEDED (Linux only). The
.deb/unix path sets it; the RPM path does not.
"""
# Determine compilers based on MPI feature
features = recipe.get('features', {})
use_mpi = features.get('mpi', False)
if use_mpi:
# Use MPI compiler wrappers
cc = 'mpicc'
cxx = 'mpicxx'
fc = 'mpifort'
else:
# Bootstrap packages build before scls gcc is usable (e.g. on macOS
# the freshly built gcc lands on PATH but can't parse Apple SDK
# headers that use clang blocks). Honor the flavor's
# bootstrap_compilers override, matching _prepare_build_env.
is_bootstrap = recipe.get('bootstrap', False)
if is_bootstrap and 'bootstrap_compilers' in flavor:
compilers = flavor['bootstrap_compilers']
else:
compilers = flavor.get('compilers', {})
cc = compilers.get('cc', 'gcc')
cxx = compilers.get('cxx', 'g++')
fc = compilers.get('fc', 'gfortran')
# For the mkl flavor, also include MKL's lib/include in the cmake search
# paths so cmake's own FindBLAS/FindLAPACK (used e.g. by lapackpp via
# use_cmake_find_lapack=ON + BLA_VENDOR=Intel10_64lp) can locate MKL.
# cmake's find_library reads CMAKE_LIBRARY_PATH, not the LIBRARY_PATH env
# var, so we have to thread it through here.
cmake_library_path = f"{prefix}/lib"
cmake_include_path = f"{prefix}/include"
extra_link_dirs = "" # extra -L / -rpath / -rpath-link entries
std_libs = "-lm" # appended to every link via CMAKE_C/CXX_STANDARD_LIBRARIES
# Apple's ld64 doesn't understand -rpath-link; Mach-O dylibs carry install
# names so the static linker resolves transitive deps without it. Only emit
# the flag on Linux (GNU ld / lld), where it's needed for try_compile checks
# and demo/test linking against indirect deps.
platform_name = flavor.get('platform', 'linux')
is_linux = platform_name == 'linux'
is_macos = platform_name == 'macos'
rpath_link_dirs = []
if is_linux:
if build_libdir is not None:
# Put the active build tree first so GNU ld resolves indirect
# shared-library deps from this build before any installed flavor.
rpath_link_dirs.append(str(build_libdir))
rpath_link_dirs.append(f"{prefix}/lib")
rpath_link_flag = ''.join(f" -Wl,-rpath-link,{path}" for path in rpath_link_dirs)
macos_linker_flags = " -Wl,-headerpad_max_install_names" if is_macos else ""
# First on the line, so it governs every library that follows.
as_needed_flag = f"{NO_AS_NEEDED} " if (no_as_needed and is_linux) else ""
if flavor.get('math', {}).get('linalg') == 'mkl':
import os as _os
mkl_root = _os.environ.get('MKLROOT', '/opt/intel/oneapi/mkl/latest')
cmake_library_path = f"{prefix}/lib;{mkl_root}/lib/intel64;{mkl_root}/lib"
cmake_include_path = f"{prefix}/include;{mkl_root}/include"
# Demos and tests link against libfoo.so which itself NEEDS libmkl_*.so.2.
# The linker needs -rpath-link to resolve those transitive deps. We also
# bake -rpath so the resulting binaries find MKL at runtime without
# needing LD_LIBRARY_PATH.
#
# Additionally, many test/demo binaries need MKL symbols transitively
# (e.g. via libblaspp.so → libmkl_gf_lp64.so). We inject the MKL libs
# via CMAKE_CXX_STANDARD_LIBRARIES / CMAKE_C_STANDARD_LIBRARIES, which
# cmake appends at the END of every link command (after target libs).
# Putting them in CMAKE_EXE_LINKER_FLAGS doesn't work because cmake
# 4.x strips -l flags from there (they're not "flags").
# Single interface library, selected by Fortran ABI — see
# math_common.get_mkl_interface_lib for the rule. Mixing
# mkl_intel_* and mkl_gf_* in one link line is incorrect.
#
# The OpenMP runtime is deliberately LEFT OUT for packages compiled
# with OpenMP. A literal -lgomp here makes every cmake try_compile
# that links an OpenMP imported target link with no OpenMP runtime at
# all and fail on undefined GOMP_* — silently compiling out whatever
# the probe gated, while the outer library link stays fine. That is
# how scls-mkl-strumpack shipped without its OpenMP tasking on three
# distros; see doc/MKL_ABI_POLICY.md. Do not "restore" it here.
#
# These packages still get the runtime by two routes. SCLS puts
# -fopenmp/-qopenmp in CFLAGS/CXXFLAGS/FCFLAGS for every CMAKE recipe
# with features.openmp (rpm_builder/unix_builder inject it; the scope
# is cmake because this helper is the only thing it backstops), and
# the compiler driver adds the runtime to every link it performs.
# Upstream's own find_package(OpenMP) adds it again where used.
# Packages WITHOUT features.openmp keep the runtime in this slot,
# because neither route applies to them.
from math_common import get_mkl_serial_link_line
uses_openmp = bool((recipe.get('features', {}) or {}).get('openmp'))
std_libs = get_mkl_serial_link_line(
flavor, with_openmp_runtime=not uses_openmp)
extra_link_dirs = (
f" -L{mkl_root}/lib/intel64"
f" -Wl,-rpath,{mkl_root}/lib/intel64"
)
if is_linux:
extra_link_dirs += f" -Wl,-rpath-link,{mkl_root}/lib/intel64"
args = [
f"-DCMAKE_INSTALL_PREFIX={install_prefix}",
"-DCMAKE_BUILD_TYPE=Release",
"-DCMAKE_INSTALL_LIBDIR=lib",
# Explicitly set compilers to avoid CMake picking up system defaults
f"-DCMAKE_C_COMPILER={cc}",
f"-DCMAKE_CXX_COMPILER={cxx}",
f"-DCMAKE_Fortran_COMPILER={fc}",
# Help CMake find libraries/headers in our prefix (needed for try_run tests)
f"-DCMAKE_PREFIX_PATH={prefix}",
f"-DCMAKE_LIBRARY_PATH={cmake_library_path}",
f"-DCMAKE_INCLUDE_PATH={cmake_include_path}",
# Pass linker flags so libraries in our prefix are found at link time.
# -rpath-link helps the linker resolve indirect shared library
# dependencies (e.g. libopenblas.so -> libgfortran.so, libcholmod.so
# -> libmkl_*.so.2) during try_compile checks and demo/test linking.
f"-DCMAKE_SHARED_LINKER_FLAGS={as_needed_flag}-L{prefix}/lib -Wl,-rpath,{prefix}/lib{rpath_link_flag}{extra_link_dirs}{macos_linker_flags}",
f"-DCMAKE_MODULE_LINKER_FLAGS={as_needed_flag}-L{prefix}/lib -Wl,-rpath,{prefix}/lib{rpath_link_flag}{extra_link_dirs}{macos_linker_flags}",
f"-DCMAKE_EXE_LINKER_FLAGS={as_needed_flag}-L{prefix}/lib -Wl,-rpath,{prefix}/lib{rpath_link_flag}{extra_link_dirs}{macos_linker_flags}",
# Libraries that every link command needs, appended at the END (after
# target libs). For MKL: the full MKL link line so transitive deps
# resolve. For non-MKL: just -lm (catches CMakeLists that forgot it).
# cmake 4.x strips -l flags from CMAKE_EXE_LINKER_FLAGS, so this is
# the only reliable way to inject them.
f"-DCMAKE_C_STANDARD_LIBRARIES={std_libs}",
f"-DCMAKE_CXX_STANDARD_LIBRARIES={std_libs}",
]
if is_macos:
args.extend([
f"-DCMAKE_INSTALL_NAME_DIR={install_prefix}/lib",
f"-DCMAKE_INSTALL_RPATH={prefix}/lib",
"-DCMAKE_SKIP_INSTALL_RPATH=OFF",
])
# Get defaults configuration if it exists
defaults = recipe.get('configure', {}).get('defaults', {})
# Check if we should add host/cross-compilation flags
# NOTE: For native builds, do NOT set CMAKE_SYSTEM_NAME or CMAKE_SYSTEM_PROCESSOR
# as this tells CMake it's cross-compiling and disables try_run() tests.
# These should only be set for actual cross-compilation scenarios.
use_host_flags = defaults.get('host_flags', True)
cross_compile = defaults.get('cross_compile', False)
if use_host_flags and cross_compile:
# Only set these for actual cross-compilation
if 'darwin' in host:
args.append("-DCMAKE_SYSTEM_NAME=Darwin")
elif 'linux' in host:
args.append("-DCMAKE_SYSTEM_NAME=Linux")
# Add processor based on host triple
if host.startswith('x86_64'):
args.append("-DCMAKE_SYSTEM_PROCESSOR=x86_64")
elif host.startswith('aarch64'):
args.append("-DCMAKE_SYSTEM_PROCESSOR=aarch64")
elif host.startswith('arm'):
args.append("-DCMAKE_SYSTEM_PROCESSOR=arm")
# Handle shared/static libraries
use_shared = defaults.get('shared', True)
if use_shared:
args.append("-DBUILD_SHARED_LIBS=ON")
else:
args.append("-DBUILD_SHARED_LIBS=OFF")
# Add recipe-specific cmake args
if 'configure' in recipe and 'args' in recipe['configure']:
for arg in recipe['configure']['args']:
# Substitute variables
arg = arg.replace('%{prefix}', str(prefix))
arg = arg.replace('%{install_prefix}', str(install_prefix))
args.append(arg)
# Add flavor-specific args from recipe
if 'configure' in recipe and 'flavor_args' in recipe['configure']:
flavor_specific = resolve_flavor_key(flavor, recipe['configure']['flavor_args'])
if flavor_specific:
for arg in flavor_specific:
# Substitute variables in flavor args too
arg = arg.replace('%{prefix}', str(prefix))
arg = arg.replace('%{install_prefix}', str(install_prefix))
args.append(arg)
return args
_GCC_IDENTITY_CACHE: Dict[str, Tuple[str, str]] = {}
def gcc_identity(prefix: Path, flavor: Dict) -> Tuple[str, str]:
"""Return (version, machine) of the SCLS-installed GCC C++ compiler.
Backs the %{gcc_version} and %{gcc_machine} macros, which exist so a
recipe can name GCC's own header directory,
<prefix>/include/c++/<version>/<machine>, without hardcoding it
(recipes/vtk.yaml points Apple clang at the SCLS libstdc++ that way).
That directory is named by the installed compiler's full version and
configured target, so both are read from the compiler itself:
`-dumpfullversion` (not `-dumpversion`, which may print the major only)
and `-dumpmachine`. Not from recipes/gcc.yaml (the installed GCC can lag
the recipe) and not from uname (a Darwin point upgrade changes uname -r
but not the directory GCC was configured with).
The probe is <prefix>/bin/<flavor cxx>, deliberately not a PATH lookup:
this runs in the builder process, whose PATH is the developer's shell
PATH (a Homebrew gcc, or /usr/bin/g++, which is Apple clang and reports
arm64-apple-darwin where GCC says aarch64-apple-darwin). An absolute
flavor cxx is used as is. A missing compiler is a hard error, not a
fallback: a wrong answer here is exactly the stale-path failure the
macros replace. Cached per probe path.
Scope: the macros describe GCC's include layout only when that GCC was
NOT configured with --with-gcc-major-version-only. The lbl flavor's GCC
is (recipes/gcc.yaml, flavor_args.gcc), so its directory is
include/c++/16 while -dumpfullversion says 16.2.0; the macos GCC is
not. Today the only consumer is recipes/vtk.yaml under
flavor_args.macos. Do not reuse the macros on lbl without revisiting
this, and do not point them at a non-GCC cxx (intel's icpx).
"""
cxx = flavor.get('compilers', {}).get('cxx', 'g++')
probe = Path(cxx) if os.path.isabs(cxx) else Path(prefix) / 'bin' / cxx
key = str(probe)
if key in _GCC_IDENTITY_CACHE:
return _GCC_IDENTITY_CACHE[key]
if not probe.is_file():
raise BuildError(
f"%{{gcc_version}}/%{{gcc_machine}} need the SCLS GCC at {probe}, "
f"which does not exist. Build and install gcc for this flavor first.")