cfgit 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.
- cfg/__init__.py +13 -0
- cfg/adapters/__init__.py +25 -0
- cfg/adapters/base.py +127 -0
- cfg/adapters/mongo.py +570 -0
- cfg/adapters/postgres.py +756 -0
- cfg/approval/__init__.py +5 -0
- cfg/approval/base.py +29 -0
- cfg/cli/__init__.py +2 -0
- cfg/cli/main.py +665 -0
- cfg/core/__init__.py +13 -0
- cfg/core/authz.py +58 -0
- cfg/core/config.py +324 -0
- cfg/core/diff.py +43 -0
- cfg/core/engine.py +1388 -0
- cfg/core/hashing.py +102 -0
- cfg/core/identity.py +213 -0
- cfg/interfaces/__init__.py +2 -0
- cfg/interfaces/actions.py +598 -0
- cfg/mcp/__init__.py +10 -0
- cfg/mcp/server.py +452 -0
- cfg/ui/__init__.py +2 -0
- cfg/ui/server.py +1066 -0
- cfgit-0.1.0.dist-info/METADATA +744 -0
- cfgit-0.1.0.dist-info/RECORD +28 -0
- cfgit-0.1.0.dist-info/WHEEL +4 -0
- cfgit-0.1.0.dist-info/entry_points.txt +3 -0
- cfgit-0.1.0.dist-info/licenses/LICENSE +201 -0
- cfgit-0.1.0.dist-info/licenses/NOTICE +10 -0
cfg/core/authz.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
"""DB-neutral mutation authorization for cfgit."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
|
|
7
|
+
from cfg.core.config import EnvConfig, PermissionConfig
|
|
8
|
+
from cfg.core.identity import Identity, self_asserted_identity
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PermissionDenied(Exception):
|
|
12
|
+
"""The resolved author is not allowed to perform this mutation."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def authorize_mutation(
|
|
16
|
+
env: EnvConfig,
|
|
17
|
+
*,
|
|
18
|
+
action: str,
|
|
19
|
+
identity: Identity | None = None,
|
|
20
|
+
author: str | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
resolved = identity or self_asserted_identity(author or "", cfg=env.identity)
|
|
23
|
+
if env.identity.mode in {"authenticated", "enforced"} and not resolved.authenticated:
|
|
24
|
+
raise PermissionDenied(
|
|
25
|
+
f"{resolved.author} cannot run {action} on {env.name}: "
|
|
26
|
+
f"{env.identity.mode} identity required"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
role = permission_role(env.permissions, resolved.author)
|
|
30
|
+
admin_only = _matches(action, env.permissions.admin_actions)
|
|
31
|
+
|
|
32
|
+
if admin_only and role != "admin":
|
|
33
|
+
raise PermissionDenied(
|
|
34
|
+
f"{resolved.author} cannot run {action} on {env.name}: admin permission required"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if env.permissions.mode == "restricted" and role not in {"admin", "writer"}:
|
|
38
|
+
raise PermissionDenied(
|
|
39
|
+
f"{resolved.author} cannot run {action} on {env.name}: writer permission required"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def permission_role(policy: PermissionConfig, author: str) -> str:
|
|
44
|
+
if _matches(author, policy.admins):
|
|
45
|
+
return "admin"
|
|
46
|
+
if _matches(author, policy.writers):
|
|
47
|
+
return "writer"
|
|
48
|
+
return "open" if policy.mode == "open" else "none"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _matches(value: str, patterns: tuple[str, ...]) -> bool:
|
|
52
|
+
raw = value.strip()
|
|
53
|
+
lowered = raw.lower()
|
|
54
|
+
for pattern in patterns:
|
|
55
|
+
candidate = pattern.strip()
|
|
56
|
+
if fnmatch.fnmatchcase(raw, candidate) or fnmatch.fnmatchcase(lowered, candidate.lower()):
|
|
57
|
+
return True
|
|
58
|
+
return False
|
cfg/core/config.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
"""Project configuration loader for cfgit.
|
|
3
|
+
|
|
4
|
+
This is the single place that maps user-facing .cfg.toml names to internal
|
|
5
|
+
engine names. Keep it DB-neutral.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
try: # pragma: no cover - py311+ path is normal
|
|
15
|
+
import tomllib
|
|
16
|
+
except ModuleNotFoundError: # pragma: no cover
|
|
17
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class CollectionConfig:
|
|
22
|
+
name: str
|
|
23
|
+
id_field: str
|
|
24
|
+
live_when: dict[str, Any] = field(default_factory=dict)
|
|
25
|
+
ignore_fields: tuple[str, ...] = ()
|
|
26
|
+
ignore_patterns: tuple[str, ...] = ()
|
|
27
|
+
ignore_paths: tuple[str, ...] = ()
|
|
28
|
+
secret_fields: tuple[str, ...] = ()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class HistoryConfig:
|
|
33
|
+
history_collection: str = "config_history"
|
|
34
|
+
heads_collection: str = "config_heads"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class BranchesConfig:
|
|
39
|
+
enabled: bool = False
|
|
40
|
+
refs_collection: str = "cfgit_refs"
|
|
41
|
+
default_branch: str = "main"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class PermissionConfig:
|
|
46
|
+
mode: str = "open"
|
|
47
|
+
admins: tuple[str, ...] = ()
|
|
48
|
+
writers: tuple[str, ...] = ()
|
|
49
|
+
admin_actions: tuple[str, ...] = ()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class IdentityTokenConfig:
|
|
54
|
+
author: str
|
|
55
|
+
token_hash: str
|
|
56
|
+
name: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class IdentityConfig:
|
|
61
|
+
mode: str = "open"
|
|
62
|
+
sources: tuple[str, ...] = ("db_principal", "token")
|
|
63
|
+
token_env: str = "CFGIT_IDENTITY_TOKEN"
|
|
64
|
+
tokens: tuple[IdentityTokenConfig, ...] = ()
|
|
65
|
+
principal_map: dict[str, str] = field(default_factory=dict)
|
|
66
|
+
fingerprint_chars: int = 5
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class ConnectionsConfig:
|
|
71
|
+
enabled: bool = False
|
|
72
|
+
ai_provider: str = "openai"
|
|
73
|
+
share_with_ai: tuple[str, ...] = ()
|
|
74
|
+
warn_level: str = "none"
|
|
75
|
+
links: tuple[dict[str, Any], ...] = ()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class SecretsConfig:
|
|
80
|
+
block_fields: tuple[str, ...] = ()
|
|
81
|
+
block_values: tuple[str, ...] = ()
|
|
82
|
+
on_match: str = "refuse"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class EnvConfig:
|
|
87
|
+
name: str
|
|
88
|
+
database: str
|
|
89
|
+
uri: str
|
|
90
|
+
db: str
|
|
91
|
+
runtime_uri: str | None = None
|
|
92
|
+
runtime_db: str | None = None
|
|
93
|
+
history_uri: str | None = None
|
|
94
|
+
history_db: str | None = None
|
|
95
|
+
needs_approval: bool = False
|
|
96
|
+
identity: IdentityConfig = field(default_factory=IdentityConfig)
|
|
97
|
+
permissions: PermissionConfig = field(default_factory=PermissionConfig)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class ProjectConfig:
|
|
102
|
+
name: str
|
|
103
|
+
path: Path
|
|
104
|
+
history: HistoryConfig
|
|
105
|
+
collections: tuple[CollectionConfig, ...]
|
|
106
|
+
envs: dict[str, EnvConfig]
|
|
107
|
+
branches: BranchesConfig = field(default_factory=BranchesConfig)
|
|
108
|
+
author_from: str = "git"
|
|
109
|
+
connections: ConnectionsConfig = field(default_factory=ConnectionsConfig)
|
|
110
|
+
secrets: SecretsConfig = field(default_factory=SecretsConfig)
|
|
111
|
+
|
|
112
|
+
def collection(self, name: str) -> CollectionConfig:
|
|
113
|
+
for coll in self.collections:
|
|
114
|
+
if coll.name == name:
|
|
115
|
+
return coll
|
|
116
|
+
raise KeyError(f"unknown collection: {name}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def load_config(path: str | Path | None = None) -> ProjectConfig:
|
|
120
|
+
cfg_path = _resolve_config_path(path)
|
|
121
|
+
with cfg_path.open("rb") as f:
|
|
122
|
+
raw = tomllib.load(f)
|
|
123
|
+
|
|
124
|
+
collections = tuple(_load_collection(item) for item in raw.get("collection", []))
|
|
125
|
+
if not collections:
|
|
126
|
+
raise ValueError("config has no [[collection]] entries")
|
|
127
|
+
|
|
128
|
+
envs: dict[str, EnvConfig] = {}
|
|
129
|
+
for name, data in (raw.get("env") or {}).items():
|
|
130
|
+
permission_raw, identity_raw = _split_identity_permissions(data)
|
|
131
|
+
envs[name] = EnvConfig(
|
|
132
|
+
name=name,
|
|
133
|
+
database=str(data.get("database", "")),
|
|
134
|
+
uri=_resolve_uri(str(data.get("uri", "")), env_name=name),
|
|
135
|
+
db=str(data.get("db", "")),
|
|
136
|
+
runtime_uri=_resolve_optional_uri(data.get("runtime_uri"), env_name=name),
|
|
137
|
+
runtime_db=str(data["runtime_db"]) if data.get("runtime_db") is not None else None,
|
|
138
|
+
history_uri=_resolve_optional_uri(data.get("history_uri"), env_name=name),
|
|
139
|
+
history_db=str(data["history_db"]) if data.get("history_db") is not None else None,
|
|
140
|
+
needs_approval=bool(data.get("needs_approval", False)),
|
|
141
|
+
identity=_load_identity(identity_raw),
|
|
142
|
+
permissions=_load_permissions(permission_raw),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
history_raw = raw.get("history") or {}
|
|
146
|
+
branches_raw = raw.get("branches") or {}
|
|
147
|
+
author_raw = raw.get("author") or {}
|
|
148
|
+
connections_raw = raw.get("connections") or {}
|
|
149
|
+
secrets_raw = raw.get("secrets") or {}
|
|
150
|
+
return ProjectConfig(
|
|
151
|
+
name=str((raw.get("project") or {}).get("name", cfg_path.parent.name)),
|
|
152
|
+
path=cfg_path,
|
|
153
|
+
history=HistoryConfig(
|
|
154
|
+
history_collection=str(history_raw.get("history_collection", "config_history")),
|
|
155
|
+
heads_collection=str(history_raw.get("heads_collection", "config_heads")),
|
|
156
|
+
),
|
|
157
|
+
branches=_load_branches(branches_raw),
|
|
158
|
+
collections=collections,
|
|
159
|
+
envs=envs,
|
|
160
|
+
author_from=str(author_raw.get("from", "git")),
|
|
161
|
+
connections=_load_connections(connections_raw),
|
|
162
|
+
secrets=_load_secrets(secrets_raw),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _resolve_config_path(path: str | Path | None) -> Path:
|
|
167
|
+
if path is not None:
|
|
168
|
+
return Path(path).expanduser().resolve()
|
|
169
|
+
local = Path(".cfg.toml")
|
|
170
|
+
if local.exists():
|
|
171
|
+
return local.resolve()
|
|
172
|
+
example = Path("examples/.cfg.toml")
|
|
173
|
+
if example.exists():
|
|
174
|
+
return example.resolve()
|
|
175
|
+
raise FileNotFoundError("no .cfg.toml found")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _load_collection(data: dict[str, Any]) -> CollectionConfig:
|
|
179
|
+
ignore_patterns = (*data.get("ignore_patterns", ()), *data.get("ignore_globs", ()))
|
|
180
|
+
secret_fields = (*data.get("secret_fields", ()), *data.get("strip_on_store", ()))
|
|
181
|
+
return CollectionConfig(
|
|
182
|
+
name=str(data["name"]),
|
|
183
|
+
id_field=str(data["id_field"]),
|
|
184
|
+
live_when=dict(data.get("live_when") or {}),
|
|
185
|
+
ignore_fields=tuple(str(x) for x in data.get("ignore_fields", ())),
|
|
186
|
+
ignore_patterns=tuple(str(x) for x in ignore_patterns),
|
|
187
|
+
ignore_paths=tuple(str(x) for x in data.get("ignore_paths", ())),
|
|
188
|
+
secret_fields=tuple(str(x) for x in secret_fields),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _load_branches(data: dict[str, Any]) -> BranchesConfig:
|
|
193
|
+
default_branch = str(data.get("default_branch", "main")).strip() or "main"
|
|
194
|
+
return BranchesConfig(
|
|
195
|
+
enabled=bool(data.get("enabled", False)),
|
|
196
|
+
refs_collection=str(data.get("refs_collection", "cfgit_refs")).strip() or "cfgit_refs",
|
|
197
|
+
default_branch=default_branch,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _load_permissions(data: dict[str, Any]) -> PermissionConfig:
|
|
202
|
+
mode = str(data.get("mode", "open"))
|
|
203
|
+
if mode not in {"open", "restricted"}:
|
|
204
|
+
raise ValueError("permissions.mode must be open or restricted")
|
|
205
|
+
return PermissionConfig(
|
|
206
|
+
mode=mode,
|
|
207
|
+
admins=tuple(str(x) for x in data.get("admins", ())),
|
|
208
|
+
writers=tuple(str(x) for x in data.get("writers", ())),
|
|
209
|
+
admin_actions=tuple(str(x) for x in data.get("admin_actions", ())),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _load_identity(data: dict[str, Any]) -> IdentityConfig:
|
|
214
|
+
mode = str(data.get("mode", "open")).lower()
|
|
215
|
+
if mode not in {"open", "authenticated", "enforced"}:
|
|
216
|
+
raise ValueError("identity.mode must be open, authenticated, or enforced")
|
|
217
|
+
raw_sources = data.get("sources", data.get("source"))
|
|
218
|
+
if raw_sources is None:
|
|
219
|
+
sources = ("db_principal", "token")
|
|
220
|
+
elif isinstance(raw_sources, str):
|
|
221
|
+
sources = (raw_sources,)
|
|
222
|
+
else:
|
|
223
|
+
sources = tuple(str(item) for item in raw_sources)
|
|
224
|
+
for source in sources:
|
|
225
|
+
if source not in {"db_principal", "token"}:
|
|
226
|
+
raise ValueError("identity.sources may contain only db_principal or token")
|
|
227
|
+
fingerprint_chars = int(data.get("fingerprint_chars", 5))
|
|
228
|
+
if not 4 <= fingerprint_chars <= 12:
|
|
229
|
+
raise ValueError("identity.fingerprint_chars must be between 4 and 12")
|
|
230
|
+
return IdentityConfig(
|
|
231
|
+
mode=mode,
|
|
232
|
+
sources=sources,
|
|
233
|
+
token_env=str(data.get("token_env", "CFGIT_IDENTITY_TOKEN")),
|
|
234
|
+
tokens=tuple(_load_identity_token(item) for item in _identity_token_items(data)),
|
|
235
|
+
principal_map={str(k): str(v) for k, v in dict(data.get("principal_map") or {}).items()},
|
|
236
|
+
fingerprint_chars=fingerprint_chars,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _load_identity_token(data: dict[str, Any]) -> IdentityTokenConfig:
|
|
241
|
+
author = str(data.get("author") or "").strip()
|
|
242
|
+
token_hash = str(data.get("sha256") or data.get("hash") or data.get("token_hash") or "").strip()
|
|
243
|
+
if not author or not token_hash:
|
|
244
|
+
raise ValueError("identity token entries require author and sha256/hash")
|
|
245
|
+
return IdentityTokenConfig(
|
|
246
|
+
author=author,
|
|
247
|
+
token_hash=_normalize_token_hash(token_hash),
|
|
248
|
+
name=str(data["name"]) if data.get("name") is not None else None,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _identity_token_items(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
253
|
+
raw = data.get("tokens", data.get("token", ()))
|
|
254
|
+
if isinstance(raw, dict):
|
|
255
|
+
return [raw]
|
|
256
|
+
return [dict(item) for item in raw]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _normalize_token_hash(value: str) -> str:
|
|
260
|
+
raw = value.lower()
|
|
261
|
+
if raw.startswith("sha256:"):
|
|
262
|
+
hex_value = raw[7:]
|
|
263
|
+
else:
|
|
264
|
+
hex_value = raw
|
|
265
|
+
raw = f"sha256:{raw}"
|
|
266
|
+
if len(hex_value) != 64 or any(ch not in "0123456789abcdef" for ch in hex_value):
|
|
267
|
+
raise ValueError("identity token hash must be a sha256:<64 hex> value")
|
|
268
|
+
return raw
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _split_identity_permissions(data: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
272
|
+
permissions = dict(data.get("permissions") or {})
|
|
273
|
+
identity = dict(data.get("identity") or {})
|
|
274
|
+
if data.get("identity_mode") is not None and identity.get("mode") is None:
|
|
275
|
+
identity["mode"] = data["identity_mode"]
|
|
276
|
+
|
|
277
|
+
permission_mode = str(permissions.get("mode", "open")).lower()
|
|
278
|
+
if permission_mode in {"authenticated", "enforced"}:
|
|
279
|
+
identity.setdefault("mode", permission_mode)
|
|
280
|
+
permissions["mode"] = "restricted"
|
|
281
|
+
return permissions, identity
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _load_connections(data: dict[str, Any]) -> ConnectionsConfig:
|
|
285
|
+
return ConnectionsConfig(
|
|
286
|
+
enabled=bool(data.get("enabled", False)),
|
|
287
|
+
ai_provider=str(data.get("ai_provider", "openai")),
|
|
288
|
+
share_with_ai=tuple(str(x) for x in data.get("share_with_ai", ())),
|
|
289
|
+
warn_level=str(data.get("warn_level", "none")),
|
|
290
|
+
links=tuple(dict(item) for item in data.get("links", ())),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _load_secrets(data: dict[str, Any]) -> SecretsConfig:
|
|
295
|
+
on_match = str(data.get("on_match", "refuse"))
|
|
296
|
+
if on_match not in {"refuse", "warn"}:
|
|
297
|
+
raise ValueError("secrets.on_match must be refuse or warn")
|
|
298
|
+
block_fields = (*data.get("block_fields", ()), *data.get("deny_field_globs", ()))
|
|
299
|
+
block_values = (*data.get("block_values", ()), *data.get("deny_value_regex", ()))
|
|
300
|
+
return SecretsConfig(
|
|
301
|
+
block_fields=tuple(str(x) for x in block_fields),
|
|
302
|
+
block_values=tuple(str(x) for x in block_values),
|
|
303
|
+
on_match=on_match,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _resolve_uri(raw: str, *, env_name: str) -> str:
|
|
308
|
+
if raw.startswith("env:"):
|
|
309
|
+
key = raw[4:]
|
|
310
|
+
value = os.environ.get(key)
|
|
311
|
+
if value:
|
|
312
|
+
return value
|
|
313
|
+
if env_name == "dev":
|
|
314
|
+
fallback = os.environ.get("MONGODB_URI")
|
|
315
|
+
if fallback:
|
|
316
|
+
return fallback
|
|
317
|
+
return ""
|
|
318
|
+
return raw
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _resolve_optional_uri(raw: Any, *, env_name: str) -> str | None:
|
|
322
|
+
if raw is None:
|
|
323
|
+
return None
|
|
324
|
+
return _resolve_uri(str(raw), env_name=env_name)
|
cfg/core/diff.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
"""Small structured diff helper."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def diff_values(before: Any, after: Any, path: str = "") -> list[dict[str, Any]]:
|
|
9
|
+
if before == after:
|
|
10
|
+
return []
|
|
11
|
+
if isinstance(before, dict) and isinstance(after, dict):
|
|
12
|
+
changes: list[dict[str, Any]] = []
|
|
13
|
+
keys = sorted(set(before) | set(after))
|
|
14
|
+
for key in keys:
|
|
15
|
+
child_path = f"{path}.{key}" if path else str(key)
|
|
16
|
+
if key not in before:
|
|
17
|
+
changes.append({"path": child_path, "op": "add", "before": None, "after": after[key]})
|
|
18
|
+
elif key not in after:
|
|
19
|
+
changes.append({"path": child_path, "op": "remove", "before": before[key], "after": None})
|
|
20
|
+
else:
|
|
21
|
+
changes.extend(diff_values(before[key], after[key], child_path))
|
|
22
|
+
return changes
|
|
23
|
+
return [{"path": path or "$", "op": "change", "before": before, "after": after}]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def format_diff(changes: list[dict[str, Any]]) -> str:
|
|
27
|
+
if not changes:
|
|
28
|
+
return "no changes"
|
|
29
|
+
lines: list[str] = []
|
|
30
|
+
for change in changes:
|
|
31
|
+
lines.append(f"{change['op']} {change['path']}")
|
|
32
|
+
if change["op"] != "add":
|
|
33
|
+
lines.append(f" before: {_short(change['before'])}")
|
|
34
|
+
if change["op"] != "remove":
|
|
35
|
+
lines.append(f" after: {_short(change['after'])}")
|
|
36
|
+
return "\n".join(lines)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _short(value: Any, limit: int = 240) -> str:
|
|
40
|
+
text = repr(value)
|
|
41
|
+
if len(text) > limit:
|
|
42
|
+
return text[: limit - 3] + "..."
|
|
43
|
+
return text
|