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/_watch_state.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Непринятая партия и позиция, которую разрешено подтвердить после неё.
|
|
2
|
+
|
|
3
|
+
Партия записывается до вызова пользовательского кода. Повтор читает её, а не
|
|
4
|
+
площадку: новый снимок не может восстановить уже исчезнувшее изменение.
|
|
5
|
+
Вложенный ASCII JSON сохраняет непрозрачные позиции и payload без NFC;
|
|
6
|
+
внешний StateFile по-прежнему имеет каноническую форму.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from contextlib import ExitStack, contextmanager
|
|
14
|
+
from dataclasses import asdict, dataclass
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from threading import Lock
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from ._diff import Delivery, Event
|
|
21
|
+
from ._fileio import file_lock
|
|
22
|
+
from ._json import load_json
|
|
23
|
+
from ._monitoring import MarketWatch, validate_market_cursor, validate_market_payload
|
|
24
|
+
from ._watch import PRODUCIBLE
|
|
25
|
+
from .errors import ConfigurationError, CursorIncompatibleError, StateSchemaIncompatibleError
|
|
26
|
+
from .events import ORDERING_KEY, EventType
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@contextmanager
|
|
30
|
+
def watch_lease(lock: Lock, path: Path | None) -> Iterator[None]:
|
|
31
|
+
"""Один цикл владеет ядром и файлом; остальные получают отказ без ожидания."""
|
|
32
|
+
if not lock.acquire(blocking=False):
|
|
33
|
+
raise ConfigurationError("на этом ядре уже работает watch")
|
|
34
|
+
try:
|
|
35
|
+
with ExitStack() as stack:
|
|
36
|
+
if path is not None:
|
|
37
|
+
try:
|
|
38
|
+
stack.enter_context(
|
|
39
|
+
file_lock(path.with_suffix(path.suffix + ".watch.lock"), blocking=False)
|
|
40
|
+
)
|
|
41
|
+
except OSError as exc:
|
|
42
|
+
raise ConfigurationError(
|
|
43
|
+
"не удалось получить исключительное владение файлом watch"
|
|
44
|
+
) from exc
|
|
45
|
+
yield
|
|
46
|
+
finally:
|
|
47
|
+
lock.release()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _text(value: Any) -> bool:
|
|
51
|
+
return isinstance(value, str) and bool(value)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate_cursor(value: Any) -> dict[str, Any]:
|
|
55
|
+
"""Проверяет курсор партии до сети и вызова обработчиков."""
|
|
56
|
+
if isinstance(value, dict) and "market" in value:
|
|
57
|
+
return validate_market_cursor(value)
|
|
58
|
+
if not isinstance(value, dict) or set(value) != {
|
|
59
|
+
"orders",
|
|
60
|
+
"chats",
|
|
61
|
+
"threads",
|
|
62
|
+
"pending_threads",
|
|
63
|
+
}:
|
|
64
|
+
raise ValueError("неверный состав курсора партии")
|
|
65
|
+
for name in ("orders", "chats"):
|
|
66
|
+
entries = value[name]
|
|
67
|
+
if entries is not None and (
|
|
68
|
+
not isinstance(entries, dict)
|
|
69
|
+
or not all(_text(key) and isinstance(item, str) for key, item in entries.items())
|
|
70
|
+
):
|
|
71
|
+
raise ValueError("неверный курсор списка")
|
|
72
|
+
threads = value["threads"]
|
|
73
|
+
if not isinstance(threads, dict) or not all(
|
|
74
|
+
_text(key) and isinstance(ids, list) and all(_text(item) for item in ids)
|
|
75
|
+
for key, ids in threads.items()
|
|
76
|
+
):
|
|
77
|
+
raise ValueError("неверный курсор переписок")
|
|
78
|
+
pending = value["pending_threads"]
|
|
79
|
+
if not isinstance(pending, list) or not all(_text(item) for item in pending):
|
|
80
|
+
raise ValueError("неверная очередь переписок")
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class PendingBatch:
|
|
86
|
+
"""События, ещё не принятые обработчиками, и целевой курсор всей партии."""
|
|
87
|
+
|
|
88
|
+
events: tuple[Event, ...]
|
|
89
|
+
cursor: dict[str, Any]
|
|
90
|
+
greeted: bool
|
|
91
|
+
|
|
92
|
+
def encode(self) -> str:
|
|
93
|
+
"""Снимает независимую копию до передачи изменяемого payload обработчику."""
|
|
94
|
+
records = []
|
|
95
|
+
for event in self.events:
|
|
96
|
+
record = asdict(event)
|
|
97
|
+
record["observed_at"] = event.observed_at.isoformat()
|
|
98
|
+
records.append(record)
|
|
99
|
+
return json.dumps(
|
|
100
|
+
{"version": 1, "events": records, "cursor": self.cursor, "greeted": self.greeted},
|
|
101
|
+
ensure_ascii=True,
|
|
102
|
+
allow_nan=False,
|
|
103
|
+
sort_keys=True,
|
|
104
|
+
separators=(",", ":"),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def decode(cls, raw: Any, *, account_id: str) -> PendingBatch:
|
|
109
|
+
"""Отвергает повреждённый журнал вместо молчаливого нового чтения."""
|
|
110
|
+
try:
|
|
111
|
+
if not isinstance(raw, str) or not raw.isascii():
|
|
112
|
+
raise ValueError("журнал должен быть ASCII JSON")
|
|
113
|
+
value = load_json(raw)
|
|
114
|
+
if not isinstance(value, dict) or set(value) != {
|
|
115
|
+
"version",
|
|
116
|
+
"events",
|
|
117
|
+
"cursor",
|
|
118
|
+
"greeted",
|
|
119
|
+
}:
|
|
120
|
+
raise ValueError("неверный состав журнала")
|
|
121
|
+
if type(value["version"]) is not int or value["version"] != 1:
|
|
122
|
+
raise ValueError("неизвестная версия журнала")
|
|
123
|
+
if type(value["greeted"]) is not bool or not isinstance(value["events"], list):
|
|
124
|
+
raise ValueError("неверные поля журнала")
|
|
125
|
+
cursor = validate_cursor(value["cursor"])
|
|
126
|
+
events: list[Event] = []
|
|
127
|
+
seen: set[str] = set()
|
|
128
|
+
for record in value["events"]:
|
|
129
|
+
if not isinstance(record, dict) or set(record) != {
|
|
130
|
+
"id",
|
|
131
|
+
"type",
|
|
132
|
+
"account_id",
|
|
133
|
+
"ordering_key",
|
|
134
|
+
"entity_id",
|
|
135
|
+
"observed_at",
|
|
136
|
+
"origin",
|
|
137
|
+
"payload",
|
|
138
|
+
"delivery",
|
|
139
|
+
}:
|
|
140
|
+
raise ValueError("неверный состав события")
|
|
141
|
+
if not all(
|
|
142
|
+
_text(record[key])
|
|
143
|
+
for key in (
|
|
144
|
+
"id",
|
|
145
|
+
"type",
|
|
146
|
+
"account_id",
|
|
147
|
+
"ordering_key",
|
|
148
|
+
"entity_id",
|
|
149
|
+
"observed_at",
|
|
150
|
+
"origin",
|
|
151
|
+
)
|
|
152
|
+
):
|
|
153
|
+
raise ValueError("неверное поле события")
|
|
154
|
+
if record["account_id"] != account_id:
|
|
155
|
+
raise CursorIncompatibleError(
|
|
156
|
+
"непринятая партия принадлежит другому account_id"
|
|
157
|
+
)
|
|
158
|
+
kind = EventType(record["type"])
|
|
159
|
+
if kind not in PRODUCIBLE or record["id"] in seen:
|
|
160
|
+
raise ValueError("неизвестное либо повторное событие")
|
|
161
|
+
seen.add(record["id"])
|
|
162
|
+
key_value = record["entity_id"]
|
|
163
|
+
if "market" in cursor:
|
|
164
|
+
payload = record["payload"]
|
|
165
|
+
if (
|
|
166
|
+
not isinstance(payload, dict)
|
|
167
|
+
or payload.get("watch_id") not in cursor["market"]
|
|
168
|
+
):
|
|
169
|
+
raise ValueError("событие другого наблюдения")
|
|
170
|
+
key_value = payload["watch_id"]
|
|
171
|
+
market_kinds = {
|
|
172
|
+
EventType.MARKET_OFFER_APPEARED,
|
|
173
|
+
EventType.MARKET_OFFER_DISAPPEARED,
|
|
174
|
+
EventType.MARKET_PRICE_CHANGED,
|
|
175
|
+
}
|
|
176
|
+
if kind in market_kinds:
|
|
177
|
+
if payload.get("offer_id") != record["entity_id"]:
|
|
178
|
+
raise ValueError("событие другого предложения")
|
|
179
|
+
elif (
|
|
180
|
+
kind
|
|
181
|
+
not in {
|
|
182
|
+
EventType.WATCH_PRIMED,
|
|
183
|
+
EventType.SNAPSHOT_INCOMPLETE,
|
|
184
|
+
EventType.WATCH_DEGRADED,
|
|
185
|
+
}
|
|
186
|
+
or record["entity_id"] != key_value
|
|
187
|
+
):
|
|
188
|
+
raise ValueError("чужой вид события в партии рынка")
|
|
189
|
+
if kind in market_kinds or kind is EventType.WATCH_DEGRADED:
|
|
190
|
+
validate_market_payload(
|
|
191
|
+
kind, payload, MarketWatch(**cursor["market"][key_value]["config"])
|
|
192
|
+
)
|
|
193
|
+
elif kind in {
|
|
194
|
+
EventType.MARKET_OFFER_APPEARED,
|
|
195
|
+
EventType.MARKET_OFFER_DISAPPEARED,
|
|
196
|
+
EventType.MARKET_PRICE_CHANGED,
|
|
197
|
+
EventType.WATCH_DEGRADED,
|
|
198
|
+
}:
|
|
199
|
+
raise ValueError("рыночное событие в личной партии")
|
|
200
|
+
if record["ordering_key"] != ORDERING_KEY[kind].format(
|
|
201
|
+
**dict.fromkeys(
|
|
202
|
+
("order_id", "chat_id", "watch_id", "account_id"),
|
|
203
|
+
key_value,
|
|
204
|
+
)
|
|
205
|
+
):
|
|
206
|
+
raise ValueError("неверный ключ упорядочивания")
|
|
207
|
+
moment = datetime.fromisoformat(record["observed_at"])
|
|
208
|
+
if moment.tzinfo is None or moment.utcoffset() is None:
|
|
209
|
+
raise ValueError("момент события без часового пояса")
|
|
210
|
+
delivery = record["delivery"]
|
|
211
|
+
if not isinstance(delivery, dict) or set(delivery) != {"attempt", "coalesced"}:
|
|
212
|
+
raise ValueError("неверные метаданные доставки")
|
|
213
|
+
if (
|
|
214
|
+
type(delivery["attempt"]) is not int
|
|
215
|
+
or delivery["attempt"] < 0
|
|
216
|
+
or delivery["coalesced"] is not False
|
|
217
|
+
):
|
|
218
|
+
raise ValueError("неверный номер попытки либо сжатая партия")
|
|
219
|
+
if not isinstance(record["payload"], dict):
|
|
220
|
+
raise ValueError("нагрузка должна быть объектом")
|
|
221
|
+
# Запрещены NaN/Infinity и другие непереносимые значения JSON.
|
|
222
|
+
json.dumps(record["payload"], allow_nan=False)
|
|
223
|
+
events.append(
|
|
224
|
+
Event(
|
|
225
|
+
**{
|
|
226
|
+
**record,
|
|
227
|
+
"type": kind,
|
|
228
|
+
"observed_at": moment,
|
|
229
|
+
"delivery": Delivery(**delivery),
|
|
230
|
+
}
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
return cls(tuple(events), cursor, value["greeted"])
|
|
234
|
+
except (ValueError, TypeError, KeyError, OverflowError, RecursionError) as exc:
|
|
235
|
+
raise StateSchemaIncompatibleError(
|
|
236
|
+
"непринятая партия watch повреждена; её нельзя заменить новым снимком"
|
|
237
|
+
) from exc
|