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.
- sud_cli/__init__.py +3 -0
- sud_cli/__main__.py +4 -0
- sud_cli/_wm.py +118 -0
- sud_cli/agenda.py +90 -0
- sud_cli/autofill.py +192 -0
- sud_cli/autofill_markers.json +56 -0
- sud_cli/backup.py +256 -0
- sud_cli/calendar_ics.py +191 -0
- sud_cli/case_diff.py +165 -0
- sud_cli/case_search_bridge.py +1322 -0
- sud_cli/chain.py +226 -0
- sud_cli/cli.py +3721 -0
- sud_cli/completeness.py +105 -0
- sud_cli/config.py +20 -0
- sud_cli/conflict.py +210 -0
- sud_cli/conflicts.py +207 -0
- sud_cli/court_alerts.py +156 -0
- sud_cli/court_source.py +108 -0
- sud_cli/data/completeness_rules.json +18 -0
- sud_cli/data/key_rates.json +59 -0
- sud_cli/data/npa_historical_editions.json +19 -0
- sud_cli/db.py +2072 -0
- sud_cli/deadline_matrix.py +99 -0
- sud_cli/deadline_state.py +25 -0
- sud_cli/deadlines.py +8 -0
- sud_cli/deadlines_state.py +1 -0
- sud_cli/deferral.py +5 -0
- sud_cli/efrsb_manual.py +85 -0
- sud_cli/efrsb_public.py +210 -0
- sud_cli/egrul_nalog.py +150 -0
- sud_cli/explain.py +190 -0
- sud_cli/fees_extra.py +104 -0
- sud_cli/forms.py +204 -0
- sud_cli/fssp_manual.py +91 -0
- sud_cli/fssp_semi.py +285 -0
- sud_cli/fssp_stats.py +105 -0
- sud_cli/government_transfers.json +8 -0
- sud_cli/holidays_rf.json +62 -0
- sud_cli/idempotency.py +78 -0
- sud_cli/importing.py +575 -0
- sud_cli/interest.py +144 -0
- sud_cli/jurisdiction.py +177 -0
- sud_cli/jurisdiction_rules.json +265 -0
- sud_cli/legal_deadlines.py +278 -0
- sud_cli/legal_engine.py +558 -0
- sud_cli/legal_rules.json +1058 -0
- sud_cli/limitation.py +70 -0
- sud_cli/mcp_server.py +1594 -0
- sud_cli/metrics.py +142 -0
- sud_cli/npa_as_of.py +21 -0
- sud_cli/output.py +89 -0
- sud_cli/payment.py +24 -0
- sud_cli/pdn.py +259 -0
- sud_cli/plenum_corpus.py +134 -0
- sud_cli/plenums_seed.json +45 -0
- sud_cli/practice_status.py +136 -0
- sud_cli/pravo_publication.py +361 -0
- sud_cli/pretrial.py +151 -0
- sud_cli/production_calendar.py +233 -0
- sud_cli/providers.py +95 -0
- sud_cli/readiness.py +234 -0
- sud_cli/regional_holidays.py +37 -0
- sud_cli/reminders.py +92 -0
- sud_cli/reports.py +349 -0
- sud_cli/sud_netguard.py +943 -0
- sud_cli/timeline_audit.py +80 -0
- sud_cli/verify.py +201 -0
- sud_cli-0.1.1.dist-info/METADATA +119 -0
- sud_cli-0.1.1.dist-info/RECORD +74 -0
- sud_cli-0.1.1.dist-info/WHEEL +4 -0
- sud_cli-0.1.1.dist-info/entry_points.txt +2 -0
- sud_cli-0.1.1.dist-info/licenses/AUTHORS +5 -0
- sud_cli-0.1.1.dist-info/licenses/LICENSE.md +661 -0
- sud_cli-0.1.1.dist-info/licenses/NOTICE +7 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"cards": [
|
|
3
|
+
{
|
|
4
|
+
"doc_id": "PP_VS_RF_2015_43",
|
|
5
|
+
"court": "Верховный Суд РФ",
|
|
6
|
+
"instance": "Пленум",
|
|
7
|
+
"doc_type": "Постановление Пленума",
|
|
8
|
+
"number": "43",
|
|
9
|
+
"date": "2015-09-29",
|
|
10
|
+
"title": "О некоторых вопросах, связанных с применением норм Гражданского кодекса Российской Федерации об исковой давности",
|
|
11
|
+
"official_sources": [
|
|
12
|
+
{
|
|
13
|
+
"source_type": "official_site",
|
|
14
|
+
"url": "https://vsrf.ru/documents/own/8454/",
|
|
15
|
+
"access_date": "2026-10-25"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"source_type": "official_publication",
|
|
19
|
+
"publication": "Российская газета, № 223, 05.10.2015; Бюллетень Верховного Суда РФ, № 12, декабрь 2015"
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"status": "effective",
|
|
23
|
+
"amendments": [
|
|
24
|
+
{
|
|
25
|
+
"amended_by_doc_id": "PP_VS_RF_2020_6",
|
|
26
|
+
"date": "2020-06-11"
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"paragraphs": [
|
|
30
|
+
{
|
|
31
|
+
"paragraph_no": 17,
|
|
32
|
+
"key_topic": "Отмена судебного приказа и течение исковой давности",
|
|
33
|
+
"norm_links": [
|
|
34
|
+
"п. 3 ст. 204 ГК РФ",
|
|
35
|
+
"ст. 129 ГПК РФ"
|
|
36
|
+
],
|
|
37
|
+
"verbatim_text": "В силу пункта 1 статьи 204 ГК РФ срок исковой давности не течет с момента обращения за судебной защитой, в том числе со дня подачи заявления о вынесении судебного приказа либо обращения в третейский суд, если такое заявление было принято к производству. В случае отмены судебного приказа, если неистекшая часть срока исковой давности составляет менее шести месяцев, она удлиняется до шести месяцев.",
|
|
38
|
+
"sha256": "bfd57ebdc54638c7c7696d5322ca77c3f3ed6a143f21e26e201e53caed6d27aa",
|
|
39
|
+
"act_recorded_sha256": "4a7f05809dc8a927e1f6eefc4bb0fecf310f8a8bb6b38c35aef2f1e626e95c47",
|
|
40
|
+
"hash_note": "legal 25.10 (письмо Re: акт V2170): канон — пересчёт bfd57ebd…; хэш акта 4a7f0580… = артефакт промежуточной нормализации (act_recorded_sha256)"
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Practice/act status probe (S-54, v2.9.0) — локальные артефакты, ничего из воздуха.
|
|
2
|
+
|
|
3
|
+
``practice status <case-number>`` answers exactly one honest question: is there
|
|
4
|
+
a LOCAL record that suggests the case decision was overturned or modified? The
|
|
5
|
+
fact base is limited to:
|
|
6
|
+
|
|
7
|
+
* saved case-search snapshot files (``--store`` dir, JSON case snapshots
|
|
8
|
+
and ``case-diff`` artifacts — the structures written by
|
|
9
|
+
``sud_cli.case_diff.diff_case_snapshots`` and the snapshot schema), and
|
|
10
|
+
* an explicit ``--related "<title;title;...>"`` fixture input (offline agent
|
|
11
|
+
hand-off, e.g. titles already collected by case_history in a live session).
|
|
12
|
+
|
|
13
|
+
The snapshot diff structure carries no overturning field (verified against
|
|
14
|
+
``case_search/diff.py``: only path/before/after scalars), so the marker rule
|
|
15
|
+
is header-based: a title from instance>1-style records matching the
|
|
16
|
+
отмен/измен root marks ``possibly_overturned`` — unless negated ("без
|
|
17
|
+
изменения", "оставлено без ..."). Every other answer is ``unknown`` with a
|
|
18
|
+
mandatory hint. This module NEVER asserts «действует»/«отменён»: absence of a
|
|
19
|
+
local overturn record is not evidence of validity.
|
|
20
|
+
|
|
21
|
+
Strictly offline: no network primitives are imported here (gate-tested).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
|
|
27
|
+
from sud_cli import _wm
|
|
28
|
+
from sud_cli import __version__
|
|
29
|
+
|
|
30
|
+
HINT = _wm.render_line("status_hint", __version__)
|
|
31
|
+
|
|
32
|
+
# Overturn/modification roots; the negation guard keeps affirmations
|
|
33
|
+
# ("оставлено без изменения") out of the overturn verdict.
|
|
34
|
+
_OVERTURN_RE = re.compile(r"отмен|измен|направлен|новое решение", re.IGNORECASE)
|
|
35
|
+
_NEGATED_RE = re.compile(r"без\s+(?:изменени|отмен)|остав[ле]+но\s+без", re.IGNORECASE)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def has_overturn_marker(title: str) -> bool:
|
|
39
|
+
if not isinstance(title, str) or not title.strip():
|
|
40
|
+
return False
|
|
41
|
+
return bool(_OVERTURN_RE.search(title)) and not _NEGATED_RE.search(title)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _iter_strings(payload):
|
|
45
|
+
"""Flat scan of header-like strings from a snapshot/diff JSON payload."""
|
|
46
|
+
out = []
|
|
47
|
+
if not isinstance(payload, dict):
|
|
48
|
+
return out
|
|
49
|
+
for key in ("case_number", "status", "title"):
|
|
50
|
+
value = payload.get(key)
|
|
51
|
+
if isinstance(value, str) and value:
|
|
52
|
+
out.append(value)
|
|
53
|
+
last_event = payload.get("last_event")
|
|
54
|
+
if isinstance(last_event, dict):
|
|
55
|
+
for key in ("event_type", "name", "document_url"):
|
|
56
|
+
value = last_event.get(key)
|
|
57
|
+
if isinstance(value, str) and value:
|
|
58
|
+
out.append(value)
|
|
59
|
+
for event in payload.get("events_timeline") or []:
|
|
60
|
+
if isinstance(event, dict) and isinstance(event.get("name"), str):
|
|
61
|
+
out.append(event["name"])
|
|
62
|
+
for change in payload.get("changes") or []:
|
|
63
|
+
if isinstance(change, dict):
|
|
64
|
+
for key in ("before", "after"):
|
|
65
|
+
value = change.get(key)
|
|
66
|
+
if isinstance(value, str) and value:
|
|
67
|
+
out.append(value)
|
|
68
|
+
for change in payload.get("timeline_changes") or []:
|
|
69
|
+
if isinstance(change, dict) and isinstance(change.get("name"), str):
|
|
70
|
+
out.append(change["name"])
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _mentions_case(payload, case_number: str, path) -> bool:
|
|
75
|
+
numbers = [str(payload.get("case_number") or ""), path.stem]
|
|
76
|
+
return any(case_number in num or num in case_number for num in numbers if num)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def scan_store_dir(store_dir, case_number: str):
|
|
80
|
+
"""Evidence records {file, title} from local snapshot/diff artifacts."""
|
|
81
|
+
evidence = []
|
|
82
|
+
root = store_dir
|
|
83
|
+
if not root or not root.is_dir():
|
|
84
|
+
return evidence
|
|
85
|
+
for path in sorted(root.rglob("*.json")):
|
|
86
|
+
try:
|
|
87
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
88
|
+
except (OSError, json.JSONDecodeError):
|
|
89
|
+
continue
|
|
90
|
+
if isinstance(payload, dict) and isinstance(payload.get("snapshot"), dict):
|
|
91
|
+
payload = payload["snapshot"]
|
|
92
|
+
if not isinstance(payload, dict) or not _mentions_case(payload, case_number, path):
|
|
93
|
+
continue
|
|
94
|
+
for title in _iter_strings(payload):
|
|
95
|
+
if has_overturn_marker(title):
|
|
96
|
+
evidence.append({"file": str(path), "title": title})
|
|
97
|
+
return evidence
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def related_records(related):
|
|
101
|
+
"""Titles from the explicit --related fixture input (list or ';'-string)."""
|
|
102
|
+
if not related:
|
|
103
|
+
return []
|
|
104
|
+
if isinstance(related, str):
|
|
105
|
+
entries = [item.strip() for item in related.split(";")]
|
|
106
|
+
else:
|
|
107
|
+
entries = [str(item).strip() for item in related]
|
|
108
|
+
return [{"file": f"cli:--related#{i + 1}", "title": entry}
|
|
109
|
+
for i, entry in enumerate(entries) if entry]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def assess_status(case_number, *, related=None, store_dir=None):
|
|
113
|
+
"""Pure verdict builder. No network, no state-dir mutation.
|
|
114
|
+
|
|
115
|
+
verdict ∈ {possibly_overturned, unknown}; «действует» is not in the
|
|
116
|
+
vocabulary of this command by construction.
|
|
117
|
+
"""
|
|
118
|
+
if not isinstance(case_number, str) or not case_number.strip():
|
|
119
|
+
raise ValueError("case_number must be a non-empty string")
|
|
120
|
+
case_number = case_number.strip()
|
|
121
|
+
|
|
122
|
+
from pathlib import Path # local: keep module surface file-system-only
|
|
123
|
+
|
|
124
|
+
evidence = scan_store_dir(Path(store_dir) if store_dir else None, case_number)
|
|
125
|
+
evidence.extend(rec for rec in related_records(related) if has_overturn_marker(rec["title"]))
|
|
126
|
+
|
|
127
|
+
verdict = "possibly_overturned" if evidence else "unknown"
|
|
128
|
+
return {
|
|
129
|
+
"case_number": case_number,
|
|
130
|
+
"status": verdict,
|
|
131
|
+
"verdict": verdict,
|
|
132
|
+
"evidence": evidence,
|
|
133
|
+
"hint": HINT,
|
|
134
|
+
"coverage": "local snapshot/diff artifacts + --related fixture only; "
|
|
135
|
+
"no live sudact/case_history call",
|
|
136
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Official publication.pravo.gov.ru metadata adapter.
|
|
2
|
+
|
|
3
|
+
This module performs a single, bounded, read-only GET to the documented
|
|
4
|
+
``/api/Documents`` endpoint on ``publication.pravo.gov.ru``. It returns only
|
|
5
|
+
publication metadata and makes no claims about consolidated/current law text or
|
|
6
|
+
legal status.
|
|
7
|
+
|
|
8
|
+
Transport modes
|
|
9
|
+
---------------
|
|
10
|
+
``http`` (default)
|
|
11
|
+
Plain HTTP to ``publication.pravo.gov.ru``. This is the owner-approved
|
|
12
|
+
default for this host (decision of 2026-10-08): TLS to
|
|
13
|
+
publication.pravo.gov.ru is not reachable from the the deployment host, while plain
|
|
14
|
+
HTTP answers normally. Plaintext cannot be protected against a network-path
|
|
15
|
+
MITM, so results obtained this way are marked
|
|
16
|
+
``integrity: unverified_transport`` and must not be treated as
|
|
17
|
+
authoritative on their own.
|
|
18
|
+
|
|
19
|
+
``https``
|
|
20
|
+
Opt-in strict mode. Use it when a working TLS path exists: it is the only
|
|
21
|
+
mode that protects metadata integrity end to end.
|
|
22
|
+
|
|
23
|
+
The mode is chosen by the caller or by ``SUD_CLI_PRAVO_TRANSPORT``. An unknown
|
|
24
|
+
value raises rather than falling back to a default, so a typo cannot silently
|
|
25
|
+
select a channel.
|
|
26
|
+
|
|
27
|
+
There is still no silent HTTPS-to-HTTP fallback *within* a single invocation:
|
|
28
|
+
a transport failure in ``https`` mode raises instead of quietly degrading. The
|
|
29
|
+
owner-approved default is applied once, at resolution time, and is visible in
|
|
30
|
+
the result as ``transport_mode``.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import datetime
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import socket
|
|
37
|
+
import time
|
|
38
|
+
import urllib.error
|
|
39
|
+
import urllib.parse
|
|
40
|
+
import urllib.request
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
from typing import Any, Callable, Dict, Optional
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PublicationError(Exception):
|
|
46
|
+
"""Raised when a publication query cannot be completed safely."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
50
|
+
"""Fail closed on any HTTP redirect rather than following it.
|
|
51
|
+
|
|
52
|
+
urllib.request follows redirects automatically, which could make an
|
|
53
|
+
additional request to a different host before the final URL is validated.
|
|
54
|
+
This handler rejects every 3xx before a second request is issued.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
58
|
+
raise urllib.error.HTTPError(
|
|
59
|
+
req.full_url, code, "redirect rejected", headers, fp
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class TransportResponse:
|
|
65
|
+
"""Minimal HTTP response shape used by the adapter."""
|
|
66
|
+
|
|
67
|
+
status: int
|
|
68
|
+
headers: Dict[str, str]
|
|
69
|
+
body: bytes
|
|
70
|
+
url: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
Transport = Callable[[str, str, Dict[str, str], float], TransportResponse]
|
|
74
|
+
|
|
75
|
+
ALLOWED_HOST = "publication.pravo.gov.ru"
|
|
76
|
+
ALLOWED_PATH = "/api/Documents"
|
|
77
|
+
#: Retained for backwards compatibility: the strict TLS scheme. Callers should
|
|
78
|
+
#: use ``resolve_transport_mode()`` instead of assuming this is the active mode.
|
|
79
|
+
SCHEME = "https"
|
|
80
|
+
TIMEOUT_SECONDS = 5
|
|
81
|
+
MAX_RESPONSE_BYTES = 1 * 1024 * 1024
|
|
82
|
+
USER_AGENT = "sud-cli/0.1.0 (metadata query; read-only)"
|
|
83
|
+
MAX_QUERY_LENGTH = 200
|
|
84
|
+
DEFAULT_PAGE_SIZE = 10
|
|
85
|
+
DEFAULT_INDEX = 1
|
|
86
|
+
|
|
87
|
+
#: Transport mode switch. ``http`` is the owner-approved default for this host;
|
|
88
|
+
#: ``https`` is opt-in strict mode.
|
|
89
|
+
TRANSPORT_ENV_VAR = "SUD_CLI_PRAVO_TRANSPORT"
|
|
90
|
+
SUPPORTED_TRANSPORT_MODES = ("https", "http")
|
|
91
|
+
|
|
92
|
+
#: Owner-approved default (decision 2026-10-08): TLS is unreachable to
|
|
93
|
+
#: publication.pravo.gov.ru from the the deployment host, and plain HTTP is acceptable for
|
|
94
|
+
#: public legal-publication metadata.
|
|
95
|
+
DEFAULT_TRANSPORT = "http"
|
|
96
|
+
|
|
97
|
+
#: Integrity label attached to results, per transport mode.
|
|
98
|
+
_INTEGRITY_BY_MODE = {
|
|
99
|
+
"https": "tls_verified",
|
|
100
|
+
"http": "unverified_transport",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#: Retry policy for transient failures. The observed failure on the the deployment host
|
|
104
|
+
#: was intermittent DNS resolution (gaierror) plus TLS connect timeouts, so a
|
|
105
|
+
#: single attempt is not a reliable signal. Retries are bounded and total
|
|
106
|
+
#: wall-clock time stays small.
|
|
107
|
+
MAX_TRANSPORT_ATTEMPTS = 3
|
|
108
|
+
RETRY_BACKOFF_SECONDS = 0.4
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def resolve_transport_mode(override: Optional[str] = None) -> str:
|
|
112
|
+
"""Resolve the active transport mode.
|
|
113
|
+
|
|
114
|
+
Precedence: explicit argument, then ``SUD_CLI_PRAVO_TRANSPORT``, then the
|
|
115
|
+
owner-approved ``http`` default. An unknown value raises rather than
|
|
116
|
+
falling back, so a typo can neither silently downgrade nor silently
|
|
117
|
+
ignore the setting.
|
|
118
|
+
"""
|
|
119
|
+
mode = override
|
|
120
|
+
if mode is None:
|
|
121
|
+
mode = os.environ.get(TRANSPORT_ENV_VAR)
|
|
122
|
+
if mode is None:
|
|
123
|
+
return DEFAULT_TRANSPORT
|
|
124
|
+
mode = mode.strip().lower()
|
|
125
|
+
if mode not in SUPPORTED_TRANSPORT_MODES:
|
|
126
|
+
raise PublicationError(
|
|
127
|
+
f"unsupported transport mode {mode!r}; "
|
|
128
|
+
f"expected one of {SUPPORTED_TRANSPORT_MODES}"
|
|
129
|
+
)
|
|
130
|
+
return mode
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def source_url(mode: Optional[str] = None) -> str:
|
|
134
|
+
"""Canonical provenance string for the active transport mode."""
|
|
135
|
+
active = resolve_transport_mode(mode)
|
|
136
|
+
return f"{active}://{ALLOWED_HOST}{ALLOWED_PATH}"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _utc_now() -> str:
|
|
140
|
+
return (
|
|
141
|
+
datetime.datetime.now(tz=datetime.timezone.utc).replace(microsecond=0).isoformat()
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# Module-level opener with redirect handling disabled. Using a shared opener
|
|
146
|
+
# avoids the default urllib.request.urlopen behavior of silently following
|
|
147
|
+
# cross-host redirects and exposing query data to an intermediate request.
|
|
148
|
+
_opener = urllib.request.build_opener(NoRedirectHandler)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def default_transport(
|
|
152
|
+
method: str, url: str, headers: Dict[str, str], timeout: float
|
|
153
|
+
) -> TransportResponse:
|
|
154
|
+
"""Standard-library transport for the production path.
|
|
155
|
+
|
|
156
|
+
Retries bounded transient failures (DNS resolution errors, TLS connect
|
|
157
|
+
timeouts, connection resets) because the host intermittently fails to
|
|
158
|
+
resolve. Redirects are never followed, in either scheme: a redirect could
|
|
159
|
+
move the request to another host before the URL is validated.
|
|
160
|
+
|
|
161
|
+
Tests never call this function directly; they inject a fake transport.
|
|
162
|
+
"""
|
|
163
|
+
last_error: Optional[BaseException] = None
|
|
164
|
+
# v0.1.1: egress-guard + breaker (pravo.gov.ru — внешний официальный источник)
|
|
165
|
+
from sud_cli.sud_netguard import BreakerOpenError, check_before_fetch
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
check_before_fetch(url)
|
|
169
|
+
except BreakerOpenError as exc:
|
|
170
|
+
raise PublicationError(f"pravo host in cooldown (anti-ban breaker): {exc}") from exc
|
|
171
|
+
for attempt in range(MAX_TRANSPORT_ATTEMPTS):
|
|
172
|
+
req = urllib.request.Request(url, method=method, headers=headers)
|
|
173
|
+
try:
|
|
174
|
+
with _opener.open(req, timeout=timeout) as resp:
|
|
175
|
+
# Read one byte past the cap so we can fail-closed on oversize bodies.
|
|
176
|
+
body = resp.read(MAX_RESPONSE_BYTES + 1)
|
|
177
|
+
return TransportResponse(
|
|
178
|
+
status=resp.status,
|
|
179
|
+
headers=dict(resp.headers),
|
|
180
|
+
body=body,
|
|
181
|
+
url=resp.geturl(),
|
|
182
|
+
)
|
|
183
|
+
except urllib.error.HTTPError as exc:
|
|
184
|
+
if 300 <= exc.code < 400:
|
|
185
|
+
raise PublicationError(
|
|
186
|
+
f"redirect rejected (HTTP {exc.code})"
|
|
187
|
+
) from exc
|
|
188
|
+
raise
|
|
189
|
+
except (socket.gaierror, TimeoutError, ConnectionError, OSError) as exc:
|
|
190
|
+
last_error = exc
|
|
191
|
+
if attempt < MAX_TRANSPORT_ATTEMPTS - 1:
|
|
192
|
+
time.sleep(RETRY_BACKOFF_SECONDS * (attempt + 1))
|
|
193
|
+
continue
|
|
194
|
+
raise
|
|
195
|
+
# Unreachable in practice: the loop either returns or raises.
|
|
196
|
+
raise PublicationError(f"transport error: {last_error}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _build_url(query: Optional[str], number: Optional[str], scheme: str = DEFAULT_TRANSPORT) -> str:
|
|
200
|
+
params = [
|
|
201
|
+
("PageSize", str(DEFAULT_PAGE_SIZE)),
|
|
202
|
+
("Index", str(DEFAULT_INDEX)),
|
|
203
|
+
]
|
|
204
|
+
if query is not None:
|
|
205
|
+
params.append(("Name", query))
|
|
206
|
+
if number is not None:
|
|
207
|
+
params.append(("Number", number))
|
|
208
|
+
params.append(("NumberSearchType", "0"))
|
|
209
|
+
query_string = urllib.parse.urlencode(params)
|
|
210
|
+
return f"{scheme}://{ALLOWED_HOST}{ALLOWED_PATH}?{query_string}"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _validate_url(url: str, scheme: str = DEFAULT_TRANSPORT) -> None:
|
|
214
|
+
parsed = urllib.parse.urlparse(url)
|
|
215
|
+
if parsed.scheme != scheme:
|
|
216
|
+
raise PublicationError(
|
|
217
|
+
f"{scheme} required; got scheme {parsed.scheme!r}"
|
|
218
|
+
)
|
|
219
|
+
if parsed.netloc.lower() != ALLOWED_HOST.lower():
|
|
220
|
+
raise PublicationError(f"unexpected host {parsed.netloc!r}")
|
|
221
|
+
if parsed.path != ALLOWED_PATH:
|
|
222
|
+
raise PublicationError(f"unexpected path {parsed.path!r}")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _validate_response(response: TransportResponse, scheme: str = DEFAULT_TRANSPORT) -> Any:
|
|
226
|
+
_validate_url(response.url, scheme)
|
|
227
|
+
if response.status != 200:
|
|
228
|
+
raise PublicationError(f"HTTP {response.status}")
|
|
229
|
+
content_type = response.headers.get("Content-Type", "")
|
|
230
|
+
if "application/json" not in content_type:
|
|
231
|
+
raise PublicationError(f"unexpected content type {content_type!r}")
|
|
232
|
+
if len(response.body) > MAX_RESPONSE_BYTES:
|
|
233
|
+
raise PublicationError(
|
|
234
|
+
f"response body exceeds maximum size of {MAX_RESPONSE_BYTES} bytes"
|
|
235
|
+
)
|
|
236
|
+
try:
|
|
237
|
+
payload = json.loads(response.body.decode("utf-8"))
|
|
238
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
239
|
+
raise PublicationError(f"malformed json response: {exc}") from exc
|
|
240
|
+
if not isinstance(payload, dict):
|
|
241
|
+
raise PublicationError("response root must be a JSON object")
|
|
242
|
+
required = (
|
|
243
|
+
"items",
|
|
244
|
+
"itemsTotalCount",
|
|
245
|
+
"currentPage",
|
|
246
|
+
"itemsPerPage",
|
|
247
|
+
"pagesTotalCount",
|
|
248
|
+
)
|
|
249
|
+
for key in required:
|
|
250
|
+
if key not in payload:
|
|
251
|
+
raise PublicationError(f"response schema error: missing required field {key!r}")
|
|
252
|
+
if not isinstance(payload["items"], list):
|
|
253
|
+
raise PublicationError("'items' must be an array")
|
|
254
|
+
return payload
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _normalize_item(item: Any, scheme: str = DEFAULT_TRANSPORT) -> Dict[str, Any]:
|
|
258
|
+
if not isinstance(item, dict):
|
|
259
|
+
raise PublicationError("each item must be a JSON object")
|
|
260
|
+
eo_number = item.get("eoNumber")
|
|
261
|
+
title = item.get("title") or item.get("name")
|
|
262
|
+
number = item.get("number")
|
|
263
|
+
document_date = item.get("documentDate")
|
|
264
|
+
publish_date = item.get("publishDateShort")
|
|
265
|
+
return {
|
|
266
|
+
"eo_number": eo_number,
|
|
267
|
+
"title": title,
|
|
268
|
+
"document_number": number,
|
|
269
|
+
"signed_date": document_date,
|
|
270
|
+
"publication_date": publish_date,
|
|
271
|
+
"source": source_url(scheme),
|
|
272
|
+
"retrieved_at": _utc_now(),
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def query_publication_metadata(
|
|
277
|
+
query: Optional[str] = None,
|
|
278
|
+
number: Optional[str] = None,
|
|
279
|
+
transport: Optional[Transport] = None,
|
|
280
|
+
scheme: Optional[str] = None,
|
|
281
|
+
) -> Dict[str, Any]:
|
|
282
|
+
"""Query the official publication endpoint for metadata only.
|
|
283
|
+
|
|
284
|
+
Exactly one HTTP GET is performed per invocation (the transport may retry
|
|
285
|
+
a bounded number of times on transient network failures). The transport
|
|
286
|
+
callable is injectable so all automated tests remain offline.
|
|
287
|
+
|
|
288
|
+
``scheme`` selects the transport mode. When omitted it is resolved from
|
|
289
|
+
``SUD_CLI_PRAVO_TRANSPORT`` and defaults to :data:`DEFAULT_TRANSPORT`
|
|
290
|
+
(``http`` — plain HTTP is owner-approved for this host; see the module
|
|
291
|
+
docstring). Results fetched over ``http`` carry
|
|
292
|
+
``integrity: unverified_transport``.
|
|
293
|
+
|
|
294
|
+
Raises:
|
|
295
|
+
PublicationError: on invalid input, transport failure, non-200 HTTP,
|
|
296
|
+
cross-host redirect, content-type mismatch, oversize payload,
|
|
297
|
+
malformed JSON, or unexpected response schema.
|
|
298
|
+
"""
|
|
299
|
+
active_scheme = resolve_transport_mode(scheme)
|
|
300
|
+
if query is not None and number is not None:
|
|
301
|
+
raise PublicationError("provide either query or number, not both")
|
|
302
|
+
if query is None and number is None:
|
|
303
|
+
raise PublicationError("provide a text query or an exact document number")
|
|
304
|
+
active = query if query is not None else number
|
|
305
|
+
if active == "":
|
|
306
|
+
raise PublicationError("query/number must not be empty")
|
|
307
|
+
if len(active) > MAX_QUERY_LENGTH:
|
|
308
|
+
raise PublicationError(
|
|
309
|
+
f"search term exceeds maximum length of {MAX_QUERY_LENGTH}"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
transport = transport or default_transport
|
|
313
|
+
url = _build_url(query, number, active_scheme)
|
|
314
|
+
headers = {
|
|
315
|
+
"User-Agent": USER_AGENT,
|
|
316
|
+
"Accept": "application/json",
|
|
317
|
+
}
|
|
318
|
+
try:
|
|
319
|
+
response = transport("GET", url, headers, TIMEOUT_SECONDS)
|
|
320
|
+
except TimeoutError as exc:
|
|
321
|
+
raise PublicationError(f"request timeout after {TIMEOUT_SECONDS}s") from exc
|
|
322
|
+
except urllib.error.HTTPError as exc:
|
|
323
|
+
if 300 <= exc.code < 400:
|
|
324
|
+
raise PublicationError(
|
|
325
|
+
f"redirect rejected (HTTP {exc.code})"
|
|
326
|
+
) from exc
|
|
327
|
+
raise PublicationError(f"HTTP {exc.code}") from exc
|
|
328
|
+
except urllib.error.URLError as exc:
|
|
329
|
+
raise PublicationError(f"transport error: {exc.reason}") from exc
|
|
330
|
+
except PublicationError:
|
|
331
|
+
raise
|
|
332
|
+
except Exception as exc:
|
|
333
|
+
raise PublicationError(f"transport error: {exc}") from exc
|
|
334
|
+
|
|
335
|
+
payload = _validate_response(response, active_scheme)
|
|
336
|
+
items = [_normalize_item(item, active_scheme) for item in payload["items"]]
|
|
337
|
+
integrity = _INTEGRITY_BY_MODE[active_scheme]
|
|
338
|
+
result = {
|
|
339
|
+
"status": "ok",
|
|
340
|
+
"items": items,
|
|
341
|
+
"pagination": {
|
|
342
|
+
"page": payload["currentPage"],
|
|
343
|
+
"page_size": payload["itemsPerPage"],
|
|
344
|
+
"total": payload["itemsTotalCount"],
|
|
345
|
+
"pages_total": payload["pagesTotalCount"],
|
|
346
|
+
},
|
|
347
|
+
"source": source_url(active_scheme),
|
|
348
|
+
"transport_mode": active_scheme,
|
|
349
|
+
"integrity": integrity,
|
|
350
|
+
"provider_label": (
|
|
351
|
+
"official-publication metadata only "
|
|
352
|
+
"(not consolidated/current text or legal status)"
|
|
353
|
+
),
|
|
354
|
+
}
|
|
355
|
+
if integrity != "tls_verified":
|
|
356
|
+
# Never let plaintext results masquerade as authoritative metadata.
|
|
357
|
+
result["warning"] = (
|
|
358
|
+
"metadata fetched over plaintext HTTP: transport integrity is "
|
|
359
|
+
"unverified, verify independently before relying on it"
|
|
360
|
+
)
|
|
361
|
+
return result
|