decision-cascade 1.0.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.
- api/sdk/__init__.py +0 -0
- api/sdk/agent.py +145 -0
- api/sdk/client.py +127 -0
- api/server/__init__.py +0 -0
- api/server/deprecation.py +114 -0
- api/server/main.py +927 -0
- api/server/metrics.py +126 -0
- api/server/observability.py +178 -0
- api/server/service.py +569 -0
- api/server/systemone.py +161 -0
- decision_cascade-1.0.0.dist-info/METADATA +1253 -0
- decision_cascade-1.0.0.dist-info/RECORD +127 -0
- decision_cascade-1.0.0.dist-info/WHEEL +5 -0
- decision_cascade-1.0.0.dist-info/licenses/LICENSE +202 -0
- decision_cascade-1.0.0.dist-info/licenses/NOTICE +2 -0
- decision_cascade-1.0.0.dist-info/top_level.txt +5 -0
- eval/__init__.py +0 -0
- eval/benchmark/__init__.py +1 -0
- eval/benchmark/core.py +252 -0
- eval/benchmark/run.py +108 -0
- eval/calibration/__init__.py +0 -0
- eval/calibration/metrics.py +113 -0
- eval/calibration/run.py +39 -0
- eval/calibration/selective.py +92 -0
- eval/competitors/__init__.py +1 -0
- eval/competitors/evaluate.py +123 -0
- eval/competitors/fit_remote_accuracy.py +147 -0
- eval/competitors/mocks.py +86 -0
- eval/competitors/release.py +264 -0
- eval/competitors/run.py +360 -0
- eval/competitors/serve.py +286 -0
- eval/datasets/__init__.py +1 -0
- eval/datasets/image_dataset.py +72 -0
- eval/datasets/public_dataset.py +104 -0
- eval/datasets/routerbench.py +266 -0
- eval/datasets/run.py +43 -0
- eval/datasets/text_dataset.py +218 -0
- eval/drift/__init__.py +1 -0
- eval/drift/run.py +427 -0
- eval/latency/__init__.py +0 -0
- eval/latency/benchmark.py +107 -0
- eval/latency/run.py +169 -0
- eval/nightly.py +177 -0
- eval/option_transfer/__init__.py +0 -0
- eval/option_transfer/core.py +267 -0
- eval/option_transfer/run.py +98 -0
- eval/report/__init__.py +1 -0
- eval/report/run.py +263 -0
- eval/risk/__init__.py +0 -0
- eval/risk/core.py +409 -0
- eval/risk/features.py +1341 -0
- eval/risk/run.py +264 -0
- eval/risk/simulate.py +548 -0
- examples/__init__.py +8 -0
- examples/_common.py +233 -0
- examples/batch/csv_triage.py +79 -0
- examples/batch/inprocess_batch.py +50 -0
- examples/batch/jsonl_batch.py +85 -0
- examples/cascade/client_fallback.py +68 -0
- examples/cascade/hash_to_llm.py +96 -0
- examples/cascade/risk_aware.py +174 -0
- examples/integrations/chat_agent.py +66 -0
- examples/integrations/discord_bot.py +81 -0
- examples/integrations/email_triage.py +65 -0
- examples/integrations/moderation.py +74 -0
- examples/integrations/openai_tools.py +82 -0
- examples/integrations/sentiment_router.py +61 -0
- examples/integrations/slack_app.py +71 -0
- examples/integrations/support_copilot.py +65 -0
- examples/integrations/telegram_bot.py +92 -0
- examples/integrations/webhook_fastapi.py +65 -0
- examples/ops/device_select.py +49 -0
- examples/ops/local_heads.py +45 -0
- examples/ops/option_transfer.py +112 -0
- examples/ops/weight_pack.py +58 -0
- examples/protocol/systemone_client.py +107 -0
- examples/quickstart/hello.py +42 -0
- examples/quickstart/http_client.py +91 -0
- examples/vision/image_folder.py +81 -0
- examples/vision/image_single.py +74 -0
- examples/vision/make_sample_images.py +37 -0
- examples/vision/video_frames.py +72 -0
- examples/vision/vision_offline.py +47 -0
- src/__init__.py +7 -0
- src/backends/__init__.py +23 -0
- src/backends/base.py +148 -0
- src/backends/benefit.py +259 -0
- src/backends/cascade.py +518 -0
- src/backends/http.py +223 -0
- src/backends/local.py +88 -0
- src/backends/protocol.py +100 -0
- src/decision_head/__init__.py +0 -0
- src/decision_head/heads.py +331 -0
- src/device.py +72 -0
- src/encoder/__init__.py +0 -0
- src/encoder/base.py +43 -0
- src/encoder/caching.py +30 -0
- src/encoder/factory.py +57 -0
- src/encoder/gemma.py +156 -0
- src/encoder/pooling.py +36 -0
- src/encoder/transformer.py +175 -0
- src/encoder/vision.py +237 -0
- src/errors.py +49 -0
- src/risk.py +373 -0
- src/sampler/__init__.py +0 -0
- src/sampler/parallel.py +14 -0
- src/sampler/questions.py +127 -0
- src/schemas.py +72 -0
- src/versioning.py +70 -0
- training/__init__.py +0 -0
- training/feedback/__init__.py +1 -0
- training/feedback/run.py +529 -0
- training/finetune/__init__.py +0 -0
- training/finetune/train.py +227 -0
- training/labeling/__init__.py +1 -0
- training/labeling/run.py +281 -0
- training/recipe/__init__.py +0 -0
- training/recipe/calibrate_rlcd.py +177 -0
- training/recipe/calibrate_temperature.py +281 -0
- training/recipe/finetune_encoder.py +214 -0
- training/recipe/risk.py +125 -0
- training/recipe/train_heads.py +259 -0
- training/rlcd/__init__.py +0 -0
- training/rlcd/reward.py +16 -0
- training/rlcd/train.py +118 -0
- training/sft/__init__.py +0 -0
- training/sft/train.py +108 -0
api/sdk/__init__.py
ADDED
|
File without changes
|
api/sdk/agent.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Маршрутизация обращения по решениям decision-cascade.
|
|
2
|
+
|
|
3
|
+
Показывает типичную интеграцию «мозга решений» в чат-агента: по типизированным
|
|
4
|
+
решениям (noul/choice/score) выбирается команда, приоритет, эскалация и тон
|
|
5
|
+
ответа. Логика чистая (без сети) и легко тестируется отдельно от SDK.
|
|
6
|
+
|
|
7
|
+
Каскад: ``route_ticket`` решает, нужна ли эскалация; вызывающий код (см.
|
|
8
|
+
``examples/integrations/chat_agent.py``) может на этом основании переспросить внешний сервис.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
PRIORITIES = ("low", "normal", "high", "urgent")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class RoutingPolicy:
|
|
20
|
+
"""Пороги, по которым решения превращаются в действие агента."""
|
|
21
|
+
|
|
22
|
+
escalate_threshold: float = 0.5
|
|
23
|
+
urgent_threshold: float = 0.6
|
|
24
|
+
high_threshold: float = 0.3
|
|
25
|
+
churn_threshold: float = 3.0
|
|
26
|
+
heat_urgent: float = 3.5
|
|
27
|
+
heat_high: float = 2.0
|
|
28
|
+
default_team: str = "общая поддержка"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class AgentAction:
|
|
33
|
+
team: str
|
|
34
|
+
priority: str
|
|
35
|
+
escalate: bool
|
|
36
|
+
tone: str
|
|
37
|
+
reply: str
|
|
38
|
+
rationale: tuple[str, ...] = ()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _noul(decisions: dict[str, Any], key: str) -> float | None:
|
|
42
|
+
d = decisions.get(key)
|
|
43
|
+
if isinstance(d, dict) and d.get("type") == "noul":
|
|
44
|
+
return float(d["noul"])
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _choice(decisions: dict[str, Any], key: str) -> str | None:
|
|
49
|
+
d = decisions.get(key)
|
|
50
|
+
if isinstance(d, dict) and d.get("type") == "choice" and "choice" in d:
|
|
51
|
+
return str(d["choice"])
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _score(decisions: dict[str, Any], key: str) -> float | None:
|
|
56
|
+
d = decisions.get(key)
|
|
57
|
+
if isinstance(d, dict) and d.get("type") == "score":
|
|
58
|
+
return float(d["score"])
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _priority(
|
|
63
|
+
urgency: float,
|
|
64
|
+
heat: float,
|
|
65
|
+
churn: float,
|
|
66
|
+
policy: RoutingPolicy,
|
|
67
|
+
rationale: list[str],
|
|
68
|
+
) -> str:
|
|
69
|
+
if urgency >= policy.urgent_threshold or heat >= policy.heat_urgent:
|
|
70
|
+
rationale.append(
|
|
71
|
+
f"срочность={urgency:.2f}, накал={heat:.2f} -> urgent"
|
|
72
|
+
)
|
|
73
|
+
return "urgent"
|
|
74
|
+
if urgency >= policy.high_threshold or heat >= policy.heat_high or churn >= policy.churn_threshold:
|
|
75
|
+
rationale.append(
|
|
76
|
+
f"срочность={urgency:.2f}, накал={heat:.2f}, отток={churn:.2f} -> high"
|
|
77
|
+
)
|
|
78
|
+
return "high"
|
|
79
|
+
if urgency < 0.2 and heat < 1.0:
|
|
80
|
+
rationale.append("низкая срочность и низкий накал -> low")
|
|
81
|
+
return "low"
|
|
82
|
+
return "normal"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _tone(heat: float, churn: float) -> str:
|
|
86
|
+
if heat >= 3.0:
|
|
87
|
+
return "извиняющийся"
|
|
88
|
+
if heat >= 1.5 or churn >= 3.0:
|
|
89
|
+
return "эмпатичный"
|
|
90
|
+
return "нейтральный"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _reply(team: str, priority: str, tone: str, escalate: bool) -> str:
|
|
94
|
+
if tone == "извиняющийся":
|
|
95
|
+
opening = "Приносим извинения за доставленные неудобства."
|
|
96
|
+
elif tone == "эмпатичный":
|
|
97
|
+
opening = "Понимаю, как это неприятно."
|
|
98
|
+
else:
|
|
99
|
+
opening = "Спасибо за обращение."
|
|
100
|
+
if escalate:
|
|
101
|
+
return (
|
|
102
|
+
f"{opening} Передаю обращение старшему инженеру "
|
|
103
|
+
f"(команда «{team}», приоритет: {priority})."
|
|
104
|
+
)
|
|
105
|
+
return f"{opening} Обращение принято в команду «{team}» (приоритет: {priority})."
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def route_ticket(
|
|
109
|
+
decisions: dict[str, Any],
|
|
110
|
+
policy: RoutingPolicy | None = None,
|
|
111
|
+
) -> AgentAction:
|
|
112
|
+
"""Превращает решения по обращению в действие чат-агента.
|
|
113
|
+
|
|
114
|
+
Ожидаемые вопросы (любой можно опустить): ``срочность``/``эскалация`` (noul),
|
|
115
|
+
``команда`` (choice), ``накал``/``удержание`` (score).
|
|
116
|
+
"""
|
|
117
|
+
policy = policy or RoutingPolicy()
|
|
118
|
+
rationale: list[str] = []
|
|
119
|
+
|
|
120
|
+
team = _choice(decisions, "команда") or policy.default_team
|
|
121
|
+
if team != policy.default_team:
|
|
122
|
+
rationale.append(f"команда из решения: {team!r}")
|
|
123
|
+
|
|
124
|
+
urgency = _noul(decisions, "срочность") or 0.0
|
|
125
|
+
escalation = _noul(decisions, "эскалация") or 0.0
|
|
126
|
+
heat = _score(decisions, "накал") or 0.0
|
|
127
|
+
churn = _score(decisions, "удержание") or 0.0
|
|
128
|
+
|
|
129
|
+
priority = _priority(urgency, heat, churn, policy, rationale)
|
|
130
|
+
escalate = escalation >= policy.escalate_threshold or priority == "urgent"
|
|
131
|
+
if escalate:
|
|
132
|
+
rationale.append(
|
|
133
|
+
f"эскалация={escalation:.2f} (порог {policy.escalate_threshold:.2f})"
|
|
134
|
+
)
|
|
135
|
+
tone = _tone(heat, churn)
|
|
136
|
+
rationale.append(f"тон ответа: {tone}")
|
|
137
|
+
|
|
138
|
+
return AgentAction(
|
|
139
|
+
team=team,
|
|
140
|
+
priority=priority,
|
|
141
|
+
escalate=escalate,
|
|
142
|
+
tone=tone,
|
|
143
|
+
reply=_reply(team, priority, tone, escalate),
|
|
144
|
+
rationale=tuple(rationale),
|
|
145
|
+
)
|
api/sdk/client.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Python SDK для decision-cascade API: типизированный клиент с валидацией."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Annotated, Any, Literal, Self
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from src.schemas import SCHEMA_VERSION
|
|
10
|
+
from src.versioning import is_compatible
|
|
11
|
+
|
|
12
|
+
QuestionPayload = dict[str, Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _NoulOut(BaseModel):
|
|
16
|
+
type: Literal["noul"] = "noul"
|
|
17
|
+
noul: float = Field(..., ge=0.0, le=1.0)
|
|
18
|
+
confidence: float = Field(..., ge=0.0, le=1.0)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _ChoiceOut(BaseModel):
|
|
22
|
+
type: Literal["choice"] = "choice"
|
|
23
|
+
choice: str
|
|
24
|
+
probabilities: dict[str, float]
|
|
25
|
+
confidence: float = Field(..., ge=0.0, le=1.0)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _ScoreOut(BaseModel):
|
|
29
|
+
type: Literal["score"] = "score"
|
|
30
|
+
score: float
|
|
31
|
+
probabilities: dict[str, float]
|
|
32
|
+
confidence: float = Field(..., ge=0.0, le=1.0)
|
|
33
|
+
legend: list[str]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_Out = Annotated[
|
|
37
|
+
_NoulOut | _ChoiceOut | _ScoreOut, Field(discriminator="type")
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _Response(BaseModel):
|
|
42
|
+
schema_version: str
|
|
43
|
+
decisions: dict[str, _Out]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _FeedbackQuestion(BaseModel):
|
|
47
|
+
question: str
|
|
48
|
+
accepted: int = Field(..., ge=0)
|
|
49
|
+
examples: int = Field(..., ge=0)
|
|
50
|
+
reference: int = Field(..., ge=0)
|
|
51
|
+
retrained: bool
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _FeedbackResponse(BaseModel):
|
|
55
|
+
accepted: int = Field(..., ge=0)
|
|
56
|
+
questions: list[_FeedbackQuestion]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def validate_response(payload: dict[str, Any]) -> list[str] | None:
|
|
60
|
+
"""Проверяет ответ API на соответствие типизированному контракту.
|
|
61
|
+
|
|
62
|
+
Версия схемы должна быть совместима по major (аддитивные minor-изменения не
|
|
63
|
+
ломают клиент), см. ``src/versioning.py``.
|
|
64
|
+
"""
|
|
65
|
+
schema_version = payload.get("schema_version")
|
|
66
|
+
if not isinstance(schema_version, str) or not is_compatible(
|
|
67
|
+
SCHEMA_VERSION, schema_version
|
|
68
|
+
):
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"schema_version mismatch: expected major of {SCHEMA_VERSION}, "
|
|
71
|
+
f"got {schema_version!r}"
|
|
72
|
+
)
|
|
73
|
+
_Response.model_validate(payload)
|
|
74
|
+
for name, res in payload["decisions"].items():
|
|
75
|
+
probs = res.get("probabilities")
|
|
76
|
+
if probs is not None:
|
|
77
|
+
if abs(sum(probs.values()) - 1.0) > 1e-6:
|
|
78
|
+
raise ValueError(f"decision {name!r}: probabilities must sum to 1.0")
|
|
79
|
+
if any(p < 0.0 for p in probs.values()):
|
|
80
|
+
raise ValueError(f"decision {name!r}: probabilities must be non-negative")
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class DecisionClient:
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
base_url: str = "http://localhost:8000",
|
|
88
|
+
timeout: float = 60.0,
|
|
89
|
+
transport: httpx.BaseTransport | None = None,
|
|
90
|
+
):
|
|
91
|
+
self._client = httpx.Client(base_url=base_url, timeout=timeout, transport=transport)
|
|
92
|
+
|
|
93
|
+
def decisions(
|
|
94
|
+
self, state: str, questions: dict[str, QuestionPayload]
|
|
95
|
+
) -> dict[str, Any]:
|
|
96
|
+
payload = {"state": state, "questions": questions}
|
|
97
|
+
resp = self._client.post("/decisions", json=payload)
|
|
98
|
+
resp.raise_for_status()
|
|
99
|
+
body = resp.json()
|
|
100
|
+
validate_response(body)
|
|
101
|
+
return body
|
|
102
|
+
|
|
103
|
+
def feedback(self, examples: list[dict[str, Any]]) -> dict[str, Any]:
|
|
104
|
+
"""Прислать размеченные примеры решений (few-shot риск-модели).
|
|
105
|
+
|
|
106
|
+
``examples`` — список вида
|
|
107
|
+
``{"question": ..., "decision": <решение из decisions()>, "correct": bool}``:
|
|
108
|
+
повторяется именно решение, которое сервер уже отдавал, состояние кодировать
|
|
109
|
+
заново не нужно. В ответе — сколько примеров накоплено по вопросу и не
|
|
110
|
+
достигнут ли порог `DECISION_RISK_FEW_SHOT_N` (тогда риск-модель вопроса
|
|
111
|
+
переобучена). Если бэкенд обратную связь не принимает, сервер отвечает
|
|
112
|
+
``409 FEEDBACK_UNSUPPORTED`` — это ``httpx.HTTPStatusError``.
|
|
113
|
+
"""
|
|
114
|
+
resp = self._client.post("/v1/feedback", json={"examples": examples})
|
|
115
|
+
resp.raise_for_status()
|
|
116
|
+
body = resp.json()
|
|
117
|
+
_FeedbackResponse.model_validate(body)
|
|
118
|
+
return body
|
|
119
|
+
|
|
120
|
+
def close(self) -> None:
|
|
121
|
+
self._client.close()
|
|
122
|
+
|
|
123
|
+
def __enter__(self) -> Self:
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
def __exit__(self, *exc: object) -> None:
|
|
127
|
+
self.close()
|
api/server/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Политика депрекации: реестр устаревших поверхностей и заголовки ответа.
|
|
2
|
+
|
|
3
|
+
Правило (``docs/stability.md`` §5): поверхность объявляется устаревшей минимум
|
|
4
|
+
за две минорные версии до удаления. Всё это время она работает, отдаёт
|
|
5
|
+
заголовки ``Deprecation``/``Sunset``/``Link`` и один раз пишет предупреждение в
|
|
6
|
+
лог. Удаление — только в major-релизе.
|
|
7
|
+
|
|
8
|
+
Реестр намеренно пуст: пока в проекте нет устаревших поверхностей. Механизм
|
|
9
|
+
здесь, чтобы первая же депрекация была объявлена одинаково и проверялась
|
|
10
|
+
тестом, а не «на словах».
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("decision.deprecation")
|
|
18
|
+
|
|
19
|
+
#: Сколько минорных релизов поверхность обязана прожить после объявления.
|
|
20
|
+
MIN_MINOR_RELEASES = 2
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_minor(version: str) -> tuple[int, int]:
|
|
24
|
+
"""Разбирает ``X.Y[.Z]`` в ``(major, minor)``; иначе ``ValueError``."""
|
|
25
|
+
parts = version.strip().split(".")
|
|
26
|
+
if len(parts) < 2:
|
|
27
|
+
raise ValueError(f"expected MAJOR.MINOR[.PATCH], got {version!r}")
|
|
28
|
+
try:
|
|
29
|
+
return int(parts[0]), int(parts[1])
|
|
30
|
+
except ValueError as exc:
|
|
31
|
+
raise ValueError(f"expected numeric MAJOR.MINOR, got {version!r}") from exc
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def minor_distance(since: str, remove_in: str) -> int:
|
|
35
|
+
"""Сколько минорных версий между объявлением и удалением."""
|
|
36
|
+
since_major, since_minor = parse_minor(since)
|
|
37
|
+
remove_major, remove_minor = parse_minor(remove_in)
|
|
38
|
+
return (remove_major - since_major) * 1000 + (remove_minor - since_minor)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Deprecation:
|
|
43
|
+
"""Одна устаревшая поверхность: путь, поле ответа или переменная окружения."""
|
|
44
|
+
|
|
45
|
+
#: что именно устарело — путь (``/decisions``) или имя (``DECISION_FOO``)
|
|
46
|
+
item: str
|
|
47
|
+
#: версия, в которой объявлена депрекация
|
|
48
|
+
since: str
|
|
49
|
+
#: версия, в которой поверхность исчезнет (не раньше since + 2 минорные)
|
|
50
|
+
remove_in: str
|
|
51
|
+
#: чем заменить; попадает в ``Link`` и в предупреждение
|
|
52
|
+
alternative: str | None = None
|
|
53
|
+
#: дата отключения в формате RFC 8594 (``Sunset``), необязательна
|
|
54
|
+
sunset: str | None = None
|
|
55
|
+
|
|
56
|
+
def headers(self) -> dict[str, str]:
|
|
57
|
+
"""Заголовки ответа по RFC 8594 и RFC 9745."""
|
|
58
|
+
headers = {"Deprecation": "true"}
|
|
59
|
+
if self.sunset:
|
|
60
|
+
headers["Sunset"] = self.sunset
|
|
61
|
+
if self.alternative:
|
|
62
|
+
headers["Link"] = f'<{self.alternative}>; rel="deprecation"'
|
|
63
|
+
return headers
|
|
64
|
+
|
|
65
|
+
def __post_init__(self) -> None:
|
|
66
|
+
distance = minor_distance(self.since, self.remove_in)
|
|
67
|
+
if distance < MIN_MINOR_RELEASES:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"deprecation {self.item!r}: remove_in={self.remove_in} is only "
|
|
70
|
+
f"{distance} minor release(s) after since={self.since}; "
|
|
71
|
+
f"minimum is {MIN_MINOR_RELEASES}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
#: Реестр устаревших поверхностей: ключ — ``Deprecation.item``.
|
|
76
|
+
DEPRECATIONS: dict[str, Deprecation] = {}
|
|
77
|
+
|
|
78
|
+
#: Уже предупреждённые элементы: в лог пишем один раз за жизнь процесса.
|
|
79
|
+
_warned: set[str] = set()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def deprecation_for(item: str) -> Deprecation | None:
|
|
83
|
+
"""Возвращает депрекацию для поверхности или ``None``."""
|
|
84
|
+
return DEPRECATIONS.get(item)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def warn_once(item: str) -> Deprecation | None:
|
|
88
|
+
"""Пишет предупреждение о депрекации один раз и возвращает её запись."""
|
|
89
|
+
deprecation = DEPRECATIONS.get(item)
|
|
90
|
+
if deprecation is None or item in _warned:
|
|
91
|
+
return deprecation
|
|
92
|
+
_warned.add(item)
|
|
93
|
+
logger.warning(
|
|
94
|
+
"deprecated %s since %s, removal in %s: %s",
|
|
95
|
+
deprecation.item,
|
|
96
|
+
deprecation.since,
|
|
97
|
+
deprecation.remove_in,
|
|
98
|
+
deprecation.alternative or "no replacement announced",
|
|
99
|
+
)
|
|
100
|
+
return deprecation
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def reset_warnings() -> None:
|
|
104
|
+
"""Сбрасывает память предупреждений (для тестов и перезапуска)."""
|
|
105
|
+
_warned.clear()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def validate_registry() -> None:
|
|
109
|
+
"""Проверяет, что реестр не нарушает правило двух минорных версий."""
|
|
110
|
+
for item, deprecation in DEPRECATIONS.items():
|
|
111
|
+
if item != deprecation.item:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"registry key {item!r} does not match item {deprecation.item!r}"
|
|
114
|
+
)
|