Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/uipath-platform/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath-platform"
version = "0.2.32"
version = "0.2.33"
description = "HTTP client library for programmatic access to UiPath Platform"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand Down
10 changes: 10 additions & 0 deletions packages/uipath-platform/src/uipath/platform/common/_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@

T = TypeVar("T")

BINDING_METADATA_ATTRIBUTE = "__uipath_binding__"


class ResourceOverwrite(BaseModel, ABC):
"""Abstract base class for resource overwrites.
Expand Down Expand Up @@ -304,13 +306,20 @@ def process_args(args, kwargs) -> dict[str, Any]:

return all_args

binding_metadata = {
"resource_type": resource_type,
"resource_identifier": resource_identifier,
"folder_identifier": folder_identifier,
}

if inspect.iscoroutinefunction(func):

@functools.wraps(func)
async def async_wrapper(*args, **kwargs):
all_args = process_args(args, kwargs)
return await func(**all_args)

async_wrapper.__dict__[BINDING_METADATA_ATTRIBUTE] = binding_metadata
return async_wrapper
else:

Expand All @@ -319,6 +328,7 @@ def wrapper(*args, **kwargs):
all_args = process_args(args, kwargs)
return func(**all_args)

wrapper.__dict__[BINDING_METADATA_ATTRIBUTE] = binding_metadata
return wrapper

return decorator
2 changes: 1 addition & 1 deletion packages/uipath-platform/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/uipath/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Uses **click** framework. Commands are organized as `cli_<command>.py` files.
| `cli_pull.py` | `pull` | Pull from remote storage |
| `cli_dev.py` | `dev` | Development server mode |
| `cli_add.py` | `add` | Add resource/dependency |
| `cli_bindings.py` | `bindings` | Generate bindings.json from resources referenced in code |
| `cli_server.py` | `server` | Run as server |
| `cli_register.py` | `register` | Register resource |
| `cli_debug.py` | `debug` | Debug execution |
Expand Down
30 changes: 30 additions & 0 deletions packages/uipath/docs/cli/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,36 @@ Do not change or remove it. Changing it makes the project look like a brand-new,
///
---

::: mkdocs-click
:module: uipath._cli
:command: bindings
:depth: 1
:style: table

Records the UiPath resources your code refers to in `bindings.json`, so they can be remapped to different resources per environment when the project is pushed to a solution or published. `uipath init --infer-bindings` does the same thing as part of initialization.

Discovery is static and best effort. Only a resource named by a literal or by a module-level constant is recorded. A name or folder assembled at runtime (a variable, an f-string, `os.getenv(...)`) is reported with its file and line rather than guessed at, because a Python expression is not a resource name and would reach the platform as a request for one. Both direct SDK calls and resources reached through an interrupt model (`InvokeProcess`, `CreateTask`, `CreateEscalation`, `CreateDeepRag`, `CreateBatchTransform`) are matched:

<!-- termynal -->

```shell
> uipath bindings generate
Discovered asset:MyAsset.Shared
Discovered bucket:Invoices.Finance
⚠️ storage.py:41: bucket — could not determine 'name' for buckets.download
✓ Wrote 'bindings.json' with 2 binding(s) (2 new).
```

Entries already in the file are never rewritten, since they may carry connector metadata or display names that a scan cannot reproduce. Use `--check` in CI to fail when the file is missing bindings, and `--dry-run` to preview.

/// info
### What is not scanned

Test files (`test_*.py`, `*_test.py`) and directories such as `tests/`, `.venv/` and `node_modules/` are skipped, so a resource referenced only by a test does not become a solution requirement.
///

---

::: mkdocs-click
:module: uipath._cli
:command: run
Expand Down
4 changes: 2 additions & 2 deletions packages/uipath/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
[project]
name = "uipath"
version = "2.14.25"
version = "2.14.26"
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
dependencies = [
"uipath-core>=0.5.30, <0.6.0",
"uipath-runtime>=0.13.5, <0.14.0",
"uipath-platform>=0.2.31, <0.3.0",
"uipath-platform>=0.2.33, <0.3.0",
"uipath-ipc>=2.5.1, <2.6.0",
"click>=8.3.3, <9.0.0",
"httpx>=0.28.1",
Expand Down
1 change: 1 addition & 0 deletions packages/uipath/src/uipath/_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"eval": "cli_eval",
"dev": "cli_dev",
"add": "cli_add",
"bindings": "cli_bindings",
"server": "cli_server",
"register": "cli_register",
"debug": "cli_debug",
Expand Down
40 changes: 40 additions & 0 deletions packages/uipath/src/uipath/_cli/_bindings/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Best-effort discovery of UiPath resource bindings from project source code."""

from ._apply import (
InferOutcome,
infer_bindings,
infer_bindings_into_file,
load_bindings,
report_outcome,
serialize_bindings,
)
from ._emitter import MergeReport, binding_key, build_binding, merge_bindings
from ._registry import BINDABLE_RESOURCE_TYPES, BindingSpec, build_registry
from ._scanner import (
ResourceReference,
ScanResult,
SkippedReference,
scan_project,
scan_source,
)

__all__ = [
"BINDABLE_RESOURCE_TYPES",
"InferOutcome",
"BindingSpec",
"MergeReport",
"ResourceReference",
"ScanResult",
"SkippedReference",
"binding_key",
"build_binding",
"build_registry",
"infer_bindings",
"infer_bindings_into_file",
"load_bindings",
"merge_bindings",
"report_outcome",
"serialize_bindings",
"scan_project",
"scan_source",
]
76 changes: 76 additions & 0 deletions packages/uipath/src/uipath/_cli/_bindings/_apply.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Shared scan-merge-write step behind `bindings generate` and `init`."""

import json
from dataclasses import dataclass
from pathlib import Path
from typing import Optional

from .._utils._console import ConsoleLogger
from ..models.runtime_schema import Bindings
from ._emitter import MergeReport, merge_bindings
from ._registry import build_registry
from ._scanner import SkippedReference, scan_project

console = ConsoleLogger()


@dataclass
class InferOutcome:
"""What a scan would change about a bindings file."""

merged: Bindings
report: MergeReport
skipped: list[SkippedReference]

@property
def has_changes(self) -> bool:
return bool(self.report.added)


def load_bindings(path: Path) -> Optional[Bindings]:
"""Read an existing bindings file, or None when there isn't one."""
if not path.exists():
return None
try:
return Bindings.model_validate_json(path.read_text())
except ValueError as exc:
console.error(f"Could not read '{path}': {exc}")


def serialize_bindings(bindings: Bindings) -> str:
payload = bindings.model_dump(by_alias=True, exclude_none=True)
return json.dumps(payload, indent=4)


def infer_bindings(root: Path, existing: Optional[Bindings]) -> InferOutcome:
"""Scan ``root`` and merge what it finds into ``existing``."""
result = scan_project(root, build_registry())
merged, report = merge_bindings(existing, result.references)
return InferOutcome(merged=merged, report=report, skipped=result.skipped)


def report_outcome(outcome: InferOutcome) -> None:
"""Say what was found and, just as importantly, what was not."""
for skipped in outcome.skipped:
console.warning(f"{skipped.source}: {skipped.resource_type} — {skipped.reason}")
for label in outcome.report.added:
console.info(f"Discovered {label}")
if outcome.report.preserved:
console.info(
f"Kept {len(outcome.report.preserved)} existing binding(s) "
"not found in code."
)


def infer_bindings_into_file(root: Path, bindings_path: Path) -> InferOutcome:
"""Scan, merge and write. Existing entries are never rewritten."""
outcome = infer_bindings(root, load_bindings(bindings_path))
report_outcome(outcome)
if outcome.has_changes:
bindings_path.write_text(serialize_bindings(outcome.merged))
console.success(
f"Recorded {len(outcome.report.added)} binding(s) in '{bindings_path}'."
)
else:
console.info(f"No new bindings to record in '{bindings_path}'.")
return outcome
113 changes: 113 additions & 0 deletions packages/uipath/src/uipath/_cli/_bindings/_emitter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Turns discovered resource references into ``bindings.json`` entries.

Merging never rewrites an entry that is already in the file. Existing bindings
carry things a static scan cannot reproduce — expressions, connector metadata,
display names edited by hand — so a known key is left exactly as it is.
"""

from dataclasses import dataclass, field
from typing import Optional

from ..models.runtime_schema import BindingResource, BindingResourceValue, Bindings
from ._scanner import ResourceReference

BINDINGS_VERSION = "2.0"
BINDINGS_METADATA_VERSION = "2.2"

_DISPLAY_NAMES = {
"app": ("App Name", "App Folder Path"),
}
_DEFAULT_DISPLAY_NAMES = ("Name", "Folder Path")


@dataclass
class MergeReport:
added: list[str] = field(default_factory=list)
unchanged: list[str] = field(default_factory=list)
preserved: list[str] = field(default_factory=list)


def binding_key(reference: ResourceReference) -> str:
"""The ``key`` field, which the platform prefixes with the resource type."""
if reference.resource_type == "connection" or not reference.folder_path:
return reference.name
return f"{reference.name}.{reference.folder_path}"
Comment thread
tudormatei1 marked this conversation as resolved.


def _value(default_value: str, display_name: str) -> BindingResourceValue:
"""Generated values are always literal; see _scanner._record."""
return BindingResourceValue(
default_value=default_value,
is_expression=False,
display_name=display_name,
)


def _connection_binding(reference: ResourceReference) -> BindingResource:
return BindingResource(
resource="connection",
key=binding_key(reference),
value={"ConnectionId": _value(reference.name, "Connection")},
metadata={
"BindingsVersion": BINDINGS_METADATA_VERSION,
"Connector": "",
"UseConnectionService": "True",
},
)


def build_binding(reference: ResourceReference) -> BindingResource:
"""Build a single binding entry for a discovered reference."""
if reference.resource_type == "connection":
return _connection_binding(reference)

name_label, folder_label = _DISPLAY_NAMES.get(
reference.resource_type, _DEFAULT_DISPLAY_NAMES
)
display_label = reference.name if reference.resource_type == "app" else "FullName"

return BindingResource(
resource=reference.resource_type,
key=binding_key(reference),
value={
"name": _value(reference.name, name_label),
"folderPath": _value(reference.folder_path or "", folder_label),
},
metadata={
"ActivityName": reference.activity_name,
"BindingsVersion": BINDINGS_METADATA_VERSION,
"DisplayLabel": display_label,
},
)


def merge_bindings(
existing: Optional[Bindings], references: list[ResourceReference]
) -> tuple[Bindings, MergeReport]:
"""Add newly discovered bindings without disturbing the ones already there."""
resources = list(existing.resources) if existing else []
known = {(entry.resource, entry.key) for entry in resources}
report = MergeReport()
discovered: set[tuple[str, str]] = set()

ordered = sorted(references, key=lambda ref: (ref.resource_type, binding_key(ref)))
for reference in ordered:
identity = (reference.resource_type, binding_key(reference))
if identity in discovered:
continue
discovered.add(identity)
label = f"{identity[0]}:{identity[1]}"
if identity in known:
report.unchanged.append(label)
continue
resources.append(build_binding(reference))
known.add(identity)
report.added.append(label)

for entry in resources:
identity = (entry.resource, entry.key)
if identity not in discovered:
report.preserved.append(f"{entry.resource}:{entry.key}")

version = existing.version if existing else BINDINGS_VERSION
return Bindings(version=version, resources=resources), report
Loading
Loading