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,726 @@
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.core.response import (
7
+ extract_object,
8
+ extract_object_list,
9
+ next_page_header_value,
10
+ )
11
+
12
+ JsonObject = dict[str, Any]
13
+ CARD_STATES = (
14
+ "undecided",
15
+ "accepted",
16
+ "waiting",
17
+ "in_progress",
18
+ "completed",
19
+ "closed",
20
+ "dropped",
21
+ )
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class ResolvedField:
26
+ id: int
27
+ name: str
28
+ definition: JsonObject
29
+
30
+
31
+ @dataclass(slots=True)
32
+ class ResolvedGroup:
33
+ id: int
34
+ name: str
35
+ definition: JsonObject
36
+
37
+
38
+ def list_cards(
39
+ client: KantreeClient,
40
+ workspace_id: int,
41
+ *,
42
+ filter_text: str | None = None,
43
+ archived: bool = False,
44
+ limit: int | None = None,
45
+ page: int | None = None,
46
+ per_page: int | None = None,
47
+ all_pages: bool = False,
48
+ ) -> list[JsonObject]:
49
+ if limit is not None and limit < 1:
50
+ raise ValidationError("`--limit` expects a positive integer.")
51
+
52
+ params: dict[str, str] = {}
53
+ if filter_text is not None:
54
+ normalized_filter = filter_text.strip()
55
+ if not normalized_filter:
56
+ raise ValidationError("`--filter` expects a non-empty value.")
57
+ params["filter"] = normalized_filter
58
+
59
+ if archived:
60
+ params["with_archived"] = "true"
61
+ if all_pages:
62
+ if page is not None:
63
+ raise ValidationError("`--all` cannot be combined with `--page`.")
64
+
65
+ merged_cards: list[JsonObject] = []
66
+ next_page = 1
67
+ while True:
68
+ request_params = dict(params)
69
+ request_params["page"] = str(next_page)
70
+ if per_page is not None:
71
+ request_params["per_page"] = str(per_page)
72
+
73
+ response = client.request(
74
+ method="GET",
75
+ path=f"/projects/{workspace_id}/cards",
76
+ params=request_params,
77
+ )
78
+ page_cards = extract_object_list(
79
+ response.data,
80
+ context=f"`GET /projects/{workspace_id}/cards`",
81
+ preferred_keys=("cards", "data", "items", "results"),
82
+ )
83
+ merged_cards.extend(page_cards)
84
+
85
+ if limit is not None and len(merged_cards) >= limit:
86
+ return merged_cards[:limit]
87
+
88
+ parsed_next_page = next_page_header_value(response.headers)
89
+ if parsed_next_page is None:
90
+ break
91
+ next_page = parsed_next_page
92
+
93
+ return merged_cards
94
+
95
+ if page is not None:
96
+ params["page"] = str(page)
97
+ if per_page is not None:
98
+ params["per_page"] = str(per_page)
99
+
100
+ response = client.request(
101
+ method="GET",
102
+ path=f"/projects/{workspace_id}/cards",
103
+ params=params or None,
104
+ )
105
+ cards = extract_object_list(
106
+ response.data,
107
+ context=f"`GET /projects/{workspace_id}/cards`",
108
+ preferred_keys=("cards", "data", "items", "results"),
109
+ )
110
+ if limit is not None:
111
+ return cards[:limit]
112
+ return cards
113
+
114
+
115
+ def get_card(client: KantreeClient, card_id: int) -> JsonObject:
116
+ payload = client.get_json(f"/cards/{card_id}")
117
+ return extract_object(
118
+ payload,
119
+ context=f"`GET /cards/{card_id}`",
120
+ preferred_keys=("card", "data", "item", "result"),
121
+ )
122
+
123
+
124
+ def get_workspace_card_by_ref(
125
+ client: KantreeClient,
126
+ *,
127
+ workspace_id: int,
128
+ card_ref: int,
129
+ ) -> JsonObject:
130
+ payload = client.get_json(f"/projects/{workspace_id}/cards/{card_ref}")
131
+ return extract_object(
132
+ payload,
133
+ context=f"`GET /projects/{workspace_id}/cards/{card_ref}`",
134
+ preferred_keys=("card", "data", "item", "result"),
135
+ )
136
+
137
+
138
+ def list_card_children(
139
+ client: KantreeClient, card_id: int, *, archived: bool = False
140
+ ) -> list[JsonObject]:
141
+ endpoint = f"/cards/{card_id}/children/archived" if archived else f"/cards/{card_id}/children"
142
+ payload = client.get_json(endpoint)
143
+ return extract_object_list(
144
+ payload,
145
+ context=f"`GET {endpoint}`",
146
+ preferred_keys=("cards", "children", "data", "items", "results"),
147
+ )
148
+
149
+
150
+ def list_card_stream(client: KantreeClient, card_id: int) -> list[JsonObject]:
151
+ payload = client.get_json(f"/cards/{card_id}/stream")
152
+ return extract_object_list(
153
+ payload,
154
+ context=f"`GET /cards/{card_id}/stream`",
155
+ preferred_keys=("stream", "events", "data", "items", "results"),
156
+ )
157
+
158
+
159
+ def list_card_reminders(client: KantreeClient, card_id: int) -> list[JsonObject]:
160
+ payload = client.get_json(f"/cards/{card_id}/reminders")
161
+ return extract_object_list(
162
+ payload,
163
+ context=f"`GET /cards/{card_id}/reminders`",
164
+ preferred_keys=("reminders", "data", "items", "results"),
165
+ )
166
+
167
+
168
+ def create_card_reminder(
169
+ client: KantreeClient,
170
+ *,
171
+ card_id: int,
172
+ targets: list[str],
173
+ reminding_date: str,
174
+ ) -> JsonObject:
175
+ response = client.request(
176
+ method="POST",
177
+ path=f"/cards/{card_id}/reminders",
178
+ json_body={"targets": targets, "reminding_date": reminding_date},
179
+ )
180
+ return extract_object(
181
+ response.data,
182
+ context=f"`POST /cards/{card_id}/reminders`",
183
+ preferred_keys=("reminder", "data", "item", "result"),
184
+ )
185
+
186
+
187
+ def delete_card_reminder(
188
+ client: KantreeClient,
189
+ *,
190
+ card_id: int,
191
+ reminder_id: int,
192
+ ) -> Any:
193
+ response = client.request(
194
+ method="DELETE",
195
+ path=f"/cards/{card_id}/reminders/{reminder_id}",
196
+ )
197
+ return response.data
198
+
199
+
200
+ def comment_card(
201
+ client: KantreeClient,
202
+ *,
203
+ card_id: int,
204
+ message: str,
205
+ ) -> Any:
206
+ response = client.request(
207
+ method="POST",
208
+ path=f"/cards/{card_id}/comments",
209
+ json_body={"message": message},
210
+ )
211
+ return response.data
212
+
213
+
214
+ def archive_card(client: KantreeClient, *, card_id: int) -> Any:
215
+ response = client.request(
216
+ method="POST",
217
+ path=f"/cards/{card_id}/archive",
218
+ )
219
+ return response.data
220
+
221
+
222
+ def restore_card(client: KantreeClient, *, card_id: int) -> Any:
223
+ response = client.request(
224
+ method="DELETE",
225
+ path=f"/cards/{card_id}/archive",
226
+ )
227
+ return response.data
228
+
229
+
230
+ def delete_card(client: KantreeClient, *, card_id: int) -> Any:
231
+ response = client.request(
232
+ method="DELETE",
233
+ path=f"/cards/{card_id}",
234
+ )
235
+ return response.data
236
+
237
+
238
+ def create_card(
239
+ client: KantreeClient,
240
+ *,
241
+ parent_id: int,
242
+ title: str,
243
+ group_id: int | None = None,
244
+ model_id: int | None = None,
245
+ attributes: dict[int, object] | None = None,
246
+ ) -> JsonObject:
247
+ json_body: dict[str, object] = {"title": title}
248
+ if group_id is not None:
249
+ json_body["group_id"] = group_id
250
+ if model_id is not None:
251
+ json_body["model_id"] = model_id
252
+ if attributes is not None:
253
+ json_body["attributes"] = {str(field_id): value for field_id, value in attributes.items()}
254
+ response = client.request(
255
+ method="POST",
256
+ path=f"/cards/{parent_id}/children",
257
+ json_body=json_body,
258
+ )
259
+ return extract_object(
260
+ response.data,
261
+ context=f"`POST /cards/{parent_id}/children`",
262
+ preferred_keys=("card", "data", "item", "result"),
263
+ )
264
+
265
+
266
+ def update_card(
267
+ client: KantreeClient,
268
+ card_id: int,
269
+ *,
270
+ title: str | None = None,
271
+ state: str | None = None,
272
+ default_child_card_model_id: int | None = None,
273
+ image_cover_url: str | None = None,
274
+ ) -> Any:
275
+ payload: dict[str, object] = {}
276
+ if title is not None:
277
+ payload["title"] = title
278
+ if state is not None:
279
+ payload["state"] = state
280
+ if default_child_card_model_id is not None:
281
+ payload["default_child_card_model_id"] = default_child_card_model_id
282
+ if image_cover_url is not None:
283
+ payload["image_cover_url"] = image_cover_url
284
+ if not payload:
285
+ raise ValidationError("Card update requires at least one field.")
286
+ response = client.request(
287
+ method="PUT",
288
+ path=f"/cards/{card_id}",
289
+ json_body=payload,
290
+ )
291
+ return response.data
292
+
293
+
294
+ def set_card_attributes(
295
+ client: KantreeClient,
296
+ card_id: int,
297
+ *,
298
+ attributes: dict[int, object],
299
+ ) -> Any:
300
+ if not attributes:
301
+ raise ValidationError("Card attribute update requires at least one field.")
302
+ encoded = {str(field_id): value for field_id, value in attributes.items()}
303
+ response = client.request(
304
+ method="POST",
305
+ path=f"/cards/{card_id}/attributes",
306
+ json_body={"attributes": encoded},
307
+ )
308
+ return response.data
309
+
310
+
311
+ def append_card_attribute(
312
+ client: KantreeClient,
313
+ *,
314
+ card_id: int,
315
+ field_id: int,
316
+ value: object,
317
+ ) -> Any:
318
+ response = client.request(
319
+ method="POST",
320
+ path=f"/cards/{card_id}/attributes/{field_id}/append",
321
+ json_body={"value": value},
322
+ )
323
+ return response.data
324
+
325
+
326
+ def pop_card_attribute(
327
+ client: KantreeClient,
328
+ *,
329
+ card_id: int,
330
+ field_id: int,
331
+ value: object,
332
+ ) -> Any:
333
+ response = client.request(
334
+ method="POST",
335
+ path=f"/cards/{card_id}/attributes/{field_id}/pop",
336
+ json_body={"value": value},
337
+ )
338
+ return response.data
339
+
340
+
341
+ def clear_card_attribute(
342
+ client: KantreeClient,
343
+ *,
344
+ card_id: int,
345
+ field_id: int,
346
+ ) -> Any:
347
+ response = client.request(
348
+ method="DELETE",
349
+ path=f"/cards/{card_id}/attributes/{field_id}",
350
+ )
351
+ return response.data
352
+
353
+
354
+ def set_card_model(
355
+ client: KantreeClient,
356
+ *,
357
+ card_id: int,
358
+ model_id: int | None,
359
+ ) -> Any:
360
+ response = client.request(
361
+ method="POST",
362
+ path=f"/cards/{card_id}/model",
363
+ json_body={"model_id": model_id},
364
+ )
365
+ return response.data
366
+
367
+
368
+ def relocate_card(
369
+ client: KantreeClient,
370
+ *,
371
+ card_id: int,
372
+ group_id: int,
373
+ parent_id: int | None = None,
374
+ position: int | None = None,
375
+ ) -> Any:
376
+ payload: dict[str, object] = {"group_id": group_id}
377
+ if parent_id is not None:
378
+ payload["parent_id"] = parent_id
379
+ if position is not None:
380
+ payload["position"] = position
381
+ response = client.request(
382
+ method="POST",
383
+ path=f"/cards/{card_id}/relocate",
384
+ json_body=payload,
385
+ )
386
+ return response.data
387
+
388
+
389
+ def add_card_to_group(
390
+ client: KantreeClient,
391
+ *,
392
+ card_id: int,
393
+ group_id: int,
394
+ ) -> Any:
395
+ response = client.request(
396
+ method="POST",
397
+ path=f"/cards/{card_id}/add-to-group",
398
+ json_body={"group_id": group_id},
399
+ )
400
+ return response.data
401
+
402
+
403
+ def remove_card_from_group(
404
+ client: KantreeClient,
405
+ *,
406
+ card_id: int,
407
+ group_id: int,
408
+ ) -> Any:
409
+ response = client.request(
410
+ method="POST",
411
+ path=f"/cards/{card_id}/remove-from-group",
412
+ json_body={"group_id": group_id},
413
+ )
414
+ return response.data
415
+
416
+
417
+ def clear_card_group_type(
418
+ client: KantreeClient,
419
+ *,
420
+ card_id: int,
421
+ type_id: int,
422
+ ) -> Any:
423
+ response = client.request(
424
+ method="POST",
425
+ path=f"/cards/{card_id}/remove-from-group-type",
426
+ json_body={"type_id": type_id},
427
+ )
428
+ return response.data
429
+
430
+
431
+ def copy_card(
432
+ client: KantreeClient,
433
+ *,
434
+ card_id: int,
435
+ parent_id: int | None = None,
436
+ group_id: int | None = None,
437
+ position: int | None = None,
438
+ ) -> Any:
439
+ payload: dict[str, object] = {}
440
+ if parent_id is not None:
441
+ payload["parent_id"] = parent_id
442
+ if group_id is not None:
443
+ payload["group_id"] = group_id
444
+ if position is not None:
445
+ payload["position"] = position
446
+ response = client.request(
447
+ method="POST", path=f"/cards/{card_id}/copy", json_body=payload or None
448
+ )
449
+ return response.data
450
+
451
+
452
+ def list_card_states(client: KantreeClient, *, card_id: int) -> list[JsonObject]:
453
+ payload = client.get_json(f"/cards/{card_id}/states")
454
+ return extract_object_list(
455
+ payload,
456
+ context=f"`GET /cards/{card_id}/states`",
457
+ preferred_keys=("states", "data", "items", "results"),
458
+ )
459
+
460
+
461
+ def get_card_hierarchy(client: KantreeClient, *, card_id: int) -> Any:
462
+ return client.get_json(f"/cards/{card_id}/hierarchy")
463
+
464
+
465
+ def list_card_descendants(
466
+ client: KantreeClient,
467
+ *,
468
+ card_id: int,
469
+ reversed_order: bool = False,
470
+ ) -> list[JsonObject]:
471
+ params: dict[str, str] = {}
472
+ if reversed_order:
473
+ params["reverse"] = "true"
474
+ response = client.request(
475
+ method="GET",
476
+ path=f"/cards/{card_id}/descendants",
477
+ params=params or None,
478
+ )
479
+ return extract_object_list(
480
+ response.data,
481
+ context=f"`GET /cards/{card_id}/descendants`",
482
+ preferred_keys=("cards", "descendants", "data", "items", "results"),
483
+ )
484
+
485
+
486
+ def batch_operation(
487
+ client: KantreeClient,
488
+ *,
489
+ workspace_id: int,
490
+ operation: str,
491
+ params: dict[str, object],
492
+ card_ids: list[int] | None = None,
493
+ filter_text: str | None = None,
494
+ parent_id: int | None = None,
495
+ group_id: int | None = None,
496
+ with_archived: bool = False,
497
+ ) -> Any:
498
+ """Execute a batch operation server-side."""
499
+ payload: dict[str, object] = {"operation": operation, "params": params}
500
+ if card_ids is not None:
501
+ payload["card_ids"] = card_ids
502
+ if filter_text is not None:
503
+ payload["filter"] = filter_text
504
+ if parent_id is not None:
505
+ payload["parent_id"] = parent_id
506
+ if group_id is not None:
507
+ payload["group_id"] = group_id
508
+ if with_archived:
509
+ payload["with_archived"] = True
510
+ response = client.request(
511
+ method="POST",
512
+ path=f"/projects/{workspace_id}/batch",
513
+ json_body=payload,
514
+ )
515
+ return response.data
516
+
517
+
518
+ def get_async_job(client: KantreeClient, job_id: int | str) -> Any:
519
+ response = client.request(method="GET", path=f"/async/{job_id}")
520
+ return response.data
521
+
522
+
523
+ def batch_dates(
524
+ client: KantreeClient,
525
+ *,
526
+ workspace_id: int,
527
+ cards: dict[str, list[dict[str, Any]]],
528
+ ) -> Any:
529
+ response = client.request(
530
+ method="POST",
531
+ path=f"/projects/{workspace_id}/batch/dates",
532
+ json_body={"cards": cards},
533
+ )
534
+ return response.data
535
+
536
+
537
+ def favorite_card(client: KantreeClient, *, card_id: int) -> Any:
538
+ response = client.request(method="POST", path=f"/cards/{card_id}/favorite")
539
+ return response.data
540
+
541
+
542
+ def unfavorite_card(client: KantreeClient, *, card_id: int) -> Any:
543
+ response = client.request(method="DELETE", path=f"/cards/{card_id}/favorite")
544
+ return response.data
545
+
546
+
547
+ def resolve_field_selector(fields: list[JsonObject], selector: str) -> ResolvedField:
548
+ normalized_selector = selector.strip()
549
+ if not normalized_selector:
550
+ raise ValidationError("Field selector must not be empty.")
551
+
552
+ parsed_id = _parse_int(normalized_selector)
553
+ if parsed_id is not None:
554
+ matches = [field for field in fields if field_id(field, context="Field list") == parsed_id]
555
+ if not matches:
556
+ raise NotFoundError(f"Field id `{parsed_id}` was not found in this workspace.")
557
+ return _single_field_match(matches, selector=f"id `{parsed_id}`")
558
+
559
+ matches = [
560
+ field for field in fields if field_name(field, context="Field list") == normalized_selector
561
+ ]
562
+ if not matches:
563
+ raise NotFoundError(f"Field `{normalized_selector}` was not found in this workspace.")
564
+ return _single_field_match(matches, selector=f"`{normalized_selector}`")
565
+
566
+
567
+ def resolve_group_selector(groups: list[JsonObject], selector: str) -> ResolvedGroup:
568
+ normalized_selector = selector.strip()
569
+ if not normalized_selector:
570
+ raise ValidationError("Group selector must not be empty.")
571
+
572
+ parsed_id = _parse_int(normalized_selector)
573
+ if parsed_id is not None:
574
+ if parsed_id < 1:
575
+ raise ValidationError(
576
+ f"Group selector expects a positive integer id, got `{normalized_selector}`."
577
+ )
578
+ return ResolvedGroup(
579
+ id=parsed_id,
580
+ name=normalized_selector,
581
+ definition={"id": parsed_id},
582
+ )
583
+
584
+ matches = [
585
+ group for group in groups if group_name(group, context="Group list") == normalized_selector
586
+ ]
587
+ if not matches:
588
+ raise NotFoundError(f"Group `{normalized_selector}` was not found under this parent.")
589
+ if len(matches) > 1:
590
+ ids = ", ".join(str(group_id(match, context="Group lookup")) for match in matches)
591
+ raise AmbiguityError(
592
+ f"Group `{normalized_selector}` is ambiguous under this parent "
593
+ f"({len(matches)} matches: {ids}). Use a group id."
594
+ )
595
+
596
+ match = matches[0]
597
+ return ResolvedGroup(
598
+ id=group_id(match, context="Group lookup"),
599
+ name=group_name(match, context="Group lookup"),
600
+ definition=match,
601
+ )
602
+
603
+
604
+ def field_id(field: JsonObject, *, context: str) -> int:
605
+ parsed = _parse_int(field.get("id"))
606
+ if parsed is None:
607
+ raise ValidationError(f"{context} does not contain a usable integer `id`.")
608
+ return parsed
609
+
610
+
611
+ def field_name(field: JsonObject, *, context: str) -> str:
612
+ for key in ("name", "title"):
613
+ value = field.get(key)
614
+ if isinstance(value, str):
615
+ normalized = value.strip()
616
+ if normalized:
617
+ return normalized
618
+ raise ValidationError(f"{context} does not contain a usable field name.")
619
+
620
+
621
+ def field_is_group_type(field: JsonObject) -> bool:
622
+ for raw_token in _field_type_tokens(field):
623
+ normalized = raw_token.strip().lower().replace("-", "_").replace(" ", "_")
624
+ if normalized == "group_type":
625
+ return True
626
+ return False
627
+
628
+
629
+ def group_id(group: JsonObject, *, context: str) -> int:
630
+ parsed = _parse_int(group.get("id"))
631
+ if parsed is None:
632
+ raise ValidationError(f"{context} does not contain a usable integer `id`.")
633
+ return parsed
634
+
635
+
636
+ def group_name(group: JsonObject, *, context: str) -> str:
637
+ for key in ("title", "name"):
638
+ value = group.get(key)
639
+ if isinstance(value, str):
640
+ normalized = value.strip()
641
+ if normalized:
642
+ return normalized
643
+ raise ValidationError(f"{context} does not contain a usable group name.")
644
+
645
+
646
+ def card_parent_id(card: JsonObject, *, context: str) -> int:
647
+ parsed_parent = _parse_int(card.get("parent_id"))
648
+ if parsed_parent is not None:
649
+ return parsed_parent
650
+
651
+ parent = card.get("parent")
652
+ if isinstance(parent, dict):
653
+ parsed_parent = _parse_int(parent.get("id"))
654
+ if parsed_parent is not None:
655
+ return parsed_parent
656
+
657
+ raise ValidationError(f"{context} does not contain a usable `parent_id`.")
658
+
659
+
660
+ def card_workspace_id(card: JsonObject, *, context: str) -> int:
661
+ for key in ("project_id", "workspace_id"):
662
+ parsed = _parse_int(card.get(key))
663
+ if parsed is not None:
664
+ return parsed
665
+
666
+ for key in ("project", "workspace"):
667
+ nested = card.get(key)
668
+ if isinstance(nested, dict):
669
+ parsed = _parse_int(nested.get("id"))
670
+ if parsed is not None:
671
+ return parsed
672
+
673
+ raise ValidationError(f"{context} does not contain a usable workspace id.")
674
+
675
+
676
+ def card_id(card: JsonObject, *, context: str) -> int:
677
+ parsed = _parse_int(card.get("id"))
678
+ if parsed is None:
679
+ raise ValidationError(f"{context} does not contain a usable integer `id`.")
680
+ return parsed
681
+
682
+
683
+ def _single_field_match(matches: list[JsonObject], *, selector: str) -> ResolvedField:
684
+ if len(matches) > 1:
685
+ ids = ", ".join(str(field_id(match, context="Field lookup")) for match in matches)
686
+ raise AmbiguityError(
687
+ f"Field {selector} is ambiguous in this workspace "
688
+ f"({len(matches)} matches: {ids}). Use a field id."
689
+ )
690
+
691
+ match = matches[0]
692
+ return ResolvedField(
693
+ id=field_id(match, context="Field lookup"),
694
+ name=field_name(match, context="Field lookup"),
695
+ definition=match,
696
+ )
697
+
698
+
699
+ def _field_type_tokens(field: JsonObject) -> list[str]:
700
+ tokens: list[str] = []
701
+ for key in ("type", "attribute_type", "field_type", "kind", "attribute_type_name", "type_name"):
702
+ value = field.get(key)
703
+ if isinstance(value, str):
704
+ tokens.append(value)
705
+ elif isinstance(value, dict):
706
+ for nested_key in ("name", "slug", "key", "code", "type"):
707
+ nested_value = value.get(nested_key)
708
+ if isinstance(nested_value, str):
709
+ tokens.append(nested_value)
710
+ return tokens
711
+
712
+
713
+ def _parse_int(value: Any) -> int | None:
714
+ if isinstance(value, bool):
715
+ return None
716
+ if isinstance(value, int):
717
+ return value
718
+ if isinstance(value, str):
719
+ stripped = value.strip()
720
+ if not stripped:
721
+ return None
722
+ try:
723
+ return int(stripped)
724
+ except ValueError:
725
+ return None
726
+ return None