Skip to content

Commit d1caba0

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 d1caba0

10 files changed

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