diff --git a/flake-parts/formatting.nix b/flake-parts/formatting.nix index a1b21597..15cbff2c 100644 --- a/flake-parts/formatting.nix +++ b/flake-parts/formatting.nix @@ -1,4 +1,4 @@ -{ self +{ lib , inputs , ... }: { @@ -9,7 +9,32 @@ treefmt.projectRootFile = "flake.nix"; treefmt.flakeCheck = true; treefmt.flakeFormatter = true; - treefmt.programs.nixpkgs-fmt.enable = true; treefmt.programs.shellcheck.enable = true; + treefmt.settings.formatter.nix = { + command = "sh"; + options = [ + "-eucx" + '' + # First deadnix + ${lib.getExe pkgs.deadnix} --edit "$@" + # Then nixpkgs-fmt + ${lib.getExe pkgs.nixpkgs-fmt} "$@" + '' + "--" # this argument is ignored by bash + ]; + includes = [ "*.nix" ]; + }; + treefmt.settings.formatter.python = { + command = "sh"; + options = [ + "-eucx" + '' + ${lib.getExe pkgs.ruff} --fix "$@" + ${lib.getExe pkgs.black} "$@" + '' + "--" # this argument is ignored by bash + ]; + includes = [ "*.py" ]; + }; }; } diff --git a/flake-parts/pre-commit.nix b/flake-parts/pre-commit.nix index d44afc88..fd579e9f 100644 --- a/flake-parts/pre-commit.nix +++ b/flake-parts/pre-commit.nix @@ -1,4 +1,4 @@ -{ self, ... }: { +{ ... }: { pre-commit.settings.hooks.alejandra.enable = true; pre-commit.settings.hooks.shellcheck.enable = true; } diff --git a/flake.nix b/flake.nix index 77741213..be2d9834 100644 --- a/flake.nix +++ b/flake.nix @@ -14,7 +14,6 @@ outputs = inputs @ { flake-parts, ... }: flake-parts.lib.mkFlake { inherit inputs; } ({ lib , config - , self , ... }: { systems = lib.systems.flakeExposed; @@ -22,6 +21,7 @@ ./flake-parts/packages.nix ./flake-parts/formatting.nix ./templates/python-project/flake-module.nix + ./pkgs/clan-cli/flake-module.nix ]; flake = { nixosConfigurations.installer = lib.nixosSystem { diff --git a/installer.nix b/installer.nix index 8da5d50f..2daef898 100644 --- a/installer.nix +++ b/installer.nix @@ -1,5 +1,4 @@ -{ config -, lib +{ lib , pkgs , ... }: { diff --git a/pkgs/clan-cli/.envrc b/pkgs/clan-cli/.envrc new file mode 100644 index 00000000..1d953f4b --- /dev/null +++ b/pkgs/clan-cli/.envrc @@ -0,0 +1 @@ +use nix diff --git a/pkgs/clan-cli/clan-admin.py b/pkgs/clan-cli/clan-admin.py deleted file mode 100755 index 9ec28898..00000000 --- a/pkgs/clan-cli/clan-admin.py +++ /dev/null @@ -1,122 +0,0 @@ -# !/usr/bin/env python3 -import argparse -import os -import subprocess - - -def create(args): - os.makedirs(args.folder, exist_ok=True) - # TODO create clan template in flake - subprocess.Popen( - [ - "nix", - "flake", - "init", - "-t", - "git+https://git.clan.lol/clan/clan-core#clan-template", - ] - ) - - -def edit(args): - # TODO add some cli options to change certain options without relying on a text editor - clan_flake = f"{args.folder}/flake.nix" - if os.path.isfile(clan_flake): - subprocess.Popen( - [ - os.environ["EDITOR"], - clan_flake, - ] - ) - else: - print( - f"{args.folder} has no flake.nix, so it does not seem to be the clan root folder" - ) - - -def rebuild(args): - # TODO get clients from zerotier cli? - if args.host: - print(f"would redeploy {args.host} from clan {args.folder}") - else: - print(f"would redeploy all hosts from clan {args.folder}") - - -def destroy(args): - # TODO get clan folder & hosts from somwhere (maybe ~/.config/clan/$name /) - # send some kind of kill signal, then remove the folder - if args.yes: - print(f"would remove {args.folder}") - else: - print( - "are you really sure? this is non reversible and destructive, add --yes to confirm" - ) - - -def backup(args): - if args.host: - print(f"would backup {args.host} from clan {args.folder}") - else: - print(f"would backup all hosts from clan {args.folder}") - - -def git(args): - subprocess.Popen( - [ - "git", - "-C", - args.folder, - ] + args.git_args - ) - - -parser = argparse.ArgumentParser(description="clan-admin") -parser.add_argument( - "-f", - "--folder", - help="the folder where the clan is defined, default to the current folder", - default=os.environ["PWD"], -) -subparser = parser.add_subparsers( - title="command", - description="the command to run", - help="the command to run", - required=True, -) - -parser_create = subparser.add_parser("create", help="create a new clan") -parser_create.set_defaults(func=create) - -parser_edit = subparser.add_parser("edit", help="edit a clan") -parser_edit.set_defaults(func=edit) - -parser_rebuild = subparser.add_parser( - "rebuild", help="build configuration of a clan and push it to the target" -) -parser_rebuild.add_argument( - "--host", help="specify single host to rebuild", default=None -) -parser_rebuild.set_defaults(func=rebuild) - -parser_destroy = subparser.add_parser( - "destroy", help="destroy a clan, including all the machines" -) -parser_destroy.add_argument( - "--yes", help="specify single host to rebuild", action="store_true" -) -parser_destroy.set_defaults(func=destroy) - -parser_backup = subparser.add_parser( - "backup", help="backup all the state of all machines in a clan or just a single one" -) -parser_backup.add_argument( - "--host", help="specify single host to rebuild", default=None -) -parser_backup.set_defaults(func=backup) - -parser_git = subparser.add_parser("git", help="control the clan repo via git") -parser_git.add_argument("git_args", nargs="*") -parser_git.set_defaults(func=git) - -args = parser.parse_args() -args.func(args) diff --git a/pkgs/clan-cli/clan.py b/pkgs/clan-cli/clan.py index 8528c2b0..45261fb4 100755 --- a/pkgs/clan-cli/clan.py +++ b/pkgs/clan-cli/clan.py @@ -1,24 +1,26 @@ # !/usr/bin/env python3 +import argparse import sys -import subprocess - - -def showhelp(): - print(''' - usage: - clan admin ... - clan join ... - clan delete ... - ''') - +has_argcomplete = True try: - cmd = f'clan-{sys.argv[1]}' -except: # noqa - showhelp() + import argcomplete +except ImportError: + has_argcomplete = False -try: - subprocess.Popen([cmd] + sys.argv[2:]) -except FileNotFoundError: - print(f'command {cmd} not found') - exit(2) +import clan_admin + + +# this will be the entrypoint under /bin/clan (see pyproject.toml config) +def clan() -> None: + parser = argparse.ArgumentParser(description="cLAN tool") + subparsers = parser.add_subparsers() + + # init clan admin + parser_admin = subparsers.add_parser("admin") + clan_admin.make_parser(parser_admin) + if has_argcomplete: + argcomplete.autocomplete(parser) + parser.parse_args() + if len(sys.argv) == 1: + parser.print_help() diff --git a/pkgs/clan-cli/clan_admin.py b/pkgs/clan-cli/clan_admin.py new file mode 100755 index 00000000..7b216fcf --- /dev/null +++ b/pkgs/clan-cli/clan_admin.py @@ -0,0 +1,129 @@ +# !/usr/bin/env python3 +import argparse +import os +import subprocess + + +def create(args: argparse.Namespace) -> None: + os.makedirs(args.folder, exist_ok=True) + # TODO create clan template in flake + subprocess.Popen( + [ + "nix", + "flake", + "init", + "-t", + "git+https://git.clan.lol/clan/clan-core#clan-template", + ] + ) + + +def edit(args: argparse.Namespace) -> None: # pragma: no cover + # TODO add some cli options to change certain options without relying on a text editor + clan_flake = f"{args.folder}/flake.nix" + if os.path.isfile(clan_flake): + subprocess.Popen( + [ + os.environ["EDITOR"], + clan_flake, + ] + ) + else: + print( + f"{args.folder} has no flake.nix, so it does not seem to be the clan root folder", + ) + + +def rebuild(args: argparse.Namespace) -> None: # pragma: no cover + # TODO get clients from zerotier cli? + if args.host: + print(f"would redeploy {args.host} from clan {args.folder}") + else: + print(f"would redeploy all hosts from clan {args.folder}") + + +def destroy(args: argparse.Namespace) -> None: # pragma: no cover + # TODO get clan folder & hosts from somwhere (maybe ~/.config/clan/$name /) + # send some kind of kill signal, then remove the folder + if args.yes: + print(f"would remove {args.folder}") + else: + print( + "are you really sure? this is non reversible and destructive, add --yes to confirm" + ) + + +def backup(args: argparse.Namespace) -> None: # pragma: no cover + if args.host: + print(f"would backup {args.host} from clan {args.folder}") + else: + print(f"would backup all hosts from clan {args.folder}") + + +def git(args: argparse.Namespace) -> None: # pragma: no cover + subprocess.Popen( + [ + "git", + "-C", + args.folder, + ] + args.git_args + ) + +# takes a (sub)parser and configures it +def make_parser(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "-f", + "--folder", + help="the folder where the clan is defined, default to the current folder", + default=os.environ["PWD"], + ) + subparser = parser.add_subparsers( + title="command", + description="the command to run", + help="the command to run", + required=True, + ) + + parser_create = subparser.add_parser("create", help="create a new clan") + parser_create.set_defaults(func=create) + + parser_edit = subparser.add_parser("edit", help="edit a clan") + parser_edit.set_defaults(func=edit) + + parser_rebuild = subparser.add_parser( + "rebuild", help="build configuration of a clan and push it to the target" + ) + parser_rebuild.add_argument( + "--host", help="specify single host to rebuild", default=None + ) + parser_rebuild.set_defaults(func=rebuild) + + parser_destroy = subparser.add_parser( + "destroy", help="destroy a clan, including all the machines" + ) + parser_destroy.add_argument( + "--yes", help="specify single host to rebuild", action="store_true" + ) + parser_destroy.set_defaults(func=destroy) + + parser_backup = subparser.add_parser( + "backup", help="backup all the state of all machines in a clan or just a single one" + ) + parser_backup.add_argument( + "--host", help="specify single host to rebuild", default=None + ) + parser_backup.set_defaults(func=backup) + + parser_git = subparser.add_parser("git", help="control the clan repo via git") + parser_git.add_argument("git_args", nargs="*") + parser_git.set_defaults(func=git) + + +def clan_admin() -> None: # pragma: no cover + parser = argparse.ArgumentParser(description="clan-admin") + args = parser.parse_args() + args.func(args) + +# entry point if this file is executed directly +if __name__ == "__main__": # pragma: no cover + clan_admin() diff --git a/pkgs/clan-cli/default.nix b/pkgs/clan-cli/default.nix index 72214801..74e88d5b 100644 --- a/pkgs/clan-cli/default.nix +++ b/pkgs/clan-cli/default.nix @@ -1,10 +1,71 @@ -{ symlinkJoin -, writers +{ pkgs ? import { } +, lib ? pkgs.lib +, python3 ? pkgs.python3 +, ruff ? pkgs.ruff +, runCommand ? pkgs.runCommand +, installShellFiles ? pkgs.installShellFiles +, }: -symlinkJoin { - name = "clan"; - paths = [ - (writers.writePython3Bin "clan" { } ./clan.py) - (writers.writePython3Bin "clan-admin" { flakeIgnore = [ "E501" ]; } ./clan-admin.py) - ]; -} +let + pyproject = builtins.fromTOML (builtins.readFile ./pyproject.toml); + name = pyproject.project.name; + + src = lib.cleanSource ./.; + + dependencies = lib.attrValues { + inherit (python3.pkgs) + argcomplete + ; + }; + + devDependencies = lib.attrValues { + inherit (pkgs) ruff; + inherit (python3.pkgs) + black + mypy + pytest + pytest-cov + pytest-subprocess + setuptools + wheel + ; + }; + + package = python3.pkgs.buildPythonPackage { + inherit name src; + format = "pyproject"; + nativeBuildInputs = [ + python3.pkgs.setuptools + installShellFiles + ]; + propagatedBuildInputs = + dependencies + ++ [ ]; + passthru.tests = { inherit check; }; + passthru.devDependencies = devDependencies; + postInstall = '' + installShellCompletion --bash --name clan \ + <(${python3.pkgs.argcomplete}/bin/register-python-argcomplete --shell bash clan) + installShellCompletion --fish --name clan.fish \ + <(${python3.pkgs.argcomplete}/bin/register-python-argcomplete --shell fish clan) + ''; + }; + + checkPython = python3.withPackages (_ps: devDependencies ++ dependencies); + + check = runCommand "${name}-check" { } '' + cp -r ${src} ./src + chmod +w -R ./src + cd src + export PYTHONPATH=. + echo -e "\x1b[32m## run ruff\x1b[0m" + ${ruff}/bin/ruff check . + echo -e "\x1b[32m## run mypy\x1b[0m" + ${checkPython}/bin/mypy . + echo -e "\x1b[32m## run pytest\x1b[0m" + ${checkPython}/bin/pytest + touch $out + ''; + +in +package diff --git a/pkgs/clan-cli/flake-module.nix b/pkgs/clan-cli/flake-module.nix new file mode 100644 index 00000000..6426e481 --- /dev/null +++ b/pkgs/clan-cli/flake-module.nix @@ -0,0 +1,12 @@ +{ + perSystem = { pkgs, ... }: + let + pyproject = builtins.fromTOML (builtins.readFile ./pyproject.toml); + name = pyproject.project.name; + package = pkgs.callPackage ./default.nix { }; + in + { + packages.${name} = package; + checks.${name} = package.tests.check; + }; +} diff --git a/pkgs/clan-cli/pyproject.toml b/pkgs/clan-cli/pyproject.toml new file mode 100644 index 00000000..33d3eb4f --- /dev/null +++ b/pkgs/clan-cli/pyproject.toml @@ -0,0 +1,65 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["clan.py"] + +[project] +name = "clan" +description = "cLAN CLI tool" +dynamic = ["version"] +scripts = {clan = "clan:clan"} + +[tool.pytest.ini_options] +addopts = "--cov . --cov-report term --cov-fail-under=100 --no-cov-on-fail" + +[tool.mypy] +python_version = "3.10" +warn_redundant_casts = true +disallow_untyped_calls = true +disallow_untyped_defs = true +no_implicit_optional = true +exclude = [ + "tests" +] + +[[tool.mypy.overrides]] +module = "setuptools.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "pytest.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "argcomplete.*" +ignore_missing_imports = true + +[tool.ruff] +line-length = 88 + +select = ["E", "F", "I"] +ignore = [ "E501" ] + +[tool.black] +line-length = 88 +target-version = ['py310'] +include = '\.pyi?$' +exclude = ''' +/( + \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist + # The following are specific to Black, you probably don't want those. + | blib2to3 + | tests/data + | profiling +)/ +''' diff --git a/pkgs/clan-cli/shell.nix b/pkgs/clan-cli/shell.nix new file mode 100644 index 00000000..79cb983b --- /dev/null +++ b/pkgs/clan-cli/shell.nix @@ -0,0 +1,55 @@ +{ pkgs ? import { } +, +}: +let + lib = pkgs.lib; + python3 = pkgs.python3; + package = import ./default.nix { + inherit lib python3; + }; + pythonWithDeps = python3.withPackages ( + ps: + package.propagatedBuildInputs + ++ package.devDependencies + ++ [ + ps.pip + ] + ); + checkScript = pkgs.writeScriptBin "check" '' + nix build -f . tests.check -L "$@" + ''; + devShell = pkgs.mkShell { + packages = [ + pkgs.ruff + pythonWithDeps + ]; + # sets up an editable install and add enty points to $PATH + shellHook = '' + tmp_path=$(realpath ./.pythonenv) + repo_root=$(realpath .) + rm -rf $tmp_path + mkdir -p "$tmp_path/${pythonWithDeps.sitePackages}" + + ${pythonWithDeps.interpreter} -m pip install \ + --quiet \ + --disable-pip-version-check \ + --no-index \ + --no-build-isolation \ + --prefix "$tmp_path" \ + --editable $repo_root + + export PATH="$tmp_path/bin:${checkScript}/bin:$PATH" + export PYTHONPATH="$repo_root:$tmp_path/${pythonWithDeps.sitePackages}" + + export XDG_DATA_DIRS="$tmp_path/share''${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}" + export fish_complete_path="$tmp_path/share/fish/vendor_completions.d''${fish_complete_path:+:$fish_complete_path}" + mkdir -p \ + $tmp_path/share/fish/vendor_completions.d \ + $tmp_path/share/bash-completion/completions \ + $tmp_path/share/zsh/site-functions + register-python-argcomplete --shell fish clan > $tmp_path/share/fish/vendor_completions.d/clan.fish + register-python-argcomplete --shell bash clan > $tmp_path/share/bash-completion/completions/clan + ''; + }; +in +devShell diff --git a/pkgs/clan-cli/tests/test_clan_admin.py b/pkgs/clan-cli/tests/test_clan_admin.py new file mode 100644 index 00000000..4440242b --- /dev/null +++ b/pkgs/clan-cli/tests/test_clan_admin.py @@ -0,0 +1,17 @@ +import argparse + +import clan_admin + + +def test_make_parser(): + parser = argparse.ArgumentParser() + clan_admin.make_parser(parser) + +# using fp fixture from pytest-subprocess +def test_create(fp): + cmd = ["nix", "flake", "init", "-t", fp.any()] + fp.register(cmd) + args = argparse.Namespace(folder="./my-clan") + clan_admin.create(args) + assert fp.call_count(cmd) == 1 + diff --git a/pkgs/clan-cli/tests/test_cli.py b/pkgs/clan-cli/tests/test_cli.py new file mode 100644 index 00000000..94d94cce --- /dev/null +++ b/pkgs/clan-cli/tests/test_cli.py @@ -0,0 +1,19 @@ +import sys + +import pytest + +import clan + + +def test_no_args(capsys): + clan.clan() + captured = capsys.readouterr() + assert captured.out.startswith("usage:") + + +def test_help(capsys, monkeypatch): + monkeypatch.setattr(sys, "argv", ["", "--help"]) + with pytest.raises(SystemExit): + clan.clan() + captured = capsys.readouterr() + assert captured.out.startswith("usage:") diff --git a/templates/python-project/default.nix b/templates/python-project/default.nix index 30dae1b7..18fa4c6f 100644 --- a/templates/python-project/default.nix +++ b/templates/python-project/default.nix @@ -42,7 +42,7 @@ let passthru.devDependencies = devDependencies; }; - checkPython = python3.withPackages (ps: devDependencies ++ dependencies); + checkPython = python3.withPackages (_ps: devDependencies ++ dependencies); check = runCommand "${name}-check" { } '' cp -r ${src} ./src diff --git a/templates/python-project/flake-module.nix b/templates/python-project/flake-module.nix index 74218c6d..cfd5c552 100644 --- a/templates/python-project/flake-module.nix +++ b/templates/python-project/flake-module.nix @@ -1,8 +1,6 @@ { perSystem = { pkgs, ... }: let - pyproject = builtins.fromTOML (builtins.readFile ./pyproject.toml); - name = pyproject.project.name; package = pkgs.callPackage ./default.nix { }; in { diff --git a/templates/python-project/shell.nix b/templates/python-project/shell.nix index 2b7b0748..861fe5ef 100644 --- a/templates/python-project/shell.nix +++ b/templates/python-project/shell.nix @@ -1,5 +1,4 @@ { pkgs ? import { } -, system ? builtins.currentSystem , }: let