Skip to content

Commit 9f88f29

Browse files
cezara98tclaude
andcommitted
feat(platform): add IXP designtime transport foundation
First increment of the IXP design-time Python SDK (epic ACTV-89216, spike ACTV-89217): the shared transport base every IXP service will build on. Adds platform/ixp/IxpDesigntimeService(BaseService) encoding the du_/api/designtimeapi conventions: - du_/api/designtimeapi base path, org/tenant scoped, mandatory api-version=1.0 on every call - strict path-segment percent-encoding (quote(safe="")) so reserved chars survive (/ -> %2F, space -> %20) - NO retry on non-idempotent writes (POST/PUT/PATCH/upload) or DELETE, matching the CLI SDK's zero-retry contract to avoid double-create/confirm on transient 5xx; idempotent GETs keep the platform retry policy - empty write body sent as {}; multipart `file` upload; binary download - sync + async twins; errors via EnrichedException; no FolderContext (IXP is org/tenant scoped only) Additive only: a new platform/ixp/ subpackage nothing else imports yet. 13 pytest-httpx tests; ruff + mypy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8124543 commit 9f88f29

3 files changed

Lines changed: 561 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""UiPath IXP design-time SDK (``du_/api/designtimeapi``).
2+
3+
This package hosts the IXP design-time services (projects, taxonomy, labellings,
4+
documents, models). It currently provides the shared transport foundation;
5+
resource services are added on top of :class:`IxpDesigntimeService`.
6+
"""
7+
8+
from ._transport import (
9+
DESIGNTIME_API_BASE,
10+
DESIGNTIME_API_VERSION,
11+
IxpDesigntimeService,
12+
)
13+
14+
__all__ = [
15+
"IxpDesigntimeService",
16+
"DESIGNTIME_API_BASE",
17+
"DESIGNTIME_API_VERSION",
18+
]
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
"""Transport foundation for the IXP design-time API.
2+
3+
All IXP design-time endpoints live under ``du_/api/designtimeapi`` and share a
4+
small set of transport conventions that differ from the rest of the platform
5+
SDK. This module centralises them in :class:`IxpDesigntimeService`, the base
6+
class every IXP service (projects, taxonomy, labellings, documents, models)
7+
builds on:
8+
9+
* **Base path** — every request is prefixed with ``du_/api/designtimeapi`` and
10+
scoped to ``{org}/{tenant}`` by :class:`BaseService`.
11+
* **Mandatory api-version** — the gateway requires an explicit
12+
``api-version=1.0`` query parameter on *every* call.
13+
* **Strict path encoding** — dynamic path segments (project name, field name,
14+
tag, ...) may contain reserved characters, so each is percent-encoded with
15+
``quote(safe="")`` (``/`` -> ``%2F``, space -> ``%20``).
16+
* **No retry on writes** — unlike the platform default, non-idempotent verbs
17+
(POST/PUT/PATCH, including multipart upload) and DELETE are **not** retried,
18+
matching the CLI SDK's zero-retry contract so a transient 5xx cannot
19+
double-create or double-confirm. Only idempotent GETs keep the platform
20+
retry policy.
21+
* **Multipart upload / binary download** — helpers for the ``file`` multipart
22+
field and for reading raw bytes + content-type back.
23+
24+
Errors surface as :class:`~uipath.platform.errors.EnrichedException`, which
25+
already carries the status code and (truncated) response body as structured
26+
fields.
27+
28+
Note: IXP is org/tenant-scoped only — it has no Orchestrator folder concept —
29+
so this base deliberately does **not** inherit ``FolderContext``.
30+
"""
31+
32+
from typing import Any, Optional, Union
33+
from urllib.parse import quote
34+
35+
from httpx import HTTPStatusError, Response
36+
from tenacity import (
37+
retry,
38+
retry_if_exception,
39+
stop_after_attempt,
40+
)
41+
42+
from uipath.platform.constants import HEADER_USER_AGENT
43+
44+
from ..common._base_service import BaseService, _inject_trace_context
45+
from ..common._models import Endpoint
46+
from ..common._user_agent import user_agent_value
47+
from ..common.retry import (
48+
MAX_RETRY_ATTEMPTS,
49+
is_retryable_platform_exception,
50+
platform_wait_strategy,
51+
)
52+
from ..errors import EnrichedException
53+
54+
#: API segment every design-time request is prefixed with.
55+
DESIGNTIME_API_BASE = "du_/api/designtimeapi"
56+
57+
#: The only api-version the gateway currently accepts; sent on every call.
58+
DESIGNTIME_API_VERSION = "1.0"
59+
60+
61+
class IxpDesigntimeService(BaseService):
62+
"""Base class for IXP design-time services (``du_/api/designtimeapi``).
63+
64+
Subclasses build endpoints with :meth:`_endpoint` and issue requests with
65+
the ``_get`` / ``_post`` / ``_put`` / ``_patch`` / ``_delete`` / ``_upload``
66+
/ ``_download`` helpers (each has an ``_async`` twin). Every helper appends
67+
the mandatory ``api-version`` query parameter; GETs are retried per the
68+
platform policy while writes and deletes are not (see module docstring).
69+
"""
70+
71+
def _endpoint(self, template: str, **segments: Any) -> Endpoint:
72+
"""Build a design-time endpoint, percent-encoding dynamic segments.
73+
74+
Args:
75+
template: Path template rooted at the API (e.g.
76+
``"/api/projects/{name}/models/{version}/metrics"``). Fixed
77+
segments are kept verbatim; ``{placeholder}`` values are filled
78+
from ``segments``.
79+
**segments: Values substituted into the template, each encoded with
80+
``quote(safe="")`` so reserved characters (``/``, spaces, ...)
81+
survive as ``%2F`` / ``%20``.
82+
83+
Returns:
84+
The normalized ``Endpoint`` under ``du_/api/designtimeapi``.
85+
"""
86+
encoded = {key: quote(str(value), safe="") for key, value in segments.items()}
87+
return Endpoint(f"/{DESIGNTIME_API_BASE}{template.format(**encoded)}")
88+
89+
@staticmethod
90+
def _params(params: Optional[dict[str, Any]]) -> dict[str, Any]:
91+
"""Merge the mandatory ``api-version`` into a request's query params."""
92+
return {"api-version": DESIGNTIME_API_VERSION, **(params or {})}
93+
94+
@staticmethod
95+
def _raise_for_status(response: Response) -> Response:
96+
"""Raise :class:`EnrichedException` on a non-2xx response."""
97+
try:
98+
response.raise_for_status()
99+
except HTTPStatusError as error:
100+
raise EnrichedException(error) from error
101+
return response
102+
103+
def _headers(self) -> dict[str, str]:
104+
"""Per-request headers: user-agent + trace context (auth is on the client)."""
105+
headers: dict[str, str] = {
106+
HEADER_USER_AGENT: user_agent_value(self._specific_component)
107+
}
108+
_inject_trace_context(headers)
109+
return headers
110+
111+
# ── low-level send (no retry) ────────────────────────────────────────────
112+
113+
def _send(
114+
self,
115+
method: str,
116+
endpoint: Union[Endpoint, str],
117+
*,
118+
params: Optional[dict[str, Any]] = None,
119+
json: Optional[Any] = None,
120+
files: Optional[dict[str, Any]] = None,
121+
) -> Response:
122+
scoped_url = self._url.scope_url(str(endpoint), "tenant")
123+
response = self._client.request(
124+
method,
125+
scoped_url,
126+
params=self._params(params),
127+
json=json,
128+
files=files,
129+
headers=self._headers(),
130+
)
131+
return self._raise_for_status(response)
132+
133+
async def _send_async(
134+
self,
135+
method: str,
136+
endpoint: Union[Endpoint, str],
137+
*,
138+
params: Optional[dict[str, Any]] = None,
139+
json: Optional[Any] = None,
140+
files: Optional[dict[str, Any]] = None,
141+
) -> Response:
142+
scoped_url = self._url.scope_url(str(endpoint), "tenant")
143+
response = await self._client_async.request(
144+
method,
145+
scoped_url,
146+
params=self._params(params),
147+
json=json,
148+
files=files,
149+
headers=self._headers(),
150+
)
151+
return self._raise_for_status(response)
152+
153+
# ── retrying send (idempotent GET only) ──────────────────────────────────
154+
155+
@retry(
156+
retry=retry_if_exception(is_retryable_platform_exception),
157+
wait=platform_wait_strategy,
158+
stop=stop_after_attempt(MAX_RETRY_ATTEMPTS),
159+
reraise=True,
160+
)
161+
def _send_retrying(
162+
self,
163+
method: str,
164+
endpoint: Union[Endpoint, str],
165+
*,
166+
params: Optional[dict[str, Any]] = None,
167+
) -> Response:
168+
return self._send(method, endpoint, params=params)
169+
170+
@retry(
171+
retry=retry_if_exception(is_retryable_platform_exception),
172+
wait=platform_wait_strategy,
173+
stop=stop_after_attempt(MAX_RETRY_ATTEMPTS),
174+
reraise=True,
175+
)
176+
async def _send_retrying_async(
177+
self,
178+
method: str,
179+
endpoint: Union[Endpoint, str],
180+
*,
181+
params: Optional[dict[str, Any]] = None,
182+
) -> Response:
183+
return await self._send_async(method, endpoint, params=params)
184+
185+
# ── verb helpers ─────────────────────────────────────────────────────────
186+
187+
def _get(
188+
self, endpoint: Union[Endpoint, str], *, params: Optional[dict[str, Any]] = None
189+
) -> Response:
190+
"""GET (idempotent — retried per the platform policy)."""
191+
return self._send_retrying("GET", endpoint, params=params)
192+
193+
async def _get_async(
194+
self, endpoint: Union[Endpoint, str], *, params: Optional[dict[str, Any]] = None
195+
) -> Response:
196+
"""Async GET (idempotent — retried per the platform policy)."""
197+
return await self._send_retrying_async("GET", endpoint, params=params)
198+
199+
def _post(
200+
self,
201+
endpoint: Union[Endpoint, str],
202+
*,
203+
body: Optional[Any] = None,
204+
params: Optional[dict[str, Any]] = None,
205+
) -> Response:
206+
"""POST (not retried). A ``None`` body is sent as ``{}`` (matching the CLI SDK)."""
207+
return self._send(
208+
"POST", endpoint, params=params, json=body if body is not None else {}
209+
)
210+
211+
async def _post_async(
212+
self,
213+
endpoint: Union[Endpoint, str],
214+
*,
215+
body: Optional[Any] = None,
216+
params: Optional[dict[str, Any]] = None,
217+
) -> Response:
218+
"""Async POST (not retried). A ``None`` body is sent as ``{}``."""
219+
return await self._send_async(
220+
"POST", endpoint, params=params, json=body if body is not None else {}
221+
)
222+
223+
def _put(
224+
self,
225+
endpoint: Union[Endpoint, str],
226+
*,
227+
body: Optional[Any] = None,
228+
params: Optional[dict[str, Any]] = None,
229+
) -> Response:
230+
"""PUT (not retried). A ``None`` body is sent as ``{}``."""
231+
return self._send(
232+
"PUT", endpoint, params=params, json=body if body is not None else {}
233+
)
234+
235+
async def _put_async(
236+
self,
237+
endpoint: Union[Endpoint, str],
238+
*,
239+
body: Optional[Any] = None,
240+
params: Optional[dict[str, Any]] = None,
241+
) -> Response:
242+
"""Async PUT (not retried). A ``None`` body is sent as ``{}``."""
243+
return await self._send_async(
244+
"PUT", endpoint, params=params, json=body if body is not None else {}
245+
)
246+
247+
def _patch(
248+
self,
249+
endpoint: Union[Endpoint, str],
250+
*,
251+
body: Optional[Any] = None,
252+
params: Optional[dict[str, Any]] = None,
253+
) -> Response:
254+
"""PATCH (not retried). A ``None`` body is sent as ``{}``."""
255+
return self._send(
256+
"PATCH", endpoint, params=params, json=body if body is not None else {}
257+
)
258+
259+
async def _patch_async(
260+
self,
261+
endpoint: Union[Endpoint, str],
262+
*,
263+
body: Optional[Any] = None,
264+
params: Optional[dict[str, Any]] = None,
265+
) -> Response:
266+
"""Async PATCH (not retried). A ``None`` body is sent as ``{}``."""
267+
return await self._send_async(
268+
"PATCH", endpoint, params=params, json=body if body is not None else {}
269+
)
270+
271+
def _delete(
272+
self, endpoint: Union[Endpoint, str], *, params: Optional[dict[str, Any]] = None
273+
) -> Response:
274+
"""DELETE (not retried — avoids a spurious 404 from a retried delete)."""
275+
return self._send("DELETE", endpoint, params=params)
276+
277+
async def _delete_async(
278+
self, endpoint: Union[Endpoint, str], *, params: Optional[dict[str, Any]] = None
279+
) -> Response:
280+
"""Async DELETE (not retried)."""
281+
return await self._send_async("DELETE", endpoint, params=params)
282+
283+
def _upload(
284+
self,
285+
endpoint: Union[Endpoint, str],
286+
*,
287+
filename: str,
288+
content: Any,
289+
content_type: str = "application/octet-stream",
290+
params: Optional[dict[str, Any]] = None,
291+
) -> Response:
292+
"""Multipart upload of a single file under the ``file`` field (not retried)."""
293+
return self._send(
294+
"POST",
295+
endpoint,
296+
params=params,
297+
files={"file": (filename, content, content_type)},
298+
)
299+
300+
async def _upload_async(
301+
self,
302+
endpoint: Union[Endpoint, str],
303+
*,
304+
filename: str,
305+
content: Any,
306+
content_type: str = "application/octet-stream",
307+
params: Optional[dict[str, Any]] = None,
308+
) -> Response:
309+
"""Async multipart upload under the ``file`` field (not retried)."""
310+
return await self._send_async(
311+
"POST",
312+
endpoint,
313+
params=params,
314+
files={"file": (filename, content, content_type)},
315+
)
316+
317+
def _download(
318+
self, endpoint: Union[Endpoint, str], *, params: Optional[dict[str, Any]] = None
319+
) -> tuple[bytes, str]:
320+
"""GET raw bytes, returning ``(content, content_type)``."""
321+
response = self._send_retrying("GET", endpoint, params=params)
322+
content_type = (
323+
response.headers.get("content-type") or "application/octet-stream"
324+
)
325+
return response.content, content_type
326+
327+
async def _download_async(
328+
self, endpoint: Union[Endpoint, str], *, params: Optional[dict[str, Any]] = None
329+
) -> tuple[bytes, str]:
330+
"""Async GET raw bytes, returning ``(content, content_type)``."""
331+
response = await self._send_retrying_async("GET", endpoint, params=params)
332+
content_type = (
333+
response.headers.get("content-type") or "application/octet-stream"
334+
)
335+
return response.content, content_type

0 commit comments

Comments
 (0)