diff --git a/cutekit/__init__.py b/cutekit/__init__.py index 4813234..c49ac6f 100644 --- a/cutekit/__init__.py +++ b/cutekit/__init__.py @@ -3,12 +3,20 @@ import os import logging from . import ( + builder, cli, + compat, const, + graph, + jexpr, + mixins, model, + ninja, plugins, + rules, + shell, + utils, vt100, - cmds, # noqa: F401 ) diff --git a/cutekit/builder.py b/cutekit/builder.py index 84ad9b3..da44520 100644 --- a/cutekit/builder.py +++ b/cutekit/builder.py @@ -1,170 +1,290 @@ import os import logging -from typing import TextIO +from pathlib import Path +from dataclasses import dataclass +from itertools import chain +from typing import Generator, TextIO, Union, cast -from . import shell, rules, model, ninja, context +from . import shell, rules, model, ninja, const, utils, cli _logger = logging.getLogger(__name__) -def gen(out: TextIO, context: context.Context): - writer = ninja.Writer(out) +def aggregateCincs(target: model.Target, registry: model.Registry) -> set[str]: + res = set() - target = context.target + for c in registry.iterEnabled(target): + if "cpp-root-include" in c.props: + res.add(c.dirname()) + elif c.type == model.Kind.LIB: + res.add(str(Path(c.dirname()).parent)) - writer.comment("File generated by the build system, do not edit") - writer.newline() - writer.variable("builddir", context.builddir()) + return set(map(lambda i: f"-I{i}", res)) - writer.separator("Tools") - writer.variable("cincs", " ".join(map(lambda i: f"-I{i}", context.cincls()))) +def aggregateCdefs(target: model.Target) -> set[str]: + res = set() - writer.variable("cdefs", " ".join(context.cdefs())) + def sanatize(s: str) -> str: + return s.lower().replace(" ", "_").replace("-", "_").replace(".", "_") - writer.newline() + for k, v in target.props.items(): + if isinstance(v, bool): + if v: + res.add(f"-D__ck_{sanatize(k)}__") + else: + res.add(f"-D__ck_{sanatize(k)}_{sanatize(str(v))}__") + res.add(f"-D__ck_{sanatize(k)}_value={str(v)}") - writer.rule("cp", "cp $in $out") - writer.newline() + return res + +def buildpath(target: model.Target, component: model.Component, path) -> Path: + return Path(target.builddir) / component.id / path + + +# --- Compilation ------------------------------------------------------------ # + + +def listSrc(component: model.Component) -> list[str]: + wildcards = set(chain(*map(lambda rule: rule.fileIn, rules.rules.values()))) + dirs = [component.dirname()] + list( + map(lambda d: os.path.join(component.dirname(), d), component.subdirs) + ) + return shell.find(dirs, list(wildcards), recusive=False) + + +def compileSrc( + w: ninja.Writer, target: model.Target, component: model.Component +) -> list[str]: + res: list[str] = [] + for src in listSrc(component): + rel = Path(src).relative_to(component.dirname()) + + r = rules.byFileIn(src) + if r is None: + raise RuntimeError(f"Unknown rule for file {src}") + + dest = buildpath(target, component, "obj") / rel.with_suffix(r.fileOut[0][1:]) + t = target.tools[r.id] + w.build(str(dest), r.id, inputs=src, order_only=t.files) + res.append(str(dest)) + return res + + +# --- Ressources ------------------------------------------------------------- # + + +def listRes(component: model.Component) -> list[str]: + return shell.find(str(component.subpath("res"))) + + +def compileRes( + w: ninja.Writer, + target: model.Target, + component: model.Component, +) -> list[str]: + res: list[str] = [] + for r in listRes(component): + rel = Path(r).relative_to(component.subpath("res")) + dest = buildpath(target, component, "res") / rel + w.build(str(dest), "cp", r) + res.append(str(dest)) + return res + + +# --- Linking ---------------------------------------------------------------- # + + +def outfile(target: model.Target, component: model.Component) -> str: + if component.type == model.Kind.LIB: + return str(buildpath(target, component, f"lib/{component.id}.a")) + else: + return str(buildpath(target, component, f"bin/{component.id}.out")) + + +def collectLibs( + registry: model.Registry, target: model.Target, component: model.Component +) -> list[str]: + res: list[str] = [] + for r in component.resolved[target.id].resolved: + req = registry.lookup(r, model.Component) + assert req is not None # model.Resolver has already checked this + + if r == component.id: + continue + if not req.type == model.Kind.LIB: + raise RuntimeError(f"Component {r} is not a library") + res.append(outfile(target, req)) + return res + + +def link( + w: ninja.Writer, + registry: model.Registry, + target: model.Target, + component: model.Component, +) -> str: + w.newline() + out = outfile(target, component) + objs: list[str] = compileSrc(w, target, component) + res = compileRes(w, target, component) + libs = collectLibs(registry, target, component) + if component.type == model.Kind.LIB: + w.build(out, "ar", objs, implicit=res) + else: + w.build(out, "ld", objs + libs, implicit=res) + return out + + +# --- Phony ------------------------------------------------------------------ # + + +def all(w: ninja.Writer, registry: model.Registry, target: model.Target) -> list[str]: + all: list[str] = [] + for c in registry.iterEnabled(target): + all.append(link(w, registry, target, c)) + w.build("all", "phony", all) + w.default("all") + return all + + +def gen(out: TextIO, target: model.Target, registry: model.Registry): + w = ninja.Writer(out) + + w.comment("File generated by the build system, do not edit") + w.newline() + + w.variable("builddir", target.builddir) + w.variable("hashid", target.hashid) + + w.separator("Tools") + + w.variable("cincs", " ".join(aggregateCincs(target, registry))) + w.variable("cdefs", " ".join(aggregateCdefs(target))) + w.newline() + + w.rule("cp", "cp $in $out") for i in target.tools: tool = target.tools[i] rule = rules.rules[i] - writer.variable(i, tool.cmd) - writer.variable(i + "flags", " ".join(rule.args + tool.args)) - writer.rule( + w.variable(i, tool.cmd) + w.variable(i + "flags", " ".join(rule.args + tool.args)) + w.rule( i, f"{tool.cmd} {rule.rule.replace('$flags',f'${i}flags')}", depfile=rule.deps, ) - writer.newline() + w.newline() - writer.separator("Components") + w.separator("Build") - all: list[str] = [] + all(w, registry, target) - for instance in context.enabledInstances(): - objects = instance.objsfiles() - assets = instance.resfiles() - writer.comment(f"Component: {instance.manifest.id}") - writer.comment(f"Resolved: {', '.join(instance.resolved)}") - for obj in objects: - r = rules.byFileIn(obj[0]) - if r is None: - raise RuntimeError(f"Unknown rule for file {obj[0]}") - t = target.tools[r.id] - writer.build(obj[1], r.id, obj[0], order_only=t.files) - - for asset in assets: - writer.build(asset[1], "cp", asset[0]) - - writer.newline() - - if instance.isLib(): - writer.build( - instance.outfile(), - "ar", - list(map(lambda o: o[1], objects)), - implicit=list(map(lambda o: o[1], assets)), - ) - else: - libraries: list[str] = [] - - for req in instance.resolved: - reqInstance = context.componentByName(req) - - if reqInstance is None: - raise RuntimeError(f"Component {req} not found") - - if not reqInstance.isLib(): - raise RuntimeError(f"Component {req} is not a library") - - libraries.append(reqInstance.outfile()) - - writer.build( - instance.outfile(), - "ld", - list(map(lambda o: o[1], objects)) + libraries, - implicit=list(map(lambda o: o[1], assets)), - ) - - all.append(instance.outfile()) - - writer.newline() - - writer.separator("Phony targets") - - writer.build("all", "phony", all) - writer.default("all") +@dataclass +class Product: + path: Path + target: model.Target + component: model.Component def build( - componentSpec: str, targetSpec: str, props: model.Props = {} -) -> context.ComponentInstance: - ctx = context.contextFor(targetSpec, props) - - shell.mkdir(ctx.builddir()) - ninjaPath = os.path.join(ctx.builddir(), "build.ninja") - + target: model.Target, + registry: model.Registry, + components: Union[list[model.Component], model.Component, None] = None, +) -> list[Product]: + all = False + shell.mkdir(target.builddir) + ninjaPath = os.path.join(target.builddir, "build.ninja") with open(ninjaPath, "w") as f: - gen(f, ctx) + gen(f, target, registry) - instance = ctx.componentByName(componentSpec) + if components is None: + all = True + components = list(registry.iterEnabled(target)) - if instance is None: - raise RuntimeError(f"Component {componentSpec} not found") + if isinstance(components, model.Component): + components = [components] - if not instance.enabled: - raise RuntimeError( - f"Component {componentSpec} is disabled: {instance.disableReason}" + products: list[Product] = [] + for c in components: + products.append( + Product( + path=Path(outfile(target, c)), + target=target, + component=c, + ) ) - shell.exec("ninja", "-f", ninjaPath, instance.outfile()) - - return instance + outs = list(map(lambda p: str(p.path), products)) + if all: + shell.exec("ninja", "-v", "-f", ninjaPath) + else: + shell.exec("ninja", "-v", "-f", ninjaPath, *outs) + return products -class Paths: - bin: str - lib: str - obj: str - - def __init__(self, bin: str, lib: str, obj: str): - self.bin = bin - self.lib = lib - self.obj = obj +# --- Commands --------------------------------------------------------------- # -def buildAll(targetSpec: str, props: model.Props = {}) -> context.Context: - ctx = context.contextFor(targetSpec, props) - - shell.mkdir(ctx.builddir()) - ninjaPath = os.path.join(ctx.builddir(), "build.ninja") - - with open(ninjaPath, "w") as f: - gen(f, ctx) - - shell.exec("ninja", "-v", "-f", ninjaPath) - - return ctx +@cli.command("b", "build", "Build a component or all components") +def buildCmd(args: cli.Args): + registry = model.Registry.use(args) + target = model.Target.use(args) + componentSpec = args.consumeArg() + if componentSpec is None: + raise RuntimeError("No component specified") + component = registry.lookup(componentSpec, model.Component) + build(target, registry, component)[0] -def testAll(targetSpec: str): - ctx = context.contextFor(targetSpec) +@cli.command("p", "project", "Show project information") +def runCmd(args: cli.Args): + registry = model.Registry.use(args) + target = model.Target.use(args) + debug = args.consumeOpt("debug", False) is True - shell.mkdir(ctx.builddir()) - ninjaPath = os.path.join(ctx.builddir(), "build.ninja") + componentSpec = args.consumeArg() + if componentSpec is None: + raise RuntimeError("No component specified") - with open(ninjaPath, "w") as f: - gen(f, ctx) + component = registry.lookup(componentSpec, model.Component) + if component is None: + raise RuntimeError(f"Component {componentSpec} not found") - shell.exec("ninja", "-v", "-f", ninjaPath, "all") + product = build(target, registry, component)[0] - for instance in ctx.enabledInstances(): - if instance.isLib(): - continue + os.environ["CK_TARGET"] = target.id + os.environ["CK_COMPONENT"] = product.component.id + os.environ["CK_BUILDDIR"] = target.builddir - if instance.id().endswith("-tests"): - print(f"Running {instance.id()}") - shell.exec(instance.outfile()) + shell.exec(*(["lldb", "-o", "run"] if debug else []), str(product.path), *args.args) + + +@cli.command("t", "test", "Run all test targets") +def testCmd(args: cli.Args): + # This is just a wrapper around the `run` command that try + # to run a special hook component named __tests__. + args.args.insert(0, "__tests__") + runCmd(args) + + +@cli.command("d", "debug", "Debug a component") +def debugCmd(args: cli.Args): + # This is just a wrapper around the `run` command that + # always enable debug mode. + args.opts["debug"] = True + runCmd(args) + + +@cli.command("c", "clean", "Clean build files") +def cleanCmd(args: cli.Args): + model.Project.use(args) + shell.rmrf(const.BUILD_DIR) + + +@cli.command("n", "nuke", "Clean all build files and caches") +def nukeCmd(args: cli.Args): + model.Project.use(args) + shell.rmrf(const.PROJECT_CK_DIR) diff --git a/cutekit/cli.py b/cutekit/cli.py index 94df42c..74fed4f 100644 --- a/cutekit/cli.py +++ b/cutekit/cli.py @@ -70,7 +70,7 @@ Callback = Callable[[Args], None] @dataclass class Command: - shortName: str + shortName: Optional[str] longName: str helpText: str isPlugin: bool @@ -86,7 +86,7 @@ def append(command: Command): commands.sort(key=lambda c: c.shortName or c.longName) -def command(shortName: str, longName: str, helpText: str): +def command(shortName: Optional[str], longName: str, helpText: str): curframe = inspect.currentframe() calframe = inspect.getouterframes(curframe, 2) @@ -103,7 +103,7 @@ def command(shortName: str, longName: str, helpText: str): @command("u", "usage", "Show usage information") -def usage(args: Args | None = None): +def usage(args: Optional[Args] = None): print(f"Usage: {const.ARGV0} [args...]") @@ -122,7 +122,7 @@ def helpCmd(args: Args): print() vt100.title("Commands") - for cmd in commands: + for cmd in sorted(commands, key=lambda c: c.shortName or c.longName): pluginText = "" if cmd.isPlugin: pluginText = f"{vt100.CYAN}(plugin){vt100.RESET}" @@ -140,7 +140,7 @@ def helpCmd(args: Args): @command("v", "version", "Show current version") def versionCmd(args: Args): - print(f"CuteKit v{const.VERSION_STR}\n") + print(f"CuteKit v{const.VERSION_STR}") def exec(args: Args): diff --git a/cutekit/cmds.py b/cutekit/cmds.py deleted file mode 100644 index c302492..0000000 --- a/cutekit/cmds.py +++ /dev/null @@ -1,196 +0,0 @@ -import logging -import os - - -from . import ( - context, - shell, - const, - vt100, - builder, - cli, - model, - jexpr, -) - - -_logger = logging.getLogger(__name__) - - -@cli.command("p", "project", "Show project information") -def runCmd(args: cli.Args): - model.Project.chdir() - - targetSpec = str(args.consumeOpt("target", "host-" + shell.uname().machine)) - props = args.consumePrefix("prop:") - - componentSpec = args.consumeArg() - - if componentSpec is None: - raise RuntimeError("Component not specified") - - component = builder.build(componentSpec, targetSpec, props) - - os.environ["CK_TARGET"] = component.context.target.id - os.environ["CK_COMPONENT"] = component.id() - os.environ["CK_BUILDDIR"] = component.context.builddir() - - shell.exec(component.outfile(), *args.args) - - -@cli.command("t", "test", "Run all test targets") -def testCmd(args: cli.Args): - model.Project.chdir() - - targetSpec = str(args.consumeOpt("target", "host-" + shell.uname().machine)) - builder.testAll(targetSpec) - - -@cli.command("d", "debug", "Debug a component") -def debugCmd(args: cli.Args): - model.Project.chdir() - - targetSpec = str(args.consumeOpt("target", "host-" + shell.uname().machine)) - props = args.consumePrefix("prop:") - - componentSpec = args.consumeArg() - - if componentSpec is None: - raise RuntimeError("Component not specified") - - component = builder.build(componentSpec, targetSpec, props) - - os.environ["CK_TARGET"] = component.context.target.id - os.environ["CK_COMPONENT"] = component.id() - os.environ["CK_BUILDDIR"] = component.context.builddir() - - shell.exec("lldb", "-o", "run", component.outfile(), *args.args) - - -@cli.command("b", "build", "Build a component or all components") -def buildCmd(args: cli.Args): - model.Project.chdir() - - targetSpec = str(args.consumeOpt("target", "host-" + shell.uname().machine)) - props = args.consumePrefix("prop:") - componentSpec = args.consumeArg() - - if componentSpec is None: - builder.buildAll(targetSpec, props) - else: - builder.build(componentSpec, targetSpec, props) - - -@cli.command("l", "list", "List all components and targets") -def listCmd(args: cli.Args): - model.Project.chdir() - - components = context.loadAllComponents() - targets = context.loadAllTargets() - - vt100.title("Components") - if len(components) == 0: - print(" (No components available)") - else: - print(vt100.indent(vt100.wordwrap(", ".join(map(lambda m: m.id, components))))) - print() - - vt100.title("Targets") - - if len(targets) == 0: - print(" (No targets available)") - else: - print(vt100.indent(vt100.wordwrap(", ".join(map(lambda m: m.id, targets))))) - - print() - - -@cli.command("c", "clean", "Clean build files") -def cleanCmd(args: cli.Args): - model.Project.chdir() - shell.rmrf(const.BUILD_DIR) - - -@cli.command("n", "nuke", "Clean all build files and caches") -def nukeCmd(args: cli.Args): - model.Project.chdir() - shell.rmrf(const.PROJECT_CK_DIR) - - -def grabExtern(extern: dict[str, model.Extern]): - for extSpec, ext in extern.items(): - extPath = os.path.join(const.EXTERN_DIR, extSpec) - - if os.path.exists(extPath): - print(f"Skipping {extSpec}, already installed") - continue - - print(f"Installing {extSpec}-{ext.tag} from {ext.git}...") - shell.popen( - "git", "clone", "--depth", "1", "--branch", ext.tag, ext.git, extPath - ) - - if os.path.exists(os.path.join(extPath, "project.json")): - grabExtern(context.loadProject(extPath).extern) - - -@cli.command("i", "install", "Install required external packages") -def installCmd(args: cli.Args): - model.Project.chdir() - pj = context.loadProject(".") - grabExtern(pj.extern) - - -@cli.command("I", "init", "Initialize a new project") -def initCmd(args: cli.Args): - import requests - - repo = args.consumeOpt("repo", const.DEFAULT_REPO_TEMPLATES) - list = args.consumeOpt("list") - - template = args.consumeArg() - name = args.consumeArg() - - _logger.info("Fetching registry...") - r = requests.get(f"https://raw.githubusercontent.com/{repo}/main/registry.json") - - if r.status_code != 200: - _logger.error("Failed to fetch registry") - exit(1) - - registry = r.json() - - if list: - print( - "\n".join(f"* {entry['id']} - {entry['description']}" for entry in registry) - ) - return - - if not template: - raise RuntimeError("Template not specified") - - def template_match(t: jexpr.Json) -> str: - return t["id"] == template - - if not any(filter(template_match, registry)): - raise LookupError(f"Couldn't find a template named {template}") - - if not name: - _logger.info(f"No name was provided, defaulting to {template}") - name = template - - if os.path.exists(name): - raise RuntimeError(f"Directory {name} already exists") - - print(f"Creating project {name} from template {template}...") - shell.cloneDir(f"https://github.com/{repo}", template, name) - print(f"Project {name} created\n") - - print("We suggest that you begin by typing:") - print(f" {vt100.GREEN}cd {name}{vt100.RESET}") - print( - f" {vt100.GREEN}cutekit install{vt100.BRIGHT_BLACK} # Install external packages{vt100.RESET}" - ) - print( - f" {vt100.GREEN}cutekit build{vt100.BRIGHT_BLACK} # Build the project{vt100.RESET}" - ) diff --git a/cutekit/context.py b/cutekit/context.py deleted file mode 100644 index 90e92c1..0000000 --- a/cutekit/context.py +++ /dev/null @@ -1,351 +0,0 @@ -from typing import cast, Optional, Protocol, Iterable -from itertools import chain -from pathlib import Path -import os -import logging - - -from . import const, shell, jexpr, utils, rules, mixins, model - -_logger = logging.getLogger(__name__) - - -class IContext(Protocol): - target: model.Target - - def builddir(self) -> str: - ... - - -class ComponentInstance: - enabled: bool = True - disableReason = "" - manifest: model.Component - sources: list[str] = [] - res: list[str] = [] - resolved: list[str] = [] - context: IContext - - def __init__( - self, - enabled: bool, - disableReason: str, - manifest: model.Component, - sources: list[str], - res: list[str], - resolved: list[str], - ): - self.enabled = enabled - self.disableReason = disableReason - self.manifest = manifest - self.sources = sources - self.res = res - self.resolved = resolved - - def id(self) -> str: - return self.manifest.id - - def isLib(self): - return self.manifest.type == model.Kind.LIB - - def objdir(self) -> str: - return os.path.join(self.context.builddir(), f"{self.manifest.id}/obj") - - def resdir(self) -> str: - return os.path.join(self.context.builddir(), f"{self.manifest.id}/res") - - def objsfiles(self) -> list[tuple[str, str]]: - def toOFile(s: str) -> str: - return os.path.join( - self.objdir(), - s.replace(os.path.join(self.manifest.dirname(), ""), "") + ".o", - ) - - return list(map(lambda s: (s, toOFile(s)), self.sources)) - - def resfiles(self) -> list[tuple[str, str, str]]: - def toAssetFile(s: str) -> str: - return os.path.join( - self.resdir(), - s.replace(os.path.join(self.manifest.dirname(), "res/"), ""), - ) - - def toAssetId(s: str) -> str: - return s.replace(os.path.join(self.manifest.dirname(), "res/"), "") - - return list(map(lambda s: (s, toAssetFile(s), toAssetId(s)), self.res)) - - def outfile(self) -> str: - if self.isLib(): - return os.path.join( - self.context.builddir(), self.manifest.id, f"lib/{self.manifest.id}.a" - ) - else: - return os.path.join( - self.context.builddir(), self.manifest.id, f"bin/{self.manifest.id}.out" - ) - - def cinclude(self) -> str: - if "cpp-root-include" in self.manifest.props: - return self.manifest.dirname() - elif self.manifest.type == model.Kind.LIB: - return str(Path(self.manifest.dirname()).parent) - else: - return "" - - -class Context(IContext): - target: model.Target - instances: list[ComponentInstance] - tools: model.Tools - - def enabledInstances(self) -> Iterable[ComponentInstance]: - return filter(lambda x: x.enabled, self.instances) - - def __init__( - self, - target: model.Target, - instances: list[ComponentInstance], - tools: model.Tools, - ): - self.target = target - self.instances = instances - self.tools = tools - - def componentByName(self, name: str) -> Optional[ComponentInstance]: - result = list(filter(lambda x: x.manifest.id == name, self.instances)) - if len(result) == 0: - return None - return result[0] - - def cincls(self) -> list[str]: - includes = list( - filter( - lambda x: x != "", map(lambda x: x.cinclude(), self.enabledInstances()) - ) - ) - return utils.uniq(includes) - - def cdefs(self) -> list[str]: - return self.target.cdefs() - - def hashid(self) -> str: - return utils.hash( - (self.target.props, [self.tools[t].to_dict() for t in self.tools]) - )[0:8] - - def builddir(self) -> str: - return os.path.join(const.BUILD_DIR, f"{self.target.id}-{self.hashid()[:8]}") - - -def loadAllTargets() -> list[model.Target]: - projectRoot = model.Project.root() - if projectRoot is None: - return [] - - pj = loadProject(projectRoot) - paths = list( - map( - lambda e: os.path.join(const.EXTERN_DIR, e, const.TARGETS_DIR), - pj.extern.keys(), - ) - ) + [const.TARGETS_DIR] - - ret = [] - for entry in paths: - files = shell.find(entry, ["*.json"]) - ret += list( - map( - lambda path: model.Manifest.load(Path(path)).ensureType(model.Target), - files, - ) - ) - - return ret - - -def loadProject(path: str) -> model.Project: - path = os.path.join(path, "project.json") - return model.Manifest.load(Path(path)).ensureType(model.Project) - - -def loadTarget(id: str) -> model.Target: - try: - return next(filter(lambda t: t.id == id, loadAllTargets())) - except StopIteration: - raise RuntimeError(f"Target '{id}' not found") - - -def loadAllComponents() -> list[model.Component]: - files = shell.find(const.SRC_DIR, ["manifest.json"]) - files += shell.find(const.EXTERN_DIR, ["manifest.json"]) - - return list( - map( - lambda path: model.Manifest.load(Path(path)).ensureType(model.Component), - files, - ) - ) - - -def filterDisabled( - components: list[model.Component], target: model.Target -) -> tuple[list[model.Component], list[model.Component]]: - return list(filter(lambda c: c.isEnabled(target)[0], components)), list( - filter(lambda c: not c.isEnabled(target)[0], components) - ) - - -def providerFor( - what: str, components: list[model.Component] -) -> tuple[Optional[str], str]: - result: list[model.Component] = list(filter(lambda c: c.id == what, components)) - - if len(result) == 0: - # Try to find a provider - result = list(filter(lambda x: (what in x.provides), components)) - - if len(result) == 0: - _logger.error(f"No provider for '{what}'") - return (None, f"No provider for '{what}'") - - if len(result) > 1: - ids = list(map(lambda x: x.id, result)) - _logger.error(f"Multiple providers for '{what}': {result}") - return (None, f"Multiple providers for '{what}': {','.join(ids)}") - - return (result[0].id, "") - - -def resolveDeps( - componentSpec: str, components: list[model.Component], target: model.Target -) -> tuple[bool, str, list[str]]: - mapping = dict(map(lambda c: (c.id, c), components)) - - def resolveInner(what: str, stack: list[str] = []) -> tuple[bool, str, list[str]]: - result: list[str] = [] - what = target.route(what) - resolved, unresolvedReason = providerFor(what, components) - - if resolved is None: - return False, unresolvedReason, [] - - if resolved in stack: - raise RuntimeError(f"Dependency loop: {stack} -> {resolved}") - - stack.append(resolved) - - for req in mapping[resolved].requires: - keep, unresolvedReason, reqs = resolveInner(req, stack) - - if not keep: - stack.pop() - _logger.error(f"Dependency '{req}' not met for '{resolved}'") - return False, unresolvedReason, [] - - result.extend(reqs) - - stack.pop() - result.insert(0, resolved) - - return True, "", result - - enabled, unresolvedReason, resolved = resolveInner(componentSpec) - - return enabled, unresolvedReason, utils.uniq(resolved) - - -def instanciate( - componentSpec: str, components: list[model.Component], target: model.Target -) -> Optional[ComponentInstance]: - manifest = next(filter(lambda c: c.id == componentSpec, components)) - wildcards = set(chain(*map(lambda rule: rule.fileIn, rules.rules.values()))) - dirs = [manifest.dirname()] + list( - map(lambda d: os.path.join(manifest.dirname(), d), manifest.subdirs) - ) - sources = shell.find(dirs, list(wildcards), recusive=False) - - res = shell.find(os.path.join(manifest.dirname(), "res")) - - enabled, unresolvedReason, resolved = resolveDeps(componentSpec, components, target) - - return ComponentInstance( - enabled, unresolvedReason, manifest, sources, res, resolved[1:] - ) - - -def instanciateDisabled( - component: model.Component, target: model.Target -) -> ComponentInstance: - return ComponentInstance( - enabled=False, - disableReason=component.isEnabled(target)[1], - manifest=component, - sources=[], - res=[], - resolved=[], - ) - - -context: dict[str, Context] = {} - - -def contextFor(targetSpec: str, props: model.Props = {}) -> Context: - if targetSpec in context: - return context[targetSpec] - - _logger.info(f"Loading context for '{targetSpec}'") - - targetEls = targetSpec.split(":") - - if targetEls[0] == "": - targetEls[0] = "host-" + shell.uname().machine - - target = loadTarget(targetEls[0]) - target.props |= props - - components = loadAllComponents() - components, disabled = filterDisabled(components, target) - - tools: model.Tools = {} - - for toolSpec in target.tools: - tool = target.tools[toolSpec] - - tools[toolSpec] = model.Tool(cmd=tool.cmd, args=tool.args, files=tool.files) - - tools[toolSpec].args += rules.rules[toolSpec].args - - for m in targetEls[1:]: - mixin = mixins.byId(m) - tools = mixin(target, tools) - - for component in components: - for toolSpec in component.tools: - tool = component.tools[toolSpec] - tools[toolSpec].args += tool.args - - instances: list[ComponentInstance] = list( - map(lambda c: instanciateDisabled(c, target), disabled) - ) - - instances += cast( - list[ComponentInstance], - list( - filter( - lambda e: e is not None, - map(lambda c: instanciate(c.id, components, target), components), - ) - ), - ) - - context[targetSpec] = Context( - target, - instances, - tools, - ) - - for instance in instances: - instance.context = context[targetSpec] - - return context[targetSpec] diff --git a/cutekit/graph.py b/cutekit/graph.py index 19c9b3d..94dee83 100644 --- a/cutekit/graph.py +++ b/cutekit/graph.py @@ -1,96 +1,95 @@ import os -from typing import cast -from . import vt100, context, cli, shell, model +from typing import Optional, cast + +from . import vt100, cli, model def view( - context: context.Context, - scope: str | None = None, + registry: model.Registry, + target: model.Target, + scope: Optional[str] = None, showExe: bool = True, showDisabled: bool = False, ): from graphviz import Digraph # type: ignore - g = Digraph(context.target.id, filename="graph.gv") + g = Digraph(target.id, filename="graph.gv") g.attr("graph", splines="ortho", rankdir="BT", ranksep="1.5") g.attr("node", shape="ellipse") g.attr( "graph", - label=f"<{scope or 'Full Dependency Graph'}
{context.target.id}>", + label=f"<{scope or 'Full Dependency Graph'}
{target.id}>", labelloc="t", ) scopeInstance = None if scope is not None: - scopeInstance = context.componentByName(scope) + scopeInstance = registry.lookup(scope, model.Component) - for instance in context.instances: - if not instance.isLib() and not showExe: + for component in registry.iterEnabled(target): + if not component.type == model.Kind.LIB and not showExe: continue if ( scopeInstance is not None - and instance.manifest.id != scope - and instance.manifest.id not in scopeInstance.resolved + and component.id != scope + and component.id not in scopeInstance.resolved[target.id].resolved ): continue - if instance.enabled: - fillcolor = "lightgrey" if instance.isLib() else "lightblue" - shape = "plaintext" if not scope == instance.manifest.id else "box" + if component.resolved[target.id].enabled: + fillcolor = "lightgrey" if component.type == model.Kind.LIB else "lightblue" + shape = "plaintext" if not scope == component.id else "box" g.node( - instance.manifest.id, - f"<{instance.manifest.id}
{vt100.wordwrap(instance.manifest.decription, 40,newline='
')}>", + component.id, + f"<{component.id}
{vt100.wordwrap(component.decription, 40,newline='
')}>", shape=shape, style="filled", fillcolor=fillcolor, ) - for req in instance.manifest.requires: - g.edge(instance.manifest.id, req) + for req in component.requires: + g.edge(component.id, req) - for req in instance.manifest.provides: - isChosen = context.target.routing.get(req, None) == instance.manifest.id + for req in component.provides: + isChosen = target.routing.get(req, None) == component.id g.edge( req, - instance.manifest.id, + component.id, arrowhead="none", color=("blue" if isChosen else "black"), ) elif showDisabled: g.node( - instance.manifest.id, - f"<{instance.manifest.id}
{vt100.wordwrap(instance.manifest.decription, 40,newline='
')}

{vt100.wordwrap(instance.disableReason, 40,newline='
')}
>", + component.id, + f"<{component.id}
{vt100.wordwrap(component.decription, 40,newline='
')}

{vt100.wordwrap(str(component.resolved[target.id].reason), 40,newline='
')}
>", shape="plaintext", style="filled", fontcolor="#999999", fillcolor="#eeeeee", ) - for req in instance.manifest.requires: - g.edge(instance.manifest.id, req, color="#aaaaaa") + for req in component.requires: + g.edge(component.id, req, color="#aaaaaa") - for req in instance.manifest.provides: - g.edge(req, instance.manifest.id, arrowhead="none", color="#aaaaaa") + for req in component.provides: + g.edge(req, component.id, arrowhead="none", color="#aaaaaa") - g.view(filename=os.path.join(context.builddir(), "graph.gv")) + g.view(filename=os.path.join(target.builddir, "graph.gv")) @cli.command("g", "graph", "Show the dependency graph") def graphCmd(args: cli.Args): - model.Project.chdir() + registry = model.Registry.use(args) + target = model.Target.use(args) - targetSpec = str(args.consumeOpt("target", "host-" + shell.uname().machine)) + scope = cast(Optional[str], args.tryConsumeOpt("scope")) + onlyLibs = args.consumeOpt("only-libs", False) is True + showDisabled = args.consumeOpt("show-disabled", False) is True - scope: str | None = cast(str | None, args.tryConsumeOpt("scope")) - onlyLibs: bool = args.consumeOpt("only-libs", False) is True - showDisabled: bool = args.consumeOpt("show-disabled", False) is True - - ctx = context.contextFor(targetSpec) - - view(ctx, scope=scope, showExe=not onlyLibs, showDisabled=showDisabled) + view(registry, target, scope=scope, showExe=not onlyLibs, showDisabled=showDisabled) diff --git a/cutekit/model.py b/cutekit/model.py index 8a2cd58..e81e91f 100644 --- a/cutekit/model.py +++ b/cutekit/model.py @@ -3,12 +3,14 @@ import logging from enum import Enum -from typing import Any, Type, cast +from typing import Any, Generator, Optional, Type, cast from pathlib import Path -from dataclasses_json import DataClassJsonMixin, config +from dataclasses_json import DataClassJsonMixin from dataclasses import dataclass, field -from . import jexpr, compat, utils +from cutekit import const, shell + +from . import jexpr, compat, utils, cli, vt100 _logger = logging.getLogger(__name__) @@ -23,6 +25,9 @@ class Kind(Enum): EXE = "exe" +# --- Manifest --------------------------------------------------------------- # + + @dataclass class Manifest(DataClassJsonMixin): id: str @@ -31,6 +36,9 @@ class Manifest(DataClassJsonMixin): @staticmethod def parse(path: Path, data: dict[str, Any]) -> "Manifest": + """ + Parse a manifest from a given path and data + """ compat.ensureSupportedManifest(data, path) kind = Kind(data["type"]) del data["$schema"] @@ -40,12 +48,24 @@ class Manifest(DataClassJsonMixin): @staticmethod def load(path: Path) -> "Manifest": + """ + Load a manifest from a given path + """ return Manifest.parse(path, jexpr.evalRead(path)) def dirname(self) -> str: + """ + Return the directory of the manifest + """ return os.path.dirname(self.path) + def subpath(self, path) -> Path: + return Path(self.dirname()) / path + def ensureType(self, t: Type[utils.T]) -> utils.T: + """ + Ensure that the manifest is of a given type + """ if not isinstance(self, t): raise RuntimeError( f"{self.path} should be a {type.__name__} manifest but is a {self.__class__.__name__} manifest" @@ -53,6 +73,11 @@ class Manifest(DataClassJsonMixin): return cast(utils.T, self) +# --- Project ---------------------------------------------------------------- # + +_project: Optional["Project"] = None + + @dataclass class Extern(DataClassJsonMixin): git: str @@ -64,8 +89,16 @@ class Project(Manifest): description: str = field(default="(No description)") extern: dict[str, Extern] = field(default_factory=dict) + @property + def externDirs(self) -> list[str]: + res = map(lambda e: os.path.join(const.EXTERN_DIR, e), self.extern.keys()) + return list(res) + @staticmethod - def root() -> str | None: + def root() -> Optional[str]: + """ + Find the root of the project by looking for a project.json + """ cwd = Path.cwd() while str(cwd) != cwd.root: if (cwd / "project.json").is_file(): @@ -75,6 +108,9 @@ class Project(Manifest): @staticmethod def chdir() -> None: + """ + Change the current working directory to the root of the project + """ path = Project.root() if path is None: raise RuntimeError( @@ -82,6 +118,121 @@ class Project(Manifest): ) os.chdir(path) + @staticmethod + def at(path: str) -> Optional["Project"]: + path = os.path.join(path, "project.json") + if not os.path.exists(path): + return None + return Manifest.load(Path(path)).ensureType(Project) + + @staticmethod + def ensure() -> "Project": + root = Project.root() + if root is None: + raise RuntimeError( + "No project.json found in this directory or any parent directory" + ) + os.chdir(root) + return Manifest.load(Path(os.path.join(root, "project.json"))).ensureType( + Project + ) + + @staticmethod + def fetchs(extern: dict[str, Extern]): + for extSpec, ext in extern.items(): + extPath = os.path.join(const.EXTERN_DIR, extSpec) + + if os.path.exists(extPath): + print(f"Skipping {extSpec}, already installed") + continue + + print(f"Installing {extSpec}-{ext.tag} from {ext.git}...") + shell.popen( + "git", + "clone", + "--quiet", + "--depth", + "1", + "--branch", + ext.tag, + ext.git, + extPath, + ) + project = Project.at(extPath) + if project is not None: + Project.fetchs(project.extern) + + @staticmethod + def use(args: cli.Args) -> "Project": + global _project + if _project is None: + _project = Project.ensure() + return _project + + +@cli.command("i", "install", "Install required external packages") +def installCmd(args: cli.Args): + project = Project.use(args) + Project.fetchs(project.extern) + + +@cli.command("I", "init", "Initialize a new project") +def initCmd(args: cli.Args): + import requests + + repo = args.consumeOpt("repo", const.DEFAULT_REPO_TEMPLATES) + list = args.consumeOpt("list") + + template = args.consumeArg() + name = args.consumeArg() + + _logger.info("Fetching registry...") + r = requests.get(f"https://raw.githubusercontent.com/{repo}/main/registry.json") + + if r.status_code != 200: + _logger.error("Failed to fetch registry") + exit(1) + + registry = r.json() + + if list: + print( + "\n".join(f"* {entry['id']} - {entry['description']}" for entry in registry) + ) + return + + if not template: + raise RuntimeError("Template not specified") + + def template_match(t: jexpr.Json) -> str: + return t["id"] == template + + if not any(filter(template_match, registry)): + raise LookupError(f"Couldn't find a template named {template}") + + if not name: + _logger.info(f"No name was provided, defaulting to {template}") + name = template + + if os.path.exists(name): + raise RuntimeError(f"Directory {name} already exists") + + print(f"Creating project {name} from template {template}...") + shell.cloneDir(f"https://github.com/{repo}", template, name) + print(f"Project {name} created\n") + + print("We suggest that you begin by typing:") + print(f" {vt100.GREEN}cd {name}{vt100.RESET}") + print( + f" {vt100.GREEN}cutekit install{vt100.BRIGHT_BLACK} # Install external packages{vt100.RESET}" + ) + print( + f" {vt100.GREEN}cutekit build{vt100.BRIGHT_BLACK} # Build the project{vt100.RESET}" + ) + + +# --- Target ----------------------------------------------------------------- # + @dataclass class Tool(DataClassJsonMixin): @@ -99,30 +250,42 @@ class Target(Manifest): tools: Tools = field(default_factory=dict) routing: dict[str, str] = field(default_factory=dict) + @property + def hashid(self) -> str: + return utils.hash((self.props, [v.to_dict() for k, v in self.tools.items()])) + + @property + def builddir(self) -> str: + return os.path.join(const.BUILD_DIR, f"{self.id}-{self.hashid[:8]}") + + @staticmethod + def use(args: cli.Args) -> "Target": + registry = Registry.use(args) + targetSpec = str(args.consumeOpt("target", "host-" + shell.uname().machine)) + return registry.ensure(targetSpec, Target) + def route(self, componentSpec: str): + """ + Route a component spec to a target specific component spec + """ return ( self.routing[componentSpec] if componentSpec in self.routing else componentSpec ) - def cdefs(self) -> list[str]: - defines: list[str] = [] - def sanatize(s: str) -> str: - return s.lower().replace(" ", "_").replace("-", "_").replace(".", "_") +# --- Component -------------------------------------------------------------- # - for key in self.props: - prop = self.props[key] - propStr = str(prop) - if isinstance(prop, bool): - if prop: - defines += [f"-D__ck_{sanatize(key)}__"] - else: - defines += [f"-D__ck_{sanatize(key)}_{sanatize(propStr)}__"] - defines += [f"-D__ck_{sanatize(key)}_value={propStr}"] - return defines +@dataclass +class Resolved: + reason: Optional[str] = None + resolved: list[str] = field(default_factory=list) + + @property + def enabled(self) -> bool: + return self.reason is None @dataclass @@ -134,6 +297,8 @@ class Component(Manifest): requires: list[str] = field(default_factory=list) provides: list[str] = field(default_factory=list) subdirs: list[str] = field(default_factory=list) + injects: list[str] = field(default_factory=list) + resolved: dict[str, Resolved] = field(default_factory=dict) def isEnabled(self, target: Target) -> tuple[bool, str]: for k, v in self.enableIf.items(): @@ -160,3 +325,290 @@ KINDS: dict[Kind, Type[Manifest]] = { Kind.LIB: Component, Kind.EXE: Component, } + +# --- Dependency resolution -------------------------------------------------- # + + +@dataclass +class Resolver: + _registry: "Registry" + _target: Target + _mappings: dict[str, list[Component]] = field(default_factory=dict) + _cache: dict[str, Resolved] = field(default_factory=dict) + _baked = False + + def _bake(self): + """ + Bake the resolver by building a mapping of all + components that provide a given spec. + """ + + if self._baked: + return + + for c in self._registry.iter(Component): + for p in c.provides + [c.id]: + if p not in self._mappings and [0]: + self._mappings[p] = [] + self._mappings[p].append(c) + + # Overide with target routing since it has priority + # over component provides and id + for k, v in self._target.routing.items(): + component = self._registry.lookup(v, Component) + self._mappings[k] = [component] if component else [] + + self._baked = True + + def _provider(self, spec: str) -> tuple[Optional[str], str]: + """ + Returns the provider for a given spec. + """ + result = self._mappings.get(spec, []) + + if len(result) == 1: + enabled, reason = result[0].isEnabled(self._target) + if not enabled: + return (None, reason) + + def checkIsEnabled(c: Component) -> bool: + enabled, reason = c.isEnabled(self._target) + if not enabled: + _logger.info(f"Component {c.id} cannot provide '{spec}': {reason}") + return enabled + + result = list(filter(checkIsEnabled, result)) + + if result == []: + return (None, f"No provider for '{spec}'") + + if len(result) > 1: + ids = list(map(lambda x: x.id, result)) + return (None, f"Multiple providers for '{spec}': {','.join(ids)}") + + return (result[0].id, "") + + def resolve(self, what: str, stack: list[str] = []) -> Resolved: + """ + Resolve a given spec to a list of components. + """ + self._bake() + + if what in self._cache: + return self._cache[what] + + keep, unresolvedReason = self._provider(what) + + if not keep: + _logger.error(f"Dependency '{what}' not found: {unresolvedReason}") + self._cache[what] = Resolved(reason=unresolvedReason) + return self._cache[what] + + if keep in self._cache: + return self._cache[keep] + + if keep in stack: + raise RuntimeError( + f"Dependency loop while resolving '{what}': {stack} -> {keep}" + ) + + stack.append(keep) + + component = self._registry.lookup(keep, Component) + if not component: + return Resolved(reason="No provider for 'myembed'") + + result: list[str] = [] + + for req in component.requires: + reqResolved = self.resolve(req, stack) + if reqResolved.reason: + stack.pop() + + self._cache[keep] = Resolved(reason=reqResolved.reason) + return self._cache[keep] + + result.extend(reqResolved.resolved) + + stack.pop() + result.insert(0, keep) + self._cache[keep] = Resolved(resolved=utils.uniq(result)) + return self._cache[keep] + + +# --- Registry --------------------------------------------------------------- # + +_registry: Optional["Registry"] = None + + +@dataclass +class Registry(DataClassJsonMixin): + project: Project + manifests: dict[str, Manifest] = field(default_factory=dict) + + def _append(self, m: Manifest): + """ + Append a manifest to the model + """ + if m.id in self.manifests: + raise RuntimeError( + f"Duplicated manifest '{m.id}' at '{m.path}' already loaded from '{self.manifests[m.id].path}'" + ) + + self.manifests[m.id] = m + + def iter(self, type: Type[utils.T]) -> Generator[utils.T, None, None]: + """ + Iterate over all manifests of a given type + """ + + for m in self.manifests.values(): + if isinstance(m, type): + yield m + + def iterEnabled(self, target: Target) -> Generator[Component, None, None]: + for c in self.iter(Component): + resolve = c.resolved[target.id] + if resolve.enabled: + yield c + + def lookup(self, name: str, type: Type[utils.T]) -> Optional[utils.T]: + """ + Lookup a manifest of a given type by name + """ + + if name in self.manifests: + m = self.manifests[name] + if isinstance(m, type): + return m + + return None + + def ensure(self, name: str, type: Type[utils.T]) -> utils.T: + """ + Ensure that a manifest of a given type exists + and return it. + """ + + m = self.lookup(name, type) + if not m: + raise RuntimeError(f"Could not find {type.__name__} '{name}'") + return m + + @staticmethod + def use(args: cli.Args) -> "Registry": + global _registry + + if _registry is not None: + return _registry + + project = Project.use(args) + mixins = str(args.consumeOpt("mixins", "")).split(",") + if mixins == [""]: + mixins = [] + props = cast(dict[str, str], args.consumePrefix("prop:")) + + _registry = Registry.load(project, mixins, props) + return _registry + + @staticmethod + def load(project: Project, mixins: list[str], props: Props) -> "Registry": + registry = Registry(project) + registry._append(project) + + # Lookup and load all extern projects + for externDir in project.externDirs: + projectPath = os.path.join(externDir, "project.json") + manifestPath = os.path.join(externDir, "manifest.json") + + if os.path.exists(projectPath): + registry._append(Manifest.load(Path(projectPath)).ensureType(Project)) + elif os.path.exists(manifestPath): + # For simple library allow to have a manifest.json instead of a project.json + registry._append( + Manifest.load(Path(manifestPath)).ensureType(Component) + ) + else: + _logger.warn( + "Extern project does not have a project.json or manifest.json" + ) + + # Load all manifests from projects + for project in list(registry.iter(Project)): + targetDir = os.path.join(project.dirname(), const.TARGETS_DIR) + targetFiles = shell.find(targetDir, ["*.json"]) + + for targetFile in targetFiles: + registry._append(Manifest.load(Path(targetFile)).ensureType(Target)) + + componentDir = os.path.join(project.dirname(), const.SRC_DIR) + rootComponent = os.path.join(project.dirname(), "manifest.json") + componentFiles = shell.find(componentDir, ["manifest.json"]) + + if os.path.exists(rootComponent): + componentFiles += [rootComponent] + + for componentFile in componentFiles: + registry._append( + Manifest.load(Path(componentFile)).ensureType(Component) + ) + + # Resolve all dependencies for all targets + for target in registry.iter(Target): + target.props |= props + resolver = Resolver(registry, target) + + # Apply injects + for c in registry.iter(Component): + if c.isEnabled(target)[0]: + for inject in c.injects: + victim = registry.lookup(inject, Component) + if not victim: + raise RuntimeError(f"Cannot find component '{inject}'") + victim.requires += [c.id] + + # Resolve all components + for c in registry.iter(Component): + resolved = resolver.resolve(c.id) + if resolved.reason: + _logger.info(f"Component '{c.id}' disabled: {resolved.reason}") + c.resolved[target.id] = resolved + + # Resolve tooling + tools: Tools = target.tools + from . import mixins as mxs + + for mix in mixins: + mixin = mxs.byId(mix) + tools = mixin(target, tools) + + # Apply tooling from components + for c in registry.iter(Component): + if c.resolved[target.id].enabled: + for k, v in c.tools.items(): + tools[k].args += v.args + + return registry + + +@cli.command("l", "list", "List all components and targets") +def listCmd(args: cli.Args): + registry = Registry.use(args) + + components = list(registry.iter(Component)) + targets = list(registry.iter(Target)) + + vt100.title("Components") + if len(components) == 0: + print(vt100.p("(No components available)")) + else: + print(vt100.p(", ".join(map(lambda m: m.id, components)))) + print() + + vt100.title("Targets") + + if len(targets) == 0: + print(vt100.p("(No targets available)")) + else: + print(vt100.p(", ".join(map(lambda m: m.id, targets)))) + print() diff --git a/cutekit/plugins.py b/cutekit/plugins.py index 18ac72d..cbd7c15 100644 --- a/cutekit/plugins.py +++ b/cutekit/plugins.py @@ -1,7 +1,7 @@ import os import logging -from . import shell, model, const, context +from . import shell, model, const import importlib.util as importlib @@ -23,19 +23,19 @@ def load(path: str): def loadAll(): _logger.info("Loading plugins...") - projectRoot = model.Project.root() + root = model.Project.root() - if projectRoot is None: + if root is None: _logger.info("Not in project, skipping plugin loading") return - pj = context.loadProject(projectRoot) - paths = list(map(lambda e: os.path.join(const.EXTERN_DIR, e), pj.extern.keys())) + [ - "." - ] + project = model.Project.at(root) + paths = list( + map(lambda e: os.path.join(const.EXTERN_DIR, e), project.extern.keys()) + ) + ["."] for dirname in paths: - pluginDir = os.path.join(projectRoot, dirname, const.META_DIR, "plugins") + pluginDir = os.path.join(root, dirname, const.META_DIR, "plugins") for files in shell.readdir(pluginDir): if files.endswith(".py"): diff --git a/cutekit/rules.py b/cutekit/rules.py index a2ef6ba..b9147ab 100644 --- a/cutekit/rules.py +++ b/cutekit/rules.py @@ -9,7 +9,15 @@ class Rule: args: list[str] deps: Optional[str] = None - def __init__(self, id: str, fileIn: list[str], fileOut: list[str], rule: str, args: list[str] = [], deps: Optional[str] = None): + def __init__( + self, + id: str, + fileIn: list[str], + fileOut: list[str], + rule: str, + args: list[str] = [], + deps: Optional[str] = None, + ): self.id = id self.fileIn = fileIn self.fileOut = fileOut @@ -19,16 +27,22 @@ class Rule: rules: dict[str, Rule] = { - "cc": Rule("cc", ["*.c"], ["*.o"], "-c -o $out $in -MD -MF $out.d $flags $cincs $cdefs", ["-std=gnu2x", - "-Wall", - "-Wextra", - "-Werror"], "$out.d"), - "cxx": Rule("cxx", ["*.cpp", "*.cc", "*.cxx"], ["*.o"], "-c -o $out $in -MD -MF $out.d $flags $cincs $cdefs", ["-std=gnu++2b", - "-Wall", - "-Wextra", - "-Werror", - "-fno-exceptions", - "-fno-rtti"], "$out.d"), + "cc": Rule( + "cc", + ["*.c"], + ["*.o"], + "-c -o $out $in -MD -MF $out.d $flags $cincs $cdefs", + ["-std=gnu2x", "-Wall", "-Wextra", "-Werror"], + "$out.d", + ), + "cxx": Rule( + "cxx", + ["*.cpp", "*.cc", "*.cxx"], + ["*.o"], + "-c -o $out $in -MD -MF $out.d $flags $cincs $cdefs", + ["-std=gnu++2b", "-Wall", "-Wextra", "-Werror", "-fno-exceptions", "-fno-rtti"], + "$out.d", + ), "as": Rule("as", ["*.s", "*.asm", "*.S"], ["*.o"], "-o $out $in $flags"), "ar": Rule("ar", ["*.o"], ["*.a"], "$flags $out $in"), "ld": Rule("ld", ["*.o", "*.a"], ["*.out"], "-o $out $in $flags"), diff --git a/cutekit/shell.py b/cutekit/shell.py index 996cbbd..a7e4a2e 100644 --- a/cutekit/shell.py +++ b/cutekit/shell.py @@ -270,7 +270,7 @@ def latest(cmd: str) -> str: return chosen -def which(cmd: str) -> str | None: +def which(cmd: str) -> Optional[str]: """ Find the path of a command """ diff --git a/cutekit/vt100.py b/cutekit/vt100.py index ceea29b..96c7eea 100644 --- a/cutekit/vt100.py +++ b/cutekit/vt100.py @@ -28,10 +28,6 @@ CROSSED = "\033[9m" RESET = "\033[0m" -def title(text: str): - print(f"{BOLD}{text}{RESET}:") - - def wordwrap(text: str, width: int = 60, newline: str = "\n") -> str: result = "" curr = 0 @@ -49,3 +45,11 @@ def wordwrap(text: str, width: int = 60, newline: str = "\n") -> str: def indent(text: str, indent: int = 4) -> str: return " " * indent + text.replace("\n", "\n" + " " * indent) + + +def title(text: str): + print(f"{BOLD}{text}{RESET}:") + + +def p(text: str): + return indent(wordwrap(text)) diff --git a/tests/test_resolver.py b/tests/test_resolver.py new file mode 100644 index 0000000..7221ce6 --- /dev/null +++ b/tests/test_resolver.py @@ -0,0 +1,90 @@ +from cutekit import model + + +def test_direct_deps(): + r = model.Registry("") + r._append(model.Component("myapp", requires=["mylib"])) + r._append(model.Component("mylib")) + t = model.Target("host") + res = model.Resolver(r, t) + + resolved = res.resolve("myapp") + assert resolved.reason is None + assert resolved.resolved == ["myapp", "mylib"] + + +def test_indirect_deps(): + r = model.Registry("") + r._append(model.Component("myapp", requires=["mylib"])) + r._append(model.Component("mylib", requires=["myembed"])) + r._append(model.Component("myimpl", provides=["myembed"])) + t = model.Target("host") + res = model.Resolver(r, t) + assert res.resolve("myapp").resolved == ["myapp", "mylib", "myimpl"] + + +def test_deps_routing(): + r = model.Registry("") + r._append(model.Component("myapp", requires=["mylib"])) + r._append(model.Component("mylib", requires=["myembed"])) + r._append(model.Component("myimplA", provides=["myembed"])) + r._append(model.Component("myimplB", provides=["myembed"])) + t = model.Target("host", routing={"myembed": "myimplB"}) + res = model.Resolver(r, t) + assert res.resolve("myapp").resolved == ["myapp", "mylib", "myimplB"] + + t = model.Target("host", routing={"myembed": "myimplA"}) + res = model.Resolver(r, t) + assert res.resolve("myapp").resolved == ["myapp", "mylib", "myimplA"] + + t = model.Target("host", routing={"myembed": "myimplC"}) + res = model.Resolver(r, t) + assert res.resolve("myapp").reason == "No provider for 'myembed'" + + +def test_deps_routing_with_props(): + r = model.Registry("") + r._append(model.Component("myapp", requires=["mylib"])) + r._append(model.Component("mylib", requires=["myembed"])) + r._append( + model.Component("myimplA", provides=["myembed"], enableIf={"myprop": ["a"]}) + ) + r._append( + model.Component("myimplB", provides=["myembed"], enableIf={"myprop": ["b"]}) + ) + t = model.Target("host", routing={"myembed": "myimplB"}, props={"myprop": "b"}) + res = model.Resolver(r, t) + assert res.resolve("myapp").resolved == ["myapp", "mylib", "myimplB"] + + t = model.Target("host", routing={"myembed": "myimplA"}, props={"myprop": "a"}) + res = model.Resolver(r, t) + assert res.resolve("myapp").resolved == ["myapp", "mylib", "myimplA"] + + t = model.Target("host", routing={"myembed": "myimplC"}, props={"myprop": "c"}) + res = model.Resolver(r, t) + + resolved = res.resolve("myapp") + assert resolved.reason == "No provider for 'myembed'" + + +def test_deps_routing_with_props_and_requires(): + r = model.Registry("") + r._append(model.Component("myapp", requires=["mylib"])) + r._append(model.Component("mylib", requires=["myembed"])) + r._append( + model.Component("myimplA", provides=["myembed"], enableIf={"myprop": ["a"]}) + ) + r._append( + model.Component("myimplB", provides=["myembed"], enableIf={"myprop": ["b"]}) + ) + t = model.Target("host", routing={"myembed": "myimplB"}, props={"myprop": "b"}) + res = model.Resolver(r, t) + assert res.resolve("myapp").resolved == ["myapp", "mylib", "myimplB"] + + t = model.Target("host", routing={"myembed": "myimplA"}, props={"myprop": "a"}) + res = model.Resolver(r, t) + assert res.resolve("myapp").resolved == ["myapp", "mylib", "myimplA"] + + t = model.Target("host", routing={"myembed": "myimplC"}, props={"myprop": "c"}) + res = model.Resolver(r, t) + assert res.resolve("myapp").reason == "No provider for 'myembed'"