sud-cli 0.1.1__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.
Files changed (74) hide show
  1. sud_cli/__init__.py +3 -0
  2. sud_cli/__main__.py +4 -0
  3. sud_cli/_wm.py +118 -0
  4. sud_cli/agenda.py +90 -0
  5. sud_cli/autofill.py +192 -0
  6. sud_cli/autofill_markers.json +56 -0
  7. sud_cli/backup.py +256 -0
  8. sud_cli/calendar_ics.py +191 -0
  9. sud_cli/case_diff.py +165 -0
  10. sud_cli/case_search_bridge.py +1322 -0
  11. sud_cli/chain.py +226 -0
  12. sud_cli/cli.py +3721 -0
  13. sud_cli/completeness.py +105 -0
  14. sud_cli/config.py +20 -0
  15. sud_cli/conflict.py +210 -0
  16. sud_cli/conflicts.py +207 -0
  17. sud_cli/court_alerts.py +156 -0
  18. sud_cli/court_source.py +108 -0
  19. sud_cli/data/completeness_rules.json +18 -0
  20. sud_cli/data/key_rates.json +59 -0
  21. sud_cli/data/npa_historical_editions.json +19 -0
  22. sud_cli/db.py +2072 -0
  23. sud_cli/deadline_matrix.py +99 -0
  24. sud_cli/deadline_state.py +25 -0
  25. sud_cli/deadlines.py +8 -0
  26. sud_cli/deadlines_state.py +1 -0
  27. sud_cli/deferral.py +5 -0
  28. sud_cli/efrsb_manual.py +85 -0
  29. sud_cli/efrsb_public.py +210 -0
  30. sud_cli/egrul_nalog.py +150 -0
  31. sud_cli/explain.py +190 -0
  32. sud_cli/fees_extra.py +104 -0
  33. sud_cli/forms.py +204 -0
  34. sud_cli/fssp_manual.py +91 -0
  35. sud_cli/fssp_semi.py +285 -0
  36. sud_cli/fssp_stats.py +105 -0
  37. sud_cli/government_transfers.json +8 -0
  38. sud_cli/holidays_rf.json +62 -0
  39. sud_cli/idempotency.py +78 -0
  40. sud_cli/importing.py +575 -0
  41. sud_cli/interest.py +144 -0
  42. sud_cli/jurisdiction.py +177 -0
  43. sud_cli/jurisdiction_rules.json +265 -0
  44. sud_cli/legal_deadlines.py +278 -0
  45. sud_cli/legal_engine.py +558 -0
  46. sud_cli/legal_rules.json +1058 -0
  47. sud_cli/limitation.py +70 -0
  48. sud_cli/mcp_server.py +1594 -0
  49. sud_cli/metrics.py +142 -0
  50. sud_cli/npa_as_of.py +21 -0
  51. sud_cli/output.py +89 -0
  52. sud_cli/payment.py +24 -0
  53. sud_cli/pdn.py +259 -0
  54. sud_cli/plenum_corpus.py +134 -0
  55. sud_cli/plenums_seed.json +45 -0
  56. sud_cli/practice_status.py +136 -0
  57. sud_cli/pravo_publication.py +361 -0
  58. sud_cli/pretrial.py +151 -0
  59. sud_cli/production_calendar.py +233 -0
  60. sud_cli/providers.py +95 -0
  61. sud_cli/readiness.py +234 -0
  62. sud_cli/regional_holidays.py +37 -0
  63. sud_cli/reminders.py +92 -0
  64. sud_cli/reports.py +349 -0
  65. sud_cli/sud_netguard.py +943 -0
  66. sud_cli/timeline_audit.py +80 -0
  67. sud_cli/verify.py +201 -0
  68. sud_cli-0.1.1.dist-info/METADATA +119 -0
  69. sud_cli-0.1.1.dist-info/RECORD +74 -0
  70. sud_cli-0.1.1.dist-info/WHEEL +4 -0
  71. sud_cli-0.1.1.dist-info/entry_points.txt +2 -0
  72. sud_cli-0.1.1.dist-info/licenses/AUTHORS +5 -0
  73. sud_cli-0.1.1.dist-info/licenses/LICENSE.md +661 -0
  74. sud_cli-0.1.1.dist-info/licenses/NOTICE +7 -0
sud_cli/backup.py ADDED
@@ -0,0 +1,256 @@
1
+ """история §5 (S-85): backup / verify / restore snapshot'а store.db.
2
+
3
+ sqlite stdlib, без внешних процессов:
4
+ - create — ``sqlite3.Connection.backup`` (горячий снимок, не cp под WAL),
5
+ файл 0600 + sha256 sidecar; путь снимка пишется в ``meta`` (S-86 читает
6
+ «last backup» оттуда, а не из воздуха);
7
+ - verify — PRAGMA integrity_check + quick_check + список таблиц схемы;
8
+ sidecar сверяется, если лежит рядом;
9
+ - restore — деструктив: без явного подтверждения не делает ничего, превью
10
+ показывает, что будет перезаписано (путь, размер, таблица->кол-во строк
11
+ текущей БД). Живой state в тестах не трогается: вызывающий передаёт tmp-dir.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import os
17
+ import sqlite3
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Any, Dict, Optional
21
+
22
+ from .db import _db_path, get_connection, init_db, set_meta
23
+
24
+ BACKUP_PREFIX = "store-backup-"
25
+ SIDECAR_SUFFIX = ".sha256"
26
+ META_LAST_BACKUP_KEY = "last_backup_path"
27
+ META_LAST_BACKUP_SHA_KEY = "last_backup_sha256"
28
+
29
+ RESTORE_DISCLAIMER = (
30
+ "restore перезаписывает store.db state-dir целиком: всё, что было создано "
31
+ "после снимка, теряется. Подтверждение --yes обязательно."
32
+ )
33
+
34
+
35
+ def _now_stamp() -> str:
36
+ return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
37
+
38
+
39
+ def _sha256_file(path: Path) -> str:
40
+ h = hashlib.sha256()
41
+ with path.open("rb") as f:
42
+ for chunk in iter(lambda: f.read(65536), b""):
43
+ h.update(chunk)
44
+ return h.hexdigest()
45
+
46
+
47
+ def default_backup_dir(state_dir: Any) -> Path:
48
+ return Path(state_dir) / "backups"
49
+
50
+
51
+ def snapshot_tables(path: Path) -> Dict[str, int]:
52
+ """таблица -> число строк (только пользовательские таблицы, без sqlite_INTERNAL)."""
53
+ conn = sqlite3.connect(str(path))
54
+ try:
55
+ rows = conn.execute(
56
+ "SELECT name FROM sqlite_master WHERE type='table' "
57
+ "AND name NOT LIKE 'sqlite_%' ORDER BY name ASC"
58
+ ).fetchall()
59
+ out = {}
60
+ for (name,) in rows:
61
+ out[name] = int(conn.execute(f"SELECT COUNT(*) FROM {name}").fetchone()[0])
62
+ return out
63
+ finally:
64
+ conn.close()
65
+
66
+
67
+ def create_backup(state_dir: Any, out: Optional[Path] = None) -> Dict[str, Any]:
68
+ """Горячий снимок store.db -> timestamped файл 0600 + sha256 sidecar."""
69
+ state_dir = Path(state_dir)
70
+ init_db(state_dir)
71
+ src_path = _db_path(state_dir)
72
+ if not src_path.is_file():
73
+ raise FileNotFoundError(f"store.db не найден: {src_path}")
74
+
75
+ if out is None:
76
+ dest_dir = default_backup_dir(state_dir)
77
+ dest_dir.mkdir(parents=True, exist_ok=True)
78
+ target = dest_dir / f"{BACKUP_PREFIX}{_now_stamp()}.db"
79
+ else:
80
+ target = Path(out)
81
+ target.parent.mkdir(parents=True, exist_ok=True)
82
+
83
+ src = sqlite3.connect(str(src_path))
84
+ try:
85
+ dst = sqlite3.connect(str(target))
86
+ try:
87
+ with dst:
88
+ src.backup(dst)
89
+ finally:
90
+ dst.close()
91
+ finally:
92
+ src.close()
93
+
94
+ # 0600 до того, как появится sidecar: снимок содержит ПДн дела.
95
+ os.chmod(target, 0o600)
96
+ digest = _sha256_file(target)
97
+ sidecar = target.with_name(target.name + SIDECAR_SUFFIX)
98
+ sidecar.write_text(f"{digest} {target.name}\n", encoding="utf-8")
99
+ os.chmod(sidecar, 0o600)
100
+
101
+ conn = get_connection(state_dir)
102
+ try:
103
+ set_meta(conn, META_LAST_BACKUP_KEY, str(target))
104
+ set_meta(conn, META_LAST_BACKUP_SHA_KEY, digest)
105
+ finally:
106
+ conn.close()
107
+
108
+ return {
109
+ "path": str(target),
110
+ "sidecar": str(sidecar),
111
+ "sha256": digest,
112
+ "bytes": target.stat().st_size,
113
+ "mode": oct(0o600),
114
+ "tables": snapshot_tables(target),
115
+ "source": str(src_path),
116
+ "created_at": datetime.now(timezone.utc).isoformat(),
117
+ }
118
+
119
+
120
+ def read_sidecar(backup: Path) -> Optional[str]:
121
+ sidecar = backup.with_name(backup.name + SIDECAR_SUFFIX)
122
+ if not sidecar.is_file():
123
+ return None
124
+ text = sidecar.read_text(encoding="utf-8").strip()
125
+ return text.split()[0] if text else None
126
+
127
+
128
+ def verify_backup(path: Any) -> Dict[str, Any]:
129
+ """integrity_check + quick_check + таблицы схемы; sidecar, если есть рядом."""
130
+ backup = Path(path)
131
+ if not backup.is_file():
132
+ raise FileNotFoundError(f"backup не найден: {backup}")
133
+ integrity = quick = None
134
+ conn = sqlite3.connect(str(backup))
135
+ try:
136
+ integrity = conn.execute("PRAGMA integrity_check").fetchone()[0]
137
+ quick = conn.execute("PRAGMA quick_check").fetchone()[0]
138
+ tables = [r[0] for r in conn.execute(
139
+ "SELECT name FROM sqlite_master WHERE type='table' "
140
+ "AND name NOT LIKE 'sqlite_%' ORDER BY name ASC").fetchall()]
141
+ schema_version = None
142
+ if "meta" in tables:
143
+ row = conn.execute(
144
+ "SELECT value FROM meta WHERE key='schema_version'").fetchone()
145
+ schema_version = row[0] if row else None
146
+ finally:
147
+ conn.close()
148
+
149
+ recorded = read_sidecar(backup)
150
+ actual = _sha256_file(backup)
151
+ return {
152
+ "path": str(backup),
153
+ "bytes": backup.stat().st_size,
154
+ "integrity_check": integrity,
155
+ "quick_check": quick,
156
+ "integrity_ok": integrity == "ok",
157
+ "quick_ok": quick == "ok",
158
+ "tables": tables,
159
+ "table_count": len(tables),
160
+ "schema_version": schema_version,
161
+ "sha256": actual,
162
+ "sidecar_sha256": recorded,
163
+ "sidecar_match": None if recorded is None else (recorded == actual),
164
+ }
165
+
166
+
167
+ def restore_preview(state_dir: Any, path: Any) -> Dict[str, Any]:
168
+ """Что будет перезаписано: путь живой БД, её размер/таблицы + снимок."""
169
+ state_dir = Path(state_dir)
170
+ live = _db_path(state_dir)
171
+ info: Dict[str, Any] = {
172
+ "target": str(live),
173
+ "target_exists": live.is_file(),
174
+ "target_bytes": live.stat().st_size if live.is_file() else 0,
175
+ "target_tables": snapshot_tables(live) if live.is_file() else {},
176
+ "source": str(path),
177
+ "source_tables": {},
178
+ }
179
+ if Path(path).is_file():
180
+ info["source_tables"] = snapshot_tables(Path(path))
181
+ return info
182
+
183
+
184
+ def restore_backup(state_dir: Any, path: Any, confirmed: bool = False,
185
+ actor: str = "cli") -> Dict[str, Any]:
186
+ """Перезапись store.db из снимка. Без confirmed ничего не меняется."""
187
+ state_dir = Path(state_dir)
188
+ backup = Path(path)
189
+ preview = restore_preview(state_dir, backup)
190
+ if not confirmed:
191
+ return {
192
+ "status": "needs_confirmation",
193
+ "detail": "restore деструктивен: требуется --yes; изменится store.db "
194
+ "целиком — всё, что создано после снимка, будет потеряно",
195
+ "preview": preview,
196
+ "disclaimer": RESTORE_DISCLAIMER,
197
+ }
198
+ if not backup.is_file():
199
+ raise FileNotFoundError(f"backup не найден: {backup}")
200
+ check = verify_backup(backup)
201
+ if not (check["integrity_ok"] and check["quick_ok"]):
202
+ raise sqlite3.DatabaseError(
203
+ f"backup не прошёл integrity_check/quick_check: "
204
+ f"{check['integrity_check']}/{check['quick_check']}")
205
+
206
+ state_dir.mkdir(parents=True, exist_ok=True)
207
+ target = _db_path(state_dir)
208
+ src = sqlite3.connect(str(backup))
209
+ try:
210
+ dst = sqlite3.connect(str(target))
211
+ try:
212
+ with dst:
213
+ src.backup(dst)
214
+ finally:
215
+ dst.close()
216
+ finally:
217
+ src.close()
218
+ os.chmod(target, 0o600)
219
+ conn = get_connection(state_dir)
220
+ try:
221
+ set_meta(conn, "last_restore_at", datetime.now(timezone.utc).isoformat())
222
+ set_meta(conn, "last_restore_source", str(backup))
223
+ finally:
224
+ conn.close()
225
+ return {
226
+ "status": "restored",
227
+ "target": str(target),
228
+ "source": str(backup),
229
+ "actor": actor,
230
+ "tables_after": snapshot_tables(target),
231
+ "disclaimer": RESTORE_DISCLAIMER,
232
+ }
233
+
234
+
235
+ def last_backup(state_dir: Any) -> Dict[str, Any]:
236
+ """Последний снимок из meta (S-86): путь+sha, которые мы сами записали."""
237
+ state_dir = Path(state_dir)
238
+ init_db(state_dir)
239
+ conn = get_connection(state_dir)
240
+ try:
241
+ from .db import get_meta
242
+
243
+ path = get_meta(conn, META_LAST_BACKUP_KEY)
244
+ sha = get_meta(conn, META_LAST_BACKUP_SHA_KEY)
245
+ finally:
246
+ conn.close()
247
+ exists = bool(path) and Path(str(path)).is_file()
248
+ recorded = read_sidecar(Path(str(path))) if exists else None
249
+ return {
250
+ "path": path,
251
+ "sha256": sha,
252
+ "exists": exists,
253
+ # verified — sidecar рядом и совпал с записанным в meta; None, если
254
+ # снимка нет: «не проверялось», а не «проверено и плохо».
255
+ "verified": None if not exists else bool(recorded and recorded == sha),
256
+ }
@@ -0,0 +1,191 @@
1
+ """W6a §3 (S-28): экспорт дедлайнов в RFC 5545 (ICS).
2
+
3
+ Источник — deadline_states computed/confirmed (the wave), по последнему состоянию
4
+ на пару (case_id, deadline_key); waived и due_at=NULL не экспортируются.
5
+ Конверт детерминирован: DTSTAMP выводится из due-даты, поэтому повторный
6
+ экспорт тех же состояний даёт побайтово тот же файл (UID стабильны —
7
+ требование идемпотентности из ТЗ).
8
+
9
+ Формат (RFC 5545):
10
+ - CRLF переносы; строки не длиннее 75 октетов — длинные фолдятся continuation
11
+ пробелом, разбивка идёт по границам символов utf-8;
12
+ - спецсимволы TEXT-значений экранируются: ``\\\\`` -> ``\\\\\\\\``, ``;`` -> ``\\\\;``,
13
+ ``,`` -> ``\\\\,``, перевод строки -> ``\\\\n``;
14
+ - DTSTART/DTEND — локальное время суда (Europe/Moscow, 09:00, +1 час) с явным
15
+ TZID и блоком VTIMEZONE: календарь без таймзоны агент прочтёт как UTC;
16
+ - VALARM -P1D ставится ТОЛЬКО на confirmed: напоминать за сутки по unverified
17
+ вычисленному сроку — давление на оператора, а не факт (anti-laches).
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from datetime import date, timedelta
22
+ from typing import Optional, Tuple
23
+
24
+ PRODID = "-//sud-cli//DEADLINES RU 2.11.0//EN"
25
+ TZID = "Europe/Moscow"
26
+ DUE_HOUR = 9 # процессуальные сроки истекают в конце дня; событие ставим на утро суда
27
+ LINE_LEN = 75 # октетов, RFC 5545 3.1
28
+ EXPORT_STATES = ("computed", "confirmed")
29
+
30
+ _VTIMEZONE = (
31
+ "BEGIN:VTIMEZONE",
32
+ f"TZID:{TZID}",
33
+ "BEGIN:STANDARD",
34
+ "DTSTART:20141026T000000",
35
+ "TZOFFSETFROM:+0400",
36
+ "TZOFFSETTO:+0300",
37
+ "TZNAME:MSK",
38
+ "END:STANDARD",
39
+ "END:VTIMEZONE",
40
+ )
41
+
42
+
43
+ def _esc(text) -> str:
44
+ out = str(text if text is not None else "")
45
+ out = out.replace("\\", "\\\\")
46
+ out = out.replace(";", "\\;")
47
+ out = out.replace(",", "\\,")
48
+ out = out.replace("\r\n", "\\n").replace("\n", "\\n")
49
+ return out
50
+
51
+
52
+ def _fold(line: str) -> list[str]:
53
+ """Разбить строку на куски <= 75 октетов; continuation-строки с ведущим
54
+ пробелом (RFC 5545 3.1), разбивка по границам символов utf-8."""
55
+ if len(line.encode("utf-8")) <= LINE_LEN:
56
+ return [line]
57
+ pieces: list[str] = []
58
+ current: list[str] = []
59
+ current_len = 0
60
+ limit = LINE_LEN
61
+ for ch in line:
62
+ ch_len = len(ch.encode("utf-8"))
63
+ if current_len + ch_len > limit:
64
+ pieces.append("".join(current))
65
+ current = [ch]
66
+ current_len = ch_len
67
+ limit = LINE_LEN - 1 # continuation-строка начинается с пробела
68
+ else:
69
+ current.append(ch)
70
+ current_len += ch_len
71
+ if current:
72
+ pieces.append("".join(current))
73
+ return [pieces[0]] + [" " + p for p in pieces[1:]]
74
+
75
+
76
+ def _line(name: str, value: str) -> list[str]:
77
+ return _fold(f"{name}:{value}")
78
+
79
+
80
+ def event_uid(case_id: str, deadline_key: str) -> str:
81
+ """Стабильный UID: пара (дело, ключ) — не id строки истории."""
82
+ return f"{case_id}-{deadline_key}@sud-cli"
83
+
84
+
85
+ def _latest_per_key(states):
86
+ latest = {}
87
+ for s in states:
88
+ key = (s.get("case_id"), s.get("deadline_key"))
89
+ try:
90
+ sid = int(s.get("id") or 0)
91
+ except (TypeError, ValueError):
92
+ sid = 0
93
+ prev = latest.get(key)
94
+ if prev is None or sid >= int(prev.get("id") or 0):
95
+ latest[key] = s
96
+ return latest
97
+
98
+
99
+ def _describe(state: dict) -> str:
100
+ basis = state.get("basis")
101
+ if isinstance(basis, dict):
102
+ basis_txt = "; ".join(f"{k}={v}" for k, v in sorted(basis.items()))
103
+ elif basis:
104
+ basis_txt = str(basis)
105
+ else:
106
+ basis_txt = "-"
107
+ parts = [f"case={state.get('case_id')}", f"key={state.get('deadline_key')}",
108
+ f"state={state.get('state')}", f"basis: {basis_txt}"]
109
+ if state.get("evidence_note"):
110
+ parts.append(f"evidence: {state['evidence_note']}")
111
+ if state.get("confirmed_by"):
112
+ parts.append(f"confirmed_by: {state['confirmed_by']}")
113
+ return " | ".join(parts)
114
+
115
+
116
+ def build_ics(states, today: Optional[date] = None, days: int = 60) -> Tuple[str, list[dict]]:
117
+ """VCALENDAR-текст (CRLF) + список событий; пустой вход = валидный пустой календарь."""
118
+ today = today or date.today()
119
+ horizon = today + timedelta(days=days)
120
+ events_meta: list[dict] = []
121
+ body: list[str] = []
122
+ chosen = []
123
+ for (case_id, key), s in sorted(_latest_per_key(states).items(),
124
+ key=lambda kv: (str(kv[0][0]), str(kv[0][1]))):
125
+ if s.get("state") not in EXPORT_STATES:
126
+ continue
127
+ raw_due = s.get("due_at")
128
+ if not raw_due:
129
+ continue
130
+ try:
131
+ due = date.fromisoformat(str(raw_due)[:10])
132
+ except ValueError:
133
+ continue
134
+ if not (today <= due <= horizon):
135
+ continue
136
+ chosen.append((case_id, key, s, due))
137
+
138
+ for case_id, key, s, due in chosen:
139
+ uid = event_uid(str(case_id), str(key))
140
+ stamp = f"{due.strftime('%Y%m%d')}T000000Z"
141
+ start = f"{due.strftime('%Y%m%d')}T{DUE_HOUR:02d}0000"
142
+ # DTEND = DTSTART + 1 час; дата та же, поскольку DUE_HOUR+1 < 24.
143
+ end = f"{due.strftime('%Y%m%d')}T{DUE_HOUR + 1:02d}0000"
144
+ lines: list[str] = ["BEGIN:VEVENT"]
145
+ lines += _line("UID", _esc(uid))
146
+ lines += _line("DTSTAMP", stamp)
147
+ lines += _line("DTSTART;TZID=" + TZID, start)
148
+ lines += _line("DTEND;TZID=" + TZID, end)
149
+ lines += _line("SUMMARY", _esc(f"deadline: {key} {case_id}"))
150
+ lines += _line("DESCRIPTION", _esc(_describe(s)))
151
+ lines += _line("STATUS", "CONFIRMED")
152
+ if s.get("state") == "confirmed":
153
+ lines += ["BEGIN:VALARM"]
154
+ lines += _line("ACTION", "DISPLAY")
155
+ lines += _line("TRIGGER", "-P1D")
156
+ lines += _line("DESCRIPTION", _esc(f"deadline reminder: {key} {case_id}"))
157
+ lines += ["END:VALARM"]
158
+ lines.append("END:VEVENT")
159
+ body += lines
160
+ events_meta.append({
161
+ "uid": uid,
162
+ "case_id": case_id,
163
+ "deadline_key": key,
164
+ "due_at": due.isoformat(),
165
+ "state": s.get("state"),
166
+ "alarm": s.get("state") == "confirmed",
167
+ })
168
+
169
+ out: list[str] = ["BEGIN:VCALENDAR"]
170
+ out += _line("VERSION", "2.0")
171
+ out += _line("PRODID", PRODID)
172
+ out += _line("CALSCALE", "GREGORIAN")
173
+ out += _line("METHOD", "PUBLISH")
174
+ out += list(_VTIMEZONE)
175
+ out += body
176
+ out.append("END:VCALENDAR")
177
+ text = "\r\n".join(out) + "\r\n"
178
+ return text, events_meta
179
+
180
+
181
+ def ics_for_state_dir(state_dir, days: int = 60,
182
+ today: Optional[date] = None) -> Tuple[str, list[dict]]:
183
+ from sud_cli.db import get_connection, init_db, list_all_deadline_states
184
+
185
+ init_db(state_dir)
186
+ conn = get_connection(state_dir)
187
+ try:
188
+ states = list_all_deadline_states(conn)
189
+ finally:
190
+ conn.close()
191
+ return build_ics(states, today=today, days=days)
sud_cli/case_diff.py ADDED
@@ -0,0 +1,165 @@
1
+ """Build a version diff over two saved snapshots — mirrors case-search semantics.
2
+
3
+ case-search has ``diff_snapshots`` in ``case_search/diff.py:59`` but never
4
+ exposes it: no CLI subcommand calls it and ``public_api.py`` does not re-export
5
+ it (verified by grep over the whole package and by ``case-search --help``).
6
+ sud-cli therefore implements the same field set on its own, reading the two
7
+ snapshot JSON files that the import commands already write into the state
8
+ directory. case-search itself is untouched.
9
+
10
+ The compared fields are copied from ``case_search.diff._SCALARS`` so the two
11
+ implementations agree on what counts as a change.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from pathlib import Path
18
+ from typing import Any, Dict, List, Optional, Tuple
19
+
20
+ # Copied from case_search/diff.py::_SCALARS.
21
+ SCALAR_FIELDS: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
22
+ ("status", ("status",)),
23
+ ("court", ("court",)),
24
+ ("judge", ("judge",)),
25
+ ("updated_at", ("updated_at",)),
26
+ ("next_hearing.date", ("next_hearing", "date")),
27
+ ("next_hearing.time", ("next_hearing", "time")),
28
+ ("next_hearing.hall", ("next_hearing", "hall")),
29
+ ("next_hearing.type", ("next_hearing", "type")),
30
+ ("last_event.date", ("last_event", "date")),
31
+ ("last_event.event_type", ("last_event", "event_type")),
32
+ ("last_event.document_url", ("last_event", "document_url")),
33
+ )
34
+
35
+
36
+ class DiffError(Exception):
37
+ """A snapshot file is missing, unreadable or not a case snapshot."""
38
+
39
+
40
+ def _dig(payload: Dict[str, Any], path: Tuple[str, ...]) -> Any:
41
+ current: Any = payload
42
+ for key in path:
43
+ if not isinstance(current, dict):
44
+ return None
45
+ current = current.get(key)
46
+ return current
47
+
48
+
49
+ def _load(path: Path) -> Dict[str, Any]:
50
+ try:
51
+ raw = path.read_text(encoding="utf-8")
52
+ except OSError as exc:
53
+ raise DiffError(f"cannot read snapshot: {exc}") from exc
54
+ try:
55
+ payload = json.loads(raw)
56
+ except json.JSONDecodeError as exc:
57
+ raise DiffError(f"snapshot is not valid JSON: {exc}") from exc
58
+ if not isinstance(payload, dict):
59
+ raise DiffError("snapshot must be a JSON object")
60
+ return payload
61
+
62
+
63
+ def _snapshot_payload(path: Path) -> Dict[str, Any]:
64
+ """Return the snapshot object, unwrapping an ``auto-import`` envelope.
65
+
66
+ ``auto-import`` wraps its result as ``{"snapshot": {...}, "extensions": ...}``
67
+ while ``sudrf-import`` writes the snapshot at the top level. Accepting both
68
+ keeps the command usable regardless of which importer produced the file.
69
+ """
70
+ payload = _load(path)
71
+ inner = payload.get("snapshot")
72
+ if isinstance(inner, dict):
73
+ return inner
74
+ return payload
75
+
76
+
77
+ def _parties(payload: Dict[str, Any]) -> Dict[str, List[str]]:
78
+ parties = payload.get("parties")
79
+ if not isinstance(parties, dict):
80
+ return {"plaintiff": [], "respondent": [], "third_parties": []}
81
+ return {
82
+ key: list(parties.get(key) or [])
83
+ for key in ("plaintiff", "respondent", "third_parties")
84
+ }
85
+
86
+
87
+ def _timeline_index(payload: Dict[str, Any]) -> Dict[Tuple[str, str], bool]:
88
+ index: Dict[Tuple[str, str], bool] = {}
89
+ for event in payload.get("events_timeline") or []:
90
+ if not isinstance(event, dict):
91
+ continue
92
+ date = event.get("date")
93
+ name = event.get("name")
94
+ if date is None or name is None:
95
+ continue
96
+ index[(str(date), str(name))] = bool(event.get("published"))
97
+ return index
98
+
99
+
100
+ def diff_case_snapshots(
101
+ old_path: Path, new_path: Path
102
+ ) -> Dict[str, Any]:
103
+ """Compare two saved snapshots of the same case.
104
+
105
+ Returns a dict with ``changed``, ``changes`` (path/before/after) and
106
+ ``added_removed_events``. An unchanged pair yields ``changed: False`` with
107
+ empty lists rather than an error, because "nothing moved" is a legitimate
108
+ answer to the question.
109
+ """
110
+ old = _snapshot_payload(old_path)
111
+ new = _snapshot_payload(new_path)
112
+
113
+ old_number = str(old.get("case_number") or "")
114
+ new_number = str(new.get("case_number") or "")
115
+ if not old_number or not new_number:
116
+ raise DiffError("snapshot has no case_number")
117
+
118
+ # Check identity *before* comparing: a field-by-field report across two
119
+ # different cases would be noise dressed up as a diff.
120
+ if old_number != new_number:
121
+ raise DiffError(
122
+ f"snapshots belong to different cases: {old_number} vs {new_number}"
123
+ )
124
+
125
+ changes: List[Dict[str, Any]] = []
126
+ for path, keys in SCALAR_FIELDS:
127
+ before = _dig(old, keys)
128
+ after = _dig(new, keys)
129
+ if before != after:
130
+ changes.append({"path": path, "before": before, "after": after})
131
+
132
+ before_parties = _parties(old)
133
+ after_parties = _parties(new)
134
+ for role in ("plaintiff", "respondent", "third_parties"):
135
+ if before_parties[role] != after_parties[role]:
136
+ changes.append({
137
+ "path": f"parties.{role}",
138
+ "before": before_parties[role],
139
+ "after": after_parties[role],
140
+ })
141
+
142
+ old_tl = _timeline_index(old)
143
+ new_tl = _timeline_index(new)
144
+ timeline_changes: List[Dict[str, Any]] = []
145
+ for key in sorted(set(old_tl) | set(new_tl)):
146
+ was, now = old_tl.get(key), new_tl.get(key)
147
+ if was != now:
148
+ timeline_changes.append({
149
+ "date": key[0],
150
+ "name": key[1],
151
+ "before": was,
152
+ "after": now,
153
+ })
154
+
155
+ return {
156
+ "case_number": new_number,
157
+ "changed": bool(changes or timeline_changes),
158
+ "changes": sorted(changes, key=lambda c: c["path"]),
159
+ "timeline_changes": timeline_changes,
160
+ "compared_fields": len(SCALAR_FIELDS) + 3,
161
+ "source": {
162
+ "old": str(old_path),
163
+ "new": str(new_path),
164
+ },
165
+ }