s-corekit 0.0.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.
- corekit/__init__.py +168 -0
- corekit/context.py +268 -0
- corekit/diagnosis/__init__.py +103 -0
- corekit/diagnosis/access.py +440 -0
- corekit/diagnosis/egress.py +442 -0
- corekit/diagnosis/verdict.py +226 -0
- corekit/dto.py +83 -0
- corekit/enums.py +77 -0
- corekit/errors.py +410 -0
- corekit/limits.py +99 -0
- corekit/redaction.py +206 -0
- s_corekit-0.0.1.dist-info/METADATA +127 -0
- s_corekit-0.0.1.dist-info/RECORD +15 -0
- s_corekit-0.0.1.dist-info/WHEEL +4 -0
- s_corekit-0.0.1.dist-info/licenses/LICENSE +21 -0
corekit/__init__.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""corekit — ОСНОВАНИЕ экосистемы китов. Ноль зависимостей, дешёвый импорт.
|
|
2
|
+
|
|
3
|
+
ФОРМА ГРАФА::
|
|
4
|
+
|
|
5
|
+
clikit adapterkit <- ветки-оболочки
|
|
6
|
+
\\ /
|
|
7
|
+
\\ /
|
|
8
|
+
librarykit <- ОРКЕСТРАТОР (сеть, сессии, браузер)
|
|
9
|
+
|
|
|
10
|
+
corekit <- ОСНОВАНИЕ (значения и правила)
|
|
11
|
+
|
|
12
|
+
Что попало в основание и по какому признаку. Ровно одно: **сущность не тянет
|
|
13
|
+
реализацию**. Здесь живут ЗНАЧЕНИЯ и ЧИСТЫЕ ПРАВИЛА — то, что нужно и тому, кто
|
|
14
|
+
поднимает браузер, и тому, кто просто читает конфиг:
|
|
15
|
+
|
|
16
|
+
* :mod:`corekit.context` — `ExecutionContext`: чей вызов, где его состояние,
|
|
17
|
+
куда он ходит в сеть. ОДИН на экосистему, с синонимами имён обеих школ
|
|
18
|
+
(``tenant``/``tenant_id``, ``profile``/``profile_name``);
|
|
19
|
+
* :mod:`corekit.dto` — `SessionRef` / `Creds` / `RequestBody`;
|
|
20
|
+
* :mod:`corekit.limits` — `QuotaScope` / `QuotaInfo` / `LimitSpec`;
|
|
21
|
+
* :mod:`corekit.enums` — `PaginationMode` / `AuthMode` / `TransportKind`;
|
|
22
|
+
* :mod:`corekit.errors` — ЕДИНАЯ иерархия исключений (`CliError` и потомки);
|
|
23
|
+
* :mod:`corekit.redaction` — маскировка секретов в тексте/заголовках/теле;
|
|
24
|
+
* :mod:`corekit.diagnosis` — таксономия причин (`Reason`) и состояние доступа
|
|
25
|
+
(`AccessState`/`AccessVerdict`) — классификация по УЛИКЕ, без сети.
|
|
26
|
+
|
|
27
|
+
ЧЕГО ЗДЕСЬ НЕТ И НЕ БУДЕТ: httpx, pydantic, cryptography, keyring, playwright —
|
|
28
|
+
никаких зависимостей вообще (``dependencies = []`` в pyproject, проверено
|
|
29
|
+
тестом). Это не аскеза, а условие: основание ставится туда, где чужие колёса
|
|
30
|
+
поставить нельзя.
|
|
31
|
+
|
|
32
|
+
ЦЕНА ИМПОРТА (замер, а не обещание; Python 3.14, Windows, холодный
|
|
33
|
+
интерпретатор, 3 прогона). Пол stdlib, который основание тянет законно
|
|
34
|
+
(``dataclasses``/``pathlib``/``enum``/``re``/``json``/``datetime``) — 77-85 мс на
|
|
35
|
+
этой машине. СОБСТВЕННАЯ цена ``import corekit`` поверх этого пола — 24-30 мс;
|
|
36
|
+
для сравнения ``import librarykit`` поверх того же пола — 151-162 мс. Полный
|
|
37
|
+
импорт: corekit ~105 мс против librarykit ~246 мс. Порог зафиксирован тестом
|
|
38
|
+
``tests/test_stdlib_only.py`` и меряется ИМЕННО над полом — абсолютное число
|
|
39
|
+
здесь описывает машину, а не код.
|
|
40
|
+
|
|
41
|
+
СОВМЕСТИМОСТЬ. Ни одно имя отсюда не «переехало» для потребителя:
|
|
42
|
+
`librarykit.contract`, `librarykit.errors`, `librarykit.redaction` и
|
|
43
|
+
`librarykit.diagnosis.*` реэкспортируют ТЕ ЖЕ объекты (не копии), поэтому
|
|
44
|
+
``isinstance``/``except``/``is``-сравнения работают через любой из путей.
|
|
45
|
+
"""
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
from corekit.context import DEFAULT_TENANT, ExecutionContext
|
|
49
|
+
from corekit.diagnosis import (
|
|
50
|
+
ACCESS_STATE_MESSAGES,
|
|
51
|
+
REASON_MESSAGES,
|
|
52
|
+
REASON_ORDER,
|
|
53
|
+
REASON_RANK,
|
|
54
|
+
AccessState,
|
|
55
|
+
AccessVerdict,
|
|
56
|
+
Reason,
|
|
57
|
+
Verdict,
|
|
58
|
+
classify_access,
|
|
59
|
+
classify_failure,
|
|
60
|
+
describe_egress,
|
|
61
|
+
is_egress_failure,
|
|
62
|
+
is_launch_failure,
|
|
63
|
+
resolve,
|
|
64
|
+
state_from_reason,
|
|
65
|
+
stronger,
|
|
66
|
+
verdict_from_error,
|
|
67
|
+
)
|
|
68
|
+
from corekit.dto import Creds, RequestBody, SessionRef
|
|
69
|
+
from corekit.enums import AuthMode, PaginationMode, RequestExecutionMode, TransportKind
|
|
70
|
+
from corekit.errors import (
|
|
71
|
+
RETRYABLE,
|
|
72
|
+
AdapterError,
|
|
73
|
+
ApiError,
|
|
74
|
+
AuthRequired,
|
|
75
|
+
Blocked,
|
|
76
|
+
CliError,
|
|
77
|
+
GraphQLError,
|
|
78
|
+
NotFound,
|
|
79
|
+
NotFoundError,
|
|
80
|
+
NotImplementedYet,
|
|
81
|
+
ProcessError,
|
|
82
|
+
ProfileBroken,
|
|
83
|
+
RateLimited,
|
|
84
|
+
ServerError,
|
|
85
|
+
SessionEgressMismatch,
|
|
86
|
+
SessionExpired,
|
|
87
|
+
TransportError,
|
|
88
|
+
ValidationError,
|
|
89
|
+
)
|
|
90
|
+
from corekit.limits import LimitSpec, QuotaInfo, QuotaScope
|
|
91
|
+
from corekit.redaction import (
|
|
92
|
+
BODY_FIELD_PATTERNS,
|
|
93
|
+
HEADER_BLACKLIST,
|
|
94
|
+
SECRET_MASK,
|
|
95
|
+
excerpt_response_body,
|
|
96
|
+
sanitize_body,
|
|
97
|
+
sanitize_headers,
|
|
98
|
+
scrub_credentials,
|
|
99
|
+
truncate_body,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
__version__ = "0.0.1"
|
|
103
|
+
|
|
104
|
+
__all__ = [
|
|
105
|
+
"__version__",
|
|
106
|
+
# контекст исполнения
|
|
107
|
+
"ExecutionContext",
|
|
108
|
+
"DEFAULT_TENANT",
|
|
109
|
+
# базовые DTO
|
|
110
|
+
"SessionRef",
|
|
111
|
+
"Creds",
|
|
112
|
+
"RequestBody",
|
|
113
|
+
# квоты / лимиты
|
|
114
|
+
"QuotaScope",
|
|
115
|
+
"QuotaInfo",
|
|
116
|
+
"LimitSpec",
|
|
117
|
+
# перечисления «талии»
|
|
118
|
+
"PaginationMode",
|
|
119
|
+
"AuthMode",
|
|
120
|
+
"TransportKind",
|
|
121
|
+
"RequestExecutionMode",
|
|
122
|
+
# единая иерархия ошибок
|
|
123
|
+
"CliError",
|
|
124
|
+
"ApiError",
|
|
125
|
+
"AuthRequired",
|
|
126
|
+
"SessionExpired",
|
|
127
|
+
"SessionEgressMismatch",
|
|
128
|
+
"ProfileBroken",
|
|
129
|
+
"RateLimited",
|
|
130
|
+
"ValidationError",
|
|
131
|
+
"NotImplementedYet",
|
|
132
|
+
"ProcessError",
|
|
133
|
+
"AdapterError",
|
|
134
|
+
"TransportError",
|
|
135
|
+
"NotFound",
|
|
136
|
+
"NotFoundError",
|
|
137
|
+
"Blocked",
|
|
138
|
+
"ServerError",
|
|
139
|
+
"GraphQLError",
|
|
140
|
+
"RETRYABLE",
|
|
141
|
+
# диагностика: причина захода + состояние доступа
|
|
142
|
+
"Reason",
|
|
143
|
+
"Verdict",
|
|
144
|
+
"REASON_ORDER",
|
|
145
|
+
"REASON_RANK",
|
|
146
|
+
"REASON_MESSAGES",
|
|
147
|
+
"resolve",
|
|
148
|
+
"stronger",
|
|
149
|
+
"AccessState",
|
|
150
|
+
"AccessVerdict",
|
|
151
|
+
"ACCESS_STATE_MESSAGES",
|
|
152
|
+
"classify_access",
|
|
153
|
+
"state_from_reason",
|
|
154
|
+
"classify_failure",
|
|
155
|
+
"describe_egress",
|
|
156
|
+
"is_egress_failure",
|
|
157
|
+
"is_launch_failure",
|
|
158
|
+
"verdict_from_error",
|
|
159
|
+
# редакция секретов
|
|
160
|
+
"SECRET_MASK",
|
|
161
|
+
"HEADER_BLACKLIST",
|
|
162
|
+
"BODY_FIELD_PATTERNS",
|
|
163
|
+
"sanitize_headers",
|
|
164
|
+
"sanitize_body",
|
|
165
|
+
"truncate_body",
|
|
166
|
+
"excerpt_response_body",
|
|
167
|
+
"scrub_credentials",
|
|
168
|
+
]
|
corekit/context.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""ExecutionContext — ЕДИНЫЙ контекст одного вызова (ОСНОВАНИЕ экосистемы).
|
|
2
|
+
|
|
3
|
+
Здесь живёт КАНОН контекста всей экосистемы китов. Модуль намеренно НИЧЕГО не
|
|
4
|
+
знает про носитель (`contextvars`), про
|
|
5
|
+
HTTP-клиент и про пути: он даёт ЗНАЧЕНИЕ. Носитель и резолверы ресурсов остаются
|
|
6
|
+
слоем выше (`librarykit.context`) — иначе основание потянуло бы за собой сеть и
|
|
7
|
+
файловую систему.
|
|
8
|
+
|
|
9
|
+
ТОЛЬКО stdlib: ``dataclasses`` + ``pathlib`` + ``typing``. Ни httpx, ни pydantic,
|
|
10
|
+
ни platformdirs.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Callable, Mapping
|
|
15
|
+
from dataclasses import FrozenInstanceError, dataclass, field, replace
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"DEFAULT_TENANT",
|
|
21
|
+
"ExecutionContext",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
#: Идентификатор ЕДИНСТВЕННОГО тенанта в локальном (CLI) режиме. Дефолт всех
|
|
25
|
+
#: контекстов и `SessionRef`: пока он не изменён, раскладка путей и индекс
|
|
26
|
+
#: байт-в-байт совпадают с тем, что было до введения мультитенантности.
|
|
27
|
+
DEFAULT_TENANT = "local"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
#: Сентинел «аргумент не передан» для `ExecutionContext.__init__`: отличает
|
|
31
|
+
#: «поле оставили по умолчанию» от «поле передали, и значение совпало с дефолтом».
|
|
32
|
+
#: Без него нельзя решить конфликт синонимов (`tenant` vs `tenant_id`) честно.
|
|
33
|
+
_UNSET: Any = object()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _pick_alias(
|
|
37
|
+
canonical_name: str, canonical: Any, alias_name: str, alias: Any, default: Any
|
|
38
|
+
) -> Any:
|
|
39
|
+
"""Значение поля, заданного одним из двух синонимичных имён.
|
|
40
|
+
|
|
41
|
+
Правило простое и без «победителей»: задано одно — берём его; задано оба и
|
|
42
|
+
они РАЗНЫЕ — `TypeError`. Молчаливый выбор одного из двух — ровно та ошибка,
|
|
43
|
+
из-за которой мост clikit↔librarykit терял тенанта (см. `ExecutionContext`).
|
|
44
|
+
"""
|
|
45
|
+
has_canonical = canonical is not _UNSET
|
|
46
|
+
has_alias = alias is not None
|
|
47
|
+
if has_canonical and has_alias and canonical != alias:
|
|
48
|
+
raise TypeError(
|
|
49
|
+
f"ExecutionContext: {canonical_name}={canonical!r} и {alias_name}={alias!r} — "
|
|
50
|
+
f"это одно и то же поле под двумя именами, но значения разные. "
|
|
51
|
+
f"Передай что-то одно."
|
|
52
|
+
)
|
|
53
|
+
if has_canonical:
|
|
54
|
+
return canonical
|
|
55
|
+
if has_alias:
|
|
56
|
+
return alias
|
|
57
|
+
return default
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True, init=False)
|
|
61
|
+
class ExecutionContext:
|
|
62
|
+
"""Контекст ОДНОГО вызова: чей он, где его состояние, куда он ходит в сеть.
|
|
63
|
+
|
|
64
|
+
Ядро мультитенантности (Этап 2). До него всё это текло через ``os.environ`` и
|
|
65
|
+
модульные global'ы — а значит, было ПРОЦЕССНЫМ: два тенанта в одном хосте
|
|
66
|
+
(или две корутины) переписывали друг другу профиль, ``{BRAND}_HOME``,
|
|
67
|
+
``ALL_PROXY`` и флаг раскрытия секретов. `ExecutionContext` делает эти
|
|
68
|
+
величины ЯВНЫМИ, а скоупит их по `contextvars` слой выше (носитель —
|
|
69
|
+
:mod:`librarykit.context`), а не по процессу.
|
|
70
|
+
|
|
71
|
+
Поля:
|
|
72
|
+
|
|
73
|
+
- ``tenant_id`` — владелец вызова; в CLI ровно один — ``"local"``
|
|
74
|
+
(`DEFAULT_TENANT`), и тогда все раскладки/пути совпадают с досегодняшними;
|
|
75
|
+
- ``profile_name`` — профиль (набор сессий) внутри тенанта;
|
|
76
|
+
- ``home_dir`` — КОРЕНЬ состояния тенанта (вместо чтения ``{BRAND}_HOME``
|
|
77
|
+
в глубине кода);
|
|
78
|
+
- ``proxy`` — egress ИМЕННО ЭТОГО вызова (вместо ``ALL_PROXY`` /
|
|
79
|
+
``install_opener`` на весь процесс); передаётся аргументом в транспорт;
|
|
80
|
+
- ``output_mode`` — режим вывода (``"json"``/``"text"``) для CLI-границы;
|
|
81
|
+
- ``reveal_secrets`` — раскрывать ли секреты в выводе (вместо global ``_REVEAL``);
|
|
82
|
+
- ``deadline`` — абсолютный дедлайн вызова, ``time.monotonic()``-шкала
|
|
83
|
+
(вместо global ``_DEADLINE``); ``None`` = без дедлайна;
|
|
84
|
+
- ``trace_id`` — сквозной идентификатор вызова для логов/трейсинга;
|
|
85
|
+
- ``brand`` — имя продукта/CLI, под которым контекст собран: по нему
|
|
86
|
+
потребитель понимает, «его» ли это контекст (``clikit.context.context_for``),
|
|
87
|
+
и не подхватывает чужой профиль. ``""`` = нейтральный, ничей;
|
|
88
|
+
- ``redactor`` — функция редакции данных вывода (``None`` — не
|
|
89
|
+
редактировать). РЕСУРС-исключение: живёт в контексте, потому что решение
|
|
90
|
+
«маскировать ли» принимается на границе вывода, а не в глубине;
|
|
91
|
+
- ``extra`` — произвольные поля потребителя, ядром не
|
|
92
|
+
интерпретируются.
|
|
93
|
+
|
|
94
|
+
**ЕДИНСТВЕННОСТЬ.** Это ОДИН на экосистему `ExecutionContext`.
|
|
95
|
+
Раньше их было два — свой у `clikit` (``brand``/``tenant``/``profile``/
|
|
96
|
+
``redactor``/``extra``) плюс мост, который переносил значения по таблице
|
|
97
|
+
синонимов и молча терял ровно то, ради чего существовал. Здесь тот и другой
|
|
98
|
+
набор полей сведены в один тип, а ИМЕНА обеих школ поддержаны как синонимы:
|
|
99
|
+
|
|
100
|
+
================== ================== ===============================
|
|
101
|
+
канон (corekit) синоним (clikit) где принимается
|
|
102
|
+
================== ================== ===============================
|
|
103
|
+
``tenant_id`` ``tenant`` конструктор, `evolve`, чтение
|
|
104
|
+
``profile_name`` ``profile`` конструктор, `evolve`, чтение
|
|
105
|
+
================== ================== ===============================
|
|
106
|
+
|
|
107
|
+
Синоним — не второе поле, а другое ИМЯ того же (``ctx.tenant is
|
|
108
|
+
ctx.tenant_id``). Передать оба разом можно, только если значения совпадают:
|
|
109
|
+
иначе `TypeError` — тихо выбирать «победителя» тут нельзя, это и была
|
|
110
|
+
исходная болезнь моста.
|
|
111
|
+
|
|
112
|
+
``profile_name`` по умолчанию — ПУСТАЯ строка, а не ``"default"``: пустое
|
|
113
|
+
значит «профиль не выбран», и потребитель волен подставить свой дефолт
|
|
114
|
+
(`clikit.config.active_profile` по пустому уходит в общий конфиг-каталог, а
|
|
115
|
+
не в ``profiles/default/``). Свойство ``profile`` отдаёт такое значение как
|
|
116
|
+
``None`` — форма, к которой привык clikit.
|
|
117
|
+
|
|
118
|
+
``frozen=True, slots=True`` — как `SessionRef`/`Endpoint`: объект неизменяем,
|
|
119
|
+
поэтому его безопасно шарить между потоками и задачами (Этап 1), а «изменение»
|
|
120
|
+
делается порождением потомка (``dataclasses.replace`` →
|
|
121
|
+
:func:`librarykit.context.derive_context`).
|
|
122
|
+
|
|
123
|
+
.. note:: **Почему тут НЕТ поля ``http_client``.**
|
|
124
|
+
|
|
125
|
+
DTO ядра — ЗНАЧЕНИЕ: неизменяемое, хешируемое, дешёвое для копирования,
|
|
126
|
+
безопасное для логов и для передачи между потоками. HTTP-клиент — РЕСУРС с
|
|
127
|
+
жизненным циклом (пул соединений, привязка к event loop, обязательный
|
|
128
|
+
``aclose``). Положив его полем, мы бы: (1) сделали ``replace()`` источником
|
|
129
|
+
утечки сокетов (клон разделяет пул, но никто не владеет закрытием);
|
|
130
|
+
(2) привязали контекст к конкретному event loop, хотя контекст живёт и в
|
|
131
|
+
потоках; (3) сломали дешёвое копирование и сериализацию контекста.
|
|
132
|
+
Поэтому клиент РЕЗОЛВИТСЯ ПО контексту, а не хранится В нём: см.
|
|
133
|
+
:func:`librarykit.context.http_client_for_context` (реестр «ключ контекста
|
|
134
|
+
→ клиент» с явным :func:`librarykit.context.aclose_context_clients`) и
|
|
135
|
+
:func:`librarykit.context.transport_kwargs`.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
tenant_id: str = DEFAULT_TENANT
|
|
139
|
+
profile_name: str = ""
|
|
140
|
+
home_dir: Path | None = field(default_factory=Path.home)
|
|
141
|
+
proxy: str | None = None
|
|
142
|
+
output_mode: str = "json"
|
|
143
|
+
reveal_secrets: bool = False
|
|
144
|
+
deadline: float | None = None
|
|
145
|
+
trace_id: str = ""
|
|
146
|
+
brand: str = ""
|
|
147
|
+
redactor: Callable[[Any], Any] | None = None
|
|
148
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
149
|
+
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
tenant_id: str = _UNSET,
|
|
153
|
+
profile_name: str = _UNSET,
|
|
154
|
+
home_dir: Path | None = _UNSET,
|
|
155
|
+
proxy: str | None = None,
|
|
156
|
+
output_mode: str = "json",
|
|
157
|
+
reveal_secrets: bool = False,
|
|
158
|
+
deadline: float | None = None,
|
|
159
|
+
trace_id: str = "",
|
|
160
|
+
brand: str = "",
|
|
161
|
+
redactor: Callable[[Any], Any] | None = None,
|
|
162
|
+
extra: Mapping[str, Any] | None = None,
|
|
163
|
+
*,
|
|
164
|
+
tenant: str | None = None,
|
|
165
|
+
profile: str | None = None,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Собрать контекст; ``tenant``/``profile`` — синонимы канонических имён.
|
|
168
|
+
|
|
169
|
+
Порядок ПОЗИЦИОННЫХ параметров исторический, а новые поля
|
|
170
|
+
(``brand``/``redactor``/``extra``) добавлены В КОНЕЦ — поэтому и
|
|
171
|
+
``ExecutionContext("acme", "main")``, и любой прежний вызов по именам
|
|
172
|
+
продолжают значить ровно то же самое.
|
|
173
|
+
"""
|
|
174
|
+
object.__setattr__(
|
|
175
|
+
self, "tenant_id", _pick_alias("tenant_id", tenant_id, "tenant", tenant, DEFAULT_TENANT)
|
|
176
|
+
)
|
|
177
|
+
object.__setattr__(
|
|
178
|
+
self, "profile_name", _pick_alias("profile_name", profile_name, "profile", profile, "")
|
|
179
|
+
)
|
|
180
|
+
object.__setattr__(
|
|
181
|
+
self, "home_dir", Path.home() if home_dir is _UNSET else home_dir
|
|
182
|
+
)
|
|
183
|
+
object.__setattr__(self, "proxy", proxy)
|
|
184
|
+
object.__setattr__(self, "output_mode", output_mode)
|
|
185
|
+
object.__setattr__(self, "reveal_secrets", reveal_secrets)
|
|
186
|
+
object.__setattr__(self, "deadline", deadline)
|
|
187
|
+
object.__setattr__(self, "trace_id", trace_id)
|
|
188
|
+
object.__setattr__(self, "brand", brand)
|
|
189
|
+
object.__setattr__(self, "redactor", redactor)
|
|
190
|
+
object.__setattr__(self, "extra", {} if extra is None else extra)
|
|
191
|
+
|
|
192
|
+
# --- синонимы имён (clikit-школа): ЧТЕНИЕ того же поля, не второе поле --- #
|
|
193
|
+
@property
|
|
194
|
+
def tenant(self) -> str:
|
|
195
|
+
"""Синоним `tenant_id` (имя из clikit)."""
|
|
196
|
+
return self.tenant_id
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def profile(self) -> str | None:
|
|
200
|
+
"""Синоним `profile_name`; пустое значение отдаётся как ``None``."""
|
|
201
|
+
return self.profile_name or None
|
|
202
|
+
|
|
203
|
+
def evolve(self, **changes: Any) -> ExecutionContext:
|
|
204
|
+
"""Потомок с изменёнными полями (принимает и синонимы ``tenant``/``profile``).
|
|
205
|
+
|
|
206
|
+
То же, что `dataclasses.replace`, но понимает clikit-имена: ``replace``
|
|
207
|
+
работает строго по именам полей и на ``profile=`` упал бы.
|
|
208
|
+
"""
|
|
209
|
+
for alias, canonical in (("tenant", "tenant_id"), ("profile", "profile_name")):
|
|
210
|
+
if alias in changes:
|
|
211
|
+
value = changes.pop(alias)
|
|
212
|
+
changes[canonical] = "" if value is None and canonical == "profile_name" else value
|
|
213
|
+
return replace(self, **changes)
|
|
214
|
+
|
|
215
|
+
def __hash__(self) -> int:
|
|
216
|
+
"""Хеш по ЗНАЧИМЫМ координатам вызова (без ``redactor``/``extra``).
|
|
217
|
+
|
|
218
|
+
Сгенерированный dataclass-хеш взял бы ВСЕ поля и падал бы на ``extra``
|
|
219
|
+
(обычный ``dict`` не хешируется), а `ExecutionContext` обязан оставаться
|
|
220
|
+
хешируемым: он ложится в ключи реестров (см.
|
|
221
|
+
`librarykit.context.http_client_for_context`) и в множества в тестах.
|
|
222
|
+
Опущены ровно два поля-«довеска»: функция редакции и мешок потребителя —
|
|
223
|
+
они не меняют, ЧЕЙ это вызов и куда он ходит.
|
|
224
|
+
"""
|
|
225
|
+
return hash(
|
|
226
|
+
(
|
|
227
|
+
self.tenant_id,
|
|
228
|
+
self.profile_name,
|
|
229
|
+
self.home_dir,
|
|
230
|
+
self.proxy,
|
|
231
|
+
self.output_mode,
|
|
232
|
+
self.reveal_secrets,
|
|
233
|
+
self.deadline,
|
|
234
|
+
self.trace_id,
|
|
235
|
+
self.brand,
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _seal_frozen(cls: type) -> type:
|
|
241
|
+
"""Сделать «заморозку» полной: присвоение ЛЮБОГО имени → `FrozenInstanceError`.
|
|
242
|
+
|
|
243
|
+
Ловушка CPython (проверено на 3.14): у ``@dataclass(frozen=True, slots=True)``
|
|
244
|
+
сгенерированный ``__setattr__`` замыкается на ИСХОДНЫЙ класс, а ``slots=True``
|
|
245
|
+
затем создаёт НОВЫЙ. Поэтому ветка «не поле — отдай родителю» уходит в
|
|
246
|
+
``super(старый_класс, self)`` и вылетает
|
|
247
|
+
``TypeError: super(type, obj): obj is not an instance…`` вместо внятного
|
|
248
|
+
отказа. Присвоение полю (``ctx.tenant_id = …``) отрабатывает верно — падает
|
|
249
|
+
только присвоение ИМЕНИ ВНЕ полей, а у `ExecutionContext` такие имена
|
|
250
|
+
появились: синонимы-свойства ``tenant``/``profile``.
|
|
251
|
+
|
|
252
|
+
Ставим свои ``__setattr__``/``__delattr__``: единый внятный отказ для всего.
|
|
253
|
+
Инициализация от этого не страдает — она идёт через `object.__setattr__`
|
|
254
|
+
(см. ``__init__``), а не через класс.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
def _no_setattr(self: Any, name: str, value: Any) -> None:
|
|
258
|
+
raise FrozenInstanceError(f"cannot assign to field {name!r}")
|
|
259
|
+
|
|
260
|
+
def _no_delattr(self: Any, name: str) -> None:
|
|
261
|
+
raise FrozenInstanceError(f"cannot delete field {name!r}")
|
|
262
|
+
|
|
263
|
+
cls.__setattr__ = _no_setattr # type: ignore[method-assign, assignment]
|
|
264
|
+
cls.__delattr__ = _no_delattr # type: ignore[method-assign, assignment]
|
|
265
|
+
return cls
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
_seal_frozen(ExecutionContext)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""ЧИСТАЯ диагностика доступа: таксономия причин + классификация по УЛИКАМ.
|
|
2
|
+
|
|
3
|
+
Что здесь есть и почему это ОСНОВАНИЕ, а не «утиль»:
|
|
4
|
+
|
|
5
|
+
* :mod:`corekit.diagnosis.verdict` — таксономия причин (`Reason`/`Verdict`) и
|
|
6
|
+
ПОРЯДОК их разбора как ДАННЫЕ (``REASON_ORDER``), а не как проза в комментариях;
|
|
7
|
+
* :mod:`corekit.diagnosis.egress` — разбор ТЕКСТА ошибки: «лёг выход» против
|
|
8
|
+
«сервер спрашивает, кто ты» против «браузер не поднялся»;
|
|
9
|
+
* :mod:`corekit.diagnosis.access` — состояние ДОСТУПА (`AccessState`/
|
|
10
|
+
`AccessVerdict`): что именно мертво — сессия, эндпоинт или инфраструктура.
|
|
11
|
+
|
|
12
|
+
Ни один из трёх не ходит в сеть, не поднимает браузер и не читает файлы: на вход
|
|
13
|
+
им дают статус/тело/URL/текст исключения, на выход они дают вердикт С УЛИКОЙ.
|
|
14
|
+
Именно поэтому они годятся в основание — их можно позвать откуда угодно, включая
|
|
15
|
+
процесс, где нет ни httpx, ни playwright.
|
|
16
|
+
|
|
17
|
+
.. note:: Ссылки вида ``librarykit.diagnosis.geo`` / ``librarykit.diagnosis.landing``
|
|
18
|
+
в докстрингах ниже — указатели на слои ВЫШЕ (кто этими примитивами
|
|
19
|
+
пользуется и что достраивает). Импортов туда нет и быть не может: контракт
|
|
20
|
+
``0a`` в `import-linter` роняет прогон на первой же такой строке.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from corekit.diagnosis.access import (
|
|
25
|
+
ACCESS_STATE_MESSAGES,
|
|
26
|
+
ENDPOINT_DEAD_MARKERS,
|
|
27
|
+
LOGIN_HOST_MARKERS,
|
|
28
|
+
SESSION_DEAD_MARKERS,
|
|
29
|
+
TOKEN_EXPIRED_MARKERS,
|
|
30
|
+
AccessState,
|
|
31
|
+
AccessVerdict,
|
|
32
|
+
classify_access,
|
|
33
|
+
state_from_reason,
|
|
34
|
+
)
|
|
35
|
+
from corekit.diagnosis.egress import (
|
|
36
|
+
AUTH_MARKERS,
|
|
37
|
+
AUTH_STATUS_CODES,
|
|
38
|
+
AUTH_TIMEOUT_PHRASES,
|
|
39
|
+
EGRESS_MARKERS,
|
|
40
|
+
EGRESS_STATUS_CODES,
|
|
41
|
+
KIND_AUTH,
|
|
42
|
+
KIND_EGRESS,
|
|
43
|
+
KIND_UNKNOWN,
|
|
44
|
+
LAUNCH_FAILURE_MARKERS,
|
|
45
|
+
LAYER_NAME_MARKERS,
|
|
46
|
+
PROXY_AUTH_PHRASES,
|
|
47
|
+
WEAK_EGRESS_MARKERS,
|
|
48
|
+
classify_failure,
|
|
49
|
+
describe_egress,
|
|
50
|
+
is_egress_failure,
|
|
51
|
+
is_launch_failure,
|
|
52
|
+
probe_egress_alive,
|
|
53
|
+
verdict_from_error,
|
|
54
|
+
)
|
|
55
|
+
from corekit.diagnosis.verdict import (
|
|
56
|
+
REASON_MESSAGES,
|
|
57
|
+
REASON_ORDER,
|
|
58
|
+
REASON_RANK,
|
|
59
|
+
Reason,
|
|
60
|
+
Verdict,
|
|
61
|
+
resolve,
|
|
62
|
+
stronger,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
# taxonomy: причины, порядок, вердикт
|
|
67
|
+
"Reason",
|
|
68
|
+
"Verdict",
|
|
69
|
+
"REASON_ORDER",
|
|
70
|
+
"REASON_RANK",
|
|
71
|
+
"REASON_MESSAGES",
|
|
72
|
+
"resolve",
|
|
73
|
+
"stronger",
|
|
74
|
+
# access: ЧТО мертво — сессия / эндпоинт / инфраструктура
|
|
75
|
+
"AccessState",
|
|
76
|
+
"AccessVerdict",
|
|
77
|
+
"ACCESS_STATE_MESSAGES",
|
|
78
|
+
"ENDPOINT_DEAD_MARKERS",
|
|
79
|
+
"LOGIN_HOST_MARKERS",
|
|
80
|
+
"SESSION_DEAD_MARKERS",
|
|
81
|
+
"TOKEN_EXPIRED_MARKERS",
|
|
82
|
+
"classify_access",
|
|
83
|
+
"state_from_reason",
|
|
84
|
+
# egress: транспорт vs авторизация vs «движок не поднялся»
|
|
85
|
+
"KIND_AUTH",
|
|
86
|
+
"KIND_EGRESS",
|
|
87
|
+
"KIND_UNKNOWN",
|
|
88
|
+
"AUTH_MARKERS",
|
|
89
|
+
"AUTH_STATUS_CODES",
|
|
90
|
+
"AUTH_TIMEOUT_PHRASES",
|
|
91
|
+
"EGRESS_MARKERS",
|
|
92
|
+
"EGRESS_STATUS_CODES",
|
|
93
|
+
"LAUNCH_FAILURE_MARKERS",
|
|
94
|
+
"LAYER_NAME_MARKERS",
|
|
95
|
+
"PROXY_AUTH_PHRASES",
|
|
96
|
+
"WEAK_EGRESS_MARKERS",
|
|
97
|
+
"classify_failure",
|
|
98
|
+
"describe_egress",
|
|
99
|
+
"is_egress_failure",
|
|
100
|
+
"is_launch_failure",
|
|
101
|
+
"probe_egress_alive",
|
|
102
|
+
"verdict_from_error",
|
|
103
|
+
]
|