Merge pull request 'clan-cli: dev-shell, build, tab completion, formatting, linting, unit tests' (#11) from cli into main

Reviewed-on: #11
This commit is contained in:
Mic92 2023-07-21 09:26:57 +00:00
commit c87db7106a
17 changed files with 420 additions and 160 deletions

View File

@ -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" ];
};
};
}

View File

@ -1,4 +1,4 @@
{ self, ... }: {
{ ... }: {
pre-commit.settings.hooks.alejandra.enable = true;
pre-commit.settings.hooks.shellcheck.enable = true;
}

View File

@ -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 {

View File

@ -1,5 +1,4 @@
{ config
, lib
{ lib
, pkgs
, ...
}: {

1
pkgs/clan-cli/.envrc Normal file
View File

@ -0,0 +1 @@
use nix

View File

@ -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)

View File

@ -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()

129
pkgs/clan-cli/clan_admin.py Executable file
View File

@ -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()

View File

@ -1,10 +1,71 @@
{ symlinkJoin
, writers
{ pkgs ? import <nixpkgs> { }
, 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

View File

@ -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;
};
}

View File

@ -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
)/
'''

55
pkgs/clan-cli/shell.nix Normal file
View File

@ -0,0 +1,55 @@
{ pkgs ? import <nixpkgs> { }
,
}:
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

View File

@ -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

View File

@ -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:")

View File

@ -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

View File

@ -1,8 +1,6 @@
{
perSystem = { pkgs, ... }:
let
pyproject = builtins.fromTOML (builtins.readFile ./pyproject.toml);
name = pyproject.project.name;
package = pkgs.callPackage ./default.nix { };
in
{

View File

@ -1,5 +1,4 @@
{ pkgs ? import <nixpkgs> { }
, system ? builtins.currentSystem
,
}:
let