s-orchestrationkit 0.1.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.
- orchestrationkit/__init__.py +47 -0
- orchestrationkit/entities.py +176 -0
- orchestrationkit/enums.py +79 -0
- orchestrationkit/fanout.py +181 -0
- orchestrationkit/orchestration/__init__.py +43 -0
- orchestrationkit/orchestration/health.py +428 -0
- orchestrationkit/orchestration/onboarding.py +473 -0
- orchestrationkit/orchestration/session_loader.py +121 -0
- orchestrationkit/ports.py +117 -0
- orchestrationkit/protocols.py +183 -0
- s_orchestrationkit-0.1.0.dist-info/METADATA +67 -0
- s_orchestrationkit-0.1.0.dist-info/RECORD +14 -0
- s_orchestrationkit-0.1.0.dist-info/WHEEL +4 -0
- s_orchestrationkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""orchestrationkit — развязанные оркестраторы КОРНЯ китов: домен-нейтральны,
|
|
2
|
+
переиспользуемы любым потребителем.
|
|
3
|
+
|
|
4
|
+
Родился волной 5 платформы китов (Atlas #2710, Ruling 5-3) выделением связного
|
|
5
|
+
кластера из `librarykit`: `SessionLoader`/`HealthMonitor`/`OnboardingService`
|
|
6
|
+
(`orchestrationkit.orchestration`) + носители данных (`orchestrationkit.entities`),
|
|
7
|
+
порты (`orchestrationkit.ports`), канонические перечисления (`orchestrationkit.enums`),
|
|
8
|
+
контракты подключения и здоровья (`orchestrationkit.protocols`) и внутренний
|
|
9
|
+
примитив параллельных проб (`orchestrationkit.fanout`) — три разных внешних
|
|
10
|
+
потребителя (adapterkit, gws, bublictr) держали один и тот же код по чужому
|
|
11
|
+
адресу.
|
|
12
|
+
|
|
13
|
+
**Закон слоёв.** `orchestrationkit` не знает ни одного другого кита экосистемы,
|
|
14
|
+
кроме `corekit` (только `corekit.dto.SessionRef` в `protocols.py` — тонкая точка,
|
|
15
|
+
не библиотека целиком). Никакого импорта `librarykit`/`adapterkit`/`clikit`/
|
|
16
|
+
`bublictr` — оркестраторы стоят на нейтральных `Session`/`SessionContext`/
|
|
17
|
+
`HealthReport`, а доменные привязки (реестр адаптеров, enum'ы `Operation`/
|
|
18
|
+
`TargetKind`, координаты `SessionRef`, audit-store, браузерный профиль)
|
|
19
|
+
ИНЪЕКТИРУЮТСЯ доменом при инстанцировании.
|
|
20
|
+
|
|
21
|
+
`librarykit.orchestration`/`librarykit.entities`/`librarykit.ports`/
|
|
22
|
+
`librarykit.enums`/`librarykit.protocols`/`librarykit.fanout` остаются АЛИАСАМИ
|
|
23
|
+
на этот кит (`sys.modules`-подмена, не реэкспорт) — старые формы доступа
|
|
24
|
+
работают байт-в-байт для 54 потребителей librarykit.
|
|
25
|
+
|
|
26
|
+
Версия читается из метаданных установленного дистрибутива
|
|
27
|
+
(``importlib.metadata``), а не из литерала в коде — второй источник правды
|
|
28
|
+
здесь намеренно не заведён (см. `docs/kits/SAFETY-NET.md` в devcontour,
|
|
29
|
+
release_guard проверяет это на теге).
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
34
|
+
|
|
35
|
+
#: Не `__version__ = "0.0.0+unknown"` строкой-литералом — гейт релиза
|
|
36
|
+
#: (`scripts/release_guard.py`) ищет ЛЮБУЮ строку вида `^__version__\s*=\s*"`
|
|
37
|
+
#: как признак второго источника правды и откажет тегу, даже если это только
|
|
38
|
+
#: аварийный fallback редактируемой установки. Вынесено в константу, чтобы
|
|
39
|
+
#: строка присваивания `__version__` не начиналась с кавычки.
|
|
40
|
+
_UNBUILT_FALLBACK = "0.0.0+unknown"
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
__version__ = version("s-orchestrationkit")
|
|
44
|
+
except PackageNotFoundError: # pragma: no cover — редактируемая установка без сборки
|
|
45
|
+
__version__ = _UNBUILT_FALLBACK
|
|
46
|
+
|
|
47
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Домен-нейтральные data-классы оркестраторов — почва под будущий переезд.
|
|
2
|
+
|
|
3
|
+
Здесь живут НЕЙТРАЛЬНЫЕ копии данных, на которых стоят оркестраторы
|
|
4
|
+
(`OnboardingService`/`HealthMonitor`/`SessionLoader`): `Session`,
|
|
5
|
+
`SessionChanges`, `SessionContext`, `HealthReport`. Развязка от домена:
|
|
6
|
+
|
|
7
|
+
- поле `network` / `target` — ``str`` (машинное имя сети), НЕ доменный enum
|
|
8
|
+
(`NetworkId`/`TargetKind`); оркестратор не должен знать конкретный домен;
|
|
9
|
+
- `HealthReport.state` — `orchestrationkit.enums.HealthState` (канон-нейтральный enum,
|
|
10
|
+
реэкспортируется доменом).
|
|
11
|
+
|
|
12
|
+
ЗАКОН СЛОЁВ: только stdlib (+ `orchestrationkit.enums`); никаких импортов из
|
|
13
|
+
adapterkit/clikit/bublictr. Это data-carriers, поэтому stdlib `@dataclass`
|
|
14
|
+
(orchestrationkit — «почти-stdlib», pydantic НЕ зависимость пакета).
|
|
15
|
+
|
|
16
|
+
Нота: на момент переноса доменные `bublictr.core.entities.Session`/...
|
|
17
|
+
ОСТАЮТСЯ pydantic+`NetworkId` (домен использует ``session.network.value`` и
|
|
18
|
+
сравнения ``== NetworkId.X`` в ~40 файлах + 1168 тестах — развязка bublictr.Session
|
|
19
|
+
на ``str`` слишком инвазивна). Поэтому эти классы — ОТДЕЛЬНЫЕ нейтральные формы
|
|
20
|
+
ПОЗЖЕ, когда оркестраторы переедут на них. `HealthState` же канонизирован и
|
|
21
|
+
реэкспортирован доменом (identity-equal) уже сейчас.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from orchestrationkit.enums import HealthState
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Session:
|
|
35
|
+
"""Логин/токены/storage_state одного аккаунта в одной сети (нейтрально).
|
|
36
|
+
|
|
37
|
+
Нейтральный аналог `bublictr.core.entities.Session`: `network` — ``str``
|
|
38
|
+
(машинное имя сети), а не `NetworkId`. Остальные поля 1:1 с доменной
|
|
39
|
+
версией (включая V5-координаты unified session storage).
|
|
40
|
+
|
|
41
|
+
Несколько Binding могут разделять один Session. `storage_path` — JSON-файл с
|
|
42
|
+
cookies/state; секреты — в keyring под `keyring_ref`.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
id: str # uuid4
|
|
46
|
+
network: str # машинное имя сети ("vk" / "telegram" / ...)
|
|
47
|
+
storage_path: Path
|
|
48
|
+
created_at: datetime
|
|
49
|
+
keyring_ref: str | None = None
|
|
50
|
+
account_handle: str | None = None
|
|
51
|
+
account_external_id: str | None = None
|
|
52
|
+
last_refreshed_at: datetime | None = None
|
|
53
|
+
expires_at: datetime | None = None
|
|
54
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
55
|
+
# --- V5: координаты unified session storage ---
|
|
56
|
+
profile_id: str | None = None
|
|
57
|
+
account_id: str | None = None
|
|
58
|
+
session_dir_path: Path | None = None
|
|
59
|
+
auth_provider_id: int | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class SessionChanges:
|
|
64
|
+
"""DTO частичного обновления `Session` (нейтрально).
|
|
65
|
+
|
|
66
|
+
Только non-None поля применяются. Immutable после create (не входят сюда):
|
|
67
|
+
id, network, storage_path, created_at.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
keyring_ref: str | None = None
|
|
71
|
+
account_handle: str | None = None
|
|
72
|
+
account_external_id: str | None = None
|
|
73
|
+
last_refreshed_at: datetime | None = None
|
|
74
|
+
expires_at: datetime | None = None
|
|
75
|
+
metadata: dict[str, Any] | None = None
|
|
76
|
+
# --- V5 ---
|
|
77
|
+
profile_id: str | None = None
|
|
78
|
+
account_id: str | None = None
|
|
79
|
+
session_dir_path: Path | None = None
|
|
80
|
+
auth_provider_id: int | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class SessionContext:
|
|
85
|
+
"""Полный контекст для DI в адаптер — `Session` + storage_state + secrets.
|
|
86
|
+
|
|
87
|
+
Нейтральный аналог `bublictr.usecases.session_loader.SessionContext`.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
session: Session
|
|
91
|
+
storage_state: dict[str, Any]
|
|
92
|
+
secrets: dict[str, str] # secret_name → value, e.g. {"access_token": "..."}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class RawHealthStatus:
|
|
97
|
+
"""RAW-результат health-check адаптера ДО классификации (нейтрально).
|
|
98
|
+
|
|
99
|
+
Это «сырая» форма, которую отдаёт ``NetworkAdapter.health_check()`` и которую
|
|
100
|
+
жуёт :func:`orchestrationkit.orchestration.health.classify_health_state` → выдаёт
|
|
101
|
+
`HealthState`. Нейтральный аналог `bublictr.core.entities.HealthStatus`:
|
|
102
|
+
`network` — ``str`` (машинное имя сети), а не `NetworkId`.
|
|
103
|
+
|
|
104
|
+
Развязка vs `orchestrationkit.protocols.HealthStatus`: тот — УЖЕ классифицированный
|
|
105
|
+
DTO (несёт `state: HealthState`, для `HealthProtocol`); ЭТОТ — сырой вход
|
|
106
|
+
классификатора (несёт `healthy: bool` + `rate_limit_remaining`, без `state`).
|
|
107
|
+
Раньше оба назывались `HealthStatus` — конфликт снят переименованием этой,
|
|
108
|
+
RAW-формы, в `RawHealthStatus` (старое имя — deprecated-алиас ниже).
|
|
109
|
+
|
|
110
|
+
`classify_health_state` читает поля по duck-typing (`.auth_valid`/
|
|
111
|
+
`.api_reachable`/`.notes`/`.rate_limit_remaining`/`.healthy`), поэтому
|
|
112
|
+
одинаково принимает и эту форму, и доменный pydantic-`HealthStatus` bublictr
|
|
113
|
+
(тот несёт сверху `network: NetworkId` + `raw` + `model_dump` — их домен
|
|
114
|
+
оставляет себе, развязка их не трогает).
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
network: str
|
|
118
|
+
healthy: bool
|
|
119
|
+
auth_valid: bool
|
|
120
|
+
api_reachable: bool
|
|
121
|
+
rate_limit_remaining: int | None = None
|
|
122
|
+
notes: list[str] = field(default_factory=list)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class HealthReport:
|
|
127
|
+
"""Результат периодической проверки binding'а (нейтрально).
|
|
128
|
+
|
|
129
|
+
Нейтральный аналог `bublictr.core.entities.HealthReport`: `network` — ``str``,
|
|
130
|
+
`state` — `orchestrationkit.enums.HealthState` (канон). Добавляет binding/profile
|
|
131
|
+
контекст, классифицированный state и временные метки для trending.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
binding_id: str
|
|
135
|
+
profile_id: str
|
|
136
|
+
network: str
|
|
137
|
+
state: HealthState
|
|
138
|
+
auth_valid: bool
|
|
139
|
+
api_reachable: bool
|
|
140
|
+
checked_at: datetime
|
|
141
|
+
rate_limit_remaining: int | None = None
|
|
142
|
+
retry_after_sec: int | None = None
|
|
143
|
+
notes: list[str] = field(default_factory=list)
|
|
144
|
+
next_check_at: datetime | None = None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
__all__ = [
|
|
148
|
+
"Session",
|
|
149
|
+
"SessionChanges",
|
|
150
|
+
"SessionContext",
|
|
151
|
+
"RawHealthStatus",
|
|
152
|
+
"HealthReport",
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def __getattr__(name: str) -> Any:
|
|
157
|
+
"""PEP 562: deprecated-алиас `HealthStatus` → `RawHealthStatus`.
|
|
158
|
+
|
|
159
|
+
RAW-снимок здоровья переименован в `RawHealthStatus`, чтобы снять конфликт
|
|
160
|
+
имён с классифицированным DTO `orchestrationkit.protocols.HealthStatus`. Старое имя
|
|
161
|
+
остаётся импортируемым (`from orchestrationkit.entities import HealthStatus`) ради
|
|
162
|
+
обратной совместимости, но предупреждает о переезде.
|
|
163
|
+
"""
|
|
164
|
+
if name == "HealthStatus":
|
|
165
|
+
import warnings
|
|
166
|
+
|
|
167
|
+
warnings.warn(
|
|
168
|
+
"orchestrationkit.entities.HealthStatus переименован в RawHealthStatus. "
|
|
169
|
+
"Имя HealthStatus теперь закреплено за классифицированным DTO "
|
|
170
|
+
"orchestrationkit.protocols.HealthStatus; RAW-снимок стал RawHealthStatus. "
|
|
171
|
+
"Обнови импорт на RawHealthStatus — старый алиас будет удалён позже.",
|
|
172
|
+
DeprecationWarning,
|
|
173
|
+
stacklevel=2,
|
|
174
|
+
)
|
|
175
|
+
return RawHealthStatus
|
|
176
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Домен-нейтральные enums КОРНЯ китов (orchestrationkit).
|
|
2
|
+
|
|
3
|
+
Канон состояний здоровья сессии (`HealthState`) живёт ЗДЕСЬ — единый источник
|
|
4
|
+
истины для всего графа (orchestrationkit <- adapterkit <- clikit) и для домена
|
|
5
|
+
(bublictr реэкспортирует его из `bublictr.core.enums`). ``orchestrationkit.protocols``
|
|
6
|
+
реэкспортирует `HealthState` отсюда — НЕ плодим второй enum.
|
|
7
|
+
|
|
8
|
+
Рядом с ним — `SsoState`: живость входа У ПОСТАВЩИКА ЛИЧНОСТИ. Это ВТОРАЯ ось
|
|
9
|
+
того же вопроса «можно ли работать», и жить она обязана там же, где первая:
|
|
10
|
+
сессия сервиса и вход у Google умирают порознь, а состояния их читает один и
|
|
11
|
+
тот же оркестратор. Разведи эти два перечисления по разным китам — и потребитель
|
|
12
|
+
вынужден тянуть кит пула аккаунтов ради одного слова «alive».
|
|
13
|
+
|
|
14
|
+
ЗАКОН СЛОЁВ: только stdlib; никаких импортов из adapterkit/clikit/bublictr.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from enum import StrEnum
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HealthState(StrEnum):
|
|
22
|
+
"""Агрегированное состояние здоровья сессии/binding'а (домен-нейтрально).
|
|
23
|
+
|
|
24
|
+
StrEnum — значение совпадает со строкой, прямая сериализация в JSON и
|
|
25
|
+
сравнение с raw-ответами. Канон тут; `orchestrationkit.protocols.HealthState`,
|
|
26
|
+
`bublictr.core.enums.HealthState` — реэкспорт этого объекта (identity-equal).
|
|
27
|
+
|
|
28
|
+
- `HEALTHY` — всё работает;
|
|
29
|
+
- `WARNING` — работает, но накопились issues;
|
|
30
|
+
- `DEGRADED` — частично сломано;
|
|
31
|
+
- `UNAUTHENTICATED` — сессия разлогинена → нужен re-login;
|
|
32
|
+
- `RATE_LIMITED` — упёрлись в лимит запросов;
|
|
33
|
+
- `UNREACHABLE` — API недоступно (сеть/сервер);
|
|
34
|
+
- `CAPTCHA` — требуется challenge/ручное действие;
|
|
35
|
+
- `UNKNOWN` — состояние не удалось определить.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
HEALTHY = "healthy"
|
|
39
|
+
WARNING = "warning"
|
|
40
|
+
DEGRADED = "degraded"
|
|
41
|
+
UNAUTHENTICATED = "unauthenticated"
|
|
42
|
+
RATE_LIMITED = "rate_limited"
|
|
43
|
+
UNREACHABLE = "unreachable"
|
|
44
|
+
CAPTCHA = "captcha"
|
|
45
|
+
UNKNOWN = "unknown"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SsoState(StrEnum):
|
|
49
|
+
"""Живость входа У ПОСТАВЩИКА ЛИЧНОСТИ — отдельно от сессии сервиса.
|
|
50
|
+
|
|
51
|
+
Аккаунт, заведённый «через Google», держится на ДВУХ входах сразу, и сроки
|
|
52
|
+
жизни у них разные: ChatGPT продолжает отвечать, когда войти в Google уже
|
|
53
|
+
нельзя. Поэтому вопрос «жив ли общий вход» — свой, и ответ у него свой.
|
|
54
|
+
|
|
55
|
+
ИСХОДОВ ТРИ, И ТРЕТИЙ НЕ РОСКОШЬ.
|
|
56
|
+
|
|
57
|
+
- ``ALIVE`` — поставщик НАЗВАЛ, кого узнал. Ответ «200 OK» сам по себе
|
|
58
|
+
не признание: гостю сервисы отвечают ровно так же;
|
|
59
|
+
- ``LOGGED_OUT`` — только по ПРЯМОЙ улике отзыва (поставщик предъявил форму
|
|
60
|
+
входа; он ответил 401/403). Улику при этом обязаны предъявить: вердикт без
|
|
61
|
+
улики непроверяем, а по непроверяемому выключают рабочие аккаунты;
|
|
62
|
+
- ``UNKNOWN`` — всё прочее: связь оборвалась, ответ невнятен, проверки не
|
|
63
|
+
было. «Не знаю» — рабочий ответ, оставляющий аккаунт в строю.
|
|
64
|
+
|
|
65
|
+
Схлопывание неизвестного в «разлогинен» выключает живые аккаунты;
|
|
66
|
+
схлопывание в «жив» прячет поломку до часа, когда войти надо, а нечем.
|
|
67
|
+
|
|
68
|
+
StrEnum — значение совпадает со строкой (прямая сериализация в JSON и
|
|
69
|
+
сравнение с тем, что уже записано в реестрах). Канон ТУТ; кит пула аккаунтов
|
|
70
|
+
(`accountpoolkit.domain.enums.SsoState`) держит те же три значения и
|
|
71
|
+
переезжает на реэкспорт этого объекта — НЕ второй enum.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
ALIVE = "alive"
|
|
75
|
+
LOGGED_OUT = "logged_out"
|
|
76
|
+
UNKNOWN = "unknown"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = ["HealthState", "SsoState"]
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""ВЕЕР: N параллельных задач → результаты И привязанные к ним ошибки.
|
|
2
|
+
|
|
3
|
+
ЗАЧЕМ. Веерные пути кита («спроси три канала», «проверь здоровье десяти
|
|
4
|
+
привязок», «опубликуй в четыре сети») делались голым ``asyncio.gather``, и это
|
|
5
|
+
даёт ровно то, чего в таких сценариях быть не должно:
|
|
6
|
+
|
|
7
|
+
* **первое же исключение выбрасывается наружу, а остальные результаты теряются.**
|
|
8
|
+
Три канала: два ответили, третий упал — вызывающий не получает НИЧЕГО, хотя два
|
|
9
|
+
ответа у него в руках. Веер именно тем и ценен, что частичный успех — успех;
|
|
10
|
+
* **осиротевшие задачи.** ``gather`` при пробросе исключения НЕ отменяет
|
|
11
|
+
соседей: они продолжают выполняться уже никем не ожидаемые, дописывают файлы,
|
|
12
|
+
жгут лимиты, а их ошибки всплывают позже как ``Task exception was never
|
|
13
|
+
retrieved``;
|
|
14
|
+
* **``return_exceptions=True`` тоже не лечит.** Он отдаёт плоский список, где
|
|
15
|
+
исключение лежит НА МЕСТЕ результата и не привязано к тому, ЧЕЙ это результат:
|
|
16
|
+
вызывающий восстанавливает соответствие по индексу и на первой же правке
|
|
17
|
+
порядка каналов начинает винить не того.
|
|
18
|
+
|
|
19
|
+
КАК ЗДЕСЬ. :func:`fan_out` строит структурную конкурентность на
|
|
20
|
+
:class:`asyncio.TaskGroup` (PEP 654, стандартный механизм): у задач есть ОДИН
|
|
21
|
+
владелец, при выходе из блока не остаётся ни одной живой, а отмена извне
|
|
22
|
+
корректно доезжает до всех. Исключения при этом НЕ рвут группу: каждая работа
|
|
23
|
+
запускается в обёртке, которая ловит своё исключение и кладёт его в результат под
|
|
24
|
+
СВОИМ КЛЮЧОМ. Итог — :class:`FanOut`: ``results`` (кто ответил) и ``errors``
|
|
25
|
+
(кто упал и чем именно).
|
|
26
|
+
|
|
27
|
+
ЧТО НЕ ЛОВИТСЯ НАМЕРЕННО: `asyncio.CancelledError`. Отмена — не результат работы,
|
|
28
|
+
а приказ извне; проглотить её значит сломать отмену всего дерева задач.
|
|
29
|
+
|
|
30
|
+
ЧИСТЫЙ модуль: только stdlib, ``asyncio`` импортируется ЛЕНИВО (ядро не имеет
|
|
31
|
+
права тянуть event loop на ``import orchestrationkit`` — см. `test_lazy_facade`).
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from collections.abc import Awaitable, Callable, Hashable, Mapping, Sequence
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from typing import Any, TypeVar
|
|
38
|
+
|
|
39
|
+
__all__ = ["FanOut", "FanOutError", "fan_out", "fan_out_all"]
|
|
40
|
+
|
|
41
|
+
K = TypeVar("K", bound=Hashable)
|
|
42
|
+
V = TypeVar("V")
|
|
43
|
+
|
|
44
|
+
#: Работа веера: либо корутина/awaitable, либо фабрика корутин (``lambda: coro()``).
|
|
45
|
+
#: Фабрика предпочтительнее для ленивых и повторяемых сценариев; готовый awaitable
|
|
46
|
+
#: поддержан ради краткости вызова.
|
|
47
|
+
Job = Awaitable[Any] | Callable[[], Awaitable[Any]]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FanOutError(Exception):
|
|
51
|
+
"""Ни одна ветка веера не отработала — поднимает :meth:`FanOut.unwrap`.
|
|
52
|
+
|
|
53
|
+
Несёт ``errors`` целиком: «всё упало» почти всегда означает общую причину
|
|
54
|
+
(лёг выход, протухла сессия), и вызывающему нужны ВСЕ улики, а не первая.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, message: str, errors: Mapping[Any, BaseException]) -> None:
|
|
58
|
+
super().__init__(message)
|
|
59
|
+
self.errors = dict(errors)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class FanOut:
|
|
64
|
+
"""Итог веера: кто ответил и кто упал — с сохранением ЧЕЙ это ответ/ошибка.
|
|
65
|
+
|
|
66
|
+
* ``results`` — ключ → значение по успешным веткам;
|
|
67
|
+
* ``errors`` — ключ → исключение по упавшим (само исключение, не текст:
|
|
68
|
+
вызывающий волен разобрать его типом, а не регуляркой по сообщению).
|
|
69
|
+
|
|
70
|
+
Порядок ключей сохраняется от входа — веер отвечает вразнобой, но отчёт
|
|
71
|
+
человеку должен быть стабильным.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
results: dict[Any, Any] = field(default_factory=dict)
|
|
75
|
+
errors: dict[Any, BaseException] = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def ok(self) -> bool:
|
|
79
|
+
"""Все ветки отработали."""
|
|
80
|
+
return not self.errors
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def partial(self) -> bool:
|
|
84
|
+
"""Часть ответила, часть упала — типовой и ЛЕГАЛЬНЫЙ исход веера."""
|
|
85
|
+
return bool(self.results) and bool(self.errors)
|
|
86
|
+
|
|
87
|
+
def unwrap(self) -> dict[Any, Any]:
|
|
88
|
+
"""Результаты, если ответила ХОТЬ ОДНА ветка; иначе `FanOutError`.
|
|
89
|
+
|
|
90
|
+
Для сценариев «мне достаточно любого ответа» (спроси три канала, возьми
|
|
91
|
+
что дали). Тем, кому нужны все, — смотреть ``errors`` самому либо звать
|
|
92
|
+
:func:`fan_out_all`.
|
|
93
|
+
"""
|
|
94
|
+
if not self.results:
|
|
95
|
+
raise FanOutError(
|
|
96
|
+
f"веер не дал ни одного результата ({len(self.errors)} веток упало)",
|
|
97
|
+
self.errors,
|
|
98
|
+
)
|
|
99
|
+
return dict(self.results)
|
|
100
|
+
|
|
101
|
+
def __len__(self) -> int:
|
|
102
|
+
return len(self.results) + len(self.errors)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def _await_job(job: Job) -> Any:
|
|
106
|
+
"""Развернуть работу: фабрику — вызвать, awaitable — дождаться."""
|
|
107
|
+
return await (job() if callable(job) else job)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def fan_out(
|
|
111
|
+
jobs: Mapping[K, Job] | Sequence[Job],
|
|
112
|
+
*,
|
|
113
|
+
limit: int | None = None,
|
|
114
|
+
) -> FanOut:
|
|
115
|
+
"""Выполнить работы ПАРАЛЛЕЛЬНО; вернуть результаты И привязанные ошибки.
|
|
116
|
+
|
|
117
|
+
``jobs`` — словарь «ключ → работа» (ключ уедет в ``results``/``errors``) либо
|
|
118
|
+
последовательность, и тогда ключами станут индексы. ``limit`` ограничивает
|
|
119
|
+
число одновременно исполняемых работ (семафор) — для веера по десяткам
|
|
120
|
+
привязок, где залп по всем сразу упрётся в лимиты провайдера.
|
|
121
|
+
|
|
122
|
+
Падение одной ветки НЕ отменяет остальные и НЕ выбрасывается наружу — оно
|
|
123
|
+
приезжает в ``errors`` под своим ключом. Наружу проходит только отмена
|
|
124
|
+
(`asyncio.CancelledError`): её глотать нельзя.
|
|
125
|
+
"""
|
|
126
|
+
import asyncio # лениво: ядро не тянет event loop на импорте
|
|
127
|
+
|
|
128
|
+
items: list[tuple[Any, Job]] = (
|
|
129
|
+
list(jobs.items()) if isinstance(jobs, Mapping) else list(enumerate(jobs))
|
|
130
|
+
)
|
|
131
|
+
results: dict[Any, Any] = {}
|
|
132
|
+
errors: dict[Any, BaseException] = {}
|
|
133
|
+
if not items:
|
|
134
|
+
return FanOut(results, errors)
|
|
135
|
+
|
|
136
|
+
semaphore = asyncio.Semaphore(limit) if limit and limit > 0 else None
|
|
137
|
+
|
|
138
|
+
async def run(key: Any, job: Job) -> None:
|
|
139
|
+
try:
|
|
140
|
+
if semaphore is None:
|
|
141
|
+
results[key] = await _await_job(job)
|
|
142
|
+
else:
|
|
143
|
+
async with semaphore:
|
|
144
|
+
results[key] = await _await_job(job)
|
|
145
|
+
except asyncio.CancelledError:
|
|
146
|
+
# Отмена — приказ извне, а не исход работы: пробрасываем как есть,
|
|
147
|
+
# иначе `TaskGroup` не сможет свернуть дерево задач.
|
|
148
|
+
raise
|
|
149
|
+
except BaseException as exc:
|
|
150
|
+
errors[key] = exc
|
|
151
|
+
|
|
152
|
+
async with asyncio.TaskGroup() as group:
|
|
153
|
+
for key, job in items:
|
|
154
|
+
group.create_task(run(key, job))
|
|
155
|
+
|
|
156
|
+
# Порядок ключей — как на входе: отчёт человеку обязан быть стабильным.
|
|
157
|
+
ordered_keys = [key for key, _ in items]
|
|
158
|
+
return FanOut(
|
|
159
|
+
{k: results[k] for k in ordered_keys if k in results},
|
|
160
|
+
{k: errors[k] for k in ordered_keys if k in errors},
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def fan_out_all(
|
|
165
|
+
jobs: Mapping[K, Job] | Sequence[Job],
|
|
166
|
+
*,
|
|
167
|
+
limit: int | None = None,
|
|
168
|
+
) -> dict[Any, Any]:
|
|
169
|
+
"""Веер «нужны ВСЕ»: любая упавшая ветка → `FanOutError` со ВСЕМИ ошибками.
|
|
170
|
+
|
|
171
|
+
Отличие от голого ``gather``: соседи всё равно доработали (их результаты уже
|
|
172
|
+
получены), задачи не осиротели, а в исключении лежат ВСЕ причины с
|
|
173
|
+
привязкой к ключам, а не одна первая.
|
|
174
|
+
"""
|
|
175
|
+
outcome = await fan_out(jobs, limit=limit)
|
|
176
|
+
if outcome.errors:
|
|
177
|
+
keys = ", ".join(repr(k) for k in outcome.errors)
|
|
178
|
+
raise FanOutError(
|
|
179
|
+
f"веер требовал всех, упали ветки: {keys}", outcome.errors
|
|
180
|
+
)
|
|
181
|
+
return dict(outcome.results)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Развязанные оркестраторы КОРНЯ китов: переиспользуемы, домен-нейтральны.
|
|
2
|
+
|
|
3
|
+
Кульминация переезда: `SessionLoader`/`HealthMonitor`/`OnboardingService` +
|
|
4
|
+
классификатор здоровья + probe-registry + generic adapter probe живут ЗДЕСЬ, а не
|
|
5
|
+
в bublictr. Они переиспользуемы любым потребителем (bublictr / reverse-factory):
|
|
6
|
+
|
|
7
|
+
- стоят на `orchestrationkit.entities` (нейтральные `Session`/`SessionContext`/
|
|
8
|
+
`HealthReport`/`RawHealthStatus`, `network: str`) и `orchestrationkit.enums.HealthState`;
|
|
9
|
+
- репо/сторы/probe инжектируются как `orchestrationkit.ports`-Protocol;
|
|
10
|
+
- доменные привязки (реестр адаптеров, enum-значения `Operation`/`TargetKind`,
|
|
11
|
+
координаты `SessionRef`, audit-store, браузерный профиль) ИНЪЕКТИРУЮТСЯ доменом
|
|
12
|
+
при инстанцировании (bublictr — в `composition.py`).
|
|
13
|
+
|
|
14
|
+
ЗАКОН СЛОЁВ: ни один модуль пакета НЕ импортит adapterkit/clikit/bublictr и не
|
|
15
|
+
знает `NetworkId`/registry — всё инжектится. Граф китов: orchestrationkit <- adapterkit
|
|
16
|
+
<- clikit; orchestrationkit не зависит ни от кого выше.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from orchestrationkit.orchestration.health import (
|
|
21
|
+
GenericAdapterHealthProbe,
|
|
22
|
+
HealthMonitor,
|
|
23
|
+
classify_health_state,
|
|
24
|
+
get_health_probe,
|
|
25
|
+
register_health_probe,
|
|
26
|
+
)
|
|
27
|
+
from orchestrationkit.orchestration.onboarding import OnboardingDeps, OnboardingService
|
|
28
|
+
from orchestrationkit.orchestration.session_loader import SessionContext, SessionLoader
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
# session_loader
|
|
32
|
+
"SessionLoader",
|
|
33
|
+
"SessionContext",
|
|
34
|
+
# health
|
|
35
|
+
"classify_health_state",
|
|
36
|
+
"register_health_probe",
|
|
37
|
+
"get_health_probe",
|
|
38
|
+
"HealthMonitor",
|
|
39
|
+
"GenericAdapterHealthProbe",
|
|
40
|
+
# onboarding
|
|
41
|
+
"OnboardingService",
|
|
42
|
+
"OnboardingDeps",
|
|
43
|
+
]
|