From 51d780773a54c92b57d36a0acd184af194d21e3c Mon Sep 17 00:00:00 2001 From: tudormatei Date: Tue, 22 Sep 2026 17:02:16 +0300 Subject: [PATCH 1/8] chore(cli): regenerate CLI_REFERENCE.md The bundled reference had drifted from the commands it documents. Running scripts/update_agents_md.py picks up options added to `run` and `eval` since it was last generated, plus the new `bindings` group. Split out from the bindings change so the unrelated churn is reviewable on its own. --- .../src/uipath/_resources/CLI_REFERENCE.md | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) 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. From 9131eb08b711e290c3dbd1b272503a94b0cbced1 Mon Sep 17 00:00:00 2001 From: tudormatei Date: Tue, 22 Sep 2026 17:02:34 +0300 Subject: [PATCH 2/8] feat(cli): generate bindings.json from resources referenced in code Coded agents have never had working resource bindings. `uipath init` writes an empty `resources` array and nothing ever fills it, so an agent's assets, buckets and processes are invisible to solutions, to package requirements and to deploy-time overrides. Low-code projects get the file written for them by the designer; coded ones got nothing. `uipath bindings generate` fills it by parsing the project's Python and matching calls against the SDK's own `@resource_override` decorators, which already declare the resource type and which parameter carries the name. The decorators are now readable as data rather than only from closure cells, so the scanner stays in step with the SDK instead of carrying a list that rots. Discovery is best effort and says so: literals and module-level constants are recorded, runtime expressions are kept as expressions, and a call whose name is built at runtime is reported with file and line rather than guessed. Entries already in the file are never rewritten, since they carry connector metadata and display names a scan cannot reproduce. `uipath init --infer-bindings` runs the same scan, making good on a flag the bundled agent docs have been advertising in four places without it existing. Inference is opt-in in both places; plain `uipath init` still writes an empty array, and a test pins that. --- .../src/uipath/platform/common/_bindings.py | 10 + packages/uipath/CLAUDE.md | 1 + packages/uipath/docs/cli/index.md | 30 + packages/uipath/src/uipath/_cli/__init__.py | 1 + .../src/uipath/_cli/_bindings/__init__.py | 40 ++ .../src/uipath/_cli/_bindings/_apply.py | 78 +++ .../src/uipath/_cli/_bindings/_emitter.py | 122 ++++ .../src/uipath/_cli/_bindings/_registry.py | 165 +++++ .../src/uipath/_cli/_bindings/_scanner.py | 229 ++++++ .../uipath/src/uipath/_cli/cli_bindings.py | 101 +++ packages/uipath/src/uipath/_cli/cli_init.py | 13 +- .../uipath/tests/cli/test_cli_bindings.py | 654 ++++++++++++++++++ 12 files changed, 1443 insertions(+), 1 deletion(-) create mode 100644 packages/uipath/src/uipath/_cli/_bindings/__init__.py create mode 100644 packages/uipath/src/uipath/_cli/_bindings/_apply.py create mode 100644 packages/uipath/src/uipath/_cli/_bindings/_emitter.py create mode 100644 packages/uipath/src/uipath/_cli/_bindings/_registry.py create mode 100644 packages/uipath/src/uipath/_cli/_bindings/_scanner.py create mode 100644 packages/uipath/src/uipath/_cli/cli_bindings.py create mode 100644 packages/uipath/tests/cli/test_cli_bindings.py 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/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..d0bc91cad 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. A resource named by a literal or a module-level constant is recorded; one whose name is built at runtime is recorded as an expression, and a call whose name cannot be determined at all is reported rather than guessed: + + + +```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/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..8204e92f0 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_bindings/_apply.py @@ -0,0 +1,78 @@ +"""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()) + print(f"======================================\n {result}") + merged, report = merge_bindings(existing, result.references) + print(f"+++++++++++++++++++++++++++++++++MERGED \n {merged}") + 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..86f41bce7 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_bindings/_emitter.py @@ -0,0 +1,122 @@ +"""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, is_expression: bool, display_name: str +) -> BindingResourceValue: + return BindingResourceValue( + default_value=default_value, + is_expression=is_expression, + display_name=display_name, + ) + + +def _connection_binding(reference: ResourceReference) -> BindingResource: + return BindingResource( + resource="connection", + key=binding_key(reference), + value={ + "ConnectionId": _value( + reference.name, reference.name_is_expression, "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, reference.name_is_expression, name_label), + "folderPath": _value( + reference.folder_path or "", + reference.folder_is_expression, + 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/_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..c28cbe717 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_bindings/_scanner.py @@ -0,0 +1,229 @@ +"""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 ._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 refers to.""" + + resource_type: str + name: str + name_is_expression: bool + folder_path: Optional[str] + folder_is_expression: bool + 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 _module_constants(tree: ast.Module) -> dict[str, str]: + """Module-level ``NAME = "literal"`` assignments, minus anything reassigned.""" + constants: dict[str, str] = {} + reassigned: set[str] = set() + for node in tree.body: + targets: list[ast.expr] = [] + value: Optional[ast.expr] = None + if isinstance(node, ast.Assign): + targets = list(node.targets) + value = node.value + elif isinstance(node, ast.AnnAssign) and node.value is not None: + targets = [node.target] + value = node.value + for target in targets: + if not isinstance(target, ast.Name): + continue + if target.id in constants or target.id in reassigned: + reassigned.add(target.id) + constants.pop(target.id, None) + continue + if isinstance(value, ast.Constant) and isinstance(value.value, str): + 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 _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 scan_tree( + tree: ast.Module, path_label: str, registry: dict[tuple[str, str], BindingSpec] +) -> ScanResult: + result = ScanResult() + services = service_attributes(registry) + constants = _module_constants(tree) + + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + spec = _spec_for_call(node, registry, services) + if spec is None: + continue + + source = f"{path_label}:{node.lineno}" + name_node = _argument(node, spec.name_param, spec.name_index) + if name_node is None: + result.skipped.append( + SkippedReference( + resource_type=spec.resource_type, + reason=( + f"could not determine '{spec.name_param}' for " + f"{spec.service_attr}.{spec.method}" + ), + source=source, + ) + ) + continue + + name, name_is_expression = _resolve(name_node, constants) + folder_node = _argument(node, spec.folder_param, spec.folder_index) + if folder_node is None: + folder_path, folder_is_expression = None, False + else: + folder_path, folder_is_expression = _resolve(folder_node, constants) + + result.references.append( + ResourceReference( + resource_type=spec.resource_type, + name=name, + name_is_expression=name_is_expression, + folder_path=folder_path, + folder_is_expression=folder_is_expression, + activity_name=spec.activity_name, + source=source, + ) + ) + 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/tests/cli/test_cli_bindings.py b/packages/uipath/tests/cli/test_cli_bindings.py new file mode 100644 index 000000000..fed609a3d --- /dev/null +++ b/packages/uipath/tests/cli/test_cli_bindings.py @@ -0,0 +1,654 @@ +import json +import os +from pathlib import Path +from types import SimpleNamespace +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, + build_registry, +) +from uipath._cli._bindings._scanner import scan_project, scan_source +from uipath._cli.models.runtime_schema import Bindings +from uipath.platform.common._bindings import ( + ConnectionResourceOverwrite, + GenericResourceOverwrite, + _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 _refs_by_type(result): + return {ref.resource_type: ref for ref in result.references} + + +class TestRegistry: + def test_registry_covers_every_bindable_resource_type(self) -> None: + registry = build_registry() + found = {spec.resource_type for spec in registry.values()} + assert BINDABLE_RESOURCE_TYPES <= found + + 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 + + from uipath.platform import UiPath + + registry = build_registry() + missing = [] + for service_attr, service_cls in _iter_services(UiPath): + 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_recovers_every_resource_type_from_the_sample_agent(self) -> None: + result = scan_project(SAMPLE_DIR, build_registry()) + assert _refs_by_type(result).keys() == BINDABLE_RESOURCE_TYPES + assert result.skipped == [] + + 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" + assert ref.name_is_expression is False + + def test_does_not_fold_constants_reassigned_in_the_module(self) -> None: + source = ( + "CONNECTION = 'first'\n" + "CONNECTION = 'second'\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 == "CONNECTION" + assert ref.name_is_expression is True + + def test_marks_non_literal_arguments_as_expressions(self) -> None: + source = ( + "async def run(sdk, state):\n" + " await sdk.context_grounding.add_to_index_async(\n" + " name=state.index_name, folder_path=state.index_folder_path\n" + " )\n" + ) + result = scan_source(source, "agent.py", build_registry()) + ref = _refs_by_type(result)["index"] + assert ref.name == "state.index_name" + assert ref.name_is_expression is True + assert ref.folder_path == "state.index_folder_path" + assert ref.folder_is_expression is True + + 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_resolves_positional_and_keyword_name_arguments(self) -> None: + source = ( + "async def run(sdk):\n" + " await sdk.assets.retrieve_async('positional', folder_path='F')\n" + " await sdk.processes.invoke_async(name='keyword', folder_path='F')\n" + ) + result = scan_source(source, "main.py", build_registry()) + refs = _refs_by_type(result) + assert refs["asset"].name == "positional" + assert refs["process"].name == "keyword" + + 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 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: + """Every generated entry must yield an action, not an exception. + + `uipath push` reads this file to build solution resources. 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. + """ + from uipath._cli._push._resolvers import resolve_bindings + from uipath.platform.resource_catalog import ResourceType + + result = scan_project(SAMPLE_DIR, build_registry()) + generated, _ = merge_bindings(None, result.references) + + catalog = MagicMock() + catalog.list_by_type_async.return_value = _EmptyAsyncIterator() + connections = MagicMock() + connections.retrieve_async = AsyncMock( + return_value=SimpleNamespace( + name="resolved-connection", folder={"path": "Shared"} + ) + ) + supported = {t.value for t in ResourceType} + + actions = [ + action + async for action in resolve_bindings( + generated, catalog, connections, supported + ) + ] + assert len(actions) == len(generated.resources) + + async def test_a_binding_without_a_folder_still_resolves(self) -> None: + """A call with no folder_path is normal — the folder comes from the env.""" + from uipath._cli._push._resolvers import resolve_bindings + from uipath.platform.resource_catalog import ResourceType + + 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" + + catalog = MagicMock() + catalog.list_by_type_async.return_value = _EmptyAsyncIterator() + actions = [ + action + async for action in resolve_bindings( + generated, + catalog, + MagicMock(), + {t.value for t in ResourceType}, + ) + ] + assert len(actions) == 1 + + def test_merge_keeps_hand_edited_entries_untouched(self) -> None: + existing = Bindings.model_validate( + { + "version": "2.0", + "resources": [ + { + "resource": "asset", + "key": "A.F", + "value": { + "name": { + "defaultValue": "A", + "isExpression": False, + "displayName": "Custom Label", + }, + "folderPath": { + "defaultValue": "F", + "isExpression": False, + "displayName": "Folder Path", + }, + }, + "metadata": {"BindingsVersion": "2.2", "Hand": "written"}, + } + ], + } + ) + 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.added == ["bucket:B.F"] + assert report.unchanged == ["asset:A.F"] + + def test_merge_never_drops_entries_the_scan_did_not_find(self) -> None: + existing = Bindings.model_validate( + { + "version": "2.0", + "resources": [ + { + "resource": "index", + "key": "state.i.state.f", + "value": { + "name": { + "defaultValue": "state.i", + "isExpression": True, + "displayName": "Name", + }, + "folderPath": { + "defaultValue": "state.f", + "isExpression": True, + "displayName": "Folder Path", + }, + }, + "metadata": {"BindingsVersion": "2.2"}, + } + ], + } + ) + merged, report = merge_bindings(existing, []) + assert len(merged.resources) == 1 + 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}" + if ref.resource_type == "connection": + overwrite = ConnectionResourceOverwrite( + resource_type="connection", + connectionId="NEW_ID", + folderKey="NEW_FOLDER", + ) + else: + overwrite = GenericResourceOverwrite( + resource_type=ref.resource_type, + name="NEW_NAME", + folderPath="NEW_FOLDER", + ) + + call_args = {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" + + +class TestCommand: + def _project(self, body: str) -> None: + with open("pyproject.toml", "w") as f: + f.write( + '[project]\nname = "test-project"\nversion = "0.1.0"\n' + 'description = "Test"\nauthors = [{name = "Test"}]\n' + 'requires-python = ">=3.11"\n' + ) + with open("main.py", "w") as f: + f.write(body) + + def test_generate_writes_bindings_for_discovered_resources( + self, runner: CliRunner, temp_dir: str + ) -> None: + with runner.isolated_filesystem(temp_dir=temp_dir): + self._project( + "async def run(sdk):\n" + " await sdk.assets.retrieve_async('MyAsset', folder_path='Shared')\n" + ) + 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): + self._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): + self._project( + "async def run(sdk):\n" + " await sdk.assets.retrieve_async('MyAsset', folder_path='Shared')\n" + ) + 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): + self._project( + "async def run(sdk):\n" + " await sdk.assets.retrieve_async('MyAsset', folder_path='Shared')\n" + ) + 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): + self._project( + "async def run(sdk):\n" + " await sdk.assets.retrieve_async('MyAsset', folder_path='Shared')\n" + ) + 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): + self._project( + "async def run(sdk):\n" + " await sdk.assets.retrieve_async('MyAsset', folder_path='Shared')\n" + ) + 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 _iter_services(uipath_cls): + import typing + + for attr, descriptor in vars(uipath_cls).items(): + fget = getattr(descriptor, "fget", None) or getattr(descriptor, "func", None) + if fget is None: + continue + try: + hints = typing.get_type_hints(fget) + except Exception: + continue + service_cls = hints.get("return") + if isinstance(service_cls, type): + yield attr, service_cls + + +def _binding_metadata(func): + target = getattr(func, "__func__", func) + meta = getattr(target, "__uipath_binding__", None) + if meta is not None: + return meta + 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_cells = dict( + zip(inner.__code__.co_freevars, inner.__closure__ or (), strict=True) + ) + if "resource_type" not in inner_cells: + return None + return { + "resource_type": inner_cells["resource_type"].cell_contents, + "resource_identifier": inner_cells["resource_identifier"].cell_contents, + "folder_identifier": inner_cells["folder_identifier"].cell_contents, + } + + +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 = {} + 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 _project(self, body: str) -> None: + with open("pyproject.toml", "w") as f: + f.write( + '[project]\nname = "test-project"\nversion = "0.1.0"\n' + 'description = "Test"\nauthors = [{name = "Test"}]\n' + 'requires-python = ">=3.11"\n' + ) + with open("main.py", "w") as f: + f.write(body) + + _AGENT = ( + "async def run(sdk):\n" + " await sdk.assets.retrieve_async('MyAsset', folder_path='Shared')\n" + ) + + 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): + self._project(self._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): + self._project(self._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"] + + def test_init_infer_bindings_merges_into_an_existing_file( + self, runner: CliRunner, temp_dir: str + ) -> None: + with runner.isolated_filesystem(temp_dir=temp_dir): + self._project(self._AGENT) + Path("bindings.json").write_text( + json.dumps( + { + "version": "2.0", + "resources": [ + { + "resource": "connection", + "key": "kept", + "value": { + "ConnectionId": { + "defaultValue": "kept", + "isExpression": False, + "displayName": "Connection", + } + }, + "metadata": {"BindingsVersion": "2.2"}, + } + ], + } + ) + ) + result = runner.invoke(cli, ["init", "--infer-bindings"], env={}) + assert result.exit_code == 0, result.output + + keys = { + r["key"] + for r in json.loads(Path("bindings.json").read_text())["resources"] + } + assert keys == {"kept", "MyAsset.Shared"} From 3ec738c64b18d2a083a93037fcfd38993e5a55a8 Mon Sep 17 00:00:00 2001 From: tudormatei Date: Tue, 22 Sep 2026 17:17:55 +0300 Subject: [PATCH 3/8] fix(cli): drop debug prints and stop folding rebound constants Two debug prints were dumping raw ScanResult and Bindings objects to stdout on every run, ahead of the real report. Constant folding only looked at top-level assignments, so a name changed by `X += ...` or rebound inside a branch was still folded to its first literal, producing a binding key the runtime would never match. Folding now requires the name to be bound exactly once anywhere in the module, counting augmented assignments, nested rebinds, imports and def/class shadowing. Also covers a positionally passed folder argument, which the registry supported but nothing exercised, and pins the push-time behaviour of a folderless binding instead of only asserting that some action came back. --- .../src/uipath/_cli/_bindings/_apply.py | 2 - .../src/uipath/_cli/_bindings/_scanner.py | 49 +++++++++++------ .../uipath/tests/cli/test_cli_bindings.py | 52 +++++++++++++++++++ 3 files changed, 86 insertions(+), 17 deletions(-) diff --git a/packages/uipath/src/uipath/_cli/_bindings/_apply.py b/packages/uipath/src/uipath/_cli/_bindings/_apply.py index 8204e92f0..8c35d5772 100644 --- a/packages/uipath/src/uipath/_cli/_bindings/_apply.py +++ b/packages/uipath/src/uipath/_cli/_bindings/_apply.py @@ -45,9 +45,7 @@ def serialize_bindings(bindings: Bindings) -> str: def infer_bindings(root: Path, existing: Optional[Bindings]) -> InferOutcome: """Scan ``root`` and merge what it finds into ``existing``.""" result = scan_project(root, build_registry()) - print(f"======================================\n {result}") merged, report = merge_bindings(existing, result.references) - print(f"+++++++++++++++++++++++++++++++++MERGED \n {merged}") return InferOutcome(merged=merged, report=report, skipped=result.skipped) diff --git a/packages/uipath/src/uipath/_cli/_bindings/_scanner.py b/packages/uipath/src/uipath/_cli/_bindings/_scanner.py index c28cbe717..02a9b5cf5 100644 --- a/packages/uipath/src/uipath/_cli/_bindings/_scanner.py +++ b/packages/uipath/src/uipath/_cli/_bindings/_scanner.py @@ -94,27 +94,46 @@ def iter_project_files(root: Path) -> list[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, minus anything reassigned.""" + """Module-level ``NAME = "literal"`` assignments that are never rebound.""" + counts = _binding_counts(tree) constants: dict[str, str] = {} - reassigned: set[str] = set() for node in tree.body: - targets: list[ast.expr] = [] - value: Optional[ast.expr] = None + targets: list[ast.expr] + value: ast.expr if isinstance(node, ast.Assign): - targets = list(node.targets) - value = node.value + targets, value = list(node.targets), node.value elif isinstance(node, ast.AnnAssign) and node.value is not None: - targets = [node.target] - value = node.value + 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 not isinstance(target, ast.Name): - continue - if target.id in constants or target.id in reassigned: - reassigned.add(target.id) - constants.pop(target.id, None) - continue - if isinstance(value, ast.Constant) and isinstance(value.value, str): + if isinstance(target, ast.Name) and counts.get(target.id) == 1: constants[target.id] = value.value return constants diff --git a/packages/uipath/tests/cli/test_cli_bindings.py b/packages/uipath/tests/cli/test_cli_bindings.py index fed609a3d..ce4758324 100644 --- a/packages/uipath/tests/cli/test_cli_bindings.py +++ b/packages/uipath/tests/cli/test_cli_bindings.py @@ -14,6 +14,7 @@ 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 ( ConnectionResourceOverwrite, @@ -147,6 +148,52 @@ def test_does_not_fold_constants_reassigned_in_the_module(self) -> None: assert ref.name == "CONNECTION" assert ref.name_is_expression is True + def test_does_not_fold_constants_changed_by_augmented_assignment(self) -> None: + """`X += ...` changes the value the call actually receives.""" + source = ( + "CONNECTION = 'first'\n" + "CONNECTION += '-suffix'\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 == "CONNECTION" + assert ref.name_is_expression is True + + def test_does_not_fold_constants_reassigned_inside_a_branch(self) -> None: + """A nested rebind is still a rebind, even though it is not top level.""" + source = ( + "NAME = 'a'\n" + "if SOMETHING:\n" + " NAME = 'b'\n" + "async def run(sdk):\n" + " await sdk.assets.retrieve_async(NAME, folder_path='F')\n" + ) + result = scan_source(source, "graph.py", build_registry()) + ref = _refs_by_type(result)["asset"] + assert ref.name == "NAME" + assert ref.name_is_expression is True + + 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" + assert ref.folder_is_expression is False + + from uipath._cli._bindings._emitter import binding_key + + assert binding_key(ref) == "Idx.Policies" + def test_marks_non_literal_arguments_as_expressions(self) -> None: source = ( "async def run(sdk, state):\n" @@ -302,7 +349,12 @@ async def test_a_binding_without_a_folder_still_resolves(self) -> None: {t.value for t in ResourceType}, ) ] + # Pin the side effect rather than just "an action happened": a folderless + # binding reaches push as an uncatalogued resource and becomes a virtual + # placeholder. Changing that is a product decision, not an accident. assert len(actions) == 1 + assert isinstance(actions[0], CreateVirtual) + assert actions[0].request.name == "Solo" def test_merge_keeps_hand_edited_entries_untouched(self) -> None: existing = Bindings.model_validate( From 569671cf64d080bcb367c80f6cf6c3b402c9b129 Mon Sep 17 00:00:00 2001 From: tudormatei Date: Tue, 22 Sep 2026 17:25:28 +0300 Subject: [PATCH 4/8] chore(deps): bump uipath and uipath-platform, fix test type errors uipath-platform 0.2.32 -> 0.2.33 and uipath 2.14.24 -> 2.14.25, since both packages carry source changes here and the versions on main are already on PyPI. uipath's lower bound on uipath-platform moves to >=0.2.33 so a standalone install cannot resolve a release without the binding metadata. The overwrite fixtures in the binding tests were built with field aliases, which the pydantic mypy plugin rejects. They now go through ResourceOverwriteParser, the same path the runtime uses on the server's response, so the test also exercises production construction. --- packages/uipath-platform/pyproject.toml | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 2 +- .../uipath/tests/cli/test_cli_bindings.py | 28 ++++++++----------- packages/uipath/uv.lock | 2 +- 5 files changed, 16 insertions(+), 20 deletions(-) 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/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/pyproject.toml b/packages/uipath/pyproject.toml index bb12bfa6f..078a100ba 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -7,7 +7,7 @@ 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/tests/cli/test_cli_bindings.py b/packages/uipath/tests/cli/test_cli_bindings.py index ce4758324..b05fbc32b 100644 --- a/packages/uipath/tests/cli/test_cli_bindings.py +++ b/packages/uipath/tests/cli/test_cli_bindings.py @@ -2,6 +2,7 @@ import os from pathlib import Path from types import SimpleNamespace +from typing import Any from unittest.mock import AsyncMock, MagicMock import pytest @@ -17,8 +18,7 @@ from uipath._cli._push._resource_actions import CreateVirtual from uipath._cli.models.runtime_schema import Bindings from uipath.platform.common._bindings import ( - ConnectionResourceOverwrite, - GenericResourceOverwrite, + ResourceOverwriteParser, _resource_overwrites, resource_override, ) @@ -454,20 +454,16 @@ def test_override_applies_to_the_generated_key(self, source: str) -> None: probe = _make_probe(spec) overwrite_key = f"{ref.resource_type}.{binding.key}" - if ref.resource_type == "connection": - overwrite = ConnectionResourceOverwrite( - resource_type="connection", - connectionId="NEW_ID", - folderKey="NEW_FOLDER", - ) - else: - overwrite = GenericResourceOverwrite( - resource_type=ref.resource_type, - name="NEW_NAME", - folderPath="NEW_FOLDER", - ) + # 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 = {spec.name_param: ref.name} + call_args: dict[str, Any] = {spec.name_param: ref.name} if spec.folder_param: call_args[spec.folder_param] = ref.folder_path @@ -620,7 +616,7 @@ def _make_probe(spec): if spec.folder_param: params.append(f"{spec.folder_param}=None") folder_expr = spec.folder_param if spec.folder_param else "None" - namespace: dict = {} + namespace: dict[str, Any] = {} exec( f"def probe({', '.join(params)}):\n" f" return ({spec.name_param}, {folder_expr})\n", diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index bc92233bb..9ca759335 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.32" +version = "0.2.33" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, From edd399e042af54670bdc4f32463d8406877d29e1 Mon Sep 17 00:00:00 2001 From: tudormatei Date: Fri, 25 Sep 2026 11:46:22 +0300 Subject: [PATCH 5/8] feat(cli): discover resources reached through interrupt models A LangGraph agent rarely calls the SDK directly. It builds an interrupt model and hands it to interrupt(), and the resume-trigger protocol makes the decorated call on its behalf. The call site is a constructor, so the decorator registry could not see it, and a project whose only resource came that way generated an empty bindings file with no warning. Each spec mirrors one branch of resume_triggers._protocol, which is what decides the SDK method a model routes to. That matters for CreateDeepRag and CreateBatchTransform, where `name` is the task's own name and `index_name` is the resource being bound. Constructors are matched by class name, which is generic enough to collide, so only names imported from a uipath module count. Wait models and the rest are listed as explicitly non-binding, and a test fails if a new interrupt model is neither mapped nor excluded. Checked against the checked-in samples: ticket-classification and wait-until-timeout-agent now produce exactly the bindings their hand-written files declare. --- packages/uipath/docs/cli/index.md | 2 +- .../src/uipath/_cli/_bindings/_interrupts.py | 110 +++++++++++++ .../src/uipath/_cli/_bindings/_scanner.py | 150 ++++++++++++++---- .../uipath/tests/cli/test_cli_bindings.py | 124 +++++++++++++++ 4 files changed, 353 insertions(+), 33 deletions(-) create mode 100644 packages/uipath/src/uipath/_cli/_bindings/_interrupts.py diff --git a/packages/uipath/docs/cli/index.md b/packages/uipath/docs/cli/index.md index d0bc91cad..8bd8233ef 100644 --- a/packages/uipath/docs/cli/index.md +++ b/packages/uipath/docs/cli/index.md @@ -199,7 +199,7 @@ Do not change or remove it. Changing it makes the project look like a brand-new, 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. A resource named by a literal or a module-level constant is recorded; one whose name is built at runtime is recorded as an expression, and a call whose name cannot be determined at all is reported rather than guessed: +Discovery is static and best effort. A resource named by a literal or a module-level constant is recorded; one whose name is built at runtime is recorded as an expression, and a call whose name cannot be determined at all is reported rather than guessed. Both direct SDK calls and resources reached through an interrupt model (`InvokeProcess`, `CreateTask`, `CreateEscalation`, `CreateDeepRag`, `CreateBatchTransform`) are matched: 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/_scanner.py b/packages/uipath/src/uipath/_cli/_bindings/_scanner.py index 02a9b5cf5..acc902ff3 100644 --- a/packages/uipath/src/uipath/_cli/_bindings/_scanner.py +++ b/packages/uipath/src/uipath/_cli/_bindings/_scanner.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import Optional +from ._interrupts import INTERRUPT_SPECS, InterruptSpec from ._registry import BindingSpec, service_attributes EXCLUDED_DIR_NAMES = frozenset( @@ -163,6 +164,46 @@ def _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]: @@ -176,53 +217,98 @@ def _spec_for_call( 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.""" + name_node = _argument(node, name_param, name_index) + if name_node is None: + result.skipped.append( + SkippedReference( + resource_type=resource_type, + reason=f"could not determine '{name_param}' for {origin}", + source=source, + ) + ) + return + + name, name_is_expression = _resolve(name_node, constants) + folder_node = _argument(node, folder_param, folder_index) + if folder_node is None: + folder_path, folder_is_expression = None, False + else: + folder_path, folder_is_expression = _resolve(folder_node, constants) + + result.references.append( + ResourceReference( + resource_type=resource_type, + name=name, + name_is_expression=name_is_expression, + folder_path=folder_path, + folder_is_expression=folder_is_expression, + 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 - spec = _spec_for_call(node, registry, services) - if spec is None: - continue - source = f"{path_label}:{node.lineno}" - name_node = _argument(node, spec.name_param, spec.name_index) - if name_node is None: - result.skipped.append( - SkippedReference( - resource_type=spec.resource_type, - reason=( - f"could not determine '{spec.name_param}' for " - f"{spec.service_attr}.{spec.method}" - ), - source=source, - ) - ) - continue - - name, name_is_expression = _resolve(name_node, constants) - folder_node = _argument(node, spec.folder_param, spec.folder_index) - if folder_node is None: - folder_path, folder_is_expression = None, False - else: - folder_path, folder_is_expression = _resolve(folder_node, constants) - result.references.append( - ResourceReference( + spec = _spec_for_call(node, registry, services) + if spec is not None: + _record( + result, + node, + source, + constants, resource_type=spec.resource_type, - name=name, - name_is_expression=name_is_expression, - folder_path=folder_path, - folder_is_expression=folder_is_expression, + 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, - source=source, + 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 diff --git a/packages/uipath/tests/cli/test_cli_bindings.py b/packages/uipath/tests/cli/test_cli_bindings.py index b05fbc32b..13b9e42af 100644 --- a/packages/uipath/tests/cli/test_cli_bindings.py +++ b/packages/uipath/tests/cli/test_cli_bindings.py @@ -268,6 +268,130 @@ def test_deduplicates_repeated_references_to_the_same_resource(self) -> None: 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. + """ + + def test_invoke_process_is_discovered(self) -> None: + source = ( + "from uipath.platform.common.interrupt_models import InvokeProcess\n" + "from langgraph.types import interrupt\n" + "def node(state):\n" + " return interrupt(\n" + " InvokeProcess(name='child-agent', process_folder_path='Shared')\n" + " )\n" + ) + result = scan_source(source, "graph.py", build_registry()) + ref = _refs_by_type(result)["process"] + assert ref.name == "child-agent" + assert ref.folder_path == "Shared" + assert ref.activity_name == "invoke_async" + + def test_create_task_is_discovered(self) -> None: + source = ( + "from uipath.platform.common.interrupt_models import CreateTask\n" + "def node(state):\n" + " return interrupt(CreateTask(\n" + " app_name='escalation_agent_app',\n" + " app_folder_path='Shared',\n" + " title='Review',\n" + " ))\n" + ) + result = scan_source(source, "graph.py", build_registry()) + ref = _refs_by_type(result)["app"] + assert ref.name == "escalation_agent_app" + assert ref.folder_path == "Shared" + + def test_escalation_and_module_qualified_form(self) -> None: + source = ( + "from uipath.platform.common import interrupt_models\n" + "def node(state):\n" + " return interrupt(interrupt_models.CreateEscalation(\n" + " app_name='approval', app_folder_path='Ops', title='t'\n" + " ))\n" + ) + result = scan_source(source, "graph.py", build_registry()) + assert _refs_by_type(result)["app"].name == "approval" + + 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_an_unresolvable_model_argument_is_reported(self) -> None: + source = ( + "from uipath.platform.common.interrupt_models import InvokeProcess\n" + "def node(state, cfg):\n" + " return interrupt(InvokeProcess(**cfg))\n" + ) + result = scan_source(source, "graph.py", build_registry()) + assert result.references == [] + assert len(result.skipped) == 1 + assert result.skipped[0].resource_type == "process" + + 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( From d57fa05a80cbaf1660324edf45c83133699817be Mon Sep 17 00:00:00 2001 From: tudormatei Date: Fri, 25 Sep 2026 12:59:58 +0300 Subject: [PATCH 6/8] fix(cli): skip resource names computed at runtime instead of guessing A non-literal argument was written into bindings.json as its own source text with isExpression set. A helper such as def get_asset(client, name, folder_path): return client.assets.retrieve(name=name, folder_path=folder_path) produced a binding keyed "name.folder_path" from the parameter names. The runtime builds its lookup key from evaluated values, so that binding can never match and the override silently does not apply; push meanwhile looks for a resource called "name" and creates a virtual one. samples/asset-modifier-agent does exactly this, and three of the four expression bindings found across the checked-in samples were junk of the same kind. Only literals and module-level constants are recorded now. Anything else is reported with its file, line and the expression that could not be resolved, so it can be declared by hand. A call with no folder argument still binds, since the environment supplies the folder; only a computed one is refused, which also keeps a half-resolved binding from reaching push. Hand-written entries that use isExpression are still preserved on merge. The one sample that ships such a binding, RAG-quiz-generator, is no longer reproduced by the generator. --- packages/uipath/docs/cli/index.md | 2 +- .../src/uipath/_cli/_bindings/_emitter.py | 21 ++---- .../src/uipath/_cli/_bindings/_scanner.py | 34 +++++----- .../uipath/tests/cli/test_cli_bindings.py | 64 ++++++++++++++----- 4 files changed, 73 insertions(+), 48 deletions(-) diff --git a/packages/uipath/docs/cli/index.md b/packages/uipath/docs/cli/index.md index 8bd8233ef..fc3291560 100644 --- a/packages/uipath/docs/cli/index.md +++ b/packages/uipath/docs/cli/index.md @@ -199,7 +199,7 @@ Do not change or remove it. Changing it makes the project look like a brand-new, 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. A resource named by a literal or a module-level constant is recorded; one whose name is built at runtime is recorded as an expression, and a call whose name cannot be determined at all is reported rather than guessed. Both direct SDK calls and resources reached through an interrupt model (`InvokeProcess`, `CreateTask`, `CreateEscalation`, `CreateDeepRag`, `CreateBatchTransform`) are matched: +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: diff --git a/packages/uipath/src/uipath/_cli/_bindings/_emitter.py b/packages/uipath/src/uipath/_cli/_bindings/_emitter.py index 86f41bce7..65b007556 100644 --- a/packages/uipath/src/uipath/_cli/_bindings/_emitter.py +++ b/packages/uipath/src/uipath/_cli/_bindings/_emitter.py @@ -34,12 +34,11 @@ def binding_key(reference: ResourceReference) -> str: return f"{reference.name}.{reference.folder_path}" -def _value( - default_value: str, is_expression: bool, display_name: str -) -> BindingResourceValue: +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=is_expression, + is_expression=False, display_name=display_name, ) @@ -48,11 +47,7 @@ def _connection_binding(reference: ResourceReference) -> BindingResource: return BindingResource( resource="connection", key=binding_key(reference), - value={ - "ConnectionId": _value( - reference.name, reference.name_is_expression, "Connection" - ) - }, + value={"ConnectionId": _value(reference.name, "Connection")}, metadata={ "BindingsVersion": BINDINGS_METADATA_VERSION, "Connector": "", @@ -75,12 +70,8 @@ def build_binding(reference: ResourceReference) -> BindingResource: resource=reference.resource_type, key=binding_key(reference), value={ - "name": _value(reference.name, reference.name_is_expression, name_label), - "folderPath": _value( - reference.folder_path or "", - reference.folder_is_expression, - folder_label, - ), + "name": _value(reference.name, name_label), + "folderPath": _value(reference.folder_path or "", folder_label), }, metadata={ "ActivityName": reference.activity_name, diff --git a/packages/uipath/src/uipath/_cli/_bindings/_scanner.py b/packages/uipath/src/uipath/_cli/_bindings/_scanner.py index acc902ff3..86e025e65 100644 --- a/packages/uipath/src/uipath/_cli/_bindings/_scanner.py +++ b/packages/uipath/src/uipath/_cli/_bindings/_scanner.py @@ -41,13 +41,11 @@ @dataclass(frozen=True) class ResourceReference: - """A resource the agent code refers to.""" + """A resource the agent code names with a literal value.""" resource_type: str name: str - name_is_expression: bool folder_path: Optional[str] - folder_is_expression: bool activity_name: str source: str @@ -232,31 +230,37 @@ def _record( origin: str, ) -> None: """Turn one matched call into a reference, or a skip with a reason.""" - name_node = _argument(node, name_param, name_index) - if name_node is None: + + def skip(reason: str) -> None: result.skipped.append( - SkippedReference( - resource_type=resource_type, - reason=f"could not determine '{name_param}' for {origin}", - source=source, - ) + 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 None: - folder_path, folder_is_expression = None, False - else: + 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, - name_is_expression=name_is_expression, folder_path=folder_path, - folder_is_expression=folder_is_expression, activity_name=activity_name, source=source, ) diff --git a/packages/uipath/tests/cli/test_cli_bindings.py b/packages/uipath/tests/cli/test_cli_bindings.py index 13b9e42af..c2585fde4 100644 --- a/packages/uipath/tests/cli/test_cli_bindings.py +++ b/packages/uipath/tests/cli/test_cli_bindings.py @@ -134,7 +134,6 @@ def test_folds_module_level_string_constants(self) -> None: result = scan_source(source, "graph.py", build_registry()) ref = _refs_by_type(result)["connection"] assert ref.name == "outlook-key" - assert ref.name_is_expression is False def test_does_not_fold_constants_reassigned_in_the_module(self) -> None: source = ( @@ -144,9 +143,8 @@ def test_does_not_fold_constants_reassigned_in_the_module(self) -> None: " await sdk.connections.retrieve_async(CONNECTION)\n" ) result = scan_source(source, "graph.py", build_registry()) - ref = _refs_by_type(result)["connection"] - assert ref.name == "CONNECTION" - assert ref.name_is_expression is True + assert result.references == [] + assert len(result.skipped) == 1 def test_does_not_fold_constants_changed_by_augmented_assignment(self) -> None: """`X += ...` changes the value the call actually receives.""" @@ -157,9 +155,8 @@ def test_does_not_fold_constants_changed_by_augmented_assignment(self) -> None: " await sdk.connections.retrieve_async(CONNECTION)\n" ) result = scan_source(source, "graph.py", build_registry()) - ref = _refs_by_type(result)["connection"] - assert ref.name == "CONNECTION" - assert ref.name_is_expression is True + assert result.references == [] + assert len(result.skipped) == 1 def test_does_not_fold_constants_reassigned_inside_a_branch(self) -> None: """A nested rebind is still a rebind, even though it is not top level.""" @@ -171,9 +168,8 @@ def test_does_not_fold_constants_reassigned_inside_a_branch(self) -> None: " await sdk.assets.retrieve_async(NAME, folder_path='F')\n" ) result = scan_source(source, "graph.py", build_registry()) - ref = _refs_by_type(result)["asset"] - assert ref.name == "NAME" - assert ref.name_is_expression is True + 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.""" @@ -188,13 +184,13 @@ def test_resolves_a_positionally_passed_folder(self) -> None: ref = _refs_by_type(result)["index"] assert ref.name == "Idx" assert ref.folder_path == "Policies" - assert ref.folder_is_expression is False from uipath._cli._bindings._emitter import binding_key assert binding_key(ref) == "Idx.Policies" - def test_marks_non_literal_arguments_as_expressions(self) -> None: + def test_skips_a_name_computed_at_runtime(self) -> None: + """A python expression is not a resource name, so never emit one.""" source = ( "async def run(sdk, state):\n" " await sdk.context_grounding.add_to_index_async(\n" @@ -202,11 +198,45 @@ def test_marks_non_literal_arguments_as_expressions(self) -> None: " )\n" ) result = scan_source(source, "agent.py", build_registry()) - ref = _refs_by_type(result)["index"] - assert ref.name == "state.index_name" - assert ref.name_is_expression is True - assert ref.folder_path == "state.index_folder_path" - assert ref.folder_is_expression is True + assert result.references == [] + assert len(result.skipped) == 1 + assert "state.index_name" 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_the_reviewers_wrapper_case_is_skipped(self) -> None: + """Parameter names must never reach bindings.json. + + samples/asset-modifier-agent does exactly this; before the change it + produced a binding keyed 'name.folder_path'. + """ + source = ( + "def get_asset(client, name, folder_path):\n" + " return client.assets.retrieve(name=name, folder_path=folder_path)\n" + ) + result = scan_source(source, "helpers.py", build_registry()) + assert result.references == [] + assert len(result.skipped) == 1 + assert result.skipped[0].resource_type == "asset" + + 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 = ( From 50d099d46708ad7a9f0a637a4fc85469e13cf071 Mon Sep 17 00:00:00 2001 From: tudormatei Date: Fri, 25 Sep 2026 13:10:44 +0300 Subject: [PATCH 7/8] chore(deps): bump uipath to 2.14.26 main released 2.14.25 while this branch was open, so the version it carried is now on PyPI. --- packages/uipath/pyproject.toml | 2 +- packages/uipath/uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 078a100ba..254d85a56 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [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" diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 9ca759335..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" }, From 8684fc2258231748beed820f2aae4aef924d8476 Mon Sep 17 00:00:00 2001 From: tudormatei Date: Fri, 25 Sep 2026 13:27:53 +0300 Subject: [PATCH 8/8] test(cli): trim the bindings suite to what actually catches bugs The file had grown to 856 lines as each review round added cases. Five tests were removed as redundant: two that the golden-file test already subsumes, one whose positional and keyword forms the golden sample exercises anyway, one duplicating the unresolvable-argument path through the other matching rule, and one exercising merge through init when merge is covered directly. Four rebind syntaxes, three expression shapes and three constructor forms collapse into parametrized cases. The file also carried its own copies of _binding_metadata and _iter_service_classes; it now imports them. The two merge tests became one behind a fixture builder, and the push-resolver tests share their mock setup. Coverage is unchanged, checked by mutation: reversing the key order fails 10 tests, dropping the expression skip fails 7, removing the rebind check fails 4, and disabling the directory exclusions, the opt-in guard on init, folder_index or the uipath-import guard each fail their own. --- .../uipath/tests/cli/test_cli_bindings.py | 516 ++++++------------ 1 file changed, 176 insertions(+), 340 deletions(-) diff --git a/packages/uipath/tests/cli/test_cli_bindings.py b/packages/uipath/tests/cli/test_cli_bindings.py index c2585fde4..f1079d133 100644 --- a/packages/uipath/tests/cli/test_cli_bindings.py +++ b/packages/uipath/tests/cli/test_cli_bindings.py @@ -12,6 +12,8 @@ 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 @@ -39,16 +41,59 @@ 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_covers_every_bindable_resource_type(self) -> None: - registry = build_registry() - found = {spec.resource_type for spec in registry.values()} - assert BINDABLE_RESOURCE_TYPES <= found - def test_registry_maps_known_sdk_methods_to_their_parameters(self) -> None: registry = build_registry() @@ -80,11 +125,9 @@ def test_registry_matches_the_live_resource_override_decorators(self) -> None: """ import inspect - from uipath.platform import UiPath - registry = build_registry() missing = [] - for service_attr, service_cls in _iter_services(UiPath): + 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: @@ -107,11 +150,6 @@ def test_activity_name_is_the_async_variant(self) -> None: class TestScanner: - def test_recovers_every_resource_type_from_the_sample_agent(self) -> None: - result = scan_project(SAMPLE_DIR, build_registry()) - assert _refs_by_type(result).keys() == BINDABLE_RESOURCE_TYPES - assert result.skipped == [] - 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()) @@ -135,22 +173,19 @@ def test_folds_module_level_string_constants(self) -> None: ref = _refs_by_type(result)["connection"] assert ref.name == "outlook-key" - def test_does_not_fold_constants_reassigned_in_the_module(self) -> None: - source = ( - "CONNECTION = 'first'\n" - "CONNECTION = 'second'\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_does_not_fold_constants_changed_by_augmented_assignment(self) -> None: - """`X += ...` changes the value the call actually receives.""" + @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 = ( - "CONNECTION = 'first'\n" - "CONNECTION += '-suffix'\n" + f"CONNECTION = 'first'\n{rebind}\n" "async def run(sdk):\n" " await sdk.connections.retrieve_async(CONNECTION)\n" ) @@ -158,19 +193,6 @@ def test_does_not_fold_constants_changed_by_augmented_assignment(self) -> None: assert result.references == [] assert len(result.skipped) == 1 - def test_does_not_fold_constants_reassigned_inside_a_branch(self) -> None: - """A nested rebind is still a rebind, even though it is not top level.""" - source = ( - "NAME = 'a'\n" - "if SOMETHING:\n" - " NAME = 'b'\n" - "async def run(sdk):\n" - " await sdk.assets.retrieve_async(NAME, folder_path='F')\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() @@ -189,18 +211,35 @@ def test_resolves_a_positionally_passed_folder(self) -> None: assert binding_key(ref) == "Idx.Policies" - def test_skips_a_name_computed_at_runtime(self) -> None: + @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 = ( - "async def run(sdk, state):\n" - " await sdk.context_grounding.add_to_index_async(\n" - " name=state.index_name, folder_path=state.index_folder_path\n" - " )\n" - ) + 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 "state.index_name" in result.skipped[0].reason + 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: @@ -216,21 +255,6 @@ def test_skips_a_literal_name_whose_folder_is_computed(self) -> None: assert result.references == [] assert len(result.skipped) == 1 - def test_the_reviewers_wrapper_case_is_skipped(self) -> None: - """Parameter names must never reach bindings.json. - - samples/asset-modifier-agent does exactly this; before the change it - produced a binding keyed 'name.folder_path'. - """ - source = ( - "def get_asset(client, name, folder_path):\n" - " return client.assets.retrieve(name=name, folder_path=folder_path)\n" - ) - result = scan_source(source, "helpers.py", build_registry()) - assert result.references == [] - assert len(result.skipped) == 1 - assert result.skipped[0].resource_type == "asset" - 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" @@ -251,17 +275,6 @@ def test_skips_calls_whose_resource_name_cannot_be_determined(self) -> None: assert result.skipped[0].resource_type == "bucket" assert "backend.py:2" in result.skipped[0].source - def test_resolves_positional_and_keyword_name_arguments(self) -> None: - source = ( - "async def run(sdk):\n" - " await sdk.assets.retrieve_async('positional', folder_path='F')\n" - " await sdk.processes.invoke_async(name='keyword', folder_path='F')\n" - ) - result = scan_source(source, "main.py", build_registry()) - refs = _refs_by_type(result) - assert refs["asset"].name == "positional" - assert refs["process"].name == "keyword" - def test_ignores_unrelated_calls_with_the_same_method_name(self) -> None: source = ( "async def run(repo, sdk):\n" @@ -307,46 +320,42 @@ class TestInterruptModels: see it. """ - def test_invoke_process_is_discovered(self) -> None: - source = ( - "from uipath.platform.common.interrupt_models import InvokeProcess\n" - "from langgraph.types import interrupt\n" - "def node(state):\n" - " return interrupt(\n" - " InvokeProcess(name='child-agent', process_folder_path='Shared')\n" - " )\n" - ) - result = scan_source(source, "graph.py", build_registry()) - ref = _refs_by_type(result)["process"] - assert ref.name == "child-agent" - assert ref.folder_path == "Shared" - assert ref.activity_name == "invoke_async" - - def test_create_task_is_discovered(self) -> None: - source = ( - "from uipath.platform.common.interrupt_models import CreateTask\n" - "def node(state):\n" - " return interrupt(CreateTask(\n" - " app_name='escalation_agent_app',\n" - " app_folder_path='Shared',\n" - " title='Review',\n" - " ))\n" - ) - result = scan_source(source, "graph.py", build_registry()) - ref = _refs_by_type(result)["app"] - assert ref.name == "escalation_agent_app" - assert ref.folder_path == "Shared" - - def test_escalation_and_module_qualified_form(self) -> None: - source = ( - "from uipath.platform.common import interrupt_models\n" - "def node(state):\n" - " return interrupt(interrupt_models.CreateEscalation(\n" - " app_name='approval', app_folder_path='Ops', title='t'\n" - " ))\n" - ) + @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()) - assert _refs_by_type(result)["app"].name == "approval" + 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.""" @@ -386,17 +395,6 @@ def test_a_wait_model_binds_nothing(self) -> None: result = scan_source(source, "graph.py", build_registry()) assert result.references == [] - def test_an_unresolvable_model_argument_is_reported(self) -> None: - source = ( - "from uipath.platform.common.interrupt_models import InvokeProcess\n" - "def node(state, cfg):\n" - " return interrupt(InvokeProcess(**cfg))\n" - ) - result = scan_source(source, "graph.py", build_registry()) - assert result.references == [] - assert len(result.skipped) == 1 - assert result.skipped[0].resource_type == "process" - def test_every_interrupt_model_is_classified(self) -> None: """A new interrupt model must be mapped or explicitly excluded. @@ -451,87 +449,51 @@ def test_emitted_entries_satisfy_the_published_json_schema(self) -> None: assert set(prop) == required_prop_fields async def test_generated_bindings_survive_the_push_resolver(self) -> None: - """Every generated entry must yield an action, not an exception. - - `uipath push` reads this file to build solution resources. 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. + """`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. """ - from uipath._cli._push._resolvers import resolve_bindings - from uipath.platform.resource_catalog import ResourceType - result = scan_project(SAMPLE_DIR, build_registry()) generated, _ = merge_bindings(None, result.references) - - catalog = MagicMock() - catalog.list_by_type_async.return_value = _EmptyAsyncIterator() - connections = MagicMock() - connections.retrieve_async = AsyncMock( - return_value=SimpleNamespace( - name="resolved-connection", folder={"path": "Shared"} - ) - ) - supported = {t.value for t in ResourceType} - - actions = [ - action - async for action in resolve_bindings( - generated, catalog, connections, supported - ) - ] + actions = await _resolve(generated) assert len(actions) == len(generated.resources) - async def test_a_binding_without_a_folder_still_resolves(self) -> None: - """A call with no folder_path is normal — the folder comes from the env.""" - from uipath._cli._push._resolvers import resolve_bindings - from uipath.platform.resource_catalog import ResourceType + 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" - catalog = MagicMock() - catalog.list_by_type_async.return_value = _EmptyAsyncIterator() - actions = [ - action - async for action in resolve_bindings( - generated, - catalog, - MagicMock(), - {t.value for t in ResourceType}, - ) - ] - # Pin the side effect rather than just "an action happened": a folderless - # binding reaches push as an uncatalogued resource and becomes a virtual - # placeholder. Changing that is a product decision, not an accident. + actions = await _resolve(generated) assert len(actions) == 1 assert isinstance(actions[0], CreateVirtual) assert actions[0].request.name == "Solo" - def test_merge_keeps_hand_edited_entries_untouched(self) -> None: + 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": [ - { - "resource": "asset", - "key": "A.F", - "value": { - "name": { - "defaultValue": "A", - "isExpression": False, - "displayName": "Custom Label", - }, - "folderPath": { - "defaultValue": "F", - "isExpression": False, - "displayName": "Folder Path", - }, - }, - "metadata": {"BindingsVersion": "2.2", "Hand": "written"}, - } + _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), ], } ) @@ -546,36 +508,11 @@ def test_merge_keeps_hand_edited_entries_untouched(self) -> None: 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.added == ["bucket:B.F"] assert report.unchanged == ["asset:A.F"] + assert report.added == ["bucket:B.F"] - def test_merge_never_drops_entries_the_scan_did_not_find(self) -> None: - existing = Bindings.model_validate( - { - "version": "2.0", - "resources": [ - { - "resource": "index", - "key": "state.i.state.f", - "value": { - "name": { - "defaultValue": "state.i", - "isExpression": True, - "displayName": "Name", - }, - "folderPath": { - "defaultValue": "state.f", - "isExpression": True, - "displayName": "Folder Path", - }, - }, - "metadata": {"BindingsVersion": "2.2"}, - } - ], - } - ) - merged, report = merge_bindings(existing, []) - assert len(merged.resources) == 1 + 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"] @@ -633,25 +570,28 @@ def test_override_applies_to_the_generated_key(self, source: str) -> None: assert observed_folder == "NEW_FOLDER" -class TestCommand: - def _project(self, body: str) -> None: - with open("pyproject.toml", "w") as f: - f.write( - '[project]\nname = "test-project"\nversion = "0.1.0"\n' - 'description = "Test"\nauthors = [{name = "Test"}]\n' - 'requires-python = ">=3.11"\n' - ) - with open("main.py", "w") as f: - f.write(body) +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): - self._project( - "async def run(sdk):\n" - " await sdk.assets.retrieve_async('MyAsset', folder_path='Shared')\n" - ) + _write_project(_AGENT) result = runner.invoke(cli, ["bindings", "generate"], env={}) assert result.exit_code == 0, result.output @@ -664,7 +604,7 @@ def test_generate_reports_what_it_could_not_resolve( self, runner: CliRunner, temp_dir: str ) -> None: with runner.isolated_filesystem(temp_dir=temp_dir): - self._project( + _write_project( "def run(self):\n" " self._sdk.buckets.download(blob_file_path='a', **self._kw())\n" ) @@ -677,10 +617,7 @@ def test_dry_run_does_not_write_the_file( self, runner: CliRunner, temp_dir: str ) -> None: with runner.isolated_filesystem(temp_dir=temp_dir): - self._project( - "async def run(sdk):\n" - " await sdk.assets.retrieve_async('MyAsset', folder_path='Shared')\n" - ) + _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") @@ -689,10 +626,7 @@ 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): - self._project( - "async def run(sdk):\n" - " await sdk.assets.retrieve_async('MyAsset', folder_path='Shared')\n" - ) + _write_project(_AGENT) Path("bindings.json").write_text('{"version": "2.0", "resources": []}') result = runner.invoke(cli, ["bindings", "generate", "--check"], env={}) @@ -703,68 +637,20 @@ 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): - self._project( - "async def run(sdk):\n" - " await sdk.assets.retrieve_async('MyAsset', folder_path='Shared')\n" - ) + _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): - self._project( - "async def run(sdk):\n" - " await sdk.assets.retrieve_async('MyAsset', folder_path='Shared')\n" - ) + _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 _iter_services(uipath_cls): - import typing - - for attr, descriptor in vars(uipath_cls).items(): - fget = getattr(descriptor, "fget", None) or getattr(descriptor, "func", None) - if fget is None: - continue - try: - hints = typing.get_type_hints(fget) - except Exception: - continue - service_cls = hints.get("return") - if isinstance(service_cls, type): - yield attr, service_cls - - -def _binding_metadata(func): - target = getattr(func, "__func__", func) - meta = getattr(target, "__uipath_binding__", None) - if meta is not None: - return meta - 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_cells = dict( - zip(inner.__code__.co_freevars, inner.__closure__ or (), strict=True) - ) - if "resource_type" not in inner_cells: - return None - return { - "resource_type": inner_cells["resource_type"].cell_contents, - "resource_identifier": inner_cells["resource_identifier"].cell_contents, - "folder_identifier": inner_cells["folder_identifier"].cell_contents, - } - - def _make_probe(spec): params = [f"{spec.name_param}=None"] if spec.folder_param: @@ -784,27 +670,12 @@ def _make_probe(spec): class TestInitInferBindings: - def _project(self, body: str) -> None: - with open("pyproject.toml", "w") as f: - f.write( - '[project]\nname = "test-project"\nversion = "0.1.0"\n' - 'description = "Test"\nauthors = [{name = "Test"}]\n' - 'requires-python = ">=3.11"\n' - ) - with open("main.py", "w") as f: - f.write(body) - - _AGENT = ( - "async def run(sdk):\n" - " await sdk.assets.retrieve_async('MyAsset', folder_path='Shared')\n" - ) - 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): - self._project(self._AGENT) + _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"] == [] @@ -813,44 +684,9 @@ def test_init_infer_bindings_records_discovered_resources( self, runner: CliRunner, temp_dir: str ) -> None: with runner.isolated_filesystem(temp_dir=temp_dir): - self._project(self._AGENT) + _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"] - - def test_init_infer_bindings_merges_into_an_existing_file( - self, runner: CliRunner, temp_dir: str - ) -> None: - with runner.isolated_filesystem(temp_dir=temp_dir): - self._project(self._AGENT) - Path("bindings.json").write_text( - json.dumps( - { - "version": "2.0", - "resources": [ - { - "resource": "connection", - "key": "kept", - "value": { - "ConnectionId": { - "defaultValue": "kept", - "isExpression": False, - "displayName": "Connection", - } - }, - "metadata": {"BindingsVersion": "2.2"}, - } - ], - } - ) - ) - result = runner.invoke(cli, ["init", "--infer-bindings"], env={}) - assert result.exit_code == 0, result.output - - keys = { - r["key"] - for r in json.loads(Path("bindings.json").read_text())["resources"] - } - assert keys == {"kept", "MyAsset.Shared"}