From 7e3a21e6caa76f15aa3776bdf3d7e193fbb06639 Mon Sep 17 00:00:00 2001 From: ionmincu Date: Wed, 25 Mar 2026 17:06:17 +0200 Subject: [PATCH] feat: add Property binding resource support to SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/publish-dev.yml | 64 ++-- .../src/uipath/platform/_uipath.py | 5 + .../src/uipath/platform/common/__init__.py | 2 + .../src/uipath/platform/common/_bindings.py | 26 +- .../platform/common/_bindings_service.py | 127 ++++++++ packages/uipath/specs/bindings.spec.md | 162 +++++++++- .../src/uipath/_cli/_utils/_studio_project.py | 3 + .../src/uipath/_cli/models/runtime_schema.py | 2 + .../tests/sdk/test_property_bindings.py | 306 ++++++++++++++++++ 9 files changed, 651 insertions(+), 46 deletions(-) create mode 100644 packages/uipath-platform/src/uipath/platform/common/_bindings_service.py create mode 100644 packages/uipath/tests/sdk/test_property_bindings.py diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml index 3c438f75c..0b201cd1b 100644 --- a/.github/workflows/publish-dev.yml +++ b/.github/workflows/publish-dev.yml @@ -109,7 +109,11 @@ jobs: Write-Output "Package $PROJECT_NAME version set to $DEV_VERSION" + $startMarker = "" + $endMarker = "" + $dependencyMessage = @" + $startMarker ### $PROJECT_NAME ``````toml @@ -130,7 +134,13 @@ jobs: [tool.uv.sources] $PROJECT_NAME = { index = "testpypi" } + + [tool.uv] + override-dependencies = [ + "$PROJECT_NAME>=$MIN_VERSION,<$MAX_VERSION", + ] `````` + $endMarker "@ # Get the owner and repo from the GitHub repository @@ -148,36 +158,28 @@ jobs: $pr = Invoke-RestMethod -Uri $prUri -Method Get -Headers $headers $currentBody = $pr.body - # Define regex patterns for matching package sections $devPackagesHeader = "## Development Packages" - $packageHeaderPattern = "### $PROJECT_NAME\s*\n" - - # Find if the package section exists using multiline regex - $packageSectionRegex = "(?ms)### $PROJECT_NAME\s*\n``````toml.*?``````" - - if ($currentBody -match $devPackagesHeader) { - # Development Packages section exists - if ($currentBody -match $packageSectionRegex) { - # Replace existing package section - Write-Output "Updating existing $PROJECT_NAME section" - $newBody = $currentBody -replace $packageSectionRegex, $dependencyMessage.Trim() + $markerPattern = "(?s)$([regex]::Escape($startMarker)).*?$([regex]::Escape($endMarker))" + + function Get-UpdatedBody($body, $message) { + if ($body -match $markerPattern) { + return $body -replace $markerPattern, $message.Trim() + } elseif ($body -match $devPackagesHeader) { + $insertPoint = $body.IndexOf($devPackagesHeader) + $devPackagesHeader.Length + return $body.Insert($insertPoint, "`n`n$($message.Trim())") } else { - # Append new package section after the Development Packages header - Write-Output "Adding new $PROJECT_NAME section" - $insertPoint = $currentBody.IndexOf($devPackagesHeader) + $devPackagesHeader.Length - $newBody = $currentBody.Insert($insertPoint, "`n`n$dependencyMessage") + $section = "$devPackagesHeader`n`n$($message.Trim())" + if ($body) { + $result = "$body`n`n$section" + } else { + $result = $section + } + return $result } - } else { - # Create the Development Packages section - Write-Output "Creating Development Packages section with $PROJECT_NAME" - $packageSection = @" - ## Development Packages - - $dependencyMessage - "@ - $newBody = if ($currentBody) { "$currentBody`n`n$packageSection" } else { $packageSection } } + $newBody = Get-UpdatedBody $currentBody $dependencyMessage + # Update the PR description with retry logic $maxRetries = 3 $retryCount = 0 @@ -200,17 +202,7 @@ jobs: # Re-fetch PR body in case another job updated it $pr = Invoke-RestMethod -Uri $prUri -Method Get -Headers $headers $currentBody = $pr.body - - # Recompute newBody with fresh data - if ($currentBody -match $packageSectionRegex) { - $newBody = $currentBody -replace $packageSectionRegex, $dependencyMessage.Trim() - } elseif ($currentBody -match $devPackagesHeader) { - $insertPoint = $currentBody.IndexOf($devPackagesHeader) + $devPackagesHeader.Length - $newBody = $currentBody.Insert($insertPoint, "`n`n$dependencyMessage") - } else { - $packageSection = "$devPackagesHeader`n`n$dependencyMessage" - $newBody = if ($currentBody) { "$currentBody`n`n$packageSection" } else { $packageSection } - } + $newBody = Get-UpdatedBody $currentBody $dependencyMessage } else { Write-Output "Failed to update PR description after $maxRetries attempts" throw diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py index 87c3a17f0..729e870c6 100644 --- a/packages/uipath-platform/src/uipath/platform/_uipath.py +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -11,6 +11,7 @@ from .chat import ConversationsService, UiPathLlmChatService, UiPathOpenAIService from .common import ( ApiClient, + BindingsService, ExternalApplicationService, UiPathApiConfig, UiPathExecutionContext, @@ -76,6 +77,10 @@ def __init__( raise SecretMissingError() from e self._execution_context = UiPathExecutionContext() + @cached_property + def bindings(self) -> BindingsService: + return BindingsService() + @property def api_client(self) -> ApiClient: return ApiClient(self._config, self._execution_context) diff --git a/packages/uipath-platform/src/uipath/platform/common/__init__.py b/packages/uipath-platform/src/uipath/platform/common/__init__.py index 40fc1ac34..5e37bcafe 100644 --- a/packages/uipath-platform/src/uipath/platform/common/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/common/__init__.py @@ -13,6 +13,7 @@ ResourceOverwritesContext, resource_override, ) +from ._bindings_service import BindingsService from ._config import UiPathApiConfig, UiPathConfig from ._endpoints_manager import EndpointManager from ._execution_context import UiPathExecutionContext @@ -99,6 +100,7 @@ "validate_pagination_params", "EndpointManager", "jsonschema_to_pydantic", + "BindingsService", "ConnectionResourceOverwrite", "GenericResourceOverwrite", "ResourceOverwrite", diff --git a/packages/uipath-platform/src/uipath/platform/common/_bindings.py b/packages/uipath-platform/src/uipath/platform/common/_bindings.py index 01ff732a4..f0ba07a94 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_bindings.py +++ b/packages/uipath-platform/src/uipath/platform/common/_bindings.py @@ -42,11 +42,22 @@ def folder_identifier(self) -> str: """The folder location identifier for this resource.""" pass + @property + @abstractmethod + def properties(self) -> dict[str, Any]: + """A dictionary of properties provided by this overwrite.""" + pass + class GenericResourceOverwrite(ResourceOverwrite): - resource_type: Literal["process", "index", "app", "asset", "bucket", "mcpServer"] - name: str = Field(alias="name") - folder_path: str = Field(alias="folderPath") + model_config = ConfigDict(populate_by_name=True, extra="allow") + resource_type: Literal["process", "index", "app", "asset", "bucket", "mcpserver", "property", "mcpServer"] + name: str = Field(default="", alias="name") + folder_path: str = Field(default="", alias="folderPath") + + @property + def properties(self) -> dict[str, Any]: + return self.model_extra or {} @property def resource_identifier(self) -> str: @@ -71,6 +82,10 @@ class ConnectionResourceOverwrite(ResourceOverwrite): extra="ignore", ) + @property + def properties(self) -> dict[str, Any]: + return self.model_dump(by_alias=True, exclude={"resource_type"}) + @property def resource_identifier(self) -> str: return self.connection_id @@ -109,7 +124,7 @@ def parse(cls, key: str, value: dict[str, Any]) -> ResourceOverwrite: Returns: The appropriate ResourceOverwrite subclass instance """ - resource_type = key.split(".")[0] + resource_type = key.split(".")[0].lower() value_with_type = {"resource_type": resource_type, **value} return cls._adapter.validate_python(value_with_type) @@ -138,6 +153,9 @@ async def __aenter__(self) -> "ResourceOverwritesContext": len(existing), ) overwrites = await self.get_overwrites_callable() + + logger.debug("ResourceOverwritesContext loading overwrites: %s", overwrites) + self._token = _resource_overwrites.set(overwrites) self.overwrites_count = len(overwrites) if overwrites: diff --git a/packages/uipath-platform/src/uipath/platform/common/_bindings_service.py b/packages/uipath-platform/src/uipath/platform/common/_bindings_service.py new file mode 100644 index 000000000..6152eab20 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/_bindings_service.py @@ -0,0 +1,127 @@ +import json +import logging +from functools import cached_property +from pathlib import Path +from typing import Any, overload + +from ._bindings import ResourceOverwrite, _resource_overwrites +from ._config import UiPathConfig + +logger = logging.getLogger(__name__) + + +class BindingsService: + """Service for reading bindings configurations from bindings.json. + + Provides access to properties configured at design time and resolved at runtime. + """ + + def __init__(self, bindings_file_path: Path | None = None) -> None: + self._bindings_file_path = bindings_file_path or UiPathConfig.bindings_file_path + + @cached_property + def _load_bindings(self) -> list[dict[str, Any]]: + try: + with open(self._bindings_file_path, "r") as f: + data = json.load(f) + return data.get("resources", []) + except FileNotFoundError: + logger.debug("Bindings file not found: %s", self._bindings_file_path) + return [] + except (json.JSONDecodeError, OSError) as e: + logger.warning( + "Failed to load bindings file %s: %s", self._bindings_file_path, e + ) + return [] + + def _find_resource(self, key: str) -> dict[str, Any] | None: + """Find a binding resource by exact or suffix key match.""" + resources = self._load_bindings + for resource in resources: + resource_key = resource.get("key", "") + if ( + resource_key == key + or resource_key.endswith(f".{key}") + or resource_key.endswith(key) + ): + return resource + return None + + def _get_overwrite(self, key: str) -> ResourceOverwrite | None: + """Check context var for a runtime overwrite for the given key. + + Supports exact key match and suffix match so that + a short label like ``"SharePoint Invoices folder"`` resolves against a + fully-qualified stored key like + ``"property.sharepoint-connection.SharePoint Invoices folder"``. + """ + context_overwrites = _resource_overwrites.get() + if context_overwrites is None: + return None + for stored_key, overwrite in context_overwrites.items(): + # Remove the `.` prefix correctly + parts = stored_key.split(".", 1) + bare_key = parts[1] if len(parts) > 1 else stored_key + + if bare_key == key or bare_key.endswith(f".{key}") or stored_key == key: + return overwrite + return None + + @overload + def get_property(self, key: str) -> dict[str, str]: ... + + @overload + def get_property(self, key: str, sub_property: str) -> str: ... + + def get_property( + self, key: str, sub_property: str | None = None + ) -> str | dict[str, str]: + """Get the value(s) of a binding resource. + + Args: + key: The binding key, e.g. ``"sharepoint-connection.SharePoint Invoices folder"`` or ``"asset.my-asset"``. + Accepts the full key or a suffix that uniquely identifies the binding. + sub_property: The name of a specific sub-property to retrieve (e.g. ``"ID"`` or ``"folderPath"``). + If omitted, returns all sub-properties as a ``{name: value}`` dict. Returns: + The ``defaultValue`` of the requested sub-property when ``sub_property`` is + given, or a dict of all sub-property names mapped to their ``defaultValue`` + when ``sub_property`` is omitted. + + Raises: + KeyError: When the binding key is not found, or when ``sub_property`` is given + but does not exist on the binding. + """ + # Check for runtime overwrite first + overwrite = self._get_overwrite(key) + if overwrite is not None: + if sub_property is not None: + if sub_property not in overwrite.properties: + raise KeyError( + f"Sub-property '{sub_property}' not found in binding '{key}'. " + f"Available: {list(overwrite.properties.keys())}" + ) + return overwrite.properties[sub_property] + return dict(overwrite.properties) + + # Fall back to bindings.json + resource = self._find_resource(key) + if resource is None: + raise KeyError( + f"Binding '{key}' not found in {self._bindings_file_path}." + ) + + value: dict = resource.get("value", {}) + all_values = { + name: props.get("defaultValue", "") if isinstance(props, dict) else str(props) + for name, props in value.items() + } + + if sub_property is not None: + if sub_property not in all_values: + raise KeyError( + f"Sub-property '{sub_property}' not found in binding '{key}'. " + f"Available: {list(all_values.keys())}" + ) + return all_values[sub_property] + + return all_values diff --git a/packages/uipath/specs/bindings.spec.md b/packages/uipath/specs/bindings.spec.md index 6fecf2f70..aca3bcf4c 100644 --- a/packages/uipath/specs/bindings.spec.md +++ b/packages/uipath/specs/bindings.spec.md @@ -43,6 +43,7 @@ The configuration supports multiple resource types: 4. **index** - Search indexes 5. **apps** - Action center apps 6. **connection** - External connections +7. **Property** - Connector-defined resource properties (e.g. SharePoint folder IDs selected at design time) --- @@ -53,7 +54,7 @@ Each resource in the `resources` array has the following structure: ```json { - "resource": "asset|process|bucket|index|connection", + "resource": "asset|process|bucket|index|connection|Property", "key": "unique_key", "value": { ... }, "metadata": { ... } @@ -64,7 +65,7 @@ Each resource in the `resources` array has the following structure: | Property | Type | Required | Description | |----------|------|----------|-------------| -| `resource` | `string` | Yes | Resource type (one of the five types) | +| `resource` | `string` | Yes | Resource type (one of the seven types) | | `key` | `string` | Yes | Unique identifier for this resource | | `value` | `object` | Yes | Resource-specific configuration | | `metadata` | `object` | No | Additional metadata for the binding | @@ -303,6 +304,101 @@ Connections define external system integrations. --- +### 7. Property + +Property bindings represent connector-defined resources that a user browses and selects at design time (e.g. a SharePoint folder, an OneDrive file). They are child resources of a parent Connection binding and contain **arbitrary sub-properties** with resolved values. + +**Key Format:** `.