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.
- kaiten_cli/__init__.py +5 -0
- kaiten_cli/__main__.py +9 -0
- kaiten_cli/agent_gateway.py +577 -0
- kaiten_cli/app.py +1636 -0
- kaiten_cli/completion.py +570 -0
- kaiten_cli/discovery.py +39 -0
- kaiten_cli/errors.py +79 -0
- kaiten_cli/i18n.py +92 -0
- kaiten_cli/locales/ru.json +2967 -0
- kaiten_cli/localized_click.py +160 -0
- kaiten_cli/models.py +202 -0
- kaiten_cli/profiles.py +316 -0
- kaiten_cli/registry/__init__.py +260 -0
- kaiten_cli/registry/addons.py +392 -0
- kaiten_cli/registry/audit_and_analytics.py +451 -0
- kaiten_cli/registry/automation_schema.py +44 -0
- kaiten_cli/registry/automation_schemas.json +7417 -0
- kaiten_cli/registry/automations.py +342 -0
- kaiten_cli/registry/base.py +62 -0
- kaiten_cli/registry/blockers.py +320 -0
- kaiten_cli/registry/boards.py +520 -0
- kaiten_cli/registry/card_relations.py +376 -0
- kaiten_cli/registry/card_types.py +363 -0
- kaiten_cli/registry/cards.py +1329 -0
- kaiten_cli/registry/charts.py +539 -0
- kaiten_cli/registry/checklists.py +600 -0
- kaiten_cli/registry/columns.py +558 -0
- kaiten_cli/registry/comments.py +224 -0
- kaiten_cli/registry/custom_directories.py +980 -0
- kaiten_cli/registry/custom_properties.py +1460 -0
- kaiten_cli/registry/dashboards.py +564 -0
- kaiten_cli/registry/documents.py +761 -0
- kaiten_cli/registry/external_links.py +128 -0
- kaiten_cli/registry/files.py +738 -0
- kaiten_cli/registry/github_addon.py +613 -0
- kaiten_cli/registry/iterations.py +397 -0
- kaiten_cli/registry/lanes.py +205 -0
- kaiten_cli/registry/live_contracts.py +361 -0
- kaiten_cli/registry/members.py +336 -0
- kaiten_cli/registry/module_docs.py +108 -0
- kaiten_cli/registry/projects.py +369 -0
- kaiten_cli/registry/query.py +129 -0
- kaiten_cli/registry/restrictions.py +249 -0
- kaiten_cli/registry/roles_and_groups.py +1102 -0
- kaiten_cli/registry/scim.py +263 -0
- kaiten_cli/registry/service_desk.py +1270 -0
- kaiten_cli/registry/snapshot.py +187 -0
- kaiten_cli/registry/spaces.py +272 -0
- kaiten_cli/registry/subscribers.py +168 -0
- kaiten_cli/registry/tags.py +193 -0
- kaiten_cli/registry/time_logs.py +353 -0
- kaiten_cli/registry/tree.py +352 -0
- kaiten_cli/registry/utilities.py +287 -0
- kaiten_cli/registry/webhooks.py +265 -0
- kaiten_cli/runtime/__init__.py +1 -0
- kaiten_cli/runtime/automation_validation.py +115 -0
- kaiten_cli/runtime/behaviors.py +787 -0
- kaiten_cli/runtime/cache.py +733 -0
- kaiten_cli/runtime/client.py +387 -0
- kaiten_cli/runtime/endpoints.py +59 -0
- kaiten_cli/runtime/executor.py +269 -0
- kaiten_cli/runtime/fs_security.py +168 -0
- kaiten_cli/runtime/input.py +310 -0
- kaiten_cli/runtime/output.py +59 -0
- kaiten_cli/runtime/public_validation.py +35 -0
- kaiten_cli/runtime/snapshot_build.py +527 -0
- kaiten_cli/runtime/snapshot_common.py +363 -0
- kaiten_cli/runtime/snapshot_query.py +306 -0
- kaiten_cli/runtime/snapshot_store.py +1192 -0
- kaiten_cli/runtime/snapshots.py +62 -0
- kaiten_cli/runtime/sqlite_errors.py +23 -0
- kaiten_cli/runtime/support/__init__.py +1 -0
- kaiten_cli/runtime/support/addons.py +953 -0
- kaiten_cli/runtime/support/audit.py +70 -0
- kaiten_cli/runtime/support/batch.py +234 -0
- kaiten_cli/runtime/support/card_move_url.py +213 -0
- kaiten_cli/runtime/support/cards.py +174 -0
- kaiten_cli/runtime/support/checklists.py +21 -0
- kaiten_cli/runtime/support/company_users.py +117 -0
- kaiten_cli/runtime/support/dashboards.py +35 -0
- kaiten_cli/runtime/support/documents.py +175 -0
- kaiten_cli/runtime/support/files.py +561 -0
- kaiten_cli/runtime/support/markdown_export.py +525 -0
- kaiten_cli/runtime/support/pagination.py +167 -0
- kaiten_cli/runtime/support/projects.py +66 -0
- kaiten_cli/runtime/support/relations.py +86 -0
- kaiten_cli/runtime/support/restrictions.py +62 -0
- kaiten_cli/runtime/support/spaces.py +20 -0
- kaiten_cli/runtime/support/time_logs.py +61 -0
- kaiten_cli/runtime/support/tree.py +140 -0
- kaiten_cli/runtime/support/tree_sharing.py +447 -0
- kaiten_cli/runtime/trace.py +500 -0
- kaiten_cli/runtime/transforms.py +128 -0
- kaiten_cli/schema_docs.py +22 -0
- kaiten_cli/update_check.py +519 -0
- kaiten_cli-0.3.3.dist-info/METADATA +1110 -0
- kaiten_cli-0.3.3.dist-info/RECORD +99 -0
- kaiten_cli-0.3.3.dist-info/WHEEL +4 -0
- 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}"
|