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 +2 -0
- acbox/cli/__init__.py +0 -0
- acbox/cli/flags/__init__.py +0 -0
- acbox/cli/flags/base.py +44 -0
- acbox/cli/flags/logging.py +34 -0
- acbox/cli/parsers/__init__.py +0 -0
- acbox/cli/parsers/base.py +68 -0
- acbox/cli/parsers/parser.py +1 -0
- acbox/cli/parsers/simple.py +47 -0
- acbox/cli/script.py +125 -0
- acbox/cli/shared.py +34 -0
- acbox/cli2.py +59 -0
- acbox/dictionaries.py +39 -0
- acbox/fileops.py +62 -0
- acbox/git.py +36 -0
- acbox/loader.py +52 -0
- acbox/maincli.py +8 -0
- acbox/packer.py +25 -0
- acbox/run1.py +141 -0
- acbox/runner.py +86 -0
- acbox/ureporting.py +135 -0
- acbox/utils.py +41 -0
- acbox-0.0.0.dist-info/METADATA +52 -0
- acbox-0.0.0.dist-info/RECORD +27 -0
- acbox-0.0.0.dist-info/WHEEL +5 -0
- acbox-0.0.0.dist-info/entry_points.txt +2 -0
- acbox-0.0.0.dist-info/top_level.txt +1 -0
acbox/__init__.py
ADDED
acbox/cli/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
acbox/cli/flags/base.py
ADDED
|
@@ -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
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
|
+
[](https://pypi.org/project/acbox)
|
|
27
|
+
[](https://pypi.org/project/acbox)
|
|
28
|
+
[](https://app.codecov.io/gh/cav71/acbox/tree/main)
|
|
29
|
+
[](https://github.com/cav71/acbox/actions/workflows/main.yml)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
[](https://spdx.org/licenses/)
|
|
33
|
+

|
|
34
|
+
[](https://mypy-lang.org/)
|
|
35
|
+
[](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 @@
|
|
|
1
|
+
acbox
|