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