ktr-cli 0.1.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,304 @@
1
+ import mimetypes
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ from kantree_cli.core.client import KantreeClient
6
+ from kantree_cli.core.errors import ValidationError
7
+
8
+ JsonObject = dict[str, Any]
9
+
10
+ _SUPPORTED_SPREADSHEET_EXTENSIONS = {".csv", ".xlsx", ".ods"}
11
+
12
+ # Assumption: these field names match the spreadsheet-import REST API payload.
13
+ # They are centralized here so we can adjust quickly if endpoint verification differs.
14
+ _IMPORT_PAYLOAD_PROJECT_KEY = "project_id"
15
+ _IMPORT_PAYLOAD_DISCOVER_FILE_ID_KEY = "discover_file_id"
16
+ _IMPORT_PAYLOAD_MAPPING_KEY = "mapping"
17
+ _IMPORT_PAYLOAD_UPDATE_FIELD_KEY = "update_field"
18
+ _IMPORT_PAYLOAD_UPDATE_ONLY_KEY = "update_only"
19
+ _IMPORT_PAYLOAD_OPTIONS_KEY = "options"
20
+ _IMPORT_PAYLOAD_IGNORE_HEADER_KEY = "ignore_header"
21
+ _IMPORT_PAYLOAD_SUPPRESS_ACTIVITIES_KEY = "suppress_activities"
22
+ _TEMPLATE_WRAPPER_KEYS = {"discover_file_id", "source_columns", "fields"}
23
+
24
+
25
+ def discover_spreadsheet_import(
26
+ client: KantreeClient,
27
+ *,
28
+ spreadsheet_path: Path,
29
+ ) -> Any:
30
+ normalized_path = _validate_spreadsheet_path(spreadsheet_path)
31
+ mime_type = _guess_spreadsheet_mime_type(normalized_path)
32
+ with normalized_path.open("rb") as stream:
33
+ response = client.request(
34
+ method="POST",
35
+ path="/importers/spreadsheet/discover",
36
+ files={"file": (normalized_path.name, stream, mime_type)},
37
+ )
38
+ return response.data
39
+
40
+
41
+ def apply_spreadsheet_import(
42
+ client: KantreeClient,
43
+ *,
44
+ workspace_id: int,
45
+ discover_file_id: str,
46
+ mapping: Any,
47
+ update_field: str | None,
48
+ update_only: bool,
49
+ ignore_header: bool,
50
+ suppress_activities: bool,
51
+ ) -> Any:
52
+ payload = _build_spreadsheet_import_payload(
53
+ workspace_id=workspace_id,
54
+ discover_file_id=discover_file_id,
55
+ mapping=mapping,
56
+ update_field=update_field,
57
+ update_only=update_only,
58
+ ignore_header=ignore_header,
59
+ suppress_activities=suppress_activities,
60
+ )
61
+ response = client.request(
62
+ method="POST",
63
+ path="/importers/spreadsheet/import",
64
+ json_body=payload,
65
+ )
66
+ return response.data
67
+
68
+
69
+ def build_template_from_discovery(discovery_payload: Any) -> JsonObject:
70
+ source_fields = _extract_discovery_fields(discovery_payload)
71
+ mapping: JsonObject = {}
72
+ for index, (field_key, field_name) in enumerate(source_fields):
73
+ if index == 0:
74
+ mapping[field_key] = {"type": "title"}
75
+ continue
76
+ mapping[field_key] = {
77
+ "type": "attribute",
78
+ "attr_type": "text",
79
+ "name": field_name,
80
+ }
81
+
82
+ return {
83
+ "discover_file_id": _extract_discovery_file_id(discovery_payload),
84
+ "source_columns": [field_name for _, field_name in source_fields],
85
+ "fields": [[field_key, field_name] for field_key, field_name in source_fields],
86
+ "mapping": mapping,
87
+ }
88
+
89
+
90
+ def normalize_spreadsheet_import_mapping(mapping_payload: JsonObject) -> JsonObject:
91
+ if not (_TEMPLATE_WRAPPER_KEYS & mapping_payload.keys()):
92
+ return mapping_payload
93
+
94
+ nested_mapping = mapping_payload.get(_IMPORT_PAYLOAD_MAPPING_KEY)
95
+ if not isinstance(nested_mapping, dict):
96
+ raise ValidationError("`--mapping` template output must contain a JSON object `mapping`.")
97
+ return nested_mapping
98
+
99
+
100
+ def resolve_spreadsheet_update_field(mapping: JsonObject, update_field: str | None) -> str | None:
101
+ if update_field is None:
102
+ return None
103
+
104
+ normalized = update_field.strip()
105
+ if not normalized:
106
+ raise ValidationError("`--update-field` expects a non-empty value.")
107
+ if normalized in mapping:
108
+ return normalized
109
+
110
+ matches = [
111
+ key
112
+ for key, mapinfo in mapping.items()
113
+ if _mapping_entry_matches_update_field(mapinfo, normalized)
114
+ ]
115
+ if len(matches) == 1:
116
+ return str(matches[0])
117
+ if len(matches) > 1:
118
+ raise ValidationError(
119
+ "`--update-field` matched multiple import mapping entries; use the column key instead."
120
+ )
121
+
122
+ raise ValidationError(
123
+ "`--update-field` must match an import mapping column key or mapped field name."
124
+ )
125
+
126
+
127
+ def _mapping_entry_matches_update_field(mapinfo: Any, update_field: str) -> bool:
128
+ if not isinstance(mapinfo, dict):
129
+ return False
130
+
131
+ normalized = update_field.lower()
132
+ if mapinfo.get("type") in {"title", "ref"} and normalized == mapinfo.get("type"):
133
+ return True
134
+
135
+ for key in ("name", "title", "label", "source_column", "attribute", "attribute_name"):
136
+ raw_value = mapinfo.get(key)
137
+ if isinstance(raw_value, str) and raw_value.strip().lower() == normalized:
138
+ return True
139
+ return False
140
+
141
+
142
+ def _build_spreadsheet_import_payload(
143
+ *,
144
+ workspace_id: int,
145
+ discover_file_id: str,
146
+ mapping: Any,
147
+ update_field: str | None,
148
+ update_only: bool,
149
+ ignore_header: bool,
150
+ suppress_activities: bool,
151
+ ) -> JsonObject:
152
+ payload: JsonObject = {
153
+ _IMPORT_PAYLOAD_PROJECT_KEY: workspace_id,
154
+ _IMPORT_PAYLOAD_DISCOVER_FILE_ID_KEY: discover_file_id,
155
+ _IMPORT_PAYLOAD_MAPPING_KEY: mapping,
156
+ }
157
+ if update_field is not None:
158
+ payload[_IMPORT_PAYLOAD_UPDATE_FIELD_KEY] = update_field
159
+ if update_only:
160
+ payload[_IMPORT_PAYLOAD_UPDATE_ONLY_KEY] = True
161
+ if ignore_header:
162
+ payload[_IMPORT_PAYLOAD_OPTIONS_KEY] = {_IMPORT_PAYLOAD_IGNORE_HEADER_KEY: True}
163
+ if suppress_activities:
164
+ payload[_IMPORT_PAYLOAD_SUPPRESS_ACTIVITIES_KEY] = True
165
+ return payload
166
+
167
+
168
+ def _validate_spreadsheet_path(path: Path) -> Path:
169
+ resolved = path.expanduser()
170
+ if not resolved.exists():
171
+ raise ValidationError(f"Spreadsheet path does not exist: `{resolved}`")
172
+ if not resolved.is_file():
173
+ raise ValidationError(f"Spreadsheet path is not a file: `{resolved}`")
174
+ suffix = resolved.suffix.lower()
175
+ if suffix not in _SUPPORTED_SPREADSHEET_EXTENSIONS:
176
+ expected = ", ".join(sorted(_SUPPORTED_SPREADSHEET_EXTENSIONS))
177
+ raise ValidationError(
178
+ f"Unsupported spreadsheet format `{resolved.suffix}`. Expected one of: {expected}."
179
+ )
180
+ return resolved
181
+
182
+
183
+ def _guess_spreadsheet_mime_type(path: Path) -> str:
184
+ guessed, _ = mimetypes.guess_type(str(path))
185
+ if guessed:
186
+ return guessed
187
+ return "application/octet-stream"
188
+
189
+
190
+ def _extract_discovery_file_id(payload: Any) -> str | None:
191
+ candidates: list[Any] = []
192
+ if isinstance(payload, dict):
193
+ for key in ("discover_file_id", "file_id", "id"):
194
+ candidates.append(payload.get(key))
195
+
196
+ file_obj = payload.get("file")
197
+ if isinstance(file_obj, dict):
198
+ for key in ("discover_file_id", "file_id", "id"):
199
+ candidates.append(file_obj.get(key))
200
+
201
+ for candidate in candidates:
202
+ if candidate is None:
203
+ continue
204
+ if isinstance(candidate, str):
205
+ normalized = candidate.strip()
206
+ if normalized:
207
+ return normalized
208
+ continue
209
+ return str(candidate)
210
+ return None
211
+
212
+
213
+ def _extract_discovery_fields(payload: Any) -> list[tuple[str, str]]:
214
+ if not isinstance(payload, dict):
215
+ return []
216
+
217
+ for key in ("fields", "columns", "headers"):
218
+ maybe_fields = payload.get(key)
219
+ parsed_fields = _normalize_discovery_fields(maybe_fields)
220
+ if parsed_fields:
221
+ return parsed_fields
222
+
223
+ file_obj = payload.get("file")
224
+ if isinstance(file_obj, dict):
225
+ for key in ("fields", "columns", "headers"):
226
+ maybe_fields = file_obj.get(key)
227
+ parsed_fields = _normalize_discovery_fields(maybe_fields)
228
+ if parsed_fields:
229
+ return parsed_fields
230
+
231
+ return []
232
+
233
+
234
+ def _normalize_discovery_fields(raw_fields: Any) -> list[tuple[str, str]]:
235
+ if not isinstance(raw_fields, list):
236
+ return []
237
+
238
+ normalized_fields: list[tuple[str, str]] = []
239
+ seen_keys: set[str] = set()
240
+ for index, field in enumerate(raw_fields):
241
+ normalized_field = _normalize_discovery_field(field, index=index)
242
+ if normalized_field is None:
243
+ continue
244
+ field_key, _ = normalized_field
245
+ if field_key in seen_keys:
246
+ continue
247
+ seen_keys.add(field_key)
248
+ normalized_fields.append(normalized_field)
249
+ return normalized_fields
250
+
251
+
252
+ def _normalize_discovery_field(value: Any, *, index: int) -> tuple[str, str] | None:
253
+ if isinstance(value, (list, tuple)):
254
+ if len(value) < 2:
255
+ return None
256
+ field_key = _normalize_field_key(value[0])
257
+ field_name = _normalize_field_name(value[1])
258
+ if field_key is None or field_name is None:
259
+ return None
260
+ return field_key, field_name
261
+
262
+ if isinstance(value, dict):
263
+ field_key = None
264
+ for key in ("id", "key", "index", "column"):
265
+ field_key = _normalize_field_key(value.get(key))
266
+ if field_key is not None:
267
+ break
268
+ if field_key is None:
269
+ field_key = str(index)
270
+
271
+ field_name = None
272
+ for key in ("name", "title", "label"):
273
+ field_name = _normalize_field_name(value.get(key))
274
+ if field_name is not None:
275
+ break
276
+ if field_name is None:
277
+ return None
278
+ return field_key, field_name
279
+
280
+ field_name = _normalize_field_name(value)
281
+ if field_name is None:
282
+ return None
283
+ return str(index), field_name
284
+
285
+
286
+ def _normalize_field_key(value: Any) -> str | None:
287
+ if isinstance(value, bool):
288
+ return None
289
+ if isinstance(value, int):
290
+ return str(value)
291
+ if isinstance(value, str):
292
+ normalized = value.strip()
293
+ if normalized:
294
+ return normalized
295
+ return None
296
+
297
+
298
+ def _normalize_field_name(value: Any) -> str | None:
299
+ if not isinstance(value, str):
300
+ return None
301
+ normalized = value.strip()
302
+ if not normalized:
303
+ return None
304
+ return normalized
@@ -0,0 +1,43 @@
1
+ from typing import Any
2
+
3
+ from kantree_cli.core.client import KantreeClient
4
+ from kantree_cli.core.errors import ApiError
5
+ from kantree_cli.core.response import JsonObject, extract_object_list
6
+
7
+
8
+ def validate_kql(client: KantreeClient, *, kql_text: str) -> Any:
9
+ return _query_kql_endpoint(client, endpoint="/kql/validate", kql_text=kql_text)
10
+
11
+
12
+ def parse_kql(client: KantreeClient, *, kql_text: str) -> Any:
13
+ return _query_kql_endpoint(client, endpoint="/kql/parse-filter", kql_text=kql_text)
14
+
15
+
16
+ def list_kql_functions(client: KantreeClient) -> list[JsonObject]:
17
+ payload = client.get_json("/kql/autocomplete/functions")
18
+ return extract_object_list(
19
+ payload,
20
+ context="`GET /kql/autocomplete/functions`",
21
+ preferred_keys=("functions", "data", "items", "results"),
22
+ )
23
+
24
+
25
+ def _query_kql_endpoint(client: KantreeClient, *, endpoint: str, kql_text: str) -> Any:
26
+ encoded = _quote_kql(kql_text)
27
+ try:
28
+ return client.get_json(f"{endpoint}?source={encoded}")
29
+ except ApiError as exc:
30
+ if not _is_q_required_error(exc):
31
+ raise
32
+ return client.get_json(f"{endpoint}?q={encoded}")
33
+
34
+
35
+ def _is_q_required_error(exc: ApiError) -> bool:
36
+ message = str(exc).lower()
37
+ return "q:" in message and "required field" in message
38
+
39
+
40
+ def _quote_kql(text: str) -> str:
41
+ import urllib.parse
42
+
43
+ return urllib.parse.quote(text)
@@ -0,0 +1,236 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+ from kantree_cli.core.client import KantreeClient
5
+ from kantree_cli.core.errors import AmbiguityError, NotFoundError, ValidationError
6
+ from kantree_cli.services.cards import card_id as card_identifier
7
+ from kantree_cli.services.cards import get_card, get_workspace_card_by_ref
8
+ from kantree_cli.services.workspaces import get_workspace, list_workspaces
9
+ from kantree_cli.services.workspaces import workspace_id as workspace_identifier
10
+
11
+ JsonObject = dict[str, Any]
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class ResolvedWorkspace:
16
+ workspace: JsonObject
17
+ selector_source: str
18
+ selector_value: str
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class ResolvedCard:
23
+ card: JsonObject
24
+ selector_source: str
25
+ selector_value: str
26
+ workspace: JsonObject | None = None
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class ResolvedCardSelector:
31
+ card_id: int
32
+ selector_source: str
33
+ selector_value: str
34
+ workspace: JsonObject | None = None
35
+
36
+
37
+ def resolve_workspace(
38
+ client: KantreeClient,
39
+ *,
40
+ organization_id: int | None = None,
41
+ workspace_id: int | None = None,
42
+ workspace_name: str | None = None,
43
+ default_workspace: str | None = None,
44
+ default_workspace_source: str | None = None,
45
+ require_selector: bool = True,
46
+ ) -> ResolvedWorkspace | None:
47
+ if workspace_id is not None:
48
+ workspace = get_workspace(client, workspace_id)
49
+ return ResolvedWorkspace(
50
+ workspace=workspace,
51
+ selector_source="flag:--workspace-id",
52
+ selector_value=str(workspace_id),
53
+ )
54
+
55
+ if workspace_name is not None:
56
+ normalized_name = _normalize_workspace_name(
57
+ workspace_name,
58
+ source="flag:--workspace",
59
+ )
60
+ return _resolve_workspace_by_name(
61
+ client,
62
+ organization_id=organization_id,
63
+ workspace_name=normalized_name,
64
+ selector_source="flag:--workspace",
65
+ )
66
+
67
+ if default_workspace:
68
+ normalized_default = default_workspace.strip()
69
+ if normalized_default:
70
+ parsed_default_id = _parse_int(normalized_default)
71
+ if parsed_default_id is not None:
72
+ source = default_workspace_source or "runtime:default_workspace"
73
+ if parsed_default_id < 1:
74
+ raise ValidationError(
75
+ f"{source} expects a positive integer workspace id when numeric, "
76
+ f"got `{normalized_default}`."
77
+ )
78
+ workspace = get_workspace(client, parsed_default_id)
79
+ return ResolvedWorkspace(
80
+ workspace=workspace,
81
+ selector_source=source,
82
+ selector_value=normalized_default,
83
+ )
84
+ source = default_workspace_source or "runtime:default_workspace"
85
+ return _resolve_workspace_by_name(
86
+ client,
87
+ organization_id=organization_id,
88
+ workspace_name=normalized_default,
89
+ selector_source=source,
90
+ )
91
+
92
+ if require_selector:
93
+ raise ValidationError(
94
+ "Missing workspace selector. Use `--workspace-id`, `--workspace`, or configure a default workspace."
95
+ )
96
+
97
+ return None
98
+
99
+
100
+ def resolve_card_for_show(
101
+ client: KantreeClient,
102
+ *,
103
+ card_id_value: int | None,
104
+ card_ref: int | None,
105
+ workspace_id_value: int | None,
106
+ workspace_name_value: str | None,
107
+ organization_id: int | None = None,
108
+ default_workspace: str | None = None,
109
+ default_workspace_source: str | None = None,
110
+ ) -> ResolvedCard:
111
+ selector = resolve_card_selector(
112
+ client,
113
+ card_id_value=card_id_value,
114
+ card_ref=card_ref,
115
+ workspace_id_value=workspace_id_value,
116
+ workspace_name_value=workspace_name_value,
117
+ organization_id=organization_id,
118
+ default_workspace=default_workspace,
119
+ default_workspace_source=default_workspace_source,
120
+ )
121
+ return ResolvedCard(
122
+ card=get_card(client, selector.card_id),
123
+ selector_source=selector.selector_source,
124
+ selector_value=selector.selector_value,
125
+ workspace=selector.workspace,
126
+ )
127
+
128
+
129
+ def resolve_card_selector(
130
+ client: KantreeClient,
131
+ *,
132
+ card_id_value: int | None,
133
+ card_ref: int | None,
134
+ workspace_id_value: int | None,
135
+ workspace_name_value: str | None,
136
+ organization_id: int | None = None,
137
+ default_workspace: str | None = None,
138
+ default_workspace_source: str | None = None,
139
+ ) -> ResolvedCardSelector:
140
+ if card_id_value is not None and card_ref is not None:
141
+ raise ValidationError("Use either `CARD_ID` or `--ref`, not both.")
142
+
143
+ if card_id_value is not None:
144
+ return ResolvedCardSelector(
145
+ card_id=card_id_value,
146
+ selector_source="arg:CARD_ID",
147
+ selector_value=str(card_id_value),
148
+ )
149
+
150
+ if card_ref is None:
151
+ raise ValidationError("Missing card selector. Provide `CARD_ID` or `--ref`.")
152
+
153
+ resolved_workspace = resolve_workspace(
154
+ client,
155
+ organization_id=organization_id,
156
+ workspace_id=workspace_id_value,
157
+ workspace_name=workspace_name_value,
158
+ default_workspace=default_workspace,
159
+ default_workspace_source=default_workspace_source,
160
+ require_selector=True,
161
+ )
162
+
163
+ workspace = resolved_workspace.workspace
164
+ resolved_workspace_id = workspace_identifier(workspace)
165
+ match = get_workspace_card_by_ref(
166
+ client,
167
+ workspace_id=resolved_workspace_id,
168
+ card_ref=card_ref,
169
+ )
170
+ resolved_id = card_identifier(match, context="Card lookup response")
171
+ return ResolvedCardSelector(
172
+ card_id=resolved_id,
173
+ selector_source="flag:--ref",
174
+ selector_value=str(card_ref),
175
+ workspace=workspace,
176
+ )
177
+
178
+
179
+ def _resolve_workspace_by_name(
180
+ client: KantreeClient,
181
+ *,
182
+ organization_id: int | None,
183
+ workspace_name: str,
184
+ selector_source: str,
185
+ ) -> ResolvedWorkspace:
186
+ workspace_pool = list_workspaces(
187
+ client,
188
+ organization_id=organization_id,
189
+ include_team_projects=organization_id is not None,
190
+ )
191
+ matches = [
192
+ workspace for workspace in workspace_pool if workspace.get("title") == workspace_name
193
+ ]
194
+ if not matches:
195
+ if organization_id is None:
196
+ raise NotFoundError(
197
+ f"Workspace `{workspace_name}` not found. If it belongs to an organization "
198
+ "team, retry with root `--org ORG`."
199
+ )
200
+ raise NotFoundError(
201
+ f"Workspace `{workspace_name}` not found in organization `{organization_id}`."
202
+ )
203
+ if len(matches) > 1:
204
+ ids = ", ".join(str(match.get("id")) for match in matches)
205
+ raise AmbiguityError(
206
+ f"Workspace `{workspace_name}` is ambiguous ({len(matches)} matches: {ids}). "
207
+ "Use `--workspace-id`."
208
+ )
209
+ return ResolvedWorkspace(
210
+ workspace=matches[0],
211
+ selector_source=selector_source,
212
+ selector_value=workspace_name,
213
+ )
214
+
215
+
216
+ def _normalize_workspace_name(workspace_name: str, *, source: str) -> str:
217
+ normalized = workspace_name.strip()
218
+ if not normalized:
219
+ raise ValidationError(f"{source} expects a non-empty workspace name.")
220
+ return normalized
221
+
222
+
223
+ def _parse_int(value: str) -> int | None:
224
+ try:
225
+ return int(value)
226
+ except ValueError:
227
+ return None
228
+
229
+
230
+ def _workspace_label(workspace: JsonObject) -> str:
231
+ title = workspace.get("title")
232
+ if isinstance(title, str):
233
+ normalized_title = title.strip()
234
+ if normalized_title:
235
+ return normalized_title
236
+ return str(workspace_identifier(workspace))
@@ -0,0 +1,67 @@
1
+ from typing import Any
2
+
3
+ from kantree_cli.core.client import KantreeClient
4
+ from kantree_cli.core.errors import ApiError, ValidationError
5
+ from kantree_cli.core.response import extract_object_list, next_page_header_value
6
+
7
+ JsonObject = dict[str, Any]
8
+
9
+
10
+ def search_cards(
11
+ client: KantreeClient,
12
+ *,
13
+ organization_id: int | None = None,
14
+ filter_text: str | None = None,
15
+ limit: int | None = None,
16
+ ) -> list[JsonObject]:
17
+ if limit is not None and limit < 1:
18
+ raise ValidationError("`--limit` expects a positive integer.")
19
+
20
+ params: dict[str, str] = {}
21
+ if filter_text is not None:
22
+ normalized_filter = filter_text.strip()
23
+ if not normalized_filter:
24
+ raise ValidationError("`--filter` expects a non-empty value.")
25
+ params["filters"] = normalized_filter
26
+
27
+ path = "/search" if organization_id is None else f"/organizations/{organization_id}/search"
28
+ merged_cards: list[JsonObject] = []
29
+ next_page = 1
30
+ legacy_filter_param = False
31
+ while True:
32
+ request_params = dict(params)
33
+ if legacy_filter_param and "filters" in request_params:
34
+ request_params["filter"] = request_params.pop("filters")
35
+ request_params["page"] = str(next_page)
36
+ try:
37
+ response = client.request(method="GET", path=path, params=request_params)
38
+ except ApiError as exc:
39
+ if not legacy_filter_param and "filters" in params and _is_filter_required_error(exc):
40
+ legacy_filter_param = True
41
+ retry_params = dict(params)
42
+ retry_params["filter"] = retry_params.pop("filters")
43
+ retry_params["page"] = str(next_page)
44
+ response = client.request(method="GET", path=path, params=retry_params)
45
+ else:
46
+ raise
47
+ page_cards = extract_object_list(
48
+ response.data,
49
+ context=f"`GET {path}`",
50
+ preferred_keys=("cards", "data", "items", "results"),
51
+ )
52
+ merged_cards.extend(page_cards)
53
+
54
+ if limit is not None and len(merged_cards) >= limit:
55
+ return merged_cards[:limit]
56
+
57
+ parsed_next_page = next_page_header_value(response.headers)
58
+ if parsed_next_page is None:
59
+ break
60
+ next_page = parsed_next_page
61
+
62
+ return merged_cards
63
+
64
+
65
+ def _is_filter_required_error(exc: ApiError) -> bool:
66
+ message = str(exc).lower()
67
+ return "filter:" in message and "required field" in message