Skip to content

Commit 9ef2936

Browse files
authored
feat: create virtual resources on push when catalog lookup misses (#1584)
1 parent 59d8ca4 commit 9ef2936

13 files changed

Lines changed: 903 additions & 224 deletions

File tree

packages/uipath-platform/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-platform"
3-
version = "0.1.34"
3+
version = "0.1.35"
44
description = "HTTP client library for programmatic access to UiPath Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

packages/uipath-platform/src/uipath/platform/resource_catalog/_resource_catalog_service.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, AsyncIterator, Dict, Iterator, List, Optional
1+
from typing import Any, AsyncGenerator, Dict, Iterator, List, Optional
22

33
from uipath.core.tracing import traced
44

@@ -110,7 +110,7 @@ async def search_async(
110110
resource_types: Optional[List[ResourceType]] = None,
111111
resource_sub_types: Optional[List[str]] = None,
112112
page_size: int = _DEFAULT_PAGE_SIZE,
113-
) -> AsyncIterator[Resource]:
113+
) -> AsyncGenerator[Resource, None]:
114114
"""Asynchronously search for tenant scoped resources and folder scoped resources (accessible to the user).
115115
116116
This method automatically handles pagination and yields resources one by one.
@@ -258,7 +258,7 @@ async def list_async(
258258
folder_path: Optional[str] = None,
259259
folder_key: Optional[str] = None,
260260
page_size: int = _DEFAULT_PAGE_SIZE,
261-
) -> AsyncIterator[Resource]:
261+
) -> AsyncGenerator[Resource, None]:
262262
"""Asynchronously get tenant scoped resources and folder scoped resources (accessible to the user).
263263
264264
If no folder identifier is provided (path or key) only tenant resources will be retrieved.
@@ -428,7 +428,7 @@ async def list_by_type_async(
428428
folder_path: Optional[str] = None,
429429
folder_key: Optional[str] = None,
430430
page_size: int = _DEFAULT_PAGE_SIZE,
431-
) -> AsyncIterator[Resource]:
431+
) -> AsyncGenerator[Resource, None]:
432432
"""Asynchronously get resources of a specific type (tenant scoped or folder scoped).
433433
434434
If no folder identifier is provided (path or key) only tenant resources will be retrieved.

packages/uipath-platform/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/uipath/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath"
3-
version = "2.10.51"
3+
version = "2.10.52"
44
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
from typing import AsyncIterator
2+
3+
from uipath.platform.connections import ConnectionsService
4+
from uipath.platform.errors import EnrichedException, FolderNotFoundException
5+
from uipath.platform.resource_catalog import (
6+
Resource,
7+
ResourceCatalogService,
8+
ResourceType,
9+
)
10+
11+
from .._utils._studio_project import (
12+
ReferencedResourceFolder,
13+
ReferencedResourceRequest,
14+
VirtualResourceRequest,
15+
)
16+
from ..models.runtime_schema import BindingResource, Bindings
17+
from ._resource_actions import CreateReference, CreateVirtual, ResourceAction, Skip
18+
19+
_NOT_FOUND_SUFFIX = "was not found and will not be added to the solution."
20+
21+
22+
async def resolve_bindings(
23+
bindings: Bindings,
24+
resource_catalog: ResourceCatalogService,
25+
connections: ConnectionsService,
26+
supported_virtual_kinds: set[str],
27+
) -> AsyncIterator[ResourceAction]:
28+
"""Yield one ResourceAction per importable binding.
29+
30+
Bindings that should be silently ignored (e.g. guardrail bindings without a
31+
folderPath) are filtered out here.
32+
"""
33+
for binding in bindings.resources:
34+
action = await _resolve_binding(
35+
binding, resource_catalog, connections, supported_virtual_kinds
36+
)
37+
if action is not None:
38+
yield action
39+
40+
41+
async def _resolve_binding(
42+
binding: BindingResource,
43+
resource_catalog: ResourceCatalogService,
44+
connections: ConnectionsService,
45+
supported_virtual_kinds: set[str],
46+
) -> ResourceAction | None:
47+
if binding.resource == "connection":
48+
return await _resolve_connection(binding, resource_catalog, connections)
49+
return await _resolve_regular(binding, resource_catalog, supported_virtual_kinds)
50+
51+
52+
async def _resolve_connection(
53+
binding: BindingResource,
54+
resource_catalog: ResourceCatalogService,
55+
connections: ConnectionsService,
56+
) -> ResourceAction | None:
57+
connection_id_value = binding.value.get("ConnectionId")
58+
if connection_id_value is None:
59+
raise ValueError(
60+
f"Connection binding {binding.key!r} is missing required field 'ConnectionId'"
61+
)
62+
connection_key = connection_id_value.default_value
63+
64+
try:
65+
connection = await connections.retrieve_async(connection_key)
66+
except EnrichedException:
67+
connector_name = (binding.metadata or {}).get("Connector")
68+
return Skip(
69+
message=(
70+
f"Connection with key '{connection_key}' of type "
71+
f"'{connector_name}' {_NOT_FOUND_SUFFIX}"
72+
)
73+
)
74+
75+
resource_name: str = connection.name
76+
folder_path: str = connection.folder.get("path")
77+
78+
found = await _find_in_resource_catalog(
79+
resource_catalog, "connection", resource_name, folder_path
80+
)
81+
if found is None:
82+
return Skip(
83+
message=(
84+
f"Resource '{resource_name}' of type 'connection' at folder path "
85+
f"'{folder_path}' {_NOT_FOUND_SUFFIX}"
86+
)
87+
)
88+
return _build_create_reference(found, resource_name)
89+
90+
91+
async def _resolve_regular(
92+
binding: BindingResource,
93+
resource_catalog: ResourceCatalogService,
94+
supported_virtual_kinds: set[str],
95+
) -> ResourceAction | None:
96+
name_value = binding.value.get("name")
97+
folder_path_value = binding.value.get("folderPath")
98+
if not folder_path_value:
99+
# guardrail resource, nothing to import
100+
return None
101+
if name_value is None:
102+
raise ValueError(f"Binding {binding.key!r} is missing required field 'name'")
103+
resource_name: str = name_value.default_value
104+
folder_path: str = folder_path_value.default_value
105+
resource_type: str = binding.resource
106+
107+
found = await _find_in_resource_catalog(
108+
resource_catalog, resource_type, resource_name, folder_path
109+
)
110+
if found is not None:
111+
return _build_create_reference(found, resource_name)
112+
113+
if resource_type not in supported_virtual_kinds:
114+
return Skip(
115+
message=(
116+
f"Cannot create virtual resource '{resource_name}' — "
117+
f"kind '{resource_type}' is not supported."
118+
)
119+
)
120+
121+
sub_type: str | None = (binding.metadata or {}).get("SubType")
122+
return CreateVirtual(
123+
request=VirtualResourceRequest(
124+
kind=resource_type,
125+
name=resource_name,
126+
type=sub_type,
127+
)
128+
)
129+
130+
131+
async def _find_in_resource_catalog(
132+
resource_catalog: ResourceCatalogService,
133+
resource_type: str,
134+
name: str,
135+
folder_path: str,
136+
) -> Resource | None:
137+
"""Look up a single resource in the Resource Catalog.
138+
139+
Returns the first match or None if the catalog can't search this kind, the
140+
folder is unknown, or no resource matches.
141+
"""
142+
catalog_type = next(
143+
(m for m in ResourceType if m.value == resource_type.lower()), None
144+
)
145+
if catalog_type is None:
146+
return None
147+
148+
resources = resource_catalog.list_by_type_async(
149+
resource_type=catalog_type, name=name, folder_path=folder_path
150+
)
151+
try:
152+
return await anext(resources, None)
153+
except FolderNotFoundException:
154+
return None
155+
finally:
156+
await resources.aclose()
157+
158+
159+
def _build_create_reference(
160+
found_resource: Resource, resource_name: str
161+
) -> CreateReference:
162+
folder = next(iter(found_resource.folders))
163+
return CreateReference(
164+
request=ReferencedResourceRequest(
165+
key=found_resource.resource_key,
166+
kind=found_resource.resource_type,
167+
type=found_resource.resource_sub_type,
168+
folder=ReferencedResourceFolder(
169+
folder_key=folder.key,
170+
fully_qualified_name=folder.fully_qualified_name,
171+
path=folder.path,
172+
),
173+
),
174+
resource_name=resource_name,
175+
kind=found_resource.resource_type,
176+
sub_type=found_resource.resource_sub_type,
177+
)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from dataclasses import dataclass
2+
3+
from .._utils._studio_project import (
4+
ReferencedResourceRequest,
5+
VirtualResourceRequest,
6+
)
7+
8+
9+
@dataclass(frozen=True, slots=True)
10+
class CreateReference:
11+
request: ReferencedResourceRequest
12+
resource_name: str
13+
kind: str
14+
sub_type: str | None
15+
16+
17+
@dataclass(frozen=True, slots=True)
18+
class CreateVirtual:
19+
request: VirtualResourceRequest
20+
21+
22+
@dataclass(frozen=True, slots=True)
23+
class Skip:
24+
message: str
25+
26+
27+
ResourceAction = CreateReference | CreateVirtual | Skip
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from dataclasses import dataclass
2+
3+
import click
4+
5+
6+
@dataclass
7+
class ResourceImportSummary:
8+
created: int = 0
9+
updated: int = 0
10+
unchanged: int = 0
11+
virtual_created: int = 0
12+
virtual_existing: int = 0
13+
not_found: int = 0
14+
15+
@property
16+
def total(self) -> int:
17+
return (
18+
self.created
19+
+ self.updated
20+
+ self.unchanged
21+
+ self.virtual_created
22+
+ self.virtual_existing
23+
+ self.not_found
24+
)
25+
26+
def __str__(self) -> str:
27+
return (
28+
f"\n \U0001f535 Resource import summary: {self.total} total resources - "
29+
f"{click.style(str(self.created), fg='green')} created, "
30+
f"{click.style(str(self.updated), fg='blue')} updated, "
31+
f"{click.style(str(self.unchanged), fg='yellow')} unchanged, "
32+
f"{click.style(str(self.virtual_created), fg='green')} virtual-created, "
33+
f"{click.style(str(self.virtual_existing), fg='yellow')} virtual-existing, "
34+
f"{click.style(str(self.not_found), fg='red')} not found"
35+
)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import logging
2+
3+
from .._utils._studio_project import ResourceBuilderMetadataEntry, StudioClient
4+
5+
logger = logging.getLogger(__name__)
6+
7+
_FALLBACK: frozenset[str] = frozenset(
8+
{"app", "asset", "bucket", "process", "queue", "taskCatalog", "trigger"}
9+
)
10+
11+
12+
async def fetch_supported_virtual_kinds(studio_client: StudioClient) -> set[str]:
13+
"""Return the set of resource kinds that support inline creation.
14+
15+
Falls back to a static list on any failure — the caller shouldn't have to
16+
care whether the metadata endpoint was reachable.
17+
"""
18+
try:
19+
metadata = await studio_client.get_resource_builder_metadata()
20+
except Exception as e:
21+
logger.debug("Resource Builder metadata fetch failed, using fallback: %s", e)
22+
return set(_FALLBACK)
23+
return _extract_supported_kinds(metadata)
24+
25+
26+
def _extract_supported_kinds(
27+
metadata: list[ResourceBuilderMetadataEntry],
28+
) -> set[str]:
29+
# metadata has one entry per (kind, type), so a kind may appear multiple times
30+
return {
31+
entry.kind
32+
for entry in metadata
33+
if any(version.supports_in_line_creation for version in entry.versions)
34+
}

0 commit comments

Comments
 (0)