Skip to content

Commit ef6d66a

Browse files
committed
feat: add Property binding resource support to SDK
Adds support for the Property resource type in bindings.json, enabling users to read connector-defined property values (e.g. SharePoint folder IDs) without writing custom helpers. - Extend BindingResourceValue with optional description/propertyName fields - Add "property" to GenericResourceOverwrite (replaces a separate class) - Add BindingsService with get_property() that reads from bindings.json and respects runtime resource overwrites via the existing ContextVar - Expose sdk.bindings as a cached_property on the UiPath class - Update bindings.spec.md to document Property as the 7th resource type - Add unit tests covering file reads, suffix key matching, runtime overwrites (including Studio-loaded and real runtime payload formats), and error cases ResourceOverwriteParser.parse now normalises the Property key prefix to lowercase ("Property" -> "property") and accepts the real runtime flat-dict format in addition to the explicit {"values": {...}} form. Both changes are additive — existing valid inputs are unaffected.
1 parent 10bc126 commit ef6d66a

10 files changed

Lines changed: 626 additions & 48 deletions

File tree

.github/workflows/publish-dev.yml

Lines changed: 28 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,11 @@ jobs:
109109
110110
Write-Output "Package $PROJECT_NAME version set to $DEV_VERSION"
111111
112+
$startMarker = "<!-- DEV_PACKAGE_START:$PROJECT_NAME -->"
113+
$endMarker = "<!-- DEV_PACKAGE_END:$PROJECT_NAME -->"
114+
112115
$dependencyMessage = @"
116+
$startMarker
113117
### $PROJECT_NAME
114118
115119
``````toml
@@ -130,7 +134,13 @@ jobs:
130134
131135
[tool.uv.sources]
132136
$PROJECT_NAME = { index = "testpypi" }
137+
138+
[tool.uv]
139+
override-dependencies = [
140+
"$PROJECT_NAME>=$MIN_VERSION,<$MAX_VERSION",
141+
]
133142
``````
143+
$endMarker
134144
"@
135145
136146
# Get the owner and repo from the GitHub repository
@@ -148,36 +158,28 @@ jobs:
148158
$pr = Invoke-RestMethod -Uri $prUri -Method Get -Headers $headers
149159
$currentBody = $pr.body
150160
151-
# Define regex patterns for matching package sections
152161
$devPackagesHeader = "## Development Packages"
153-
$packageHeaderPattern = "### $PROJECT_NAME\s*\n"
154-
155-
# Find if the package section exists using multiline regex
156-
$packageSectionRegex = "(?ms)### $PROJECT_NAME\s*\n``````toml.*?``````"
157-
158-
if ($currentBody -match $devPackagesHeader) {
159-
# Development Packages section exists
160-
if ($currentBody -match $packageSectionRegex) {
161-
# Replace existing package section
162-
Write-Output "Updating existing $PROJECT_NAME section"
163-
$newBody = $currentBody -replace $packageSectionRegex, $dependencyMessage.Trim()
162+
$markerPattern = "(?s)$([regex]::Escape($startMarker)).*?$([regex]::Escape($endMarker))"
163+
164+
function Get-UpdatedBody($body, $message) {
165+
if ($body -match $markerPattern) {
166+
return $body -replace $markerPattern, $message.Trim()
167+
} elseif ($body -match $devPackagesHeader) {
168+
$insertPoint = $body.IndexOf($devPackagesHeader) + $devPackagesHeader.Length
169+
return $body.Insert($insertPoint, "`n`n$($message.Trim())")
164170
} else {
165-
# Append new package section after the Development Packages header
166-
Write-Output "Adding new $PROJECT_NAME section"
167-
$insertPoint = $currentBody.IndexOf($devPackagesHeader) + $devPackagesHeader.Length
168-
$newBody = $currentBody.Insert($insertPoint, "`n`n$dependencyMessage")
171+
$section = "$devPackagesHeader`n`n$($message.Trim())"
172+
if ($body) {
173+
$result = "$body`n`n$section"
174+
} else {
175+
$result = $section
176+
}
177+
return $result
169178
}
170-
} else {
171-
# Create the Development Packages section
172-
Write-Output "Creating Development Packages section with $PROJECT_NAME"
173-
$packageSection = @"
174-
## Development Packages
175-
176-
$dependencyMessage
177-
"@
178-
$newBody = if ($currentBody) { "$currentBody`n`n$packageSection" } else { $packageSection }
179179
}
180180
181+
$newBody = Get-UpdatedBody $currentBody $dependencyMessage
182+
181183
# Update the PR description with retry logic
182184
$maxRetries = 3
183185
$retryCount = 0
@@ -200,17 +202,7 @@ jobs:
200202
# Re-fetch PR body in case another job updated it
201203
$pr = Invoke-RestMethod -Uri $prUri -Method Get -Headers $headers
202204
$currentBody = $pr.body
203-
204-
# Recompute newBody with fresh data
205-
if ($currentBody -match $packageSectionRegex) {
206-
$newBody = $currentBody -replace $packageSectionRegex, $dependencyMessage.Trim()
207-
} elseif ($currentBody -match $devPackagesHeader) {
208-
$insertPoint = $currentBody.IndexOf($devPackagesHeader) + $devPackagesHeader.Length
209-
$newBody = $currentBody.Insert($insertPoint, "`n`n$dependencyMessage")
210-
} else {
211-
$packageSection = "$devPackagesHeader`n`n$dependencyMessage"
212-
$newBody = if ($currentBody) { "$currentBody`n`n$packageSection" } else { $packageSection }
213-
}
205+
$newBody = Get-UpdatedBody $currentBody $dependencyMessage
214206
} else {
215207
Write-Output "Failed to update PR description after $maxRetries attempts"
216208
throw

packages/uipath-platform/src/uipath/platform/_uipath.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .chat import ConversationsService, UiPathLlmChatService, UiPathOpenAIService
1212
from .common import (
1313
ApiClient,
14+
BindingsService,
1415
ExternalApplicationService,
1516
UiPathApiConfig,
1617
UiPathExecutionContext,
@@ -76,6 +77,10 @@ def __init__(
7677
raise SecretMissingError() from e
7778
self._execution_context = UiPathExecutionContext()
7879

80+
@cached_property
81+
def bindings(self) -> BindingsService:
82+
return BindingsService()
83+
7984
@property
8085
def api_client(self) -> ApiClient:
8186
return ApiClient(self._config, self._execution_context)

packages/uipath-platform/src/uipath/platform/common/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
ResourceOverwritesContext,
1414
resource_override,
1515
)
16+
from ._bindings_service import BindingsService
1617
from ._config import UiPathApiConfig, UiPathConfig
1718
from ._endpoints_manager import EndpointManager
1819
from ._execution_context import UiPathExecutionContext
@@ -99,6 +100,7 @@
99100
"validate_pagination_params",
100101
"EndpointManager",
101102
"jsonschema_to_pydantic",
103+
"BindingsService",
102104
"ConnectionResourceOverwrite",
103105
"GenericResourceOverwrite",
104106
"ResourceOverwrite",

packages/uipath-platform/src/uipath/platform/common/_bindings.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,14 @@ def folder_identifier(self) -> str:
4444

4545

4646
class GenericResourceOverwrite(ResourceOverwrite):
47-
resource_type: Literal["process", "index", "app", "asset", "bucket", "mcpServer"]
48-
name: str = Field(alias="name")
49-
folder_path: str = Field(alias="folderPath")
47+
model_config = ConfigDict(populate_by_name=True, extra="allow")
48+
resource_type: Literal["process", "index", "app", "asset", "bucket", "mcpserver", "property", "mcpServer"]
49+
name: str = Field(default="", alias="name")
50+
folder_path: str = Field(default="", alias="folderPath")
51+
52+
@property
53+
def properties(self) -> dict[str, Any]:
54+
return self.model_extra or {}
5055

5156
@property
5257
def resource_identifier(self) -> str:
@@ -109,7 +114,7 @@ def parse(cls, key: str, value: dict[str, Any]) -> ResourceOverwrite:
109114
Returns:
110115
The appropriate ResourceOverwrite subclass instance
111116
"""
112-
resource_type = key.split(".")[0]
117+
resource_type = key.split(".")[0].lower()
113118
value_with_type = {"resource_type": resource_type, **value}
114119
return cls._adapter.validate_python(value_with_type)
115120

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import json
2+
import logging
3+
from pathlib import Path
4+
from typing import Optional, Union, overload
5+
6+
from ._bindings import GenericResourceOverwrite, _resource_overwrites
7+
from ._config import UiPathConfig
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
class BindingsService:
13+
"""Service for reading bindings configurations from bindings.json.
14+
15+
Provides access to properties configured at design time and resolved at runtime.
16+
This works generically for any resource type (like Property, asset, queue) by matching keys.
17+
"""
18+
19+
def __init__(self, bindings_file_path: Optional[Path] = None) -> None:
20+
self._bindings_file_path = bindings_file_path or UiPathConfig.bindings_file_path
21+
22+
def _load_bindings(self) -> list[dict]:
23+
try:
24+
with open(self._bindings_file_path, "r") as f:
25+
data = json.load(f)
26+
return data.get("resources", [])
27+
except FileNotFoundError:
28+
logger.debug("Bindings file not found: %s", self._bindings_file_path)
29+
return []
30+
except (json.JSONDecodeError, OSError) as e:
31+
logger.warning(
32+
"Failed to load bindings file %s: %s", self._bindings_file_path, e
33+
)
34+
return []
35+
36+
def _find_resource(self, key: str) -> Optional[dict]:
37+
"""Find a binding resource by exact or suffix key match."""
38+
resources = self._load_bindings()
39+
for resource in resources:
40+
resource_key = resource.get("key", "")
41+
if (
42+
resource_key == key
43+
or resource_key.endswith(f".{key}")
44+
or resource_key.endswith(key)
45+
):
46+
return resource
47+
return None
48+
49+
def _get_overwrite(self, key: str) -> Optional[GenericResourceOverwrite]:
50+
"""Check context var for a runtime overwrite for the given key.
51+
52+
Supports exact key match and suffix match so that
53+
a short label like ``"SharePoint Invoices folder"`` resolves against a
54+
fully-qualified stored key like
55+
``"property.sharepoint-connection.SharePoint Invoices folder"``.
56+
"""
57+
context_overwrites = _resource_overwrites.get()
58+
if context_overwrites is None:
59+
return None
60+
for stored_key, overwrite in context_overwrites.items():
61+
if not isinstance(overwrite, GenericResourceOverwrite):
62+
continue
63+
64+
# Remove the `<resource_type>.` prefix correctly
65+
parts = stored_key.split(".", 1)
66+
bare_key = parts[1] if len(parts) > 1 else stored_key
67+
68+
if bare_key == key or bare_key.endswith(f".{key}") or stored_key == key:
69+
return overwrite
70+
return None
71+
72+
@overload
73+
def get_property(self, key: str) -> dict[str, str]: ...
74+
75+
@overload
76+
def get_property(self, key: str, sub_property: str) -> str: ...
77+
78+
def get_property(
79+
self, key: str, sub_property: Optional[str] = None
80+
) -> Union[str, dict[str, str]]:
81+
"""Get the value(s) of a binding resource.
82+
83+
Args:
84+
key: The binding key, e.g. ``"sharepoint-connection.SharePoint Invoices folder"`` or ``"asset.my-asset"``.
85+
Accepts the full key or a suffix that uniquely identifies the binding.
86+
sub_property: The name of a specific sub-property to retrieve (e.g. ``"ID"`` or ``"folderPath"``).
87+
If omitted, returns all sub-properties as a ``{name: value}`` dict. Returns:
88+
The ``defaultValue`` of the requested sub-property when ``sub_property`` is
89+
given, or a dict of all sub-property names mapped to their ``defaultValue``
90+
when ``sub_property`` is omitted.
91+
92+
Raises:
93+
KeyError: When the binding key is not found, or when ``sub_property`` is given
94+
but does not exist on the binding.
95+
"""
96+
# Check for runtime overwrite first
97+
overwrite = self._get_overwrite(key)
98+
if overwrite is not None:
99+
if sub_property is not None:
100+
if sub_property not in overwrite.properties:
101+
raise KeyError(
102+
f"Sub-property '{sub_property}' not found in binding '{key}'. "
103+
f"Available: {list(overwrite.properties.keys())}"
104+
)
105+
return overwrite.properties[sub_property]
106+
return dict(overwrite.properties)
107+
108+
# Fall back to bindings.json
109+
resource = self._find_resource(key)
110+
if resource is None:
111+
raise KeyError(
112+
f"Binding '{key}' not found in {self._bindings_file_path}."
113+
)
114+
115+
value: dict = resource.get("value", {})
116+
all_values = {
117+
name: props.get("defaultValue", "") if isinstance(props, dict) else str(props)
118+
for name, props in value.items()
119+
}
120+
121+
if sub_property is not None:
122+
if sub_property not in all_values:
123+
raise KeyError(
124+
f"Sub-property '{sub_property}' not found in binding '{key}'. "
125+
f"Available: {list(all_values.keys())}"
126+
)
127+
return all_values[sub_property]
128+
129+
return all_values

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.30"
3+
version = "2.10.31"
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"

0 commit comments

Comments
 (0)