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
funora/_secret.py ADDED
@@ -0,0 +1,385 @@
1
+ """Работа с сессионными секретами.
2
+
3
+ Модуль намеренно написан раньше транспорта. Сессионный ключ FunPay - это не токен
4
+ с ограниченными правами, а доступ ко всему аккаунту: кто им владеет, тот читает
5
+ переписку, видит заказы и действует от имени продавца. Если сначала появится
6
+ транспорт, ключ успеет протечь в отладочный вывод HTTP-клиента раньше, чем
7
+ появится тип, который его защищает.
8
+
9
+ Что этот модуль гарантирует:
10
+ * значение не попадает в repr, str, format и текст исключений;
11
+ * значение не сериализуется ни json, ни pickle, ни copy;
12
+ * получить значение можно только явным вызовом reveal().
13
+
14
+ Чего он не гарантирует и не может: сторонний HTTP-клиент с включённым отладочным
15
+ логированием, APM-агент и плагин внутри процесса прочитают ключ в момент отправки
16
+ запроса. Граница защиты проходит по краю этого проекта, и делать вид, что она
17
+ шире, было бы обманом.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ from collections.abc import Callable
24
+ from pathlib import Path
25
+ from typing import Any, Final, Protocol, runtime_checkable
26
+
27
+ from .errors import ConfigurationError
28
+
29
+ __all__ = [
30
+ "Secret",
31
+ "SecretProvider",
32
+ "EnvSecretProvider",
33
+ "CallableSecretProvider",
34
+ "FileSecretProvider",
35
+ "SecretNotFoundError",
36
+ ]
37
+
38
+ #: Текст, который выводится вместо значения во всех строковых представлениях.
39
+ _MASK: Final[str] = "Secret(<redacted>)"
40
+
41
+
42
+ class SecretNotFoundError(ConfigurationError):
43
+ """Источник не смог предоставить секрет.
44
+
45
+ Отдельный тип нужен, чтобы отличать отсутствие ключа в конфигурации от
46
+ отказа площадки принять существующий ключ: пользователю в этих случаях надо
47
+ делать разное.
48
+
49
+ Наследуется от ConfigurationError, а не от встроенной ошибки языка. Прежде
50
+ здесь стоял RuntimeError, и отсутствие ключа НЕ ловилось общим перехватом
51
+ FunoraError: бот, обернувший сборку клиента в except FunoraError, падал мимо
52
+ собственного обработчика. Пропавшая переменная окружения - обычное дело при
53
+ переносе на другую машину, и падать на ней всем процессом слишком дорого.
54
+
55
+ Класс отказа тот же, что у прочих ошибок сборки: повтор не поможет,
56
+ исправляется вызывающим.
57
+ """
58
+
59
+
60
+ class Secret:
61
+ """Обёртка над сессионным секретом, не раскрывающая значение при выводе.
62
+
63
+ Все строковые представления возвращают маску. Значение доступно только через
64
+ reveal(), и такой вызов видно при чтении кода - в отличие от неявной
65
+ подстановки в f-строку.
66
+
67
+ Args:
68
+ value (str): Значение секрета. Пустая строка недопустима.
69
+ label (str): Короткая метка для диагностики, например ``golden_key``.
70
+ Попадает в вывод repr вместо значения.
71
+
72
+ Raises:
73
+ ValueError: Если значение пустое или состоит из пробельных символов.
74
+ """
75
+
76
+ __slots__ = ("_value", "_label")
77
+
78
+ def __init__(self, value: str, label: str = "secret") -> None:
79
+ if not value or not value.strip():
80
+ raise ValueError("секрет не может быть пустым")
81
+ self._value = value
82
+ self._label = label
83
+
84
+ def reveal(self) -> str:
85
+ """Возвращает значение секрета.
86
+
87
+ Единственный способ получить значение. Вызов намеренно назван так, чтобы
88
+ он бросался в глаза при чтении кода и при поиске по репозиторию.
89
+
90
+ Returns:
91
+ str: Значение секрета.
92
+ """
93
+ return self._value
94
+
95
+ @property
96
+ def label(self) -> str:
97
+ """Метка секрета для диагностики.
98
+
99
+ Returns:
100
+ str: Метка, переданная при создании. Значения не содержит.
101
+ """
102
+ return self._label
103
+
104
+ def __repr__(self) -> str:
105
+ """Возвращает безопасное представление для отладки.
106
+
107
+ Returns:
108
+ str: Маска с меткой, без значения.
109
+ """
110
+ return f"Secret({self._label}=<redacted>)"
111
+
112
+ def __str__(self) -> str:
113
+ """Возвращает безопасное строковое представление.
114
+
115
+ Returns:
116
+ str: Маска без значения.
117
+ """
118
+ return _MASK
119
+
120
+ def __format__(self, spec: str) -> str:
121
+ """Возвращает маску при подстановке в f-строку.
122
+
123
+ Перекрыт намеренно: без него ``f"{secret}"`` вызвал бы format у str и
124
+ напечатал значение, обойдя __str__.
125
+
126
+ Args:
127
+ spec (str): Спецификация формата. Игнорируется.
128
+
129
+ Returns:
130
+ str: Маска без значения.
131
+ """
132
+ return _MASK
133
+
134
+ def __eq__(self, other: object) -> bool:
135
+ """Сравнивает два секрета по значению.
136
+
137
+ Args:
138
+ other (object): Объект для сравнения.
139
+
140
+ Returns:
141
+ bool: True, если другой объект - Secret с тем же значением.
142
+ """
143
+ if not isinstance(other, Secret):
144
+ return NotImplemented
145
+ return self._value == other._value
146
+
147
+ def __hash__(self) -> int:
148
+ """Возвращает хэш секрета.
149
+
150
+ Хэшируется значение, чтобы Secret можно было класть в множества и
151
+ использовать как ключ словаря.
152
+
153
+ Returns:
154
+ int: Хэш значения.
155
+ """
156
+ return hash(self._value)
157
+
158
+ def __reduce__(self) -> Any:
159
+ """Запрещает сериализацию через pickle.
160
+
161
+ Raises:
162
+ TypeError: Всегда. Сериализованный секрет пережил бы процесс и
163
+ оказался бы на диске или в очереди задач.
164
+ """
165
+ raise TypeError("Secret не сериализуется: значение оказалось бы вне процесса")
166
+
167
+ def __copy__(self) -> Any:
168
+ """Запрещает копирование.
169
+
170
+ Raises:
171
+ TypeError: Всегда. Копия обходит контроль за числом мест,
172
+ где живёт значение.
173
+ """
174
+ raise TypeError("Secret не копируется")
175
+
176
+ def __deepcopy__(self, memo: dict[int, Any]) -> Any:
177
+ """Запрещает глубокое копирование.
178
+
179
+ Args:
180
+ memo (dict[int, Any]): Служебный словарь copy.deepcopy. Игнорируется.
181
+
182
+ Raises:
183
+ TypeError: Всегда.
184
+ """
185
+ raise TypeError("Secret не копируется")
186
+
187
+
188
+ @runtime_checkable
189
+ class SecretProvider(Protocol):
190
+ """Источник секретов.
191
+
192
+ Протокол намеренно узкий: одна операция. Пользователь может подключить
193
+ менеджер секретов, файл с ограниченными правами или собственный код, не
194
+ завися от того, как устроен клиент.
195
+ """
196
+
197
+ def get(self, name: str) -> Secret:
198
+ """Возвращает секрет по имени.
199
+
200
+ Args:
201
+ name (str): Логическое имя секрета, например ``golden_key``.
202
+
203
+ Returns:
204
+ Secret: Найденный секрет.
205
+
206
+ Raises:
207
+ SecretNotFoundError: Если секрет недоступен.
208
+ """
209
+ ...
210
+
211
+
212
+ class EnvSecretProvider:
213
+ """Источник, читающий секреты из переменных окружения.
214
+
215
+ Простейший рабочий вариант. Подходит для разработки и для контейнеров, но не
216
+ защищает от чтения другими процессами того же пользователя.
217
+
218
+ Args:
219
+ prefix (str): Префикс переменной. Имя ``golden_key`` при префиксе
220
+ ``FUNORA_`` читается из ``FUNORA_GOLDEN_KEY``.
221
+ """
222
+
223
+ __slots__ = ("_prefix",)
224
+
225
+ def __init__(self, prefix: str = "FUNORA_") -> None:
226
+ self._prefix = prefix
227
+
228
+ def get(self, name: str) -> Secret:
229
+ """Возвращает секрет из переменной окружения.
230
+
231
+ Args:
232
+ name (str): Логическое имя секрета.
233
+
234
+ Returns:
235
+ Secret: Значение переменной, обёрнутое в Secret.
236
+
237
+ Raises:
238
+ SecretNotFoundError: Если переменная не задана или пуста.
239
+ """
240
+ var = f"{self._prefix}{name.upper()}"
241
+ value = os.environ.get(var)
242
+ if not value:
243
+ raise SecretNotFoundError(f"переменная окружения {var} не задана")
244
+ return Secret(value, label=name)
245
+
246
+
247
+ class CallableSecretProvider:
248
+ """Источник, вызывающий переданную функцию.
249
+
250
+ Нужен, чтобы подключить менеджер секретов, не описывая для него отдельный
251
+ класс. Функция вызывается при каждом обращении, поэтому ротация ключа
252
+ подхватывается без перезапуска процесса.
253
+
254
+ Args:
255
+ fn (Callable[[str], str]): Функция, возвращающая значение по имени.
256
+ Должна поднимать исключение, если секрет недоступен.
257
+ """
258
+
259
+ __slots__ = ("_fn",)
260
+
261
+ def __init__(self, fn: Callable[[str], str]) -> None:
262
+ self._fn = fn
263
+
264
+ def get(self, name: str) -> Secret:
265
+ """Возвращает секрет, полученный от функции.
266
+
267
+ Args:
268
+ name (str): Логическое имя секрета.
269
+
270
+ Returns:
271
+ Secret: Значение, обёрнутое в Secret.
272
+
273
+ Raises:
274
+ SecretNotFoundError: Если функция вернула пустое значение или упала.
275
+ """
276
+ try:
277
+ value = self._fn(name)
278
+ except Exception as exc:
279
+ raise SecretNotFoundError(f"источник не смог выдать секрет {name}") from exc
280
+ if not value:
281
+ raise SecretNotFoundError(f"источник вернул пустой секрет {name}")
282
+ return Secret(value, label=name)
283
+
284
+
285
+ def _not_found(asked: Path, tried: Path, name: str) -> str:
286
+ """Объясняет, почему файла нет, и называет похожий, если он рядом.
287
+
288
+ Блокнот дописывает расширение, когда файла ещё не было: человек сохраняет
289
+ golden_key и получает golden_key.txt. Отказ, говорящий только «не найден»,
290
+ оставляет его гадать - при том что нужный файл лежит рядом и виден.
291
+
292
+ Args:
293
+ asked (Path): Путь, который назвал вызывающий.
294
+ tried (Path): Путь, по которому искали.
295
+ name (str): Логическое имя секрета.
296
+
297
+ Returns:
298
+ str: Сообщение отказа.
299
+ """
300
+ base = (
301
+ f"файл секрета не найден: {tried}. Укажите либо сам файл, либо "
302
+ f"каталог, в котором лежит файл с именем {name}"
303
+ )
304
+
305
+ # Сосед ищется ТОЛЬКО там, куда указали, - и это не мелочь. Первая редакция
306
+ # заглядывала ещё и в текущий каталог: подсказка находила чужой файл,
307
+ # никакого отношения к названному пути не имеющий, и уверенно советовала
308
+ # взять его. Проверка это и поймала.
309
+ folder = asked.parent if str(asked.parent) else Path()
310
+ try:
311
+ seen = [
312
+ one
313
+ for one in folder.iterdir()
314
+ if one.is_file() and one.stem == asked.stem and one != asked
315
+ ]
316
+ except OSError:
317
+ seen = []
318
+
319
+ if not seen:
320
+ return base
321
+
322
+ nearby = sorted({str(one) for one in seen})
323
+ return (
324
+ f"{base}."
325
+ + chr(10)
326
+ + chr(10)
327
+ + f"Рядом лежит похожее: {', '.join(nearby)}. Блокнот дописывает "
328
+ + "расширение, когда файла ещё не было - возможно, нужен именно этот "
329
+ + f"путь: --secret-file {nearby[0]}"
330
+ )
331
+
332
+
333
+ class FileSecretProvider:
334
+ """Источник, читающий секрет из файла.
335
+
336
+ Файл читается при каждом обращении: это позволяет сменить ключ, не
337
+ перезапуская процесс. Права доступа проверяются на POSIX-системах; на Windows
338
+ проверка пропускается, потому что режим файла там не отражает реальную
339
+ модель доступа.
340
+
341
+ Args:
342
+ directory (Path): Сам файл секрета либо каталог, в котором он лежит. В
343
+ каталоге имя файла совпадает с именем секрета.
344
+ check_permissions (bool): Проверять ли, что файл недоступен для чтения
345
+ другими пользователями.
346
+ """
347
+
348
+ __slots__ = ("_dir", "_check")
349
+
350
+ def __init__(self, directory: Path, check_permissions: bool = True) -> None:
351
+ self._dir = directory
352
+ self._check = check_permissions
353
+
354
+ def get(self, name: str) -> Secret:
355
+ """Возвращает секрет, прочитанный из файла.
356
+
357
+ Args:
358
+ name (str): Логическое имя секрета, оно же имя файла.
359
+
360
+ Returns:
361
+ Secret: Содержимое файла без завершающих пробельных символов.
362
+
363
+ Raises:
364
+ SecretNotFoundError: Если файл отсутствует, пуст или доступен на
365
+ чтение посторонним.
366
+ """
367
+ # Принимается и каталог, и сам файл.
368
+ #
369
+ # Прежде принимался только каталог - при том что ключ командной строки
370
+ # называется --secret-file. Человек, прочитавший имя буквально, указывал
371
+ # файл и получал «файл секрета не найден: golden_key/golden_key»: имя
372
+ # обещало одно, поведение требовало другого.
373
+ path = self._dir if self._dir.is_file() else self._dir / name
374
+ if not path.is_file():
375
+ raise SecretNotFoundError(_not_found(self._dir, path, name))
376
+
377
+ if self._check and os.name == "posix":
378
+ mode = path.stat().st_mode & 0o077
379
+ if mode:
380
+ raise SecretNotFoundError(f"файл {path} доступен посторонним; ожидаются права 0600")
381
+
382
+ value = path.read_text(encoding="utf-8").strip()
383
+ if not value:
384
+ raise SecretNotFoundError(f"файл секрета пуст: {path}")
385
+ return Secret(value, label=name)