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/readiness.py ADDED
@@ -0,0 +1,234 @@
1
+ """Live-readiness self-check for AI agents driving sud-cli.
2
+
3
+ An agent that only reads ``--help`` cannot tell that every court command needs
4
+ ``SUD_CLI_BRIDGE_LIVE=1``, that ``lookup`` needs ``--source`` or that the case
5
+ reference uses a two-digit year. Each of those was discovered by hand on
6
+ 2026-10-09, at the cost of a full debugging session. This module answers the
7
+ same questions in one call, without contacting any court host.
8
+
9
+ Everything here is local: the checks only inspect configuration, the
10
+ case-search binary on PATH, and the optional Camofox runtime on loopback.
11
+ No court request is made, so the check is safe to run before every session.
12
+
13
+ ``check_live_readiness`` returns a mapping with one entry per concern, each
14
+ carrying ``ok``, ``detail`` and — where it applies — ``remedy``, so an agent
15
+ can act on the output without reading the source.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import shutil
21
+ from pathlib import Path
22
+ from typing import Any, Dict, Optional
23
+ from urllib.request import urlopen
24
+ from urllib.error import URLError
25
+
26
+ #: Environment variable that opts the bridge into live court access.
27
+ LIVE_ENV_VAR = "SUD_CLI_BRIDGE_LIVE"
28
+
29
+ #: Environment variable that routes case-search at an external Camofox
30
+ #: runtime. Required on this host: the in-process browser cannot initialise
31
+ #: inside the bridge's child ("Playwright Sync API inside the asyncio loop"),
32
+ #: so every JS-capable command fails without it.
33
+ CAMOFOX_ENV_VAR = "CSE_CAMOFOX_URL"
34
+
35
+ #: Default loopback Camofox endpoint used when the variable is unset.
36
+ DEFAULT_CAMOFOX_URL = "http://127.0.0.1:9377"
37
+
38
+ #: Values of ``SUD_CLI_BRIDGE_LIVE`` that count as enabled. Kept in step with
39
+ #: ``case_search_bridge._live_access_enabled`` — an unset variable must never
40
+ #: open network access.
41
+ LIVE_ENABLED_VALUES = frozenset({"1", "true", "yes", "on"})
42
+
43
+ #: Real court references, covering each prefix shape the bridge accepts.
44
+ #: Prefixes are mixed alphanumerics of up to 4 characters and letters are legal
45
+ #: in ANY position — ГАС Правосудие prints ``2а-2939/2025`` with a digit first.
46
+ #: These are examples for the agent, not a validation contract: the pattern
47
+ #: itself lives in ``case_search_bridge`` and is exercised by its own tests.
48
+ CASE_REFERENCE_EXAMPLES = (
49
+ ("А40-11111/25", "АС Москвы — the abbreviated two-digit year courts print"),
50
+ ("А40-11111/2025", "same case, four-digit year"),
51
+ ("33-35073/2026", "SudRF, no letter prefix"),
52
+ ("2а-2939/2025", "ГАС Правосудие — district court, digit before letter"),
53
+ ("16АП-12345/2025", "appellate instance, letter suffix"),
54
+ )
55
+
56
+ #: Commands that reach an external court host through the bridge.
57
+ LIVE_COMMANDS = (
58
+ "lookup",
59
+ "case-history",
60
+ "sudrf-live",
61
+ "mos-live",
62
+ "search-practice",
63
+ "kad-search",
64
+ "get-text",
65
+ )
66
+
67
+
68
+ def _entry(ok: bool, detail: str, remedy: str = "") -> Dict[str, Any]:
69
+ entry: Dict[str, Any] = {"ok": ok, "detail": detail}
70
+ if remedy:
71
+ entry["remedy"] = remedy
72
+ return entry
73
+
74
+
75
+ def _live_flag_enabled(environ: Dict[str, str]) -> bool:
76
+ return str(environ.get(LIVE_ENV_VAR, "")).strip().lower() in LIVE_ENABLED_VALUES
77
+
78
+
79
+ def _check_live_flag(environ: Dict[str, str]) -> Dict[str, Any]:
80
+ raw = environ.get(LIVE_ENV_VAR)
81
+ if raw is None:
82
+ return _entry(
83
+ False,
84
+ f"{LIVE_ENV_VAR} is not set; all court commands return 'blocked'",
85
+ f"export {LIVE_ENV_VAR}=1",
86
+ )
87
+ if _live_flag_enabled(environ):
88
+ return _entry(True, f"{LIVE_ENV_VAR}={raw} enables live court access")
89
+ return _entry(
90
+ False,
91
+ f"{LIVE_ENV_VAR}={raw!r} is not an enabling value",
92
+ f"export {LIVE_ENV_VAR}=1",
93
+ )
94
+
95
+
96
+ def _check_binary(environ: Dict[str, str]) -> Dict[str, Any]:
97
+ path = environ.get("PATH", "")
98
+ # An explicitly empty PATH must read as "not installed", not as "fall back
99
+ # to os.defpath": shutil.which(cmd, path=None) searches the default PATH
100
+ # and would report a binary the caller cannot actually reach.
101
+ found = shutil.which("case-search", path=path) if path else None
102
+ if not found:
103
+ return _entry(
104
+ False,
105
+ "case-search is not on PATH",
106
+ "install case-search v1.2.0 or add it to PATH",
107
+ )
108
+ return _entry(True, f"case-search found at {found}")
109
+
110
+
111
+ def _check_license(environ: Dict[str, str]) -> Dict[str, Any]:
112
+ explicit = environ.get("CSE_LICENSE_FILE")
113
+ candidates = (
114
+ [explicit]
115
+ if explicit
116
+ else [
117
+ str(Path(environ.get("HOME", str(Path.home()))) / ".config" / "case-search" / "config.json")
118
+ ]
119
+ )
120
+ for candidate in candidates:
121
+ if candidate and Path(candidate).expanduser().is_file():
122
+ return _entry(True, f"license file present at {candidate}")
123
+ return _entry(
124
+ False,
125
+ "no case-search license file; licensed commands fail with rc=4",
126
+ "set CSE_LICENSE_FILE or install ~/.config/case-search/config.json",
127
+ )
128
+
129
+
130
+ def _camofox_url(environ: Dict[str, str]) -> Optional[str]:
131
+ raw = environ.get(CAMOFOX_ENV_VAR)
132
+ return raw.strip() if raw and raw.strip() else None
133
+
134
+
135
+ def _probe_camofox(url: str, timeout: float = 4.0) -> Optional[bool]:
136
+ """Return True/False if the runtime answered, None if it did not respond.
137
+
138
+ Any HTTP answer counts as up: the endpoint is a local service whose job is
139
+ to render pages, and its status body is not the question being asked here.
140
+ """
141
+ try:
142
+ with urlopen(url.rstrip("/") + "/health", timeout=timeout) as resp:
143
+ resp.read(1024)
144
+ return True
145
+ except URLError:
146
+ # A 4xx/5xx still proves the listener is there.
147
+ return False
148
+ except Exception:
149
+ return None
150
+
151
+
152
+ def _check_camofox(environ: Dict[str, str]) -> Dict[str, Any]:
153
+ configured = _camofox_url(environ)
154
+ if not configured:
155
+ return _entry(
156
+ False,
157
+ f"{CAMOFOX_ENV_VAR} is not set; JS-capable commands fall back to the "
158
+ "in-process browser and fail",
159
+ f"export {CAMOFOX_ENV_VAR}={DEFAULT_CAMOFOX_URL}",
160
+ )
161
+ answer = _probe_camofox(configured)
162
+ if answer is True:
163
+ return _entry(True, f"camofox runtime reachable at {configured}")
164
+ if answer is False:
165
+ return _entry(
166
+ False,
167
+ f"camofox runtime at {configured} answered with an error status",
168
+ "start it, then re-run this check",
169
+ )
170
+ return _entry(
171
+ False,
172
+ f"camofox runtime at {configured} did not answer",
173
+ "start it, then re-run this check",
174
+ )
175
+
176
+
177
+ def _check_legal_rules() -> Dict[str, Any]:
178
+ """Report the tariff ruleset the engine will actually apply.
179
+
180
+ Reading it here proves the engine is usable before an agent spends a
181
+ calculation on it, and pins the exact spec/tariff versions that produced a
182
+ number — a fee computed under 1.0.0 is not the same claim as one under 1.1.0.
183
+ """
184
+ from sud_cli.legal_engine import load_rules
185
+
186
+ rules = load_rules()
187
+ if not isinstance(rules, dict):
188
+ return _entry(False, "legal rules file did not parse into a mapping")
189
+ spec = rules.get("spec_version") or rules.get("version") or "unknown"
190
+ tariff = rules.get("tariff_version", "unknown")
191
+ calendar = rules.get("production_calendar") or {}
192
+ transfers = calendar.get("government_transfers_encoded_through", "n/a")
193
+ status = calendar.get("status", "unknown")
194
+ return _entry(
195
+ True,
196
+ f"legal rules spec v{spec} (tariff {tariff}); production calendar "
197
+ f"{status}, government transfers encoded through {transfers}",
198
+ )
199
+
200
+
201
+ def check_live_readiness(environ: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
202
+ """Return a machine-readable readiness report.
203
+
204
+ ``ready`` is true only when every blocking check passed. It is a statement
205
+ about configuration, never about whether a given court answers — the tool
206
+ cannot know that without making a request.
207
+ """
208
+ source: Dict[str, str] = dict(environ if environ is not None else os.environ)
209
+
210
+ named_checks = {
211
+ "live_access": _check_live_flag(source),
212
+ "case_search_binary": _check_binary(source),
213
+ "license": _check_license(source),
214
+ "camofox_runtime": _check_camofox(source),
215
+ "legal_rules": _check_legal_rules(),
216
+ }
217
+ blocking = [name for name, check in named_checks.items() if not check["ok"]]
218
+ return {
219
+ "ready": not blocking,
220
+ "checks": named_checks,
221
+ "blocked_by": blocking,
222
+ "usage": {
223
+ "case_reference_examples": [
224
+ {"reference": ref, "note": note} for ref, note in CASE_REFERENCE_EXAMPLES
225
+ ],
226
+ "lookup_note": (
227
+ "lookup defaults to case-search's 'synthetic' provider, which "
228
+ "only knows fixture ids — always pass --source kad|mos-gorsud|"
229
+ "mos-gorsud-dom|sudrf"
230
+ ),
231
+ "live_commands": list(LIVE_COMMANDS),
232
+ "global_flags": ["--json must precede the subcommand", "--version"],
233
+ },
234
+ }
@@ -0,0 +1,37 @@
1
+ """Fixed regional non-working holidays used as an optional calendar layer."""
2
+
3
+ from datetime import date
4
+
5
+ REGIONS = {
6
+ "02": {"name": "Республика Башкортостан", "holidays": {"03-17": "День Конституции", "10-11": "День Республики"}},
7
+ "03": {"name": "Республика Бурятия", "holidays": {"07-11": "День Республики"}},
8
+ "05": {"name": "Республика Дагестан", "holidays": {"09-15": "День единства народов Дагестана"}},
9
+ "14": {"name": "Республика Саха (Якутия)", "holidays": {"04-27": "День Республики"}},
10
+ "16": {"name": "Республика Татарстан", "holidays": {"08-30": "День Республики Татарстан", "11-06": "День Конституции Татарстана"}},
11
+ "20": {"name": "Чеченская Республика", "holidays": {"09-06": "День гражданского согласия и единения"}},
12
+ "91": {"name": "Республика Крым", "holidays": {"03-18": "День воссоединения с Россией", "09-20": "День Республики Крым"}},
13
+ "77": {"name": "Федеральный календарь", "holidays": {}},
14
+ }
15
+
16
+
17
+ def normalize_region(region):
18
+ if region is None:
19
+ return None
20
+ code = str(region).strip()
21
+ if code == "9":
22
+ code = "09"
23
+ if code not in REGIONS:
24
+ raise ValueError(f"unknown region code: {region}")
25
+ return code
26
+
27
+
28
+ def holidays_for_year(year, region=None):
29
+ code = normalize_region(region)
30
+ if code is None or code == "77":
31
+ return set()
32
+ return {date(year, int(token[:2]), int(token[3:])) for token in REGIONS[code]["holidays"]}
33
+
34
+
35
+ def region_name(region):
36
+ code = normalize_region(region)
37
+ return REGIONS[code]["name"] if code else None
sud_cli/reminders.py ADDED
@@ -0,0 +1,92 @@
1
+ """the wave §2 (S-26): reminders due — bus-free генератор готовых a2a-строк.
2
+
3
+ Границы:
4
+ - sud-cli НИКОГДА не вызывает шину a2a сам: модуль не импортирует ничего,
5
+ что умеет запускать внешние процессы. Здесь только чистые функции над
6
+ списком состояний сроков и сборка строки-текста. Исполняет строку
7
+ оператор или агент-планировщик осознанно, вне sud.
8
+ - Источник данных — deadline_states (the wave): state computed|confirmed,
9
+ due_at ISO; due_at NULL и waived пропускаются молча, но не угадываются.
10
+ - числа в теле письма есть => по политике a2a строка обязана нести
11
+ --verify "<детерминированная команда>" --verify-run.
12
+ """
13
+ from datetime import date, timedelta
14
+ from typing import List
15
+
16
+ DUE_STATES = ("computed", "confirmed")
17
+
18
+ BUS_FREE_NOTE = (
19
+ "bus-free: sud-cli не вызывает a2a; ниже готовые экранированные строки "
20
+ "команд — исполнение остаётся за оператором"
21
+ )
22
+
23
+ VERIFY_CMD_TEMPLATE = "/usr/bin/sud-cli reminders due --days {days} --json"
24
+
25
+
26
+ def _latest_per_key(states):
27
+ """Последнее состояние на пару (case_id, deadline_key): id растёт с историей."""
28
+ latest = {}
29
+ for s in states:
30
+ key = (s.get("case_id"), s.get("deadline_key"))
31
+ try:
32
+ sid = int(s.get("id") or 0)
33
+ except (TypeError, ValueError):
34
+ sid = 0
35
+ prev = latest.get(key)
36
+ if prev is None or sid >= int(prev.get("id") or 0):
37
+ latest[key] = s
38
+ return list(latest.values())
39
+
40
+
41
+ def due_items(states, today, days: int) -> List[dict]:
42
+ """Сроки, попадающие в окно [today, today+days], по последнему состоянию."""
43
+ horizon = today + timedelta(days=days)
44
+ items = []
45
+ for s in _latest_per_key(states):
46
+ if s.get("state") not in DUE_STATES:
47
+ continue
48
+ due_raw = s.get("due_at")
49
+ if not due_raw:
50
+ continue
51
+ try:
52
+ due = date.fromisoformat(str(due_raw)[:10])
53
+ except ValueError:
54
+ continue
55
+ if not (today <= due <= horizon):
56
+ continue
57
+ items.append({
58
+ "case_id": s.get("case_id"),
59
+ "deadline_key": s.get("deadline_key"),
60
+ "due_at": due.isoformat(),
61
+ "days_left": (due - today).days,
62
+ "state": s.get("state"),
63
+ })
64
+ items.sort(key=lambda i: (i["due_at"], str(i["case_id"]), str(i["deadline_key"])))
65
+ return items
66
+
67
+
68
+ def a2a_command(item, days: int) -> str:
69
+ """Одна готовая строка a2a mail send; все значения экранированы shlex."""
70
+ import shlex
71
+ subject = f"deadline: {item['case_id']}/{item['deadline_key']} до {item['due_at']}"
72
+ body = (
73
+ f"Напоминание: срок {item['deadline_key']} по делу {item['case_id']} "
74
+ f"до {item['due_at']}, осталось {item['days_left']} дн. "
75
+ f"Окно выборки: {days} дн. Сверка матрицы: sud-cli deadlines list {item['case_id']}. "
76
+ f"Срок может быть просрочен к дате исполнения — проверь до отправки."
77
+ )
78
+ verify = VERIFY_CMD_TEMPLATE.format(days=days)
79
+ parts = [
80
+ "a2a", "mail", "send",
81
+ "--to", "colleague-legal",
82
+ "--priority", "today",
83
+ "--subject", subject,
84
+ "--body", body,
85
+ "--verify", verify,
86
+ "--verify-run",
87
+ ]
88
+ return " ".join(shlex.quote(p) for p in parts)
89
+
90
+
91
+ def generate_mail_commands(items, days: int) -> List[str]:
92
+ return [a2a_command(i, days) for i in items]