Package: sane-build
Sanesaneis a command runner made simple.Whatsaneis:AsinglePythonfile, providingA decorator (@recipe), and a function (sane_run)sanedoesnot:Have its own domain specific language,Have an install process,Require anything other thanpython3,Restrict your Python code.WhyMore portableAt ~600 lines of code in a single file,saneis extremely portable, being made to be distributed alongside your code base. Being pure Python makes it cross-platform and with an extremely low adoption barrier.sanedoes not parse Python, or do otherwise "meta" operations, improving its future-proofness.saneaims to do only as much as reasonably documentable in a single README, and aims to have the minimum amount of gotchas, while preserving maximum flexibility.More readableIts simple syntax and operation make it easy to understand and modify your recipe files. Everything is just Python, meaning neither you nor your users have to learn yet another domain specific language.More flexibleYou are free to keep state as you see fit, and all correct Python is valid.sanecan function as a build system or as a command runner.ExampleBelow is a sane recipes file to compile a C executable (Makefilestyle).\"\"\"make.pyExistsintherootofaCprojectfolder,withthefollowingstructure<root>└make.py└sane.py│└src└*.c(sourcefiles)The`build`recipewillbuildanexecutableattheroot.Theexecutablecanbelaunchedwith`pythonmake.py`.\"\"\"importosfromsubprocessimportrunfromglobimportglobfromsaneimport*fromsaneimport_HelpasHelpCC="gcc"EXE="main"SRC_DIR="src"OBJ_DIR="obj"COMPILE_FLAGS='-g -O2'# Ensure source and objects directories existos.makedirs(SRC_DIR,exist_ok=True)os.makedirs(OBJ_DIR,exist_ok=True)sources=glob(f'{SRC_DIR}/*.c')# Define a compile recipe for each source file in SRC_DIRdefmake_recipe(source_file):basename=os.path.basename(source_file)obj_file=f'{OBJ_DIR}/{basename}.o'objects_older_than_source=(Help.file_condition(sources=[source_file],targets=[obj_file]))@recipe(name=source_file,conditions=[objects_older_than_source],hooks=['compile'],info=f'Compiles the file\'{source_file}\'')defcompile():run(f'{CC}{COMPILE_FLAGS}-c{source_file}-o{obj_file}',shell=True)forsource_fileinsources:make_recipe(source_file)# Why not define the recipe here directly? Because Python loops don't create# new scope. See stackoverflow:2295290 for more information.# Define a linking recipe@recipe(hook_deps=['compile'],info='Links the executable.')deflink():obj_files=glob(f'{OBJ_DIR}/*.o')run(f'{CC}{" ".join(obj_files)}-o{EXE}',shell=True)# Define a run recipe# Always run the executable!@recipe(recipe_deps=[link],conditions=[lambda:True],info='Runs the compiled executable.')defrun_exe():run(f'./{EXE}',shell=True)sane_run(run_exe)The Flow of Recipessaneusesrecipes,conditionsandhooks.Recipe:A python function, with dependencies (on either/both other recipes and hooks), hooks, and conditions.Conditions:Argument-less functions returning True or False.Hook:A non-unique indentifier for a recipe. When a recipe depends on a hook, it depends on every recipe tagged with that hook.The dependency tree of a given recipe is built and ran withsane_run(recipe).
This is done according to a simple recursive algorithm:Starting with the root recipe,If the current recipe has no conditions or dependencies, register it as activeOtherwise, if any of the conditions is satisfied or dependency recipes is active, register it as active.Sort the active recipes in descending depth and order of enumeration,Run the recipes in order.In concrete terms, this means that ifRecipeAdepends onBBhas some conditions and depends onCChas some conditionsthenIf any ofB's conditions is satisfied, but none ofC's are,Bis called and thenAis calledIf any ofC's conditions is satisfied,C,B,Aare called in that orderOtherwise, nothing is ran.The@recipedecoratorRecipes are defined by decorating an argument-less function with@recipe:@recipe(name='...',hooks=['...'],recipe_deps=['...'],hook_deps=['...'],conditions=[...],info='...')defmy_recipe():# ...passname:The name ('str') of the recipe. If unspecified orNone, it is inferred from the__name__attribute of the recipe function. However, recipe names must be unique, so dynamically created recipes (from, e.g., within a loop) typically require this argument.hooks:listofstrings defining hooks for this recipe.recipe_deps:listofstring names that this recipe depends on. If an element of the list is not a string, anameis inferred from the__name__attribute, but this may cause an error if it does not match the givenname.hook_deps:listofstring hooks that this recipe depends on. This means that the recipe implicitly depends on any recipe tagged with one of these hooks.conditions:listof callables with signature() -> bool. If any of these isTrue, the recipe is considered active (seeThe Flow of Recipesfor more information).info:a descriptionstring to display when recipes are listed with--list.sane_runsane_run(default=None,cli=True)This function should be called at the end of a recipes file, which will
trigger command-line arguments parsing, and run either the command-line
provided recipe, or, if none is specified, the defineddefaultrecipe.
(If neither are defined, an error is reported, and the program exits.)(There are exceptions to this:--help,--listand similars will simply output the request information and exit.)By default,sane_runruns in "CLI mode" (cli=True).
However,sane_runcan also be called in "programmatic mode" (cli=False).
In this mode, command-line arguments will be ignored, and thedefaultrecipe will be ran (observing dependencies, like in CLI mode).
This is useful if you wish to programmatically call upon a recipe (and its
subtree).To see the available options and syntax when calling a recipes file (e.g.,make.py), callpythonmake.py--helpInstallationIt is recommended to just include sane.py in the same directory as your project.You can do this easily withcurlcurl'https://raw.githubusercontent.com/mikeevmm/sane/master/sane.py'>sane.pyHowever, because it's convenient,saneis also available to install from PyPi withpipinstallsane-buildMiscelaneous_Helpsaneprovides a few helper functions that are not included by default. These are contained in aHelpclass and can be imported withfromsaneimport_HelpasHelpHelp.file_conditionHelp.file_condition(sources=['...'],targets=['...'])Returns a callable that isTrueif the newest file insourcesis older than the oldest files intargets, or if any of the files intargetsdoes not exist.sources:listofstring path to files.targets:listofstring path to files.LoggingThesanelogging functions are exposed inHelpaslog,warn,error. These take a single string as a message, and theerrorfunction terminates the program withexit(1).ConcurrencyRecipes at the same depth are ran concurrently with aThreadPoolExecutor. You can specify the number of threads to use with--threads(as of version 7.0). By default a single thread is used.Callingpython ...is GruesomeI suggest defining the following aliasaliassane='python3 make.py'LicenseThis tool is licensed under an MIT license.
See LICENSE for details.
The LICENSE is included at the top ofsane.py, so you may redistribute this file alone freely.Support💕 If you liked sane, considerbuying me a coffee.