acbox 0.0.0b0__tar.gz

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.
Files changed (42) hide show
  1. acbox-0.0.0b0/PKG-INFO +40 -0
  2. acbox-0.0.0b0/README.md +17 -0
  3. acbox-0.0.0b0/pyproject.toml +116 -0
  4. acbox-0.0.0b0/setup.cfg +4 -0
  5. acbox-0.0.0b0/src/acbox/__init__.py +2 -0
  6. acbox-0.0.0b0/src/acbox/cli/__init__.py +0 -0
  7. acbox-0.0.0b0/src/acbox/cli/flags/__init__.py +0 -0
  8. acbox-0.0.0b0/src/acbox/cli/flags/base.py +44 -0
  9. acbox-0.0.0b0/src/acbox/cli/flags/logging.py +34 -0
  10. acbox-0.0.0b0/src/acbox/cli/parsers/__init__.py +0 -0
  11. acbox-0.0.0b0/src/acbox/cli/parsers/base.py +68 -0
  12. acbox-0.0.0b0/src/acbox/cli/parsers/parser.py +1 -0
  13. acbox-0.0.0b0/src/acbox/cli/parsers/simple.py +47 -0
  14. acbox-0.0.0b0/src/acbox/cli/script.py +125 -0
  15. acbox-0.0.0b0/src/acbox/cli/shared.py +34 -0
  16. acbox-0.0.0b0/src/acbox/cli2.py +59 -0
  17. acbox-0.0.0b0/src/acbox/dictionaries.py +39 -0
  18. acbox-0.0.0b0/src/acbox/fileops.py +62 -0
  19. acbox-0.0.0b0/src/acbox/git.py +34 -0
  20. acbox-0.0.0b0/src/acbox/loader.py +52 -0
  21. acbox-0.0.0b0/src/acbox/maincli.py +8 -0
  22. acbox-0.0.0b0/src/acbox/packer.py +25 -0
  23. acbox-0.0.0b0/src/acbox/run1.py +141 -0
  24. acbox-0.0.0b0/src/acbox/runner.py +86 -0
  25. acbox-0.0.0b0/src/acbox/ureporting.py +135 -0
  26. acbox-0.0.0b0/src/acbox/utils.py +41 -0
  27. acbox-0.0.0b0/src/acbox.egg-info/PKG-INFO +40 -0
  28. acbox-0.0.0b0/src/acbox.egg-info/SOURCES.txt +40 -0
  29. acbox-0.0.0b0/src/acbox.egg-info/dependency_links.txt +1 -0
  30. acbox-0.0.0b0/src/acbox.egg-info/entry_points.txt +2 -0
  31. acbox-0.0.0b0/src/acbox.egg-info/requires.txt +6 -0
  32. acbox-0.0.0b0/src/acbox.egg-info/top_level.txt +1 -0
  33. acbox-0.0.0b0/tests/test_cli.py +65 -0
  34. acbox-0.0.0b0/tests/test_cli_shared.py +176 -0
  35. acbox-0.0.0b0/tests/test_dictionaries.py +109 -0
  36. acbox-0.0.0b0/tests/test_fileops.py +18 -0
  37. acbox-0.0.0b0/tests/test_internals.py +32 -0
  38. acbox-0.0.0b0/tests/test_loader.py +19 -0
  39. acbox-0.0.0b0/tests/test_run1.py +2 -0
  40. acbox-0.0.0b0/tests/test_runner.py +39 -0
  41. acbox-0.0.0b0/tests/test_services_git.py +18 -0
  42. acbox-0.0.0b0/tests/test_utils.py +27 -0
acbox-0.0.0b0/PKG-INFO ADDED
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: acbox
3
+ Version: 0.0.0b0
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
+
27
+ ### Development
28
+
29
+ ```
30
+ python3.13 -m venv .venv
31
+ source .venv/bin/activate
32
+ ```
33
+
34
+ ```
35
+ python -m pip install --upgrade pip
36
+ python -m pip install --group dev
37
+ pre-commit install
38
+ ```
39
+
40
+ Ready.
@@ -0,0 +1,17 @@
1
+ ## ACBox my little toolbox
2
+
3
+
4
+ ### Development
5
+
6
+ ```
7
+ python3.13 -m venv .venv
8
+ source .venv/bin/activate
9
+ ```
10
+
11
+ ```
12
+ python -m pip install --upgrade pip
13
+ python -m pip install --group dev
14
+ pre-commit install
15
+ ```
16
+
17
+ Ready.
@@ -0,0 +1,116 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "acbox"
7
+ version = "0.0.0b0"
8
+ description = "Collection of small tools"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+
12
+ requires-python = ">= 3.10"
13
+
14
+ authors = [
15
+ { name = "Antonio Cavallo", email = "a.cavallo@cavallinux.eu" },
16
+ ]
17
+
18
+ classifiers = [
19
+ "Development Status :: 4 - Beta",
20
+ "Programming Language :: Python",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ ]
26
+
27
+ dependencies = [
28
+ "click",
29
+ "filetype",
30
+ "rich",
31
+ "pyyaml",
32
+ ]
33
+
34
+ [dependency-groups]
35
+ dev = [
36
+ "build",
37
+ "lxml",
38
+ "mypy",
39
+ "pre-commit",
40
+ "pytest",
41
+ "pytest-asyncio",
42
+ "pytest-cov",
43
+ "pytest-html",
44
+ "ruff",
45
+ "types-python-dateutil",
46
+ "types-pyyaml"
47
+ ]
48
+
49
+
50
+ [project.optional-dependencies]
51
+ extra = []
52
+
53
+ [project.urls]
54
+ Source = "https://github.com/cav71/acbox"
55
+ Issues = "https://github.com/cav71/acbox"
56
+ Documentation = "https://github.com/cav71/acbox"
57
+
58
+ [project.scripts]
59
+ acbox = "acbox.maincli:main"
60
+
61
+ [tool.setuptools.packages.find]
62
+ where = ["src"]
63
+ namespaces = false
64
+
65
+ [tool.ruff]
66
+ target-version = "py39"
67
+ line-length = 140
68
+ src = ["src/acbox"]
69
+
70
+ [tool.ruff.format]
71
+ quote-style = "double"
72
+
73
+ [tool.ruff.lint]
74
+ ignore = []
75
+ select = ["F", "E", "W", "Q", "I001"]
76
+
77
+ [tool.ruff.lint.isort]
78
+ known-first-party = ["acbox"]
79
+
80
+
81
+ [tool.mypy]
82
+ disallow_untyped_defs = false
83
+ follow_imports = "normal"
84
+ ignore_missing_imports = true
85
+ pretty = true
86
+ show_column_numbers = true
87
+ show_error_codes = true
88
+ warn_no_return = false
89
+ warn_unused_ignores = true
90
+ exclude = [
91
+ "docs/conf\\.py",
92
+ "^docs/\\.*",
93
+ "^build/\\.*",
94
+ "^support/\\.*",
95
+ ]
96
+
97
+ [tool.coverage.run]
98
+ branch = true
99
+
100
+ [tool.coverage.paths]
101
+ source = [
102
+ "src/",
103
+ ]
104
+
105
+ [tool.coverage.report]
106
+ exclude_lines = [
107
+ "no cov",
108
+ "if __name__ == .__main__.:",
109
+ "if TYPE_CHECKING:",
110
+ ]
111
+
112
+ [tool.pytest.ini_options]
113
+ markers = [
114
+ "manual: marks tests unsafe for auto-run (eg. better run them manually)",
115
+ ]
116
+ asyncio_default_fixture_loop_scope = "function"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ __version__ = "0.0.0b0"
2
+ __hash__ = "b04c92f"
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)
@@ -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
@@ -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")
@@ -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
@@ -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