ktalk-cli 1.0.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.
@@ -0,0 +1,136 @@
1
+ """Discovery, парсинг и валидация `.ktalk.toml` — конфигурации проекта-хозяина.
2
+
3
+ SA-003 (`content/40-architecture/ktalk-plugin-spec.md`), FR-20. Отдельный модуль
4
+ от `registry.py` (грандфазер заморожен на 562 строках). Не пишет файлы и не
5
+ создаёт каталоги — только читает и валидирует (границы спеки).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import tomllib
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ _KNOWN_TOP_LEVEL_KEYS = {"registry", "directories", "routing", "integrations"}
16
+ _CONFIG_FILE_NAME = ".ktalk.toml"
17
+
18
+
19
+ class HostConfigError(Exception):
20
+ """`.ktalk.toml` не парсится TOML-синтаксисом или проваливает валидацию схемы.
21
+
22
+ Именует конкретный файл и причину — процесс останавливается на этом шаге, не
23
+ продолжает работу на дефолте молча (ktalk-plugin-spec.md, «Поведение при
24
+ малформенном файле»).
25
+ """
26
+
27
+
28
+ @dataclass
29
+ class HostConfig:
30
+ """Распарсенный и провалидированный `.ktalk.toml`.
31
+
32
+ Каждая секция — `dict`-подобная с `.get(...)`; отсутствующий ключ означает
33
+ «не объявлен», не «объявлен пустым» (FR-20 AC-2/AC-3, FR-24 деградация).
34
+ """
35
+
36
+ registry: dict = field(default_factory=dict)
37
+ directories: dict = field(default_factory=dict)
38
+ routing: dict = field(default_factory=dict)
39
+ integrations: dict = field(default_factory=dict)
40
+
41
+
42
+ def _validate_string_map(section_name: str, section: dict, path: Path) -> None:
43
+ for key, value in section.items():
44
+ if not isinstance(value, str):
45
+ raise HostConfigError(
46
+ f"{path}: {section_name}.{key} должен быть строкой, получено {type(value).__name__}"
47
+ )
48
+
49
+
50
+ def load_host_config(path: str | Path) -> HostConfig:
51
+ """Парсит и валидирует `.ktalk.toml` по указанному пути.
52
+
53
+ Пустой/отсутствующий-по-ключам файл — валиден (FR-20 AC-2, boundary): каждая
54
+ секция трактуется как отсутствующая по отдельности, не как ошибка.
55
+ """
56
+ path = Path(path)
57
+ try:
58
+ raw = path.read_text(encoding="utf-8")
59
+ except OSError as exc:
60
+ raise HostConfigError(f"{path}: не удалось прочитать файл: {exc}") from exc
61
+
62
+ try:
63
+ data = tomllib.loads(raw)
64
+ except tomllib.TOMLDecodeError as exc:
65
+ raise HostConfigError(f"{path}: некорректный TOML-синтаксис: {exc}") from exc
66
+
67
+ unknown = set(data.keys()) - _KNOWN_TOP_LEVEL_KEYS
68
+ if unknown:
69
+ raise HostConfigError(
70
+ f"{path}: неизвестные секции {sorted(unknown)} "
71
+ f"(ожидаются только {sorted(_KNOWN_TOP_LEVEL_KEYS)})"
72
+ )
73
+
74
+ registry = data.get("registry", {})
75
+ directories = data.get("directories", {})
76
+ routing = data.get("routing", {})
77
+ integrations = data.get("integrations", {})
78
+
79
+ for name, section in (
80
+ ("registry", registry),
81
+ ("directories", directories),
82
+ ("routing", routing),
83
+ ("integrations", integrations),
84
+ ):
85
+ if not isinstance(section, dict):
86
+ raise HostConfigError(f"{path}: секция [{name}] должна быть таблицей")
87
+
88
+ if "db_path" in registry and not isinstance(registry["db_path"], str):
89
+ raise HostConfigError(f"{path}: registry.db_path должен быть строкой")
90
+ _validate_string_map("directories", directories, path)
91
+ _validate_string_map("routing", routing, path)
92
+ if "qmd" in integrations and not isinstance(integrations["qmd"], bool):
93
+ raise HostConfigError(f"{path}: integrations.qmd должен быть булевым")
94
+
95
+ return HostConfig(
96
+ registry=registry,
97
+ directories=directories,
98
+ routing=routing,
99
+ integrations=integrations,
100
+ )
101
+
102
+
103
+ def discover_host_config(project_dir: str | Path | None = None) -> HostConfig | None:
104
+ """Ищет и загружает `.ktalk.toml` (ktalk-plugin-spec.md, «Discovery»).
105
+
106
+ 1. `project_dir` (явный оверрайд, тот же контракт, что и `${CLAUDE_PROJECT_DIR}`)
107
+ или `${CLAUDE_PROJECT_DIR}`, если задан, — конфиг ищется РОВНО по этому
108
+ корню, обхода вверх нет.
109
+ 2. Иначе (голый CLI) — обход вверх от `cwd` до первого найденного
110
+ `.ktalk.toml`, либо до первого каталога с `.git` (граница проекта), либо
111
+ до корня файловой системы.
112
+ 3. Ничего не найдено -> `None`, не ошибка (FR-20 AC-2). Малформенный
113
+ найденный файл -> `HostConfigError` доходит до вызывающей стороны, не
114
+ гасится тихим откатом (FR-20 AC-3).
115
+ """
116
+ if project_dir is not None:
117
+ root = Path(project_dir)
118
+ candidate = root / _CONFIG_FILE_NAME
119
+ return load_host_config(candidate) if candidate.is_file() else None
120
+
121
+ claude_project_dir = os.environ.get("CLAUDE_PROJECT_DIR")
122
+ if claude_project_dir:
123
+ candidate = Path(claude_project_dir) / _CONFIG_FILE_NAME
124
+ return load_host_config(candidate) if candidate.is_file() else None
125
+
126
+ current = Path.cwd()
127
+ while True:
128
+ candidate = current / _CONFIG_FILE_NAME
129
+ if candidate.is_file():
130
+ return load_host_config(candidate)
131
+ if (current / ".git").exists():
132
+ return None
133
+ parent = current.parent
134
+ if parent == current:
135
+ return None
136
+ current = parent
@@ -0,0 +1,178 @@
1
+ """FR-13/NFR-9: allow-list компоновщик тела встречи + канонический хеш (ADR-005
2
+ «Решение», ADR-009 §1-4 — состав тела приведён к живому снимку DevTools).
3
+
4
+ Единая функция принимает только согласованный постановкой набор полей — без
5
+ `**kwargs` и без прохода через произвольный словарь вызывающего. `isRecurring`/
6
+ `recurrence` и поля вне этого состава физически не имеют параметра, через который
7
+ могли бы попасть в тело — структурная невозможность, не рантайм-фильтр. ADR-009
8
+ расширяет тот же приём на `autoRunDeepFakeDetection`/`maskingSettings` и убирает
9
+ `enableSip` тем же способом (параметра больше нет).
10
+
11
+ ГИПОТЕЗА (ADR-009): состав тела и конвертация времени в UTC не проверены
12
+ собственным боевым POST — построены по одному живому снимку DevTools, ревизуются
13
+ следующей санкционированной попыткой.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ import re
21
+ from datetime import UTC, datetime
22
+
23
+ from ktalk_cli.client import KTalkError
24
+
25
+ _TIMEZONE_RE = re.compile(r"^GMT[+-](?:[0-9]|1[0-4])$")
26
+
27
+ # ADR-009 §1-2: 12 полей от вызывающего/архитектуры + `description` с тихим
28
+ # дефолтом = 13 ключей тела. `pinCode`/`anonymousAccessExpirationDate` не входят
29
+ # в этот общий None-цикл — у них своя развилка (условная обязательность), не
30
+ # плоское «передано/не передано».
31
+ _REQUIRED = (
32
+ "subject",
33
+ "start",
34
+ "end",
35
+ "timezone",
36
+ "roomName",
37
+ "requiredAttendees",
38
+ "allowAnonymous",
39
+ "enableAutoRecording",
40
+ )
41
+
42
+ # ADR-009 §2: архитектурные константы — не параметры `build_meeting_body`, не
43
+ # решения вызывающего, поэтому вне периметра NFR-9 (запрет тихих дефолтов
44
+ # относится к полям, которые вызывающий мог бы решить, но не решил).
45
+ _FIXED = {
46
+ "isRecurring": False,
47
+ "autoRunDeepFakeDetection": None,
48
+ "maskingSettings": {
49
+ "nameMaskingMode": "none",
50
+ "postMaskingMode": "none",
51
+ "showAdditionalInfo": True,
52
+ },
53
+ }
54
+
55
+
56
+ class MissingFieldError(KTalkError):
57
+ """Поле не передано явно (`None`) — отказ до сетевого вызова (NFR-9)."""
58
+
59
+ def __init__(self, field: str) -> None:
60
+ super().__init__(
61
+ f'Поле «{field}» не передано явно вызывающим — запрос на создание встречи '
62
+ "отклонён до сетевого вызова (NFR-9)."
63
+ )
64
+ self.field = field
65
+
66
+
67
+ class TimezoneFormatError(KTalkError):
68
+ """Форма `timezone` не распознана — отказ до сетевого вызова (FR-40).
69
+
70
+ ГИПОТЕЗА: диапазон `GMT-14..GMT+14` не измерен целиком, подтверждено
71
+ замером только `GMT+3` (rooms-calendar-scheduling.md:357-368,
72
+ ADR-020 §3). Границы взяты по общемировому диапазону смещений UTC, не по
73
+ факту API — ревизуются следующим боевым замером с иным значением.
74
+ """
75
+
76
+ def __init__(self, value: str) -> None:
77
+ super().__init__(
78
+ f'Часовой пояс «{value}» не распознан. Требуемый формат: `GMT±N` '
79
+ "(пример: `GMT+3`). Другие нотации (IANA, Windows ID, ISO-смещение, "
80
+ "аббревиатуры) сервер не принимает — см. FR-40."
81
+ )
82
+ self.value = value
83
+
84
+
85
+ def build_required_attendees(keys: list[str]) -> list[dict]:
86
+ """ADR-009 §4: `requiredUserKeys` (список логинов) заменён на список объектов
87
+ `{"type": "user", "key": str}` — `key` числовой id строкой (снимок сериализует
88
+ id как `"668"`, не `668`). Формат `key` не валидируется (`.isdigit()`) —
89
+ компоновщик не знает формат id других типов участников за пределами снимка."""
90
+ return [{"type": "user", "key": key} for key in keys]
91
+
92
+
93
+ def _to_utc_z(value: str) -> str:
94
+ """ADR-009 §1: `start`/`end` — UTC с суффиксом `Z` и миллисекундами, не
95
+ локальное смещение. Вызывающий передаёт локальное ISO с оффсетом,
96
+ конвертация — деталь этого модуля (единственная точка правки, не
97
+ компоновщика вызывающего кода)."""
98
+ dt = datetime.fromisoformat(value)
99
+ dt_utc = dt.astimezone(UTC)
100
+ millis = dt_utc.microsecond // 1000
101
+ return f"{dt_utc.strftime('%Y-%m-%dT%H:%M:%S')}.{millis:03d}Z"
102
+
103
+
104
+ def build_meeting_body(
105
+ *,
106
+ subject: str | None = None,
107
+ start: str | None = None,
108
+ end: str | None = None,
109
+ timezone: str | None = None,
110
+ room_name: str | None = None,
111
+ required_attendee_keys: list[str] | None = None,
112
+ description: str | None = None,
113
+ enable_auto_recording: bool | None = None,
114
+ pin_code: str | None = None,
115
+ pin_code_explicit_none: bool = False,
116
+ allow_anonymous: bool | None = None,
117
+ anonymous_access_expiration: str | None = None,
118
+ ) -> dict:
119
+ """Каждое поле `_REQUIRED` проверяется через `is None` (не truthiness) — явный
120
+ пустой список участников/`False` — валидные явные решения, отсутствие
121
+ значения — нет. `description` — единственное поле с разрешённым тихим
122
+ дефолтом (пустая строка, NFR-9). `pinCode`/`anonymousAccessExpirationDate`
123
+ решаются отдельными условными правилами (ADR-009 §2-3), не общим циклом.
124
+
125
+ `timezone` принимает единственную форму `GMT±N` (пример `GMT+3`, диапазон
126
+ `GMT-14..GMT+14`, ГИПОТЕЗА — измерен только `GMT+3`). Другая нотация
127
+ отклоняется `TimezoneFormatError` до сборки тела.
128
+ """
129
+ body = {
130
+ "subject": subject,
131
+ "start": _to_utc_z(start) if start is not None else None,
132
+ "end": _to_utc_z(end) if end is not None else None,
133
+ "timezone": timezone,
134
+ "roomName": room_name,
135
+ "requiredAttendees": (
136
+ build_required_attendees(required_attendee_keys)
137
+ if required_attendee_keys is not None
138
+ else None
139
+ ),
140
+ "description": description if description is not None else "",
141
+ "enableAutoRecording": enable_auto_recording,
142
+ "allowAnonymous": allow_anonymous,
143
+ }
144
+ for field in _REQUIRED:
145
+ if body[field] is None:
146
+ raise MissingFieldError(field)
147
+ if not _TIMEZONE_RE.match(body["timezone"]):
148
+ raise TimezoneFormatError(body["timezone"])
149
+
150
+ # ADR-009 §2: `pin_code_explicit_none=True` побеждает при одновременной
151
+ # передаче обоих сигналов — «решено: нет PIN» перекрывает конкретное
152
+ # значение `pin_code` (порядок не специфицирован спекой, зафиксирован здесь).
153
+ if pin_code_explicit_none:
154
+ body["pinCode"] = None
155
+ elif pin_code is not None:
156
+ body["pinCode"] = pin_code
157
+ else:
158
+ raise MissingFieldError("pinCode")
159
+
160
+ # ADR-009 §3: `anonymousAccessExpirationDate` условно обязателен — вне
161
+ # общего `_REQUIRED`-цикла. `allow_anonymous=False` + значение передано —
162
+ # значение отбрасывается (поле неприменимо, доступ выключен), не ошибка:
163
+ # решение Dev по edge case, не специфицированному спекой дословно.
164
+ if allow_anonymous is True and anonymous_access_expiration is None:
165
+ raise MissingFieldError("anonymousAccessExpirationDate")
166
+ body["anonymousAccessExpirationDate"] = (
167
+ anonymous_access_expiration if allow_anonymous else None
168
+ )
169
+
170
+ body.update(_FIXED)
171
+ return body
172
+
173
+
174
+ def canonical_body_hash(body: dict) -> str:
175
+ """sha256 канонической (стабильной по порядку ключей) сериализации тела —
176
+ основа привязки подтверждения к содержимому (ADR-005)."""
177
+ canonical = json.dumps(body, sort_keys=True, ensure_ascii=False)
178
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
@@ -0,0 +1,33 @@
1
+ """ADR-011 §2: компоновка данных отмены встречи (без сети).
2
+
3
+ Параллель `meeting_scheduling.PreviewService` — чистые функции, без `KTalkClient`,
4
+ физическая невозможность сетевого вызова из этого модуля.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ktalk_cli.confirmation import ConfirmationStore
10
+ from ktalk_cli.meeting_body import canonical_body_hash
11
+
12
+
13
+ def build_cancel_confirmation_payload(*, id: str, reason: str = "") -> dict:
14
+ """Не тело запроса (то — `{"reason": reason}`), а предмет хеширования
15
+ подтверждения (ADR-011 п.2): `id` — часть пути, не тела, но обязан входить
16
+ в хеш, иначе подтверждение для одной встречи матчится для любой другой.
17
+ `operation` — дискриминатор против путаницы с будущим подтверждением
18
+ `update_meeting` на том же `id`."""
19
+ return {"operation": "cancel_meeting", "id": id, "reason": reason}
20
+
21
+
22
+ class CancelPreviewService:
23
+ """Параллель `PreviewService` (`meeting_scheduling.py`) — тот же
24
+ `ConfirmationStore`, та же `canonical_body_hash`."""
25
+
26
+ def __init__(self, store: ConfirmationStore) -> None:
27
+ self._store = store
28
+
29
+ def preview(self, *, id: str, reason: str = "") -> tuple[dict, str]:
30
+ payload = build_cancel_confirmation_payload(id=id, reason=reason)
31
+ payload_hash = canonical_body_hash(payload)
32
+ confirmation_id = self._store.issue(payload_hash)
33
+ return payload, confirmation_id
@@ -0,0 +1,120 @@
1
+ """FR-13 §6.6: предпросмотр (без сети) + создание (ровно одна сетевая попытка).
2
+
3
+ `create_meeting` — свободная функция вне `client.py` (гейт C13), тем же приёмом,
4
+ что `rooms.get_room`/`calendar_reader._fetch_segment`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+
11
+ from ktalk_cli.auth import quote_path_param
12
+ from ktalk_cli.client import KTalkClient
13
+ from ktalk_cli.confirmation import ConfirmationStore
14
+ from ktalk_cli.contour_diagnostics import TRANSIENT_ERRORS, diagnose_undocumented_failure
15
+ from ktalk_cli.meeting_body import build_meeting_body, canonical_body_hash
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class PreviewService:
21
+ def __init__(self, store: ConfirmationStore) -> None:
22
+ self._store = store
23
+
24
+ def preview(self, **fields: object) -> tuple[dict, str]:
25
+ """`build_meeting_body` -> `canonical_body_hash` -> `store.issue`. Без сети
26
+ физически — не получает `KTalkClient` (структурная невозможность, не
27
+ поведенческая)."""
28
+ body = build_meeting_body(**fields)
29
+ body_hash = canonical_body_hash(body)
30
+ confirmation_id = self._store.issue(body_hash)
31
+ return body, confirmation_id
32
+
33
+
34
+ async def create_meeting(client: KTalkClient, body: dict) -> dict:
35
+ """Ровно одна сетевая попытка `POST /api/calendar`, без retry. ADR-007 п.3:
36
+ оборачивается корреляционной диагностикой ADR-004, тем же приёмом, что
37
+ `calendar_reader._fetch_segment` — сбой недокументированного пути неотличим
38
+ от обычного auth/сетевого сбоя без контрольного вызова.
39
+
40
+ ADR-009 §6 (пересматривает ADR-008 §1): на `profile.mutating` (сегодня только
41
+ session-режим этой операции) заголовки `Authorization: Session <token>` +
42
+ `X-Platform: web` отправляются ВМЕСТО query-параметра `sessionToken`, не
43
+ поверх него — единственная известная рабочая конфигурация (снимок DevTools).
44
+ Query вырезается точечно из уже построенного `httpx.Request`
45
+ (`copy_remove_param`) — конструктор клиента (`client._client.params`,
46
+ ADR-003) не трогается, следующий read-путь того же клиента снова несёт
47
+ `sessionToken` в query как обычно. ГИПОТЕЗА до следующего боевого POST. Тело
48
+ ответа 4xx/5xx читается до классификации и прикрепляется к перехваченному
49
+ исключению атрибутом `response_body` (обрезано до 500 символов) — переживает
50
+ прогон независимо от конфигурации `logging`, которой в проекте нет."""
51
+ profile = client._profile_for("create_meeting") # noqa: SLF001
52
+ headers = (
53
+ {"Authorization": f"Session {client._auth.credential}", "X-Platform": "web"} # noqa: SLF001
54
+ if profile.mutating
55
+ else None
56
+ )
57
+ request = client._client.build_request( # noqa: SLF001
58
+ "POST", profile.path_template, json=body, headers=headers
59
+ )
60
+ if profile.mutating:
61
+ request.url = request.url.copy_remove_param("sessionToken")
62
+ try:
63
+ response = await client._client.send(request) # noqa: SLF001
64
+ except TRANSIENT_ERRORS as exc:
65
+ await diagnose_undocumented_failure(client, "create_meeting", exc)
66
+ raise # недостижимо
67
+
68
+ body_text = response.text[:500] if response.status_code >= 400 else None
69
+ if body_text:
70
+ logger.warning("create_meeting: HTTP %s, тело: %s", response.status_code, body_text)
71
+
72
+ try:
73
+ client._classify(response, profile.required_scope) # noqa: SLF001
74
+ except TRANSIENT_ERRORS as exc:
75
+ # DEV-008: тело пустое ("") — это факт контура, отличный от "тело не
76
+ # прикреплено вовсе" (той ветки, где ответа не было — сетевой сбой выше).
77
+ # `if body_text:` терял этот факт при пустой строке.
78
+ if body_text is not None:
79
+ exc.response_body = body_text # атрибут читает CLI/MCP на границе вывода
80
+ await diagnose_undocumented_failure(client, "create_meeting", exc)
81
+ raise # недостижимо
82
+ return response.json()
83
+
84
+
85
+ async def cancel_meeting(client: KTalkClient, *, id: str, reason: str = "") -> dict:
86
+ """Ровно одна сетевая попытка `POST /api/calendar/{id}/cancel`, без retry —
87
+ тот же контракт, что `create_meeting` (ADR-005 п.2). Транспорт (заголовки,
88
+ отсутствие query) — тот же код, что `create_meeting`: `profile.mutating`
89
+ общий признак, не специфичный для операции. `{id}` — обязательный
90
+ `quote_path_param` (Ф-56): `+`/`/`/`=` меняют путь без квотирования."""
91
+ profile = client._profile_for("cancel_meeting") # noqa: SLF001
92
+ path = profile.path_template.format(id=quote_path_param(id))
93
+ headers = (
94
+ {"Authorization": f"Session {client._auth.credential}", "X-Platform": "web"} # noqa: SLF001
95
+ if profile.mutating
96
+ else None
97
+ )
98
+ request = client._client.build_request( # noqa: SLF001
99
+ "POST", path, json={"reason": reason}, headers=headers
100
+ )
101
+ if profile.mutating:
102
+ request.url = request.url.copy_remove_param("sessionToken")
103
+ try:
104
+ response = await client._client.send(request) # noqa: SLF001
105
+ except TRANSIENT_ERRORS as exc:
106
+ await diagnose_undocumented_failure(client, "cancel_meeting", exc)
107
+ raise # недостижимо
108
+
109
+ body_text = response.text[:500] if response.status_code >= 400 else None
110
+ try:
111
+ client._classify(response, profile.required_scope) # noqa: SLF001
112
+ except TRANSIENT_ERRORS as exc:
113
+ if body_text is not None:
114
+ exc.response_body = body_text
115
+ await diagnose_undocumented_failure(client, "cancel_meeting", exc)
116
+ raise # недостижимо
117
+ # Ф-57: успешная отмена отвечает 200 с ПУСТЫМ телом — `response.json()` на нём
118
+ # падает, и необратимая выполненная операция докладывалась как «исход неизвестен».
119
+ # Отличие от `create_meeting`, чей ответ несёт объект встречи.
120
+ return response.json() if response.content else {}
@@ -0,0 +1,108 @@
1
+ """Единый асинхронный итератор постраничного чтения (FR-9, FR-14).
2
+
3
+ `paginate_pages` — форма-независимый движок термination-логики: продолжает, пока
4
+ очередная страница непустая И следующий курсор истинный; останавливается на первом
5
+ же из двух отрицательных сигналов. Не полагается на конкретное имя поля пагинации
6
+ в ответе API — это и закрывает Ф-3 (`/api/recordings` не отдаёт `nextPageToken`).
7
+
8
+ `skip_pages` — offset-адаптер (session-список записей, архив, FR-9/FR-14): жёстко
9
+ клэмпит размер страницы в [1, 100] (закрывает регрессию `top=1000`, зонд Ф-2) и
10
+ продолжает по `skip`, пока очередной запрос не вернёт пустую страницу — намеренно
11
+ НЕ останавливается на «короткой», но непустой странице: домен ни разу не подтвердил,
12
+ что API отдаёт заведомо неполные непоследние страницы, а недоверие отсутствующему
13
+ полю (Ф-3) распространяется и на предположение «короткая = последняя».
14
+
15
+ `token_pages` — курсорный адаптер (api-key список записей): `nextPageToken`.
16
+
17
+ `clip_to_window` — клиентское окно дат (Ф-15). API игнорирует `startFrom`/`startTo`:
18
+ зонд показал побитово одинаковую выдачу с фильтром и без него, а описания этих
19
+ параметров у v1 и v2 в спеке вдобавок зеркальны. Поэтому окно `--days` обеспечивает
20
+ клиент, а не сервер. Ранняя остановка опирается на `orderMode=byTimeNewFirst`
21
+ и проверена на пяти страницах подряд (Ф-16): порядок строго убывающий внутри
22
+ страницы и монотонный между страницами.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from collections.abc import AsyncIterator, Awaitable, Callable
28
+ from typing import Any
29
+
30
+ Cursor = Any
31
+ FetchPage = Callable[[Cursor | None], Awaitable[tuple[list[dict], Cursor | None]]]
32
+
33
+
34
+ def clip_to_window(
35
+ items: list[dict],
36
+ start_from: str | None,
37
+ *,
38
+ date_key: str = "createdDate",
39
+ ) -> tuple[list[dict], bool]:
40
+ """Оставляет записи не старше `start_from`, сообщая, исчерпано ли окно.
41
+
42
+ Возвращает `(kept, exhausted)`. `exhausted=True` означает, что страница
43
+ содержала запись старше порога — при сортировке от новых к старым все
44
+ последующие страницы заведомо вне окна и запрашивать их не нужно.
45
+
46
+ Записи без разбираемой даты не отбрасываются: пустой `createdDate` — это
47
+ неизвестность, а не «старая запись», и терять её молча нельзя.
48
+ """
49
+ if not start_from:
50
+ return items, False
51
+ kept: list[dict] = []
52
+ exhausted = False
53
+ for item in items:
54
+ raw = (item.get(date_key) or "")[:10]
55
+ if not raw:
56
+ kept.append(item)
57
+ continue
58
+ if raw < start_from[:10]:
59
+ exhausted = True
60
+ continue
61
+ kept.append(item)
62
+ return kept, exhausted
63
+
64
+
65
+ async def paginate_pages(fetch_page: FetchPage) -> AsyncIterator[list[dict]]:
66
+ """Обходит страницы через `fetch_page(cursor) -> (items, next_cursor)`."""
67
+ cursor: Cursor | None = None
68
+ while True:
69
+ items, next_cursor = await fetch_page(cursor)
70
+ if not items:
71
+ return
72
+ yield items
73
+ if not next_cursor:
74
+ return
75
+ cursor = next_cursor
76
+
77
+
78
+ def skip_pages(
79
+ fetch: Callable[[int, int], Awaitable[dict]],
80
+ page_size: int = 100,
81
+ items_key: str = "recordings",
82
+ ) -> FetchPage:
83
+ """Строит `fetch_page` для offset-пагинации поверх `fetch(skip, top) -> raw_dict`."""
84
+ page_size = max(1, min(int(page_size), 100))
85
+
86
+ async def fetch_page(cursor: int | None) -> tuple[list[dict], int | None]:
87
+ skip = cursor or 0
88
+ raw = await fetch(skip, page_size)
89
+ items = raw.get(items_key) or []
90
+ next_cursor = skip + len(items) if items else None
91
+ return items, next_cursor
92
+
93
+ return fetch_page
94
+
95
+
96
+ def token_pages(
97
+ fetch: Callable[[str | None], Awaitable[dict]],
98
+ items_key: str = "entities",
99
+ token_key: str = "nextPageToken",
100
+ ) -> FetchPage:
101
+ """Строит `fetch_page` для курсорной пагинации поверх `fetch(token) -> raw_dict`."""
102
+
103
+ async def fetch_page(cursor: str | None) -> tuple[list[dict], str | None]:
104
+ raw = await fetch(cursor)
105
+ items = raw.get(items_key) or []
106
+ return items, raw.get(token_key) or None
107
+
108
+ return fetch_page
@@ -0,0 +1,59 @@
1
+ """Сверка идентификаторов перед первым api-key sync (FR-15).
2
+
3
+ Внутренний контур отдаёт только `id`, интеграторский — `id` и `key`; совпадают ли
4
+ значения между контурами не проверено эмпирически (ключ выдан без нужных scope,
5
+ зонд Ф-11/Ф-14). Если разойдутся, первый `ktalk sync` под ключом задвоит реестр
6
+ (~1316 записей) — обязателен `ktalk sync --dry-run` до первого боевого прогона.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ def recording_ids(recordings: list[dict]) -> list[str]:
13
+ """Извлекает id (fallback на key, как `registry.recording_fields_from_api`)."""
14
+ return [rid for r in recordings if (rid := r.get("id") or r.get("key"))]
15
+
16
+
17
+ def compare_id_sets(fetched: list[str], existing: list[str]) -> dict:
18
+ """Сравнивает множество id из API-ответа с уже существующими строками реестра."""
19
+ fetched_set = set(fetched)
20
+ existing_set = set(existing)
21
+ return {
22
+ "only_in_fetched": fetched_set - existing_set,
23
+ "only_in_existing": existing_set - fetched_set,
24
+ "common": fetched_set & existing_set,
25
+ }
26
+
27
+
28
+ def dry_run_report(fetched_ids: list[str], existing_ids: list[str]) -> dict:
29
+ """Отчёт для `ktalk sync --dry-run` (FR-15 AC-2/AC-3).
30
+
31
+ `ok=True` — можно продолжать обычную синхронизацию (полное совпадение либо
32
+ реестр ещё пуст, сверивать не с чем). `ok=False` — расхождение идентификаторов,
33
+ боевой sync заблокирован до осознанного решения оператора (используется как
34
+ exit-код CLI, по аналогии с уже принятым в проекте `migrate --dry-run`).
35
+ """
36
+ if not existing_ids:
37
+ return {
38
+ "ok": True,
39
+ "message": (
40
+ "Реестр пуст — нет данных для сравнения. Сверка id будет возможна "
41
+ "после первого сохранённого sync."
42
+ ),
43
+ "only_in_fetched": sorted(set(fetched_ids)),
44
+ "only_in_existing": [],
45
+ }
46
+ comparison = compare_id_sets(fetched_ids, existing_ids)
47
+ mismatch = bool(comparison["only_in_fetched"] or comparison["only_in_existing"])
48
+ message = (
49
+ "Обнаружено расхождение идентификаторов — боевой sync заблокирован до "
50
+ "осознанного решения оператора."
51
+ if mismatch
52
+ else "Идентификаторы полностью совпадают — можно продолжать обычную синхронизацию."
53
+ )
54
+ return {
55
+ "ok": not mismatch,
56
+ "message": message,
57
+ "only_in_fetched": sorted(comparison["only_in_fetched"]),
58
+ "only_in_existing": sorted(comparison["only_in_existing"]),
59
+ }