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.
Files changed (87) hide show
  1. funora/__init__.py +265 -0
  2. funora/_account.py +661 -0
  3. funora/_aclient.py +1484 -0
  4. funora/_budget.py +579 -0
  5. funora/_calc.py +182 -0
  6. funora/_canonical.py +235 -0
  7. funora/_catalog.py +473 -0
  8. funora/_chat_history.py +367 -0
  9. funora/_chats.py +392 -0
  10. funora/_chips.py +403 -0
  11. funora/_classify.py +466 -0
  12. funora/_client.py +1490 -0
  13. funora/_currency_switch.py +130 -0
  14. funora/_cursor.py +120 -0
  15. funora/_delivered.py +233 -0
  16. funora/_diff.py +752 -0
  17. funora/_engine.py +4795 -0
  18. funora/_extract.py +124 -0
  19. funora/_field_schema.py +112 -0
  20. funora/_fileio.py +58 -0
  21. funora/_gate.py +69 -0
  22. funora/_hops.py +101 -0
  23. funora/_host.py +120 -0
  24. funora/_identity.py +284 -0
  25. funora/_json.py +31 -0
  26. funora/_listen.py +312 -0
  27. funora/_lot_form.py +331 -0
  28. funora/_market.py +406 -0
  29. funora/_matching.py +147 -0
  30. funora/_money.py +279 -0
  31. funora/_monitoring.py +396 -0
  32. funora/_observed.py +237 -0
  33. funora/_order.py +552 -0
  34. funora/_order_details.py +281 -0
  35. funora/_orders.py +807 -0
  36. funora/_outbound.py +453 -0
  37. funora/_own_lots.py +301 -0
  38. funora/_poll.py +410 -0
  39. funora/_price_audit.py +281 -0
  40. funora/_proxies.py +232 -0
  41. funora/_raise.py +150 -0
  42. funora/_refund.py +102 -0
  43. funora/_result.py +157 -0
  44. funora/_retry.py +213 -0
  45. funora/_review_write.py +138 -0
  46. funora/_reviews.py +584 -0
  47. funora/_runner.py +651 -0
  48. funora/_secret.py +385 -0
  49. funora/_showcase.py +362 -0
  50. funora/_signals.py +375 -0
  51. funora/_skeleton.py +752 -0
  52. funora/_snapshot.py +276 -0
  53. funora/_state.py +279 -0
  54. funora/_stock.py +32 -0
  55. funora/_thread.py +574 -0
  56. funora/_transport.py +1023 -0
  57. funora/_updates.py +292 -0
  58. funora/_verdicts.py +91 -0
  59. funora/_viewing.py +143 -0
  60. funora/_watch.py +825 -0
  61. funora/_watch_state.py +237 -0
  62. funora/_whoami.py +546 -0
  63. funora/bot/__init__.py +52 -0
  64. funora/bot/_delivery.py +341 -0
  65. funora/bot/_outbox.py +261 -0
  66. funora/bot/_runtime.py +447 -0
  67. funora/bot/_spool.py +534 -0
  68. funora/budget.py +311 -0
  69. funora/capabilities.py +297 -0
  70. funora/conformance.py +758 -0
  71. funora/contract.py +73 -0
  72. funora/errors.py +996 -0
  73. funora/events.py +189 -0
  74. funora/extraction.py +420 -0
  75. funora/observe.py +560 -0
  76. funora/operations.py +671 -0
  77. funora/py.typed +0 -0
  78. funora/reconciliation.py +51 -0
  79. funora/response_classes.py +155 -0
  80. funora/retry.py +238 -0
  81. funora/send_outcome.py +78 -0
  82. funora/skeleton_format.py +77 -0
  83. funora-0.0.1.dev2.dist-info/METADATA +294 -0
  84. funora-0.0.1.dev2.dist-info/RECORD +87 -0
  85. funora-0.0.1.dev2.dist-info/WHEEL +4 -0
  86. funora-0.0.1.dev2.dist-info/entry_points.txt +2 -0
  87. funora-0.0.1.dev2.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,51 @@
1
+ r"""Сверка после неоднозначного исхода записи.
2
+
3
+ Файл порождён из спецификации, править его руками нельзя: правка исчезнет при
4
+ следующей сборке. Источник - spec/protocol/reconciliation.yaml в репозитории Funora-spec.
5
+ Перестроить: .venv\Scripts\python.exe tools/codegen.py
6
+
7
+ Сверка - ПРОЦЕДУРА НАБЛЮДЕНИЯ, а не петля управления. Она отвечает на
8
+ вопрос «что вышло из отправки» и ничего не предпринимает.
9
+
10
+ СВЕРКА НИКОГДА НЕ ОТПРАВЛЯЕТ ПОВТОРНО. Решение принимает вызывающий, и
11
+ только он: у отправленного сообщения нет отмены, и автоматика,
12
+ решающая за человека, однажды напишет покупателю дважды.
13
+
14
+ Вердикта отрицания здесь НЕТ. Отсутствие сообщения в истории -
15
+ свидетельство отрицательное, и объявлять по нему «не отправлено»
16
+ значило бы подтолкнуть вызывающего написать второй раз.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from enum import StrEnum
22
+ from typing import Final
23
+
24
+ __all__ = ["ReconcileVerdict", "RECONCILE_READS", "RECONCILE_DELAYS_MS"]
25
+
26
+
27
+ class ReconcileVerdict(StrEnum):
28
+ """Чем окончилась сверка.
29
+
30
+ delivered: Своё новое сообщение найдено в истории. Положительное свидетельство.
31
+
32
+ absent_from_history: Чтение полное, признак себя показал себя, искомого сообщения
33
+ нет.
34
+
35
+ undetermined: Сверка ответа не дала. Честное незнание.
36
+
37
+ """
38
+
39
+ DELIVERED = "delivered"
40
+ ABSENT_FROM_HISTORY = "absent_from_history"
41
+ UNDETERMINED = "undetermined"
42
+
43
+
44
+ #: Сколько раз перечитывать историю.
45
+ RECONCILE_READS: Final[int] = 3
46
+
47
+ #: Паузы перед каждым чтением, миллисекунды.
48
+ #:
49
+ #: Числа ВЫБРАНЫ, а не наблюдены: ни одной записанной отправки не
50
+ #: пришлось сверять чтением - все подтверждались самим ответом канала.
51
+ RECONCILE_DELAYS_MS: Final[tuple[int, ...]] = (1000, 3000, 8000)
@@ -0,0 +1,155 @@
1
+ r"""Соответствие вердиктов классификатора ошибкам.
2
+
3
+ Файл порождён из спецификации, править его руками нельзя: правка исчезнет при
4
+ следующей сборке. Источник - spec/protocol/response-classes.yaml в репозитории Funora-spec.
5
+ Перестроить: .venv\Scripts\python.exe tools/codegen.py
6
+
7
+ Ключ - пара из класса ответа и машиночитаемой причины. Значение - класс
8
+ ошибки либо None, если ответ пригоден для разбора.
9
+
10
+ Таблица порождается, а не пишется, потому что от неё зависит, повторит
11
+ клиент запрос или остановится навсегда. Шесть реализаций, составивших её
12
+ порознь, разойдутся именно на негативных ветках - там, где расхождение
13
+ дороже всего и заметно позже всего.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from enum import StrEnum
19
+ from typing import Final
20
+
21
+ from .errors import (
22
+ AccessBlockedError,
23
+ ChallengeRequiredError,
24
+ NetworkError,
25
+ ProtocolChangedError,
26
+ RateLimitedError,
27
+ RemoteServerError,
28
+ SessionExpiredError,
29
+ UnexpectedResponseError,
30
+ )
31
+
32
+ __all__ = [
33
+ "VERDICT_ERRORS",
34
+ "RESPONSE_CLASSES",
35
+ "STATUS_CLASS",
36
+ "Health",
37
+ "INITIAL_HEALTH",
38
+ "HEALTH_BY_VERDICT",
39
+ "WRITES_PAUSED_IN",
40
+ ]
41
+
42
+ #: Классы ответа, объявленные спецификацией.
43
+ #:
44
+ #: Перечень нужен, чтобы проверить полноту таблицы: класс без единой
45
+ #: записи означает, что реализации выберут ошибку сами.
46
+ RESPONSE_CLASSES: Final[frozenset[str]] = frozenset(
47
+ {
48
+ "ok",
49
+ "login_required",
50
+ "challenge",
51
+ "blocked",
52
+ "rate_limited",
53
+ "maintenance",
54
+ "wrong_identity",
55
+ "transport_error",
56
+ "unknown",
57
+ }
58
+ )
59
+
60
+ #: Класс ответа по коду, когда тело разбирать бессмысленно.
61
+ #:
62
+ #: Прежде таблица жила рукописной копией в классификаторе. Правка
63
+ #: спецификации не давала ни одного признака: сборка зелёная,
64
+ #: спецификация зелёная, а расхождение обнаружилось бы в работе - и
65
+ #: ровно на той ошибке, от которой спецификация предостерегает
66
+ #: отдельным разделом: код 429, принятый за блокировку, навсегда
67
+ #: останавливает опрос.
68
+ STATUS_CLASS: Final[dict[int, str]] = {
69
+ 401: "login_required",
70
+ 403: "blocked",
71
+ 429: "rate_limited",
72
+ 503: "maintenance",
73
+ }
74
+
75
+ #: Пара «класс ответа, причина» и ошибка, которую она означает.
76
+ VERDICT_ERRORS: Final[dict[tuple[str, str], type[Exception] | None]] = {
77
+ ("ok", "identity_confirmed"): None,
78
+ ("ok", "no_signature_matched_identity_unchecked"): None,
79
+ ("login_required", "http_401"): SessionExpiredError,
80
+ ("login_required", "signature:guest_navbar"): SessionExpiredError,
81
+ ("login_required", "signature:login_page"): SessionExpiredError,
82
+ ("login_required", "signature:login_form"): SessionExpiredError,
83
+ ("challenge", "signature:challenge_widget"): ChallengeRequiredError,
84
+ ("challenge", "signature:challenge_text"): ChallengeRequiredError,
85
+ ("blocked", "http_403"): AccessBlockedError,
86
+ ("blocked", "signature:blocked_text"): AccessBlockedError,
87
+ ("rate_limited", "http_429"): RateLimitedError,
88
+ ("maintenance", "http_503"): RemoteServerError,
89
+ ("maintenance", "signature:maintenance_text"): RemoteServerError,
90
+ ("wrong_identity", "host_mismatch"): UnexpectedResponseError,
91
+ ("wrong_identity", "host_unreadable"): UnexpectedResponseError,
92
+ ("transport_error", "http_5xx"): RemoteServerError,
93
+ ("transport_error", "http_4xx"): UnexpectedResponseError,
94
+ ("transport_error", "body_truncated"): NetworkError,
95
+ ("unknown", "empty_body"): UnexpectedResponseError,
96
+ ("unknown", "identity_marker_absent"): ProtocolChangedError,
97
+ ("unknown", "body_unparsable"): UnexpectedResponseError,
98
+ }
99
+
100
+
101
+ class Health(StrEnum):
102
+ """Состояние доступа к площадке.
103
+
104
+ От него зависит, приостановлена ли автоматика записи. Перечень
105
+ объявлен схемой события protocol.health_changed и повторён в
106
+ spec/protocol/response-classes.yaml вместе с правилами перехода.
107
+ """
108
+
109
+ AUTHENTICATED = "authenticated"
110
+ DEGRADED = "degraded"
111
+ PROTOCOL_CHANGED = "protocol_changed"
112
+ RATE_LIMITED = "rate_limited"
113
+ CHALLENGED = "challenged"
114
+ BLOCKED = "blocked"
115
+
116
+
117
+ #: Начальное состояние.
118
+ #:
119
+ #: До первого ответа состояние не проверяется: клиент не знает о
120
+ #: площадке ничего, пока не сходил.
121
+ INITIAL_HEALTH: Final[Health] = Health.AUTHENTICATED
122
+
123
+ #: В какое состояние переводит класс ответа.
124
+ #:
125
+ #: None означает «состояние не меняется». Сетевой отказ и
126
+ #: неопознанный ответ говорят о нас и о дороге, а не о том, как
127
+ #: площадка к нам относится: менять по ним состояние доступа значило
128
+ #: бы объявлять аккаунт ограниченным из-за оборванного соединения.
129
+ HEALTH_BY_VERDICT: Final[dict[str, Health | None]] = {
130
+ "ok": Health.AUTHENTICATED,
131
+ "login_required": Health.DEGRADED,
132
+ "challenge": Health.CHALLENGED,
133
+ "blocked": Health.BLOCKED,
134
+ "rate_limited": Health.RATE_LIMITED,
135
+ "maintenance": Health.DEGRADED,
136
+ "wrong_identity": Health.DEGRADED,
137
+ "transport_error": None,
138
+ "unknown": None,
139
+ }
140
+
141
+ #: Состояния, в которых автоматика записи приостановлена.
142
+ #:
143
+ #: Возобновление - только явным действием пользователя либо
144
+ #: возвратом в начальное состояние по успешному ответу. Сама по себе
145
+ #: пауза не истекает: истекающая означала бы, что клиент снова пишет
146
+ #: на площадку, которая только что отказала, и не спросил никого.
147
+ WRITES_PAUSED_IN: Final[frozenset[Health]] = frozenset(
148
+ {
149
+ Health.DEGRADED,
150
+ Health.PROTOCOL_CHANGED,
151
+ Health.RATE_LIMITED,
152
+ Health.CHALLENGED,
153
+ Health.BLOCKED,
154
+ }
155
+ )
funora/retry.py ADDED
@@ -0,0 +1,238 @@
1
+ r"""Политики повторов.
2
+
3
+ Файл порождён из спецификации, править его руками нельзя: правка исчезнет при
4
+ следующей сборке. Источник - spec/protocol/retry-policy.yaml в репозитории Funora-spec.
5
+ Перестроить: .venv\Scripts\python.exe tools/codegen.py
6
+
7
+ Числа не выбираются реализацией. Отступление, синхронизированное между
8
+ шестью SDK, превращается в согласованную волну запросов, и площадка
9
+ видит не шесть вежливых клиентов, а один невежливый.
10
+
11
+ Запасная политика намеренно строже конкретных: неизвестный класс отказа
12
+ не повод быть смелее.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from enum import StrEnum
19
+ from typing import Final
20
+
21
+ __all__ = [
22
+ "RetryPolicy",
23
+ "RETRY_POLICIES",
24
+ "FALLBACK_POLICY",
25
+ "GLOBAL_MAX_ATTEMPTS",
26
+ "RetryDecision",
27
+ "DECISION_MATRIX",
28
+ ]
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class RetryPolicy:
33
+ """Политика повторов для одного класса ошибки.
34
+
35
+ Attributes:
36
+ stable_id (str): Устойчивый идентификатор класса ошибки.
37
+ max_attempts (int): Сколько попыток допустимо всего, включая первую.
38
+ base_ms (int): Основа задержки, миллисекунды.
39
+ multiplier (float): Во сколько раз растёт задержка.
40
+ cap_ms (int): Потолок задержки, миллисекунды.
41
+ jitter (str): Вид разброса.
42
+ respect_retry_after (bool): Уважать ли заголовок Retry-After.
43
+ max_retry_after_ms (int): Верхняя граница уважения заголовка.
44
+ fail_closed (bool): Останавливать ли работу до вмешательства.
45
+ account_scoped (bool): Действует ли ограничение на весь аккаунт.
46
+ """
47
+
48
+ stable_id: str
49
+ max_attempts: int
50
+ base_ms: int
51
+ multiplier: float
52
+ cap_ms: int
53
+ jitter: str
54
+ respect_retry_after: bool
55
+ max_retry_after_ms: int
56
+ fail_closed: bool
57
+ account_scoped: bool
58
+
59
+
60
+ #: Политика по устойчивому идентификатору класса ошибки.
61
+ RETRY_POLICIES: Final[dict[str, RetryPolicy]] = {
62
+ "funora.transport": RetryPolicy(
63
+ stable_id="funora.transport",
64
+ max_attempts=2,
65
+ base_ms=1000,
66
+ multiplier=2.0,
67
+ cap_ms=15000,
68
+ jitter="full",
69
+ respect_retry_after=True,
70
+ max_retry_after_ms=300000,
71
+ fail_closed=False,
72
+ account_scoped=False,
73
+ ),
74
+ "funora.transport.timeout": RetryPolicy(
75
+ stable_id="funora.transport.timeout",
76
+ max_attempts=3,
77
+ base_ms=1000,
78
+ multiplier=2.0,
79
+ cap_ms=30000,
80
+ jitter="full",
81
+ respect_retry_after=False,
82
+ max_retry_after_ms=300000,
83
+ fail_closed=False,
84
+ account_scoped=False,
85
+ ),
86
+ "funora.transport.network": RetryPolicy(
87
+ stable_id="funora.transport.network",
88
+ max_attempts=3,
89
+ base_ms=500,
90
+ multiplier=2.0,
91
+ cap_ms=30000,
92
+ jitter="full",
93
+ respect_retry_after=False,
94
+ max_retry_after_ms=300000,
95
+ fail_closed=False,
96
+ account_scoped=False,
97
+ ),
98
+ "funora.transport.rate_limited": RetryPolicy(
99
+ stable_id="funora.transport.rate_limited",
100
+ max_attempts=2,
101
+ base_ms=5000,
102
+ multiplier=3.0,
103
+ cap_ms=120000,
104
+ jitter="full",
105
+ respect_retry_after=True,
106
+ max_retry_after_ms=300000,
107
+ fail_closed=False,
108
+ account_scoped=True,
109
+ ),
110
+ "funora.transport.remote_server": RetryPolicy(
111
+ stable_id="funora.transport.remote_server",
112
+ max_attempts=3,
113
+ base_ms=2000,
114
+ multiplier=2.0,
115
+ cap_ms=60000,
116
+ jitter="full",
117
+ respect_retry_after=True,
118
+ max_retry_after_ms=300000,
119
+ fail_closed=False,
120
+ account_scoped=False,
121
+ ),
122
+ "funora.auth.invalid_credentials": RetryPolicy(
123
+ stable_id="funora.auth.invalid_credentials",
124
+ max_attempts=1,
125
+ base_ms=0,
126
+ multiplier=1.0,
127
+ cap_ms=0,
128
+ jitter="full",
129
+ respect_retry_after=False,
130
+ max_retry_after_ms=300000,
131
+ fail_closed=False,
132
+ account_scoped=False,
133
+ ),
134
+ "funora.auth.session_expired": RetryPolicy(
135
+ stable_id="funora.auth.session_expired",
136
+ max_attempts=1,
137
+ base_ms=0,
138
+ multiplier=1.0,
139
+ cap_ms=0,
140
+ jitter="full",
141
+ respect_retry_after=False,
142
+ max_retry_after_ms=300000,
143
+ fail_closed=False,
144
+ account_scoped=False,
145
+ ),
146
+ "funora.auth.access_blocked": RetryPolicy(
147
+ stable_id="funora.auth.access_blocked",
148
+ max_attempts=1,
149
+ base_ms=0,
150
+ multiplier=1.0,
151
+ cap_ms=0,
152
+ jitter="full",
153
+ respect_retry_after=False,
154
+ max_retry_after_ms=300000,
155
+ fail_closed=True,
156
+ account_scoped=False,
157
+ ),
158
+ "funora.auth.challenge_required": RetryPolicy(
159
+ stable_id="funora.auth.challenge_required",
160
+ max_attempts=1,
161
+ base_ms=0,
162
+ multiplier=1.0,
163
+ cap_ms=0,
164
+ jitter="full",
165
+ respect_retry_after=False,
166
+ max_retry_after_ms=300000,
167
+ fail_closed=True,
168
+ account_scoped=False,
169
+ ),
170
+ "funora.protocol.changed": RetryPolicy(
171
+ stable_id="funora.protocol.changed",
172
+ max_attempts=1,
173
+ base_ms=0,
174
+ multiplier=1.0,
175
+ cap_ms=0,
176
+ jitter="full",
177
+ respect_retry_after=False,
178
+ max_retry_after_ms=300000,
179
+ fail_closed=False,
180
+ account_scoped=False,
181
+ ),
182
+ "funora.budget.exhausted": RetryPolicy(
183
+ stable_id="funora.budget.exhausted",
184
+ max_attempts=1,
185
+ base_ms=0,
186
+ multiplier=1.0,
187
+ cap_ms=0,
188
+ jitter="full",
189
+ respect_retry_after=False,
190
+ max_retry_after_ms=300000,
191
+ fail_closed=False,
192
+ account_scoped=False,
193
+ ),
194
+ }
195
+
196
+ #: Политика для классов ошибок без собственной записи.
197
+ #:
198
+ #: Строже конкретных намеренно: неизвестный класс отказа не повод быть
199
+ #: смелее. Реализация, подставляющая здесь самую щедрую политику,
200
+ #: получает самое агрессивное поведение как раз тогда, когда меньше
201
+ #: всего понимает происходящее.
202
+ FALLBACK_POLICY: Final[RetryPolicy] = RETRY_POLICIES["funora.transport"]
203
+
204
+ #: Потолок числа попыток независимо от политики класса ошибки.
205
+ GLOBAL_MAX_ATTEMPTS: Final[int] = 5
206
+
207
+
208
+ class RetryDecision(StrEnum):
209
+ """Что матрица говорит о повторе.
210
+
211
+ Решение о повторе - пересечение класса ошибки и безопасности
212
+ операции. Реализация, сводящая матрицу к «повторяем только
213
+ чтения», строже контракта: это безопасно, но расходится -
214
+ второй SDK на той же трассе поступит иначе.
215
+ """
216
+
217
+ NEVER = "never"
218
+ ALLOWED = "allowed"
219
+ ALLOWED_WITH_KEY = "allowed_with_key"
220
+ RECONCILE_FIRST = "reconcile_first"
221
+
222
+
223
+ #: Строки матрицы решения о повторе, в порядке спецификации.
224
+ #:
225
+ #: Порядок значим: строки читаются сверху вниз, и первая подошедшая
226
+ #: решает. Первая строка отсекает неповторяемый класс ошибки
227
+ #: независимо от операции.
228
+ #:
229
+ #: Кортеж: повторяем ли класс ошибки; безопасность операции либо
230
+ #: None, если строка о любой; возможен ли побочный эффект либо None,
231
+ #: если неважно; решение.
232
+ DECISION_MATRIX: Final[tuple[tuple[bool, str | None, bool | None, RetryDecision], ...]] = (
233
+ (False, None, None, RetryDecision.NEVER),
234
+ (True, "safe", None, RetryDecision.ALLOWED),
235
+ (True, "idempotent", None, RetryDecision.ALLOWED_WITH_KEY),
236
+ (True, "unsafe", True, RetryDecision.RECONCILE_FIRST),
237
+ (True, "unsafe", False, RetryDecision.ALLOWED),
238
+ )
funora/send_outcome.py ADDED
@@ -0,0 +1,78 @@
1
+ r"""Исход обращения к каналу обновлений с действием.
2
+
3
+ Файл порождён из спецификации, править его руками нельзя: правка исчезнет при
4
+ следующей сборке. Источник - spec/protocol/send-outcome.yaml в репозитории Funora-spec.
5
+ Перестроить: .venv\Scripts\python.exe tools/codegen.py
6
+
7
+ Три исхода, а не два. Третий - unconfirmed - честное незнание, а не
8
+ третий род неудачи: отправка могла состояться и могла не состояться.
9
+
10
+ Двузначный ответ вынудил бы реализацию выбрать одно из двух зол.
11
+ Объявить успехом всё, что вернулось с кодом 200, значит однажды сказать
12
+ «отправлено» о неотправленном. Объявить неудачей значит однажды
13
+ отправить второй раз, а отменить сообщение покупателю нельзя.
14
+
15
+ Порядок шагов нормативен и объявлен в спецификации. Здесь он
16
+ порождается перечнем, чтобы проверка могла пройти по нему, а не по
17
+ прозе.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from enum import StrEnum
23
+ from typing import Final
24
+
25
+ __all__ = ["SendOutcome", "SEND_REASONS", "SEND_PIPELINE"]
26
+
27
+
28
+ class SendOutcome(StrEnum):
29
+ """Чем окончилось обращение к каналу с действием.
30
+
31
+ confirmed: Площадка вернула сообщение в ответе. Отправка состоялась, и это
32
+ ПОЛОЖИТЕЛЬНОЕ свидетельство, а не отсутствие отказа.
33
+
34
+ refused: Площадка сообщила об отказе. Сообщение не отправлено.
35
+
36
+ unconfirmed: Ответ получен, но подтверждения в нём нет. Отправка могла состояться и
37
+ могла не состояться, и вызывающему сказано именно это.
38
+
39
+ """
40
+
41
+ CONFIRMED = "confirmed"
42
+ REFUSED = "refused"
43
+ UNCONFIRMED = "unconfirmed"
44
+
45
+
46
+ #: Причины решения. Машиночитаемы и закрыты.
47
+ SEND_REASONS: Final[dict[str, str]] = {
48
+ "transport_error": "Ответ после отправки не получен; запрос мог быть исполнен.",
49
+ "unexpected_http_status": "HTTP-статус ответа не подтверждает исполнение действия.",
50
+ "confirmed_by_channel": "Ответ несёт новое сообщение в том самом диалоге.",
51
+ "channel_reported_error": "Поле error непусто. Что именно в нём - неизвестно.",
52
+ "body_not_json": "Тело ответа не разобралось.",
53
+ "body_not_an_object": "Разобранное - не объект.",
54
+ "response_not_an_object": "Поле response не объект, а значит ответ не на действие.",
55
+ "response_error_missing": "В объекте response отсутствует обязательное поле error.",
56
+ "no_chat_node_in_answer": "Среди объектов ответа нет узла диалога.",
57
+ "node_mismatch": "Узел диалога в ответе - не тот, в который отправляли.",
58
+ "empty_message_list": "Узел диалога есть, новых сообщений в нём нет.",
59
+ }
60
+
61
+ #: Порядок шагов. НОРМАТИВЕН: две реализации, проверившие условия
62
+ #: в разном порядке, разойдутся ровно на том ответе, ради которого
63
+ #: правило написано.
64
+ #:
65
+ #: Каждый шаг - имя, исход при непрохождении и причина.
66
+ SEND_PIPELINE: Final[tuple[tuple[str, str, str], ...]] = (
67
+ ("Ответ транспорта получен", "unconfirmed", "transport_error"),
68
+ ("HTTP-статус ответа допускает подтверждение", "unconfirmed", "unexpected_http_status"),
69
+ ("Тело разбирается как JSON", "unconfirmed", "body_not_json"),
70
+ ("Разобранное - объект", "unconfirmed", "body_not_an_object"),
71
+ ("Поле response - объект", "unconfirmed", "response_not_an_object"),
72
+ ("В объекте response есть поле error", "unconfirmed", "response_error_missing"),
73
+ ("Поле error пусто", "refused", "channel_reported_error"),
74
+ ("Среди объектов есть узел диалога", "unconfirmed", "no_chat_node_in_answer"),
75
+ ("Узел диалога - тот самый", "unconfirmed", "node_mismatch"),
76
+ ("Список сообщений непуст", "unconfirmed", "empty_message_list"),
77
+ ("Подтверждено", "confirmed", "confirmed_by_channel"),
78
+ )
@@ -0,0 +1,77 @@
1
+ r"""Формат структурного скелета страницы.
2
+
3
+ Файл порождён из спецификации, править его руками нельзя: правка исчезнет при
4
+ следующей сборке. Источник - spec/conformance/skeleton-format.yaml в репозитории Funora-spec.
5
+ Перестроить: .venv\Scripts\python.exe tools/codegen.py
6
+
7
+ Формат снимков страниц. Снимки - общая проверочная база: по ним
8
+ сверяется, что объявленный селектор вправду присутствует на
9
+ наблюдённой странице.
10
+
11
+ Версия формата и набор классов знаков порождаются, а не пишутся здесь.
12
+ Прежде версия была литералом в _skeleton.py, а описание формата жило
13
+ только в README фикстур эталонной реализации: вторая реализация не
14
+ могла ни построить такой же скелет, ни проверить, что построила.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Final
20
+
21
+ __all__ = [
22
+ "SKELETON_FORMAT",
23
+ "ACCEPTED_SKELETON_FORMATS",
24
+ "NUMBERED_SKELETON_FORMATS",
25
+ "CHARACTER_CLASSES",
26
+ ]
27
+
28
+
29
+ #: Версия формата, которой снимаются новые скелеты.
30
+ SKELETON_FORMAT: Final[str] = "structural-skeleton-v9"
31
+
32
+ #: Версии, которые разрешено читать.
33
+ #:
34
+ #: Старые версии принимаются, а не отвергаются: снимок стоит
35
+ #: живого запроса под сессией, и переснять его бывает нечем -
36
+ #: истечение сессии воспроизводится не по желанию.
37
+ ACCEPTED_SKELETON_FORMATS: Final[frozenset[str]] = frozenset(
38
+ {
39
+ "structural-skeleton-v3",
40
+ "structural-skeleton-v4",
41
+ "structural-skeleton-v5",
42
+ "structural-skeleton-v6",
43
+ "structural-skeleton-v7",
44
+ "structural-skeleton-v8",
45
+ "structural-skeleton-v9",
46
+ }
47
+ )
48
+
49
+ #: Версии, в которых идентификаторы различимы между собой.
50
+ #:
51
+ #: Пока они схлопывались в одну подпись, всякая проверка курсора,
52
+ #: гашения и порождения событий проходила впустую и выглядела при
53
+ #: этом пройденной. Требовать различимости от снимка версии v3
54
+ #: нечестно - восстановить её он не может, - а от прочих обязательно.
55
+ NUMBERED_SKELETON_FORMATS: Final[frozenset[str]] = frozenset(
56
+ {
57
+ "structural-skeleton-v4",
58
+ "structural-skeleton-v5",
59
+ "structural-skeleton-v6",
60
+ "structural-skeleton-v7",
61
+ "structural-skeleton-v8",
62
+ "structural-skeleton-v9",
63
+ }
64
+ )
65
+
66
+ #: Классы знаков, из которых складывается подпись текста.
67
+ #:
68
+ #: Две реализации с разными наборами дадут разные подписи одному
69
+ #: тексту, и снимок одной перестанет годиться другой.
70
+ CHARACTER_CLASSES: Final[dict[str, str]] = {
71
+ "a": "Латиница любого регистра.",
72
+ "c": "Кириллица любого регистра.",
73
+ "d": "Цифры.",
74
+ "o": "Всё прочее.",
75
+ "p": "Пунктуация ASCII.",
76
+ "s": "Пробельные знаки.",
77
+ }