snapdelta 0.1.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.
- snapdelta/__init__.py +5 -0
- snapdelta/about.py +50 -0
- snapdelta/activity.py +182 -0
- snapdelta/catalog.py +160 -0
- snapdelta/cli.py +528 -0
- snapdelta/errors.py +36 -0
- snapdelta/inputs.py +26 -0
- snapdelta/inventory.py +177 -0
- snapdelta/jsonfile.py +36 -0
- snapdelta/output.py +202 -0
- snapdelta/parallel.py +37 -0
- snapdelta/profile.py +191 -0
- snapdelta/py.typed +0 -0
- snapdelta/render.py +257 -0
- snapdelta/settings.py +634 -0
- snapdelta/snapshot.py +108 -0
- snapdelta/source.py +213 -0
- snapdelta/store.py +728 -0
- snapdelta/take.py +296 -0
- snapdelta-0.1.0.dist-info/METADATA +72 -0
- snapdelta-0.1.0.dist-info/RECORD +24 -0
- snapdelta-0.1.0.dist-info/WHEEL +4 -0
- snapdelta-0.1.0.dist-info/entry_points.txt +2 -0
- snapdelta-0.1.0.dist-info/licenses/LICENSE +202 -0
snapdelta/__init__.py
ADDED
snapdelta/about.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""What a user or a support request needs to know: the versions, the plugins and the project links."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
|
|
10
|
+
from .source import broken_sources, source_classes
|
|
11
|
+
|
|
12
|
+
PROJECT_URL = "https://github.com/antoinekh/snapdelta"
|
|
13
|
+
DOCS_URL = f"{PROJECT_URL}/tree/main/docs"
|
|
14
|
+
CHANGELOG_URL = f"{PROJECT_URL}/blob/main/CHANGELOG.md"
|
|
15
|
+
ISSUES_URL = f"{PROJECT_URL}/issues"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Component:
|
|
20
|
+
name: str
|
|
21
|
+
version: str
|
|
22
|
+
sources: list[str] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def package_version(distribution: str) -> str:
|
|
26
|
+
try:
|
|
27
|
+
return version(distribution)
|
|
28
|
+
except PackageNotFoundError:
|
|
29
|
+
return "unknown"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def components(extra: Iterable[str] = ()) -> list[Component]:
|
|
33
|
+
"""snapdelta, the other given packages (the web UI), diffsync and Python."""
|
|
34
|
+
names = ["snapdelta", *extra, "diffsync"]
|
|
35
|
+
return [*(Component(name, package_version(name)) for name in names), Component("Python", platform.python_version())]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def plugins() -> list[Component]:
|
|
39
|
+
"""The installed plugins with their sources; a source with no distribution is listed under its own name."""
|
|
40
|
+
by_plugin: dict[str, list[str]] = {}
|
|
41
|
+
for name, cls in sorted(source_classes().items()):
|
|
42
|
+
by_plugin.setdefault(cls.distribution or name, []).append(name)
|
|
43
|
+
for name in sorted(broken_sources()):
|
|
44
|
+
by_plugin.setdefault(f"{name} (cannot load)", []).append(name)
|
|
45
|
+
return [Component(plugin, package_version(plugin), names) for plugin, names in sorted(by_plugin.items())]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def version_line() -> str:
|
|
49
|
+
core, diffsync = package_version("snapdelta"), package_version("diffsync")
|
|
50
|
+
return f"snapdelta {core} (diffsync {diffsync}, Python {platform.python_version()})"
|
snapdelta/activity.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""What snapdelta did: one run per command (snapshots, a diff), with its result and the log messages of its loads.
|
|
2
|
+
|
|
3
|
+
A source plugin logs with the standard `logging` module. While a load runs, `LogCollector` adds each message to
|
|
4
|
+
the log of that load, even from the threads of `run_parallel()`, which copy the context of their caller.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import getpass
|
|
10
|
+
import logging
|
|
11
|
+
import secrets
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from contextvars import ContextVar
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, Field
|
|
20
|
+
|
|
21
|
+
from .errors import SnapdeltaError
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from .store import Store
|
|
25
|
+
|
|
26
|
+
_log = logging.getLogger(__name__)
|
|
27
|
+
# The log that receives the messages of the current context, and the target that they are about.
|
|
28
|
+
_lines: ContextVar[list[LogLine] | None] = ContextVar("snapdelta_log_lines", default=None)
|
|
29
|
+
_target: ContextVar[str | None] = ContextVar("snapdelta_log_target", default=None)
|
|
30
|
+
# Only a load collects: the compare of a run logs too (diffsync), and that says nothing about the targets.
|
|
31
|
+
_loading: ContextVar[bool] = ContextVar("snapdelta_loading", default=False)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LogLine(BaseModel):
|
|
35
|
+
at: datetime
|
|
36
|
+
level: str
|
|
37
|
+
logger: str
|
|
38
|
+
target: str | None = None
|
|
39
|
+
message: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RunStatus(StrEnum):
|
|
43
|
+
OK = "ok"
|
|
44
|
+
PARTIAL = "partial"
|
|
45
|
+
FAILED = "failed"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Run(BaseModel):
|
|
49
|
+
"""One command: what it took or compared, how it ended, and the log of its loads."""
|
|
50
|
+
|
|
51
|
+
id: str
|
|
52
|
+
kind: str = Field(description="snapshot or diff")
|
|
53
|
+
origin: str = Field(description="cli, web, api, mcp, or python for a direct call")
|
|
54
|
+
user: str | None = None
|
|
55
|
+
started_at: datetime
|
|
56
|
+
ended_at: datetime
|
|
57
|
+
status: RunStatus
|
|
58
|
+
targets: list[str] = []
|
|
59
|
+
saved: list[str] = Field(default=[], description="The references of the saved snapshots.")
|
|
60
|
+
output: str | None = Field(default=None, description="The name of the saved diff.")
|
|
61
|
+
errors: dict[str, str] = Field(default={}, description="The error of each failed target, or of the run under `*`.")
|
|
62
|
+
log: list[LogLine] = []
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def duration_s(self) -> float:
|
|
66
|
+
return (self.ended_at - self.started_at).total_seconds()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class LogCollector(logging.Handler):
|
|
70
|
+
"""Adds each message of a load to the log of the current context; any other message goes nowhere."""
|
|
71
|
+
|
|
72
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
73
|
+
lines = _lines.get()
|
|
74
|
+
if lines is None or not _loading.get():
|
|
75
|
+
return
|
|
76
|
+
try:
|
|
77
|
+
message = record.getMessage()
|
|
78
|
+
except (TypeError, ValueError):
|
|
79
|
+
message = str(record.msg)
|
|
80
|
+
lines.append(
|
|
81
|
+
LogLine(
|
|
82
|
+
at=datetime.fromtimestamp(record.created, UTC),
|
|
83
|
+
level=record.levelname.lower(),
|
|
84
|
+
logger=record.name,
|
|
85
|
+
target=_target.get(),
|
|
86
|
+
message=message,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def configure_logging(level: str = "info") -> None:
|
|
92
|
+
"""Collect the messages at `level` and above, and keep the console at warning; once per process."""
|
|
93
|
+
root = logging.getLogger()
|
|
94
|
+
wanted = logging.getLevelNamesMapping()[level.upper()]
|
|
95
|
+
for handler in root.handlers:
|
|
96
|
+
# The console handler of logging.basicConfig() shows warnings only, as before the collector.
|
|
97
|
+
if type(handler) is logging.StreamHandler and handler.level == logging.NOTSET:
|
|
98
|
+
handler.setLevel(logging.WARNING)
|
|
99
|
+
if not any(isinstance(handler, LogCollector) for handler in root.handlers):
|
|
100
|
+
root.addHandler(LogCollector(wanted))
|
|
101
|
+
else:
|
|
102
|
+
for handler in root.handlers:
|
|
103
|
+
if isinstance(handler, LogCollector):
|
|
104
|
+
handler.setLevel(wanted)
|
|
105
|
+
root.setLevel(min(root.level or logging.WARNING, wanted))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@contextmanager
|
|
109
|
+
def collecting(target: str | None = None, loading: bool = True) -> Iterator[list[LogLine]]:
|
|
110
|
+
"""The log of the load in the block, about `target`; the lines also go to the log around it."""
|
|
111
|
+
outer = _lines.get()
|
|
112
|
+
lines: list[LogLine] = []
|
|
113
|
+
tokens = (_lines.set(lines), _target.set(target), _loading.set(loading))
|
|
114
|
+
try:
|
|
115
|
+
yield lines
|
|
116
|
+
finally:
|
|
117
|
+
_lines.reset(tokens[0])
|
|
118
|
+
_target.reset(tokens[1])
|
|
119
|
+
_loading.reset(tokens[2])
|
|
120
|
+
if outer is not None:
|
|
121
|
+
outer.extend(lines)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class RunRecord:
|
|
125
|
+
"""A run while it goes on; the caller fills `saved`, `output` and `errors`."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, kind: str, origin: str, targets: list[str]) -> None:
|
|
128
|
+
self.kind = kind
|
|
129
|
+
self.origin = origin
|
|
130
|
+
self.targets = targets
|
|
131
|
+
self.started_at = datetime.now(UTC)
|
|
132
|
+
self.saved: list[str] = []
|
|
133
|
+
self.output: str | None = None
|
|
134
|
+
self.errors: dict[str, str] = {}
|
|
135
|
+
self.log: list[LogLine] = []
|
|
136
|
+
|
|
137
|
+
def finished(self) -> Run:
|
|
138
|
+
done = len(self.saved) + (1 if self.output else 0)
|
|
139
|
+
status = RunStatus.OK if not self.errors else RunStatus.PARTIAL if done else RunStatus.FAILED
|
|
140
|
+
# Microseconds, so that the ids of two runs of the same second sort in their order.
|
|
141
|
+
stamp = self.started_at.strftime("%Y%m%dT%H%M%S%fZ")
|
|
142
|
+
return Run(
|
|
143
|
+
id=f"{stamp}-{self.kind}-{secrets.token_hex(2)}",
|
|
144
|
+
kind=self.kind,
|
|
145
|
+
origin=self.origin,
|
|
146
|
+
user=_user() if self.origin == "cli" else None,
|
|
147
|
+
started_at=self.started_at,
|
|
148
|
+
ended_at=datetime.now(UTC),
|
|
149
|
+
status=status,
|
|
150
|
+
targets=self.targets,
|
|
151
|
+
saved=self.saved,
|
|
152
|
+
output=self.output,
|
|
153
|
+
errors=self.errors,
|
|
154
|
+
# The loads add their lines when they end: the time gives back the order of the events.
|
|
155
|
+
log=sorted(self.log, key=lambda line: line.at),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@contextmanager
|
|
160
|
+
def recorded(store: Store, kind: str, origin: str, targets: list[str]) -> Iterator[RunRecord]:
|
|
161
|
+
"""Record the block as a run of the store; an error fails the run and goes on to the caller."""
|
|
162
|
+
record = RunRecord(kind, origin, targets)
|
|
163
|
+
try:
|
|
164
|
+
with collecting(loading=False) as lines:
|
|
165
|
+
record.log = lines
|
|
166
|
+
yield record
|
|
167
|
+
except Exception as error:
|
|
168
|
+
record.errors["*"] = str(error) if isinstance(error, SnapdeltaError) else f"{type(error).__name__}: {error}"
|
|
169
|
+
raise
|
|
170
|
+
finally:
|
|
171
|
+
try:
|
|
172
|
+
store.save_run(record.finished())
|
|
173
|
+
except (OSError, SnapdeltaError) as error:
|
|
174
|
+
# The command itself went well: a run that cannot be saved must not turn it into an error.
|
|
175
|
+
_log.warning("the run was not saved: %s", error)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _user() -> str | None:
|
|
179
|
+
try:
|
|
180
|
+
return getpass.getuser()
|
|
181
|
+
except (KeyError, OSError):
|
|
182
|
+
return None
|
snapdelta/catalog.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""The models of the installed sources, read from their classes: what a profile can name and which sources compare."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, get_args
|
|
10
|
+
|
|
11
|
+
from diffsync import DiffSyncModel
|
|
12
|
+
|
|
13
|
+
from .source import Source
|
|
14
|
+
|
|
15
|
+
# diffsync joins the identifiers of an object with this into its unique id.
|
|
16
|
+
ID_SEPARATOR = "__"
|
|
17
|
+
MODULE_PATH_RE = re.compile(r"\b(?:\w+\.)+(\w+)")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ModelField:
|
|
22
|
+
name: str
|
|
23
|
+
type: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ModelInfo:
|
|
28
|
+
name: str
|
|
29
|
+
doc: str | None
|
|
30
|
+
identifiers: list[ModelField]
|
|
31
|
+
attributes: list[ModelField]
|
|
32
|
+
children: list[str]
|
|
33
|
+
parent: str | None
|
|
34
|
+
# Kept in the snapshot, never compared.
|
|
35
|
+
context: list[ModelField] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def unique_id(self) -> str:
|
|
39
|
+
"""The form of the unique id of an object, for example `interface__index`."""
|
|
40
|
+
return ID_SEPARATOR.join(item.name for item in self.identifiers)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def shape(self) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
|
44
|
+
"""What two sources must share for this model to compare: the names of the identifiers and attributes."""
|
|
45
|
+
return tuple(item.name for item in self.identifiers), tuple(item.name for item in self.attributes)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class SourceModels:
|
|
50
|
+
source: str
|
|
51
|
+
distribution: str | None
|
|
52
|
+
fetches: bool
|
|
53
|
+
doc: str | None
|
|
54
|
+
models: list[ModelInfo]
|
|
55
|
+
top_level: list[str]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class ModelSet:
|
|
60
|
+
"""Sources with the same models: one table of models serves them all."""
|
|
61
|
+
|
|
62
|
+
sources: list[SourceModels]
|
|
63
|
+
# The sources of other sets that share top-level models with this one, so that a compare between them works.
|
|
64
|
+
compares_with: list[str] = field(default_factory=list)
|
|
65
|
+
# One message per model that another set names the same way with other fields.
|
|
66
|
+
conflicts: list[str] = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def models(self) -> list[ModelInfo]:
|
|
70
|
+
return self.sources[0].models
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def names(self) -> list[str]:
|
|
74
|
+
return [item.source for item in self.sources]
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def top_level(self) -> list[str]:
|
|
78
|
+
return self.sources[0].top_level
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def describe(cls: type[Source], source: str | None = None) -> SourceModels:
|
|
82
|
+
"""The models of one source class, top-level models first, each followed by its children."""
|
|
83
|
+
order = cls.model_order()
|
|
84
|
+
parents = {child: name for name in order for child in _model(cls, name).get_children_mapping()}
|
|
85
|
+
models = [_model_info(name, _model(cls, name), parents.get(name)) for name in order]
|
|
86
|
+
return SourceModels(
|
|
87
|
+
source=source or cls.source,
|
|
88
|
+
distribution=cls.distribution,
|
|
89
|
+
fetches=cls.fetches,
|
|
90
|
+
doc=inspect.getdoc(cls),
|
|
91
|
+
models=models,
|
|
92
|
+
top_level=list(cls.top_level),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def catalog(classes: Mapping[str, type[Source]]) -> list[ModelSet]:
|
|
97
|
+
"""The installed sources grouped by the same models, with the other sets that they compare with or conflict with."""
|
|
98
|
+
groups: dict[tuple[Any, ...], list[SourceModels]] = {}
|
|
99
|
+
for name, cls in sorted(classes.items()):
|
|
100
|
+
item = describe(cls, name)
|
|
101
|
+
key = tuple((model.name, model.shape, tuple(model.children)) for model in item.models)
|
|
102
|
+
groups.setdefault(key, []).append(item)
|
|
103
|
+
sets = [ModelSet(sources) for sources in groups.values()]
|
|
104
|
+
for found in sets:
|
|
105
|
+
own = {model.name: model for model in found.models}
|
|
106
|
+
for other in sets:
|
|
107
|
+
if other is found:
|
|
108
|
+
continue
|
|
109
|
+
if any(name in other.top_level for name in found.top_level):
|
|
110
|
+
found.compares_with += other.names
|
|
111
|
+
others = {model.name: model for model in other.models}
|
|
112
|
+
found.conflicts += [
|
|
113
|
+
_conflict(own[name], others[name], ", ".join(other.names))
|
|
114
|
+
for name in own
|
|
115
|
+
if name in others and own[name].shape != others[name].shape
|
|
116
|
+
]
|
|
117
|
+
return sets
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _model(cls: type[Source], name: str) -> type[DiffSyncModel]:
|
|
121
|
+
return getattr(cls, name)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def context_fields(model: type[DiffSyncModel]) -> list[str]:
|
|
125
|
+
"""The fields that a snapshot keeps but that diffsync never compares: not an identifier, attribute or child."""
|
|
126
|
+
compared = {*model._identifiers, *model._attributes, *model._children.values(), *DiffSyncModel.model_fields}
|
|
127
|
+
return [name for name in model.model_fields if name not in compared]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _model_info(name: str, model: type[DiffSyncModel], parent: str | None) -> ModelInfo:
|
|
131
|
+
fields = model.model_fields
|
|
132
|
+
return ModelInfo(
|
|
133
|
+
name=name,
|
|
134
|
+
doc=inspect.getdoc(model) if model.__doc__ else None,
|
|
135
|
+
identifiers=[ModelField(key, _type_name(fields[key].annotation)) for key in model._identifiers],
|
|
136
|
+
attributes=[ModelField(key, _type_name(fields[key].annotation)) for key in model._attributes],
|
|
137
|
+
children=list(model.get_children_mapping()),
|
|
138
|
+
parent=parent,
|
|
139
|
+
context=[ModelField(key, _type_name(fields[key].annotation)) for key in context_fields(model)],
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _type_name(annotation: Any) -> str:
|
|
144
|
+
"""`int`, `str | None`, `list[str]`, `InterfaceType | None`: the type as a reader writes it, without modules."""
|
|
145
|
+
if isinstance(annotation, type) and not get_args(annotation):
|
|
146
|
+
return annotation.__name__
|
|
147
|
+
return MODULE_PATH_RE.sub(r"\1", str(annotation).replace("typing.", ""))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _conflict(own: ModelInfo, other: ModelInfo, source: str) -> str:
|
|
151
|
+
own_fields = set(own.shape[0]) | set(own.shape[1])
|
|
152
|
+
other_fields = set(other.shape[0]) | set(other.shape[1])
|
|
153
|
+
parts = []
|
|
154
|
+
if own.shape[0] != other.shape[0]:
|
|
155
|
+
parts.append(f"identifiers {', '.join(own.shape[0])} here, {', '.join(other.shape[0])} in {source}")
|
|
156
|
+
if only_here := sorted(own_fields - other_fields):
|
|
157
|
+
parts.append(f"only here: {', '.join(only_here)}")
|
|
158
|
+
if only_there := sorted(other_fields - own_fields):
|
|
159
|
+
parts.append(f"only in {source}: {', '.join(only_there)}")
|
|
160
|
+
return f"{own.name}: not the same fields as in {source} ({'; '.join(parts) or 'other order'})"
|