Compare commits

...

52 commits

Author SHA1 Message Date
Sleepy Monax ad5cc391be wip 2024-02-13 20:23:37 +01:00
Sleepy Monax 647043f4d3 wip 2024-02-13 09:56:43 +01:00
Sleepy Monax a13c76cddf Renamed license.txt readme.md 2024-02-12 11:53:13 +01:00
Sleepy Monax d3d4482f8e Fix tests 2024-02-12 11:51:52 +01:00
Sleepy Monax 01371ed5d4 More sensibles names for distros. 2024-02-08 10:34:34 +01:00
Sleepy Monax d64980d43d Dont reincarnate interactivly. 2024-02-01 08:43:06 +01:00
Sleepy Monax 2e005bd48e Fix arguments passing when reincarnating. 2024-01-31 15:58:16 +01:00
Sleepy Monax 9164c02a3e Tweaked console output when building 2024-01-30 13:43:26 +01:00
Sleepy Monax ec77c87515 Fix arguments propagation to pods 2024-01-29 15:21:35 +01:00
Sleepy Monax 2f8b35f79e Fix fedora 39 support. 2024-01-29 11:51:30 +01:00
Sleepy Monax 3dcca6591b Added devShell to nix flake. 2024-01-28 00:19:19 +01:00
Jordan ⌨️ 86768ad452 fix: NixOS will use clang and not clang-xx 2024-01-25 15:34:08 +01:00
Jordan ⌨️ b91e73662a feat: added Nix flake 2024-01-23 13:10:21 +01:00
Sleepy Monax da8d82e09f Store mem profile file in /tmp 2024-01-22 14:56:46 +01:00
Sleepy Monax 35843cce22 Added more profiling options to run command 2024-01-22 14:50:38 +01:00
Sleepy Monax c4ae611ec3 Add support for heap profiling 2024-01-22 14:32:02 +01:00
Sleepy Monax 3019b1807f Allow overiding profiler rate. 2024-01-22 13:17:30 +01:00
Sleepy Monax 07932d134a Improved sanatization of props when converting them to Cdefs 2024-01-21 16:32:31 +01:00
Sleepy Monax aa6a55f717 Fix command error being ignored and not reported as error code 2024-01-20 18:02:02 +01:00
Sleepy Monax 16225f50d4 Expose some shell utilites as commands. 2024-01-17 17:19:11 +01:00
Sleepy Monax dd4324bad4 Profiling is easy now :) 2024-01-17 10:40:06 +01:00
Sleepy Monax f134c5752b Improved build api. 2024-01-17 09:18:27 +01:00
Sleepy Monax 9c9db6c36b Profiling support and better compression support. 2024-01-17 06:32:38 +01:00
Sleepy Monax 1fd19e757a Added wait option to debbuger 2024-01-15 13:47:51 +01:00
Sleepy Monax 594953ea68 Update boostrap script. 2024-01-11 15:37:31 +01:00
Sleepy Monax b0c8ee351f Deep instead of depth 2024-01-10 14:17:48 +01:00
Sleepy Monax 1a7c2c475a Added support for deep extern cloning 2024-01-10 14:07:36 +01:00
Sleepy Monax 5805d1ddf3 Strip popen output 2024-01-10 11:00:09 +01:00
Jordan ⌨️ 4c18fe5089 fix: better traceback when cwd path doesn't exist 2024-01-10 10:38:08 +01:00
Sleepy Monax 0c7191d050 Add compress utility 2024-01-04 13:03:19 +01:00
Sleepy Monax 78130b4f7f Don't crash when plugins fail to load. 2024-01-04 13:03:19 +01:00
Sleepy Monax 3e8c2f94ff Update documentation 2024-01-04 13:03:19 +01:00
Sleepy Monax 00e35a2d0f Make sure cins and cdefs are always in the same order 2024-01-04 13:03:19 +01:00
Sleepy Monax f810003ab9 Use RuntimeError 2024-01-02 15:25:18 +01:00
Sleepy Monax 83a57b77eb Added build-essential to default pod configurations. 2024-01-02 14:19:33 +01:00
Sleepy Monax da5a11932f Fix typing issue and added .vscode to gitignore 2024-01-02 10:17:04 +01:00
Sleepy Monax 0dd584f881 Allow overriding the debbuger 2023-12-24 12:41:46 +01:00
Sleepy Monax ecdae7903e Fix package-data names 2023-12-16 20:57:54 +01:00
Sleepy Monax d6cff3bcbc Adjusted build directory layout to avoid comflict with generated sources 2023-12-16 20:57:32 +01:00
Sleepy Monax 0dac774fb8 Fix how we detect how injectable are enabled 2023-12-13 22:23:34 +01:00
Sleepy Monax 0bbced54c4 Allow target to overide rules. 2023-12-13 17:37:19 +01:00
Sleepy Monax f50a571afa Fix component injection 2023-12-12 21:08:52 +01:00
Jordan ⌨️ 2b452c744a fix(rule): compileObj denies user to add custom languages 2023-12-09 19:54:50 +01:00
Sleepy Monax ea0e5613e8 Update 2023-12-09 13:18:48 +01:00
Sleepy Monax cc88242cef Install plugin requirements in pod 2023-12-05 10:39:36 +01:00
Sleepy Monax 6c0f6d5bd5 Added support for nested commands and running cutekit in containers. 2023-11-30 16:40:59 +01:00
Sleepy Monax 8a9c4689e9 ci: Updated to checkout@v3 2023-11-28 10:17:20 +01:00
Sleepy Monax f3e7727796 Bring back build all when passing no components. 2023-11-28 10:17:20 +01:00
Sleepy Monax a76f4921e4 Require python 3.11 2023-11-28 10:17:20 +01:00
Sleepy Monax c136dd8e1a Use relative path when possible 2023-11-28 10:17:20 +01:00
Jordan ⌨️ fafcfbca48 feat: Plugins system allows __init__.py for relative imports 2023-11-28 10:01:29 +01:00
Jordan ⌨️ d8b7a1630c cleanup: remove unused assignation and if stmt
the function doesn't return anything (see function signature)
2023-11-28 10:01:29 +01:00
29 changed files with 2163 additions and 375 deletions

View file

@ -13,12 +13,12 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v2 uses: actions/checkout@v3
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v3 uses: actions/setup-python@v3
with: with:
python-version: '3.x' python-version: '3.11'
- name: Install dependencies - name: Install dependencies
run: | run: |

View file

@ -20,11 +20,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - name: Checkout repository
uses: actions/checkout@v3
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v3 uses: actions/setup-python@v3
with: with:
python-version: '3.x' python-version: '3.11'
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip

1
.gitignore vendored
View file

@ -127,3 +127,4 @@ dmypy.json
# Pyre type checker # Pyre type checker
.pyre/ .pyre/
.vscode/

View file

@ -1,66 +0,0 @@
<br/>
<br/>
<br/>
<p align="center">
<img src="logo.png" width="200" height="200">
</p>
<h1 align="center">CuteKit</h1>
<p align="center">
The *magical* build system and package manager
</p>
<br/>
<br/>
<br/>
## Table of contents
- [Table of contents](#table-of-contents)
- [Introduction](#introduction)
- [Quick-start](#quick-start)
- [Example](#example)
## Introduction
**CuteKit** is a simple - yet - powerful build system and package manager for C and C++. It:
- ✨ It uses **JSON**: Cutekit uses JSON instead of introducing a whole new programming language for describing the project. And also has macros to help the user experience (see [Jexpr](doc/spec/jexpr.md)).
- ✨ It's a **package manager**: Cutekit package manager is based on **Git**, nothing is centralized.
- ✨ It's **extendible**: Cutekit can be [extended](./doc/extends.md) by writing custom Python plugins.
- ✨ It's **easy**: the [**templates**](./doc/templates.md) help the user quick-start a project.
- ✨ It's **portable**: Cutekit can run on MacOS Gnu/Linux and Windows.
## Installation
To install Cutekit, you may use your favourite package manager if it is available. Or you can install it manually by following the instructions below.
```bash
$ git clone https://github.com/cute-engineering/cutekit
$ cd cutekit
# If you want to use the latest version of Cutekit, you can switch to the dev branch.
# $ git switch dev
$ pip install --user -e .
```
## Quick-start
-> If you directly want to start using Cutekit for a new project, you can just run `$ ck I host` and it will create a new project in the host directory (you can rename it later).
-> If you want to use Cutekit for writing operating systems, you can create a new [limine](https://github.com/limine-bootloader/limine/)-based project by running `$ ck I limine-barebone`.
## Example
If you want to see how it works you can read the [doc/cutekit.md](doc/cutekit.md) file.
# License
<a href="https://opensource.org/licenses/MIT">
<img align="right" height="96" alt="MIT License" src="doc/mit.svg" />
</a>
Cutekit is licensed under the **MIT License**.
The full text of the license can be accessed via [this link](https://opensource.org/licenses/MIT) and is also included in the [license.md](license.md) file of this software package.

View file

@ -9,6 +9,8 @@ from . import (
graph, # noqa: F401 this is imported for side effects graph, # noqa: F401 this is imported for side effects
model, model,
plugins, plugins,
pods, # noqa: F401 this is imported for side effects
shell,
vt100, vt100,
) )
@ -39,10 +41,7 @@ def setupLogger(verbose: bool):
if projectRoot is not None: if projectRoot is not None:
logFile = os.path.join(projectRoot.dirname(), const.PROJECT_LOG_FILE) logFile = os.path.join(projectRoot.dirname(), const.PROJECT_LOG_FILE)
# create the directory if it doesn't exist shell.mkdir(os.path.dirname(logFile))
logDir = os.path.dirname(logFile)
if not os.path.isdir(logDir):
os.makedirs(logDir)
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@ -55,18 +54,22 @@ def setupLogger(verbose: bool):
def main() -> int: def main() -> int:
try: try:
a = cli.parse(sys.argv[1:]) shell.mkdir(const.GLOBAL_CK_DIR)
setupLogger(a.consumeOpt("verbose", False) is True) extraArgs = os.environ.get("CK_EXTRA_ARGS", None)
plugins.loadAll() args = cli.parse((extraArgs.split(" ") if extraArgs else []) + sys.argv[1:])
cli.exec(a) setupLogger(args.consumeOpt("verbose", False) is True)
print()
const.setup()
plugins.setup(args)
pods.setup(args)
cli.exec(args)
return 0 return 0
except RuntimeError as e: except RuntimeError as e:
logging.exception(e) logging.exception(e)
cli.error(str(e)) cli.error(str(e))
cli.usage() cli.usage()
print()
return 1
except KeyboardInterrupt: except KeyboardInterrupt:
print() print()
return 1
return 1

86
cutekit/bootstrap.sh Normal file
View file

@ -0,0 +1,86 @@
#!/usr/bin/env bash
# This script is meant to be place at the root of any cutekit project.
# It will make sure that the virtual environment is set up and that the
# plugins requirements are installed.
set -e
if [ -z "$CUTEKIT_PYTHON" ]; then
export CUTEKIT_PYTHON="python3.11"
fi
if [ -z "$CUTEKIT_VERSION" ]; then
export CUTEKIT_VERSION="0.7-dev"
fi
if [ -n "$CUTEKIT_NOVENV" ]; then
echo "CUTEKIT_NOVENV is set, skipping virtual environment setup."
exec cutekit $@
exit $?
fi
if [ "$1" == "tools" -a "$2" == "nuke" ]; then
rm -rf .cutekit/tools .cutekit/venv
exit 0
fi
if [ ! -f .cutekit/tools/ready ]; then
if [ ! \( "$1" == "tools" -a "$2" == "setup" \) ]; then
echo "CuteKit is not installed."
echo "This script will install cutekit into $PWD/.cutekit"
read -p "Do you want to continue? [Y/n] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborting."
exit 1
fi
else
echo "Installing CuteKit..."
fi
mkdir -p .cutekit
if [ ! -d .cutekit/venv ]; then
echo "Setting up Python virtual environment..."
$CUTEKIT_PYTHON -m venv .cutekit/venv
fi
source .cutekit/venv/bin/activate
echo "Downloading CuteKit..."
if [ ! -d .cutekit/tools/cutekit ]; then
git clone --depth 1 https://github.com/cute-engineering/cutekit .cutekit/tools/cutekit --branch "$CUTEKIT_VERSION"
else
echo "CuteKit already downloaded."
fi
echo "Installing Tools..."
$CUTEKIT_PYTHON -m pip install -e .cutekit/tools/cutekit
echo "Installing plugins requirements..."
if [ -f "meta/plugins/requirements.txt" ]; then
echo "Root plugin requirements found."
$CUTEKIT_PYTHON -m pip install -r meta/plugins/requirements.txt
fi
for extern in meta/externs/*; do
if [ -f "$extern/meta/plugins/requirements.txt" ]; then
echo "Plugin requirements found in $extern."
$CUTEKIT_PYTHON -m pip install -r "$extern/meta/plugins/requirements.txt"
fi
done
touch .cutekit/tools/ready
echo "Done!"
fi
if [ "$1" == "tools" -a "$2" == "setup" ]; then
echo "Tools already installed."
exit 0
fi
source .cutekit/venv/bin/activate
export PATH="$PATH:.cutekit/venv/bin"
$CUTEKIT_PYTHON -m cutekit $@

View file

@ -3,32 +3,121 @@ import logging
import dataclasses as dt import dataclasses as dt
from pathlib import Path from pathlib import Path
from typing import TextIO, Union import sys
from typing import Callable, Literal, TextIO, Union
from . import shell, rules, model, ninja, const, cli from . import shell, rules, model, ninja, const, cli, vt100
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
def aggregateCincs(target: model.Target, registry: model.Registry) -> set[str]: @dt.dataclass
class Scope:
registry: model.Registry
@staticmethod
def use(args: cli.Args, props: model.Props = {}) -> "Scope":
registry = model.Registry.use(args, props)
return Scope(registry)
def key(self) -> str:
return self.registry.project.id
def openTargetScope(self, t: model.Target):
return TargetScope(self.registry, t)
@dt.dataclass
class TargetScope(Scope):
registry: model.Registry
target: model.Target
@staticmethod
def use(args: cli.Args, props: model.Props = {}) -> "TargetScope":
registry = model.Registry.use(args, props)
target = model.Target.use(args, props)
return TargetScope(registry, target)
def key(self) -> str:
return super().key() + "/" + self.target.id + "/" + self.target.hashid
def openComponentScope(self, c: model.Component):
return ComponentScope(self.registry, self.target, c)
@dt.dataclass
class ComponentScope(TargetScope):
component: model.Component
def key(self) -> str:
return super().key() + "/" + self.component.id
def openComponentScope(self, c: model.Component):
return ComponentScope(self.registry, self.target, c)
def openProductScope(self, path: Path):
return ProductScope(self.registry, self.target, self.component, path)
@dt.dataclass
class ProductScope(ComponentScope):
path: Path
# --- Variables -------------------------------------------------------------- #
Compute = Callable[[TargetScope], list[str]]
_vars: dict[str, Compute] = {}
def var(name: str) -> Callable[[Compute], Compute]:
def decorator(func: Compute):
_vars[name] = func
return func
return decorator
@var("builddir")
def _computeBuilddir(scope: TargetScope) -> list[str]:
"""
This variable is needed by ninja to know where to put
the .ninja_log file.
"""
return [scope.target.builddir]
@var("hashid")
def _computeHashid(scope: TargetScope) -> list[str]:
return [scope.target.hashid]
@var("cincs")
def _computeCinc(scope: TargetScope) -> list[str]:
res = set() res = set()
for c in registry.iterEnabled(target): for c in scope.registry.iterEnabled(scope.target):
if "cpp-root-include" in c.props: if "cpp-root-include" in c.props:
res.add(c.dirname()) res.add(c.dirname())
elif "cpp-excluded" in c.props:
pass
elif c.type == model.Kind.LIB: elif c.type == model.Kind.LIB:
res.add(str(Path(c.dirname()).parent)) res.add(str(Path(c.dirname()).parent))
return set(map(lambda i: f"-I{i}", res)) return sorted(map(lambda i: f"-I{i}", res))
def aggregateCdefs(target: model.Target) -> set[str]: @var("cdefs")
def _computeCdef(scope: TargetScope) -> list[str]:
res = set() res = set()
def sanatize(s: str) -> str: def sanatize(s: str) -> str:
return s.lower().replace(" ", "_").replace("-", "_").replace(".", "_") TO_REPLACE = [" ", "-", "."] # -> "_"
for r in TO_REPLACE:
s = s.replace(r, "_")
return "".join(filter(lambda c: c.isalnum() or c == "_", s))
for k, v in target.props.items(): for k, v in scope.target.props.items():
if isinstance(v, bool): if isinstance(v, bool):
if v: if v:
res.add(f"-D__ck_{sanatize(k)}__") res.add(f"-D__ck_{sanatize(k)}__")
@ -36,40 +125,60 @@ def aggregateCdefs(target: model.Target) -> set[str]:
res.add(f"-D__ck_{sanatize(k)}_{sanatize(str(v))}__") res.add(f"-D__ck_{sanatize(k)}_{sanatize(str(v))}__")
res.add(f"-D__ck_{sanatize(k)}_value={str(v)}") res.add(f"-D__ck_{sanatize(k)}_value={str(v)}")
return res return sorted(res)
def buildpath(target: model.Target, component: model.Component, path) -> Path: def buildpath(scope: ComponentScope, path) -> Path:
return Path(target.builddir) / component.id / path return Path(scope.target.builddir) / scope.component.id / path
# --- Compilation ------------------------------------------------------------ # # --- Compilation ------------------------------------------------------------ #
def wilcard(component: model.Component, wildcards: list[str]) -> list[str]: def subdirs(scope: ComponentScope) -> list[str]:
dirs = [component.dirname()] + list( component = scope.component
map(lambda d: os.path.join(component.dirname(), d), component.subdirs) result = [component.dirname()]
)
return shell.find(dirs, list(wildcards), recusive=False) for subs in component.subdirs:
result.append(os.path.join(component.dirname(), subs))
return result
def wilcard(scope: ComponentScope, wildcards: list[str]) -> list[str]:
return shell.find(subdirs(scope), list(wildcards), recusive=False)
def compile( def compile(
w: ninja.Writer, w: ninja.Writer, scope: ComponentScope, rule: str, srcs: list[str]
target: model.Target,
component: model.Component,
rule: str,
srcs: list[str],
) -> list[str]: ) -> list[str]:
res: list[str] = [] res: list[str] = []
for src in srcs: for src in srcs:
rel = Path(src).relative_to(component.dirname()) rel = Path(src).relative_to(scope.component.dirname())
dest = buildpath(target, component, "obj") / rel.with_suffix(".o") dest = buildpath(scope, path="__obj__") / rel.with_suffix(rel.suffix + ".o")
t = target.tools[rule] t = scope.target.tools[rule]
w.build(str(dest), rule, inputs=src, order_only=t.files) w.build(
str(dest),
rule,
inputs=src,
order_only=t.files,
variables={
"ck_target": scope.target.id,
"ck_component": scope.component.id,
},
)
res.append(str(dest)) res.append(str(dest))
return res return res
def compileObjs(w: ninja.Writer, scope: ComponentScope) -> list[str]:
objs = []
for rule in rules.rules.values():
if rule.id not in ["cp", "ld", "ar"]:
objs += compile(w, scope, rule.id, wilcard(scope, rule.fileIn))
return objs
# --- Ressources ------------------------------------------------------------- # # --- Ressources ------------------------------------------------------------- #
@ -79,14 +188,21 @@ def listRes(component: model.Component) -> list[str]:
def compileRes( def compileRes(
w: ninja.Writer, w: ninja.Writer,
target: model.Target, scope: ComponentScope,
component: model.Component,
) -> list[str]: ) -> list[str]:
res: list[str] = [] res: list[str] = []
for r in listRes(component): for r in listRes(scope.component):
rel = Path(r).relative_to(component.subpath("res")) rel = Path(r).relative_to(scope.component.subpath("res"))
dest = buildpath(target, component, "res") / rel dest = buildpath(scope, "__res__") / rel
w.build(str(dest), "cp", r) w.build(
str(dest),
"cp",
r,
variables={
"ck_target": scope.target.id,
"ck_component": scope.component.id,
},
)
res.append(str(dest)) res.append(str(dest))
return res return res
@ -94,204 +210,252 @@ def compileRes(
# --- Linking ---------------------------------------------------------------- # # --- Linking ---------------------------------------------------------------- #
def outfile(target: model.Target, component: model.Component) -> str: def outfile(scope: ComponentScope) -> str:
if component.type == model.Kind.LIB: if scope.component.type == model.Kind.LIB:
return str(buildpath(target, component, f"lib/{component.id}.a")) return str(buildpath(scope, f"__lib__/{scope.component.id}.a"))
else: else:
return str(buildpath(target, component, f"bin/{component.id}.out")) return str(buildpath(scope, f"__bin__/{scope.component.id}.out"))
def collectLibs( def collectLibs(
registry: model.Registry, target: model.Target, component: model.Component scope: ComponentScope,
) -> list[str]: ) -> list[str]:
res: list[str] = [] res: list[str] = []
for r in component.resolved[target.id].resolved: for r in scope.component.resolved[scope.target.id].required:
req = registry.lookup(r, model.Component) req = scope.registry.lookup(r, model.Component)
assert req is not None # model.Resolver has already checked this assert req is not None # model.Resolver has already checked this
if r == component.id: if r == scope.component.id:
continue continue
if not req.type == model.Kind.LIB: if not req.type == model.Kind.LIB:
raise RuntimeError(f"Component {r} is not a library") raise RuntimeError(f"Component {r} is not a library")
res.append(outfile(target, req)) res.append(outfile(scope.openComponentScope(req)))
return res
def collectInjectedLibs(scope: ComponentScope) -> list[str]:
res: list[str] = []
for r in scope.component.resolved[scope.target.id].injected:
req = scope.registry.lookup(r, model.Component)
assert req is not None # model.Resolver has already checked this
if r == scope.component.id:
continue
if not req.type == model.Kind.LIB:
raise RuntimeError(f"Component {r} is not a library")
res.append(outfile(scope.openComponentScope(req)))
return res return res
def link( def link(
w: ninja.Writer, w: ninja.Writer,
registry: model.Registry, scope: ComponentScope,
target: model.Target,
component: model.Component,
) -> str: ) -> str:
w.newline() w.newline()
out = outfile(target, component) out = outfile(scope)
objs = [] res = compileRes(w, scope)
objs += compile(w, target, component, "cc", wilcard(component, ["*.c"])) objs = compileObjs(w, scope)
objs += compile( if scope.component.type == model.Kind.LIB:
w, target, component, "cxx", wilcard(component, ["*.cpp", "*.cc", "*.cxx"]) w.build(
) out,
objs += compile( "ar",
w, target, component, "as", wilcard(component, ["*.s", "*.asm", "*.S"]) objs,
) implicit=res,
variables={
res = compileRes(w, target, component) "ck_target": scope.target.id,
libs = collectLibs(registry, target, component) "ck_component": scope.component.id,
if component.type == model.Kind.LIB: },
w.build(out, "ar", objs, implicit=res) )
else: else:
w.build(out, "ld", objs + libs, implicit=res) wholeLibs = collectInjectedLibs(scope)
libs = collectLibs(scope)
w.build(
out,
"ld",
objs + wholeLibs + libs,
variables={
"objs": " ".join(objs),
"wholeLibs": " ".join(wholeLibs),
"libs": " ".join(libs),
"ck_target": scope.target.id,
"ck_component": scope.component.id,
},
implicit=res,
)
return out return out
# --- Phony ------------------------------------------------------------------ # # --- Phony ------------------------------------------------------------------ #
def all(w: ninja.Writer, registry: model.Registry, target: model.Target) -> list[str]: def all(w: ninja.Writer, scope: TargetScope) -> list[str]:
all: list[str] = [] all: list[str] = []
for c in registry.iterEnabled(target): for c in scope.registry.iterEnabled(scope.target):
all.append(link(w, registry, target, c)) all.append(link(w, scope.openComponentScope(c)))
w.build("all", "phony", all) w.build("all", "phony", all)
w.default("all") w.default("all")
return all return all
def gen(out: TextIO, target: model.Target, registry: model.Registry): def applyExtraProps(scope: TargetScope, name: str, var: list[str]) -> list[str]:
target: model.Target = scope.target
extra = target.props.get(f"ck-{name}-extra", None)
if extra:
var += extra.split(" ")
override = target.props.get(f"ck-{name}-override")
if override:
var = override.split(" ")
return var
def gen(out: TextIO, scope: TargetScope):
w = ninja.Writer(out) w = ninja.Writer(out)
target: model.Target = scope.target
w.comment("File generated by the build system, do not edit") w.comment("File generated by the build system, do not edit")
w.newline() w.newline()
w.variable("builddir", target.builddir) w.separator("Variables")
w.variable("hashid", target.hashid) for name, compute in _vars.items():
w.variable(name, applyExtraProps(scope, name, compute(scope)))
w.newline()
w.separator("Tools") w.separator("Tools")
w.variable("cincs", " ".join(aggregateCincs(target, registry))) for i in scope.target.tools:
w.variable("cdefs", " ".join(aggregateCdefs(target))) tool = scope.target.tools[i]
w.newline()
for i in target.tools:
tool = target.tools[i]
rule = rules.rules[i] rule = rules.rules[i]
w.variable(i, tool.cmd) w.variable(i, tool.cmd)
w.variable(i + "flags", " ".join(rule.args + tool.args)) w.variable(
i + "flags",
" ".join(applyExtraProps(scope, i + "flags", rule.args + tool.args)),
)
w.rule( w.rule(
i, i,
f"{tool.cmd} {rule.rule.replace('$flags',f'${i}flags')}", f"{tool.cmd} {(tool.rule or rule.rule).replace('$flags',f'${i}flags')}",
description=f"{vt100.BLUE}$ck_target{vt100.RESET}/{vt100.CYAN}$ck_component{vt100.RESET}: {vt100.YELLOW}{i} {vt100.FAINT + vt100.WHITE}$out...{vt100.RESET}",
depfile=rule.deps, depfile=rule.deps,
) )
w.newline() w.newline()
w.separator("Build") w.separator("Build")
all(w, registry, target) all(w, scope)
@dt.dataclass
class Product:
path: Path
target: model.Target
component: model.Component
def build( def build(
target: model.Target, scope: TargetScope,
registry: model.Registry, components: Union[list[model.Component], model.Component, Literal["all"]] = "all",
components: Union[list[model.Component], model.Component, None] = None, ) -> list[ProductScope]:
) -> list[Product]:
all = False all = False
shell.mkdir(target.builddir) shell.mkdir(scope.target.builddir)
ninjaPath = os.path.join(target.builddir, "build.ninja") ninjaPath = os.path.join(scope.target.builddir, "build.ninja")
with open(ninjaPath, "w") as f:
gen(f, target, registry)
if components is None: with open(ninjaPath, "w") as f:
gen(f, scope)
if components == "all":
all = True all = True
components = list(registry.iterEnabled(target)) components = list(scope.registry.iterEnabled(scope.target))
if isinstance(components, model.Component): if isinstance(components, model.Component):
components = [components] components = [components]
products: list[Product] = [] products: list[ProductScope] = []
for c in components: for c in components:
r = c.resolved[target.id] s = scope.openComponentScope(c)
r = c.resolved[scope.target.id]
if not r.enabled: if not r.enabled:
raise RuntimeError(f"Component {c.id} is disabled: {r.reason}") raise RuntimeError(f"Component {c.id} is disabled: {r.reason}")
products.append( products.append(s.openProductScope(Path(outfile(scope.openComponentScope(c)))))
Product(
path=Path(outfile(target, c)),
target=target,
component=c,
)
)
outs = list(map(lambda p: str(p.path), products)) outs = list(map(lambda p: str(p.path), products))
if all:
shell.exec("ninja", "-f", ninjaPath) shell.exec("ninja", "-f", ninjaPath, *(outs if not all else []))
else:
shell.exec("ninja", "-f", ninjaPath, *outs)
return products return products
# --- Commands --------------------------------------------------------------- # # --- Commands --------------------------------------------------------------- #
@cli.command("b", "build", "Build a component or all components") @cli.command("b", "builder", "Build/Run/Clean a component or all components")
def buildCmd(args: cli.Args): def _(args: cli.Args):
registry = model.Registry.use(args) pass
target = model.Target.use(args)
@cli.command("b", "builder/build", "Build a component or all components")
def _(args: cli.Args):
scope = TargetScope.use(args)
componentSpec = args.consumeArg() componentSpec = args.consumeArg()
if componentSpec is None: component = None
raise RuntimeError("No component specified") if componentSpec is not None:
component = registry.lookup(componentSpec, model.Component) component = scope.registry.lookup(componentSpec, model.Component)
build(target, registry, component)[0] build(scope, component if component is not None else "all")[0]
@cli.command("r", "run", "Run a component") @cli.command("r", "builder/run", "Run a component")
def runCmd(args: cli.Args): def runCmd(args: cli.Args):
registry = model.Registry.use(args)
target = model.Target.use(args)
debug = args.consumeOpt("debug", False) is True debug = args.consumeOpt("debug", False) is True
wait = args.consumeOpt("wait", False) is True
debugger = str(args.consumeOpt("debugger", "lldb"))
profile = args.consumeOpt("profile", False) is True
what = str(args.consumeOpt("what", "cpu"))
rate = int(args.consumeOpt("rate", 1000))
componentSpec = args.consumeArg() or "__main__" componentSpec = args.consumeArg() or "__main__"
component = registry.lookup(componentSpec, model.Component, includeProvides=True) scope = TargetScope.use(args, {"debug": debug})
component = scope.registry.lookup(
componentSpec, model.Component, includeProvides=True
)
if component is None: if component is None:
raise RuntimeError(f"Component {componentSpec} not found") raise RuntimeError(f"Component {componentSpec} not found")
product = build(target, registry, component)[0] product = build(scope, component)[0]
os.environ["CK_TARGET"] = target.id os.environ["CK_TARGET"] = product.target.id
os.environ["CK_BUILDDIR"] = product.target.builddir
os.environ["CK_COMPONENT"] = product.component.id os.environ["CK_COMPONENT"] = product.component.id
os.environ["CK_BUILDDIR"] = target.builddir
shell.exec(*(["lldb", "-o", "run"] if debug else []), str(product.path), *args.args) command = [str(product.path), *args.extra]
if debug:
shell.debug(command, debugger=debugger, wait=wait)
elif profile:
shell.profile(command, what=what, rate=rate)
else:
shell.exec(*command)
@cli.command("t", "test", "Run all test targets") @cli.command("t", "builder/test", "Run all test targets")
def testCmd(args: cli.Args): def _(args: cli.Args):
# This is just a wrapper around the `run` command that try # This is just a wrapper around the `run` command that try
# to run a special hook component named __tests__. # to run a special hook component named __tests__.
args.args.insert(0, "__tests__") args.args.insert(0, "__tests__")
runCmd(args) runCmd(args)
@cli.command("d", "debug", "Debug a component") @cli.command("d", "builder/debug", "Debug a component")
def debugCmd(args: cli.Args): def _(args: cli.Args):
# This is just a wrapper around the `run` command that # This is just a wrapper around the `run` command that
# always enable debug mode. # always enable debug mode.
args.opts["debug"] = True args.opts["debug"] = True
runCmd(args) runCmd(args)
@cli.command("c", "clean", "Clean build files") @cli.command("c", "builder/clean", "Clean build files")
def cleanCmd(args: cli.Args): def _(args: cli.Args):
model.Project.use(args) model.Project.use(args)
shell.rmrf(const.BUILD_DIR) shell.rmrf(const.BUILD_DIR)
@cli.command("n", "nuke", "Clean all build files and caches") @cli.command("n", "builder/nuke", "Clean all build files and caches")
def nukeCmd(args: cli.Args): def _(args: cli.Args):
model.Project.use(args) model.Project.use(args)
shell.rmrf(const.PROJECT_CK_DIR) shell.rmrf(const.PROJECT_CK_DIR)

View file

@ -16,10 +16,12 @@ _logger = logging.getLogger(__name__)
class Args: class Args:
opts: dict[str, Value] opts: dict[str, Value]
args: list[str] args: list[str]
extra: list[str]
def __init__(self): def __init__(self):
self.opts = {} self.opts = {}
self.args = [] self.args = []
self.extra = []
def consumePrefix(self, prefix: str) -> dict[str, Value]: def consumePrefix(self, prefix: str) -> dict[str, Value]:
result: dict[str, Value] = {} result: dict[str, Value] = {}
@ -56,13 +58,17 @@ class Args:
def parse(args: list[str]) -> Args: def parse(args: list[str]) -> Args:
result = Args() result = Args()
for arg in args: for i in range(len(args)):
if arg.startswith("--"): arg = args[i]
if arg.startswith("--") and not arg == "--":
if "=" in arg: if "=" in arg:
key, value = arg[2:].split("=", 1) key, value = arg[2:].split("=", 1)
result.opts[key] = value result.opts[key] = value
else: else:
result.opts[arg[2:]] = True result.opts[arg[2:]] = True
elif arg == "--":
result.extra += args[i + 1 :]
break
else: else:
result.args.append(arg) result.args.append(arg)
@ -80,8 +86,10 @@ class Command:
isPlugin: bool isPlugin: bool
callback: Callback callback: Callback
subcommands: dict[str, "Command"] = dt.field(default_factory=dict)
commands: list[Command] = []
commands: dict[str, Command] = {}
def command(shortName: Optional[str], longName: str, helpText: str): def command(shortName: Optional[str], longName: str, helpText: str):
@ -90,15 +98,18 @@ def command(shortName: Optional[str], longName: str, helpText: str):
def wrap(fn: Callable[[Args], None]): def wrap(fn: Callable[[Args], None]):
_logger.debug(f"Registering command {longName}") _logger.debug(f"Registering command {longName}")
commands.append( path = longName.split("/")
Command( parent = commands
shortName, for p in path[:-1]:
longName, parent = parent[p].subcommands
helpText, parent[path[-1]] = Command(
Path(calframe[1].filename).parent != Path(__file__).parent, shortName,
fn, path[-1],
) helpText,
Path(calframe[1].filename).parent != Path(__file__).parent,
fn,
) )
return fn return fn
return wrap return wrap
@ -116,6 +127,28 @@ def error(msg: str) -> None:
print(f"{vt100.RED}Error:{vt100.RESET} {msg}\n", file=sys.stderr) print(f"{vt100.RED}Error:{vt100.RESET} {msg}\n", file=sys.stderr)
def warning(msg: str) -> None:
print(f"{vt100.YELLOW}Warning:{vt100.RESET} {msg}\n", file=sys.stderr)
def ask(msg: str, default: Optional[bool] = None) -> bool:
if default is None:
msg = f"{msg} [y/n] "
elif default:
msg = f"{msg} [Y/n] "
else:
msg = f"{msg} [y/N] "
while True:
result = input(msg).lower()
if result in ("y", "yes"):
return True
elif result in ("n", "no"):
return False
elif result == "" and default is not None:
return default
@command("h", "help", "Show this help message") @command("h", "help", "Show this help message")
def helpCmd(args: Args): def helpCmd(args: Args):
usage() usage()
@ -127,8 +160,8 @@ def helpCmd(args: Args):
print() print()
vt100.title("Commands") vt100.title("Commands")
for cmd in sorted(commands, key=lambda c: c.longName): for cmd in sorted(commands.values(), key=lambda c: c.longName):
if cmd.longName.startswith("_"): if cmd.longName.startswith("_") or len(cmd.subcommands) > 0:
continue continue
pluginText = "" pluginText = ""
@ -139,6 +172,21 @@ def helpCmd(args: Args):
f" {vt100.GREEN}{cmd.shortName or ' '}{vt100.RESET} {cmd.longName} - {cmd.helpText} {pluginText}" f" {vt100.GREEN}{cmd.shortName or ' '}{vt100.RESET} {cmd.longName} - {cmd.helpText} {pluginText}"
) )
for cmd in sorted(commands.values(), key=lambda c: c.longName):
if cmd.longName.startswith("_") or len(cmd.subcommands) == 0:
continue
print()
vt100.title(f"{cmd.longName.capitalize()} - {cmd.helpText}")
for subcmd in sorted(cmd.subcommands.values(), key=lambda c: c.longName):
pluginText = ""
if subcmd.isPlugin:
pluginText = f"{vt100.CYAN}(plugin){vt100.RESET}"
print(
f" {vt100.GREEN}{subcmd.shortName or ' '}{vt100.RESET} {subcmd.longName} - {subcmd.helpText} {pluginText}"
)
print() print()
vt100.title("Logging") vt100.title("Logging")
print(" Logs are stored in:") print(" Logs are stored in:")
@ -151,15 +199,19 @@ def versionCmd(args: Args):
print(f"CuteKit v{const.VERSION_STR}") print(f"CuteKit v{const.VERSION_STR}")
def exec(args: Args): def exec(args: Args, cmds=commands):
cmd = args.consumeArg() cmd = args.consumeArg()
if cmd is None: if cmd is None:
raise RuntimeError("No command specified") raise RuntimeError("No command specified")
for c in commands: for c in cmds.values():
if c.shortName == cmd or c.longName == cmd: if c.shortName == cmd or c.longName == cmd:
c.callback(args) if len(c.subcommands) > 0:
return exec(args, c.subcommands)
return
else:
c.callback(args)
return
raise RuntimeError(f"Unknown command {cmd}") raise RuntimeError(f"Unknown command {cmd}")

572
cutekit/cli2.py Normal file
View file

@ -0,0 +1,572 @@
import sys
from enum import Enum
import typing as tp
import dataclasses as dt
from typing import Any, Callable, Optional
T = tp.TypeVar("T")
# --- Scan -------------------------------------------------------------- #
class Scan:
_src: str
_off: int
_save: list[int]
def __init__(self, src: str, off: int = 0):
self._src = src
self._off = 0
self._save = []
def curr(self) -> str:
if self.eof():
return "\0"
return self._src[self._off]
def next(self) -> str:
if self.eof():
return "\0"
self._off += 1
return self.curr()
def peek(self, off: int = 1) -> str:
if self._off + off >= len(self._src):
return "\0"
return self._src[self._off + off]
def eof(self) -> bool:
return self._off >= len(self._src)
def skipStr(self, s: str) -> bool:
if self._src[self._off :].startswith(s):
self._off += len(s)
return True
return False
def isStr(self, s: str) -> bool:
self.save()
if self.skipStr(s):
self.restore()
return True
self.restore()
return False
def save(self) -> None:
self._save.append(self._off)
def restore(self) -> None:
self._off = self._save.pop()
def skipWhitespace(self) -> bool:
result = False
while not self.eof() and self.curr().isspace():
self.next()
result = True
return result
def skipSeparator(self, sep: str) -> bool:
self.save()
self.skipWhitespace()
if self.skipStr(sep):
self.skipWhitespace()
return True
self.restore()
return False
def isSeparator(self, sep: str) -> bool:
self.save()
self.skipWhitespace()
if self.skipStr(sep):
self.skipWhitespace()
self.restore()
return True
self.restore()
return False
def skipKeyword(self, keyword: str) -> bool:
self.save()
self.skipWhitespace()
if self.skipStr(keyword) and not self.curr().isalnum():
return True
self.restore()
return False
def isKeyword(self, keyword: str) -> bool:
self.save()
self.skipWhitespace()
if self.skipStr(keyword) and not self.curr().isalnum():
self.restore()
return True
self.restore()
return False
# --- Parser ------------------------------------------------------------ #
PrimitiveValue = str | bool | int
Object = dict[str, PrimitiveValue]
List = list[PrimitiveValue]
Value = str | bool | int | Object | List
@dt.dataclass
class Token:
pass
@dt.dataclass
class ArgumentToken(Token):
key: str
subkey: Optional[str]
value: Value
short: bool
@dt.dataclass
class OperandToken(Token):
value: str
@dt.dataclass
class ExtraToken(Token):
args: list[str]
def _parseIdent(s: Scan) -> str:
res = ""
while not s.eof() and (s.curr().isalnum() or s.curr() in "_-+"):
res += s.curr()
s.next()
return res
def _parseUntilComma(s: Scan) -> str:
res = ""
while not s.eof() and s.curr() != ",":
res += s.curr()
s.next()
return res
def _expectIdent(s: Scan) -> str:
res = _parseIdent(s)
if len(res) == 0:
raise RuntimeError("Expected identifier")
return res
def _parseString(s: Scan, quote: str) -> str:
s.skipStr(quote)
res = ""
escaped = False
while not s.eof():
c = s.curr()
if escaped:
res += c
escaped = False
elif c == "\\":
escaped = True
elif c == quote:
break
else:
res += c
s.next()
if not s.skipStr(quote):
raise RuntimeError("Unterminated string")
return res
def _tryParseInt(ident) -> Optional[int]:
try:
return int(ident)
except ValueError:
return None
def _parsePrimitive(s: Scan) -> PrimitiveValue:
if s.curr() == '"':
return _parseString(s, '"')
elif s.curr() == "'":
return _parseString(s, "'")
else:
ident = _parseUntilComma(s)
if ident in ("true", "True", "y", "yes", "Y", "Yes"):
return True
elif ident in ("false", "False", "n", "no", "N", "No"):
return False
elif n := _tryParseInt(ident):
return n
else:
return ident
def _parseValue(s: Scan) -> Value:
lhs = _parsePrimitive(s)
if s.eof():
return lhs
values: List = [lhs]
while not s.eof() and s.skipStr(","):
values.append(_parsePrimitive(s))
return values
def parseValue(s: str) -> Value:
return _parseValue(Scan(s))
def parseArg(arg: str) -> list[Token]:
s = Scan(arg)
if s.skipStr("--"):
key = _expectIdent(s)
subkey = ""
if s.skipStr(":"):
subkey = _expectIdent(s)
if s.skipStr("="):
value = _parseValue(s)
else:
value = True
return [ArgumentToken(key, subkey, value, False)]
elif s.skipStr("-"):
res = []
while not s.eof():
key = s.curr()
if not key.isalnum():
raise RuntimeError("Expected alphanumeric")
s.next()
res.append(ArgumentToken(key, None, True, True))
return res
else:
return [OperandToken(arg)]
def parseArgs(args: list[str]) -> list[Token]:
res: list[Token] = []
while len(args) > 0:
arg = args.pop(0)
if arg == "--":
res.append(ExtraToken(args))
break
else:
res.extend(parseArg(arg))
return res
# --- Schema ----------------------------------------------------------------- #
class FieldKind(Enum):
FLAG = 0
OPERAND = 1
EXTRA = 2
@dt.dataclass
class Field:
kind: FieldKind
shortName: Optional[str]
longName: str
description: str = ""
default: Any = None
_fieldName: str | None = dt.field(init=False, default=None)
_fieldType: type | None = dt.field(init=False, default=None)
def bind(self, typ: type, name: str):
self._fieldName = name
self._fieldType = typ.__annotations__[name]
if self.longName is None:
self.longName = name
def defaultValue(self) -> Any:
if self._fieldType is None:
return None
if self.default is not None:
return self.default
if self._fieldType == bool:
return False
elif self._fieldType == int:
return 0
elif self._fieldType == str:
return ""
elif self._fieldType == list:
return []
elif self._fieldType == dict:
return {}
else:
return None
def setDefault(self, obj: Any):
if self._fieldName:
setattr(obj, self._fieldName, self.default)
def putValue(self, obj: Any, value: Any, subkey: Optional[str] = None):
if self._fieldName is None:
raise ValueError("Field type is not defined")
setattr(obj, self._fieldName, value)
def getAttr(self, obj: Any) -> Any:
if self._fieldName is None:
raise ValueError("Field name is not defined")
return getattr(obj, self._fieldName)
def arg(
shortName: str | None = None,
longName: str = "",
description: str = "",
default: Any = None,
) -> Any:
return Field(FieldKind.FLAG, shortName, longName, description, default)
def operand(longName: str = "", description: str = "") -> Any:
return Field(FieldKind.OPERAND, None, longName, description)
def extra(longName: str = "", description: str = "") -> Any:
return Field(FieldKind.EXTRA, None, longName, description)
@dt.dataclass
class Schema:
typ: Optional[type] = None
args: list[Field] = dt.field(default_factory=list)
operands: list[Field] = dt.field(default_factory=list)
extras: Optional[Field] = None
@staticmethod
def extract(typ: type) -> "Schema":
s = Schema(typ)
for f in typ.__annotations__.keys():
field = getattr(typ, f, None)
if field is None:
raise ValueError(f"Field '{f}' is not defined")
if not isinstance(field, Field):
raise ValueError(f"Field '{f}' is not a Field")
field.bind(typ, f)
if field.type == FieldKind.FLAG:
s.args.append(field)
elif field.type == FieldKind.OPERAND:
s.operands.append(field)
elif field.type == FieldKind.EXTRA:
if s.extras:
raise ValueError("Only one extra argument is allowed")
s.extras = field
return s
@staticmethod
def extractFromCallable(fn: tp.Callable) -> "Schema":
typ: type | None = (
None
if len(fn.__annotations__) == 0
else next(iter(fn.__annotations__.values()))
)
if typ is None:
return Schema()
return Schema.extract(typ)
def usage(self) -> str:
res = ""
for arg in self.args:
flag = ""
if arg.shortName:
flag += f"-{arg.shortName}"
if arg.longName:
if flag:
flag += ", "
flag += f"--{arg.longName}"
res += f"[{flag}] "
for operand in self.operands:
res += f"<{operand.longName}> "
if self.extras:
res += f"[-- {self.extras.longName}]"
return res
def _lookupArg(self, key: str, short: bool) -> Field:
for arg in self.args:
if short and arg.shortName == key:
return arg
elif not short and arg.longName == key:
return arg
raise ValueError(f"Unknown argument '{key}'")
def _setOperand(self, tok: OperandToken):
return
def _instanciate(self) -> Any:
if self.typ is None:
return None
res = self.typ()
for arg in self.args:
arg.setDefault(res)
return res
def parse(self, args: list[str]) -> Any:
res = self._instanciate()
if res is None:
if len(args) > 0:
raise ValueError("Unexpected arguments")
else:
return None
stack = args[:]
while len(stack) > 0:
if stack[0] == "--":
if not self.extras:
raise ValueError("Unexpected '--'")
self._setExtra(res, stack.pop(0))
break
toks = parseArg(stack.pop(0))
for tok in toks:
if isinstance(tok, ArgumentToken):
arg = self._lookupArg(tok.key, tok.short)
arg.putValue(res, tok.value, tok.subkey)
elif isinstance(tok, OperandToken):
self._setOperand(tok)
else:
raise ValueError(f"Unexpected token: {type(tok)}")
return res
@dt.dataclass
class Command:
shortName: Optional[str]
longName: str
description: str = ""
schema: Optional[Schema] = None
callable: Optional[tp.Callable] = None
subcommands: dict[str, "Command"] = dt.field(default_factory=dict)
populated: bool = False
def _spliceArgs(self, args: list[str]) -> tuple[list[str], list[str]]:
rest = args[:]
curr = []
if len(self.subcommands) > 0:
while len(rest) > 0 and rest[0].startswith("-") and rest[0] != "--":
curr.append(rest.pop(0))
else:
curr = rest
return curr, rest
def help(self):
pass
def usage(self) -> str:
res = " "
if self.schema:
res += self.schema.usage()
if len(self.subcommands) == 1:
sub = next(iter(self.subcommands.values()))
res += sub.longName + sub.usage()
elif len(self.subcommands) > 0:
res += "{"
first = True
for name, cmd in self.subcommands.items():
if not first:
res += " | "
else:
res += f"{name}"
res += "}"
res += "[args...]"
return res
def eval(self, args: list[str]):
cmd = args.pop(0)
curr, rest = self._spliceArgs(args)
if "-h" in curr or "--help" in curr:
self.help()
return
if "-u" in curr or "--usage" in curr:
print("Usage: " + cmd + self.usage(), end="\n\n")
return
try:
if self.schema and self.callable:
args = self.schema.parse(curr)
self.callable(args)
elif self.callable:
self.callable()
if self.subcommands:
if len(rest) == 0 and not self.populated:
raise ValueError("Expected subcommand")
elif rest[0] in self.subcommands:
print("eval", rest[0])
self.subcommands[rest[0]].eval(rest)
elif len(rest) > 0:
raise ValueError(f"Unknown subcommand '{rest[0]}'")
elif len(rest) > 0:
raise ValueError(f"Unknown operand '{rest[0]}'")
except ValueError as e:
print("Error: " + str(e))
print("Usage: " + cmd + self.usage(), end="\n\n")
return
_root = Command(None, sys.argv[0])
def _splitPath(path: str) -> list[str]:
if path == "/":
return []
return path.split("/")
def _resolvePath(path: list[str]) -> Command:
if path == "/":
return _root
cmd = _root
for name in path:
if name not in cmd.subcommands:
cmd.subcommands[name] = Command(None, name)
cmd = cmd.subcommands[name]
return cmd
def command(shortName: str, longName: str, description: str = "") -> Callable:
def wrap(fn: Callable):
schema = Schema.extractFromCallable(fn)
path = _splitPath(longName)
cmd = _resolvePath(path)
if cmd.populated:
raise ValueError(f"Command '{longName}' is already defined")
cmd.shortName = shortName
cmd.longName = path[-1]
cmd.description = description
cmd.schema = schema
cmd.callable = fn
cmd.populated = True
return fn
return wrap

View file

@ -1,7 +1,21 @@
import os import os
import sys import sys
VERSION = (0, 6, 0, "dev") from . import utils
class Uninitialized:
def __repr__(self):
raise Exception("Uninitialized constant")
def __str__(self):
raise Exception("Uninitialized constant")
def __bool__(self):
raise Exception("Uninitialized constant")
VERSION = (0, 7, 0, "dev")
VERSION_STR = f"{VERSION[0]}.{VERSION[1]}.{VERSION[2]}{'-' + VERSION[3] if len(VERSION) >= 4 else ''}" VERSION_STR = f"{VERSION[0]}.{VERSION[1]}.{VERSION[2]}{'-' + VERSION[3] if len(VERSION) >= 4 else ''}"
MODULE_DIR = os.path.dirname(os.path.realpath(__file__)) MODULE_DIR = os.path.dirname(os.path.realpath(__file__))
ARGV0 = os.path.basename(sys.argv[0]) ARGV0 = os.path.basename(sys.argv[0])
@ -10,10 +24,24 @@ GLOBAL_CK_DIR = os.path.join(os.path.expanduser("~"), ".cutekit")
BUILD_DIR = os.path.join(PROJECT_CK_DIR, "build") BUILD_DIR = os.path.join(PROJECT_CK_DIR, "build")
CACHE_DIR = os.path.join(PROJECT_CK_DIR, "cache") CACHE_DIR = os.path.join(PROJECT_CK_DIR, "cache")
EXTERN_DIR = os.path.join(PROJECT_CK_DIR, "extern") EXTERN_DIR = os.path.join(PROJECT_CK_DIR, "extern")
TMP_DIR = os.path.join(PROJECT_CK_DIR, "tmp")
SRC_DIR = "src" SRC_DIR = "src"
META_DIR = "meta" META_DIR = "meta"
TARGETS_DIR = os.path.join(META_DIR, "targets") TARGETS_DIR = os.path.join(META_DIR, "targets")
DEFAULT_REPO_TEMPLATES = "cute-engineering/cutekit-templates" DEFAULT_REPO_TEMPLATES = "cute-engineering/cutekit-templates"
DESCRIPTION = "A build system and package manager for low-level software development" DESCRIPTION = "A build system and package manager for low-level software development"
PROJECT_LOG_FILE = os.path.join(PROJECT_CK_DIR, "cutekit.log") PROJECT_LOG_FILE = os.path.join(PROJECT_CK_DIR, "cutekit.log")
GLOBAL_LOG_FILE = os.path.join(os.path.expanduser("~"), ".cutekit", "cutekit.log") GLOBAL_LOG_FILE: str = os.path.join(os.path.expanduser("~"), ".cutekit", "cutekit.log")
HOSTID: str | Uninitialized = Uninitialized()
def setup():
global HOSTID
hostIdPath = GLOBAL_CK_DIR + "/hostid"
if os.path.exists(hostIdPath):
with open(hostIdPath, "r") as f:
HOSTID = f.read().strip()
else:
HOSTID = utils.randomHash()
with open(hostIdPath, "w") as f:
f.write(HOSTID)

44
cutekit/entrypoint.sh Executable file
View file

@ -0,0 +1,44 @@
#!/usr/bin/env bash
# This script makes sure that the virtual environment is
# set up and that the plugins requirements are installed.
set -e
if [ -z "$CUTEKIT_PYTHON" ]; then
if command -v python3.11 &> /dev/null; then
export CUTEKIT_PYTHON="python3.11"
else
export CUTEKIT_PYTHON="python3"
fi
fi
if [ ! -d "/tools/venv" ]; then
echo "Creating virtual environment..."
$CUTEKIT_PYTHON -m venv /tools/venv
source /tools/venv/bin/activate
$CUTEKIT_PYTHON -m ensurepip
$CUTEKIT_PYTHON -m pip install -r /tools/cutekit/requirements.txt
echo "Installing plugins requirements..."
if [ -f "/project/meta/plugins/requirements.txt" ]; then
echo "Root plugin requirements found."
$CUTEKIT_PYTHON -m pip install -r /project/meta/plugins/requirements.txt
fi
for extern in /project/meta/externs/*; do
if [ -f "$extern/meta/plugins/requirements.txt" ]; then
echo "Plugin requirements found in $extern."
$CUTEKIT_PYTHON -m pip install -r "$extern/meta/plugins/requirements.txt"
fi
done
echo "Virtual environment created."
else
source /tools/venv/bin/activate
fi
cd /project
export PYTHONPATH=/tools
$CUTEKIT_PYTHON -m cutekit $@

View file

@ -36,7 +36,7 @@ def view(
if ( if (
scopeInstance is not None scopeInstance is not None
and component.id != scope and component.id != scope
and component.id not in scopeInstance.resolved[target.id].resolved and component.id not in scopeInstance.resolved[target.id].required
): ):
continue continue
@ -46,7 +46,7 @@ def view(
g.node( g.node(
component.id, component.id,
f"<<B>{component.id}</B><BR/>{vt100.wordwrap(component.decription, 40,newline='<BR/>')}>", f"<<B>{component.id}</B><BR/>{vt100.wordwrap(component.description, 40,newline='<BR/>')}>",
shape=shape, shape=shape,
style="filled", style="filled",
fillcolor=fillcolor, fillcolor=fillcolor,
@ -67,7 +67,7 @@ def view(
elif showDisabled: elif showDisabled:
g.node( g.node(
component.id, component.id,
f"<<B>{component.id}</B><BR/>{vt100.wordwrap(component.decription, 40,newline='<BR/>')}<BR/><BR/><I>{vt100.wordwrap(str(component.resolved[target.id].reason), 40,newline='<BR/>')}</I>>", f"<<B>{component.id}</B><BR/>{vt100.wordwrap(component.description, 40,newline='<BR/>')}<BR/><BR/><I>{vt100.wordwrap(str(component.resolved[target.id].reason), 40,newline='<BR/>')}</I>>",
shape="plaintext", shape="plaintext",
style="filled", style="filled",
fontcolor="#999999", fontcolor="#999999",
@ -84,7 +84,7 @@ def view(
@cli.command("g", "graph", "Show the dependency graph") @cli.command("g", "graph", "Show the dependency graph")
def graphCmd(args: cli.Args): def _(args: cli.Args):
registry = model.Registry.use(args) registry = model.Registry.use(args)
target = model.Target.use(args) target = model.Target.use(args)

View file

@ -57,15 +57,33 @@ def makeMixinTune(tune: str) -> Mixin:
return mixinTune return mixinTune
def combineMixins(*mixins: Mixin) -> Mixin:
def combined(target: model.Target, tools: model.Tools) -> model.Tools:
for mixin in mixins:
tools = mixin(target, tools)
return tools
return combined
mixins: dict[str, Mixin] = { mixins: dict[str, Mixin] = {
"cache": mixinCache, "cache": mixinCache,
"debug": mixinDebug, "debug": mixinDebug,
"asan": makeMixinSan("address"), "asan": combineMixins(makeMixinSan("address"), makeMixinSan("leak")),
"msan": makeMixinSan("memory"), "msan": makeMixinSan("memory"),
"tsan": makeMixinSan("thread"), "tsan": makeMixinSan("thread"),
"ubsan": makeMixinSan("undefined"), "ubsan": makeMixinSan("undefined"),
"lsan": makeMixinSan("leak"),
"san": combineMixins(
makeMixinSan("address"),
makeMixinSan("undefined"),
makeMixinSan("leak"),
),
"tune": makeMixinTune("native"), "tune": makeMixinTune("native"),
"fast": makeMixinOptimize("fast"), "release": combineMixins(
makeMixinOptimize("3"),
makeMixinTune("native"),
),
"o3": makeMixinOptimize("3"), "o3": makeMixinOptimize("3"),
"o2": makeMixinOptimize("2"), "o2": makeMixinOptimize("2"),
"o1": makeMixinOptimize("1"), "o1": makeMixinOptimize("1"),

View file

@ -7,6 +7,7 @@ from enum import Enum
from typing import Any, Generator, Optional, Type, cast from typing import Any, Generator, Optional, Type, cast
from pathlib import Path from pathlib import Path
from dataclasses_json import DataClassJsonMixin from dataclasses_json import DataClassJsonMixin
from typing import Union
from cutekit import const, shell from cutekit import const, shell
@ -71,7 +72,7 @@ class Manifest(DataClassJsonMixin):
""" """
Return the directory of the manifest Return the directory of the manifest
""" """
return os.path.dirname(self.path) return os.path.relpath(os.path.dirname(self.path), Path.cwd())
def subpath(self, path) -> Path: def subpath(self, path) -> Path:
return Path(self.dirname()) / path return Path(self.dirname()) / path
@ -96,6 +97,7 @@ _project: Optional["Project"] = None
class Extern(DataClassJsonMixin): class Extern(DataClassJsonMixin):
git: str git: str
tag: str tag: str
deep: bool = dt.field(default=False)
@dt.dataclass @dt.dataclass
@ -121,19 +123,18 @@ class Project(Manifest):
@staticmethod @staticmethod
def ensure() -> "Project": def ensure() -> "Project":
"""
Ensure that a project exists in the current directory or any parent directory
and chdir to the root of the project.
"""
project = Project.topmost() project = Project.topmost()
if project is None: if project is None:
raise RuntimeError( raise RuntimeError(
"No project found in this directory or any parent directory" "No project found in this directory or any parent directory"
) )
os.chdir(project.dirname())
return project return project
def chdir(self):
"""
Change the current working directory to the root of the project
"""
os.chdir(self.dirname())
@staticmethod @staticmethod
def at(path: Path) -> Optional["Project"]: def at(path: Path) -> Optional["Project"]:
projectManifest = Manifest.tryLoad(path / "project") projectManifest = Manifest.tryLoad(path / "project")
@ -151,17 +152,20 @@ class Project(Manifest):
continue continue
print(f"Installing {extSpec}-{ext.tag} from {ext.git}...") print(f"Installing {extSpec}-{ext.tag} from {ext.git}...")
shell.popen( cmd = [
"git", "git",
"clone", "clone",
"--quiet", "--quiet",
"--depth",
"1",
"--branch", "--branch",
ext.tag, ext.tag,
ext.git, ext.git,
extPath, extPath,
) ]
if not ext.deep:
cmd += ["--depth", "1"]
shell.exec(*cmd, quiet=True)
project = Project.at(Path(extPath)) project = Project.at(Path(extPath))
if project is not None: if project is not None:
Project.fetchs(project.extern) Project.fetchs(project.extern)
@ -174,14 +178,19 @@ class Project(Manifest):
return _project return _project
@cli.command("i", "install", "Install required external packages") @cli.command("m", "model", "Manage the model")
def installCmd(args: cli.Args): def _(args: cli.Args):
pass
@cli.command("i", "model/install", "Install required external packages")
def _(args: cli.Args):
project = Project.use(args) project = Project.use(args)
Project.fetchs(project.extern) Project.fetchs(project.extern)
@cli.command("I", "init", "Initialize a new project") @cli.command("I", "model/init", "Initialize a new project")
def initCmd(args: cli.Args): def _(args: cli.Args):
import requests import requests
repo = args.consumeOpt("repo", const.DEFAULT_REPO_TEMPLATES) repo = args.consumeOpt("repo", const.DEFAULT_REPO_TEMPLATES)
@ -244,6 +253,7 @@ class Tool(DataClassJsonMixin):
cmd: str = dt.field(default="") cmd: str = dt.field(default="")
args: list[str] = dt.field(default_factory=list) args: list[str] = dt.field(default_factory=list)
files: list[str] = dt.field(default_factory=list) files: list[str] = dt.field(default_factory=list)
rule: Optional[str] = None
Tools = dict[str, Tool] Tools = dict[str, Tool]
@ -259,17 +269,26 @@ class Target(Manifest):
tools: Tools = dt.field(default_factory=dict) tools: Tools = dt.field(default_factory=dict)
routing: dict[str, str] = dt.field(default_factory=dict) routing: dict[str, str] = dt.field(default_factory=dict)
_hashid: Optional[str] = None
@property @property
def hashid(self) -> str: def hashid(self) -> str:
return utils.hash((self.props, [v.to_dict() for k, v in self.tools.items()])) if self._hashid is None:
self._hashid = utils.hash(
(self.props, [v.to_dict() for k, v in self.tools.items()])
)
return self._hashid
@property @property
def builddir(self) -> str: def builddir(self) -> str:
return os.path.join(const.BUILD_DIR, f"{self.id}-{self.hashid[:8]}") postfix = f"-{self.hashid[:8]}"
if self.props.get("host"):
postfix += f"-{str(const.HOSTID)[:8]}"
return os.path.join(const.BUILD_DIR, f"{self.id}{postfix}")
@staticmethod @staticmethod
def use(args: cli.Args) -> "Target": def use(args: cli.Args, props: Props = {}) -> "Target":
registry = Registry.use(args) registry = Registry.use(args, props)
targetSpec = str(args.consumeOpt("target", "host-" + shell.uname().machine)) targetSpec = str(args.consumeOpt("target", "host-" + shell.uname().machine))
return registry.ensure(targetSpec, Target) return registry.ensure(targetSpec, Target)
@ -290,7 +309,8 @@ class Target(Manifest):
@dt.dataclass @dt.dataclass
class Resolved: class Resolved:
reason: Optional[str] = None reason: Optional[str] = None
resolved: list[str] = dt.field(default_factory=list) required: list[str] = dt.field(default_factory=list)
injected: list[str] = dt.field(default_factory=list)
@property @property
def enabled(self) -> bool: def enabled(self) -> bool:
@ -299,7 +319,7 @@ class Resolved:
@dt.dataclass @dt.dataclass
class Component(Manifest): class Component(Manifest):
decription: str = dt.field(default="(No description)") description: str = dt.field(default="(No description)")
props: Props = dt.field(default_factory=dict) props: Props = dt.field(default_factory=dict)
tools: Tools = dt.field(default_factory=dict) tools: Tools = dt.field(default_factory=dict)
enableIf: dict[str, list[Any]] = dt.field(default_factory=dict) enableIf: dict[str, list[Any]] = dt.field(default_factory=dict)
@ -437,11 +457,11 @@ class Resolver:
self._cache[keep] = Resolved(reason=reqResolved.reason) self._cache[keep] = Resolved(reason=reqResolved.reason)
return self._cache[keep] return self._cache[keep]
result.extend(reqResolved.resolved) result.extend(reqResolved.required)
stack.pop() stack.pop()
result.insert(0, keep) result.insert(0, keep)
self._cache[keep] = Resolved(resolved=utils.uniq(result)) self._cache[keep] = Resolved(required=utils.uniqPreserveOrder(result))
return self._cache[keep] return self._cache[keep]
@ -516,7 +536,7 @@ class Registry(DataClassJsonMixin):
return m return m
@staticmethod @staticmethod
def use(args: cli.Args) -> "Registry": def use(args: cli.Args, props: Props = {}) -> "Registry":
global _registry global _registry
if _registry is not None: if _registry is not None:
@ -526,8 +546,7 @@ class Registry(DataClassJsonMixin):
mixins = str(args.consumeOpt("mixins", "")).split(",") mixins = str(args.consumeOpt("mixins", "")).split(",")
if mixins == [""]: if mixins == [""]:
mixins = [] mixins = []
props = cast(dict[str, str], args.consumePrefix("prop:")) props |= cast(dict[str, str], args.consumePrefix("prop:"))
_registry = Registry.load(project, mixins, props) _registry = Registry.load(project, mixins, props)
return _registry return _registry
@ -569,24 +588,33 @@ class Registry(DataClassJsonMixin):
# Resolve all dependencies for all targets # Resolve all dependencies for all targets
for target in r.iter(Target): for target in r.iter(Target):
target.props |= props target.props |= props
resolver = Resolver(r, target)
# Apply injects
for c in r.iter(Component):
if c.isEnabled(target)[0]:
for inject in c.injects:
victim = r.lookup(inject, Component)
if not victim:
raise RuntimeError(f"Cannot find component '{inject}'")
victim.requires += [c.id]
# Resolve all components # Resolve all components
resolver = Resolver(r, target)
for c in r.iter(Component): for c in r.iter(Component):
resolved = resolver.resolve(c.id) resolved = resolver.resolve(c.id)
if resolved.reason: if resolved.reason:
_logger.info(f"Component '{c.id}' disabled: {resolved.reason}") _logger.info(f"Component '{c.id}' disabled: {resolved.reason}")
c.resolved[target.id] = resolved c.resolved[target.id] = resolved
# Apply injects
for c in r.iter(Component):
if c.resolved[target.id].enabled:
for inject in c.injects:
victim = r.lookup(inject, Component, includeProvides=True)
if not victim:
_logger.info(
f"Could not find component to inject '{inject}' with '{c.id}'"
)
else:
victim.resolved[target.id].injected.append(c.id)
victim.resolved[
target.id
].required = utils.uniqPreserveOrder(
c.resolved[target.id].required
+ victim.resolved[target.id].required
)
# Resolve tooling # Resolve tooling
tools: Tools = target.tools tools: Tools = target.tools
@ -610,8 +638,8 @@ class Registry(DataClassJsonMixin):
return r return r
@cli.command("l", "list", "List all components and targets") @cli.command("l", "model/list", "List all components and targets")
def listCmd(args: cli.Args): def _(args: cli.Args):
registry = Registry.use(args) registry = Registry.use(args)
components = list(registry.iter(Component)) components = list(registry.iter(Component))

View file

@ -1,7 +1,8 @@
import os
import logging import logging
import os
import sys
from . import shell, model, const from . import shell, model, const, cli
import importlib.util as importlib import importlib.util as importlib
@ -14,10 +15,16 @@ def load(path: str):
if not spec or not spec.loader: if not spec or not spec.loader:
_logger.error(f"Failed to load plugin {path}") _logger.error(f"Failed to load plugin {path}")
return None return
module = importlib.module_from_spec(spec) module = importlib.module_from_spec(spec)
spec.loader.exec_module(module) sys.modules["plugin"] = module
try:
spec.loader.exec_module(module)
except Exception as e:
_logger.error(f"Failed to load plugin {path}: {e}")
cli.warning(f"Plugin {path} loading skipped due to error")
def loadAll(): def loadAll():
@ -33,11 +40,17 @@ def loadAll():
for dirname in paths: for dirname in paths:
pluginDir = os.path.join(project.dirname(), dirname, const.META_DIR, "plugins") pluginDir = os.path.join(project.dirname(), dirname, const.META_DIR, "plugins")
pluginDir = os.path.normpath(pluginDir)
initFile = os.path.join(pluginDir, "__init__.py")
for files in shell.readdir(pluginDir): if os.path.isfile(initFile):
if files.endswith(".py"): load(initFile)
plugin = load(os.path.join(pluginDir, files)) else:
for files in shell.readdir(pluginDir):
if files.endswith(".py"):
load(os.path.join(pluginDir, files))
if plugin:
_logger.info(f"Loaded plugin {plugin.name}") def setup(args: cli.Args):
plugin.init() if not bool(args.consumeOpt("safemode", False)):
loadAll()

238
cutekit/pods.py Normal file
View file

@ -0,0 +1,238 @@
import sys
from typing import Optional
import docker # type: ignore
import os
import dataclasses as dt
from . import cli, model, shell, vt100, const
podPrefix = "CK__"
projectRoot = "/project"
toolingRoot = "/tools"
defaultPodName = f"{podPrefix}default"
defaultPodImage = "ubuntu"
@dt.dataclass
class Image:
id: str
like: str
image: str
setup: list[str]
@dt.dataclass
class Pod:
name: str
image: Image
IMAGES: dict[str, Image] = {
"ubuntu-jammy": Image(
"ubuntu-jammy",
"ubuntu",
"ubuntu:jammy",
[
"apt-get update",
"apt-get install -y python3.11 python3.11-venv ninja-build build-essential",
],
),
"debian-bookworm": Image(
"debian-bookworm",
"debian",
"debian:bookworm",
[
"apt-get update",
"apt-get install -y python3 python3-pip python3-venv ninja-build build-essential",
],
),
"alpine-3.18": Image(
"alpine-3.18",
"alpine",
"alpine:3.18",
[
"apk update",
"apk add python3 python3-dev py3-pip py3-virtualenv build-base linux-headers ninja make automake gcc g++ bash",
],
),
"arch": Image(
"arch",
"arch",
"archlinux:latest",
[
"pacman -Syu --noconfirm",
"pacman -S --noconfirm python python-pip python-virtualenv ninja base-devel",
],
),
"fedora-39": Image(
"fedora-39",
"fedora",
"fedora:39",
[
"dnf update -y",
"dnf install -y python3 python3-pip ninja-build make automake gcc gcc-c++ kernel-devel",
],
),
}
def setup(args: cli.Args):
"""
Reincarnate cutekit within a docker container, this is
useful for cross-compiling
"""
pod = args.consumeOpt("pod", False)
if not pod:
return
if isinstance(pod, str):
pod = pod.strip()
pod = podPrefix + pod
if pod is True:
pod = defaultPodName
assert isinstance(pod, str)
model.Project.ensure()
print(f"Reincarnating into pod '{pod[len(podPrefix) :]}'...")
try:
strippedArgsV = list(sys.argv[1:])
strippedArgsV = [arg for arg in strippedArgsV if not arg.startswith("--pod=")]
shell.exec(
"docker",
"exec",
"-w",
projectRoot,
"-i",
pod,
"/tools/cutekit/entrypoint.sh",
"--reincarnated",
*strippedArgsV,
)
sys.exit(0)
except Exception:
sys.exit(1)
@cli.command("p", "pod", "Manage pods")
def _(args: cli.Args):
pass
def tryDecode(data: Optional[bytes], default: str = "") -> str:
if data is None:
return default
return data.decode()
@cli.command("c", "pod/create", "Create a new pod")
def _(args: cli.Args):
"""
Create a new development pod with cutekit installed and the current
project mounted at /project
"""
project = model.Project.ensure()
name = str(args.consumeOpt("name", defaultPodName))
if not name.startswith(podPrefix):
name = f"{podPrefix}{name}"
image = IMAGES[str(args.consumeOpt("image", defaultPodImage))]
client = docker.from_env()
try:
existing = client.containers.get(name)
if cli.ask(f"Pod '{name[len(podPrefix):]}' already exists, kill it?", False):
existing.stop()
existing.remove()
else:
raise RuntimeError(f"Pod '{name[len(podPrefix):]}' already exists")
except docker.errors.NotFound:
pass
print(f"Starting pod '{name[len(podPrefix) :]}'...")
container = client.containers.run(
image.image,
"sleep infinity",
name=name,
volumes={
const.MODULE_DIR: {
"bind": toolingRoot + "/cutekit",
"mode": "ro",
},
os.path.abspath(project.dirname()): {"bind": projectRoot, "mode": "rw"},
},
detach=True,
)
print(f"Initializing pod '{name[len(podPrefix) :]}'...")
for cmd in image.setup:
print(vt100.p(cmd))
exitCode, output = container.exec_run(f"/bin/sh -c '{cmd}'", demux=True)
if exitCode != 0:
raise RuntimeError(
f"Failed to initialize pod with command '{cmd}':\n\nSTDOUT:\n{vt100.indent(vt100.wordwrap(tryDecode(output[0], '<empty>')))}\nSTDERR:\n{vt100.indent(vt100.wordwrap(tryDecode(output[1], '<empty>')))}"
)
print(f"Created pod '{name[len(podPrefix) :]}' from image '{image.image}'")
@cli.command("k", "pod/kill", "Stop and remove a pod")
def _(args: cli.Args):
client = docker.from_env()
name = str(args.consumeOpt("name", defaultPodName))
all = args.consumeOpt("all", False) is True
if not name.startswith(podPrefix):
name = f"{podPrefix}{name}"
try:
if all:
for container in client.containers.list(all=True):
if not container.name.startswith(podPrefix):
continue
container.stop()
container.remove()
print(f"Pod '{container.name[len(podPrefix) :]}' killed")
return
container = client.containers.get(name)
container.stop()
container.remove()
print(f"Pod '{name[len(podPrefix) :]}' killed")
except docker.errors.NotFound:
raise RuntimeError(f"Pod '{name[len(podPrefix):]}' does not exist")
@cli.command("s", "pod/shell", "Open a shell in a pod")
def _(args: cli.Args):
args.args.insert(0, "/bin/bash")
podExecCmd(args)
@cli.command("l", "pod/list", "List all pods")
def _(args: cli.Args):
client = docker.from_env()
hasPods = False
for container in client.containers.list(all=True):
if not container.name.startswith(podPrefix):
continue
print(container.name[len(podPrefix) :], container.status)
hasPods = True
if not hasPods:
print(vt100.p("(No pod found)"))
@cli.command("e", "pod/exec", "Execute a command in a pod")
def podExecCmd(args: cli.Args):
name = str(args.consumeOpt("name", defaultPodName))
if not name.startswith(podPrefix):
name = f"{podPrefix}{name}"
cmd = args.consumeArg()
if cmd is None:
raise RuntimeError("Missing command to execute")
try:
shell.exec("docker", "exec", "-it", name, cmd, *args.extra)
except Exception:
raise RuntimeError(f"Pod '{name[len(podPrefix):]}' does not exist")

View file

@ -1,3 +1,5 @@
requests ~= 2.31.0 requests ~= 2.31.0
graphviz ~= 0.20.1 graphviz ~= 0.20.1
dataclasses-json ~= 0.6.2 dataclasses-json ~= 0.6.2
docker ~= 6.1.3
asserts ~= 0.12.0

View file

@ -1,52 +1,58 @@
import dataclasses as dt
from typing import Optional from typing import Optional
@dt.dataclass
class Rule: class Rule:
id: str id: str
fileIn: list[str] fileIn: list[str]
fileOut: list[str] fileOut: str
rule: str rule: str
args: list[str] args: list[str] = dt.field(default_factory=list)
deps: Optional[str] = None deps: list[str] = dt.field(default_factory=list)
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
self.rule = rule
self.args = args
self.deps = deps
rules: dict[str, Rule] = { rules: dict[str, Rule] = {
"cp": Rule("cp", ["*"], ["*"], "$in $out"), "cp": Rule("cp", ["*"], "*", "$in $out"),
"cc": Rule( "cc": Rule(
"cc", "cc",
["*.c"], ["*.c"],
["*.o"], "*.o",
"-c -o $out $in -MD -MF $out.d $flags $cincs $cdefs", "-c -o $out $in -MD -MF $out.d $flags $cincs $cdefs",
["-std=gnu2x", "-Wall", "-Wextra", "-Werror"], [
"$out.d", "-std=gnu2x",
"-Wall",
"-Wextra",
"-Werror",
"-fcolor-diagnostics",
],
["$out.d"],
), ),
"cxx": Rule( "cxx": Rule(
"cxx", "cxx",
["*.cpp", "*.cc", "*.cxx"], ["*.cpp", "*.cc", "*.cxx"],
["*.o"], "*.o",
"-c -o $out $in -MD -MF $out.d $flags $cincs $cdefs", "-c -o $out $in -MD -MF $out.d $flags $cincs $cdefs",
["-std=gnu++2b", "-Wall", "-Wextra", "-Werror", "-fno-exceptions", "-fno-rtti"], [
"$out.d", "-std=gnu++2b",
"-Wall",
"-Wextra",
"-Werror",
"-fcolor-diagnostics",
"-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 $objs --whole-archive $wholeLibs --no-whole-archive $libs $flags",
), ),
"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"),
} }

View file

@ -10,28 +10,36 @@ import fnmatch
import platform import platform
import logging import logging
import tempfile import tempfile
import dataclasses as dt
from pathlib import Path
from typing import Optional from typing import Literal, Optional
from . import const from . import const, cli
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@dt.dataclass
class Uname: class Uname:
def __init__( sysname: str
self, sysname: str, nodename: str, release: str, version: str, machine: str distrib: str
): nodename: str
self.sysname = sysname release: str
self.nodename = nodename version: str
self.release = release machine: str
self.version = version
self.machine = machine
def uname() -> Uname: def uname() -> Uname:
un = platform.uname() un = platform.uname()
result = Uname(un.system, un.node, un.release, un.version, un.machine)
if hasattr(platform, "freedesktop_os_release"):
distrib = platform.freedesktop_os_release()
else:
distrib = {"NAME": "Unknown"}
result = Uname(
un.system, distrib["NAME"], un.node, un.release, un.version, un.machine
)
match result.machine: match result.machine:
case "aarch64": case "aarch64":
@ -52,14 +60,14 @@ def sha256sum(path: str) -> str:
def find( def find(
path: str | list[str], wildcards: list[str] = [], recusive: bool = True path: str | list[str], wildcards: list[str] = [], recusive: bool = True
) -> list[str]: ) -> list[str]:
_logger.info(f"Looking for files in {path} matching {wildcards}") _logger.debug(f"Looking for files in {path} matching {wildcards}")
result: list[str] = [] result: list[str] = []
if isinstance(path, list): if isinstance(path, list):
for p in path: for p in path:
result += find(p, wildcards, recusive) result += find(p, wildcards, recusive)
return result return sorted(result)
if not os.path.isdir(path): if not os.path.isdir(path):
return [] return []
@ -84,11 +92,12 @@ def find(
result.append(os.path.join(path, f)) result.append(os.path.join(path, f))
break break
return result # sort for reproducibility
return sorted(result)
def mkdir(path: str) -> str: def mkdir(path: str) -> str:
_logger.info(f"Creating directory {path}") _logger.debug(f"Creating directory {path}")
try: try:
os.makedirs(path) os.makedirs(path)
@ -99,11 +108,14 @@ def mkdir(path: str) -> str:
def rmrf(path: str) -> bool: def rmrf(path: str) -> bool:
_logger.info(f"Removing directory {path}") _logger.debug(f"Removing directory {path}")
if not os.path.exists(path): if not os.path.exists(path):
return False return False
shutil.rmtree(path, ignore_errors=True) if os.path.isfile(path):
os.remove(path)
else:
shutil.rmtree(path, ignore_errors=True)
return True return True
@ -118,7 +130,7 @@ def wget(url: str, path: Optional[str] = None) -> str:
if os.path.exists(path): if os.path.exists(path):
return path return path
_logger.info(f"Downloading {url} to {path}") _logger.debug(f"Downloading {url} to {path}")
r = requests.get(url, stream=True) r = requests.get(url, stream=True)
r.raise_for_status() r.raise_for_status()
@ -131,56 +143,129 @@ def wget(url: str, path: Optional[str] = None) -> str:
return path return path
def exec(*args: str, quiet: bool = False) -> bool: def exec(*args: str, quiet: bool = False, cwd: Optional[str] = None) -> bool:
_logger.info(f"Executing {args}") _logger.debug(f"Executing {args}")
cmdName = Path(args[0]).name
try: try:
proc = subprocess.run( proc = subprocess.run(
args, args,
cwd=cwd,
stdout=sys.stdout if not quiet else subprocess.PIPE, stdout=sys.stdout if not quiet else subprocess.PIPE,
stderr=sys.stderr if not quiet else subprocess.PIPE, stderr=sys.stderr if not quiet else subprocess.PIPE,
) )
if proc.stdout: if proc.stdout:
_logger.info(proc.stdout.decode("utf-8")) _logger.debug(proc.stdout.decode("utf-8"))
if proc.stderr: if proc.stderr:
_logger.error(proc.stderr.decode("utf-8")) _logger.debug(proc.stderr.decode("utf-8"))
except FileNotFoundError: except FileNotFoundError:
raise RuntimeError(f"{args[0]}: Command not found") if cwd and not os.path.exists(cwd):
raise RuntimeError(f"{cwd}: No such file or directory")
else:
raise RuntimeError(f"{args[0]}: Command not found")
except KeyboardInterrupt: except KeyboardInterrupt:
raise RuntimeError(f"{args[0]}: Interrupted") raise RuntimeError(f"{cmdName}: Interrupted")
if proc.returncode == -signal.SIGSEGV: if proc.returncode == -signal.SIGSEGV:
raise RuntimeError(f"{args[0]}: Segmentation fault") raise RuntimeError(f"{cmdName}: Segmentation fault")
if proc.returncode != 0: if proc.returncode != 0:
raise RuntimeError(f"{args[0]}: Process exited with code {proc.returncode}") raise RuntimeError(f"{cmdName}: Process exited with code {proc.returncode}")
return True return True
def popen(*args: str) -> str: def popen(*args: str) -> str:
_logger.info(f"Executing {args}") _logger.debug(f"Executing {args}...")
cmdName = Path(args[0]).name
try: try:
proc = subprocess.run(args, stdout=subprocess.PIPE, stderr=sys.stderr) proc = subprocess.run(args, stdout=subprocess.PIPE, stderr=sys.stderr)
except FileNotFoundError: except FileNotFoundError:
raise RuntimeError(f"{args[0]}: Command not found") raise RuntimeError(f"{cmdName}: Command not found")
if proc.returncode == -signal.SIGSEGV: if proc.returncode == -signal.SIGSEGV:
raise RuntimeError(f"{args[0]}: Segmentation fault") raise RuntimeError(f"{cmdName}: Segmentation fault")
if proc.returncode != 0: if proc.returncode != 0:
raise RuntimeError(f"{args[0]}: Process exited with code {proc.returncode}") raise RuntimeError(f"{cmdName}: Process exited with code {proc.returncode}")
return proc.stdout.decode("utf-8") return proc.stdout.decode("utf-8").strip()
def debug(cmd: list[str], debugger: str = "lldb", wait: bool = False):
if debugger == "lldb":
exec(
"lldb",
*(("-o", "b main") if wait else ()),
*("-o", "run"),
*cmd,
)
elif debugger == "gdb":
exec(
"gdb",
*(("-ex", "b main") if wait else ()),
*("-ex", "run"),
*cmd,
)
else:
raise RuntimeError(f"Unknown debugger {debugger}")
def _profileCpu(cmd: list[str], rate=1000):
mkdir(const.TMP_DIR)
perfFile = f"{const.TMP_DIR}/cpu-profile.data"
try:
exec(
"perf",
"record",
"-F",
str(rate),
"-g",
"-o",
perfFile,
"--call-graph",
"dwarf",
*cmd,
)
except Exception as e:
if not os.path.exists(perfFile):
raise e
try:
proc = subprocess.Popen(
["perf", "script", "-i", perfFile], stdout=subprocess.PIPE
)
subprocess.run(["speedscope", "-"], stdin=proc.stdout)
proc.wait()
except Exception as e:
rmrf(perfFile)
raise e
rmrf(perfFile)
def _profileMem(cmd: list[str]):
perfFile = f"{const.TMP_DIR}/mem-profile.data"
exec("heaptrack", "-o", perfFile, *cmd)
def profile(cmd: list[str], rate=1000, what: Literal["cpu", "mem"] = "cpu"):
if what == "cpu":
_profileCpu(cmd, rate)
elif what == "mem":
_profileMem(cmd)
else:
raise RuntimeError(f"Unknown profile type {what}")
def readdir(path: str) -> list[str]: def readdir(path: str) -> list[str]:
_logger.info(f"Reading directory {path}") _logger.debug(f"Reading directory {path}")
try: try:
return os.listdir(path) return os.listdir(path)
@ -189,19 +274,19 @@ def readdir(path: str) -> list[str]:
def cp(src: str, dst: str): def cp(src: str, dst: str):
_logger.info(f"Copying {src} to {dst}") _logger.debug(f"Copying {src} to {dst}")
shutil.copy(src, dst) shutil.copy(src, dst)
def mv(src: str, dst: str): def mv(src: str, dst: str):
_logger.info(f"Moving {src} to {dst}") _logger.debug(f"Moving {src} to {dst}")
shutil.move(src, dst) shutil.move(src, dst)
def cpTree(src: str, dst: str): def cpTree(src: str, dst: str):
_logger.info(f"Copying {src} to {dst}") _logger.debug(f"Copying {src} to {dst}")
shutil.copytree(src, dst, dirs_exist_ok=True) shutil.copytree(src, dst, dirs_exist_ok=True)
@ -241,10 +326,15 @@ def latest(cmd: str) -> str:
if cmd in LATEST_CACHE: if cmd in LATEST_CACHE:
return LATEST_CACHE[cmd] return LATEST_CACHE[cmd]
_logger.info(f"Finding latest version of {cmd}") if "IN_NIX_SHELL" in os.environ:
# By default, NixOS symlinks tools automatically
# to their latest version. Also, if the user uses
# clang-xx, the std libraries will not be accessible.
return cmd
_logger.debug(f"Finding latest version of {cmd}")
regex: re.Pattern[str] regex: re.Pattern[str]
if platform.system() == "Windows": if platform.system() == "Windows":
regex = re.compile(r"^" + re.escape(cmd) + r"(-.[0-9]+)?(\.exe)?$") regex = re.compile(r"^" + re.escape(cmd) + r"(-.[0-9]+)?(\.exe)?$")
else: else:
@ -263,7 +353,7 @@ def latest(cmd: str) -> str:
versions.sort() versions.sort()
chosen = versions[-1] chosen = versions[-1]
_logger.info(f"Chosen {chosen} as latest version of {cmd}") _logger.debug(f"Chosen {chosen} as latest version of {cmd}")
LATEST_CACHE[cmd] = chosen LATEST_CACHE[cmd] = chosen
@ -275,3 +365,89 @@ def which(cmd: str) -> Optional[str]:
Find the path of a command Find the path of a command
""" """
return shutil.which(cmd) return shutil.which(cmd)
def nproc() -> int:
"""
Return the number of processors
"""
return os.cpu_count() or 1
def gzip(path: str, dest: Optional[str] = None) -> str:
"""
Compress a file or directory
"""
if dest is None:
dest = path + ".gz"
with open(dest, "wb") as f:
proc = subprocess.run(
["gzip", "-c", path],
stdout=f,
stderr=sys.stderr,
)
if proc.returncode != 0:
raise RuntimeError(f"gzip: Process exited with code {proc.returncode}")
return dest
def compress(path: str, dest: Optional[str] = None, format: str = "zstd") -> str:
"""
Compress a file or directory
"""
EXTS = {
"zip": "zip",
"zstd": "zst",
"gzip": "gz",
}
if dest is None:
dest = path + "." + EXTS[format]
_logger.debug(f"Compressing {path} to {dest}")
if format == "zip":
exec("zip", "-r", dest, path)
elif format == "zstd":
exec("zstd", "-q", "-o", dest, path)
elif format == "gzip":
gzip(path, dest)
else:
raise RuntimeError(f"Unknown compression format {format}")
return dest
# --- Commands --------------------------------------------------------------- #
@cli.command("s", "scripts", "Manage scripts")
def _(args: cli.Args):
pass
@cli.command("d", "debug", "Debug a program")
def _(args: cli.Args):
wait = args.consumeOpt("wait", False) is True
debugger = args.consumeOpt("debugger", "lldb")
command = [str(args.consumeArg()), *args.extra]
debug(command, debugger=str(debugger), wait=wait)
@cli.command("p", "profile", "Profile a program")
def _(args: cli.Args):
command = [str(args.consumeArg()), *args.extra]
profile(command)
@cli.command("c", "compress", "Compress a file or directory")
def _(args: cli.Args):
path = str(args.consumeArg())
dest = args.consumeOpt("dest", None)
format = args.consumeOpt("format", "zstd")
compress(path, dest, format)

View file

@ -6,7 +6,7 @@ import hashlib
T = TypeVar("T") T = TypeVar("T")
def uniq(lst: list[T]) -> list[T]: def uniqPreserveOrder(lst: list[T]) -> list[T]:
result: list[T] = [] result: list[T] = []
for i in lst: for i in lst:
if i in result: if i in result:
@ -29,6 +29,10 @@ def hash(
return hashlib.sha256(data.encode("utf-8")).hexdigest() return hashlib.sha256(data.encode("utf-8")).hexdigest()
def randomHash() -> str:
return hashlib.sha256(os.urandom(32)).hexdigest()
def camelCase(s: str) -> str: def camelCase(s: str) -> str:
s = "".join(x for x in s.title() if x != "_" and x != "-") s = "".join(x for x in s.title() if x != "_" and x != "-")
s = s[0].lower() + s[1:] s = s[0].lower() + s[1:]

View file

@ -1,15 +1,15 @@
# Cutekit # Cutekit
Cutekit is a build system that aims to be simple, fast and easy to use. Cutekit is a build system that aims to be simple, fast and easy to use.
A project is described using json files. A project is described using json files.
## Project file ## Project file
The project file is used to describe the project and its dependencies. The project file is used to describe the project and its dependencies.
See: [doc/spec/project.md](doc/spec/project.md) for the full specification. See: [doc/spec/project.md](doc/spec/project.md) for the full specification.
Example: Example:
> project.json > project.json
```json ```json
@ -35,7 +35,7 @@ Here we describe a project with the id `skift-org/skift` and a dependency to `cu
When you want to create an executable package, you need to create a `manifest.json` file in any directory under `src/`. When you want to create an executable package, you need to create a `manifest.json` file in any directory under `src/`.
This is the file that describe an executable with its dependencies. This is the file that describe an executable with its dependencies.
> src/nyan-cat-app/manifest.json > src/nyan-cat-app/manifest.json
```json ```json
{ {
"$schema": "https://schemas.cute.engineering/stable/cutekit.manifest.component.v1", "$schema": "https://schemas.cute.engineering/stable/cutekit.manifest.component.v1",
@ -56,7 +56,7 @@ You can run the executable by running `$ ck run nyan-cat-app`.
When you want to create a library package, you need to create a `manifest.json` file in any directory under `src/`, like an executable package. When you want to create a library package, you need to create a `manifest.json` file in any directory under `src/`, like an executable package.
> src/easy-lib/manifest.json > src/easy-lib/manifest.json
```json ```json
{ {
"$schema": "https://schemas.cute.engineering/stable/cutekit.manifest.component.v1", "$schema": "https://schemas.cute.engineering/stable/cutekit.manifest.component.v1",

View file

@ -11,8 +11,6 @@ For example you can add a new command to the CLI:
from cutekit import cli from cutekit import cli
@cli.command("h", "hello", "Print hello world") @cli.command("h", "hello", "Print hello world")
def bootCmd(args: cli.Args) -> None: def _(args: cli.Args) -> None:
print("Hello world!") print("Hello world!")
``` ```
This feature is used - for example - by [SkiftOS](https://github.com/skift-org/skift/blob/main/meta/plugins/start-cmd.py) to add the `start` command, that build packages and run a virtual machine.

60
meta/nix/flake.lock Normal file
View file

@ -0,0 +1,60 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1705309234,
"narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1706098335,
"narHash": "sha256-r3dWjT8P9/Ah5m5ul4WqIWD8muj5F+/gbCdjiNVBKmU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a77ab169a83a4175169d78684ddd2e54486ac651",
"type": "github"
},
"original": {
"id": "nixpkgs",
"ref": "nixos-23.11",
"type": "indirect"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

36
meta/nix/flake.nix Normal file
View file

@ -0,0 +1,36 @@
{
description = "The *magical* build system and package manager";
inputs = {
nixpkgs.url = "nixpkgs/nixos-23.11";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
ck = pkgs.python311Packages.buildPythonApplication {
pyproject = true;
pname = "cutekit";
version = "0.7-dev";
src = ./../..;
nativeBuildInputs = with pkgs.python311Packages; [ setuptools ];
propagatedBuildInputs = with pkgs.python311Packages; [
dataclasses-json
docker
requests
graphviz
];
};
in
{
package.ck = ck;
defaultPackage = self.package.${system}.ck;
devShell = pkgs.mkShell {
buildInputs = [ ck ];
};
});
}

View file

@ -12,7 +12,7 @@ authors = [
{ name = "Cute Engineering", email = "contact@cute.engineering" }, { name = "Cute Engineering", email = "contact@cute.engineering" },
] ]
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.11"
license = { text = "MIT" } license = { text = "MIT" }
dynamic = ["version", "dependencies"] dynamic = ["version", "dependencies"]
@ -26,7 +26,7 @@ packages = ["cutekit"]
[tool.setuptools.dynamic] [tool.setuptools.dynamic]
version = { attr = "cutekit.const.VERSION" } version = { attr = "cutekit.const.VERSION" }
dependencies = { file = ["requirements.txt"] } dependencies = { file = ["cutekit/requirements.txt"] }
[tool.setuptools.package-data] [tool.setuptools.package-data]
"cutekit" = ["py.typed"] "cutekit" = ["py.typed", "requirements.txt", "entrypoint.sh", "bootstrap.sh"]

106
readme.md Normal file
View file

@ -0,0 +1,106 @@
<br/>
<br/>
<br/>
<p align="center">
<img src="logo.png" width="200" height="200">
</p>
<h1 align="center">CuteKit</h1>
<p align="center">
The *magical* build system and package manager
</p>
<br/>
<br/>
<br/>
## Introduction
**CuteKit** is a suite of tools and utilities for compiling, cross-compiling, linking, and packaging project written in low-level languages such as C, C++ or, Rust. Anything from a simple library to an operating system can be built using CuteKit.
- ✨ It uses **JSON**: Cutekit uses JSON instead of introducing a whole new programming language for describing the project. And also has macros for more advanced use cases (see [Jexpr](doc/spec/jexpr.md)).
- ✨ It's a **package manager**: Cutekit package manager is based on **Git**, nothing is centralized.
- ✨ It's **extendible**: Cutekit can be [extended](./doc/extends.md) by writing custom Python plugins.
- ✨ It's **easy**: the [**templates**](./doc/templates.md) help the user quick-start a project.
- ✨ It's **portable**: Cutekit works on Linux, Windows, and MacOS.
## CuteKit in the wild
- [SkiftOS](https://github.com/skift-org/skift) : A hobbyist operating system written in C++.
- [WKHtmlToPdf](https://github.com/odoo/wkhtmltopdf) : [Odoo](https://github.com/odoo/odoo)'s fork of wkhtmltopdf which is a command line tools to render HTML into PDF and various image formats using the Qt WebKit rendering engine.
## Installation
To install Cutekit, you may use your favourite package manager if it is available. Or you can install it manually by following the instructions below.
### By using pip
```bash
$ git clone https://github.com/cute-engineering/cutekit
$ cd cutekit
# If you want to use the latest version of Cutekit, you can switch to the dev branch.
# $ git switch dev
$ pip install --user -e .
```
### By using Nix
```bash
$ git clone https://github.com/cute-engineering/cutekit
$ cd cutekit
# If you want to use the latest version of Cutekit, you can switch to the dev branch.
# $ git switch dev
$ nix shell ./meta/nix
```
Or you can also use Cutekit in your flakes. For example:
```nix
{
description = "Hello cutekit";
inputs = {
# If you want to use the latest version of Cutekit, you can specify the dev branch.
# cutekit.url = "github:cute-engineering/cutekit/dev?dir=meta/nix";
cutekit.url = "github:cute-engineering/cutekit?dir=meta/nix";
nixpkgs.url = "nixpkgs/nixos-23.11";
};
outputs = {self, nixpkgs, cutekit, ... }:
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
ck = cutekit.defaultPackage.x86_64-linux;
in {
devShells.x86_64-linux.default = pkgs.mkShell {
packages = with pkgs; [
ck
];
};
};
}
```
## Quick-start
- If you directly want to start using Cutekit for a new project, you can just run `$ ck I host` and it will create a new project in the host directory (you can rename it later).
- If you want to use Cutekit for writing operating systems, you can create a new [limine](https://github.com/limine-bootloader/limine/)-based project by running `$ ck I limine-barebone`.
## Example
If you want to see how it works you can read the [doc/cutekit.md](doc/cutekit.md) file.
## License
<a href="https://opensource.org/licenses/MIT">
<img align="right" height="96" alt="MIT License" src="doc/mit.svg" />
</a>
Cutekit is licensed under the **MIT License**.
The full text of the license can be accessed via [this link](https://opensource.org/licenses/MIT) and is also included in the [license.md](license.md) file of this software package.

216
tests/test_cli.py Normal file
View file

@ -0,0 +1,216 @@
from cutekit import cli2, utils
from asserts import (
assert_is,
assert_equal,
assert_is_instance,
)
# --- Parse Values ----------------------------------------------------------- #
def test_parse_int_val():
assert_equal(cli2.parseValue("1"), 1)
assert_equal(cli2.parseValue("2"), 2)
assert_equal(cli2.parseValue("+2"), +2)
assert_equal(cli2.parseValue("-2"), -2)
def test_parse_true_val():
assert_equal(cli2.parseValue("true"), True)
assert_equal(cli2.parseValue("True"), True)
assert_equal(cli2.parseValue("y"), True)
assert_equal(cli2.parseValue("yes"), True)
assert_equal(cli2.parseValue("Y"), True)
assert_equal(cli2.parseValue("Yes"), True)
def test_parse_false_val():
assert_equal(cli2.parseValue("false"), False)
assert_equal(cli2.parseValue("False"), False)
assert_equal(cli2.parseValue("n"), False)
assert_equal(cli2.parseValue("no"), False)
assert_equal(cli2.parseValue("N"), False)
assert_equal(cli2.parseValue("No"), False)
def test_parse_str_val():
assert_equal(cli2.parseValue("foo"), "foo")
assert_equal(cli2.parseValue("'foo'"), "foo")
assert_equal(cli2.parseValue('"foo"'), "foo")
def test_parse_list_val():
assert_equal(cli2.parseValue("foo,bar"), ["foo", "bar"])
assert_equal(cli2.parseValue("'foo','bar'"), ["foo", "bar"])
assert_equal(cli2.parseValue('"foo","bar"'), ["foo", "bar"])
# --- Parse Args ------------------------------------------------------------- #
def test_parse_short_arg():
args = cli2.parseArg("-a")
assert_equal(len(args), 1)
arg = args[0]
assert_is_instance(arg, cli2.ArgumentToken)
assert_equal(arg.key, "a")
assert_equal(arg.value, True)
def test_parse_short_args():
args = cli2.parseArg("-abc")
assert_equal(len(args), 3)
arg = args[0]
assert_is_instance(arg, cli2.ArgumentToken)
assert_equal(arg.key, "a")
assert_equal(arg.value, True)
arg = args[1]
assert_is_instance(arg, cli2.ArgumentToken)
assert_equal(arg.key, "b")
assert_equal(arg.value, True)
arg = args[2]
assert_is_instance(arg, cli2.ArgumentToken)
assert_equal(arg.key, "c")
assert_equal(arg.value, True)
def test_parse_long_arg():
args = cli2.parseArg("--foo")
assert_equal(len(args), 1)
arg = args[0]
assert_is_instance(arg, cli2.ArgumentToken)
assert_equal(arg.key, "foo")
assert_equal(arg.value, True)
def test_parse_long_arg_with_value():
args = cli2.parseArg("--foo=bar")
assert_equal(len(args), 1)
arg = args[0]
assert_is_instance(arg, cli2.ArgumentToken)
assert_equal(arg.key, "foo")
assert_equal(arg.value, "bar")
def test_parse_long_arg_with_value_list():
args = cli2.parseArg("--foo=bar,baz")
assert_equal(len(args), 1)
arg = args[0]
assert_is_instance(arg, cli2.ArgumentToken)
assert_equal(arg.key, "foo")
assert_equal(arg.value, ["bar", "baz"])
def test_parse_key_subkey_arg():
args = cli2.parseArg("--foo:bar")
assert_equal(len(args), 1)
arg = args[0]
assert_is_instance(arg, cli2.ArgumentToken)
assert_equal(arg.key, "foo")
assert_equal(arg.subkey, "bar")
assert_equal(arg.value, True)
# --- Extract Args ----------------------------------------------------------- #
def extractParse(type: type[utils.T], args: list[str]) -> utils.T:
schema = cli2.Schema.extract(type)
return schema.parse(args)
def test_cli_arg_int():
class IntArg:
value: int = cli2.arg(None, "value")
assert_equal(extractParse(IntArg, ["--value=-1"]).value, -1)
assert_equal(extractParse(IntArg, ["--value=0"]).value, 0)
assert_equal(extractParse(IntArg, ["--value=1"]).value, 1)
"""
class StrArg:
value: str = cli2.arg(None, "value")
def test_cli_arg_str1():
v = extractParse(StrArg, ["--value=foo"])
assert v.value == "foo"
def test_cli_arg_str2():
v = extractParse(StrArg, ["--value='foo, bar'"])
assert v.value == "foo, bar"
class BoolArg:
value: bool = cli2.arg(None, "value")
def test_cli_arg_bool():
assert_is(extractParse(BoolArg, ["--value"]).value, True)
assert_is(extractParse(BoolArg, ["--value=true"]).value, True)
assert_is(extractParse(BoolArg, ["--value=True"]).value, True)
assert_is(extractParse(BoolArg, ["--value=y"]).value, True)
assert_is(extractParse(BoolArg, ["--value=yes"]).value, True)
assert_is(extractParse(BoolArg, ["--value=Y"]).value, True)
assert_is(extractParse(BoolArg, ["--value=Yes"]).value, True)
assert_is(extractParse(BoolArg, ["--value=1"]).value, True)
assert extractParse(BoolArg, ["--value=false"]).value is False
assert extractParse(BoolArg, ["--value=False"]).value is False
assert extractParse(BoolArg, ["--value=n"]).value is False
assert extractParse(BoolArg, ["--value=no"]).value is False
assert extractParse(BoolArg, ["--value=N"]).value is False
assert extractParse(BoolArg, ["--value=No"]).value is False
print(extractParse(BoolArg, ["--value=0"]).value)
assert extractParse(BoolArg, ["--value=0"]).value is False
class IntListArg:
value: list[int] = cli2.arg(None, "value")
def test_cli_arg_list_int1():
assert_equal(extractParse(IntListArg, []).value, [])
assert_equal(extractParse(IntListArg, ["--value=1", "--value=2"]).value, [1, 2])
assert_equal(extractParse(IntListArg, ["--value=1,2"]).value, [1, 2])
class StrListArg:
value: list[str] = cli2.arg(None, "value")
def test_cli_arg_list_str():
assert_equal(
extractParse(StrListArg, ["--value=foo", "--value=bar"]).value,
[
"foo",
"bar",
],
)
assert_equal(extractParse(StrListArg, ["--value=foo,bar"]).value, ["foo", "bar"])
assert_equal(extractParse(StrListArg, ["--value=foo,bar"]).value, ["foo", "bar"])
assert_equal(extractParse(StrListArg, ["--value='foo,bar'"]).value, ["foo,bar"])
assert_equal(extractParse(StrListArg, ["--value='foo, bar'"]).value, ["foo, bar"])
assert_equal(extractParse(StrListArg, ['--value="foo, bar"']).value, ["foo, bar"])
class StrDictArg:
value: dict[str, str] = cli2.arg(None, "value")
def test_cli_arg_dict_str():
assert_equal(extractParse(StrDictArg, ["--value:foo=bar"]).value, {"foo": "bar"})
assert_equal(
extractParse(StrDictArg, ["--value:foo=bar", "--value:baz=qux"]).value,
{
"foo": "bar",
"baz": "qux",
},
)
"""

View file

@ -2,7 +2,7 @@ from cutekit import model
def test_direct_deps(): def test_direct_deps():
r = model.Registry("") r = model.Registry(model.Project("myproj"))
r._append(model.Component("myapp", requires=["mylib"])) r._append(model.Component("myapp", requires=["mylib"]))
r._append(model.Component("mylib")) r._append(model.Component("mylib"))
t = model.Target("host") t = model.Target("host")
@ -10,32 +10,32 @@ def test_direct_deps():
resolved = res.resolve("myapp") resolved = res.resolve("myapp")
assert resolved.reason is None assert resolved.reason is None
assert resolved.resolved == ["myapp", "mylib"] assert resolved.required == ["myapp", "mylib"]
def test_indirect_deps(): def test_indirect_deps():
r = model.Registry("") r = model.Registry(model.Project("myproj"))
r._append(model.Component("myapp", requires=["mylib"])) r._append(model.Component("myapp", requires=["mylib"]))
r._append(model.Component("mylib", requires=["myembed"])) r._append(model.Component("mylib", requires=["myembed"]))
r._append(model.Component("myimpl", provides=["myembed"])) r._append(model.Component("myimpl", provides=["myembed"]))
t = model.Target("host") t = model.Target("host")
res = model.Resolver(r, t) res = model.Resolver(r, t)
assert res.resolve("myapp").resolved == ["myapp", "mylib", "myimpl"] assert res.resolve("myapp").required == ["myapp", "mylib", "myimpl"]
def test_deps_routing(): def test_deps_routing():
r = model.Registry("") r = model.Registry(model.Project("myproj"))
r._append(model.Component("myapp", requires=["mylib"])) r._append(model.Component("myapp", requires=["mylib"]))
r._append(model.Component("mylib", requires=["myembed"])) r._append(model.Component("mylib", requires=["myembed"]))
r._append(model.Component("myimplA", provides=["myembed"])) r._append(model.Component("myimplA", provides=["myembed"]))
r._append(model.Component("myimplB", provides=["myembed"])) r._append(model.Component("myimplB", provides=["myembed"]))
t = model.Target("host", routing={"myembed": "myimplB"}) t = model.Target("host", routing={"myembed": "myimplB"})
res = model.Resolver(r, t) res = model.Resolver(r, t)
assert res.resolve("myapp").resolved == ["myapp", "mylib", "myimplB"] assert res.resolve("myapp").required == ["myapp", "mylib", "myimplB"]
t = model.Target("host", routing={"myembed": "myimplA"}) t = model.Target("host", routing={"myembed": "myimplA"})
res = model.Resolver(r, t) res = model.Resolver(r, t)
assert res.resolve("myapp").resolved == ["myapp", "mylib", "myimplA"] assert res.resolve("myapp").required == ["myapp", "mylib", "myimplA"]
t = model.Target("host", routing={"myembed": "myimplC"}) t = model.Target("host", routing={"myembed": "myimplC"})
res = model.Resolver(r, t) res = model.Resolver(r, t)
@ -43,7 +43,7 @@ def test_deps_routing():
def test_deps_routing_with_props(): def test_deps_routing_with_props():
r = model.Registry("") r = model.Registry(model.Project("myproj"))
r._append(model.Component("myapp", requires=["mylib"])) r._append(model.Component("myapp", requires=["mylib"]))
r._append(model.Component("mylib", requires=["myembed"])) r._append(model.Component("mylib", requires=["myembed"]))
r._append( r._append(
@ -54,11 +54,11 @@ def test_deps_routing_with_props():
) )
t = model.Target("host", routing={"myembed": "myimplB"}, props={"myprop": "b"}) t = model.Target("host", routing={"myembed": "myimplB"}, props={"myprop": "b"})
res = model.Resolver(r, t) res = model.Resolver(r, t)
assert res.resolve("myapp").resolved == ["myapp", "mylib", "myimplB"] assert res.resolve("myapp").required == ["myapp", "mylib", "myimplB"]
t = model.Target("host", routing={"myembed": "myimplA"}, props={"myprop": "a"}) t = model.Target("host", routing={"myembed": "myimplA"}, props={"myprop": "a"})
res = model.Resolver(r, t) res = model.Resolver(r, t)
assert res.resolve("myapp").resolved == ["myapp", "mylib", "myimplA"] assert res.resolve("myapp").required == ["myapp", "mylib", "myimplA"]
t = model.Target("host", routing={"myembed": "myimplC"}, props={"myprop": "c"}) t = model.Target("host", routing={"myembed": "myimplC"}, props={"myprop": "c"})
res = model.Resolver(r, t) res = model.Resolver(r, t)
@ -68,7 +68,7 @@ def test_deps_routing_with_props():
def test_deps_routing_with_props_and_requires(): def test_deps_routing_with_props_and_requires():
r = model.Registry("") r = model.Registry(model.Project("myproj"))
r._append(model.Component("myapp", requires=["mylib"])) r._append(model.Component("myapp", requires=["mylib"]))
r._append(model.Component("mylib", requires=["myembed"])) r._append(model.Component("mylib", requires=["myembed"]))
r._append( r._append(
@ -79,11 +79,11 @@ def test_deps_routing_with_props_and_requires():
) )
t = model.Target("host", routing={"myembed": "myimplB"}, props={"myprop": "b"}) t = model.Target("host", routing={"myembed": "myimplB"}, props={"myprop": "b"})
res = model.Resolver(r, t) res = model.Resolver(r, t)
assert res.resolve("myapp").resolved == ["myapp", "mylib", "myimplB"] assert res.resolve("myapp").required == ["myapp", "mylib", "myimplB"]
t = model.Target("host", routing={"myembed": "myimplA"}, props={"myprop": "a"}) t = model.Target("host", routing={"myembed": "myimplA"}, props={"myprop": "a"})
res = model.Resolver(r, t) res = model.Resolver(r, t)
assert res.resolve("myapp").resolved == ["myapp", "mylib", "myimplA"] assert res.resolve("myapp").required == ["myapp", "mylib", "myimplA"]
t = model.Target("host", routing={"myembed": "myimplC"}, props={"myprop": "c"}) t = model.Target("host", routing={"myembed": "myimplC"}, props={"myprop": "c"})
res = model.Resolver(r, t) res = model.Resolver(r, t)