|
1 | 1 | import os
|
2 | 2 | import platform
|
3 | 3 | import sys
|
| 4 | +from typing import cast |
4 | 5 |
|
5 | 6 | from SCons.Action import Action
|
6 | 7 | from SCons.Builder import Builder
|
@@ -337,6 +338,10 @@ def options(opts, env):
|
337 | 338 | opts.Add(BoolVariable("debug_symbols", "Build with debugging symbols", True))
|
338 | 339 | opts.Add(BoolVariable("dev_build", "Developer build with dev-only debugging code (DEV_ENABLED)", False))
|
339 | 340 | opts.Add(BoolVariable("verbose", "Enable verbose output for the compilation", False))
|
| 341 | + opts.Add( |
| 342 | + "cache_path", "Path to a directory where SCons cache files will be stored. No value disables the cache.", "" |
| 343 | + ) |
| 344 | + opts.Add("cache_limit", "Max size (in GiB) for the SCons cache. 0 means no limit.", "0") |
340 | 345 |
|
341 | 346 | # Add platform options (custom tools can override platforms)
|
342 | 347 | for pl in sorted(set(platforms + custom_platforms)):
|
@@ -390,7 +395,134 @@ def make_doc_source(target, source, env):
|
390 | 395 | g.close()
|
391 | 396 |
|
392 | 397 |
|
| 398 | +def convert_size(size_bytes: int) -> str: |
| 399 | + import math |
| 400 | + |
| 401 | + if size_bytes == 0: |
| 402 | + return "0 bytes" |
| 403 | + SIZE_NAMES = ["bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"] |
| 404 | + index = math.floor(math.log(size_bytes, 1024)) |
| 405 | + power = math.pow(1024, index) |
| 406 | + size = round(size_bytes / power, 2) |
| 407 | + return f"{size} {SIZE_NAMES[index]}" |
| 408 | + |
| 409 | + |
| 410 | +def get_size(start_path: str = ".") -> int: |
| 411 | + total_size = 0 |
| 412 | + for dirpath, _, filenames in os.walk(start_path): |
| 413 | + for file in filenames: |
| 414 | + path = os.path.join(dirpath, file) |
| 415 | + total_size += os.path.getsize(path) |
| 416 | + return total_size |
| 417 | + |
| 418 | + |
| 419 | +def clean_cache(cache_path: str, cache_limit: int, verbose: bool): |
| 420 | + from glob import glob |
| 421 | + |
| 422 | + files = glob(os.path.join(cache_path, "*", "*")) |
| 423 | + if not files: |
| 424 | + return |
| 425 | + |
| 426 | + # Remove all text files, store binary files in list of (filename, size, atime). |
| 427 | + purge = [] |
| 428 | + texts = [] |
| 429 | + stats = [] |
| 430 | + for file in files: |
| 431 | + try: |
| 432 | + # Save file stats to rewrite after modifying. |
| 433 | + tmp_stat = os.stat(file) |
| 434 | + # Failing a utf-8 decode is the easiest way to determine if a file is binary. |
| 435 | + try: |
| 436 | + with open(file, encoding="utf-8") as out: |
| 437 | + out.read(1024) |
| 438 | + except UnicodeDecodeError: |
| 439 | + stats.append((file, *tmp_stat[6:8])) |
| 440 | + # Restore file stats after reading. |
| 441 | + os.utime(file, (tmp_stat[7], tmp_stat[8])) |
| 442 | + else: |
| 443 | + texts.append(file) |
| 444 | + except OSError: |
| 445 | + print(f'Failed to access cache file "{file}"; skipping.') |
| 446 | + |
| 447 | + if texts: |
| 448 | + count = len(texts) |
| 449 | + for file in texts: |
| 450 | + try: |
| 451 | + os.remove(file) |
| 452 | + except OSError: |
| 453 | + print(f'Failed to remove cache file "{file}"; skipping.') |
| 454 | + count -= 1 |
| 455 | + if verbose: |
| 456 | + print("Purging %d text %s from cache..." % (count, "files" if count > 1 else "file")) |
| 457 | + |
| 458 | + if cache_limit: |
| 459 | + # Sort by most recent access (most sensible to keep) first. Search for the first entry where |
| 460 | + # the cache limit is reached. |
| 461 | + stats.sort(key=lambda x: x[2], reverse=True) |
| 462 | + sum = 0 |
| 463 | + for index, stat in enumerate(stats): |
| 464 | + sum += stat[1] |
| 465 | + if sum > cache_limit: |
| 466 | + purge.extend([x[0] for x in stats[index:]]) |
| 467 | + break |
| 468 | + |
| 469 | + if purge: |
| 470 | + count = len(purge) |
| 471 | + for file in purge: |
| 472 | + try: |
| 473 | + os.remove(file) |
| 474 | + except OSError: |
| 475 | + print(f'Failed to remove cache file "{file}"; skipping.') |
| 476 | + count -= 1 |
| 477 | + if verbose: |
| 478 | + print("Purging %d %s from cache..." % (count, "files" if count > 1 else "file")) |
| 479 | + |
| 480 | + |
| 481 | +def prepare_cache(env) -> None: |
| 482 | + import atexit |
| 483 | + |
| 484 | + if env.GetOption("clean"): |
| 485 | + return |
| 486 | + |
| 487 | + cache_path = "" |
| 488 | + if env["cache_path"]: |
| 489 | + cache_path = cast(str, env["cache_path"]) |
| 490 | + elif os.environ.get("SCONS_CACHE"): |
| 491 | + print("Environment variable `SCONS_CACHE` is deprecated; use `cache_path` argument instead.") |
| 492 | + cache_path = cast(str, os.environ.get("SCONS_CACHE")) |
| 493 | + |
| 494 | + if not cache_path: |
| 495 | + return |
| 496 | + |
| 497 | + env.CacheDir(cache_path) |
| 498 | + print(f'SCons cache enabled... (path: "{cache_path}")') |
| 499 | + |
| 500 | + if env["cache_limit"]: |
| 501 | + cache_limit = float(env["cache_limit"]) |
| 502 | + elif os.environ.get("SCONS_CACHE_LIMIT"): |
| 503 | + print("Environment variable `SCONS_CACHE_LIMIT` is deprecated; use `cache_limit` argument instead.") |
| 504 | + cache_limit = float(os.getenv("SCONS_CACHE_LIMIT", "0")) / 1024 # Old method used MiB, convert to GiB |
| 505 | + |
| 506 | + # Convert GiB to bytes; treat negative numbers as 0 (unlimited). |
| 507 | + cache_limit = max(0, int(cache_limit * 1024 * 1024 * 1024)) |
| 508 | + if env["verbose"]: |
| 509 | + print( |
| 510 | + "Current cache limit is {} (used: {})".format( |
| 511 | + convert_size(cache_limit) if cache_limit else "∞", |
| 512 | + convert_size(get_size(cache_path)), |
| 513 | + ) |
| 514 | + ) |
| 515 | + |
| 516 | + atexit.register(clean_cache, cache_path, cache_limit, env["verbose"]) |
| 517 | + |
| 518 | + |
393 | 519 | def generate(env):
|
| 520 | + # Setup caching logic early to catch everything. |
| 521 | + prepare_cache(env) |
| 522 | + |
| 523 | + # Renamed to `content-timestamp` in SCons >= 4.2, keeping MD5 for compat. |
| 524 | + env.Decider("MD5-timestamp") |
| 525 | + |
394 | 526 | # Default num_jobs to local cpu count if not user specified.
|
395 | 527 | # SCons has a peculiarity where user-specified options won't be overridden
|
396 | 528 | # by SetOption, so we can rely on this to know if we should use our default.
|
|
0 commit comments