acbox 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
acbox/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ __version__ = "0.0.0"
2
+ __hash__ = "de1dc99"
acbox/cli/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,44 @@
1
+ import argparse
2
+
3
+
4
+ class ArgumentTypeBase:
5
+ class _NA:
6
+ pass
7
+
8
+ def __init__(self, *args, **kwargs):
9
+ self.args = args
10
+ self.kwargs = kwargs
11
+ self._default = self._NA
12
+ self.__name__ = self.__class__.__name__
13
+
14
+ @property
15
+ def default(self):
16
+ return self._default
17
+
18
+ @default.setter
19
+ def default(self, value):
20
+ if value is ArgumentTypeBase._NA:
21
+ self._default = ArgumentTypeBase._NA
22
+ else:
23
+ self._default = self._validate(value)
24
+ return self._default
25
+
26
+ def __call__(self, txt):
27
+ self._value = None
28
+ self._value = self._validate(txt)
29
+ return self
30
+
31
+ @property
32
+ def value(self):
33
+ return getattr(self, "_value", self.default)
34
+
35
+ def _validate(self, value):
36
+ try:
37
+ return self.validate(value)
38
+ except argparse.ArgumentTypeError as exc:
39
+ if not hasattr(self, "_value"):
40
+ raise RuntimeError(f"cannot use {value=} as default: {exc.args[0]}")
41
+ raise
42
+
43
+ def validate(self, txt):
44
+ raise NotImplementedError("need to implement the .validate(self, txt) method")
@@ -0,0 +1,34 @@
1
+ import argparse
2
+ import logging
3
+ from typing import Any
4
+
5
+ from ..parsers.base import ArgumentParserBase
6
+
7
+
8
+ def add_arguments_logging(parser: ArgumentParserBase, baselevel: int = logging.INFO) -> None:
9
+ group = parser.add_argument_group("Logging", "Logging related options")
10
+ group.add_argument("-v", "--verbose", dest="managed-loglevel", action="append_const", const=1, help="report verbose logging")
11
+ group.add_argument("-q", "--quiet", dest="managed-loglevel", action="append_const", const=-1, help="report quiet logging")
12
+
13
+ levelmap = [
14
+ logging.DEBUG,
15
+ logging.INFO,
16
+ logging.WARNING,
17
+ logging.ERROR,
18
+ logging.CRITICAL,
19
+ logging.FATAL,
20
+ ]
21
+ if baselevel not in levelmap:
22
+ raise IndexError(f"cannot find level {baselevel} in: {levelmap}")
23
+
24
+ def setup_logging(config: dict[str, Any]) -> None:
25
+ logging.basicConfig(**config)
26
+
27
+ def callback(args: argparse.Namespace):
28
+ config = {}
29
+ count = levelmap.index(baselevel) - sum(getattr(args, "managed-loglevel") or [0])
30
+ config["level"] = levelmap[min(max(count, 0), len(levelmap) - 1)]
31
+ setup_logging(config)
32
+ delattr(args, "managed-loglevel")
33
+
34
+ parser.callbacks.append(callback)
File without changes
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import types
5
+
6
+ from ..flags.base import ArgumentTypeBase
7
+ from ..shared import ArgsCallback, check_default_constructor
8
+
9
+
10
+ class ArgumentParserBase(argparse.ArgumentParser):
11
+ def __init__(self, modules: list[types.ModuleType], *args, **kwargs):
12
+ super().__init__(*args, **kwargs)
13
+ self.modules = modules
14
+ self.callbacks: list[ArgsCallback | None] = []
15
+
16
+ def _post_process(self, options):
17
+ for name in dir(options):
18
+ if isinstance(getattr(options, name), ArgumentTypeBase):
19
+ fallback = getattr(options, name).value
20
+ setattr(
21
+ options,
22
+ name,
23
+ None if fallback is ArgumentTypeBase._NA else fallback,
24
+ )
25
+ for callback in self.callbacks:
26
+ options = callback(options) or options
27
+ return options
28
+
29
+ def parse_known_args(self, args=None, namespace=None):
30
+ options, argv = super().parse_known_args(args, namespace)
31
+ return self._post_process(options), argv
32
+
33
+ def parse_args(self, args=None, namespace=None):
34
+ options = super().parse_args(args, namespace)
35
+ for name in dir(options):
36
+ if isinstance(getattr(options, name), ArgumentTypeBase):
37
+ fallback = getattr(options, name).value
38
+ setattr(
39
+ options,
40
+ name,
41
+ None if fallback is ArgumentTypeBase._NA else fallback,
42
+ )
43
+ return options
44
+
45
+ def add_argument(self, *args, **kwargs):
46
+ typ = kwargs.get("type")
47
+ obj = None
48
+ if isinstance(typ, type) and issubclass(typ, ArgumentTypeBase):
49
+ check_default_constructor(typ)
50
+ obj = typ()
51
+ if isinstance(typ, ArgumentTypeBase):
52
+ obj = typ
53
+ if obj is not None:
54
+ obj.default = kwargs.get("default", ArgumentTypeBase._NA)
55
+ kwargs["default"] = obj
56
+ kwargs["type"] = obj
57
+ super().add_argument(*args, **kwargs)
58
+
59
+ def error(self, message):
60
+ try:
61
+ super().error(message)
62
+ except SystemExit:
63
+ # gh-121018
64
+ raise argparse.ArgumentError(None, message)
65
+
66
+ @classmethod
67
+ def get_parser(cls, modules: list[types.ModuleType], **kwargs):
68
+ raise NotImplementedError("implement this method")
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1,47 @@
1
+ import argparse
2
+ import logging
3
+ import sys
4
+ import types
5
+
6
+ from .base import ArgumentParserBase
7
+
8
+ log = logging.getLogger(__name__)
9
+
10
+
11
+ def log_sys_info(modules: list[types.ModuleType]):
12
+ log.debug("interpreter: %s", sys.executable)
13
+
14
+
15
+ class ArgumentParser(ArgumentParserBase):
16
+ def parse_args(self, args=None, namespace=None):
17
+ options = super().parse_args(args, namespace)
18
+
19
+ # reserver attributes
20
+ for reserved in [
21
+ "modules",
22
+ "error",
23
+ ]:
24
+ if not hasattr(options, reserved):
25
+ continue
26
+ raise RuntimeError(f"cannot add an argument with dest='{reserved}'")
27
+ options.error = self.error
28
+ options.modules = self.modules
29
+
30
+ for callback in self.callbacks:
31
+ if not callback:
32
+ continue
33
+ options = callback(options) or options
34
+
35
+ log_sys_info(self.modules)
36
+ return options
37
+
38
+ @classmethod
39
+ def get_parser(cls, modules: list[types.ModuleType], **kwargs):
40
+ class Formatter(
41
+ argparse.RawTextHelpFormatter,
42
+ argparse.RawDescriptionHelpFormatter,
43
+ argparse.ArgumentDefaultsHelpFormatter,
44
+ ):
45
+ pass
46
+
47
+ return cls(modules, formatter_class=Formatter, **kwargs)
acbox/cli/script.py ADDED
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import contextlib
5
+ import functools
6
+ import inspect
7
+ import logging
8
+ import sys
9
+ import time
10
+ from typing import Any, Callable
11
+
12
+ from .parsers.base import ArgumentParserBase
13
+ from .parsers.simple import ArgumentParser
14
+ from .shared import AbortCliError, AbortWrongArgumentError
15
+
16
+ log = logging.getLogger(__name__)
17
+
18
+
19
+ @contextlib.contextmanager
20
+ def setup(
21
+ function: Callable,
22
+ add_arguments: (Callable[[ArgumentParserBase], None] | Callable[[argparse.ArgumentParser], None] | None) = None,
23
+ process_args: (Callable[[argparse.Namespace], argparse.Namespace | None] | None) = None,
24
+ ):
25
+ sig = inspect.signature(function)
26
+ module = inspect.getmodule(function)
27
+
28
+ # the cli decorated function might have two special parameters:
29
+ # - args this will receive non-parsed arguments (eg from nargs="*")
30
+ # - parser escape hatch to the parser object (probably never used)
31
+ if "args" in sig.parameters and "parser" in sig.parameters:
32
+ raise RuntimeError(f"function '{module}.{function.__name__}' cannot take args and parser at the same time")
33
+
34
+ # doc is taken from the function itself or the containing module
35
+ description, _, epilog = (function.__doc__ or module.__doc__ or "").strip().partition("\n")
36
+ epilog = f"{description}\n{'-' * len(description)}\n{epilog}"
37
+ description = ""
38
+
39
+ # extract parser info/fallbacks from all these modules
40
+ modules = [
41
+ sys.modules[__name__],
42
+ ]
43
+ if module:
44
+ modules.append(module)
45
+
46
+ parser = ArgumentParser.get_parser(modules, description=description, epilog=epilog)
47
+ if add_arguments and (callbacks := add_arguments(parser)):
48
+ if isinstance(callbacks, list):
49
+ parser.callbacks.extend(callbacks)
50
+ else:
51
+ parser.callbacks.append(callbacks)
52
+
53
+ kwargs = {}
54
+ if "parser" in sig.parameters:
55
+ kwargs["parser"] = parser
56
+
57
+ t0 = time.monotonic()
58
+ success = "completed"
59
+ errormsg = ""
60
+ show_timing = True
61
+ # breakpoint()
62
+ try:
63
+ if "parser" not in sig.parameters:
64
+ args = parser.parse_args()
65
+ if process_args:
66
+ args = process_args(args) or args
67
+
68
+ if "args" in sig.parameters:
69
+ kwargs["args"] = args
70
+ yield sig.bind(**kwargs)
71
+
72
+ except argparse.ArgumentError:
73
+ sys.exit(2)
74
+ pass
75
+ except AbortCliError as exc:
76
+ show_timing = False
77
+ if exc.args:
78
+ print(str(exc), file=sys.stderr)
79
+ sys.exit(2)
80
+ except AbortWrongArgumentError as exc:
81
+ show_timing = False
82
+ parser.print_usage(sys.stderr)
83
+ print(f"{parser.prog}: error: {exc.args[0]}", file=sys.stderr)
84
+ sys.exit(2)
85
+ except SystemExit as exc:
86
+ show_timing = False
87
+ sys.exit(exc.code)
88
+ except Exception:
89
+ log.exception("un-handled exception")
90
+ success = "failed with an exception"
91
+ finally:
92
+ if show_timing:
93
+ delta = round(time.monotonic() - t0, 2)
94
+ log.debug("task %s in %.2fs", success, delta)
95
+ if errormsg:
96
+ parser.error(errormsg)
97
+
98
+
99
+ def cli(
100
+ add_arguments: (Callable[[ArgumentParserBase], Any] | Callable[[argparse.ArgumentParser], Any] | None) = None,
101
+ process_args: (Callable[[argparse.Namespace], argparse.Namespace | None] | None) = None,
102
+ ):
103
+ def _cli1(function):
104
+ module = inspect.getmodule(function)
105
+
106
+ if inspect.iscoroutinefunction(function):
107
+
108
+ @functools.wraps(function)
109
+ async def _cli2(*args, **kwargs):
110
+ with setup(function, add_arguments, process_args) as ba:
111
+ return await function(*ba.args, **ba.kwargs)
112
+
113
+ else:
114
+
115
+ @functools.wraps(function)
116
+ def _cli2(*args, **kwargs):
117
+ with setup(function, add_arguments, process_args) as ba:
118
+ return function(*ba.args, **ba.kwargs)
119
+
120
+ _cli2.attributes = {
121
+ "doc": function.__doc__ or module.__doc__ or "",
122
+ }
123
+ return _cli2
124
+
125
+ return _cli1
acbox/cli/shared.py ADDED
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import inspect
5
+ import sys
6
+ from typing import Callable
7
+
8
+ # The return type of add_arguments
9
+
10
+ if sys.version_info >= (3, 10):
11
+ ArgsCallback = Callable[[argparse.Namespace], None | argparse.Namespace]
12
+ else:
13
+ ArgsCallback = Callable
14
+
15
+
16
+ class CliBaseError(Exception):
17
+ pass
18
+
19
+
20
+ class AbortCliError(CliBaseError):
21
+ pass
22
+
23
+
24
+ class AbortWrongArgumentError(CliBaseError):
25
+ pass
26
+
27
+
28
+ def check_default_constructor(klass: type):
29
+ signature = inspect.signature(klass.__init__) # type: ignore[misc]
30
+ for name, value in signature.parameters.items():
31
+ if name in {"self", "args", "kwargs"}:
32
+ continue
33
+ if value.default is inspect.Signature.empty:
34
+ raise RuntimeError(f"the {klass}() cannot be called without arguments")
acbox/cli2.py ADDED
@@ -0,0 +1,59 @@
1
+ import functools
2
+ import logging
3
+ from argparse import Namespace
4
+ from typing import Callable
5
+
6
+ import click
7
+
8
+ TypeFn = Callable[[Namespace], None]
9
+
10
+
11
+ def add_loglevel(fn: TypeFn) -> TypeFn:
12
+ fn = click.option("-v", "--verbose", count=True)(fn)
13
+ fn = click.option("-q", "--quiet", count=True)(fn)
14
+ return fn
15
+
16
+
17
+ def process_loglevel(options: Namespace, verbose_flag: bool = False) -> Namespace:
18
+ level = max(min(options.__dict__.pop("verbose") - options.__dict__.pop("quiet"), 1), -1)
19
+
20
+ # console = Console(theme=Theme({"log.time": "cyan"}))
21
+ logging.basicConfig(
22
+ level={-1: logging.WARNING, 0: logging.INFO, 1: logging.DEBUG}[level],
23
+ # datefmt="[%X]",
24
+ # handlers=[RichHandler(console=console, rich_tracebacks=True)]
25
+ )
26
+ if verbose_flag:
27
+ options.verbose = level > 0
28
+ return options
29
+
30
+
31
+ def clickwrapper(
32
+ add_arguments: Callable[[TypeFn], TypeFn] | None = None,
33
+ process_options: Callable[[Namespace], None | Namespace] | None = None,
34
+ verbose_flag: bool = False,
35
+ ) -> Callable[[TypeFn], None]:
36
+ def _clickwrapper(fn: TypeFn):
37
+ fn = add_loglevel(fn)
38
+ if add_arguments:
39
+ fn = add_arguments(fn)
40
+
41
+ @functools.wraps(fn)
42
+ def __clickwrapper(*args, **kwargs):
43
+ options = Namespace(**kwargs)
44
+ options = process_loglevel(options, verbose_flag=verbose_flag) or options
45
+ if hasattr(options, "error"):
46
+ raise RuntimeError("you have an error option")
47
+
48
+ def error(msg):
49
+ raise click.UsageError(msg)
50
+
51
+ options.error = error
52
+ if process_options:
53
+ options = process_options(options) or options
54
+
55
+ return fn(options)
56
+
57
+ return __clickwrapper
58
+
59
+ return _clickwrapper
acbox/dictionaries.py ADDED
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable, MutableMapping
4
+ from typing import Any
5
+
6
+ DELETE = object()
7
+
8
+
9
+ def dictupdate(data: MutableMapping[str, Any], updates: Iterable[tuple[str, Any]]) -> list[tuple[str, Any]]:
10
+ undo = []
11
+ for path, value in updates:
12
+ cur = data
13
+ loc = path.split(".")
14
+ for i in range(len(loc) - 1):
15
+ if loc[i] not in cur:
16
+ cur[loc[i]] = dict()
17
+ undo.append((".".join(loc[: i + 1]), DELETE))
18
+ cur = cur[loc[i]]
19
+
20
+ orig = cur.get(loc[-1], DELETE)
21
+ undo.append((path, orig))
22
+ if value is DELETE:
23
+ del cur[loc[-1]]
24
+ else:
25
+ cur[loc[-1]] = value
26
+
27
+ return undo
28
+
29
+
30
+ def dictrollback(data: MutableMapping[str, Any], undo: list[tuple[str, Any]]) -> None:
31
+ for path, value in reversed(undo):
32
+ cur = data
33
+ loc = path.split(".")
34
+ for c in loc[:-1]:
35
+ cur = cur[c]
36
+ if value is DELETE:
37
+ del cur[loc[-1]]
38
+ else:
39
+ cur[loc[-1]] = value
acbox/fileops.py ADDED
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import os
5
+ import shutil
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Callable, Generator
9
+
10
+
11
+ def which_n(exe: str | Path) -> list[Path] | None:
12
+ candidates: list[Path] | None = None
13
+ for srcdir in os.environ.get("PATH", "").split(os.pathsep):
14
+ for ext in os.environ.get("PATHEXT", "").split(os.pathsep):
15
+ path = srcdir / Path(exe).with_suffix(ext)
16
+ if not path.exists():
17
+ continue
18
+ if candidates is None:
19
+ candidates = []
20
+ candidates.append(path)
21
+ return candidates
22
+
23
+
24
+ def which(exe: str | Path) -> Path | None:
25
+ candidates = which_n(exe)
26
+ if candidates is None:
27
+ return None
28
+ return candidates[0]
29
+
30
+
31
+ @contextlib.contextmanager
32
+ def tmpdir(source: Path | None) -> Generator[Path, None, None]:
33
+ wdir = source if source else Path(tempfile.mkdtemp())
34
+ wdir.mkdir(parents=True, exist_ok=True)
35
+ try:
36
+ yield wdir
37
+ finally:
38
+ if not source:
39
+ shutil.rmtree(wdir, ignore_errors=True)
40
+
41
+
42
+ @contextlib.contextmanager
43
+ def backups() -> Generator[Callable[[Path | str], tuple[Path, Path]], None, None]:
44
+ pathlist: list[Path] = []
45
+
46
+ def save(path: Path | str) -> tuple[Path, Path]:
47
+ nonlocal pathlist
48
+ original = Path(path).expanduser().absolute()
49
+ backup = original.parent / f"{original.name}.bak"
50
+ if backup.exists():
51
+ raise RuntimeError("backup file present", backup)
52
+ shutil.copy(original, backup)
53
+ pathlist.append(backup)
54
+ return original, backup
55
+
56
+ try:
57
+ yield save
58
+ finally:
59
+ for backup in pathlist:
60
+ original = backup.with_suffix("")
61
+ original.unlink()
62
+ shutil.move(backup, original)
acbox/git.py ADDED
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses as dc
4
+ from pathlib import Path
5
+
6
+ from .runner import Paths, Runner
7
+
8
+
9
+ @dc.dataclass
10
+ class Git:
11
+ workdir: Path
12
+ runc: Runner
13
+
14
+ @classmethod
15
+ def new(cls, workdir: Path, verbose: bool) -> Git:
16
+ workdir = workdir.expanduser().absolute()
17
+ return cls(workdir, runc=Runner(verbose=verbose, exe=["git", "--git-dir", f"{workdir}/.git"]))
18
+
19
+ # @classmethod
20
+ # def clone(cls, url: str, workdir: Path, args: Paths | None = None, verbose: bool = False) -> Git:
21
+ # runc = Runner(verbose=True)
22
+ # runc(["git", "clone", *(args or []), url, workdir])
23
+ # return cls.new(workdir, verbose)
24
+
25
+ def __call__(self, args: Paths) -> str:
26
+ out = self.runc(args, capture=True) or ""
27
+ return (out.decode("utf-8") if isinstance(out, bytes) else out).strip()
28
+
29
+ def branch(self, name: str = "HEAD") -> str:
30
+ return self(["rev-parse", "--abbrev-ref", name])
31
+
32
+ def commits_on_branch(self, main: str = "origin/main"):
33
+ changes = self(["reflog", "show", "--no-abbrev", self.branch()])
34
+ return len([n for n in changes.split("\n") if n.strip()])
35
+ # parent = self(["merge-base", self.branch(), main]).strip()
36
+ # return int(self(["rev-list", "--count", f"{parent}..{self.branch()}"]))
acbox/loader.py ADDED
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses as dc
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Any, Literal, Sequence
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ Paths = str | Path | Sequence[str | Path]
11
+
12
+
13
+ def makepaths(args: Paths) -> list[Path]:
14
+ if isinstance(args, (str, Path)):
15
+ return [Path(args)]
16
+ return [Path(a) for a in args]
17
+
18
+
19
+ @dc.dataclass
20
+ class LoaderBase:
21
+ paths: list[Path] | None = None
22
+
23
+ def __post_init__(self):
24
+ self.paths = [Path(p).expanduser() for p in (self.paths or [])]
25
+
26
+ def lookup(self, name: Path | str) -> Path | None:
27
+ for item in self.paths or [Path.cwd()]:
28
+ if (found := (item / name)).exists():
29
+ return found
30
+ return None
31
+
32
+ def load(self, name: Path | str, mode: Literal["auto", "binary", "raw", "text"] = "auto") -> Any:
33
+ if not (path := self.lookup(name)):
34
+ raise FileNotFoundError(2, "cannot lookup name", name)
35
+
36
+ if mode in {"binary", "raw"}:
37
+ return path.read_bytes()
38
+ elif mode == "text":
39
+ return path.read_text()
40
+
41
+ kind = path.suffix
42
+ if kind.upper() in {".JSON"}:
43
+ from json import loads
44
+
45
+ return loads(self.load(path.absolute(), "text"))
46
+ elif kind.upper() in {".YAML", ".YML"}:
47
+ from yaml import safe_load
48
+
49
+ return safe_load(self.load(path.absolute(), "text"))
50
+ else:
51
+ raise TypeError(f"cannot find type ({kind}) for {path}")
52
+ return None
acbox/maincli.py ADDED
@@ -0,0 +1,8 @@
1
+ import click
2
+ import rich
3
+
4
+
5
+ def main():
6
+ print("Hi! from", __file__)
7
+ print("Got", rich)
8
+ print("Got", click)
acbox/packer.py ADDED
@@ -0,0 +1,25 @@
1
+ # from https://packaging.python.org/en/latest/specifications/inline-script-metadata/#inline-script-metadata
2
+ import re
3
+ from pathlib import Path
4
+
5
+ import tomllib
6
+
7
+ REGEX = r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$"
8
+
9
+
10
+ def read_header(path: Path | str) -> dict | None:
11
+ text = path if isinstance(path, str) else path.read_text()
12
+
13
+ name = "script"
14
+ matches = list(filter(lambda m: m.group("type") == name, re.finditer(REGEX, text)))
15
+ if len(matches) > 1:
16
+ raise ValueError(f"Multiple {name} blocks found")
17
+ elif len(matches) == 1:
18
+ content = "".join(line[2:] if line.startswith("# ") else line[1:] for line in matches[0].group("content").splitlines(keepends=True))
19
+ return tomllib.loads(content)
20
+ else:
21
+ return None
22
+
23
+
24
+ if __name__ == "__main__":
25
+ print(read_header(Path("support/builder.py")))
acbox/run1.py ADDED
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses as dc
4
+ import logging
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import threading
9
+ import time
10
+ from pathlib import Path
11
+ from typing import BinaryIO, Literal, Sequence
12
+
13
+ COLORS = {
14
+ "blue": "\033[94m",
15
+ "green": "\033[92m",
16
+ "red": "\033[91m",
17
+ "clear": "\033[0m",
18
+ }
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class RunnerError(Exception):
24
+ pass
25
+
26
+
27
+ @dc.dataclass
28
+ class BaseFilter:
29
+ def __call__(self, stream: BinaryIO) -> None:
30
+ for line in iter(stream.readline, b""):
31
+ pass
32
+
33
+
34
+ OMode = Literal["capture", "null", "display", "capture+display"] | BaseFilter
35
+ EMode = Literal["null", "display"] | BaseFilter
36
+ Paths = str | Path | Sequence[str | Path]
37
+
38
+
39
+ @dc.dataclass
40
+ class CaptureFilter(BaseFilter):
41
+ encode: str | None = "utf-8"
42
+ result: str | bytes | None = None
43
+
44
+ def __call__(self, stream: BinaryIO) -> None:
45
+ result = []
46
+ for line in iter(stream.readline, b""):
47
+ result.append(line[:-1])
48
+ stream.close()
49
+ if self.encode:
50
+ self.result = b"\n".join(result).decode(self.encode)
51
+ else:
52
+ self.result = b"\n".join(result)
53
+
54
+
55
+ @dc.dataclass
56
+ class DisplayFilter(BaseFilter):
57
+ color: str | None
58
+ pre: str = " | "
59
+ clear: str = COLORS["clear"]
60
+ capture: bool = False
61
+ encode: str | None = "utf-8"
62
+ result: str | bytes | None = None
63
+
64
+ def __call__(self, stream: BinaryIO) -> None:
65
+ result = []
66
+ for rawline in iter(stream.readline, b""):
67
+ if self.capture:
68
+ result.append(rawline[:-1])
69
+ line = rawline.decode("utf-8")
70
+ if line.strip().startswith("Warning:"):
71
+ line = line.replace("Warning:", f"{COLORS['red']}Warning:{self.clear}{self.color}")
72
+ print(
73
+ f"{self.pre}{self.color}{line.rstrip()}{self.clear}",
74
+ flush=True,
75
+ file=sys.stderr,
76
+ )
77
+ stream.close()
78
+ if self.capture:
79
+ self.result = b"\n".join(result).decode(self.encode) if self.encode else b"\n".join(result)
80
+
81
+
82
+ def mkpaths(args: Paths) -> list[str]:
83
+ return [str(args)] if isinstance(args, (str, Path)) else [str(a) for a in args]
84
+
85
+
86
+ def runc(
87
+ args: Paths,
88
+ stdout: OMode = "display",
89
+ stderr: EMode = "display",
90
+ overrides: dict[str, str] | None = None,
91
+ **kwargs,
92
+ ) -> str | bytes | None:
93
+ kwargs["env"] = kwargs.pop("env") if "env" in kwargs else os.environ.copy()
94
+ kwargs["env"].update(overrides or {})
95
+ kwargs["cwd"] = (str(v) if (v := kwargs.pop("cwd")) else None) if "cwd" in kwargs else None
96
+
97
+ with subprocess.Popen(mkpaths(args), stderr=subprocess.PIPE, stdout=subprocess.PIPE, **kwargs) as process:
98
+ if stdout == "capture":
99
+ ofiltermap: BaseFilter = CaptureFilter()
100
+ elif stdout == "null":
101
+ ofiltermap = BaseFilter()
102
+ elif stdout == "display":
103
+ ofiltermap = DisplayFilter(COLORS["blue"], " | ")
104
+ elif stdout == "capture+display":
105
+ ofiltermap = DisplayFilter(COLORS["blue"], " | ", capture=True)
106
+ elif isinstance(stdout, BaseFilter):
107
+ ofiltermap = stdout
108
+ else:
109
+ raise RuntimeError(f"unsupported type in {stdout=}")
110
+ othread = threading.Thread(target=ofiltermap, args=(process.stdout,), daemon=True)
111
+
112
+ if stderr == "null":
113
+ efiltermap: BaseFilter = BaseFilter()
114
+ elif stderr == "display":
115
+ efiltermap = DisplayFilter(COLORS["green"], " | ")
116
+ elif isinstance(stderr, BaseFilter):
117
+ efiltermap = stderr
118
+ else:
119
+ raise RuntimeError(f"unsupported type in {stderr=}")
120
+ ethread = threading.Thread(
121
+ target=efiltermap,
122
+ args=(process.stderr,),
123
+ daemon=True,
124
+ )
125
+ othread.start()
126
+ ethread.start()
127
+ while process.poll() is not None:
128
+ time.sleep(0.05)
129
+ othread.join()
130
+ ethread.join()
131
+
132
+ if process.returncode:
133
+ envs = " ".join(f'{k}="{v}"' for k, v in (overrides or {}).items())
134
+ cmdline = subprocess.list2cmdline(mkpaths(args))
135
+ raise RunnerError(f"failed to execute in cwd={kwargs['cwd']} ==> {envs} {cmdline}")
136
+ return ofiltermap.result if hasattr(ofiltermap, "result") else None
137
+
138
+
139
+ if __name__ == "__main__":
140
+ x = runc(["ls", "-l"], "capture", "display")
141
+ print(x)
acbox/runner.py ADDED
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import dataclasses as dc
5
+ import logging
6
+ import shutil
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import Generator
10
+
11
+ from .run1 import EMode, OMode, Paths, mkpaths, runc
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @dc.dataclass
17
+ class Runner:
18
+ verbose: bool
19
+ dryrun: bool | None = None
20
+ exe: Paths | None = None
21
+ cwd: Path | None = None
22
+ overrides: dict[str, str] | None = None
23
+ log: logging.Logger | None = None
24
+
25
+ @staticmethod
26
+ @contextlib.contextmanager
27
+ def tmpdir(source: Path | None = None) -> Generator[Path, None, None]:
28
+ wdir = source if source else Path(tempfile.mkdtemp())
29
+ wdir.mkdir(parents=True, exist_ok=True)
30
+ try:
31
+ yield wdir
32
+ finally:
33
+ if not source:
34
+ shutil.rmtree(wdir, ignore_errors=True)
35
+
36
+ def __call__(
37
+ self,
38
+ args: Paths,
39
+ capture: bool = False,
40
+ verbose: bool | None = None,
41
+ dryrun: bool | None = None,
42
+ cwd: Path | str | bool | None = None,
43
+ overrides: dict[str, str] | None = None,
44
+ log: logging.Logger | None = None,
45
+ ) -> str | bytes | None:
46
+ # capture/verbose are interacting to control how the stdout is handled
47
+ check = (capture, self.verbose if verbose is None else verbose)
48
+ stdout: OMode = "null"
49
+ if check == (True, False):
50
+ stdout = "capture"
51
+ elif check == (True, True):
52
+ stdout = "capture+display"
53
+ elif check == (False, True):
54
+ stdout = "display"
55
+ elif check == (False, False):
56
+ stdout = "null"
57
+ else:
58
+ raise RuntimeError(f"un-handled value {check=}")
59
+ stderr: EMode = "display" if self.verbose else "null"
60
+
61
+ dryrun = self.dryrun if dryrun is None else dryrun
62
+ if "capture" in stdout and dryrun:
63
+ raise RuntimeError("cannot dryrun and caputure")
64
+
65
+ cwd = cwd or self.cwd
66
+ overrides = overrides or self.overrides
67
+ log = log or self.log or logger
68
+
69
+ fullargs = mkpaths(args)
70
+ if self.exe:
71
+ fullargs = [*mkpaths(self.exe), *fullargs]
72
+
73
+ log.debug("%srun: %s", "(dry-run) " if dryrun else "", " ".join(fullargs))
74
+ if dryrun:
75
+ return None
76
+ return runc(fullargs, stdout=stdout, stderr=stderr, overrides=overrides, cwd=cwd)
77
+
78
+
79
+ if __name__ == "__main__":
80
+ runner = Runner(True)
81
+ y = runner(["ls", "-l"], capture=False, verbose=True)
82
+ print(y)
83
+
84
+ # --git-dir=$dest/.git --work-tree $dest
85
+ runner = Runner(True, exe=["git", "--git-dir", "{workdir}/.git"])
86
+ runner("status")
acbox/ureporting.py ADDED
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses as dc
4
+ import functools
5
+ import itertools
6
+ from enum import IntEnum, auto
7
+
8
+
9
+ class S(IntEnum):
10
+ OK = auto()
11
+ FAILED = auto()
12
+ WARN = auto()
13
+ NOSTATUS = auto()
14
+
15
+
16
+ @dc.dataclass
17
+ class Record:
18
+ status: S
19
+ group: str # kry to group the record under
20
+ key: str
21
+ report: str = ""
22
+
23
+
24
+ @dc.dataclass
25
+ class ColorText:
26
+ message: str
27
+ status: S
28
+
29
+ def __format__(self, width):
30
+ reset = "\033[0m"
31
+ message = self.message
32
+ msg = {
33
+ S.OK: f"\033[42m{message}{reset}",
34
+ S.FAILED: f"\033[41m{message}{reset}",
35
+ S.WARN: f"\033[43;30m{message}{reset}",
36
+ S.NOSTATUS: f"{message}",
37
+ }[self.status]
38
+ return msg + " " * (int(width or len(self.message)) - len(self.message))
39
+
40
+
41
+ def indent(txt: str, pre: str, first: str | None = None) -> str:
42
+ return (pre if first is None else first) + txt.replace("\n", "\n" + pre)
43
+
44
+
45
+ def dumps(report: list[Record], sorted_groups: bool = True) -> str:
46
+ def resolve(states):
47
+ if S.FAILED in states:
48
+ status = S.FAILED
49
+ elif S.WARN in states:
50
+ status = S.WARN
51
+ elif S.OK in states:
52
+ status = S.OK
53
+ else:
54
+ status = S.NOSTATUS
55
+ return status
56
+
57
+ def color(status: S, fall=".") -> ColorText:
58
+ message = {
59
+ S.OK: "+",
60
+ S.FAILED: "x",
61
+ S.WARN: "!",
62
+ S.NOSTATUS: ".",
63
+ }[status]
64
+ return ColorText(message, status)
65
+
66
+ def colorize(message: str, status: S) -> ColorText:
67
+ return ColorText(message, status)
68
+
69
+ pre = " " * 3
70
+ result = []
71
+
72
+ width = max(len(record.key) for record in report)
73
+
74
+ def bygroup(fn, items):
75
+ return fn(items, key=lambda r: r.group)
76
+
77
+ def nosorted(items, key):
78
+ return items
79
+
80
+ for group, itrecords in bygroup(itertools.groupby, bygroup(nosorted, report)):
81
+ records = list(itrecords)
82
+ status = resolve(set(record.status for record in records))
83
+ result.append(f"{color(status)} {group}")
84
+ start = " " * 4 + " " * width
85
+ for record in records:
86
+ if record.group != group:
87
+ continue
88
+ if isinstance(record.report, str):
89
+ message = record.report
90
+ elif isinstance(record.report, list):
91
+ message = indent("\n".join(record.report), pre=start).lstrip()
92
+ else:
93
+ raise RuntimeError("unable to handle type", type(record.report))
94
+ result.append(f"{pre}{colorize(record.key, record.status):{width}} {message}")
95
+
96
+ return "\n".join(result)
97
+
98
+
99
+ def print_report(report: list[Record], sorted_groups: bool = True) -> int:
100
+ errors = sum(r.status == S.FAILED for r in report)
101
+ warnings = sum(r.status == S.WARN for r in report)
102
+ print(dumps(report, sorted_groups))
103
+ if errors:
104
+ t = ColorText("FAILED", S.FAILED)
105
+ print(f"{t} found {errors} errors, and {warnings} warnings")
106
+ elif warnings:
107
+ t = ColorText("WARN", S.WARN)
108
+ print(f"{t} found {warnings} warnings")
109
+ else:
110
+ t = ColorText("OK", S.OK)
111
+ print(f"{t}")
112
+ return min(int(sum(r.status == S.FAILED for r in report)), 1)
113
+
114
+
115
+ def check(fn):
116
+ @functools.wraps(fn)
117
+ def _fn(*args, **kwargs):
118
+ result = fn(*args, **kwargs)
119
+ return result if isinstance(result, list) else [result]
120
+
121
+ return _fn
122
+
123
+
124
+ if __name__ == "__main__":
125
+ report = []
126
+ report.append(Record(S.NOSTATUS, "group-0", "key-0", "message"))
127
+ report.append(Record(S.OK, "group-0", "key-2", "message-2"))
128
+ report.append(Record(S.FAILED, "group-0", "key-3", "message-3"))
129
+ report.append(Record(S.WARN, "group-0", "key-4", "message-4"))
130
+ report.append(Record(S.NOSTATUS, "group-1", "key-0", "message"))
131
+ report.append(Record(S.OK, "group-1", "key-2", "message-2"))
132
+ report.append(Record(S.OK, "group-1", "key-3", "message-3"))
133
+ report.append(Record(S.WARN, "group-1", "key-4", "message-4"))
134
+ ret = print_report(report)
135
+ print(f"Final status -> {ret}")
acbox/utils.py ADDED
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.util import module_from_spec, spec_from_file_location
4
+ from pathlib import Path
5
+ from types import ModuleType
6
+ from typing import Any
7
+ from urllib.parse import urlparse
8
+ from urllib.request import urlopen
9
+
10
+
11
+ def loadmod(path: Path | str) -> ModuleType:
12
+ if urlparse(str(path)).scheme in {"http", "https"}:
13
+ urltxt = str(urlopen(str(path)).read(), encoding="utf-8")
14
+ mod = ModuleType(str(path).rpartition("/")[2])
15
+ exec(urltxt, mod.__dict__)
16
+ return mod
17
+
18
+ spec = spec_from_file_location(Path(path).name, Path(path))
19
+ module = module_from_spec(spec) # type: ignore
20
+ spec.loader.exec_module(module) # type: ignore
21
+ return module
22
+
23
+
24
+ class NA:
25
+ pass
26
+
27
+
28
+ def diffdict(
29
+ left: dict[str, Any], right: dict[str, Any], exclude: list[str] | None = None, na: str | type[NA] = NA
30
+ ) -> dict[str, tuple[Any, Any]]:
31
+ result = {}
32
+ for key in sorted(set(left) | set(right)):
33
+ if exclude and key in exclude:
34
+ continue
35
+ if key not in left:
36
+ result[key] = (na, right[key])
37
+ elif key not in right:
38
+ result[key] = (left[key], na)
39
+ elif left[key] != right[key]:
40
+ result[key] = (left[key], right[key])
41
+ return result
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: acbox
3
+ Version: 0.0.0
4
+ Summary: Collection of small tools
5
+ Author-email: Antonio Cavallo <a.cavallo@cavallinux.eu>
6
+ License: MIT
7
+ Project-URL: Source, https://github.com/cav71/acbox
8
+ Project-URL: Issues, https://github.com/cav71/acbox
9
+ Project-URL: Documentation, https://github.com/cav71/acbox
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Programming Language :: Python
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: click
19
+ Requires-Dist: filetype
20
+ Requires-Dist: rich
21
+ Requires-Dist: pyyaml
22
+ Provides-Extra: extra
23
+
24
+ ## ACBox my little toolbox
25
+
26
+ [![PyPI version](https://img.shields.io/pypi/v/acbox.svg?color=blue)](https://pypi.org/project/acbox)
27
+ [![Python versions](https://img.shields.io/pypi/pyversions/acbox.svg)](https://pypi.org/project/acbox)
28
+ [![Codecov (main)](https://img.shields.io/codecov/c/github/cav71/acbox/main)](https://app.codecov.io/gh/cav71/acbox/tree/main)
29
+ [![Build](https://github.com/cav71/acbox/actions/workflows/main.yml/badge.svg)](https://github.com/cav71/acbox/actions/workflows/main.yml)
30
+
31
+
32
+ [![License - MIT](https://img.shields.io/badge/license-MIT-9400d3.svg)](https://spdx.org/licenses/)
33
+ ![PyPI - Downloads](https://img.shields.io/pypi/dm/acbox)
34
+ [![Mypy](https://img.shields.io/badge/types-Mypy-blue.svg)](https://mypy-lang.org/)
35
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
36
+
37
+
38
+
39
+ ### Development
40
+
41
+ ```
42
+ python3.13 -m venv .venv
43
+ source .venv/bin/activate
44
+ ```
45
+
46
+ ```
47
+ python -m pip install --upgrade pip
48
+ python -m pip install --group dev
49
+ pre-commit install
50
+ ```
51
+
52
+ Ready.
@@ -0,0 +1,27 @@
1
+ acbox/__init__.py,sha256=kM9hNFcHh_nzPpKmTIZ2_ZCZpISWTcacn_fS1bi9gWo,42
2
+ acbox/cli2.py,sha256=b5p6gXa_16Z1d6QPT2bo0QFPWS5eVjhxbrnEkmibA_g,1778
3
+ acbox/dictionaries.py,sha256=SJdRK58LTw1Ysg69R_N6HSCJNNmfIQmsUfMp5UoPmWA,1067
4
+ acbox/fileops.py,sha256=1zDI4pj069QWxwGG_Bxt5JD-EnFbz-VtNRZ2QdUDp5A,1799
5
+ acbox/git.py,sha256=wDtxBiCS1re9rKTBSlnPtd5me_iitnm4KEV0uc_EQh4,1309
6
+ acbox/loader.py,sha256=t9QWOe0JnjO--e2HDJlie6Xdtc4fqn1h-iMEz5kYlcI,1518
7
+ acbox/maincli.py,sha256=Qnkfjoad_0bKECSZxoZALLECezGNDuZjr0kepqoa4hg,118
8
+ acbox/packer.py,sha256=VaqPBhIsOIDQsmFRTMiPCMyvr6qIvLyuZLnHa5DLyUI,867
9
+ acbox/run1.py,sha256=R_09NRzNHNwU1rf3AiZinpTGKkxqWxIPbVMVPEXCKi0,4404
10
+ acbox/runner.py,sha256=p5SSctPiNvONy5hZQvdpJf92CC0hcuecXvfi-d-OIvc,2651
11
+ acbox/ureporting.py,sha256=UdzFf8HU6IgNYeqHiShPs9J_lMMsOiLtpPfi-99iaNw,4075
12
+ acbox/utils.py,sha256=_i9q0Ft72d5Aud1eCpQAKINP4t_JTKFQyk5cs3yQ6F0,1266
13
+ acbox/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ acbox/cli/script.py,sha256=JQOfZvXzgzwoftIMFRTtuBCtCFatKk-Ly_L6VfgnqPg,4044
15
+ acbox/cli/shared.py,sha256=tVdExf5XaOhcTLic17urbaWU0I9D2dA34_5uFAAXZbs,806
16
+ acbox/cli/flags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
+ acbox/cli/flags/base.py,sha256=fG-fTFkA-XXq3XLOdmIifgGM4pUNRJ_aEZpjaMWd8Bc,1158
18
+ acbox/cli/flags/logging.py,sha256=04KAjZ6KLb8bMfy4R0-f0vRgC2K1SuUouBwtO6_wRR8,1238
19
+ acbox/cli/parsers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
+ acbox/cli/parsers/base.py,sha256=xKLUVfwCkuLBdJkYl5IyxyjXSw-E4ILy_wf5zKu-OtY,2398
21
+ acbox/cli/parsers/parser.py,sha256=U4S_2y3zgLZVfMenHRaJFBW8yqh2mUBuI291LGQVOJ8,35
22
+ acbox/cli/parsers/simple.py,sha256=DZX1GIUf-coto7GVhLNb8WHHmkqNe3o7Kn5FDeFWnVM,1275
23
+ acbox-0.0.0.dist-info/METADATA,sha256=v3fjDIoSfJ_A5BI9MoRKRhK2QKeSFjI6EkaoPI80f8Y,1865
24
+ acbox-0.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
25
+ acbox-0.0.0.dist-info/entry_points.txt,sha256=e_OU4H7NG6VzjUzF55JOEV-4-hhIr-uSDPwapxHLywI,45
26
+ acbox-0.0.0.dist-info/top_level.txt,sha256=ONP4w-2ISebZn3jvJcSrlPOj2pcXMKl93FwtRk9AJYs,6
27
+ acbox-0.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ acbox = acbox.maincli:main
@@ -0,0 +1 @@
1
+ acbox