kaiten-cli 0.3.3__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 (99) hide show
  1. kaiten_cli/__init__.py +5 -0
  2. kaiten_cli/__main__.py +9 -0
  3. kaiten_cli/agent_gateway.py +577 -0
  4. kaiten_cli/app.py +1636 -0
  5. kaiten_cli/completion.py +570 -0
  6. kaiten_cli/discovery.py +39 -0
  7. kaiten_cli/errors.py +79 -0
  8. kaiten_cli/i18n.py +92 -0
  9. kaiten_cli/locales/ru.json +2967 -0
  10. kaiten_cli/localized_click.py +160 -0
  11. kaiten_cli/models.py +202 -0
  12. kaiten_cli/profiles.py +316 -0
  13. kaiten_cli/registry/__init__.py +260 -0
  14. kaiten_cli/registry/addons.py +392 -0
  15. kaiten_cli/registry/audit_and_analytics.py +451 -0
  16. kaiten_cli/registry/automation_schema.py +44 -0
  17. kaiten_cli/registry/automation_schemas.json +7417 -0
  18. kaiten_cli/registry/automations.py +342 -0
  19. kaiten_cli/registry/base.py +62 -0
  20. kaiten_cli/registry/blockers.py +320 -0
  21. kaiten_cli/registry/boards.py +520 -0
  22. kaiten_cli/registry/card_relations.py +376 -0
  23. kaiten_cli/registry/card_types.py +363 -0
  24. kaiten_cli/registry/cards.py +1329 -0
  25. kaiten_cli/registry/charts.py +539 -0
  26. kaiten_cli/registry/checklists.py +600 -0
  27. kaiten_cli/registry/columns.py +558 -0
  28. kaiten_cli/registry/comments.py +224 -0
  29. kaiten_cli/registry/custom_directories.py +980 -0
  30. kaiten_cli/registry/custom_properties.py +1460 -0
  31. kaiten_cli/registry/dashboards.py +564 -0
  32. kaiten_cli/registry/documents.py +761 -0
  33. kaiten_cli/registry/external_links.py +128 -0
  34. kaiten_cli/registry/files.py +738 -0
  35. kaiten_cli/registry/github_addon.py +613 -0
  36. kaiten_cli/registry/iterations.py +397 -0
  37. kaiten_cli/registry/lanes.py +205 -0
  38. kaiten_cli/registry/live_contracts.py +361 -0
  39. kaiten_cli/registry/members.py +336 -0
  40. kaiten_cli/registry/module_docs.py +108 -0
  41. kaiten_cli/registry/projects.py +369 -0
  42. kaiten_cli/registry/query.py +129 -0
  43. kaiten_cli/registry/restrictions.py +249 -0
  44. kaiten_cli/registry/roles_and_groups.py +1102 -0
  45. kaiten_cli/registry/scim.py +263 -0
  46. kaiten_cli/registry/service_desk.py +1270 -0
  47. kaiten_cli/registry/snapshot.py +187 -0
  48. kaiten_cli/registry/spaces.py +272 -0
  49. kaiten_cli/registry/subscribers.py +168 -0
  50. kaiten_cli/registry/tags.py +193 -0
  51. kaiten_cli/registry/time_logs.py +353 -0
  52. kaiten_cli/registry/tree.py +352 -0
  53. kaiten_cli/registry/utilities.py +287 -0
  54. kaiten_cli/registry/webhooks.py +265 -0
  55. kaiten_cli/runtime/__init__.py +1 -0
  56. kaiten_cli/runtime/automation_validation.py +115 -0
  57. kaiten_cli/runtime/behaviors.py +787 -0
  58. kaiten_cli/runtime/cache.py +733 -0
  59. kaiten_cli/runtime/client.py +387 -0
  60. kaiten_cli/runtime/endpoints.py +59 -0
  61. kaiten_cli/runtime/executor.py +269 -0
  62. kaiten_cli/runtime/fs_security.py +168 -0
  63. kaiten_cli/runtime/input.py +310 -0
  64. kaiten_cli/runtime/output.py +59 -0
  65. kaiten_cli/runtime/public_validation.py +35 -0
  66. kaiten_cli/runtime/snapshot_build.py +527 -0
  67. kaiten_cli/runtime/snapshot_common.py +363 -0
  68. kaiten_cli/runtime/snapshot_query.py +306 -0
  69. kaiten_cli/runtime/snapshot_store.py +1192 -0
  70. kaiten_cli/runtime/snapshots.py +62 -0
  71. kaiten_cli/runtime/sqlite_errors.py +23 -0
  72. kaiten_cli/runtime/support/__init__.py +1 -0
  73. kaiten_cli/runtime/support/addons.py +953 -0
  74. kaiten_cli/runtime/support/audit.py +70 -0
  75. kaiten_cli/runtime/support/batch.py +234 -0
  76. kaiten_cli/runtime/support/card_move_url.py +213 -0
  77. kaiten_cli/runtime/support/cards.py +174 -0
  78. kaiten_cli/runtime/support/checklists.py +21 -0
  79. kaiten_cli/runtime/support/company_users.py +117 -0
  80. kaiten_cli/runtime/support/dashboards.py +35 -0
  81. kaiten_cli/runtime/support/documents.py +175 -0
  82. kaiten_cli/runtime/support/files.py +561 -0
  83. kaiten_cli/runtime/support/markdown_export.py +525 -0
  84. kaiten_cli/runtime/support/pagination.py +167 -0
  85. kaiten_cli/runtime/support/projects.py +66 -0
  86. kaiten_cli/runtime/support/relations.py +86 -0
  87. kaiten_cli/runtime/support/restrictions.py +62 -0
  88. kaiten_cli/runtime/support/spaces.py +20 -0
  89. kaiten_cli/runtime/support/time_logs.py +61 -0
  90. kaiten_cli/runtime/support/tree.py +140 -0
  91. kaiten_cli/runtime/support/tree_sharing.py +447 -0
  92. kaiten_cli/runtime/trace.py +500 -0
  93. kaiten_cli/runtime/transforms.py +128 -0
  94. kaiten_cli/schema_docs.py +22 -0
  95. kaiten_cli/update_check.py +519 -0
  96. kaiten_cli-0.3.3.dist-info/METADATA +1110 -0
  97. kaiten_cli-0.3.3.dist-info/RECORD +99 -0
  98. kaiten_cli-0.3.3.dist-info/WHEEL +4 -0
  99. kaiten_cli-0.3.3.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,387 @@
1
+ """Async Kaiten HTTP client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import logging
8
+ import random
9
+ import time
10
+ from datetime import timezone
11
+ from email.utils import parsedate_to_datetime
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ from kaiten_cli import __version__
17
+ from kaiten_cli.errors import ApiError, ConfigError, TransportError
18
+ from kaiten_cli.i18n import tr
19
+ from kaiten_cli.models import CACHE_POLICY_NONE, DebugReporter
20
+ from kaiten_cli.runtime.cache import ExecutionContext
21
+ from kaiten_cli.runtime.endpoints import profile_api_base_url, profile_origin
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ API_VERSION = "latest"
26
+ # Keep the client below Kaiten's 50 requests/second API limit.
27
+ RATE_LIMIT_DELAY = 0.025
28
+ RETRY_DELAY = 2.0
29
+ MAX_RETRIES = 3
30
+ MAX_RETRY_AFTER = 30.0
31
+ RETRY_JITTER_MAX = 0.25
32
+ RETRYABLE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
33
+ DEFAULT_TIMEOUT = 20.0
34
+ HEAVY_TIMEOUT = 60.0
35
+ CLIENT_TYPE = "cli"
36
+ CLIENT_NAME = "kaiten-cli"
37
+ CLIENT_USER_AGENT = f"{CLIENT_NAME}/{__version__}"
38
+ CLIENT_IDENTITY_HEADERS = {
39
+ "User-Agent": CLIENT_USER_AGENT,
40
+ "X-Kaiten-Client-Type": CLIENT_TYPE,
41
+ "X-Kaiten-Client-Name": CLIENT_NAME,
42
+ "X-Kaiten-Client-Version": __version__,
43
+ }
44
+
45
+
46
+ class KaitenClient:
47
+ """Async HTTP client for Kaiten with low-load defaults."""
48
+
49
+ def __init__(
50
+ self,
51
+ *,
52
+ domain: str,
53
+ token: str,
54
+ reporter: DebugReporter | None = None,
55
+ execution_context: ExecutionContext | None = None,
56
+ cache_policy: str = CACHE_POLICY_NONE,
57
+ mutates_remote_state: bool = True,
58
+ ):
59
+ if not domain:
60
+ raise ConfigError(tr("KAITEN_DOMAIN is required"))
61
+ if not token:
62
+ raise ConfigError(tr("KAITEN_TOKEN is required"))
63
+ self.domain = domain
64
+ self.token = token
65
+ self._reporter = reporter
66
+ self.execution_context = execution_context
67
+ self.cache_policy = cache_policy
68
+ self.mutates_remote_state = mutates_remote_state
69
+ self.root_url = profile_origin(domain)
70
+ self.base_url = profile_api_base_url(domain, api_version=API_VERSION)
71
+ self._client: httpx.AsyncClient | None = None
72
+ self._last_request_time = 0.0
73
+ self._rate_lock = asyncio.Lock()
74
+
75
+ def _debug(self, message: str) -> None:
76
+ if self._reporter is not None:
77
+ self._reporter(message)
78
+
79
+ async def _get_client(self) -> httpx.AsyncClient:
80
+ if self._client is None or self._client.is_closed:
81
+ self._client = httpx.AsyncClient(
82
+ base_url=self.base_url,
83
+ headers={
84
+ "Authorization": f"Bearer {self.token}",
85
+ "Accept": "application/json",
86
+ **CLIENT_IDENTITY_HEADERS,
87
+ },
88
+ )
89
+ return self._client
90
+
91
+ async def _rate_limit(self) -> None:
92
+ if self.execution_context is not None:
93
+ await self.execution_context.wait_for_rate_slot(RATE_LIMIT_DELAY)
94
+ return
95
+ async with self._rate_lock:
96
+ now = asyncio.get_running_loop().time()
97
+ elapsed = now - self._last_request_time
98
+ if elapsed < RATE_LIMIT_DELAY:
99
+ await asyncio.sleep(RATE_LIMIT_DELAY - elapsed)
100
+ self._last_request_time = asyncio.get_running_loop().time()
101
+
102
+ @staticmethod
103
+ def _retry_delay(attempt: int, retry_after: float | None = None) -> float:
104
+ base_delay = retry_after if retry_after is not None else RETRY_DELAY * (attempt + 1)
105
+ non_negative_delay = max(0.0, base_delay)
106
+ jitter = random.uniform(0.0, min(RETRY_JITTER_MAX, non_negative_delay * 0.1))
107
+ return min(non_negative_delay + jitter, MAX_RETRY_AFTER)
108
+
109
+ @staticmethod
110
+ def _parse_retry_after(value: str | None) -> float | None:
111
+ if not value:
112
+ return None
113
+ with contextlib.suppress(ValueError):
114
+ return float(value)
115
+ try:
116
+ parsed = parsedate_to_datetime(value)
117
+ except (TypeError, ValueError, OverflowError):
118
+ return None
119
+ if parsed.tzinfo is None:
120
+ parsed = parsed.replace(tzinfo=timezone.utc)
121
+ return max(0.0, parsed.timestamp() - time.time())
122
+
123
+ @staticmethod
124
+ def _parse_rate_limit_reset(value: str | None) -> float | None:
125
+ if not value:
126
+ return None
127
+ try:
128
+ reset_at = float(value)
129
+ except (TypeError, ValueError):
130
+ return None
131
+ return max(0.0, reset_at - time.time())
132
+
133
+ @staticmethod
134
+ def _ambiguous_mutation_error(method: str, path: str, detail: str) -> TransportError:
135
+ return TransportError(
136
+ tr(
137
+ "{value_0} calling {value_1} {value_2}. The request was not retried because it may have changed Kaiten; the remote outcome is unknown. Verify the remote state with --cache-mode off or --cache-mode refresh before retrying.",
138
+ value_0=detail,
139
+ value_1=method,
140
+ value_2=path,
141
+ )
142
+ )
143
+
144
+ async def _request(
145
+ self,
146
+ method: str,
147
+ path: str,
148
+ *,
149
+ params: dict[str, Any] | None = None,
150
+ json: dict[str, Any] | None = None,
151
+ files: Any = None,
152
+ timeout: float = DEFAULT_TIMEOUT,
153
+ ) -> Any:
154
+ client = await self._get_client()
155
+ method = method.upper()
156
+ retryable = method in RETRYABLE_METHODS
157
+ mutation_attempt = self.mutates_remote_state and not retryable
158
+ attempts = MAX_RETRIES if retryable else 1
159
+ if params:
160
+ params = {key: value for key, value in params.items() if value is not None}
161
+
162
+ for attempt in range(attempts):
163
+ await self._rate_limit()
164
+ started = time.perf_counter()
165
+ try:
166
+ response = await client.request(
167
+ method,
168
+ path,
169
+ params=params,
170
+ json=json,
171
+ files=files,
172
+ timeout=timeout,
173
+ )
174
+ if self.execution_context is not None:
175
+ self.execution_context.stats.record_http_attempt(
176
+ source="kaiten_api",
177
+ method=method,
178
+ path=path,
179
+ wait_ms=(time.perf_counter() - started) * 1000.0,
180
+ status_code=response.status_code,
181
+ )
182
+ if response.status_code == 429 and retryable and attempt < attempts - 1:
183
+ parsed_retry_after = self._parse_retry_after(
184
+ response.headers.get("Retry-After")
185
+ )
186
+ if parsed_retry_after is None:
187
+ parsed_retry_after = self._parse_rate_limit_reset(
188
+ response.headers.get("X-RateLimit-Reset")
189
+ )
190
+ delay = self._retry_delay(attempt, parsed_retry_after)
191
+ self._debug(
192
+ tr(
193
+ "retry: rate-limited on {value_0} {value_1}, waiting {value_2:.2f}s",
194
+ value_0=method,
195
+ value_1=path,
196
+ value_2=delay,
197
+ )
198
+ )
199
+ logger.warning(tr("Rate limited, retrying after %.2fs"), delay)
200
+ if self.execution_context is not None:
201
+ self.execution_context.stats.retry_count += 1
202
+ await asyncio.sleep(delay)
203
+ continue
204
+
205
+ if response.status_code >= 400:
206
+ body = None
207
+ with contextlib.suppress(Exception):
208
+ body = response.json()
209
+ message = ""
210
+ if isinstance(body, dict):
211
+ message = str(body.get("message", body.get("error", "")))
212
+ if not message:
213
+ message = response.text[:500]
214
+ if mutation_attempt and response.status_code >= 500:
215
+ raise self._ambiguous_mutation_error(
216
+ method, path, f"HTTP {response.status_code}"
217
+ )
218
+ raise ApiError(response.status_code, message, body)
219
+
220
+ if response.status_code == 204 or not response.content:
221
+ return None
222
+ try:
223
+ return response.json()
224
+ except ValueError as exc:
225
+ if mutation_attempt:
226
+ raise TransportError(
227
+ tr(
228
+ "HTTP {value_0} succeeded for {value_1} {value_2}, but the response was not valid JSON. The remote change may have been applied; verify the remote state with --cache-mode off or --cache-mode refresh before retrying.",
229
+ value_0=response.status_code,
230
+ value_1=method,
231
+ value_2=path,
232
+ )
233
+ ) from exc
234
+ raise TransportError(
235
+ tr(
236
+ "Kaiten returned invalid JSON for {value_0} {value_1}.",
237
+ value_0=method,
238
+ value_1=path,
239
+ )
240
+ ) from exc
241
+ except ApiError:
242
+ raise
243
+ except httpx.TimeoutException as exc:
244
+ if self.execution_context is not None:
245
+ self.execution_context.stats.record_http_attempt(
246
+ source="kaiten_api",
247
+ method=method,
248
+ path=path,
249
+ wait_ms=(time.perf_counter() - started) * 1000.0,
250
+ error=True,
251
+ )
252
+ if mutation_attempt:
253
+ raise self._ambiguous_mutation_error(method, path, tr("Timeout")) from exc
254
+ if not retryable:
255
+ raise TransportError(
256
+ tr("Timeout calling Kaiten API: {value_0}", value_0=exc)
257
+ ) from exc
258
+ if attempt == attempts - 1:
259
+ raise TransportError(
260
+ tr("Timeout calling Kaiten API: {value_0}", value_0=exc)
261
+ ) from exc
262
+ delay = self._retry_delay(attempt)
263
+ self._debug(
264
+ tr(
265
+ "retry: timeout on {value_0} {value_1}, attempt {value_2}/{value_3}, waiting {value_4:.2f}s",
266
+ value_0=method,
267
+ value_1=path,
268
+ value_2=attempt + 1,
269
+ value_3=MAX_RETRIES,
270
+ value_4=delay,
271
+ )
272
+ )
273
+ if self.execution_context is not None:
274
+ self.execution_context.stats.retry_count += 1
275
+ await asyncio.sleep(delay)
276
+ except httpx.HTTPError as exc:
277
+ if self.execution_context is not None:
278
+ self.execution_context.stats.record_http_attempt(
279
+ source="kaiten_api",
280
+ method=method,
281
+ path=path,
282
+ wait_ms=(time.perf_counter() - started) * 1000.0,
283
+ error=True,
284
+ )
285
+ if mutation_attempt:
286
+ raise self._ambiguous_mutation_error(
287
+ method, path, tr("Connection error")
288
+ ) from exc
289
+ if not retryable:
290
+ raise TransportError(tr("Connection error: {value_0}", value_0=exc)) from exc
291
+ if attempt == attempts - 1:
292
+ raise TransportError(tr("Connection error: {value_0}", value_0=exc)) from exc
293
+ delay = self._retry_delay(attempt)
294
+ self._debug(
295
+ tr(
296
+ "retry: transport error on {value_0} {value_1}, attempt {value_2}/{value_3}, waiting {value_4:.2f}s",
297
+ value_0=method,
298
+ value_1=path,
299
+ value_2=attempt + 1,
300
+ value_3=MAX_RETRIES,
301
+ value_4=delay,
302
+ )
303
+ )
304
+ if self.execution_context is not None:
305
+ self.execution_context.stats.retry_count += 1
306
+ await asyncio.sleep(delay)
307
+
308
+ raise TransportError(tr("Rate limit retries exhausted"))
309
+
310
+ async def get(
311
+ self, path: str, *, params: dict[str, Any] | None = None, timeout: float = DEFAULT_TIMEOUT
312
+ ) -> Any:
313
+ if self.execution_context is None:
314
+ return await self._request("GET", path, params=params, timeout=timeout)
315
+ return await self.execution_context.get_json(
316
+ method="GET",
317
+ path=path,
318
+ params=params,
319
+ cache_policy=self.cache_policy,
320
+ fetch=lambda: self._request("GET", path, params=params, timeout=timeout),
321
+ )
322
+
323
+ async def post(
324
+ self,
325
+ path: str,
326
+ *,
327
+ params: dict[str, Any] | None = None,
328
+ json: dict[str, Any] | None = None,
329
+ files: Any = None,
330
+ timeout: float = DEFAULT_TIMEOUT,
331
+ ) -> Any:
332
+ try:
333
+ return await self._request(
334
+ "POST", path, params=params, json=json, files=files, timeout=timeout
335
+ )
336
+ finally:
337
+ await self._invalidate_after_remote_mutation()
338
+
339
+ async def put(
340
+ self,
341
+ path: str,
342
+ *,
343
+ params: dict[str, Any] | None = None,
344
+ json: dict[str, Any] | None = None,
345
+ files: Any = None,
346
+ timeout: float = DEFAULT_TIMEOUT,
347
+ ) -> Any:
348
+ try:
349
+ return await self._request(
350
+ "PUT", path, params=params, json=json, files=files, timeout=timeout
351
+ )
352
+ finally:
353
+ await self._invalidate_after_remote_mutation()
354
+
355
+ async def patch(
356
+ self,
357
+ path: str,
358
+ *,
359
+ params: dict[str, Any] | None = None,
360
+ json: dict[str, Any] | None = None,
361
+ timeout: float = DEFAULT_TIMEOUT,
362
+ ) -> Any:
363
+ try:
364
+ return await self._request("PATCH", path, params=params, json=json, timeout=timeout)
365
+ finally:
366
+ await self._invalidate_after_remote_mutation()
367
+
368
+ async def delete(
369
+ self,
370
+ path: str,
371
+ *,
372
+ params: dict[str, Any] | None = None,
373
+ json: dict[str, Any] | None = None,
374
+ timeout: float = DEFAULT_TIMEOUT,
375
+ ) -> Any:
376
+ try:
377
+ return await self._request("DELETE", path, params=params, json=json, timeout=timeout)
378
+ finally:
379
+ await self._invalidate_after_remote_mutation()
380
+
381
+ async def _invalidate_after_remote_mutation(self) -> None:
382
+ if self.execution_context is not None and self.mutates_remote_state:
383
+ await self.execution_context.invalidate_after_mutation()
384
+
385
+ async def close(self) -> None:
386
+ if self._client is not None and not self._client.is_closed:
387
+ await self._client.aclose()
@@ -0,0 +1,59 @@
1
+ """Helpers for turning profile domains into Kaiten HTTP origins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import urlparse
6
+
7
+ KAITEN_HOST_SUFFIX = ".kaiten.ru"
8
+
9
+
10
+ def _with_parse_scheme(value: str) -> str:
11
+ return value if "://" in value else f"//{value}"
12
+
13
+
14
+ def _host_with_port(raw_value: str) -> str:
15
+ parsed = urlparse(_with_parse_scheme(raw_value))
16
+ host = (parsed.hostname or "").lower()
17
+ if not host:
18
+ return raw_value.strip().strip("/").lower()
19
+ try:
20
+ port = parsed.port
21
+ except ValueError:
22
+ port = None
23
+ return f"{host}:{port}" if port is not None else host
24
+
25
+
26
+ def normalize_profile_domain(value: str) -> str:
27
+ """Normalize saved/env domain values without losing custom host intent."""
28
+
29
+ raw = str(value or "").strip()
30
+ if not raw:
31
+ return ""
32
+
33
+ parsed = urlparse(_with_parse_scheme(raw))
34
+ host = (parsed.hostname or "").lower()
35
+ host_with_port = _host_with_port(raw)
36
+ if host.endswith(KAITEN_HOST_SUFFIX):
37
+ return host[: -len(KAITEN_HOST_SUFFIX)]
38
+
39
+ scheme = parsed.scheme.lower()
40
+ if scheme and scheme != "https":
41
+ return f"{scheme}://{host_with_port}"
42
+ return host_with_port
43
+
44
+
45
+ def _is_kaiten_tenant_name(value: str) -> bool:
46
+ return "." not in value and ":" not in value and not value.startswith(("http://", "https://"))
47
+
48
+
49
+ def profile_origin(domain: str) -> str:
50
+ normalized = normalize_profile_domain(domain)
51
+ if normalized.startswith(("http://", "https://")):
52
+ return normalized.rstrip("/")
53
+ if _is_kaiten_tenant_name(normalized):
54
+ return f"https://{normalized}.kaiten.ru"
55
+ return f"https://{normalized}"
56
+
57
+
58
+ def profile_api_base_url(domain: str, *, api_version: str) -> str:
59
+ return f"{profile_origin(domain)}/api/{api_version}"