forked from codrsquad/portable-python
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path__init__.py
More file actions
841 lines (668 loc) · 30.9 KB
/
__init__.py
File metadata and controls
841 lines (668 loc) · 30.9 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
"""
Designed to be used via portable-python CLI.
Can be used programmatically too, example usage:
from portable_python import BuildSetup
setup = BuildSetup("cpython:3.9.6")
setup.compile()
"""
import contextlib
import enum
import logging
import multiprocessing
import os
import pathlib
import re
from string import Template
from typing import ClassVar, List
import runez
from runez.http import RestClient
from runez.pyenv import PythonSpec
from runez.render import Header, PrettyTable
from portable_python.versions import PPG
LOG = logging.getLogger(__name__)
RX_BINARY = re.compile(r"^.*\.(dylib|gmo|icns|ico|nib|prof.*|tar)$")
def is_binary_file(path):
return RX_BINARY.match(path.name)
def patch_folder(folder, regex, replacement, ignore=None):
"""Replace all occurrences of 'old_text' by 'new_text' in all files in 'folder'
Parameters
----------
folder : pathlib.Path
Folder to scan
regex : str
Regex to replace
replacement : str
Replacement text
ignore : re.Pattern | None
Regex stating what to ignore
"""
for path in runez.ls_dir(folder):
if not path.is_symlink() and (not ignore or not ignore.match(path.name)):
if path.is_dir():
patch_folder(path, regex, replacement, ignore=ignore)
elif not is_binary_file(path):
patch_file(path, regex, replacement)
def patch_file(path, regex, replacement):
try:
with open(path) as fh:
text = fh.read()
new_text = re.sub(regex, replacement, text, flags=re.MULTILINE)
if text != new_text:
with open(path, "w") as fh:
fh.write(new_text)
LOG.info("Patched '%s' in %s", regex, runez.short(path))
except Exception as e:
with open(path, errors="ignore") as fh:
text = fh.read()
if re.search(regex, text):
LOG.warning("Can't patch '%s': %s", runez.short(path), e)
class FolderMask:
"""
Unfortunately, python source ./configure and setup.py looks at /usr/local/... we DON'T want that for a portable build
as that implies machine where our binary would run must also have the /usr/local/... stuff
TODO: find a less hacky way of doing this, or contribute an upstream option to stop looking at /usr/local/
On macos, we temporarily mask /usr/local with an empty RAM disk mount...
This is unfortunately global, and will temporarily mask /usr/local for other workers on the same macos box as well
"""
def __init__(self, target_folder):
LOG.info("Applying isolation hack/mask to %s", target_folder)
self.target_folder = target_folder
r = runez.run("hdiutil", "attach", "-nomount", "ram://2048", fatal=Exception)
self.ram_disk = r.output.strip()
self.mounted = False
def mount(self):
runez.run("newfs_hfs", "-v", "tmp-portable-python", self.ram_disk, fatal=Exception)
runez.run("mount", "-r", "-t", "hfs", "-o", "nobrowse", self.ram_disk, self.target_folder, fatal=Exception)
self.mounted = True
def cleanup(self):
LOG.info("Cleaning up isolation hack/mask for %s", self.target_folder)
if self.mounted:
runez.run("umount", self.target_folder, fatal=False)
runez.run("hdiutil", "detach", self.ram_disk, fatal=False)
class BuildContext:
"""
Context for BuildSetup.compile()
"""
usr_local = "/usr/local"
def __init__(self, setup):
self.setup = setup
self.masked_folders = []
v = self._resolved_isolation()
runez.abort_if(v and v not in ("mount-shadow", "gettext-tiny"), f"Invalid isolation method '{v}'")
self.isolate_usr_local = v
if self.isolate_usr_local:
LOG.info("isolate-usr-local: %s", self.isolate_usr_local)
def _resolved_isolation(self):
"""
Isolation setting currently configured.
Returns
-------
str | None
What strategy to use to work around the fact that python's ./configure script looks at /usr/local
"""
v = PPG.config.get_value("isolate-usr-local")
if v == "auto":
v = None
folder = os.path.join(self.usr_local, "include")
if PPG.target.is_macos and os.path.isdir(folder):
fnames = ["libintl.h"]
if self.setup.python_builder.active_module("gdbm"):
fnames.append("dbm.h")
fnames.append("gdbm.h")
for fname in fnames:
fpath = os.path.join(folder, fname)
if os.path.exists(fpath):
return "mount-shadow"
return v
def __repr__(self):
return self.isolate_usr_local or "none"
def __enter__(self):
runez.Anchored.add(self.setup.folders.base_folder)
if self.isolate_usr_local == "mount-shadow":
# Fail early if this is attempted on linux (where there should be no need for this, with a good docker image)
runez.abort_if(not PPG.target.is_macos, "/usr/local isolation implemented only for macos currently")
# Safeguard against accidental isolation hack in non-dryrun test
runez.abort_if(not runez.DRYRUN and runez.DEV.current_test(), "Folder masking not allowed in tests")
try:
for fname in ("etc", "include", "lib", "opt"):
path = os.path.join(self.usr_local, fname)
mask = FolderMask(path)
self.masked_folders.append(mask)
mask.mount()
except BaseException: # pragma: no cover, ensure cleanup if any folder couldn't be masked
self.cleanup()
raise
return self
def compile(self):
if self.isolate_usr_local == "gettext-tiny":
# Provide a dummy libintl.h, this isn't perfect but takes out the main culprit: sneaky libintl
from portable_python.external import Toolchain
toolchain = Toolchain(self.setup)
toolchain.compile()
def cleanup(self):
for mask in self.masked_folders:
mask.cleanup()
def __exit__(self, exc_type, exc_val, exc_tb):
self.cleanup()
runez.Anchored.pop(self.setup.folders.base_folder)
class BuildSetup:
"""
Drives the compilation, external modules first, then the target python itself.
All modules are compiled in the same manner, follow the same conventional build layout.
"""
# Internal, used to ensure files under {logs}/ folder sort alphabetically in the same order they were compiled
log_counter = 0
def __init__(self, python_spec=None, modules=None, prefix=None):
"""
Parameters
----------
python_spec : str | PythonSpec | None
Python to build (family and version)
modules : str | None
Modules to build (default: from config)
prefix : str | None
--prefix to use
"""
if not python_spec or python_spec == "latest":
python_spec = PPG.cpython.latest
if not isinstance(python_spec, PythonSpec):
ps = PythonSpec.from_object(python_spec)
runez.abort_if(not ps, "Invalid python spec: %s" % runez.red(python_spec))
python_spec = ps
runez.abort_if(not python_spec.version or not python_spec.version.is_valid, "Invalid python spec: %s" % runez.red(python_spec))
if len(python_spec.version.given_components) < 3:
runez.abort("Please provide full desired version: %s is not good enough" % runez.red(python_spec))
self.python_spec = python_spec
self.folders = PPG.get_folders(
base=os.getcwd(), family=python_spec.family, version=python_spec.version, abi_suffix=python_spec.abi_suffix
)
self.desired_modules = modules
prefix = self.folders.formatted(prefix)
self.prefix = prefix
self.x_debug = os.environ.get("PP_X_DEBUG")
configured_ext = PPG.config.get_value("ext")
ext = runez.SYS_INFO.platform_id.canonical_compress_extension(configured_ext, short_form=True)
if not ext:
runez.abort("Invalid extension '%s'" % runez.red(configured_ext))
if prefix:
dest = prefix.strip("/").replace("/", "-")
self.tarball_name = PPG.target.composed_basename(dest, extension=ext)
else:
self.tarball_name = PPG.target.composed_basename(
python_spec.family, python_spec.version, abi_suffix=python_spec.abi_suffix, extension=ext
)
builder = PPG.family(python_spec.family).get_builder()
self.python_builder = builder(self) # type: PythonBuilder
def __repr__(self):
return str(self.folders)
def validate_module_selection(self, fatal=True):
issues = []
selected = self.python_builder.modules.selected
for module in selected:
outcome, _ = module.linker_outcome(True)
if outcome is LinkerOutcome.failed:
issues.append(module)
for module in self.python_builder.modules.candidates:
if module not in selected:
outcome, _ = module.linker_outcome(is_selected=False)
if outcome is LinkerOutcome.failed:
issues.append(module)
if issues:
return runez.abort("Problematic modules: %s" % runez.joined(issues), fatal=fatal)
def ensure_clean_folder(self, path):
if path:
runez.ensure_folder(path, clean=not self.x_debug)
@runez.log.timeit("Overall compilation")
def compile(self):
"""Compile selected python family and version"""
self.ensure_clean_folder(self.folders.build_folder)
if self.folders.logs:
self.ensure_clean_folder(self.folders.logs)
logs_path = self.folders.logs / "00-portable-python.log"
runez.log.setup(file_location=logs_path.as_posix())
self.python_builder.validate_setup()
self.log_counter = 0
with BuildContext(self) as build_context:
self.build_context = build_context
modules = self.python_builder.modules
LOG.info("portable-python v%s, current folder: %s", runez.get_version(__name__), os.getcwd())
LOG.info(runez.joined(modules, list(modules)))
LOG.info(PPG.config.config_files_report())
LOG.info("Platform: %s", PPG.target)
LOG.info("Build report:\n%s", self.python_builder.modules.report())
self.validate_module_selection(fatal=not runez.DRYRUN and not self.x_debug)
self.ensure_clean_folder(self.folders.components)
self.ensure_clean_folder(self.folders.deps)
build_context.compile()
self.python_builder.compile()
if self.folders.dist:
runez.compress(self.python_builder.install_folder, self.folders.dist / self.tarball_name)
class ModuleCollection:
"""Models a collection of sub-modules, with auto-detection and reporting as to what is active and why"""
candidates: List["ModuleBuilder"] = None
desired: str = None
selected: List["ModuleBuilder"] = None
def __init__(self, parent_module: "ModuleBuilder", desired=None):
self.selected = []
self.auto_selected = {}
self.candidates = []
self.desired = desired
self.module_by_name = {} # type: dict[str, ModuleBuilder]
candidates = parent_module.candidate_modules()
if candidates:
for module in candidates:
module = module(parent_module)
self.candidates.append(module)
self.module_by_name[module.m_name] = module
if desired == "all":
self.selected = self.candidates
return
desired = [] if desired == "none" else runez.flattened(desired, split=True)
desired = runez.flattened(desired, split=",")
unknown = [x for x in desired if x not in self.module_by_name]
if unknown:
runez.abort("Unknown modules: %s" % runez.joined(unknown, delimiter=", ", stringify=runez.red))
for candidate in self.candidates:
if candidate.m_name not in desired:
reason = candidate.auto_select_reason()
if reason:
self.auto_selected[candidate.m_name] = reason
desired.extend(self.auto_selected.keys())
self.selected = [self.module_by_name[x] for x in desired]
def __repr__(self):
return "selected: %s (%s)" % (self.desired, runez.plural(self.selected, "module"))
def __iter__(self):
for module in self.selected:
yield from module.modules
yield module
@staticmethod
def get_module_name(module):
if not isinstance(module, str):
module = module.__name__.lower()
return module
def active_module(self, name):
name = self.get_module_name(name)
m = self.module_by_name[name]
if m in self.selected:
return m
def is_usable_module(self, name):
"""Is module with name either selected, or should be usable via its telltale"""
name = self.get_module_name(name)
m = self.module_by_name[name]
return m in self.selected or m.resolved_telltale
def report(self):
table = PrettyTable(4, missing="")
rows = list(self.report_rows())
table.add_rows(*rows)
return str(table)
def report_rows(self, indent=0):
indent_str = " +%s " % ("-" * indent) if indent else ""
for module in self.candidates:
name = module.m_name
is_selected = module in self.selected
note = module.scan_note()
outcome, problem = module.linker_outcome(is_selected)
if name in self.auto_selected:
outcome = runez.green("static*")
note = "[%s] %s" % (runez.bold("auto-selected"), self.auto_selected[name])
if isinstance(outcome, LinkerOutcome):
outcome = runez.colored(outcome.name, outcome.value)
elif outcome is runez.UNSET:
outcome = None
yield "%s%s" % (indent_str, name), module.version, outcome, problem or note
yield from module.modules.report_rows(indent + 1)
class LinkerOutcome(enum.Enum):
absent = "orange"
failed = "red"
shared = "blue"
static = "green"
# noinspection PyPep8Naming
class ModuleBuilder:
"""Common behavior for all external (typically C) modules to be compiled"""
m_build_cwd: str = None # Optional: relative (to unpacked source) folder where to run configure/make from
m_debian = None
m_include: str = None # Optional: subfolder to automatically list in CPATH when this module is active
m_telltale: ClassVar[list] # Optional: list of files that, if present, indicate this module is installed
setup: BuildSetup
parent_module: "ModuleBuilder" = None
_log_handler = None
def __init__(self, parent_module):
"""
Parameters
----------
parent_module : BuildSetup | ModuleBuilder
Associated parent
"""
self.m_name = ModuleCollection.get_module_name(self.__class__)
if isinstance(parent_module, BuildSetup):
self.setup = parent_module
else:
self.setup = parent_module.setup
self.parent_module = parent_module
self.modules = self.selected_modules()
self.m_src_build = self.setup.folders.components / self.m_name
self.resolved_telltale = self._find_telltale()
def __repr__(self):
return "%s:%s" % (self.m_name, self.version)
@classmethod
def candidate_modules(cls) -> list:
"""All possible candidate external modules for this builder"""
def selected_modules(self):
return ModuleCollection(self, desired="all")
def auto_select_reason(self):
"""
If this module must be selected (build can't succeed without), descendant should return short explanation why
"""
def linker_outcome(self, is_selected):
if self.resolved_telltale is runez.UNSET:
return runez.UNSET, None
debian = self.m_debian
if self.resolved_telltale:
if is_selected and PPG.target.is_linux and debian and debian.startswith("-"):
return LinkerOutcome.failed, "%s, can't compile statically with %s present" % (runez.red("broken"), debian[1:])
outcome = LinkerOutcome.static if is_selected else LinkerOutcome.shared
return outcome, None
if PPG.target.is_linux and debian:
if debian.startswith("!"):
return LinkerOutcome.failed, "%s, can't compile without %s" % (runez.red("broken"), debian[1:])
if debian.startswith("+") and is_selected:
return LinkerOutcome.failed, "%s, can't compile without %s" % (runez.red("broken"), debian[1:])
if not debian.startswith("-"):
return LinkerOutcome.absent, None
outcome = LinkerOutcome.static if is_selected else LinkerOutcome.absent
return outcome, None
def scan_note(self):
if self.resolved_telltale is runez.UNSET:
return runez.dim("sub-module of %s" % self.parent_module)
if self.resolved_telltale:
return "has %s" % self.resolved_telltale
return "no %s" % self.m_telltale
def _find_telltale(self):
telltales = getattr(self, "m_telltale", runez.UNSET)
if telltales is runez.UNSET:
return telltales
return PPG.find_telltale(telltales)
def active_module(self, name):
return self.modules.active_module(name)
def is_usable_module(self, name):
"""Is module with name either selected or usable as a shared lib, as determined via its telltale"""
return self.modules.is_usable_module(name)
def cfg_version(self, default):
return PPG.config.get_value("%s-version" % self.m_name) or default
def cfg_http_headers(self):
if config_http_headers := PPG.config.get_value("%s-http-headers" % self.m_name):
expanded_http_headers = {}
for header_dict in config_http_headers:
for key, value in header_dict.items():
expanded_http_headers[os.path.expandvars(key)] = os.path.expandvars(value)
return expanded_http_headers
def cfg_url(self, version):
if config_url := PPG.config.get_value("%s-url" % self.m_name):
url_template = Template(config_url)
url_subbed = url_template.substitute(version=version)
return os.path.expandvars(url_subbed)
def cfg_src_suffix(self):
return PPG.config.get_value("%s-src-suffix" % self.m_name)
def cfg_configure(self, deps_lib_dir, deps_lib64_dir):
if configure := PPG.config.get_value("%s-configure" % self.m_name):
configure_template = Template(configure)
return configure_template.substitute(lib_dir=deps_lib_dir, lib64_dir=deps_lib64_dir)
def cfg_patches(self):
return PPG.config.get_value("%s-patches" % self.m_name)
@property
def url(self):
"""Url of source tarball, if any"""
return ""
@property
def headers(self):
"""Headers for connecting to source url, if any"""
return self.cfg_http_headers()
@property
def src_suffix(self):
"""Suffix of src archive for when URL doesn't end in the file extension"""
return self.cfg_src_suffix()
@property
def version(self):
"""Version to use"""
return self.parent_module and self.parent_module.version
@property
def deps(self):
"""Folder <build>/.../deps/, where all externals modules get installed"""
return self.setup.folders.deps
@property
def deps_lib_dir(self):
return self.deps / "lib"
@property
def deps_lib64_dir(self):
return self.deps / "lib64"
@property
def deps_lib_dirs(self):
lib_dirs = [self.deps_lib_dir]
if self.deps_lib64_dir.exists():
lib_dirs.append(self.deps_lib64_dir)
return lib_dirs
def xenv_CPATH(self):
folder = self.deps / "include"
if folder.exists():
yield folder
if self.modules.selected:
# By default, set CPATH only for modules that have sub-modules (descendants can override this easily)
for module in self.modules:
if module.m_include:
yield folder / module.m_include
def xenv_LDFLAGS(self):
if self.modules.selected:
yield from (f"-L{lib_dir}" for lib_dir in self.deps_lib_dirs)
def xenv_PATH(self):
yield f"{self.deps}/bin"
yield from os.environ.get("PATH", "").split(":")
yield "/usr/bin"
yield "/bin"
def xenv_LD_LIBRARY_PATH(self):
yield from os.environ.get("LD_LIBRARY_PATH", "").split(":")
def xenv_PKG_CONFIG_PATH(self):
yield from os.environ.get("PKG_CONFIG_PATH", "").split(":")
if self.modules.selected:
yield from (f"{lib_dir}/pkgconfig" for lib_dir in self.deps_lib_dirs)
def _do_run(self, program, *args, fatal=True, env=None):
return runez.run(program, *args, passthrough=self._log_handler, stdout=None, stderr=None, fatal=fatal, env=env)
def run_configure(self, program, *args, prefix=None):
"""
Run ./configure script for this module.
"""
if prefix is None:
prefix = self.deps
if prefix:
prefix = f"--prefix={prefix}"
program = program.split()
cmd = runez.flattened(*program, prefix, *args)
return self._do_run(*cmd)
def run_make(self, *args, program="make", cpu_count=None):
cmd = program.split()
if cpu_count is None:
available = multiprocessing.cpu_count()
# If we can't retrieve the number of cores, leave cpu_count as None
# and we'll omit -j below.
if available and available > 0:
cpu_count = available
if cpu_count and cpu_count > 1:
cmd.append("-j%s" % cpu_count)
self._do_run(*cmd, *args)
@contextlib.contextmanager
def captured_logs(self):
try:
if self.setup.folders.logs:
self.setup.log_counter += 1
logs_path = self.setup.folders.logs / f"{self.setup.log_counter:02}-{self.m_name}.log"
if not runez.DRYRUN:
runez.touch(logs_path, logger=None)
self._log_handler = logging.FileHandler(logs_path)
self._log_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
self._log_handler.setLevel(logging.DEBUG)
logging.root.addHandler(self._log_handler)
yield
except Exception as e:
overview = repr(e)
LOG.exception("Error while compiling %s: %s", self, overview)
raise
finally:
if self._log_handler:
logging.root.removeHandler(self._log_handler)
self._log_handler = None
def compile(self):
"""Effectively compile this external module"""
for submodule in self.modules.selected:
submodule.compile()
if self.url:
# Modules without a url just drive sub-modules compilation typically
print(Header.aerated(str(self)))
with self.captured_logs():
if self.setup.x_debug:
if "direct-finalize" in self.setup.x_debug:
# For quicker iteration: debugging directly finalization
self._finalize()
return
# Some URL's may not end in file extension, such as with redirects.
# Github releases asset endpoint is this way .../releases/assets/48151
# Split on '#' for urls that include a checksum, such as #sha256=... fragment
basename = runez.basename(self.url, extension_marker="#")
if not basename.endswith((".zip", ".tar.gz")):
suffix = self.src_suffix or ".tar.gz"
suffix = ".%s" % (suffix.strip(".")) # Ensure it starts with a dot (in case config forgot leading dot)
basename = f"{self.m_name}-{self.version}{suffix}"
path = self.setup.folders.sources / basename
if not path.exists():
proxies = {}
http_proxy = os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy")
if http_proxy:
proxies["http"] = http_proxy
https_proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy")
if https_proxy:
proxies["https"] = https_proxy
RestClient().download(self.url, path, proxies=proxies, headers=self.headers)
runez.decompress(path, self.m_src_build, simplify=True)
env_vars = self._get_env_vars()
if not PPG.config.get_value("allow-homebrew"):
# Remove any mention of /opt/homebrew from PATH (reduce chances of dynamic links to homebrew)
path_env_var = env_vars.get("PATH")
if path_env_var:
_paths = os.environ.get("PATH", "").split(":")
_revised = [p for p in _paths if not p.startswith("/opt/homebrew")]
if _revised != _paths:
LOG.info("Removed /opt/homebrew mentions from PATH")
env_vars["PATH"] = runez.joined(_revised, delimiter=":")
prev_env_vars = {}
for var_name, value in env_vars.items():
LOG.info("env %s=%s", var_name, runez.short(value, size=2048))
prev_env_vars[var_name] = os.environ.get(var_name)
os.environ[var_name] = value
func = getattr(self, "_do_%s_compile" % PPG.target.platform, None)
if not func:
runez.abort("Compiling on platform '%s' is not yet supported" % runez.red(PPG.target.platform))
with runez.log.timeit("Compiling %s" % self.m_name):
folder = self.m_src_build
if self.m_build_cwd:
folder = folder / self.m_build_cwd
with runez.CurrentFolder(folder):
self._apply_patches()
self._prepare()
func()
self._finalize()
# Restore env vars as they were (to avoid any side effect)
for k, v in prev_env_vars.items():
if v is None:
if k in os.environ:
del os.environ[k]
else:
os.environ[k] = v
def _apply_patches(self):
if patches := self.cfg_patches():
for patch in patches:
if runez.DRYRUN:
print(f"Would apply patch: {patch}")
else:
print(f"Applying patch: {patch}")
patch_file(patch["file"], patch["regex"], patch["replacement"])
def _get_env_vars(self):
"""Yield all found env vars, first found wins"""
result = {}
for k, v in self._find_all_env_vars():
if v is not None:
if k not in result:
result[k] = v
return result
def _find_all_env_vars(self):
"""Env vars defined in code take precedence, the config can provide extra ones"""
for var_name in sorted(dir(self)):
if var_name.startswith("xenv_"):
# By convention, xenv_* values are used as env vars
value = getattr(self, var_name)
var_name = var_name[5:]
delimiter = os.pathsep if var_name.endswith("PATH") else " "
if value:
if callable(value):
value = value() # Allow for generators
value = runez.joined(value, delimiter=delimiter) # All yielded values are auto-joined
if value:
yield var_name, value
env = PPG.config.get_value("env")
if env:
for k, v in env.items():
if v is not None:
yield k, str(v)
def _prepare(self):
"""Ran before _do_*_compile()"""
def _do_macos_compile(self):
"""Compile on macos variants"""
return self._do_linux_compile()
def _do_linux_compile(self):
"""Compile on linux variants"""
def _finalize(self):
"""Ran after _do_*_compile()"""
class PythonBuilder(ModuleBuilder):
_bin_python: pathlib.Path = None
def __init__(self, parent_module):
super().__init__(parent_module)
self.destdir = self.setup.folders.destdir # Folder passed to 'make install DESTDIR='
self.c_configure_prefix = self.setup.prefix or self.setup.folders.ppp_marker
self.install_folder = self.destdir / self.c_configure_prefix.strip("/")
self.bin_folder = self.install_folder / "bin"
def validate_setup(self):
"""Descendants can double-check that setup is correct here, in order to fail early if/when applicable"""
def selected_modules(self):
desired = self.setup.desired_modules or PPG.config.get_value("%s-modules" % self.m_name)
return ModuleCollection(self, desired=desired)
@property
def bin_python(self):
"""
Returns
-------
pathlib.Path | None
Path to freshly compiled bin/python, real file (not symlink)
"""
if self._bin_python is None:
self._bin_python = PPG.config.find_main_file(self.bin_folder / "python", self.version)
return self._bin_python
@property
def version(self):
return self.setup.python_spec.version
def xenv_LDFLAGS(self):
"""Python builder does not reuse the common setting"""
def run_python(self, *args):
"""Run python command, using the freshly compiled python binary"""
env = None
if PPG.target.is_linux:
env = {"LD_LIBRARY_PATH": str(self.install_folder / "lib")}
return self._do_run(self.bin_python, *args, env=env)
def _prepare(self):
# Some libs get funky permissions for some reason
super()._prepare()
self.setup.ensure_clean_folder(self.install_folder)
for lib_dir in self.deps_lib_dirs:
for path in runez.ls_dir(lib_dir):
if not path.name.endswith(".la"):
expected = 0o755 if path.is_dir() else 0o644
current = path.stat().st_mode & 0o777
if current != expected:
LOG.info("Corrected permissions for %s (was %s)", runez.short(path), oct(current))
path.chmod(expected)