Skip to content

Commit d0d60e4

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 3e525cc commit d0d60e4

File tree

9 files changed

+650
-46
lines changed

9 files changed

+650
-46
lines changed

.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: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,22 @@ def folder_identifier(self) -> str:
4242
"""The folder location identifier for this resource."""
4343
pass
4444

45+
@property
46+
@abstractmethod
47+
def properties(self) -> dict[str, Any]:
48+
"""A dictionary of properties provided by this overwrite."""
49+
pass
50+
4551

4652
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")
53+
model_config = ConfigDict(populate_by_name=True, extra="allow")
54+
resource_type: Literal["process", "index", "app", "asset", "bucket", "mcpserver", "property", "mcpServer"]
55+
name: str = Field(default="", alias="name")
56+
folder_path: str = Field(default="", alias="folderPath")
57+
58+
@property
59+
def properties(self) -> dict[str, Any]:
60+
return self.model_extra or {}
5061

5162
@property
5263
def resource_identifier(self) -> str:
@@ -71,6 +82,10 @@ class ConnectionResourceOverwrite(ResourceOverwrite):
7182
extra="ignore",
7283
)
7384

85+
@property
86+
def properties(self) -> dict[str, Any]:
87+
return self.model_dump(by_alias=True, exclude={"resource_type"})
88+
7489
@property
7590
def resource_identifier(self) -> str:
7691
return self.connection_id
@@ -109,7 +124,7 @@ def parse(cls, key: str, value: dict[str, Any]) -> ResourceOverwrite:
109124
Returns:
110125
The appropriate ResourceOverwrite subclass instance
111126
"""
112-
resource_type = key.split(".")[0]
127+
resource_type = key.split(".")[0].lower()
113128
value_with_type = {"resource_type": resource_type, **value}
114129
return cls._adapter.validate_python(value_with_type)
115130

@@ -138,6 +153,9 @@ async def __aenter__(self) -> "ResourceOverwritesContext":
138153
len(existing),
139154
)
140155
overwrites = await self.get_overwrites_callable()
156+
157+
logger.debug("ResourceOverwritesContext loading overwrites: %s", overwrites)
158+
141159
self._token = _resource_overwrites.set(overwrites)
142160
self.overwrites_count = len(overwrites)
143161
if overwrites:
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import json
2+
import logging
3+
from functools import cached_property
4+
from pathlib import Path
5+
from typing import Any, overload
6+
7+
from ._bindings import ResourceOverwrite, _resource_overwrites
8+
from ._config import UiPathConfig
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
class BindingsService:
14+
"""Service for reading bindings configurations from bindings.json.
15+
16+
Provides access to properties configured at design time and resolved at runtime.
17+
"""
18+
19+
def __init__(self, bindings_file_path: Path | None = None) -> None:
20+
self._bindings_file_path = bindings_file_path or UiPathConfig.bindings_file_path
21+
22+
@cached_property
23+
def _load_bindings(self) -> list[dict[str, Any]]:
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) -> dict[str, Any] | None:
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) -> ResourceOverwrite | None:
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+
# Remove the `<resource_type>.` prefix correctly
63+
parts = stored_key.split(".", 1)
64+
bare_key = parts[1] if len(parts) > 1 else stored_key
65+
66+
if bare_key == key or bare_key.endswith(f".{key}") or stored_key == key:
67+
return overwrite
68+
return None
69+
70+
@overload
71+
def get_property(self, key: str) -> dict[str, str]: ...
72+
73+
@overload
74+
def get_property(self, key: str, sub_property: str) -> str: ...
75+
76+
def get_property(
77+
self, key: str, sub_property: str | None = None
78+
) -> str | dict[str, str]:
79+
"""Get the value(s) of a binding resource.
80+
81+
Args:
82+
key: The binding key, e.g. ``"sharepoint-connection.SharePoint Invoices folder"`` or ``"asset.my-asset"``.
83+
Accepts the full key or a suffix that uniquely identifies the binding.
84+
sub_property: The name of a specific sub-property to retrieve (e.g. ``"ID"`` or ``"folderPath"``).
85+
If omitted, returns all sub-properties as a ``{name: value}`` dict. Returns:
86+
The ``defaultValue`` of the requested sub-property when ``sub_property`` is
87+
given, or a dict of all sub-property names mapped to their ``defaultValue``
88+
when ``sub_property`` is omitted.
89+
90+
Raises:
91+
KeyError: When the binding key is not found, or when ``sub_property`` is given
92+
but does not exist on the binding.
93+
"""
94+
# Check for runtime overwrite first
95+
overwrite = self._get_overwrite(key)
96+
if overwrite is not None:
97+
if sub_property is not None:
98+
if sub_property not in overwrite.properties:
99+
raise KeyError(
100+
f"Sub-property '{sub_property}' not found in binding '{key}'. "
101+
f"Available: {list(overwrite.properties.keys())}"
102+
)
103+
return overwrite.properties[sub_property]
104+
return dict(overwrite.properties)
105+
106+
# Fall back to bindings.json
107+
resource = self._find_resource(key)
108+
if resource is None:
109+
raise KeyError(
110+
f"Binding '{key}' not found in {self._bindings_file_path}."
111+
)
112+
113+
value: dict = resource.get("value", {})
114+
all_values = {
115+
name: props.get("defaultValue", "") if isinstance(props, dict) else str(props)
116+
for name, props in value.items()
117+
}
118+
119+
if sub_property is not None:
120+
if sub_property not in all_values:
121+
raise KeyError(
122+
f"Sub-property '{sub_property}' not found in binding '{key}'. "
123+
f"Available: {list(all_values.keys())}"
124+
)
125+
return all_values[sub_property]
126+
127+
return all_values

0 commit comments

Comments
 (0)