diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 762af38c6..1b57a68a4 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -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" diff --git a/packages/uipath-platform/src/uipath/platform/common/_bindings.py b/packages/uipath-platform/src/uipath/platform/common/_bindings.py index a93880896..9df97f3a4 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_bindings.py +++ b/packages/uipath-platform/src/uipath/platform/common/_bindings.py @@ -27,6 +27,8 @@ T = TypeVar("T") +BINDING_METADATA_ATTRIBUTE = "__uipath_binding__" + class ResourceOverwrite(BaseModel, ABC): """Abstract base class for resource overwrites. @@ -304,6 +306,12 @@ 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) @@ -311,6 +319,7 @@ 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: @@ -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 diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index e084735b1..54ab45e3d 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.32" +version = "0.2.33" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/CLAUDE.md b/packages/uipath/CLAUDE.md index a5b489af5..0b00cfbc0 100644 --- a/packages/uipath/CLAUDE.md +++ b/packages/uipath/CLAUDE.md @@ -58,6 +58,7 @@ Uses **click** framework. Commands are organized as `cli_.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 | diff --git a/packages/uipath/docs/cli/index.md b/packages/uipath/docs/cli/index.md index 0fdddfe13..fc3291560 100644 --- a/packages/uipath/docs/cli/index.md +++ b/packages/uipath/docs/cli/index.md @@ -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: + + + +```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 diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index bb12bfa6f..254d85a56 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -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", diff --git a/packages/uipath/src/uipath/_cli/__init__.py b/packages/uipath/src/uipath/_cli/__init__.py index f12d46560..35db49d8c 100644 --- a/packages/uipath/src/uipath/_cli/__init__.py +++ b/packages/uipath/src/uipath/_cli/__init__.py @@ -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", diff --git a/packages/uipath/src/uipath/_cli/_bindings/__init__.py b/packages/uipath/src/uipath/_cli/_bindings/__init__.py new file mode 100644 index 000000000..ddef19620 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_bindings/__init__.py @@ -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", +] diff --git a/packages/uipath/src/uipath/_cli/_bindings/_apply.py b/packages/uipath/src/uipath/_cli/_bindings/_apply.py new file mode 100644 index 000000000..8c35d5772 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_bindings/_apply.py @@ -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 diff --git a/packages/uipath/src/uipath/_cli/_bindings/_emitter.py b/packages/uipath/src/uipath/_cli/_bindings/_emitter.py new file mode 100644 index 000000000..65b007556 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_bindings/_emitter.py @@ -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}" + + +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 diff --git a/packages/uipath/src/uipath/_cli/_bindings/_interrupts.py b/packages/uipath/src/uipath/_cli/_bindings/_interrupts.py new file mode 100644 index 000000000..558acd02a --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_bindings/_interrupts.py @@ -0,0 +1,110 @@ +"""Resources reached by constructing an interrupt model rather than calling the SDK. + +A LangGraph agent rarely calls ``sdk.processes.invoke_async`` directly. It +builds an interrupt model and hands it to ``interrupt(...)``; the resume-trigger +protocol then makes the decorated SDK call on its behalf:: + + interrupt(InvokeProcess(name="child", process_folder_path="Shared")) + +The call site in user code is a constructor, so the decorator registry cannot +see it. Each spec below mirrors one branch of +``uipath.platform.resume_triggers._protocol``, which is what decides the SDK +method a model routes to and therefore the resource it binds. +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class InterruptSpec: + """How to read a resource out of one interrupt model constructor.""" + + model: str + resource_type: str + name_field: str + folder_field: Optional[str] + activity_name: str + + +def _spec( + model: str, + resource_type: str, + name_field: str, + folder_field: str, + activity_name: str, +) -> tuple[str, InterruptSpec]: + return model, InterruptSpec( + model=model, + resource_type=resource_type, + name_field=name_field, + folder_field=folder_field, + activity_name=activity_name, + ) + + +INTERRUPT_SPECS: dict[str, InterruptSpec] = dict( + [ + _spec( + "InvokeProcess", "process", "name", "process_folder_path", "invoke_async" + ), + _spec( + "InvokeProcessRaw", "process", "name", "process_folder_path", "invoke_async" + ), + _spec("CreateTask", "app", "app_name", "app_folder_path", "create_async"), + _spec("CreateEscalation", "app", "app_name", "app_folder_path", "create_async"), + # `name` on these two is the task's own name; `index_name` is the resource. + _spec( + "CreateDeepRag", + "index", + "index_name", + "index_folder_path", + "start_deep_rag_async", + ), + _spec( + "CreateDeepRagRaw", + "index", + "index_name", + "index_folder_path", + "start_deep_rag_async", + ), + _spec( + "CreateBatchTransform", + "index", + "index_name", + "index_folder_path", + "start_batch_transform_async", + ), + ] +) + +NON_BINDING_INTERRUPT_MODELS: frozenset[str] = frozenset( + { + # Reference a job or action that already exists, by key. The folder on + # them locates that object; it does not name a new resource. + "WaitJob", + "WaitJobRaw", + "WaitTask", + "WaitEscalation", + "WaitDeepRag", + "WaitDeepRagRaw", + "WaitBatchTransform", + "WaitEphemeralIndex", + "WaitEphemeralIndexRaw", + "WaitDocumentExtraction", + "WaitDocumentExtractionValidation", + "WaitSystemAgent", + # Ephemeral indexes are created per run and have no name to bind. + "CreateEphemeralIndex", + "CreateEphemeralIndexRaw", + # Routed through agenthub, which carries no @resource_override. + "InvokeSystemAgent", + # Document Understanding projects are not one of the bindable types. + "DocumentExtraction", + "DocumentExtractionValidation", + # Names a connection, but connection bindings key on the connection id. + "WaitIntegrationEvent", + # Carries no resource at all. + "WaitUntil", + } +) diff --git a/packages/uipath/src/uipath/_cli/_bindings/_registry.py b/packages/uipath/src/uipath/_cli/_bindings/_registry.py new file mode 100644 index 000000000..d61e9bc8e --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_bindings/_registry.py @@ -0,0 +1,165 @@ +"""Derives the scannable SDK surface from the ``@resource_override`` decorators. + +The decorators on the platform services already declare, per method, which +resource type is touched and which parameters carry the resource name and its +folder. Reading them back is what keeps the scanner in step with the SDK: a +hand-maintained list would silently go stale as services grow. +""" + +import inspect +import typing +from dataclasses import dataclass +from functools import lru_cache +from typing import Any, Optional + +BINDABLE_RESOURCE_TYPES = frozenset( + {"asset", "process", "bucket", "index", "app", "connection"} +) + + +@dataclass(frozen=True) +class BindingSpec: + """One scannable SDK call, e.g. ``sdk.assets.retrieve_async(...)``.""" + + service_attr: str + method: str + resource_type: str + name_param: str + folder_param: Optional[str] + name_index: Optional[int] + folder_index: Optional[int] + activity_name: str + + +def _binding_metadata(func: Any) -> Optional[dict[str, str]]: + target = getattr(func, "__func__", func) + metadata = getattr(target, "__uipath_binding__", None) + if isinstance(metadata, dict): + return metadata + return _metadata_from_closure(target) + + +def _metadata_from_closure(target: Any) -> Optional[dict[str, str]]: + """Recover the decorator arguments from an SDK that predates the attribute.""" + closure = getattr(target, "__closure__", None) + code = getattr(target, "__code__", None) + if not closure or code is None: + return None + cells = dict(zip(code.co_freevars, closure, strict=True)) + process_args = cells.get("process_args") + if process_args is None: + return None + inner = process_args.cell_contents + inner_closure = getattr(inner, "__closure__", None) + inner_code = getattr(inner, "__code__", None) + if not inner_closure or inner_code is None: + return None + inner_cells = dict(zip(inner_code.co_freevars, inner_closure, strict=True)) + if not {"resource_type", "resource_identifier", "folder_identifier"} <= set( + inner_cells + ): + return None + return { + key: inner_cells[key].cell_contents + for key in ("resource_type", "resource_identifier", "folder_identifier") + } + + +def _iter_service_classes() -> typing.Iterator[tuple[str, type]]: + from uipath.platform import UiPath + + for attr, descriptor in vars(UiPath).items(): + if attr.startswith("_"): + continue + accessor = getattr(descriptor, "fget", None) or getattr( + descriptor, "func", None + ) + if accessor is None: + continue + try: + hints = typing.get_type_hints(accessor) + except Exception: + continue + service_cls = hints.get("return") + if isinstance(service_cls, type): + yield attr, service_cls + + +def _positional_index(signature: inspect.Signature, param_name: str) -> Optional[int]: + index = 0 + for name, parameter in signature.parameters.items(): + if name == "self": + continue + if parameter.kind not in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + ): + return None + if name == param_name: + return index + index += 1 + return None + + +def _resolve_activity_name(service_cls: type, method: str) -> str: + if method.endswith("_async"): + return method + if hasattr(service_cls, f"{method}_async"): + return f"{method}_async" + return method + + +def _build_spec( + service_attr: str, + service_cls: type, + method: str, + func: Any, + metadata: dict[str, str], +) -> Optional[BindingSpec]: + try: + signature = inspect.signature(func) + except (TypeError, ValueError): + return None + + name_param = metadata["resource_identifier"] + if name_param not in signature.parameters: + return None + + folder_param: Optional[str] = metadata["folder_identifier"] + if folder_param not in signature.parameters: + folder_param = None + + return BindingSpec( + service_attr=service_attr, + method=method, + resource_type=metadata["resource_type"], + name_param=name_param, + folder_param=folder_param, + name_index=_positional_index(signature, name_param), + folder_index=( + _positional_index(signature, folder_param) if folder_param else None + ), + activity_name=_resolve_activity_name(service_cls, method), + ) + + +@lru_cache(maxsize=1) +def build_registry() -> dict[tuple[str, str], BindingSpec]: + """Map ``(sdk attribute, method name)`` to its binding spec.""" + registry: dict[tuple[str, str], BindingSpec] = {} + for service_attr, service_cls in _iter_service_classes(): + for method, func in vars(service_cls).items(): + metadata = _binding_metadata(func) + if metadata is None: + continue + if metadata["resource_type"] not in BINDABLE_RESOURCE_TYPES: + continue + spec = _build_spec(service_attr, service_cls, method, func, metadata) + if spec is not None: + registry[(service_attr, method)] = spec + return registry + + +def service_attributes(registry: dict[tuple[str, str], BindingSpec]) -> set[str]: + """The SDK attribute names worth looking for in project source.""" + return {service_attr for service_attr, _ in registry} diff --git a/packages/uipath/src/uipath/_cli/_bindings/_scanner.py b/packages/uipath/src/uipath/_cli/_bindings/_scanner.py new file mode 100644 index 000000000..86e025e65 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_bindings/_scanner.py @@ -0,0 +1,338 @@ +"""Static discovery of resource references in a project's Python sources. + +The scan is deliberately conservative. It reports a reference only when it can +see which SDK method is being called and which argument carries the resource +name; everything else is reported as skipped so the gap is visible rather than +guessed at. +""" + +import ast +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +from ._interrupts import INTERRUPT_SPECS, InterruptSpec +from ._registry import BindingSpec, service_attributes + +EXCLUDED_DIR_NAMES = frozenset( + { + ".git", + ".hg", + ".idea", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ".tox", + ".uipath", + ".venv", + ".vscode", + "__pycache__", + "build", + "dist", + "env", + "node_modules", + "site-packages", + "test", + "tests", + "venv", + } +) + + +@dataclass(frozen=True) +class ResourceReference: + """A resource the agent code names with a literal value.""" + + resource_type: str + name: str + folder_path: Optional[str] + activity_name: str + source: str + + +@dataclass(frozen=True) +class SkippedReference: + """A call that touches a resource but could not be turned into a binding.""" + + resource_type: str + reason: str + source: str + + +@dataclass +class ScanResult: + references: list[ResourceReference] = field(default_factory=list) + skipped: list[SkippedReference] = field(default_factory=list) + + def extend(self, other: "ScanResult") -> None: + self.references.extend(other.references) + self.skipped.extend(other.skipped) + + def deduplicated(self) -> "ScanResult": + seen: dict[tuple[str, str, Optional[str]], ResourceReference] = {} + for reference in self.references: + key = (reference.resource_type, reference.name, reference.folder_path) + seen.setdefault(key, reference) + return ScanResult(references=list(seen.values()), skipped=self.skipped) + + +def _is_excluded_file(path: Path) -> bool: + return path.name.startswith("test_") or path.name.endswith("_test.py") + + +def iter_project_files(root: Path) -> list[Path]: + """Project sources worth scanning, in a stable order.""" + files: list[Path] = [] + for path in sorted(root.rglob("*.py")): + relative = path.relative_to(root) + if any(part in EXCLUDED_DIR_NAMES for part in relative.parts[:-1]): + continue + if _is_excluded_file(path): + continue + files.append(path) + return files + + +def _binding_counts(tree: ast.Module) -> dict[str, int]: + """Count every place a name is bound, at any depth. + + Augmented assignment, a rebind inside a branch or loop, a function-local of + the same name: all of them mean the value at the call site may not be the + literal seen at module level. + """ + counts: dict[str, int] = {} + + def bump(name: str) -> None: + counts[name] = counts.get(name, 0) + 1 + + for node in ast.walk(tree): + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store): + bump(node.id) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + bump(node.name) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + for alias in node.names: + bump(alias.asname or alias.name.split(".")[0]) + return counts + + +def _module_constants(tree: ast.Module) -> dict[str, str]: + """Module-level ``NAME = "literal"`` assignments that are never rebound.""" + counts = _binding_counts(tree) + constants: dict[str, str] = {} + for node in tree.body: + targets: list[ast.expr] + value: ast.expr + if isinstance(node, ast.Assign): + targets, value = list(node.targets), node.value + elif isinstance(node, ast.AnnAssign) and node.value is not None: + targets, value = [node.target], node.value + else: + continue + if not (isinstance(value, ast.Constant) and isinstance(value.value, str)): + continue + for target in targets: + if isinstance(target, ast.Name) and counts.get(target.id) == 1: + constants[target.id] = value.value + return constants + + +def _resolve(node: ast.expr, constants: dict[str, str]) -> tuple[str, bool]: + """Return the value and whether it had to be kept as an expression.""" + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value, False + if isinstance(node, ast.Name) and node.id in constants: + return constants[node.id], False + return ast.unparse(node), True + + +def _argument( + call: ast.Call, param: Optional[str], index: Optional[int] +) -> Optional[ast.expr]: + if param is None: + return None + for keyword in call.keywords: + if keyword.arg == param: + return keyword.value + if index is not None and len(call.args) > index: + argument = call.args[index] + if isinstance(argument, ast.Starred): + return None + return argument + return None + + +def _uipath_imports(tree: ast.Module) -> set[str]: + """Local names bound by an import from a ``uipath`` module. + + An interrupt model is matched by class name, which is generic enough + (``CreateTask``) to collide with unrelated code, so only names that + demonstrably came from the SDK are considered. + """ + names: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + module = node.module or "" + if module == "uipath" or module.startswith("uipath."): + for alias in node.names: + names.add(alias.asname or alias.name) + elif isinstance(node, ast.Import): + for alias in node.names: + if alias.name == "uipath" or alias.name.startswith("uipath."): + names.add(alias.asname or alias.name.split(".")[0]) + return names + + +def _interrupt_spec_for_call( + call: ast.Call, imported: set[str] +) -> Optional[InterruptSpec]: + """Match ``InvokeProcess(...)`` or ``interrupt_models.InvokeProcess(...)``.""" + if isinstance(call.func, ast.Name): + attribute, owner = call.func.id, call.func.id + elif isinstance(call.func, ast.Attribute): + attribute = call.func.attr + owner_node = call.func.value + if not isinstance(owner_node, ast.Name): + return None + owner = owner_node.id + else: + return None + if owner not in imported: + return None + return INTERRUPT_SPECS.get(attribute) + + +def _spec_for_call( + call: ast.Call, registry: dict[tuple[str, str], BindingSpec], services: set[str] +) -> Optional[BindingSpec]: + if not isinstance(call.func, ast.Attribute): + return None + owner = call.func.value + if not isinstance(owner, ast.Attribute): + return None + if owner.attr not in services: + return None + return registry.get((owner.attr, call.func.attr)) + + +def _record( + result: ScanResult, + node: ast.Call, + source: str, + constants: dict[str, str], + *, + resource_type: str, + name_param: str, + name_index: Optional[int], + folder_param: Optional[str], + folder_index: Optional[int], + activity_name: str, + origin: str, +) -> None: + """Turn one matched call into a reference, or a skip with a reason.""" + + def skip(reason: str) -> None: + result.skipped.append( + SkippedReference(resource_type=resource_type, reason=reason, source=source) + ) + + name_node = _argument(node, name_param, name_index) + if name_node is None: + skip(f"could not determine '{name_param}' for {origin}") + return + + name, name_is_expression = _resolve(name_node, constants) + if name_is_expression: + skip(f"{origin}: '{name_param}' is computed at runtime ({name})") + return + + # No folder argument is fine — the environment supplies one. A folder built + # at runtime is not: half a binding still reaches push as a real request. + folder_path: Optional[str] = None + folder_node = _argument(node, folder_param, folder_index) + if folder_node is not None: + folder_path, folder_is_expression = _resolve(folder_node, constants) + if folder_is_expression: + skip(f"{origin}: '{folder_param}' is computed at runtime ({folder_path})") + return + + result.references.append( + ResourceReference( + resource_type=resource_type, + name=name, + folder_path=folder_path, + activity_name=activity_name, + source=source, + ) + ) + + +def scan_tree( + tree: ast.Module, path_label: str, registry: dict[tuple[str, str], BindingSpec] +) -> ScanResult: + """Find resource references in one module, by either matching rule.""" + result = ScanResult() + services = service_attributes(registry) + constants = _module_constants(tree) + imported = _uipath_imports(tree) + + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + source = f"{path_label}:{node.lineno}" + + spec = _spec_for_call(node, registry, services) + if spec is not None: + _record( + result, + node, + source, + constants, + resource_type=spec.resource_type, + name_param=spec.name_param, + name_index=spec.name_index, + folder_param=spec.folder_param, + folder_index=spec.folder_index, + activity_name=spec.activity_name, + origin=f"{spec.service_attr}.{spec.method}", + ) + continue + + interrupt = _interrupt_spec_for_call(node, imported) + if interrupt is not None: + _record( + result, + node, + source, + constants, + resource_type=interrupt.resource_type, + name_param=interrupt.name_field, + name_index=None, + folder_param=interrupt.folder_field, + folder_index=None, + activity_name=interrupt.activity_name, + origin=interrupt.model, + ) + return result + + +def scan_source( + source: str, path_label: str, registry: dict[tuple[str, str], BindingSpec] +) -> ScanResult: + """Scan a single in-memory module.""" + return scan_tree(ast.parse(source), path_label, registry).deduplicated() + + +def scan_project( + root: Path, registry: dict[tuple[str, str], BindingSpec] +) -> ScanResult: + """Scan every project source file under ``root``.""" + combined = ScanResult() + for path in iter_project_files(root): + try: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + except (SyntaxError, UnicodeDecodeError, OSError): + continue + label = path.relative_to(root).as_posix() + combined.extend(scan_tree(tree, label, registry)) + return combined.deduplicated() diff --git a/packages/uipath/src/uipath/_cli/cli_bindings.py b/packages/uipath/src/uipath/_cli/cli_bindings.py new file mode 100644 index 000000000..bc2f854a3 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/cli_bindings.py @@ -0,0 +1,101 @@ +"""CLI commands for working with the project's resource bindings.""" + +from pathlib import Path + +import click + +from uipath.platform.common import UiPathConfig + +from ._bindings._apply import ( + infer_bindings, + load_bindings, + report_outcome, + serialize_bindings, +) +from ._telemetry import track_command +from ._utils._console import ConsoleLogger + +console = ConsoleLogger() + + +@click.group() +def bindings() -> None: + r"""Inspect and generate resource bindings. + + \b + Examples: + uipath bindings generate + uipath bindings generate --dry-run + uipath bindings generate --check + """ + pass + + +@bindings.command(name="generate") +@click.argument( + "root", + type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path), + default=Path("."), + metavar="", +) +@click.option( + "--dry-run", + is_flag=True, + help="Show what would change without writing the file.", +) +@click.option( + "--check", + is_flag=True, + help="Exit non-zero if bindings.json is missing entries. Writes nothing.", +) +@track_command("bindings_generate") +def generate(root: Path, dry_run: bool, check: bool) -> None: + """Generate bindings.json from the resources referenced in your code. + + Scans the project's Python sources for UiPath SDK calls that take a + resource name, and records each one as a binding so it can be remapped at + deployment. Discovery is best effort: a resource whose name is built at + runtime is reported rather than guessed, and entries already in the file + are never modified. + + Test files and virtual environments are not scanned. + + **Example:** + + $ uipath bindings generate + $ uipath bindings generate --check + """ + bindings_path = root / UiPathConfig.bindings_file_path + existing = load_bindings(bindings_path) + outcome = infer_bindings(root, existing) + report_outcome(outcome) + + if check: + if outcome.has_changes: + console.error( + f"'{bindings_path}' is missing {len(outcome.report.added)} " + "binding(s). Run 'uipath bindings generate'." + ) + console.success(f"'{bindings_path}' is up to date.") + return + + if dry_run: + console.info(f"Would write {len(outcome.merged.resources)} binding(s):") + click.echo(serialize_bindings(outcome.merged)) + return + + if not outcome.has_changes and existing is not None: + console.success(f"'{bindings_path}' is up to date.") + return + + bindings_path.write_text(serialize_bindings(outcome.merged)) + console.success( + f"Wrote '{bindings_path}' with {len(outcome.merged.resources)} binding(s) " + f"({len(outcome.report.added)} new)." + ) + + if outcome.skipped: + console.hint( + f"{len(outcome.skipped)} resource call(s) could not be resolved " + "statically. Add those bindings by hand if they need remapping." + ) diff --git a/packages/uipath/src/uipath/_cli/cli_init.py b/packages/uipath/src/uipath/_cli/cli_init.py index d6ff01a90..591fd76e9 100644 --- a/packages/uipath/src/uipath/_cli/cli_init.py +++ b/packages/uipath/src/uipath/_cli/cli_init.py @@ -37,6 +37,7 @@ ) from uipath.runtime.schema import UiPathRuntimeGraph, UiPathRuntimeSchema +from ._bindings._apply import infer_bindings_into_file from ._telemetry import track_command from ._utils._common import determine_project_type from ._utils._console import ConsoleLogger @@ -419,8 +420,15 @@ def _display_entrypoint_graphs(entry_point_schemas: list[UiPathRuntimeSchema]) - default=False, help="Won't override existing .agent files and AGENTS.md file.", ) +@click.option( + "--infer-bindings", + is_flag=True, + required=False, + default=False, + help="Record the resources referenced in your code in bindings.json (best effort).", +) @track_command("initialize") -def init(no_agents_md_override: bool) -> None: +def init(no_agents_md_override: bool, infer_bindings: bool) -> None: """Initialize the project.""" with console.spinner("Initializing UiPath project ..."): current_directory = os.getcwd() @@ -462,6 +470,9 @@ async def initialize() -> list[UiPathRuntimeSchema]: else: console.info(f"'{bindings_path}' already exists, skipping.") + if infer_bindings: + infer_bindings_into_file(Path(current_directory), bindings_path) + # Always create/update entry-points.json from runtime schemas factory: UiPathRuntimeFactoryProtocol = ( UiPathRuntimeFactoryRegistry.get( diff --git a/packages/uipath/src/uipath/_resources/CLI_REFERENCE.md b/packages/uipath/src/uipath/_resources/CLI_REFERENCE.md index 48cb9ad10..0f192b279 100644 --- a/packages/uipath/src/uipath/_resources/CLI_REFERENCE.md +++ b/packages/uipath/src/uipath/_resources/CLI_REFERENCE.md @@ -21,6 +21,7 @@ The UiPath Python SDK provides a comprehensive CLI for managing coded agents and | Option | Type | Default | Description | |--------|------|---------|-------------| | `--no-agents-md-override` | flag | false | Won't override existing .agent files and AGENTS.md file. | +| `--infer-bindings` | flag | false | Record the resources referenced in your code in bindings.json (best effort). | **Usage Examples:** @@ -63,6 +64,8 @@ uv run uipath init --infer-bindings | `--debug` | flag | false | Enable debugging with debugpy. The process will wait for a debugger to attach. | | `--debug-port` | value | `5678` | Port for the debug server (default: 5678) | | `--keep-state-file` | flag | false | Keep the temporary state file even when not resuming and no job id is provided | +| `--simulation` | value | none | Simulation config as a JSON object (same schema as simulation.json) | +| `--handler-ipc-pipe` | value | none | Named pipe to stream this job's logs and result over uipath-ipc instead of writing them to files. | **Usage Examples:** @@ -101,6 +104,7 @@ uv run uipath run --resume enable_mocker_cache: Enable caching for LLM mocker responses report_coverage: Report evaluation coverage model_settings_id: Model settings ID to override agent settings + agent_memory_settings_id: Agent memory settings ID to override agent memory settings trace_file: File path where traces will be written in JSONL format max_llm_concurrency: Maximum concurrent LLM requests input_overrides: Input field overrides mapping (direct field override with deep merge) @@ -125,10 +129,11 @@ uv run uipath run --resume | `--enable-mocker-cache` | flag | false | Enable caching for LLM mocker responses | | `--report-coverage` | flag | false | Report evaluation coverage | | `--model-settings-id` | value | `"default"` | Model settings ID from evaluation set to override agent settings (default: 'default') | +| `--agent-memory-settings-id` | value | `"default"` | Agent memory settings ID from evaluation set to override agent memory settings (default: 'default') | | `--trace-file` | value | `Sentinel.UNSET` | File path where traces will be written in JSONL format | | `--max-llm-concurrency` | value | `20` | Maximum concurrent LLM requests (default: 20) | | `--resume` | flag | false | Resume execution from a previous suspended state | -| `--verbose` | flag | false | Include agent execution output (trace, result) in the output file | +| `--verbose` | flag | false | Include workload execution output (trace, result) in the output file | **Usage Examples:** @@ -282,6 +287,46 @@ Options: --- +### `uipath bindings` + +Inspect and generate resource bindings. + + \b + Examples: + uipath bindings generate + uipath bindings generate --dry-run + uipath bindings generate --check + + +**Subcommands:** + +**`uipath bindings generate`** + +Generate bindings.json from the resources referenced in your code. + + Scans the project's Python sources for UiPath SDK calls that take a + resource name, and records each one as a binding so it can be remapped at + deployment. Discovery is best effort: a resource whose name is built at + runtime is reported rather than guessed, and entries already in the file + are never modified. + + Test files and virtual environments are not scanned. + + **Example:** + + $ uipath bindings generate + $ uipath bindings generate --check + + +Arguments: +- `root`: N/A + +Options: +- `--dry-run`: Show what would change without writing the file. +- `--check`: Exit non-zero if bindings.json is missing entries. Writes nothing. + +--- + ### `uipath buckets` Manage UiPath storage buckets and files. diff --git a/packages/uipath/tests/cli/test_cli_bindings.py b/packages/uipath/tests/cli/test_cli_bindings.py new file mode 100644 index 000000000..f1079d133 --- /dev/null +++ b/packages/uipath/tests/cli/test_cli_bindings.py @@ -0,0 +1,692 @@ +import json +import os +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from click.testing import CliRunner + +from uipath._cli import cli +from uipath._cli._bindings._emitter import build_binding, merge_bindings +from uipath._cli._bindings._registry import ( + BINDABLE_RESOURCE_TYPES, + _binding_metadata, + _iter_service_classes, + build_registry, +) +from uipath._cli._bindings._scanner import scan_project, scan_source +from uipath._cli._push._resource_actions import CreateVirtual +from uipath._cli.models.runtime_schema import Bindings +from uipath.platform.common._bindings import ( + ResourceOverwriteParser, + _resource_overwrites, + resource_override, +) + +SAMPLE_DIR = Path(__file__).parents[2] / "samples" / "resource-overrides" + + +class _EmptyAsyncIterator: + """Stands in for resource_catalog pagination that finds nothing.""" + + def __init__(self) -> None: + self.aclose = AsyncMock() + + def __aiter__(self): + return self + + async def __anext__(self): + raise StopAsyncIteration + + +def _hand_written( + resource: str, + key: str, + *, + display_name: str = "Name", + is_expression: bool = False, + metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: + """A bindings.json entry as a person would have written it.""" + name, _, folder = key.rpartition(".") + return { + "resource": resource, + "key": key, + "value": { + "name": { + "defaultValue": name, + "isExpression": is_expression, + "displayName": display_name, + }, + "folderPath": { + "defaultValue": folder, + "isExpression": is_expression, + "displayName": "Folder Path", + }, + }, + "metadata": metadata or {"BindingsVersion": "2.2"}, + } + + +async def _resolve(bindings): + """Run the real push resolver against a catalog that finds nothing.""" + from uipath._cli._push._resolvers import resolve_bindings + from uipath.platform.resource_catalog import ResourceType + + catalog = MagicMock() + catalog.list_by_type_async.return_value = _EmptyAsyncIterator() + connections = MagicMock() + connections.retrieve_async = AsyncMock( + return_value=SimpleNamespace(name="resolved", folder={"path": "Shared"}) + ) + return [ + action + async for action in resolve_bindings( + bindings, catalog, connections, {t.value for t in ResourceType} + ) + ] + + +def _refs_by_type(result): + return {ref.resource_type: ref for ref in result.references} + + +class TestRegistry: + def test_registry_maps_known_sdk_methods_to_their_parameters(self) -> None: + registry = build_registry() + + asset = registry[("assets", "retrieve_async")] + assert asset.resource_type == "asset" + assert asset.name_param == "name" + assert asset.folder_param == "folder_path" + assert asset.name_index == 0 + + task = registry[("tasks", "create_async")] + assert task.resource_type == "app" + assert task.name_param == "app_name" + assert task.folder_param == "app_folder_path" + assert task.name_index is None + + connection = registry[("connections", "retrieve_async")] + assert connection.resource_type == "connection" + assert connection.name_param == "key" + assert connection.folder_param is None + + def test_registry_matches_the_live_resource_override_decorators(self) -> None: + """Every bindable SDK method that can actually be overridden is known. + + This is the drift guard: a new @resource_override on a bindable service + that the scanner does not know about would silently produce incomplete + bindings. Methods whose declared resource_identifier is absent from + their signature are excluded — the override can never match them at + runtime either, so there is nothing to bind. + """ + import inspect + + registry = build_registry() + missing = [] + for service_attr, service_cls in _iter_service_classes(): + for method_name, func in vars(service_cls).items(): + meta = _binding_metadata(func) + if meta is None: + continue + if meta["resource_type"] not in BINDABLE_RESOURCE_TYPES: + continue + if ( + meta["resource_identifier"] + not in inspect.signature(func).parameters + ): + continue + if (service_attr, method_name) not in registry: + missing.append(f"{service_attr}.{method_name}") + assert missing == [] + + def test_activity_name_is_the_async_variant(self) -> None: + registry = build_registry() + assert registry[("assets", "retrieve")].activity_name == "retrieve_async" + assert registry[("processes", "invoke")].activity_name == "invoke_async" + + +class TestScanner: + def test_reproduces_the_hand_written_sample_bindings_file(self) -> None: + """The checked-in sample bindings.json is the golden output.""" + expected = json.loads((SAMPLE_DIR / "bindings.json").read_text()) + result = scan_project(SAMPLE_DIR, build_registry()) + generated, _ = merge_bindings(None, result.references) + + produced = generated.model_dump(by_alias=True, exclude_none=True) + by_key = {(r["resource"], r["key"]): r for r in produced["resources"]} + for entry in expected["resources"]: + assert by_key[(entry["resource"], entry["key"])] == entry + assert produced["version"] == expected["version"] + assert len(produced["resources"]) == len(expected["resources"]) + + def test_folds_module_level_string_constants(self) -> None: + source = ( + "CONNECTION = 'outlook-key'\n" + "async def run(sdk):\n" + " await sdk.connections.retrieve_async(CONNECTION)\n" + ) + result = scan_source(source, "graph.py", build_registry()) + ref = _refs_by_type(result)["connection"] + assert ref.name == "outlook-key" + + @pytest.mark.parametrize( + "rebind", + [ + pytest.param("CONNECTION = 'second'", id="reassigned"), + pytest.param("CONNECTION += '-suffix'", id="augmented"), + pytest.param("if SOMETHING:\n CONNECTION = 'other'", id="in-a-branch"), + pytest.param("def f():\n CONNECTION = 'local'", id="shadowed-locally"), + ], + ) + def test_does_not_fold_a_constant_that_is_rebound(self, rebind: str) -> None: + """Any second binding means the call site may not see the literal.""" + source = ( + f"CONNECTION = 'first'\n{rebind}\n" + "async def run(sdk):\n" + " await sdk.connections.retrieve_async(CONNECTION)\n" + ) + result = scan_source(source, "graph.py", build_registry()) + assert result.references == [] + assert len(result.skipped) == 1 + + def test_resolves_a_positionally_passed_folder(self) -> None: + """context_grounding.retrieve_async takes folder_path at position 2.""" + registry = build_registry() + assert registry[("context_grounding", "retrieve_async")].folder_index == 2 + + source = ( + "async def run(sdk):\n" + " await sdk.context_grounding.retrieve_async('Idx', None, 'Policies')\n" + ) + result = scan_source(source, "main.py", registry) + ref = _refs_by_type(result)["index"] + assert ref.name == "Idx" + assert ref.folder_path == "Policies" + + from uipath._cli._bindings._emitter import binding_key + + assert binding_key(ref) == "Idx.Policies" + + @pytest.mark.parametrize( + ("call", "expression"), + [ + pytest.param( + "await sdk.context_grounding.retrieve_async(name=state.index_name)", + "state.index_name", + id="attribute", + ), + # samples/asset-modifier-agent does exactly this; it used to emit a + # binding keyed on the parameter names. + pytest.param( + "await sdk.context_grounding.retrieve_async(name=index_name)", + "index_name", + id="parameter", + ), + pytest.param( + "await sdk.context_grounding.retrieve_async(name=f'idx-{state.id}')", + "idx-", + id="f-string", + ), + ], + ) + def test_skips_a_name_computed_at_runtime(self, call: str, expression: str) -> None: + """A python expression is not a resource name, so never emit one.""" + source = f"async def run(sdk, state, index_name):\n {call}\n" + result = scan_source(source, "agent.py", build_registry()) + assert result.references == [] + assert len(result.skipped) == 1 + assert expression in result.skipped[0].reason + assert result.skipped[0].source == "agent.py:2" + + def test_skips_a_literal_name_whose_folder_is_computed(self) -> None: + """Half a binding is still a binding push would act on.""" + source = ( + "import os\n" + "async def run(sdk):\n" + " await sdk.assets.retrieve_async(\n" + " 'ApiKey', folder_path=os.getenv('FOLDER')\n" + " )\n" + ) + result = scan_source(source, "agent.py", build_registry()) + assert result.references == [] + assert len(result.skipped) == 1 + + def test_a_missing_folder_is_not_an_expression(self) -> None: + """No folder argument at all is fine; the environment supplies it.""" + source = "async def run(sdk):\n await sdk.assets.retrieve_async('Solo')\n" + result = scan_source(source, "agent.py", build_registry()) + assert [r.name for r in result.references] == ["Solo"] + assert result.skipped == [] + + def test_skips_calls_whose_resource_name_cannot_be_determined(self) -> None: + source = ( + "def run(self):\n" + " self._sdk.buckets.download(\n" + " blob_file_path='a', destination_path='b', **self._bucket_kwargs()\n" + " )\n" + ) + result = scan_source(source, "backend.py", build_registry()) + assert result.references == [] + assert len(result.skipped) == 1 + assert result.skipped[0].resource_type == "bucket" + assert "backend.py:2" in result.skipped[0].source + + def test_ignores_unrelated_calls_with_the_same_method_name(self) -> None: + source = ( + "async def run(repo, sdk):\n" + " await repo.customers.retrieve_async('nope')\n" + " repo.retrieve_async('nope')\n" + ) + result = scan_source(source, "main.py", build_registry()) + assert result.references == [] + assert result.skipped == [] + + def test_does_not_scan_tests_or_virtualenvs(self, tmp_path: Path) -> None: + (tmp_path / "main.py").write_text( + "async def run(sdk):\n await sdk.assets.retrieve_async('real', folder_path='F')\n" + ) + (tmp_path / "tests").mkdir() + (tmp_path / "tests" / "test_main.py").write_text( + "async def t(sdk):\n await sdk.assets.retrieve_async('from_test', folder_path='F')\n" + ) + (tmp_path / ".venv" / "lib").mkdir(parents=True) + (tmp_path / ".venv" / "lib" / "dep.py").write_text( + "async def d(sdk):\n await sdk.assets.retrieve_async('from_venv', folder_path='F')\n" + ) + + result = scan_project(tmp_path, build_registry()) + assert [ref.name for ref in result.references] == ["real"] + + def test_deduplicates_repeated_references_to_the_same_resource(self) -> None: + source = ( + "async def run(sdk):\n" + " await sdk.assets.retrieve_async('A', folder_path='F')\n" + " await sdk.assets.retrieve_async('A', folder_path='F')\n" + ) + result = scan_source(source, "main.py", build_registry()) + assert len(result.references) == 1 + + +class TestInterruptModels: + """Resources reached through `interrupt(...)`, not a direct SDK call. + + LangGraph agents invoke processes and create actions by constructing an + interrupt model; the runtime then calls the decorated SDK method. The call + site in user code is a constructor, so the decorator registry alone cannot + see it. + """ + + @pytest.mark.parametrize( + ("imports", "call", "resource_type", "name"), + [ + pytest.param( + "from uipath.platform.common.interrupt_models import InvokeProcess", + "InvokeProcess(name='child-agent', process_folder_path='Shared')", + "process", + "child-agent", + id="invoke-process", + ), + pytest.param( + "from uipath.platform.common.interrupt_models import CreateTask", + "CreateTask(title='Review', app_name='escalation_app'," + " app_folder_path='Shared')", + "app", + "escalation_app", + id="create-task", + ), + pytest.param( + "from uipath.platform.common import interrupt_models", + "interrupt_models.CreateEscalation(title='t', app_name='approval'," + " app_folder_path='Ops')", + "app", + "approval", + id="module-qualified", + ), + ], + ) + def test_a_model_constructor_is_discovered( + self, imports: str, call: str, resource_type: str, name: str + ) -> None: + source = f"{imports}\ndef node(state):\n return interrupt({call})\n" + result = scan_source(source, "graph.py", build_registry()) + ref = _refs_by_type(result)[resource_type] + assert ref.name == name + assert ref.folder_path in {"Shared", "Ops"} + + def test_deep_rag_binds_the_index_not_the_task_name(self) -> None: + """`name` is the task's own name; `index_name` is the resource.""" + source = ( + "from uipath.platform.common.interrupt_models import CreateDeepRag\n" + "def node(state):\n" + " return interrupt(CreateDeepRag(\n" + " name='my-research-task',\n" + " index_name='ExpensePolicy',\n" + " index_folder_path='HR',\n" + " prompt='summarise',\n" + " ))\n" + ) + result = scan_source(source, "graph.py", build_registry()) + ref = _refs_by_type(result)["index"] + assert ref.name == "ExpensePolicy" + assert ref.folder_path == "HR" + + def test_a_same_named_class_from_elsewhere_is_ignored(self) -> None: + """Only constructors imported from uipath count.""" + source = ( + "from myapp.jobs import InvokeProcess\n" + "def node(state):\n" + " return InvokeProcess(name='not-ours', process_folder_path='X')\n" + ) + result = scan_source(source, "graph.py", build_registry()) + assert result.references == [] + assert result.skipped == [] + + def test_a_wait_model_binds_nothing(self) -> None: + """Wait models reference an already-created job by key.""" + source = ( + "from uipath.platform.common.interrupt_models import WaitJob\n" + "def node(state):\n" + " return interrupt(WaitJob(job=state.job, process_folder_path='Shared'))\n" + ) + result = scan_source(source, "graph.py", build_registry()) + assert result.references == [] + + def test_every_interrupt_model_is_classified(self) -> None: + """A new interrupt model must be mapped or explicitly excluded. + + Same drift guard as the decorator registry: adding a model that names a + resource should fail here rather than silently produce no binding. + """ + from pydantic import BaseModel + + from uipath._cli._bindings._interrupts import ( + INTERRUPT_SPECS, + NON_BINDING_INTERRUPT_MODELS, + ) + from uipath.platform.common import interrupt_models + + declared = set(INTERRUPT_SPECS) | set(NON_BINDING_INTERRUPT_MODELS) + live = { + name + for name, obj in vars(interrupt_models).items() + if isinstance(obj, type) + and issubclass(obj, BaseModel) + and obj.__module__ == interrupt_models.__name__ + } + assert live - declared == set() + + +class TestEmitter: + def test_emitted_entries_satisfy_the_published_json_schema(self) -> None: + schema = json.loads( + (Path(__file__).parents[2] / "specs" / "bindings.schema.json").read_text() + ) + item_schema = schema["properties"]["resources"]["items"] + allowed_types = set(item_schema["properties"]["resource"]["enum"]) + allowed_value_keys = set() + for variant in item_schema["properties"]["value"]["oneOf"]: + allowed_value_keys |= set(variant["properties"]) + required_prop_fields = set( + schema["definitions"]["propertyDefinition"]["required"] + ) + + result = scan_project(SAMPLE_DIR, build_registry()) + generated, _ = merge_bindings(None, result.references) + dumped = generated.model_dump(by_alias=True, exclude_none=True) + + assert set(dumped) == {"version", "resources"} + assert dumped["version"] in schema["properties"]["version"]["enum"] + for entry in dumped["resources"]: + assert set(item_schema["required"]) <= set(entry) + assert set(entry) <= set(item_schema["properties"]) + assert entry["resource"] in allowed_types + assert set(entry["value"]) <= allowed_value_keys + for prop in entry["value"].values(): + assert set(prop) == required_prop_fields + + async def test_generated_bindings_survive_the_push_resolver(self) -> None: + """`uipath push` reads this file. A binding whose shape the resolver + rejects — wrong casing on ConnectionId, a missing field — raises there + rather than in the generator, so drive the real resolver over real + generated output. + """ + result = scan_project(SAMPLE_DIR, build_registry()) + generated, _ = merge_bindings(None, result.references) + actions = await _resolve(generated) + assert len(actions) == len(generated.resources) + + async def test_a_binding_without_a_folder_becomes_a_virtual_resource( + self, + ) -> None: + """A call with no folder_path is normal; the environment supplies one. + + Pin the side effect rather than just "an action happened": such a + binding reaches push as an uncatalogued resource and becomes a + placeholder. Changing that is a product decision, not an accident. + """ + source = "async def run(sdk):\n await sdk.assets.retrieve_async('Solo')\n" + result = scan_source(source, "main.py", build_registry()) + generated, _ = merge_bindings(None, result.references) + assert generated.resources[0].key == "Solo" + + actions = await _resolve(generated) + assert len(actions) == 1 + assert isinstance(actions[0], CreateVirtual) + assert actions[0].request.name == "Solo" + + def test_merge_leaves_what_it_did_not_generate_alone(self) -> None: + """Existing entries carry display names, metadata and expressions a + scan cannot reproduce, so a known key is never rewritten and an entry + the scan did not find is never pruned. + """ + existing = Bindings.model_validate( + { + "version": "2.0", + "resources": [ + _hand_written( + "asset", + "A.F", + display_name="Custom Label", + metadata={"BindingsVersion": "2.2", "Hand": "written"}, + ), + _hand_written("index", "state.i.state.f", is_expression=True), + ], + } + ) + source = ( + "async def run(sdk):\n" + " await sdk.assets.retrieve_async('A', folder_path='F')\n" + " await sdk.buckets.retrieve_async(name='B', folder_path='F')\n" + ) + result = scan_source(source, "main.py", build_registry()) + merged, report = merge_bindings(existing, result.references) + + asset = next(r for r in merged.resources if r.resource == "asset") + assert asset.value["name"].display_name == "Custom Label" + assert asset.metadata == {"BindingsVersion": "2.2", "Hand": "written"} + assert report.unchanged == ["asset:A.F"] + assert report.added == ["bucket:B.F"] + + index = next(r for r in merged.resources if r.resource == "index") + assert index.value["name"].is_expression is True + assert report.preserved == ["index:state.i.state.f"] + + +class TestRuntimeKeyAgreement: + """The generated key must be the key the SDK looks up at call time. + + A mismatch here is silent: the override simply never applies and the agent + runs against its development resource. + """ + + @pytest.mark.parametrize( + "source", + [ + "async def run(sdk):\n await sdk.assets.retrieve_async('A', folder_path='F')\n", + "async def run(sdk):\n await sdk.processes.invoke_async(name='P', folder_path='F')\n", + "async def run(sdk):\n await sdk.buckets.retrieve_async(name='B', folder_path='F')\n", + "async def run(sdk):\n await sdk.context_grounding.retrieve_async(name='I', folder_path='F')\n", + "async def run(sdk):\n await sdk.tasks.create_async('t', app_name='APP', app_folder_path='F')\n", + "async def run(sdk):\n await sdk.connections.retrieve_async('C')\n", + ], + ) + def test_override_applies_to_the_generated_key(self, source: str) -> None: + registry = build_registry() + result = scan_source(source, "main.py", registry) + ref = result.references[0] + binding = build_binding(ref) + spec = next( + s for s in registry.values() if s.resource_type == ref.resource_type + ) + + probe = _make_probe(spec) + overwrite_key = f"{ref.resource_type}.{binding.key}" + # Built through the same parser the runtime uses on the server's + # response, so the test exercises the production construction path. + payload: dict[str, Any] = ( + {"connectionId": "NEW_ID", "folderKey": "NEW_FOLDER"} + if ref.resource_type == "connection" + else {"name": "NEW_NAME", "folderPath": "NEW_FOLDER"} + ) + overwrite = ResourceOverwriteParser.parse(overwrite_key, payload) + + call_args: dict[str, Any] = {spec.name_param: ref.name} + if spec.folder_param: + call_args[spec.folder_param] = ref.folder_path + + token = _resource_overwrites.set({overwrite_key: overwrite}) + try: + observed_name, observed_folder = probe(**call_args) + finally: + _resource_overwrites.reset(token) + + expected_name = "NEW_ID" if ref.resource_type == "connection" else "NEW_NAME" + assert observed_name == expected_name + if spec.folder_param: + assert observed_folder == "NEW_FOLDER" + + +def _write_project(body: str) -> None: + """A minimal project in the cwd, for the CLI tests.""" + Path("pyproject.toml").write_text( + '[project]\nname = "test-project"\nversion = "0.1.0"\n' + 'description = "Test"\nauthors = [{name = "Test"}]\n' + 'requires-python = ">=3.11"\n' + ) + Path("main.py").write_text(body) + + +_AGENT = ( + "async def run(sdk):\n" + " await sdk.assets.retrieve_async('MyAsset', folder_path='Shared')\n" +) + + +class TestCommand: + def test_generate_writes_bindings_for_discovered_resources( + self, runner: CliRunner, temp_dir: str + ) -> None: + with runner.isolated_filesystem(temp_dir=temp_dir): + _write_project(_AGENT) + result = runner.invoke(cli, ["bindings", "generate"], env={}) + assert result.exit_code == 0, result.output + + data = json.loads(Path("bindings.json").read_text()) + assert data["version"] == "2.0" + assert len(data["resources"]) == 1 + assert data["resources"][0]["key"] == "MyAsset.Shared" + + def test_generate_reports_what_it_could_not_resolve( + self, runner: CliRunner, temp_dir: str + ) -> None: + with runner.isolated_filesystem(temp_dir=temp_dir): + _write_project( + "def run(self):\n" + " self._sdk.buckets.download(blob_file_path='a', **self._kw())\n" + ) + result = runner.invoke(cli, ["bindings", "generate"], env={}) + assert result.exit_code == 0, result.output + assert "main.py:2" in result.output + assert "bucket" in result.output + + def test_dry_run_does_not_write_the_file( + self, runner: CliRunner, temp_dir: str + ) -> None: + with runner.isolated_filesystem(temp_dir=temp_dir): + _write_project(_AGENT) + result = runner.invoke(cli, ["bindings", "generate", "--dry-run"], env={}) + assert result.exit_code == 0, result.output + assert not os.path.exists("bindings.json") + + def test_check_fails_when_the_file_is_out_of_date( + self, runner: CliRunner, temp_dir: str + ) -> None: + with runner.isolated_filesystem(temp_dir=temp_dir): + _write_project(_AGENT) + Path("bindings.json").write_text('{"version": "2.0", "resources": []}') + + result = runner.invoke(cli, ["bindings", "generate", "--check"], env={}) + assert result.exit_code == 1 + assert json.loads(Path("bindings.json").read_text())["resources"] == [] + + def test_check_passes_when_the_file_is_up_to_date( + self, runner: CliRunner, temp_dir: str + ) -> None: + with runner.isolated_filesystem(temp_dir=temp_dir): + _write_project(_AGENT) + assert runner.invoke(cli, ["bindings", "generate"], env={}).exit_code == 0 + result = runner.invoke(cli, ["bindings", "generate", "--check"], env={}) + assert result.exit_code == 0, result.output + + def test_rerunning_is_idempotent(self, runner: CliRunner, temp_dir: str) -> None: + with runner.isolated_filesystem(temp_dir=temp_dir): + _write_project(_AGENT) + runner.invoke(cli, ["bindings", "generate"], env={}) + first = Path("bindings.json").read_text() + runner.invoke(cli, ["bindings", "generate"], env={}) + assert Path("bindings.json").read_text() == first + + +def _make_probe(spec): + params = [f"{spec.name_param}=None"] + if spec.folder_param: + params.append(f"{spec.folder_param}=None") + folder_expr = spec.folder_param if spec.folder_param else "None" + namespace: dict[str, Any] = {} + exec( + f"def probe({', '.join(params)}):\n" + f" return ({spec.name_param}, {folder_expr})\n", + namespace, + ) + return resource_override( + resource_type=spec.resource_type, + resource_identifier=spec.name_param, + folder_identifier=spec.folder_param or "folder_path", + )(namespace["probe"]) + + +class TestInitInferBindings: + def test_init_leaves_bindings_empty_without_the_flag( + self, runner: CliRunner, temp_dir: str + ) -> None: + """Discovery stays opt-in: plain `init` must not invent bindings.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + _write_project(_AGENT) + result = runner.invoke(cli, ["init"], env={}) + assert result.exit_code == 0, result.output + assert json.loads(Path("bindings.json").read_text())["resources"] == [] + + def test_init_infer_bindings_records_discovered_resources( + self, runner: CliRunner, temp_dir: str + ) -> None: + with runner.isolated_filesystem(temp_dir=temp_dir): + _write_project(_AGENT) + result = runner.invoke(cli, ["init", "--infer-bindings"], env={}) + assert result.exit_code == 0, result.output + + resources = json.loads(Path("bindings.json").read_text())["resources"] + assert [r["key"] for r in resources] == ["MyAsset.Shared"] diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index bc92233bb..461004df9 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.14.25" +version = "2.14.26" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.32" +version = "0.2.33" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" },