initial commit

This commit is contained in:
Miguel M 2023-04-20 17:03:54 +01:00
commit bc3125dcc8
5 changed files with 2957 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
**/__pycache__
obj/
**/*.exe

1895
contrib/stb/stb_ds.h Normal file

File diff suppressed because it is too large Load Diff

82
make.py Normal file
View File

@ -0,0 +1,82 @@
import os
import shutil
import subprocess as sp
from sane import _Help as Sane
from glob import glob
from sane import *
COMPILER = 'gcc'
COMPILE_FLAGS = ['-g',
'-Wall',
'--std=c99',
'-fopenmp']
LINK_FLAGS = []
OBJ_DIR = 'obj'
SRC_DIR = 'src'
EXE_NAME = 'main.exe'
ROOT = os.path.dirname(os.path.realpath(__file__))
if 'RELEASE' in os.environ:
COMPILE_FLAGS.append('-O2')
def as_object(source_path):
"""Takes a source path and returns the path to the corresponding compiled object."""
source_path = os.path.relpath(source_path, ROOT)
object_name = source_path + '.obj'
object_path = os.path.join(OBJ_DIR, object_name)
return object_path
def make_object_recipe(source_path):
object_path = os.path.realpath(as_object(source_path))
object_dir = os.path.dirname(object_path)
if not os.path.exists(object_dir):
os.makedirs(object_dir)
condition = Sane.file_condition(
sources=[source_path],
targets=[object_path])
@recipe(name=f'compile_{source_path}',
conditions=[condition],
hooks=['obj_compile'])
def compile_obj():
sp.run([COMPILER, *COMPILE_FLAGS,
'-c', source_path,
'-o', object_path],
shell=True)
source_files = glob('**/*.c', recursive=True)
for source_file in source_files:
make_object_recipe(source_file)
def exe_not_exists():
return not os.path.exists(EXE_NAME)
@recipe(hook_deps=['obj_compile'],
info='Compile the source files.')
def compile():
pass
@recipe(recipe_deps=[compile],
conditions=[exe_not_exists],
info='Links the main executable.')
def link():
obj_files = glob(os.path.join(OBJ_DIR, '**', '*.obj').replace('\\', '/'),
recursive=True)
sp.run([COMPILER, *obj_files, *LINK_FLAGS, '-o', EXE_NAME],
shell=True)
@recipe(info='Removes all compiled objects.')
def clean():
shutil.rmtree(OBJ_DIR)
@recipe(recipe_deps=[link],
conditions=[lambda: True],
info='Runs the main executable.')
def run():
sp.run(EXE_NAME)
sane_run(run)

966
sane.py Normal file
View File

@ -0,0 +1,966 @@
"""Sane, Makefile for humans.
Copyright 2021 Miguel Murºa
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
"""
import os
import argparse
import inspect
import bisect
import difflib
import itertools
import concurrent.futures as cf
class _Sane:
# Simplified versioning, MAJOR.MINOR.
# Breaking changes change MAJOR version, backwards compatible changes
# change MINOR version.
VERSION = '7.3'
### State ###
def __init__(self):
self.verbose = 0
self.use_ansi = True
self.list_all = False
self.graph = {}
self.recipes = []
self.hooks = []
self.recipe_calls = []
self.force = False
self.threads = 1
#### Reporting ####
class AnsiColor:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKCYAN = '\033[96m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
ENDBOLD = '\033[22m'
UNDERLINE = '\033[4m'
class VerboseLevel:
SILENT = 0
VERBOSE = 1
DEBUG = 2
INDENT_LEADER = ' '
LOG_PLATE = '[LOG] '
WARN_PLATE = '[WARN] '
ERROR_PLATE = '[ERROR] '
@staticmethod
def indent(message, leader):
if type(leader) is int:
leader = _Sane.INDENT_LEADER * leader
return '\n'.join(leader + line for line in message.split('\n'))
@staticmethod
def trace(frame):
line_call_str = f'{frame.filename}: {frame.lineno}'
line_indicator = ('> ' if i == frame.index else '| '
for i in range(len(frame.code_context)))
line_ctx_str = ''.join(f'{indicator}{line}'
for line, indicator in
zip(frame.code_context, line_indicator))
return (f'{line_call_str}\n'
f'{line_ctx_str}')
def bold(self, message):
if self.use_ansi:
return f'{_Sane.AnsiColor.BOLD}{message}{_Sane.AnsiColor.ENDBOLD}'
return message
def log(self, message, min_level=VerboseLevel.SILENT):
if self.verbose < min_level:
return
message = _Sane.indent(message, ' ' * len(_Sane.LOG_PLATE))
# Suppress the plate spacing of the first line
message = message[len(_Sane.LOG_PLATE):]
if self.use_ansi:
print(f'{_Sane.AnsiColor.OKBLUE}{_Sane.LOG_PLATE}'
f'{message}{_Sane.AnsiColor.ENDC}')
else:
print(f'{_Sane.LOG_PLATE}{message}')
def warn(self, message, min_level=VerboseLevel.SILENT):
if self.verbose < min_level:
return
message = _Sane.indent(message, ' ' * len(_Sane.WARN_PLATE))
# Suppress the plate spacing of the first line
message = message[len(_Sane.WARN_PLATE):]
if self.use_ansi:
print(f'{_Sane.AnsiColor.WARNING}{_Sane.WARN_PLATE}'
f'{message}{_Sane.AnsiColor.ENDC}')
else:
print(f'{_Sane.WARN_PLATE}{message}')
def error(self, message):
message = _Sane.indent(message, ' ' * len(_Sane.ERROR_PLATE))
# Suppress the plate spacing of the first line
message = message[len(_Sane.ERROR_PLATE):]
if self.use_ansi:
print(f'{_Sane.AnsiColor.FAIL}{_Sane.ERROR_PLATE}'
f'{message}{_Sane.AnsiColor.ENDC}')
else:
print(f'{_Sane.ERROR_PLATE}{message}')
exit(1)
def set_verbose(self, verbose_level):
self.log(f'Verbose level set to {verbose_level}',
_Sane.VerboseLevel.DEBUG)
self.verbose = verbose_level
def set_ansi(self, use_ansi):
self.log(f'Using ANSI colors: {use_ansi}',
_Sane.VerboseLevel.DEBUG)
self.use_ansi = use_ansi
def set_list_all(self, list_all):
self.log(f'Listing all recipes now set to {list_all}',
_Sane.VerboseLevel.DEBUG)
self.list_all = list_all
### Dependency Graph ###
class Node:
HOOK = 0
RECIPE = 1
def __init__(self, type_, connections=[], meta={}):
self.type = type_
self.connections = connections
self.meta = meta
def is_always_active(self):
if self.type == _Sane.Node.HOOK:
# A hook is only active if any of its children are active
return False
elif self.type == _Sane.Node.RECIPE:
conds = self.meta['conditions']
return (((len(self.connections) == 0) and len(conds) == 0) or
any(x() for x in conds))
else:
raise ValueError(f'Unknown node type \'{self.type}\'.')
@staticmethod
def get_unique_name(name, type_):
if type_ == _Sane.Node.HOOK:
return f'hook-{name}'
elif type_ == _Sane.Node.RECIPE:
return f'recipe-{name}'
else:
raise ValueError(f'Unimplemented type \'{type_}\'')
@staticmethod
def unique_name_is_recipe(unique_name):
return unique_name.startswith('recipe-')
@staticmethod
def split_unique_name(unique_name):
if unique_name.startswith('hook-'):
return _Sane.Node.HOOK, unique_name[len('hook-'):]
elif unique_name.startswith('recipe-'):
return _Sane.Node.RECIPE, unique_name[len('recipe-'):]
else:
raise ValueError(f'Unknown type of unique name \'{unique_name}\'')
@staticmethod
def human_format_unique_name(unique_name):
type_, name = _Sane.split_unique_name(unique_name)
if type_ == _Sane.Node.RECIPE:
return f'\'{name}\' (Recipe)'
elif type_ == _Sane.Node.HOOK:
return f'\'{name}\' (Hook)'
else:
raise ValueError(f'Unknown type \'{type_}\'.')
def recipe_exists(self, recipe):
i = bisect.bisect_left(self.recipes, recipe)
return (i < len(self.recipes) and self.recipes[i] == recipe)
def hook_exists(self, hook):
i = bisect.bisect_left(self.hooks, hook)
return (i < len(self.hooks) and self.hooks[i] == hook)
def list_recipes(self):
for recipe_name in self.recipes:
recipe_unique_name = _Sane.get_unique_name(
recipe_name, _Sane.Node.RECIPE)
recipe_node = self.graph[recipe_unique_name]
recipe_info = recipe_node.meta.get('info', None)
if not self.list_all and recipe_info is None:
continue # Skip recipes with no info (unless --list-all)
if self.use_ansi:
print(self.bold(recipe_name))
indent = 1
else:
print(f' -- {recipe_name}')
indent = 3
if recipe_info is not None:
print(_Sane.indent(recipe_info, indent))
else:
print(_Sane.indent('[no information given]', indent))
def report_unknown(self, from_, unknown_recipe, traceback):
error_message = (f'Recipe \'{from_}\' depends on an undefined '
f'recipe \'{unknown_recipe}\':\n'
f'{traceback}')
# Fuzz the unknown recipe. If there's another recipe name
# that matches more than 80%, suggest that.
closest_i = (
bisect.bisect_left(self.recipes, unknown_recipe) - 1)
if closest_i >= 0:
closest = self.recipes[closest_i]
diff = difflib.ndiff(closest, unknown_recipe)
common = 0
total = 0
for change in diff:
if change[0] == ' ':
common += 1
total += 1
if common/total >= 0.80:
error_message += f'\nDid you mean \'{closest}\'?'
self.error(error_message)
def report_cyclic(self, from_, traceback):
self.error(f'Recipe \'{from_}\' has cyclic dependencies:\n'
f'{traceback}')
### Registration ###
def register_recipe(self, fn, name, hooks, recipe_deps,
hook_deps, conditions, info):
unique_name = _Sane.get_unique_name(name, _Sane.Node.RECIPE)
# If we do not register the hooks in hook_deps, it may happen that
# no recipe is ever registered with those hooks, leading to an
# "unknown dependency" error. This is counter intuitive (if we depend
# on a hook and no recipes are defined with that hook, we expect the
# dependency not to have any effect).
iterator = itertools.chain(
((True, x) for x in hooks),
((False, x) for x in hook_deps))
for connect, hook in iterator:
hook_node = (
self.graph.setdefault(
_Sane.get_unique_name(hook, _Sane.Node.HOOK),
_Sane.Node(_Sane.Node.HOOK, connections=[])))
if connect:
hook_node.connections.append(unique_name)
i = bisect.bisect_left(self.hooks, hook)
if not (i < len(self.hooks) and self.hooks[i] == hook):
self.hooks.insert(i, hook)
connections = (
[_Sane.get_unique_name(recipe, _Sane.Node.RECIPE)
for recipe in recipe_deps] +
[_Sane.get_unique_name(hook, _Sane.Node.HOOK)
for hook in hook_deps])
self.graph[unique_name] = (
_Sane.Node(
_Sane.Node.RECIPE,
connections,
{
'fn': fn,
'conditions': conditions, # unique names
'info': info # string
}))
# Recipes are inserted sorted by name, so as to be able to
# perform fuzzing later.
i = bisect.bisect_left(self.recipes, name)
if not (i < len(self.recipes) and self.recipes[i] == name):
self.recipes.insert(i, name)
def register_decorator_call(self, frame, *args, **kwargs):
# @recipe parsing is done after parsing of command line arguments.
# During the @recipe calls a (args, kwargs, frame) object is stored.
self.recipe_calls.append((args, kwargs, frame))
def parse_decorator_calls(self):
self.recipe_calls.reverse()
while len(self.recipe_calls) > 0:
args, kwargs, frame = self.recipe_calls.pop()
# "Unwrap" expected kwargs
name = kwargs.pop('name', None)
hooks = kwargs.pop('hooks', None)
recipe_deps = kwargs.pop('recipe_deps', None)
hook_deps = kwargs.pop('hook_deps', None)
conditions = kwargs.pop('conditions', None)
info = kwargs.pop('info', None)
fn = kwargs.pop('fn', None)
# Often, the user will wrongly decorate the function with `@recipe`,
# instead of `@recipe()`, if they intend all the arguments to take
# their default values. It would be possible to "switch modes"
# depending on whether other arguments are specified, but for the
# sake of being less error prone, an error is reported (with the
# above suggestion).
if len(args) > 0:
quoted_args = (f'\'{arg}\'' for arg in args)
error_msg = ('Got unexpected argument in recipe decorator at\n'
f'{_Sane.trace(frame)}\n'
'Unrecognized arguments are: '
f'{", ".join(quoted_args)}.')
if len(args) == 1 and hasattr(args[0], '__call__'):
error_msg += '\nAre you missing a `()` after `@recipe`?'
self.error(error_msg)
# Deprecated and unrecognized keyword arguments
# - file_deps and target_files
file_deps_present = ('file_deps' in kwargs)
target_files_present = ('target_files' in kwargs)
if file_deps_present or target_files_present:
sample_code = (
self.bold(
'from sane import _Help as Help\n'
'conditions=[ Help.file_condition('
'sources=[...], targets=[...]) ]'))
self.warn(f'In recipe \'{name}\':\n'
'`file_deps` and `target_files` are deprecated '
'arguments. \n'
f'Use\n'
f'{_Sane.indent(sample_code, " ")}\n'
'instead.\n'
'This condition has been automatically inserted, '
'but may be ignored or fail in the future.')
conditions.append(
_Help.file_condition(
sources=kwargs.pop('file_deps', []),
targets=kwargs.pop('target_files', [])))
# - Unknown keyword arguments
if len(kwargs) > 0:
self.error('Got unexpected keyword arguments in recipe '
'decorator at\n'
f'{_Sane.trace(frame)}\n'
'Unrecognized keyword arguments are: '
f'{", ".join(kwargs)}.')
# Check if the decorator is wrapping a callable object
if not hasattr(fn, '__call__'):
self.error(f'Cannot decorate non-callable object \'{fn}\' '
'as recipe.\n'
f'At {_Sane.trace(frame)}')
# Check if name inference is needed
if name is None:
name_inferred = True
name = fn.__name__
self.log(
f'Inferred name \'{name}\' for function \'{fn}\'.',
_Sane.VerboseLevel.DEBUG)
else:
name_inferred = False
# Check if recipe name is a duplicate
if self.recipe_exists(name):
error_msg = f'Duplicate recipe of name \'{name}\'.'
if name_inferred:
error_msg += (
'The name was inferred from the function name;\n'
'consider giving the recipe a unique name, with\n')
error_msg += _Sane.indent('@recipe(name=\'...\', ...)', 3)
self.error(error_msg)
# Type checking
if type(name) is not str:
self.error(f'`name` for recipe \'{name}\' is not string.\n'
f'At {_Sane.trace(frame)}')
if type(hooks) not in (list, tuple):
self.error(
f'`hooks` for recipe \'{name}\' is not list or tuple.\n'
f'At {_Sane.trace(frame)}')
if type(recipe_deps) not in (list, tuple):
self.error(
f'`recipe_deps` for recipe \'{name}\' is not list or '
'tuple.\n'
f'At {_Sane.trace(frame)}')
if type(hook_deps) not in (list, tuple):
self.error(
f'`hook_deps` for recipe \'{name}\' is not list or '
'tuple.\n'
f'At {_Sane.trace(frame)}')
if type(conditions) not in (list, tuple):
self.error(
f'`conditions` for recipe \'{name}\' is not list or '
'tuple.\n'
f'At {_Sane.trace(frame)}')
if info is not None and type(info) is not str:
self.error(f'`info` for recipe \'{name}\' is not string.\n'
f'At {_Sane.trace(frame)}')
# Recipe dependency sanitizing
# - Recipe dependency
for i, recipe_dep in enumerate(recipe_deps):
if type(recipe_dep) is not str:
if not hasattr(recipe_dep, '__name__'):
self.error(
f'Invalid object \'{recipe_dep}\' under recipe '
f'dependencies of recipe named \'{name}\'.\n'
f'At {_Sane.trace(frame)}')
recipe_deps[i] = recipe_dep.__name__
self.log(
f'Recipe dependencies of recipe \'{name}\':\n'
f'{chr(10).join(recipe_deps)}',
_Sane.VerboseLevel.DEBUG)
# - Hook dependency
for hook in hook_deps:
if type(hook) is not str:
self.error(f'Hooks must be strings, but got \'{hook}\' '
f'as a hook dependency of recipe \'{name}\'.\n'
f'At {_Sane.trace(frame)}')
self.log(
f'Hook deps of recipe \'{name}\':\n{chr(10).join(hook_deps)}',
_Sane.VerboseLevel.DEBUG)
# - Condition dependency
for condition in conditions:
if not hasattr(condition, '__call__'):
self.error(
'Conditions must be callables, but got '
f'\'{condition}\' as a condition of recipe '
f'\'{name}\'.\n'
f'At {_Sane.trace(frame)}')
cond_signature = inspect.signature(condition)
if any(arg.default == inspect.Parameter.empty
for arg in cond_signature.parameters):
self.error(
f'Condition \'{condition}\' of recipe \'{name}\' '
'takes required parameters. This is not allowed; '
'conditions are called without arguments.')
self.log(
f'Conditions of recipe \'{name}\':\n'
f'{chr(10).join(str(x) for x in conditions)}',
_Sane.VerboseLevel.DEBUG)
# - Hooks
for hook in hooks:
if type(hook) is not str:
self.error(
f'Hooks must be strings, but got hook \'{hook}\' '
'as a hook of recipe \'{name}\'.\n'
f'At {_Sane.trace(frame)}')
self.log(
f'Hooks of recipe \'{name}\':\n'
f'{chr(10).join(str(x) for x in hooks)}',
_Sane.VerboseLevel.DEBUG)
# Register recipe
self.log(
f'Registring recipe \'{name}\' (-> \'{fn}\').',
_Sane.VerboseLevel.DEBUG)
self.register_recipe(fn, name, hooks, recipe_deps, hook_deps,
conditions, info)
del frame
### Execution ###
def set_force(self, force):
self.log(f'Forcing runs: {force}',
_Sane.VerboseLevel.DEBUG)
self.force = force
def set_threads(self, threads):
self.log(f'Using {threads} threads.',
_Sane.VerboseLevel.DEBUG)
self.threads = threads
def run_recipe(self, fn, name):
self.log(f'Running recipe \'{name}\'.', _Sane.VerboseLevel.VERBOSE)
fn()
def run_recipe_graph(self, recipe_name):
"""
This is perhaps the most "sensitive" part of `sane`,
so below is a more comprehensive description of what
the taken approach is.
This should also allow to disambiguate the behaviour
of `sane` for less clear dependency situations.
First of all, these are the assumptions (enforced by
the program either before this function was called or
upon visiting a node) about the graph of dependencies:
- Each node uniquely corresponds to either a recipe
(function) or a hook.
- The connections are directed (so it's a directed graph),
from the recipes to their dependencies.
- The graph is acyclic.
At a high level, the algorithm consists of a depth-first
search of the tree of dependencies (considering that hook
dependencies are expanded to the recipes with that hook),
recursively marking a recipe as to be ran if any node in
its subtree corresponds to an active recipe.
```python
def check_node(node):
active = (any(condition() for condition in conditions)
or any(check_node(child) for child in connections))
if is_recipe(node) and active:
register_node_as_active
register_node_as_visited
return active
```
The active recipes are then sorted by decreasing order in
their depth in the tree, and ran in sorting order.
Sorting of recipes at same depth is disambiguated by order
of iteration in exploration of the tree (recipes found
"earlier" are ran first).
Each recipe is ran iff:
- any of its conditions are satisfied
- it depends on a ran recipe
- it has no dependencies (recipes, hooks, or conditions)
The recursive approach is "iterative"-ized by manually managing
lists as stacks. The reason for this is two fold: to have more
control over the amount of objects kept in memory in deep recursion,
and to avoid hitting Python's limit recursion depth in more extreme
cases.
"""
root_name = recipe_name
root_unique_name = (
_Sane.get_unique_name(root_name, _Sane.Node.RECIPE))
root_node = self.graph[root_unique_name]
active = []
active_ordering = []
visited = []
backtrack_names = [root_unique_name]
backtrack_child_idx = [0]
backtrack_active = [root_node.is_always_active() or self.force, False]
depth = 0
ord_ = 0
while len(backtrack_names) > 0:
unique_name = backtrack_names[-1]
child_idx = backtrack_child_idx.pop()
child_active = backtrack_active.pop()
# Update self active state based on child
backtrack_active[-1] |= child_active
node = self.graph[unique_name]
# Return
if child_idx >= len(node.connections):
unique_name = backtrack_names.pop()
type_, name = _Sane.split_unique_name(unique_name)
# Mark as visited
element = (depth, unique_name)
i = bisect.bisect_left(visited, element)
visited.insert(i, element)
# Register as active if appropriate
self_active = backtrack_active[-1]
if self_active:
i = bisect.bisect_left(active, element)
active.insert(i, element)
active_ordering.insert(i, (element[0], ord_))
# Ord is decreased so that sort+reverse results in
# first found first ran under same depth
ord_ -= 1
else:
if type_ == _Sane.Node.RECIPE:
self.log(f'Skipping recipe \'{name}\'.',
_Sane.VerboseLevel.VERBOSE)
# Do not reduce depth for hooks and others
if type_ == _Sane.Node.RECIPE:
depth -= 1
continue
child_unique_name = node.connections[child_idx]
child_type, child_name = _Sane.split_unique_name(child_unique_name)
if child_type == _Sane.Node.RECIPE:
child_exists = self.recipe_exists(child_name)
elif child_type == _Sane.Node.HOOK:
child_exists = self.hook_exists(child_name)
else:
raise ValueError(f'Unknown child type \'{child_type}\'.')
# Catch unknown recipe
if not child_exists:
backtrack_names.append(child_unique_name)
traceback = ' > '.join(
_Sane.human_format_unique_name(x) for x in backtrack_names)
self.report_unknown(root_name, child_name, traceback)
# Test if cyclic dependency
# (We can't bisect here, because there's no sorted order.)
cyclic = (child_unique_name in backtrack_names)
if cyclic:
backtrack_names.append(child_unique_name)
traceback = ' > '.join(_Sane.human_format_unique_name(x)
for x in backtrack_names)
self.report_cyclic(root_name, traceback)
# When we return to this point in the stack, explore the next child
backtrack_child_idx.append(child_idx + 1)
# Check if child has been visited before (at specified depth)
child_depth = (depth + 1 if child_type ==
_Sane.Node.RECIPE else depth)
element = (child_depth, child_unique_name)
i = bisect.bisect_left(visited, element)
child_visited = (i < len(visited) and visited[i] == element)
if child_visited:
# The child subtree has been explored before
# (presumably as a subtree of a different parent).
i = bisect.bisect_left(active, element)
child_active = (i < len(active) and active[i] == element)
backtrack_active.append(child_active)
else:
# Explore the child's subtree.
depth = child_depth
backtrack_child_idx.append(0)
backtrack_names.append(child_unique_name)
child_node = self.graph[child_unique_name]
child_active = (child_node.is_always_active() or self.force)
backtrack_active.extend((child_active, False))
if len(active_ordering) > 0:
# Sort the active recipes by depth, and then by order in which they
# were encountered.
sort_key = sorted(range(len(active_ordering)),
key=active_ordering.__getitem__)
active_in_order = map(lambda i: active[i], reversed(sort_key))
active_in_order = filter(
lambda x: _Sane.unique_name_is_recipe(x[1]),
active_in_order)
depth, recipes = zip(*active_in_order)
recipes = list(recipes)
idx_groups = itertools.groupby(range(len(depth)),
key=depth.__getitem__)
self.log('Finished building and sorting dependency tree of '
f'\'{root_name}\'.\n'
'Launching recipes.',
_Sane.VerboseLevel.DEBUG)
with cf.ThreadPoolExecutor(max_workers=self.threads) as pool:
for _, idx_group in idx_groups:
group_recipes = map(recipes.__getitem__, idx_group)
group_recipes = map(lambda name: (
self.graph[name].meta['fn'],
_Sane.split_unique_name(name)[1]),
group_recipes)
futures = [pool.submit(self.run_recipe, *args)
for args in group_recipes]
for result in cf.as_completed(futures):
result.result() # Block on this depth
else:
self.log(
f'Nothing to do for \'{root_name}\'',
_Sane.VerboseLevel.DEBUG)
_stateful = _Sane()
### Optionally Exposed Tools ###
class _Help:
# For consistency, thse logging functions are never used inside this file,
# and logging should be done via `_stateful`.
@staticmethod
def log(message): return _stateful.log(message)
@staticmethod
def warn(message): return _stateful.warn(message)
@staticmethod
def error(message): return _stateful.error(message)
@staticmethod
def file_condition(sources, targets):
"""Build system-like condition.
Returns a callable that is `True` if the newest file in `sources` is
older than the oldest files in `targets`, or if any of the files in
`targets` does not exist.
Arguments:
sources: `list` of `str`ing path to files.
targets: `list` of `str`ing path to files.
Returns:
Function that takes no arguments and returns a `bool`ean.
"""
frame = inspect.stack(context=3)[-1]
if type(sources) not in (tuple, list):
_stateful.error('`sources` is expected to be tuple or list, '
f'got {type(sources)} instead.\n'
f'At {_Sane.trace(frame)}')
if type(targets) not in (tuple, list):
_stateful.error('`targets` is expected to be tuple or list, '
f'got {type(targets)} instead.\n'
f'At {_Sane.trace(frame)}')
if len(sources) == 0:
_stateful.error('File condition without `sources` is ambiguous.\n'
'Consider writing an explicit condition.\n'
f'At {_Sane.trace(frame)}')
if len(targets) == 0:
_stateful.error('File condition without `targets` is ambiguous.\n'
'Consider writing an explicit condition.\n'
f'At {_Sane.trace(frame)}')
del frame
sources = [
os.path.abspath(os.path.expanduser(path)) for path in sources]
targets = [
os.path.abspath(os.path.expanduser(path)) for path in targets]
def condition():
# If the oldest file in `targets` is older than the newest file
# in `sources`, then the recipe should run.
oldest = None
for target_file in targets:
# If a target file does not exist, assume it should be created
if not os.path.exists(target_file):
return True
# Otherwise check the time
epoch = os.stat(target_file).st_mtime
if oldest is None or epoch < oldest:
oldest = epoch
newest = None
for file_dep in sources:
# Ignore dependencies that do not exist
if not os.path.exists(file_dep):
_stateful.warn(f'File dependency \'{file_dep}\' '
'does not exist and will be ignored.')
continue
epoch = os.stat(file_dep).st_mtime
if newest is None or epoch > newest:
newest = epoch
_stateful.log(
f'File condition: oldest target has time {oldest}, '
f'newest source has time {newest}.',
_Sane.VerboseLevel.DEBUG)
return (oldest < newest)
return condition
### Recipe Tag ###
def recipe(*args, name=None, hooks=[], recipe_deps=[],
hook_deps=[], conditions=[], info=None, **kwargs):
"""Decorate a function as a `sane` recipe.
Arguments:
name: The name ('str') of the recipe. If unspecified or `None`, 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: `list` of `str`ings defining hooks for this recipe.
recipe_deps: `list` of `str`ing names that this recipe depends on.
If an element of the list is not a string, a `name` is
inferred from the `__name__` attribute, but this may cause
an error if it does not match the given `name`.
hook_deps: `list` of `str`ing hooks that this recipe depends on. This
means that the recipe implicitly depends on any recipe tagged
with one of these hooks.
conditions: `list` of callables with signature `() -> boolean`. If any
of these is `True`, the recipe is considered active.
info: a description `str`ing to display when recipes are listed with
`--list`.
"""
frame = inspect.stack(context=3)[-1]
# `frame` is disposed of once used
def recipe_fn(fn):
_stateful.register_decorator_call(*args, frame=frame, name=name,
hooks=hooks, recipe_deps=recipe_deps,
hook_deps=hook_deps,
conditions=conditions,
info=info, fn=fn, **kwargs)
return fn
return recipe_fn
### Conditions ###
### Run Routine ###
def sane_run(default=None, cli=True):
"""Run the sane recipe process.
This function should be called at the end of a recipes file, which will
trigger the command-line arguments parsing, and run either the command-line
provided recipe, or, if none is specified, the defined `default` recipe.
(If neither are defined, an error is reported, and the program exits).
By default, `sane_run` runs in "CLI mode" (`cli = True`), where
command-line arguments are read to get, among other things, the recipe to
be ran. `sane_run` can also be ran in "programmatic mode" (`cli=False`).
In this mode, command-line arguments will be ignored, and the `default`
recipe will be ran (observing dependencies, like in CLI mode).
This is useful if you wish to programmatically call upon a recipe (and its
subtree).
Arguments:
default: The recipe to run by default, when no recipe is provided in
CLI mode, or simply the recipe to run in programmatic mode.
This argument can either be the `str` name of a defined recipe,
or an object whose name can be inferred (with the `__name__`
property).
cli: Whether to run in CLI mode. See the function's description for
more information.
"""
if cli:
parser = argparse.ArgumentParser(description='Make, but Sane')
parser.add_argument('--version', action='version',
version=f'Sane {_Sane.VERSION}')
parser.add_argument('--verbose', metavar='level', type=int, default=0,
help='Level of verbosity in logs. '
f'{_Sane.VerboseLevel.SILENT} is silent, '
f'{_Sane.VerboseLevel.DEBUG} is most verbose.')
parser.add_argument('recipe', nargs='?', default=None,
help='The recipe to run. If none is given, '
'the default recipe is ran.')
parser.add_argument('--force', action='store_true',
help='Always run the recipe and all of its '
'dependencies '
'(regardless of whether dependant recipes were ran '
'or conditions are True).')
parser.add_argument('--list', action='store_true', default=False,
help='List the defined recipes.')
parser.add_argument('--list-all', action='store_true', default=False,
help='List all recipes, including those with no '
'information.')
parser.add_argument('--no-ansi', action='store_true', default=False,
help='Disable ANSI color characters in logging.')
parser.add_argument('--threads', metavar='threads', type=int, default=1,
help='The number of threads to run the tasks with.')
args = parser.parse_args()
_stateful.set_verbose(args.verbose)
_stateful.set_ansi(
not (args.no_ansi or os.environ.get('NO_COLOR', False)))
_stateful.set_force(args.force)
_stateful.set_threads(args.threads)
_stateful.set_list_all(args.list_all)
_stateful.log('Parsing registered `@recipe` decorations.',
_Sane.VerboseLevel.DEBUG)
_stateful.parse_decorator_calls()
if cli:
if args.list or args.list_all:
_stateful.list_recipes()
exit(0)
recipe = args.recipe
else:
recipe = None # Run `default`
if recipe is None:
if default is None:
if cli:
_stateful.error(
'No recipe given and no default recipe, exiting.')
else:
frame = inspect.stack(context=3)[-1]
_stateful.error(
'Recipe must be provided in `default` argument when '
'not in CLI mode.\n'
f'At {_Sane.trace(frame)}')
if type(default) is not str:
if not hasattr(default, '__name__'):
if cli:
_stateful.error(
'Given default recipe not a string, and '
'the given object has no `__name__` attribute.\n'
f'Default recipe given is \'{default}\'.')
else:
frame = inspect.stack(context=3)[-1]
_stateful.error(
'Given recipe not a string, the given object has '
'no `__name__` attribute.\n'
f'Recipe given is \'{default}\'.\n'
f'At {_Sane.trace(frame)}')
if not _stateful.recipe_exists(default.__name__):
if cli:
_stateful.error(
'Given default recipe not a string, and '
f'inferred name \'{default.__name__}\' is not '
'defined as a recipe.')
else:
frame = inspect.stack(context=3)[-1]
_stateful.error(
'Given recipe not a string, and inferred name '
f'\'{default.__name__}\' is not defined as a '
'recipe.\n'
f'At {_Sane.trace(frame)}')
recipe = default.__name__
else:
if not _stateful.recipe_exists(default):
if cli:
_stateful.error(
'No recipe given, and default recipe '
f'\'{default}\' is not defined as a recipe.')
else:
frame = inspect.stack(context=3)[-1]
_stateful.error(
f'Given recipe \'{default}\' is not defined '
'as a recipe.\n'
'At {_Sane.trace(frame)}')
recipe = default
else:
# Type check `recipe`, even though it should be type checked by
# ArgumentParser, as programmatic calls can bypass this
if type(recipe) is not str:
_stateful.error(
'Not calling default recipe, and given recipe is not a '
'string.\n'
f'(Got \'{recipe}\'.)\n'
'Are you forgetting `cli=False`?')
if not _stateful.recipe_exists(recipe):
_stateful.error(
f'Given recipe \'{recipe}\' is not defined as a recipe.')
_stateful.log(
f'Launching graph of recipe \'{recipe}\'.',
_Sane.VerboseLevel.DEBUG)
_stateful.run_recipe_graph(recipe)
if __name__ == '__main__':
_stateful.log(f'Sane v{_Sane.VERSION}, by Miguel Murça.\n'
'Sane should be imported from other files, '
'not ran directly.\n'
'Refer to the [Github page] for more information.\n'
'https://github.com/mikeevmm/sane')

11
src/main.c Normal file
View File

@ -0,0 +1,11 @@
#include <stdio.h>
#include <stddef.h>
#include <inttypes.h>
#define STB_DS_IMPLEMENTATION
#include "../contrib/stb/stb_ds.h"
int main(int argc, char *argv[]) {
return 0;
}