Skip to content

Commit 3ff1078

Browse files
feat(cli): log bindings.json and resource overwrites at INFO (#1681)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 837fe37 commit 3ff1078

5 files changed

Lines changed: 333 additions & 17 deletions

File tree

packages/uipath/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath"
3-
version = "2.10.70"
3+
version = "2.10.71"
44
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

packages/uipath/src/uipath/_cli/_utils/_common.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,14 @@ async def read_resource_overwrites_from_file(
214214
.get("internalArguments", {})
215215
.get("resourceOverwrites", {})
216216
)
217+
218+
logger.info(
219+
"Resource overwrites read from %s (%d entries):\n%s",
220+
file_path,
221+
len(resource_overwrites),
222+
json.dumps(resource_overwrites, indent=2, sort_keys=True),
223+
)
224+
217225
for key, value in resource_overwrites.items():
218226
try:
219227
overwrites_dict[key] = ResourceOverwriteParser.parse(key, value)
@@ -224,15 +232,9 @@ async def read_resource_overwrites_from_file(
224232
e,
225233
)
226234

227-
logger.debug(
228-
"Loaded %d resource overwrite(s) from file %s",
229-
len(overwrites_dict),
230-
file_path,
231-
)
232-
233235
# Return empty dict if file doesn't exist or invalid json
234236
except FileNotFoundError:
235-
logger.debug("Resource overwrites config file not found: %s", file_path)
237+
logger.info("Resource overwrites config file not found: %s", file_path)
236238
except json.JSONDecodeError as e:
237239
logger.warning("Failed to parse resource overwrites from %s: %s", file_path, e)
238240

packages/uipath/src/uipath/_cli/_utils/_studio_project.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,12 @@ async def get_resource_overwrites(self) -> dict[str, ResourceOverwrite]:
578578
with open(UiPathConfig.bindings_file_path, "rb") as f:
579579
file_content = f.read()
580580

581+
logger.info(
582+
"Resource bindings (%s):\n%s",
583+
UiPathConfig.bindings_file_path,
584+
file_content.decode(),
585+
)
586+
581587
solution_id = await self._get_solution_id()
582588
tenant_id = os.getenv(ENV_TENANT_ID, None)
583589

@@ -600,18 +606,18 @@ async def get_resource_overwrites(self) -> dict[str, ResourceOverwrite]:
600606
files=files,
601607
)
602608
data = response.json()
603-
overwrites = {}
604-
605-
for key, value in data.items():
606-
overwrites[key] = ResourceOverwriteParser.parse(key, value)
607609

608610
logger.info(
609-
"Loaded %d resource overwrite(s) from Studio API for solution %s: %s",
610-
len(overwrites),
611+
"Resource overwrites received for solution %s (%d entries):\n%s",
611612
solution_id,
612-
overwrites,
613+
len(data),
614+
json.dumps(data, indent=2),
613615
)
614616

617+
overwrites = {}
618+
for key, value in data.items():
619+
overwrites[key] = ResourceOverwriteParser.parse(key, value)
620+
615621
return overwrites
616622

617623
async def create_virtual_resource(
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
# type: ignore
2+
"""Tests for INFO-level diagnostic logging on the resource-overwrites read paths.
3+
4+
Covers the recent change that surfaces bindings.json content and raw resource
5+
overwrites (from both uipath.json and the Studio API) at INFO so binding/
6+
overwrite mismatches can be diagnosed from logs alone.
7+
"""
8+
9+
import json
10+
import logging
11+
from pathlib import Path
12+
from unittest.mock import AsyncMock, MagicMock
13+
14+
import pytest
15+
16+
from uipath._cli._utils._common import read_resource_overwrites_from_file
17+
from uipath._cli._utils._studio_project import StudioClient
18+
from uipath.platform.common import GenericResourceOverwrite
19+
20+
_VALID_OVERWRITES = {
21+
"asset.asset_name": {
22+
"name": "Overwritten Asset Name",
23+
"folderPath": "Overwritten/Asset/Folder",
24+
},
25+
"bucket.bucket_name": {
26+
"name": "Overwritten Bucket Name",
27+
"folderPath": "Overwritten/Bucket/Folder",
28+
},
29+
}
30+
31+
32+
_TARGET_LOGGERS = (
33+
"uipath._cli._utils._common",
34+
"uipath._cli._utils._studio_project",
35+
)
36+
37+
38+
@pytest.fixture(autouse=True)
39+
def _capture_uipath_loggers(
40+
caplog: pytest.LogCaptureFixture,
41+
) -> None:
42+
"""Attach caplog's handler directly to the target module loggers.
43+
44+
Earlier tests in the suite — chiefly anything that invokes the Click CLI
45+
— call ``setup_logging`` and leave the ``uipath`` logger with
46+
``propagate = False``. That breaks the usual caplog flow (handler on
47+
root, records reach it via propagation). Some intermediate loggers can
48+
also end up with ``propagate = False`` from other test setups. Attaching
49+
the handler directly to each module logger we assert against, and
50+
forcing the level to DEBUG for the duration of the test, side-steps the
51+
propagation question entirely.
52+
"""
53+
snapshots: list[tuple[logging.Logger, int, bool]] = []
54+
for name in _TARGET_LOGGERS:
55+
logger = logging.getLogger(name)
56+
snapshots.append((logger, logger.level, logger.propagate))
57+
logger.setLevel(logging.DEBUG)
58+
logger.propagate = True
59+
logger.addHandler(caplog.handler)
60+
try:
61+
yield
62+
finally:
63+
for logger, level, propagate in snapshots:
64+
logger.removeHandler(caplog.handler)
65+
logger.setLevel(level)
66+
logger.propagate = propagate
67+
68+
69+
def _write_uipath_json(directory: Path, overwrites: dict) -> Path:
70+
config_path = directory / "uipath.json"
71+
config_path.write_text(
72+
json.dumps(
73+
{
74+
"runtime": {"internalArguments": {"resourceOverwrites": overwrites}},
75+
}
76+
)
77+
)
78+
return config_path
79+
80+
81+
class TestReadResourceOverwritesFromFileLogging:
82+
"""Behavior: read_resource_overwrites_from_file logs diagnostic info at INFO."""
83+
84+
async def test_logs_raw_overwrites_at_info_when_file_present(
85+
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
86+
) -> None:
87+
config_path = _write_uipath_json(tmp_path, _VALID_OVERWRITES)
88+
89+
with caplog.at_level(logging.INFO, logger="uipath._cli._utils._common"):
90+
result = await read_resource_overwrites_from_file(str(tmp_path))
91+
92+
assert set(result.keys()) == set(_VALID_OVERWRITES.keys())
93+
94+
info_records = [r for r in caplog.records if r.levelno == logging.INFO]
95+
assert any(
96+
"Resource overwrites read from" in r.getMessage()
97+
and str(config_path) in r.getMessage()
98+
and f"({len(_VALID_OVERWRITES)} entries)" in r.getMessage()
99+
for r in info_records
100+
), f"expected INFO log with file path and entry count, got: {caplog.text}"
101+
102+
# The raw JSON payload should be present in the log so a developer can
103+
# diff it against what Studio later returns.
104+
assert "Overwritten Asset Name" in caplog.text
105+
assert "Overwritten Bucket Name" in caplog.text
106+
107+
async def test_logs_info_when_config_file_missing(
108+
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
109+
) -> None:
110+
# tmp_path is empty — no uipath.json present.
111+
missing_dir = tmp_path / "does-not-exist"
112+
missing_dir.mkdir()
113+
114+
with caplog.at_level(logging.INFO, logger="uipath._cli._utils._common"):
115+
result = await read_resource_overwrites_from_file(str(missing_dir))
116+
117+
assert result == {}
118+
info_messages = [
119+
r.getMessage() for r in caplog.records if r.levelno == logging.INFO
120+
]
121+
assert any(
122+
"Resource overwrites config file not found" in msg for msg in info_messages
123+
), f"expected INFO log for missing config, got: {info_messages}"
124+
125+
async def test_logs_warning_when_json_is_malformed(
126+
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
127+
) -> None:
128+
(tmp_path / "uipath.json").write_text("{not valid json")
129+
130+
with caplog.at_level(logging.WARNING, logger="uipath._cli._utils._common"):
131+
result = await read_resource_overwrites_from_file(str(tmp_path))
132+
133+
assert result == {}
134+
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
135+
assert any(
136+
"Failed to parse resource overwrites" in r.getMessage() for r in warnings
137+
)
138+
139+
async def test_unrecognized_overwrite_key_is_skipped_with_warning(
140+
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
141+
) -> None:
142+
overwrites = {
143+
**_VALID_OVERWRITES,
144+
"totallyUnknownKind.foo": {"name": "x", "folderPath": "y"},
145+
}
146+
_write_uipath_json(tmp_path, overwrites)
147+
148+
with caplog.at_level(logging.WARNING, logger="uipath._cli._utils._common"):
149+
result = await read_resource_overwrites_from_file(str(tmp_path))
150+
151+
# Valid entries still parsed; unknown key dropped.
152+
assert set(result.keys()) == set(_VALID_OVERWRITES.keys())
153+
assert any(
154+
"Skipping unrecognized resource overwrite" in r.getMessage()
155+
and "totallyUnknownKind.foo" in r.getMessage()
156+
for r in caplog.records
157+
if r.levelno == logging.WARNING
158+
)
159+
160+
161+
class TestStudioClientGetResourceOverwritesLogging:
162+
"""Behavior: StudioClient.get_resource_overwrites logs bindings + raw payload."""
163+
164+
@pytest.fixture
165+
def studio_client(self) -> StudioClient:
166+
# Inject a mock UiPath so no real HTTP setup is required.
167+
mock_uipath = MagicMock()
168+
mock_uipath.api_client.request_async = AsyncMock()
169+
client = StudioClient(project_id="test-project-id", uipath=mock_uipath)
170+
# Avoid the network call that resolves the solution id.
171+
client._get_solution_id = AsyncMock(return_value="test-solution-id") # type: ignore[method-assign]
172+
return client
173+
174+
async def test_warns_and_returns_empty_when_bindings_file_missing(
175+
self,
176+
studio_client: StudioClient,
177+
tmp_path: Path,
178+
monkeypatch: pytest.MonkeyPatch,
179+
caplog: pytest.LogCaptureFixture,
180+
) -> None:
181+
from uipath.platform.common._config import ConfigurationManager
182+
183+
missing_path = tmp_path / "bindings.json"
184+
monkeypatch.setattr(
185+
ConfigurationManager,
186+
"bindings_file_path",
187+
property(lambda self: missing_path),
188+
)
189+
190+
with caplog.at_level(logging.WARNING):
191+
result = await studio_client.get_resource_overwrites()
192+
193+
assert result == {}
194+
assert any(
195+
"Bindings file not found" in r.getMessage()
196+
for r in caplog.records
197+
if r.levelno == logging.WARNING
198+
)
199+
# No request should have been made when there is nothing to upload.
200+
studio_client.uipath.api_client.request_async.assert_not_called()
201+
202+
async def test_logs_bindings_content_and_received_overwrites_at_info(
203+
self,
204+
studio_client: StudioClient,
205+
tmp_path: Path,
206+
monkeypatch: pytest.MonkeyPatch,
207+
caplog: pytest.LogCaptureFixture,
208+
) -> None:
209+
from uipath.platform.common._config import ConfigurationManager
210+
211+
bindings_path = tmp_path / "bindings.json"
212+
bindings_content = json.dumps(
213+
{"version": "2", "resources": [{"name": "my_bucket", "kind": "bucket"}]}
214+
)
215+
bindings_path.write_text(bindings_content)
216+
monkeypatch.setattr(
217+
ConfigurationManager,
218+
"bindings_file_path",
219+
property(lambda self: bindings_path),
220+
)
221+
monkeypatch.delenv("UIPATH_TENANT_ID", raising=False)
222+
223+
response = MagicMock()
224+
response.json.return_value = {
225+
"bucket.my_bucket": {
226+
"name": "prod_bucket",
227+
"folderPath": "Prod/Folder",
228+
}
229+
}
230+
studio_client.uipath.api_client.request_async = AsyncMock(return_value=response)
231+
232+
with caplog.at_level(logging.INFO, logger="uipath._cli._utils._studio_project"):
233+
result = await studio_client.get_resource_overwrites()
234+
235+
# Returned dict is parsed via ResourceOverwriteParser.
236+
assert set(result.keys()) == {"bucket.my_bucket"}
237+
238+
info_text = "\n".join(
239+
r.getMessage() for r in caplog.records if r.levelno == logging.INFO
240+
)
241+
# Bindings content is logged so we can compare what was sent to Studio.
242+
assert "Resource bindings" in info_text
243+
assert "my_bucket" in info_text
244+
# Received overwrites payload is logged with the solution id and count.
245+
assert "Resource overwrites received for solution test-solution-id" in info_text
246+
assert "(1 entries)" in info_text
247+
assert "prod_bucket" in info_text
248+
249+
async def test_parses_received_overwrites_into_resource_overwrite_objects(
250+
self,
251+
studio_client: StudioClient,
252+
tmp_path: Path,
253+
monkeypatch: pytest.MonkeyPatch,
254+
) -> None:
255+
from uipath.platform.common._config import ConfigurationManager
256+
257+
bindings_path = tmp_path / "bindings.json"
258+
bindings_path.write_text("{}")
259+
monkeypatch.setattr(
260+
ConfigurationManager,
261+
"bindings_file_path",
262+
property(lambda self: bindings_path),
263+
)
264+
265+
response = MagicMock()
266+
response.json.return_value = {
267+
"bucket.my_bucket": {
268+
"name": "prod_bucket",
269+
"folderPath": "Prod/Folder",
270+
}
271+
}
272+
studio_client.uipath.api_client.request_async = AsyncMock(return_value=response)
273+
274+
result = await studio_client.get_resource_overwrites()
275+
276+
parsed = result["bucket.my_bucket"]
277+
assert isinstance(parsed, GenericResourceOverwrite)
278+
assert parsed.resource_identifier == "prod_bucket"
279+
assert parsed.folder_identifier == "Prod/Folder"
280+
281+
async def test_passes_tenant_id_header_from_environment(
282+
self,
283+
studio_client: StudioClient,
284+
tmp_path: Path,
285+
monkeypatch: pytest.MonkeyPatch,
286+
) -> None:
287+
from uipath.platform.common._config import ConfigurationManager
288+
289+
bindings_path = tmp_path / "bindings.json"
290+
bindings_path.write_text("{}")
291+
monkeypatch.setattr(
292+
ConfigurationManager,
293+
"bindings_file_path",
294+
property(lambda self: bindings_path),
295+
)
296+
monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-from-env")
297+
298+
response = MagicMock()
299+
response.json.return_value = {}
300+
request_mock = AsyncMock(return_value=response)
301+
studio_client.uipath.api_client.request_async = request_mock
302+
303+
await studio_client.get_resource_overwrites()
304+
305+
# The header carrying the tenant id should reflect the env var value.
306+
call_kwargs = request_mock.await_args.kwargs
307+
headers = call_kwargs["headers"]
308+
assert any(value == "tenant-from-env" for value in headers.values()), headers

0 commit comments

Comments
 (0)