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
@@ -0,0 +1,278 @@
1
+ """Procedural deadline calculation (spec 1.0.0 + addendum 1.1.0).
2
+
3
+ Addendum 1.1.0 closed Б1–Б4 and changed three things this module implements:
4
+
5
+ * **Б1** — the working-day counter starts on the next *calendar* day after the
6
+ event, and each non-working day is skipped without incrementing (ч. 3 + ч. 4
7
+ ст. 113 АПК РФ). This is deliberately not "start on the next working day":
8
+ both readings coincide on a Friday event, so the distinction is asserted on a
9
+ mid-week event where they diverge.
10
+ * **Б2** — the production calendar is active. Deadlines landing on a non-working
11
+ day are shifted to the next working day (ч. 4 ст. 114 АПК / ч. 2 ст. 108 ГПК).
12
+ Government transfer decrees are encoded for 2026 (№ 1466) and 2027 (№ 1187);
13
+ later years carry a full ``CALENDAR_WARNING``, the encoded years carry a
14
+ source confirmation instead.
15
+ * **Б3** — the matrix gained court-order, КАС РФ and ruling-appeal rows.
16
+
17
+ **Б5** — was open, now RESOLVED by the colleague-legal opinion of 2026-10-08
18
+ (``LEGAL-OPINION-B5-MONTH-DEADLINES.md``): a month-long term expires on the
19
+ "соответствующее число" of the last month, and that number is the day number of
20
+ the **event date** (Day 0), not of the following day (ч. 2 ст. 114 АПК РФ,
21
+ ч. 1 ст. 108 ГПК РФ, ч. 1 ст. 92 КАС РФ). The "term starts the next day" rule
22
+ only excludes the event day from the count; it does not push the expiry forward.
23
+ If the target month has no such day (31 января + 1 месяц), the term expires on
24
+ that month's last day. The non-working-day rule is applied *after* this date is
25
+ fixed. The switch lives in ``legal_rules.json`` as ``month_calculation_base``;
26
+ the rejected ``from_day1`` reading raises instead of computing silently.
27
+ """
28
+
29
+ from dataclasses import dataclass, field
30
+ from datetime import date, datetime, time, timedelta
31
+ from typing import List, Optional
32
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
33
+
34
+ from .legal_engine import (
35
+ STATUS_MANUAL_REVIEW,
36
+ STATUS_MISSING_PARAM,
37
+ STATUS_OK,
38
+ LegalRulesError,
39
+ load_rules,
40
+ )
41
+ from .production_calendar import (
42
+ add_calendar_days,
43
+ add_working_days,
44
+ calendar_warnings,
45
+ is_working_day,
46
+ )
47
+
48
+
49
+ @dataclass
50
+ class DeadlineResult:
51
+ status: str
52
+ deadline: Optional[date] = None
53
+ detail: str = ""
54
+ legal_basis: str = ""
55
+ warnings: List[str] = field(default_factory=list)
56
+ trace: List[dict] = field(default_factory=list)
57
+
58
+ deadline_datetime: Optional[str] = None
59
+ time_with_tz: Optional[str] = None
60
+ region: Optional[str] = None
61
+
62
+ def to_dict(self) -> dict:
63
+ payload = {
64
+ "status": self.status,
65
+ "deadline": self.deadline.isoformat() if self.deadline else None,
66
+ "detail": self.detail,
67
+ "legal_basis": self.legal_basis,
68
+ "warnings": self.warnings,
69
+ "trace": self.trace,
70
+ }
71
+ if self.deadline_datetime is not None:
72
+ payload.update({"deadline_datetime": self.deadline_datetime, "time_with_tz": self.time_with_tz})
73
+ if self.region is not None:
74
+ payload["region"] = self.region
75
+ return payload
76
+
77
+
78
+ def _shift_to_working_day(day: date, trace: List[dict], warnings: List[str], region=None) -> date:
79
+ """Move a deadline off a non-working day (ч. 4 ст. 114 АПК / ч. 2 ст. 108 ГПК)."""
80
+ if is_working_day(day, region=region):
81
+ return day
82
+ original = day
83
+ while not is_working_day(day, region=region):
84
+ day += timedelta(days=1)
85
+ trace.append(
86
+ {
87
+ "step": (
88
+ f"срок истекал {original.isoformat()} — нерабочий день, "
89
+ f"перенесён на {day.isoformat()}"
90
+ )
91
+ }
92
+ )
93
+ warnings.append(
94
+ f"Расчётная дата {original.isoformat()} приходилась на нерабочий день; "
95
+ f"срок перенесён на {day.isoformat()} (ч. 4 ст. 114 АПК РФ / ч. 2 ст. 108 ГПК РФ)."
96
+ )
97
+ return day
98
+
99
+
100
+ def _add_months(start: date, months: int) -> date:
101
+ """Add calendar months, clamping to the last day of a shorter month.
102
+
103
+ Per ст. 192 ГК РФ a term set in months ends on the corresponding day of
104
+ the last month, so ``30 сентября + 1 месяц -> 30 октября``. Only when the
105
+ target month has no such day does the term end on that month's last day:
106
+ ``31 января + 1 месяц -> 28/29 февраля``.
107
+
108
+ The earlier version branched on ``start.day > 28``, which sent every date
109
+ from the 29th onward to the month's last day — so 30 September became
110
+ 31 October and 30 April became 31 May, each a day late.
111
+ """
112
+ total_month = start.month - 1 + months
113
+ year = start.year + total_month // 12
114
+ month = total_month % 12 + 1
115
+ next_month_first = date(year + (month == 12), (month % 12) + 1, 1)
116
+ last_day = (next_month_first - timedelta(days=1)).day
117
+ return date(year, month, min(start.day, last_day))
118
+
119
+
120
+ def calculate_deadline(
121
+ *,
122
+ deadline_key: str,
123
+ event_date: Optional[date] = None,
124
+ rules: Optional[dict] = None,
125
+ region: Optional[str] = None,
126
+ tz: Optional[str] = None,
127
+ ) -> DeadlineResult:
128
+ """Calculate one procedural deadline from the approved matrix."""
129
+ rules = rules or load_rules()
130
+ if region is not None:
131
+ try:
132
+ from .regional_holidays import normalize_region
133
+ region = normalize_region(region)
134
+ except ValueError as exc:
135
+ return DeadlineResult(STATUS_MANUAL_REVIEW, detail=str(exc))
136
+ zone = None
137
+ if tz is not None:
138
+ try:
139
+ zone = ZoneInfo(tz)
140
+ except ZoneInfoNotFoundError:
141
+ return DeadlineResult(STATUS_MANUAL_REVIEW, detail=f"unknown timezone: {tz}")
142
+ matrix = rules.get("deadline_matrix", {})
143
+
144
+ entry = matrix.get(deadline_key)
145
+ if entry is None:
146
+ return DeadlineResult(
147
+ STATUS_MANUAL_REVIEW,
148
+ detail=(
149
+ f"срок '{deadline_key}' отсутствует в матрице спецификации; "
150
+ "возможен нестандартный срок — требуется ручная проверка"
151
+ ),
152
+ )
153
+
154
+ if entry.get("always_manual_review"):
155
+ return DeadlineResult(
156
+ STATUS_MANUAL_REVIEW,
157
+ detail=(
158
+ "специальные сокращённые сроки (напр. по обеспечению иска) "
159
+ "в MVP не кодируются — требуется ручная проверка"
160
+ ),
161
+ )
162
+
163
+ duration = entry.get("duration")
164
+ if not duration:
165
+ return DeadlineResult(
166
+ STATUS_MANUAL_REVIEW,
167
+ detail=f"у срока '{deadline_key}' не задан срок — требуется ручная проверка",
168
+ )
169
+
170
+ if not event_date:
171
+ return DeadlineResult(
172
+ STATUS_MISSING_PARAM,
173
+ detail=f"MISSING_REQUIRED_PARAM: event_date (триггер: {entry['trigger']})",
174
+ )
175
+
176
+ juris_code = entry["jurisdiction"]
177
+ juris = rules["jisdictions"].get(juris_code)
178
+ if juris is None:
179
+ raise LegalRulesError(f"matrix references unknown jurisdiction: {juris_code}")
180
+
181
+ day_mode = juris["deadline_day_mode"]
182
+ warnings: List[str] = []
183
+ trace: List[dict] = [
184
+ {"step": f"событие (Day 0): {event_date.isoformat()}, не засчитывается"}
185
+ ]
186
+
187
+ # Day 0 is never counted (п. 6): the term starts the next calendar day.
188
+ day1 = event_date + timedelta(days=1)
189
+ trace.append({"step": f"Day 1 = {day1.isoformat()} (следующий календарный день)"})
190
+
191
+ unit = duration["unit"]
192
+ b5_open = rules["spec_open_questions"].get("B5_monthly_base", "").startswith("UNRESOLVED")
193
+
194
+ if unit == "months":
195
+ # Б5 RESOLVED (colleague-legal, 2026-10-08, ч.2 ст.114 АПК РФ): a term counted in
196
+ # months expires on the corresponding day of the last month, and that
197
+ # "соответствующее число" is the day number of the EVENT date (Day 0).
198
+ # The "term starts the next day" rule (ч.4 ст.113 АПК РФ) only excludes the
199
+ # event day itself from the count — it does not push the expiry forward,
200
+ # otherwise the party would get one month plus a day.
201
+ base = rules.get("month_calculation_base", "event_date")
202
+ if base == "event_date":
203
+ deadline = _add_months(event_date, duration["value"])
204
+ trace.append(
205
+ {
206
+ "step": (
207
+ f"+{duration['value']} мес. от даты события "
208
+ f"{event_date.isoformat()} по правилу «число в число» "
209
+ "(ч.2 ст.114 АПК РФ)"
210
+ )
211
+ }
212
+ )
213
+ trace.append(
214
+ {
215
+ "step": (
216
+ "день события не засчитывается, но число окончания "
217
+ "не сдвигается на следующий день"
218
+ )
219
+ }
220
+ )
221
+ elif base == "from_day1":
222
+ # Disabled by the Б5 opinion: explicitly retained only so that a
223
+ # mis-set switch fails loudly instead of silently reverting to the
224
+ # rejected reading. Never selected by default.
225
+ raise LegalRulesError(
226
+ "month_calculation_base='from_day1' признан некорректным "
227
+ "заключением colleague-legal от 2026-10-08 (Б5 закрыт): допустимо "
228
+ "только 'event_date'"
229
+ )
230
+ else:
231
+ raise LegalRulesError(
232
+ f"unknown month_calculation_base: {base!r} "
233
+ "(допустимо только 'event_date')"
234
+ )
235
+ elif unit == "working_days":
236
+ # Б1: the counter starts at the next calendar day and skips non-working
237
+ # days without incrementing, so day1 is passed through unchanged.
238
+ deadline = add_working_days(day1, duration["value"], region=region)
239
+ trace.append(
240
+ {"step": f"+{duration['value']} рабочих дней (выходные и праздники пропущены)"}
241
+ )
242
+ elif unit == "calendar_days":
243
+ deadline = add_calendar_days(day1, duration["value"])
244
+ trace.append({"step": f"+{duration['value']} календарных дней"})
245
+ else:
246
+ raise LegalRulesError(f"unknown duration unit: {unit}")
247
+
248
+ deadline = _shift_to_working_day(deadline, trace, warnings, region)
249
+
250
+ warnings.extend(calendar_warnings(deadline.year, region=region))
251
+
252
+ if b5_open:
253
+ # Legacy branch: only reachable if the rules file is rolled back to the
254
+ # pre-opinion state. Kept so an old rules file fails with an explanation
255
+ # rather than silently computing on the rejected reading.
256
+ warnings.append(
257
+ "Б5 (открыт): для месячных сроков база отсчёта не подтверждена — "
258
+ "расчёт следует п.6 (отсчёт от дня, следующего за событием). "
259
+ "Пример п.7 спеки («31 января → 28 февраля») предполагает базу от даты "
260
+ "события и дал бы другой результат."
261
+ )
262
+
263
+ warnings.append(rules["boundary_note"])
264
+
265
+ result = DeadlineResult(
266
+ status=STATUS_OK,
267
+ deadline=deadline,
268
+ detail=f"{day_mode}, триггер: {entry['trigger']}",
269
+ legal_basis=entry["legal_basis"],
270
+ warnings=warnings,
271
+ trace=trace,
272
+ region=region,
273
+ )
274
+ if zone is not None:
275
+ local = datetime.combine(deadline, time(23, 59, 59), tzinfo=zone)
276
+ result.deadline_datetime = local.isoformat()
277
+ result.time_with_tz = f"{local:%Y-%m-%d %H:%M:%S} {local.tzname()} ({tz})"
278
+ return result