funora 0.0.1.dev2__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.
- funora/__init__.py +265 -0
- funora/_account.py +661 -0
- funora/_aclient.py +1484 -0
- funora/_budget.py +579 -0
- funora/_calc.py +182 -0
- funora/_canonical.py +235 -0
- funora/_catalog.py +473 -0
- funora/_chat_history.py +367 -0
- funora/_chats.py +392 -0
- funora/_chips.py +403 -0
- funora/_classify.py +466 -0
- funora/_client.py +1490 -0
- funora/_currency_switch.py +130 -0
- funora/_cursor.py +120 -0
- funora/_delivered.py +233 -0
- funora/_diff.py +752 -0
- funora/_engine.py +4795 -0
- funora/_extract.py +124 -0
- funora/_field_schema.py +112 -0
- funora/_fileio.py +58 -0
- funora/_gate.py +69 -0
- funora/_hops.py +101 -0
- funora/_host.py +120 -0
- funora/_identity.py +284 -0
- funora/_json.py +31 -0
- funora/_listen.py +312 -0
- funora/_lot_form.py +331 -0
- funora/_market.py +406 -0
- funora/_matching.py +147 -0
- funora/_money.py +279 -0
- funora/_monitoring.py +396 -0
- funora/_observed.py +237 -0
- funora/_order.py +552 -0
- funora/_order_details.py +281 -0
- funora/_orders.py +807 -0
- funora/_outbound.py +453 -0
- funora/_own_lots.py +301 -0
- funora/_poll.py +410 -0
- funora/_price_audit.py +281 -0
- funora/_proxies.py +232 -0
- funora/_raise.py +150 -0
- funora/_refund.py +102 -0
- funora/_result.py +157 -0
- funora/_retry.py +213 -0
- funora/_review_write.py +138 -0
- funora/_reviews.py +584 -0
- funora/_runner.py +651 -0
- funora/_secret.py +385 -0
- funora/_showcase.py +362 -0
- funora/_signals.py +375 -0
- funora/_skeleton.py +752 -0
- funora/_snapshot.py +276 -0
- funora/_state.py +279 -0
- funora/_stock.py +32 -0
- funora/_thread.py +574 -0
- funora/_transport.py +1023 -0
- funora/_updates.py +292 -0
- funora/_verdicts.py +91 -0
- funora/_viewing.py +143 -0
- funora/_watch.py +825 -0
- funora/_watch_state.py +237 -0
- funora/_whoami.py +546 -0
- funora/bot/__init__.py +52 -0
- funora/bot/_delivery.py +341 -0
- funora/bot/_outbox.py +261 -0
- funora/bot/_runtime.py +447 -0
- funora/bot/_spool.py +534 -0
- funora/budget.py +311 -0
- funora/capabilities.py +297 -0
- funora/conformance.py +758 -0
- funora/contract.py +73 -0
- funora/errors.py +996 -0
- funora/events.py +189 -0
- funora/extraction.py +420 -0
- funora/observe.py +560 -0
- funora/operations.py +671 -0
- funora/py.typed +0 -0
- funora/reconciliation.py +51 -0
- funora/response_classes.py +155 -0
- funora/retry.py +238 -0
- funora/send_outcome.py +78 -0
- funora/skeleton_format.py +77 -0
- funora-0.0.1.dev2.dist-info/METADATA +294 -0
- funora-0.0.1.dev2.dist-info/RECORD +87 -0
- funora-0.0.1.dev2.dist-info/WHEEL +4 -0
- funora-0.0.1.dev2.dist-info/entry_points.txt +2 -0
- funora-0.0.1.dev2.dist-info/licenses/LICENSE +201 -0
funora/conformance.py
ADDED
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
"""Участие в наборе соответствия.
|
|
2
|
+
|
|
3
|
+
Протокол объявлен в spec/conformance/runner-protocol.yaml. Реализация читает
|
|
4
|
+
случаи по одному в строке с ввода и отвечает так же - по одному в строке.
|
|
5
|
+
Строки на входе и выходе умеет всякий язык, и никакой общей сборки для этого не
|
|
6
|
+
нужно: проверять предстоит шесть реализаций, и привязывать раннер к одной было
|
|
7
|
+
бы странно.
|
|
8
|
+
|
|
9
|
+
Главное свойство протокола - НЕЛЬЗЯ ПРОМОЛЧАТЬ. Случай, которого реализация не
|
|
10
|
+
умеет, отвечается пропуском с указанием записи реестра неисполненного. Пропуск
|
|
11
|
+
без ссылки протокол считает отказом: набор, который можно тихо пропустить,
|
|
12
|
+
показывает согласие там, где его нет, а это хуже отсутствия набора. Отсутствие
|
|
13
|
+
видно, ложное согласие нет.
|
|
14
|
+
|
|
15
|
+
Второе свойство: ОЖИДАЕМОГО РЕАЛИЗАЦИЯ НЕ ВИДИТ. Она считает и возвращает
|
|
16
|
+
посчитанное, а сверяет раннер. Первая редакция клала ожидаемое в случай, и
|
|
17
|
+
случай уезжал реализации целиком - то есть проверяемому присылали ответ вместе
|
|
18
|
+
с вопросом, и пустая реализация, возвращающая присланное, прошла бы весь набор.
|
|
19
|
+
|
|
20
|
+
Запуск::
|
|
21
|
+
|
|
22
|
+
python -m funora.conformance < cases.jsonl > results.jsonl
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import sys
|
|
31
|
+
from datetime import UTC, datetime
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, Final
|
|
34
|
+
|
|
35
|
+
from ._canonical import canonical_dumps
|
|
36
|
+
from ._diff import Event, _fingerprint
|
|
37
|
+
from ._gate import check_capability
|
|
38
|
+
from ._identity import REGISTRY, identity_of
|
|
39
|
+
from ._outbound import OutboundGovernor
|
|
40
|
+
from ._poll import Deduplicator
|
|
41
|
+
from .budget import WAIT_ATTEMPTS, RequestClass
|
|
42
|
+
from .capabilities import Capability, CapabilityState
|
|
43
|
+
from .contract import RUNNER_PROTOCOL
|
|
44
|
+
from .errors import (
|
|
45
|
+
BudgetExhaustedError,
|
|
46
|
+
ConfigurationError,
|
|
47
|
+
ExperimentalCapabilityError,
|
|
48
|
+
FunoraError,
|
|
49
|
+
UnsupportedCapabilityError,
|
|
50
|
+
ValidationError,
|
|
51
|
+
)
|
|
52
|
+
from .events import EventType
|
|
53
|
+
|
|
54
|
+
__all__ = ["PROTOCOL", "answer", "main"]
|
|
55
|
+
|
|
56
|
+
#: Версия протокола, по которой отвечает эта реализация.
|
|
57
|
+
PROTOCOL: Final[int] = RUNNER_PROTOCOL
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _spec_file(name: str) -> Path:
|
|
61
|
+
"""Указывает путь к файлу набора в рабочей копии спецификации.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
name (str): Имя файла в каталоге spec/conformance.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Path: Путь к файлу.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
ConfigurationError: Если рабочая копия спецификации не найдена.
|
|
71
|
+
"""
|
|
72
|
+
root = os.environ.get("FUNORA_SPEC_DIR")
|
|
73
|
+
if not root:
|
|
74
|
+
raise ConfigurationError(
|
|
75
|
+
"переменная FUNORA_SPEC_DIR не задана: файл набора искать негде. "
|
|
76
|
+
"Протокол передаёт вход ссылкой, и прочесть его обязана реализация"
|
|
77
|
+
)
|
|
78
|
+
return Path(root) / "spec" / "conformance" / name
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _document(name: str) -> dict[str, Any]:
|
|
82
|
+
"""Читает файл набора СВОИМ разборщиком и сверяет версию протокола.
|
|
83
|
+
|
|
84
|
+
Вход приходит ссылкой, а не значением, и это не удобство. Первая редакция
|
|
85
|
+
протокола передавала значение, и раннер портил его по дороге: он написан на
|
|
86
|
+
JavaScript, а JSON.parse там теряет точность за 2^53 и не отличает 1.0 от
|
|
87
|
+
1 - то есть уничтожал ровно те различия, ради которых векторы существуют.
|
|
88
|
+
|
|
89
|
+
Значит вектор обязан доезжать нетронутым, а разобрать его должен тот, кто
|
|
90
|
+
будет с ним работать.
|
|
91
|
+
|
|
92
|
+
Штамп runner_protocol сверяется здесь, а не только раннером, и это не
|
|
93
|
+
дублирование: раннер и реализация ищут файл набора по РАЗНЫМ корням -
|
|
94
|
+
раннер рядом с собой, реализация по FUNORA_SPEC_DIR. Значит они могут
|
|
95
|
+
читать разные рабочие копии, и версию обязан проверить тот, кто вправду
|
|
96
|
+
открыл файл.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
name (str): Имя файла в каталоге spec/conformance.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
dict[str, Any]: Разобранный файл набора.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
ConfigurationError: Если рабочая копия спецификации не найдена.
|
|
106
|
+
ValidationError: Если файл набора написан под другую версию протокола.
|
|
107
|
+
"""
|
|
108
|
+
path = _spec_file(name)
|
|
109
|
+
parsed: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
|
|
110
|
+
|
|
111
|
+
stamp = parsed.get("runner_protocol")
|
|
112
|
+
if stamp != PROTOCOL:
|
|
113
|
+
raise ValidationError(
|
|
114
|
+
f"файл набора {name} объявляет протокол {stamp!r}, а реализация "
|
|
115
|
+
f"отвечает по версии {PROTOCOL}. Прогнать набор чужой версии молча "
|
|
116
|
+
"нельзя: ось версий заведена ровно затем, чтобы это было видно"
|
|
117
|
+
)
|
|
118
|
+
return parsed
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _vectors() -> dict[str, Any]:
|
|
122
|
+
"""Читает файл векторов канонической формы.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
dict[str, Any]: Разобранный файл набора.
|
|
126
|
+
"""
|
|
127
|
+
return _document("canonical-form.vectors.json")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _resolve(reference: str) -> Any:
|
|
131
|
+
"""Достаёт вход вектора по ссылке вида «serialize.accept[7]».
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
reference (str): Ссылка на вектор.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Any: Вход вектора, разобранный своим разборщиком.
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
ValidationError: Если ссылка не разбирается либо ведёт в пустоту.
|
|
141
|
+
"""
|
|
142
|
+
match = re.fullmatch(r"(\w+)\.(\w+)\[(\d+)\]", reference)
|
|
143
|
+
if match is None:
|
|
144
|
+
raise ValidationError(f"ссылка на вектор {reference!r} не разбирается")
|
|
145
|
+
|
|
146
|
+
section, bucket, index = match.group(1), match.group(2), int(match.group(3))
|
|
147
|
+
try:
|
|
148
|
+
return _vectors()[section][bucket][index]["input"]
|
|
149
|
+
except (KeyError, IndexError) as error:
|
|
150
|
+
raise ValidationError(
|
|
151
|
+
f"ссылка {reference!r} ведёт в пустоту: {type(error).__name__}"
|
|
152
|
+
) from error
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _materialise(value: Any) -> Any:
|
|
156
|
+
"""Превращает обёртку случая в настоящее значение.
|
|
157
|
+
|
|
158
|
+
JSON своего типа для времени не имеет, поэтому момент несёт ключ $instant
|
|
159
|
+
со строкой RFC 3339. Разобрать её обязана реализация - иначе случай
|
|
160
|
+
проверял бы сериализацию строки, а не момента.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
value (Any): Вход случая.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Any: Значение, годное для канонической формы.
|
|
167
|
+
"""
|
|
168
|
+
if isinstance(value, dict) and set(value) == {"$instant"}:
|
|
169
|
+
return {"observed_at": datetime.fromisoformat(value["$instant"])}
|
|
170
|
+
return value
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _digest(fields: dict[str, str]) -> str:
|
|
174
|
+
"""Считает отпечаток события по четырём полям случая.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
fields (dict[str, str]): Поля отпечатка.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
str: Отпечаток.
|
|
181
|
+
"""
|
|
182
|
+
return _fingerprint(
|
|
183
|
+
account_id=fields["account_id"],
|
|
184
|
+
event_type=EventType(fields["type"]),
|
|
185
|
+
entity_id=fields["entity_id"],
|
|
186
|
+
revision=fields["entity_revision"],
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _scenario(reference: str, source: str = "resume.vectors.json") -> dict[str, Any]:
|
|
191
|
+
"""Достаёт сценарий набора по ссылке вида «scenarios[3]».
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
reference (str): Ссылка на сценарий.
|
|
195
|
+
source (str): Имя файла векторов в каталоге spec/conformance.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
dict[str, Any]: Сценарий целиком.
|
|
199
|
+
|
|
200
|
+
Raises:
|
|
201
|
+
ValidationError: Если ссылка не разбирается либо ведёт в пустоту.
|
|
202
|
+
"""
|
|
203
|
+
match = re.fullmatch(r"scenarios\[(\d+)\]", reference)
|
|
204
|
+
if match is None:
|
|
205
|
+
raise ValidationError(f"ссылка на сценарий {reference!r} не разбирается")
|
|
206
|
+
|
|
207
|
+
document = _document(source)
|
|
208
|
+
try:
|
|
209
|
+
scenario: dict[str, Any] = document["scenarios"][int(match.group(1))]
|
|
210
|
+
except (KeyError, IndexError) as error:
|
|
211
|
+
raise ValidationError(
|
|
212
|
+
f"ссылка {reference!r} ведёт в пустоту: {type(error).__name__}"
|
|
213
|
+
) from error
|
|
214
|
+
return scenario
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _event(event_id: str, key: str) -> Event:
|
|
218
|
+
"""Собирает событие с заданным идентификатором и ключом упорядочивания.
|
|
219
|
+
|
|
220
|
+
Гашение смотрит только на эти два поля, остальное конверту нужно для формы.
|
|
221
|
+
Подставлять сюда настоящий отпечаток нельзя: сценарий задаёт тождество
|
|
222
|
+
событий сам, а посчитанный отпечаток сделал бы «a» из разных сценариев
|
|
223
|
+
одним и тем же событием.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
event_id (str): Идентификатор события из сценария.
|
|
227
|
+
key (str): Ключ упорядочивания.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Event: Событие в конверте.
|
|
231
|
+
"""
|
|
232
|
+
return Event(
|
|
233
|
+
id=event_id,
|
|
234
|
+
type=EventType.MESSAGE_CREATED,
|
|
235
|
+
account_id="a1",
|
|
236
|
+
ordering_key=key,
|
|
237
|
+
entity_id=event_id,
|
|
238
|
+
observed_at=datetime(2023, 11, 14, 22, 13, 20, tzinfo=UTC),
|
|
239
|
+
origin="structural",
|
|
240
|
+
payload={},
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _run_scenario(scenario: dict[str, Any]) -> list[list[str]]:
|
|
245
|
+
"""Прогоняет сценарий гашения и возвращает дошедшее по шагам.
|
|
246
|
+
|
|
247
|
+
Время шага задано стенными часами, а перезапуск объявляет аптайм нового
|
|
248
|
+
процесса. Внутренние часы получаются из этих двух величин: показание
|
|
249
|
+
секундомера равно аптайму на момент перезапуска плюс всё, что с тех пор
|
|
250
|
+
прошло по стенным часам.
|
|
251
|
+
|
|
252
|
+
Ради того сценарий и написан. Реализация, сохранившая метки показанием
|
|
253
|
+
своего секундомера, здесь разойдётся с ожидаемым: у нового процесса начало
|
|
254
|
+
отсчёта своё, и прочитает он не то, что записал.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
scenario (dict[str, Any]): Сценарий: ttl_ms и перечень шагов.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
list[list[str]]: Для каждого шага - идентификаторы событий, прошедших
|
|
261
|
+
сквозь гашение, в порядке предложения. Шаг перезапуска даёт пустой
|
|
262
|
+
перечень.
|
|
263
|
+
|
|
264
|
+
Raises:
|
|
265
|
+
ValidationError: Если сценарий начинается с перезапуска: неизвестно, на
|
|
266
|
+
какой момент стенных часов сохранять состояние.
|
|
267
|
+
"""
|
|
268
|
+
dedup = Deduplicator(ttl_ms=scenario["ttl_ms"])
|
|
269
|
+
|
|
270
|
+
# Аптайм объявляет сценарий, и умолчания тут нет. Умолчание в коде уводило
|
|
271
|
+
# величину из-под запрета: проверка «ноль ставить нельзя» сверяла данные, а
|
|
272
|
+
# нулевой аптайм задавался бы не данными. При нуле же показание секундомера
|
|
273
|
+
# совпадает с прошедшим по стенным часам, и реализация, перепутавшая одно с
|
|
274
|
+
# другим, случайно даёт верный ответ - мутация это показала.
|
|
275
|
+
if "uptime_s" not in scenario:
|
|
276
|
+
raise ValidationError(
|
|
277
|
+
"сценарий не объявил аптайма первого процесса. Умолчания тут нет: "
|
|
278
|
+
"аптайм влияет на то, что проверяется, и выбирать его за сценарий "
|
|
279
|
+
"значит проверять не объявленное"
|
|
280
|
+
)
|
|
281
|
+
uptime_base = float(scenario["uptime_s"])
|
|
282
|
+
wall_base: int | None = None
|
|
283
|
+
wall_now: int | None = None
|
|
284
|
+
delivered: list[list[str]] = []
|
|
285
|
+
|
|
286
|
+
def monotonic(at_ms: int) -> float:
|
|
287
|
+
"""Переводит момент стенных часов в показание секундомера процесса.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
at_ms (int): Момент по стенным часам, миллисекунды от эпохи.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
float: Показание секундомера, секунды.
|
|
294
|
+
"""
|
|
295
|
+
assert wall_base is not None
|
|
296
|
+
return uptime_base + (at_ms - wall_base) / 1000
|
|
297
|
+
|
|
298
|
+
for step in scenario["steps"]:
|
|
299
|
+
restart = step.get("restart")
|
|
300
|
+
if restart is not None:
|
|
301
|
+
if wall_now is None:
|
|
302
|
+
raise ValidationError(
|
|
303
|
+
"сценарий начинается с перезапуска: неизвестно, на какой "
|
|
304
|
+
"момент стенных часов сохранять состояние"
|
|
305
|
+
)
|
|
306
|
+
state = dedup.snapshot(monotonic(wall_now), wall_ms=wall_now)
|
|
307
|
+
ordering = dedup.snapshot_order()
|
|
308
|
+
dedup = Deduplicator(ttl_ms=scenario["ttl_ms"])
|
|
309
|
+
uptime_base = float(restart["uptime_s"])
|
|
310
|
+
wall_base = wall_now
|
|
311
|
+
dedup.restore(state, uptime_base, wall_ms=wall_now, ordering=ordering)
|
|
312
|
+
delivered.append([])
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
wall_now = step["at_ms"]
|
|
316
|
+
if wall_base is None:
|
|
317
|
+
wall_base = wall_now
|
|
318
|
+
now = monotonic(wall_now)
|
|
319
|
+
|
|
320
|
+
key = step.get("key", "k")
|
|
321
|
+
offered = tuple(_event(one, key) for one in step["offer"])
|
|
322
|
+
fresh = dedup.filter(offered, now)
|
|
323
|
+
delivered.append([one.id for one in fresh])
|
|
324
|
+
|
|
325
|
+
accepted = set(step.get("commit", ()))
|
|
326
|
+
dedup.commit(tuple(one for one in fresh if one.id in accepted), now)
|
|
327
|
+
|
|
328
|
+
return delivered
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _requests(scenario: dict[str, Any]) -> list[dict[str, Any]]:
|
|
332
|
+
"""Разворачивает запросы трассы из перечня либо порождающего правила.
|
|
333
|
+
|
|
334
|
+
Правило нужно там, где запросов сотня и перечень был бы нечитаем: двадцать
|
|
335
|
+
аккаунтов по пять запросов пишутся одной строкой, а читаются так же.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
scenario (dict[str, Any]): Сценарий набора rate-budget.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
list[dict[str, Any]]: Запросы в порядке появления.
|
|
342
|
+
"""
|
|
343
|
+
listed: list[dict[str, Any]] | None = scenario.get("requests")
|
|
344
|
+
if listed is not None:
|
|
345
|
+
return listed
|
|
346
|
+
|
|
347
|
+
rule = scenario["generate"]
|
|
348
|
+
return [
|
|
349
|
+
{
|
|
350
|
+
"at_ms": rule["at_ms"],
|
|
351
|
+
"class": rule["class"],
|
|
352
|
+
"account": f"аккаунт-{account}",
|
|
353
|
+
"action": rule.get("action", False),
|
|
354
|
+
}
|
|
355
|
+
for account in range(rule["accounts"])
|
|
356
|
+
for _ in range(rule["per_account"])
|
|
357
|
+
]
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _run_trace(scenario: dict[str, Any]) -> list[int | None]:
|
|
361
|
+
"""Прогоняет трассу запросов по виртуальным часам.
|
|
362
|
+
|
|
363
|
+
Часы виртуальные и двигает их вызывающий. Бюджет не спит: он отвечает,
|
|
364
|
+
сколько ждать, - иначе набор шёл бы столько же, сколько занимает настоящее
|
|
365
|
+
ожидание, и проверял бы заодно точность таймера.
|
|
366
|
+
|
|
367
|
+
Бюджет берётся у СЕТЕВОЙ ИДЕНТИЧНОСТИ, а не заводится под каждый аккаунт, и
|
|
368
|
+
в этом проверяемое: ограничение накладывает площадка, и накладывает она его
|
|
369
|
+
на пару из исходящего адреса и целевого хоста. Двадцать клиентов, каждый со
|
|
370
|
+
своим бюджетом, дают двадцатикратную нагрузку с одного адреса при формально
|
|
371
|
+
соблюдённых правилах.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
scenario (dict[str, Any]): Сценарий: запросы и признак ожидания.
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
list[int | None]: Метка отправки каждого запроса в миллисекундах
|
|
378
|
+
виртуальных часов. None означает, что запрос не ушёл вовсе.
|
|
379
|
+
"""
|
|
380
|
+
# Реестр общий на процесс, и остатки чужого сценария сделали бы трассу
|
|
381
|
+
# зависимой от порядка прогона. Набор обязан давать один ответ всегда.
|
|
382
|
+
REGISTRY.reset()
|
|
383
|
+
budget = REGISTRY.get(identity_of(None, "funpay.com")).budget
|
|
384
|
+
|
|
385
|
+
attempts = WAIT_ATTEMPTS if scenario.get("waits", True) else 1
|
|
386
|
+
now_ms = 0
|
|
387
|
+
sent: list[int | None] = []
|
|
388
|
+
|
|
389
|
+
for request in _requests(scenario):
|
|
390
|
+
now_ms = max(now_ms, int(request.get("at_ms", 0)))
|
|
391
|
+
request_class = RequestClass(request.get("class", "interactive"))
|
|
392
|
+
cost = float(request.get("cost", 1))
|
|
393
|
+
action = request.get("action", False)
|
|
394
|
+
if not isinstance(action, bool):
|
|
395
|
+
raise ValueError("action в трассе должен быть boolean")
|
|
396
|
+
account_budget = budget.for_account(request.get("account", "self"))
|
|
397
|
+
|
|
398
|
+
moment: int | None = None
|
|
399
|
+
for attempt in range(attempts):
|
|
400
|
+
try:
|
|
401
|
+
reservation = account_budget.require(
|
|
402
|
+
now_ms / 1000, cost=cost, request_class=request_class, action=action
|
|
403
|
+
)
|
|
404
|
+
except BudgetExhaustedError:
|
|
405
|
+
# Отказ по классу отменяемому либо ожидание дольше предела.
|
|
406
|
+
# И то и другое означает, что запрос не отправлен вовсе.
|
|
407
|
+
break
|
|
408
|
+
if reservation.granted:
|
|
409
|
+
moment = now_ms
|
|
410
|
+
break
|
|
411
|
+
if attempt + 1 == attempts:
|
|
412
|
+
break
|
|
413
|
+
now_ms += reservation.wait_ms
|
|
414
|
+
|
|
415
|
+
sent.append(moment)
|
|
416
|
+
|
|
417
|
+
return sent
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _capability(name: str) -> Capability:
|
|
421
|
+
"""Находит возможность по идентификатору из спецификации.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
name (str): Идентификатор вида «orders.list».
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
Capability: Возможность.
|
|
428
|
+
|
|
429
|
+
Raises:
|
|
430
|
+
ValidationError: Если такой возможности в реализации нет. Молча
|
|
431
|
+
пропустить нельзя: набор объявляет перечень возможностей
|
|
432
|
+
нормативным, и отсутствующая означает не пробел набора, а пробел
|
|
433
|
+
реализации.
|
|
434
|
+
"""
|
|
435
|
+
for one in Capability:
|
|
436
|
+
if one.value == name:
|
|
437
|
+
return one
|
|
438
|
+
raise ValidationError(f"возможность «{name}» объявлена спецификацией, а реализации неизвестна")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _decision(case: dict[str, Any]) -> str:
|
|
442
|
+
"""Решает, разрешён ли вызов, и возвращает решение словом.
|
|
443
|
+
|
|
444
|
+
Возвращается ИМЯ КЛАССА отказа, а не «отклонено». Вызывающий пишет except по
|
|
445
|
+
классу, и две реализации, отклоняющие одно и то же разными классами,
|
|
446
|
+
заставляют писать разный except - то есть у переносимого кода переносимости
|
|
447
|
+
не остаётся. Отдельно важно различие двух отказов: экспериментальную
|
|
448
|
+
возможность включают и зовут, отсутствующую - не зовут вовсе.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
case (dict[str, Any]): Случай с полями capability, state, opted_in.
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
str: «разрешено» либо имя класса отказа.
|
|
455
|
+
"""
|
|
456
|
+
capability = _capability(case["capability"])
|
|
457
|
+
state = CapabilityState(case["state"])
|
|
458
|
+
try:
|
|
459
|
+
check_capability(capability, state=state, opted_in=bool(case.get("opted_in")))
|
|
460
|
+
except FunoraError as refusal:
|
|
461
|
+
return type(refusal).__name__
|
|
462
|
+
return "разрешено"
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _run_outbound(scenario: dict[str, Any]) -> list[str]:
|
|
466
|
+
"""Прогоняет сценарий ограничителя исходящих по виртуальным часам.
|
|
467
|
+
|
|
468
|
+
Часы подаются СНАРУЖИ, обе оси. Стенная метка живёт на диске, монотонная -
|
|
469
|
+
внутри запуска, и сценарий с переводом часов подаёт их расходящимися: это
|
|
470
|
+
единственный способ проверить, что реализация не путает одно с другим.
|
|
471
|
+
|
|
472
|
+
Перезапуск изображается снимком и восстановлением: ровно то, что делает
|
|
473
|
+
файл состояния. Реестр, не переживший его, превратил бы часовую квоту в
|
|
474
|
+
квоту на запуск.
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
scenario (dict[str, Any]): Сценарий: события и признак долговечности.
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
list[str]: По решению на каждую попытку отправки - allowed либо имя
|
|
481
|
+
упёршегося предела.
|
|
482
|
+
"""
|
|
483
|
+
governor = OutboundGovernor(durable=bool(scenario.get("durable", True)))
|
|
484
|
+
decisions: list[str] = []
|
|
485
|
+
|
|
486
|
+
for event in scenario.get("events", []):
|
|
487
|
+
kind = str(event.get("kind"))
|
|
488
|
+
if kind == "incoming":
|
|
489
|
+
governor.note_incoming(str(event["chat"]), at_ms=int(event["at_ms"]))
|
|
490
|
+
continue
|
|
491
|
+
if kind == "restart":
|
|
492
|
+
# Реестр уходит на диск и возвращается оттуда. Монотонных меток у
|
|
493
|
+
# восстановленных записей нет и быть не может: отсчёт свой в каждом
|
|
494
|
+
# запуске.
|
|
495
|
+
saved = governor.snapshot()
|
|
496
|
+
governor = OutboundGovernor(durable=bool(scenario.get("durable", True)))
|
|
497
|
+
governor.restore(saved)
|
|
498
|
+
continue
|
|
499
|
+
if kind != "send":
|
|
500
|
+
raise ValueError(f"неизвестное событие сценария: {kind}")
|
|
501
|
+
|
|
502
|
+
now_ms = int(event["at_ms"])
|
|
503
|
+
now_s = float(event.get("monotonic_s", 0.0))
|
|
504
|
+
refusal = governor.check(
|
|
505
|
+
str(event["chat"]),
|
|
506
|
+
now_ms=now_ms,
|
|
507
|
+
now_s=now_s,
|
|
508
|
+
declared_cold=bool(event.get("declared_cold", False)),
|
|
509
|
+
)
|
|
510
|
+
if refusal is not None:
|
|
511
|
+
decisions.append(refusal.limit)
|
|
512
|
+
continue
|
|
513
|
+
governor.record(str(event["chat"]), now_ms=now_ms, now_s=now_s)
|
|
514
|
+
decisions.append("allowed")
|
|
515
|
+
|
|
516
|
+
return decisions
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _run_retries(scenario: dict[str, Any]) -> dict[str, Any]:
|
|
520
|
+
"""Ведёт настоящий цикл чтения с подставными ответами и виртуальным сном."""
|
|
521
|
+
from unittest.mock import patch
|
|
522
|
+
|
|
523
|
+
from ._budget import Budget
|
|
524
|
+
from ._engine import Engine, Fetch, Pause
|
|
525
|
+
from ._identity import Identity
|
|
526
|
+
from ._retry import plan_attempt
|
|
527
|
+
from ._transport import Observation, TransportSettings
|
|
528
|
+
from .errors import ERROR_BY_STABLE_ID
|
|
529
|
+
|
|
530
|
+
clock = [0.0]
|
|
531
|
+
journal: list[dict[str, Any]] = []
|
|
532
|
+
responses = iter(scenario["responses"])
|
|
533
|
+
requests = 0
|
|
534
|
+
budget = Budget(names=())
|
|
535
|
+
engine = Engine(TransportSettings(), budget, identity=Identity("retries", budget=budget))
|
|
536
|
+
|
|
537
|
+
def record(error: FunoraError, **kwargs: Any) -> Any:
|
|
538
|
+
plan = plan_attempt(error, **kwargs, rand=lambda: float(scenario.get("random", 0.5)))
|
|
539
|
+
journal.append(
|
|
540
|
+
{
|
|
541
|
+
"attempt": kwargs["attempt"],
|
|
542
|
+
"classified_as": type(error).stable_id,
|
|
543
|
+
"retried": plan.retry,
|
|
544
|
+
"delay_ms": plan.delay_ms,
|
|
545
|
+
"reason_code": plan.reason,
|
|
546
|
+
}
|
|
547
|
+
)
|
|
548
|
+
return plan
|
|
549
|
+
|
|
550
|
+
core = engine.fetch_ok(Capability.REVIEWS_GET, "/users/1/")
|
|
551
|
+
with (
|
|
552
|
+
patch("funora._engine.monotonic", lambda: clock[0]),
|
|
553
|
+
patch("funora._engine.plan_attempt", record),
|
|
554
|
+
):
|
|
555
|
+
try:
|
|
556
|
+
request = next(core)
|
|
557
|
+
while True:
|
|
558
|
+
if isinstance(request, Pause):
|
|
559
|
+
clock[0] += request.ms / 1000
|
|
560
|
+
request = core.send(None)
|
|
561
|
+
continue
|
|
562
|
+
if not isinstance(request, Fetch):
|
|
563
|
+
raise TypeError(f"неожиданная просьба чтения: {type(request)}")
|
|
564
|
+
response = next(responses, None)
|
|
565
|
+
if response is None:
|
|
566
|
+
raise ValidationError("цикл запросил ответ за пределами сценария")
|
|
567
|
+
requests += 1
|
|
568
|
+
if "error" in response:
|
|
569
|
+
error_class = ERROR_BY_STABLE_ID[response["error"]]
|
|
570
|
+
error = error_class("сценарий повторов")
|
|
571
|
+
if not isinstance(error, FunoraError):
|
|
572
|
+
raise ValidationError("ошибка сценария вне иерархии Funora")
|
|
573
|
+
error.provisional = bool(response.get("provisional", False)) # type: ignore[attr-defined]
|
|
574
|
+
request = core.throw(error)
|
|
575
|
+
else:
|
|
576
|
+
html = '<div class="navbar-toggle-logged"></div>'
|
|
577
|
+
request = core.send(
|
|
578
|
+
Observation(
|
|
579
|
+
status=int(response.get("status", 200)),
|
|
580
|
+
final_url="https://funpay.com/users/1/",
|
|
581
|
+
html=html,
|
|
582
|
+
elapsed_ms=0,
|
|
583
|
+
redirects=0,
|
|
584
|
+
content_length=len(html),
|
|
585
|
+
declared_length=len(html),
|
|
586
|
+
retry_after_ms=response.get("retry_after_ms"),
|
|
587
|
+
)
|
|
588
|
+
)
|
|
589
|
+
except StopIteration:
|
|
590
|
+
journal.append(
|
|
591
|
+
{
|
|
592
|
+
"attempt": requests,
|
|
593
|
+
"classified_as": "ok",
|
|
594
|
+
"retried": False,
|
|
595
|
+
"delay_ms": 0,
|
|
596
|
+
"reason_code": "completed",
|
|
597
|
+
}
|
|
598
|
+
)
|
|
599
|
+
except FunoraError:
|
|
600
|
+
if not journal or journal[-1]["retried"]:
|
|
601
|
+
raise
|
|
602
|
+
finally:
|
|
603
|
+
core.close()
|
|
604
|
+
return {"retry_trace": journal, "requests": requests, "waited_ms": round(clock[0] * 1000)}
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def answer(case: dict[str, Any]) -> dict[str, Any]:
|
|
608
|
+
"""Отвечает на один случай набора.
|
|
609
|
+
|
|
610
|
+
Args:
|
|
611
|
+
case (dict[str, Any]): Случай по протоколу: id, suite, kind, input и
|
|
612
|
+
необязательные expected, same_as, why.
|
|
613
|
+
|
|
614
|
+
Returns:
|
|
615
|
+
dict[str, Any]: Ответ по протоколу: id, outcome и подробности.
|
|
616
|
+
"""
|
|
617
|
+
case_id = case.get("id", "<без идентификатора>")
|
|
618
|
+
kind = case.get("kind")
|
|
619
|
+
|
|
620
|
+
try:
|
|
621
|
+
if kind == "serialize":
|
|
622
|
+
got = canonical_dumps(_materialise(_resolve(case["vector"])))
|
|
623
|
+
return {"id": case_id, "outcome": "pass", "value": got}
|
|
624
|
+
|
|
625
|
+
if kind == "fingerprint":
|
|
626
|
+
got = _digest(_resolve(case["vector"]))
|
|
627
|
+
return {"id": case_id, "outcome": "pass", "value": got}
|
|
628
|
+
|
|
629
|
+
if kind == "rate_budget":
|
|
630
|
+
trace = _scenario(case["vector"], "rate-budget.vectors.json")
|
|
631
|
+
if trace.get("concurrent"):
|
|
632
|
+
# Пропуск СО ССЫЛКОЙ. Одновременное поступление решает общая
|
|
633
|
+
# очередь с приоритетами, а эталонная реализация ходит на
|
|
634
|
+
# площадку по одному запросу за раз. Пройти сценарий по порядку
|
|
635
|
+
# поступления значило бы показать согласие там, где его нет.
|
|
636
|
+
return {
|
|
637
|
+
"id": case_id,
|
|
638
|
+
"outcome": "skip",
|
|
639
|
+
"not_implemented": trace["requires"],
|
|
640
|
+
}
|
|
641
|
+
return {"id": case_id, "outcome": "pass", "sent": _run_trace(trace)}
|
|
642
|
+
|
|
643
|
+
if kind == "retries":
|
|
644
|
+
scenario = _scenario(case["vector"], "retries.vectors.json")
|
|
645
|
+
return {"id": case_id, "outcome": "pass", **_run_retries(scenario)}
|
|
646
|
+
|
|
647
|
+
if kind == "outbound_governor":
|
|
648
|
+
scenario = _scenario(case["vector"], "outbound-governor.vectors.json")
|
|
649
|
+
return {"id": case_id, "outcome": "pass", "decisions": _run_outbound(scenario)}
|
|
650
|
+
|
|
651
|
+
if kind == "capability_decision":
|
|
652
|
+
return {"id": case_id, "outcome": "pass", "value": _decision(case)}
|
|
653
|
+
|
|
654
|
+
if kind == "capability_initial":
|
|
655
|
+
# Берётся то, что реализация подставляет ПРИ ВЫЗОВЕ без состояния, а
|
|
656
|
+
# не то, что лежит в порождённой таблице. Совпадение таблицы со
|
|
657
|
+
# спецификацией проверено отдельно; здесь проверяется, что вызов эту
|
|
658
|
+
# таблицу читает.
|
|
659
|
+
#
|
|
660
|
+
# Состояние выводится ИЗ ПОВЕДЕНИЯ, а не читается из таблицы. Три
|
|
661
|
+
# состояния вызов пропускает и возвращает сами себя; два отклоняют,
|
|
662
|
+
# и каждое своим классом - отображение в обе стороны однозначно.
|
|
663
|
+
try:
|
|
664
|
+
state = check_capability(
|
|
665
|
+
_capability(case["capability"]), state=None, opted_in=False
|
|
666
|
+
).value
|
|
667
|
+
except UnsupportedCapabilityError:
|
|
668
|
+
state = "unsupported"
|
|
669
|
+
except ExperimentalCapabilityError:
|
|
670
|
+
state = "experimental"
|
|
671
|
+
return {"id": case_id, "outcome": "pass", "value": state}
|
|
672
|
+
|
|
673
|
+
if kind == "resume":
|
|
674
|
+
# Сверяет раннер: реализация возвращает, что дошло на каждом шаге, а
|
|
675
|
+
# ожидаемого не знает - иначе она сверяла бы себя сама.
|
|
676
|
+
return {
|
|
677
|
+
"id": case_id,
|
|
678
|
+
"outcome": "pass",
|
|
679
|
+
"steps": _run_scenario(_scenario(case["vector"])),
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if kind in ("serialize_refuses", "fingerprint_refuses"):
|
|
683
|
+
worker = canonical_dumps if kind == "serialize_refuses" else _digest
|
|
684
|
+
try:
|
|
685
|
+
produced = worker(_materialise(_resolve(case["vector"])))
|
|
686
|
+
except FunoraError as refusal:
|
|
687
|
+
# Возвращается ИМЯ КЛАССА, а не просто «отвергнуто». Иначе случай
|
|
688
|
+
# судил бы себя сам: раннер канонической формы не считает и о
|
|
689
|
+
# том, обязан ли вход быть отвергнут, знать не может. Заодно имя
|
|
690
|
+
# держит согласие классов между реализациями - два SDK,
|
|
691
|
+
# отвергающие дробное число разными классами, заставляют
|
|
692
|
+
# вызывающего писать разный except.
|
|
693
|
+
return {
|
|
694
|
+
"id": case_id,
|
|
695
|
+
"outcome": "pass",
|
|
696
|
+
"value": type(refusal).__name__,
|
|
697
|
+
}
|
|
698
|
+
return {
|
|
699
|
+
"id": case_id,
|
|
700
|
+
"outcome": "fail",
|
|
701
|
+
"detail": (
|
|
702
|
+
f"вход обязан быть отвергнут, а принят и дал {produced!r}. "
|
|
703
|
+
"Принять невыразимое - значит разойтись с чужой реализацией "
|
|
704
|
+
"молча"
|
|
705
|
+
),
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
except FunoraError as error:
|
|
709
|
+
return {
|
|
710
|
+
"id": case_id,
|
|
711
|
+
"outcome": "fail",
|
|
712
|
+
"detail": f"{type(error).__name__}: {error}",
|
|
713
|
+
}
|
|
714
|
+
except Exception as error: # noqa: BLE001
|
|
715
|
+
# Своя поломка - тоже отказ, а не пропуск: пропуск означает объявленное
|
|
716
|
+
# неумение, а тут реализация просто сломалась.
|
|
717
|
+
return {
|
|
718
|
+
"id": case_id,
|
|
719
|
+
"outcome": "fail",
|
|
720
|
+
"detail": f"реализация упала: {type(error).__name__}: {error}",
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
# Неизвестный вид - отказ, а не пропуск. Он означает, что реализация
|
|
724
|
+
# отстала от набора, и молчать об этом нельзя.
|
|
725
|
+
return {
|
|
726
|
+
"id": case_id,
|
|
727
|
+
"outcome": "fail",
|
|
728
|
+
"detail": f"вид случая {kind!r} реализации неизвестен - она отстала от набора",
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def main() -> int:
|
|
733
|
+
"""Читает случаи с ввода и пишет ответы на вывод.
|
|
734
|
+
|
|
735
|
+
Кодировка потоков задаётся явно. По умолчанию Python берёт её у системы, и
|
|
736
|
+
на Windows это оказалась cp1251: раннер получал вопросительные знаки вместо
|
|
737
|
+
кириллицы и показывал отказ там, где реализация права. Транспорт, портящий
|
|
738
|
+
проверяемое, хуже отсутствия транспорта - протокол объявляет UTF-8, и
|
|
739
|
+
полагаться тут на настройки машины нельзя.
|
|
740
|
+
|
|
741
|
+
Returns:
|
|
742
|
+
int: Ноль всегда. Решение о коде возврата принимает раннер: он один
|
|
743
|
+
видит весь набор и отличает отказ от пропуска.
|
|
744
|
+
"""
|
|
745
|
+
sys.stdin.reconfigure(encoding="utf-8") # type: ignore[union-attr]
|
|
746
|
+
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr]
|
|
747
|
+
|
|
748
|
+
for line in sys.stdin:
|
|
749
|
+
stripped = line.strip()
|
|
750
|
+
if not stripped:
|
|
751
|
+
continue
|
|
752
|
+
sys.stdout.write(json.dumps(answer(json.loads(stripped)), ensure_ascii=False) + "\n")
|
|
753
|
+
sys.stdout.flush()
|
|
754
|
+
return 0
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
if __name__ == "__main__":
|
|
758
|
+
raise SystemExit(main())
|