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 ADDED
@@ -0,0 +1,5 @@
1
+ """snapdelta: snapshot anything with diffsync models, then compare two snapshots."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("snapdelta")
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'})"