Skip to content

Commit 2c18dfa

Browse files
fix: use new header for folder paths containing non-ascii chars (#1451)
1 parent 9848d16 commit 2c18dfa

11 files changed

Lines changed: 173 additions & 66 deletions

File tree

packages/uipath-platform/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-platform"
3-
version = "0.0.29"
3+
version = "0.1.0"
44
description = "HTTP client library for programmatic access to UiPath Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,10 @@
99
from ..common._bindings import resource_override
1010
from ..common._config import UiPathApiConfig, UiPathConfig
1111
from ..common._execution_context import UiPathExecutionContext
12-
from ..common._folder_context import FolderContext
12+
from ..common._folder_context import FolderContext, header_folder
1313
from ..common._models import Endpoint, RequestSpec
1414
from ..common.constants import (
1515
ENV_TENANT_ID,
16-
HEADER_FOLDER_KEY,
17-
HEADER_FOLDER_PATH,
1816
HEADER_TENANT_ID,
1917
)
2018
from .task_schema import TaskSchema
@@ -162,7 +160,7 @@ def _create_spec(
162160
method="POST",
163161
endpoint=Endpoint("/orchestrator_/tasks/AppTasks/CreateAppTask"),
164162
json=json_payload,
165-
headers=folder_headers(app_folder_key, app_folder_path),
163+
headers=header_folder(app_folder_key, app_folder_path),
166164
)
167165

168166

@@ -199,13 +197,15 @@ def _normalize_priority(priority: str | None) -> str | None:
199197

200198

201199
def _retrieve_action_spec(
202-
action_key: str, app_folder_key: str, app_folder_path: str
200+
action_key: str,
201+
app_folder_key: Optional[str],
202+
app_folder_path: Optional[str],
203203
) -> RequestSpec:
204204
return RequestSpec(
205205
method="GET",
206206
endpoint=Endpoint("/orchestrator_/tasks/GenericTasks/GetTaskDataByKey"),
207207
params={"taskKey": action_key},
208-
headers=folder_headers(app_folder_key, app_folder_path),
208+
headers=header_folder(app_folder_key, app_folder_path),
209209
)
210210

211211

@@ -317,17 +317,6 @@ def _retrieve_app_key_spec(app_name: str) -> RequestSpec:
317317
)
318318

319319

320-
def folder_headers(
321-
app_folder_key: Optional[str], app_folder_path: Optional[str]
322-
) -> Dict[str, str]:
323-
headers = {}
324-
if app_folder_key:
325-
headers[HEADER_FOLDER_KEY] = app_folder_key
326-
elif app_folder_path:
327-
headers[HEADER_FOLDER_PATH] = app_folder_path
328-
return headers
329-
330-
331320
class TasksService(FolderContext, BaseService):
332321
"""Service for managing UiPath Action Center tasks.
333322
@@ -526,8 +515,8 @@ def create(
526515
def retrieve(
527516
self,
528517
action_key: str,
529-
app_folder_path: str = "",
530-
app_folder_key: str = "",
518+
app_folder_path: Optional[str] = None,
519+
app_folder_key: Optional[str] = None,
531520
app_name: str | None = None,
532521
) -> Task:
533522
"""Retrieves a task by its key synchronously.
@@ -560,8 +549,8 @@ def retrieve(
560549
async def retrieve_async(
561550
self,
562551
action_key: str,
563-
app_folder_path: str = "",
564-
app_folder_key: str = "",
552+
app_folder_path: Optional[str] = None,
553+
app_folder_key: Optional[str] = None,
565554
app_name: str | None = None,
566555
) -> Task:
567556
"""Retrieves a task by its key asynchronously.

packages/uipath-platform/src/uipath/platform/common/_folder_context.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from base64 import b64encode
12
from os import environ as env
23
from typing import Any, Optional
34

@@ -6,9 +7,25 @@
67
ENV_FOLDER_PATH,
78
HEADER_FOLDER_KEY,
89
HEADER_FOLDER_PATH,
10+
HEADER_FOLDER_PATH_ENCODED,
911
)
1012

1113

14+
def folder_path_header(folder_path: str) -> dict[str, str]:
15+
"""Return the appropriate folder path header.
16+
17+
Uses the encoded header variant when the path contains non-ASCII
18+
characters, since HTTP headers require ASCII values. The Orchestrator
19+
expects Base64(UTF-16LE) in the encoded header.
20+
"""
21+
try:
22+
folder_path.encode("ascii")
23+
return {HEADER_FOLDER_PATH: folder_path}
24+
except UnicodeEncodeError:
25+
encoded = b64encode(folder_path.encode("utf-16-le")).decode("ascii")
26+
return {HEADER_FOLDER_PATH_ENCODED: encoded}
27+
28+
1229
def header_folder(
1330
folder_key: Optional[str], folder_path: Optional[str]
1431
) -> dict[str, str]:
@@ -19,7 +36,7 @@ def header_folder(
1936
if folder_key is not None and folder_key != "":
2037
headers[HEADER_FOLDER_KEY] = folder_key
2138
if folder_path is not None and folder_path != "":
22-
headers[HEADER_FOLDER_PATH] = folder_path
39+
headers.update(folder_path_header(folder_path))
2340

2441
return headers
2542

@@ -63,6 +80,6 @@ def folder_headers(self) -> dict[str, str]:
6380
if self._folder_key is not None:
6481
return {HEADER_FOLDER_KEY: self._folder_key}
6582
elif self._folder_path is not None:
66-
return {HEADER_FOLDER_PATH: self._folder_path}
83+
return folder_path_header(self._folder_path)
6784
else:
6885
return {}

packages/uipath-platform/src/uipath/platform/common/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
# Headers
2828
HEADER_FOLDER_KEY = "x-uipath-folderkey"
2929
HEADER_FOLDER_PATH = "x-uipath-folderpath"
30+
HEADER_FOLDER_PATH_ENCODED = "x-uipath-folderpath-encoded"
3031
HEADER_USER_AGENT = "x-uipath-user-agent"
3132
HEADER_TENANT_ID = "x-uipath-tenantid"
3233
HEADER_INTERNAL_TENANT_ID = "x-uipath-internal-tenantid"

packages/uipath-platform/tests/services/test_actions_service.py

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -369,39 +369,6 @@ def test_create_filters_by_app_folder_path(
369369

370370
assert isinstance(task, Task)
371371

372-
def test_create_folder_path_takes_priority_over_folder_key(
373-
self,
374-
httpx_mock: HTTPXMock,
375-
config: UiPathApiConfig,
376-
execution_context: UiPathExecutionContext,
377-
monkeypatch: pytest.MonkeyPatch,
378-
base_url: str,
379-
org: str,
380-
tenant: str,
381-
) -> None:
382-
monkeypatch.setenv("UIPATH_TENANT_ID", "test-tenant-id")
383-
tasks_service = self._make_tasks_service(config, execution_context, monkeypatch)
384-
self._mock_app_schemas_response(
385-
httpx_mock,
386-
base_url,
387-
org,
388-
"my-app",
389-
[
390-
_make_deployed_app("my-app", "folder-a", "key-a"),
391-
_make_deployed_app("my-app", "folder-b", "key-b"),
392-
],
393-
)
394-
self._mock_create_task_response(httpx_mock, base_url, org, tenant)
395-
396-
task = tasks_service.create(
397-
title="Test",
398-
app_name="my-app",
399-
app_folder_path="folder-a",
400-
app_folder_key="key-b",
401-
)
402-
403-
assert isinstance(task, Task)
404-
405372
def test_create_falls_back_to_env_folder_path(
406373
self,
407374
httpx_mock: HTTPXMock,
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Tests for folder path header encoding.
2+
3+
Non-ASCII characters in folder paths must be Base64-encoded (UTF-16LE)
4+
and sent via the X-UIPATH-FolderPath-Encoded header, since HTTP headers
5+
require ASCII values. The Orchestrator decodes this with
6+
Convert.FromBase64String + Encoding.Unicode (UTF-16LE).
7+
"""
8+
9+
from base64 import b64decode
10+
11+
import pytest
12+
13+
from uipath.platform.common._folder_context import (
14+
FolderContext,
15+
folder_path_header,
16+
header_folder,
17+
)
18+
from uipath.platform.common.constants import (
19+
HEADER_FOLDER_KEY,
20+
HEADER_FOLDER_PATH,
21+
HEADER_FOLDER_PATH_ENCODED,
22+
)
23+
24+
# --- folder_path_header() ---
25+
26+
27+
class TestFolderPathHeader:
28+
def test_ascii_path_uses_plain_header(self) -> None:
29+
headers = folder_path_header("MyFolder/SubFolder")
30+
assert headers == {HEADER_FOLDER_PATH: "MyFolder/SubFolder"}
31+
32+
def test_non_ascii_path_uses_encoded_header(self) -> None:
33+
path = "VA\xa0Certificate"
34+
headers = folder_path_header(path)
35+
assert HEADER_FOLDER_PATH not in headers
36+
assert HEADER_FOLDER_PATH_ENCODED in headers
37+
38+
def test_encoded_value_is_base64_utf16le(self) -> None:
39+
path = "Debug_Poétry Writer"
40+
headers = folder_path_header(path)
41+
value = headers[HEADER_FOLDER_PATH_ENCODED]
42+
decoded = b64decode(value).decode("utf-16-le")
43+
assert decoded == path
44+
45+
def test_encoded_value_is_ascii_safe(self) -> None:
46+
headers = folder_path_header("VA\xa0Certificate/Poétry")
47+
headers[HEADER_FOLDER_PATH_ENCODED].encode("ascii")
48+
49+
def test_round_trip_with_non_breaking_space(self) -> None:
50+
path = "VA\xa0Certificate of Eligibility Agent"
51+
headers = folder_path_header(path)
52+
decoded = b64decode(headers[HEADER_FOLDER_PATH_ENCODED]).decode("utf-16-le")
53+
assert decoded == path
54+
55+
56+
# --- header_folder() ---
57+
58+
59+
class TestHeaderFolder:
60+
def test_ascii_folder_path(self) -> None:
61+
headers = header_folder(None, "MyFolder/SubFolder")
62+
assert headers == {HEADER_FOLDER_PATH: "MyFolder/SubFolder"}
63+
64+
def test_non_ascii_folder_path_uses_encoded_header(self) -> None:
65+
headers = header_folder(None, "VA\xa0Certificate")
66+
assert HEADER_FOLDER_PATH not in headers
67+
assert HEADER_FOLDER_PATH_ENCODED in headers
68+
69+
def test_folder_key_returned_as_is(self) -> None:
70+
headers = header_folder("some-uuid-key", None)
71+
assert headers == {HEADER_FOLDER_KEY: "some-uuid-key"}
72+
73+
def test_none_path_and_key_returns_empty(self) -> None:
74+
assert header_folder(None, None) == {}
75+
76+
def test_both_key_and_path_raises(self) -> None:
77+
with pytest.raises(ValueError):
78+
header_folder("key", "path")
79+
80+
81+
# --- FolderContext.folder_headers ---
82+
83+
84+
class TestFolderContextHeaders:
85+
def test_non_ascii_folder_path_uses_encoded_header(
86+
self, monkeypatch: pytest.MonkeyPatch
87+
) -> None:
88+
path = "VA\xa0Certificate"
89+
monkeypatch.setenv("UIPATH_FOLDER_PATH", path)
90+
monkeypatch.delenv("UIPATH_FOLDER_KEY", raising=False)
91+
ctx = FolderContext()
92+
headers = ctx.folder_headers
93+
assert HEADER_FOLDER_PATH not in headers
94+
value = headers[HEADER_FOLDER_PATH_ENCODED]
95+
value.encode("ascii")
96+
assert b64decode(value).decode("utf-16-le") == path
97+
98+
def test_ascii_folder_path_uses_plain_header(
99+
self, monkeypatch: pytest.MonkeyPatch
100+
) -> None:
101+
monkeypatch.setenv("UIPATH_FOLDER_PATH", "my-folder")
102+
monkeypatch.delenv("UIPATH_FOLDER_KEY", raising=False)
103+
ctx = FolderContext()
104+
assert ctx.folder_headers == {HEADER_FOLDER_PATH: "my-folder"}
105+
106+
def test_folder_key_takes_precedence(self, monkeypatch: pytest.MonkeyPatch) -> None:
107+
monkeypatch.setenv("UIPATH_FOLDER_KEY", "my-key")
108+
monkeypatch.setenv("UIPATH_FOLDER_PATH", "my-path")
109+
ctx = FolderContext()
110+
assert ctx.folder_headers == {HEADER_FOLDER_KEY: "my-key"}
111+
112+
def test_no_env_vars_returns_empty(self, monkeypatch: pytest.MonkeyPatch) -> None:
113+
monkeypatch.delenv("UIPATH_FOLDER_KEY", raising=False)
114+
monkeypatch.delenv("UIPATH_FOLDER_PATH", raising=False)
115+
ctx = FolderContext()
116+
assert ctx.folder_headers == {}

packages/uipath-platform/uv.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/uipath/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
[project]
22
name = "uipath"
3-
version = "2.10.20"
3+
version = "2.10.21"
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"
77
dependencies = [
88
"uipath-core>=0.5.2, <0.6.0",
99
"uipath-runtime>=0.9.1, <0.10.0",
10-
"uipath-platform>=0.0.29, <0.1.0",
10+
"uipath-platform>=0.1.0, <0.2.0",
1111
"click>=8.3.1",
1212
"httpx>=0.28.1",
1313
"pyjwt>=2.10.1",

packages/uipath/src/uipath/_utils/_request_override.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
1+
from base64 import b64encode
12
from typing import Optional
23

3-
from .constants import HEADER_FOLDER_KEY, HEADER_FOLDER_PATH
4+
from .constants import HEADER_FOLDER_KEY, HEADER_FOLDER_PATH, HEADER_FOLDER_PATH_ENCODED
5+
6+
7+
def folder_path_header(folder_path: str) -> dict[str, str]:
8+
"""Return the appropriate folder path header.
9+
10+
Uses the encoded header variant when the path contains non-ASCII
11+
characters, since HTTP headers require ASCII values. The Orchestrator
12+
expects Base64(UTF-16LE) in the encoded header.
13+
"""
14+
try:
15+
folder_path.encode("ascii")
16+
return {HEADER_FOLDER_PATH: folder_path}
17+
except UnicodeEncodeError:
18+
encoded = b64encode(folder_path.encode("utf-16-le")).decode("ascii")
19+
return {HEADER_FOLDER_PATH_ENCODED: encoded}
420

521

622
def header_folder(
@@ -13,6 +29,6 @@ def header_folder(
1329
if folder_key is not None and folder_key != "":
1430
headers[HEADER_FOLDER_KEY] = folder_key
1531
if folder_path is not None and folder_path != "":
16-
headers[HEADER_FOLDER_PATH] = folder_path
32+
headers.update(folder_path_header(folder_path))
1733

1834
return headers

packages/uipath/src/uipath/_utils/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
HEADER_AGENTHUB_CONFIG = "x-uipath-agenthub-config"
2626
HEADER_FOLDER_KEY = "x-uipath-folderkey"
2727
HEADER_FOLDER_PATH = "x-uipath-folderpath"
28+
HEADER_FOLDER_PATH_ENCODED = "x-uipath-folderpath-encoded"
2829
HEADER_INTERNAL_ACCOUNT_ID = "x-uipath-internal-accountid"
2930
HEADER_INTERNAL_TENANT_ID = "x-uipath-internal-tenantid"
3031
HEADER_JOB_KEY = "x-uipath-jobkey"

0 commit comments

Comments
 (0)