diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index a62398e5b..a0d332607 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.64" +version = "0.1.65" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py b/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py index fc97da511..eff861d8d 100644 --- a/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py +++ b/packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py @@ -5,7 +5,7 @@ from uipath.core.tracing.span_utils import UiPathSpanUtils from ..common._config import UiPathConfig -from ..common._span_utils import _SpanUtils +from ..common._span_utils import _SpanUtils, resolve_id def build_trace_context_headers( @@ -40,8 +40,8 @@ def build_trace_context_headers( baggage_parts: list[str] = list(extra_baggage) if extra_baggage else [] if folder_key := UiPathConfig.folder_key: baggage_parts.append(f"folderKey={folder_key}") - if agent_id := UiPathConfig.agent_id: - baggage_parts.append(f"agentId={agent_id}") + if id := resolve_id(): + baggage_parts.append(f"agentId={id}") if process_key := UiPathConfig.process_key: baggage_parts.append(f"processKey={process_key}") if baggage_parts: diff --git a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py index ab91b3623..b9cde457d 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, field from datetime import datetime from enum import IntEnum +from functools import lru_cache from os import environ as env from typing import Any, Dict, List, Optional @@ -19,6 +20,31 @@ DEFAULT_SOURCE = 10 +@lru_cache(maxsize=1) +def _read_config_id() -> Optional[str]: + """Return ``id`` from ``uipath.json``, cached for the process lifetime.""" + from uipath.platform.common._config import UiPathConfig + + try: + with open(UiPathConfig.config_file_path, "r") as f: + id = json.load(f).get("id") + except (OSError, json.JSONDecodeError): + return None + + return id if isinstance(id, str) and id else None + + +def resolve_id() -> Optional[str]: + """Resolve the id: ``uipath.json#id`` then ``UIPATH_PROCESS_UUID``. + + Single resolver for every consumer (span attribute, trace baggage, eval + telemetry) so they cannot diverge. + """ + from uipath.platform.common._config import UiPathConfig + + return _read_config_id() or UiPathConfig.process_uuid + + class AttachmentProvider(IntEnum): ORCHESTRATOR = 0 @@ -281,9 +307,12 @@ def otel_span_to_uipath_span( ] attributes_dict["links"] = links_list + id = resolve_id() + if id: + attributes_dict["agentId"] = id + # Add process context attributes from environment variables for env_key, attr_key in ( - ("PROJECT_KEY", "agentId"), ("UIPATH_PROCESS_KEY", "agentName"), ("UIPATH_PROCESS_VERSION", "agentVersion"), ): @@ -297,10 +326,8 @@ def otel_span_to_uipath_span( # Top-level fields for internal tracing schema execution_type = attributes_dict.get("executionType") agent_version = attributes_dict.get("agentVersion") - reference_id = ( - env.get("UIPATH_AGENT_ID") - or attributes_dict.get("agentId") - or attributes_dict.get("referenceId") + reference_id = attributes_dict.get("agentId") or attributes_dict.get( + "referenceId" ) verbosity_level = attributes_dict.get("verbosityLevel") diff --git a/packages/uipath-platform/tests/services/test_llm_trace_context.py b/packages/uipath-platform/tests/services/test_llm_trace_context.py index 8db61200b..04dde9634 100644 --- a/packages/uipath-platform/tests/services/test_llm_trace_context.py +++ b/packages/uipath-platform/tests/services/test_llm_trace_context.py @@ -110,13 +110,16 @@ class TestBaggageHeader: """When enabled, x-uipath-tracebaggage is populated from UiPathConfig.""" def setup_method(self) -> None: + from uipath.platform.common._span_utils import _read_config_id + + _read_config_id.cache_clear() FeatureFlags.reset_flags() FeatureFlags.configure_flags({FEATURE_FLAG: True}) def test_all_env_vars_present(self) -> None: env = { "UIPATH_FOLDER_KEY": "folder-abc", - "UIPATH_AGENT_ID": "agent-123", + "UIPATH_PROCESS_UUID": "agent-123", "UIPATH_PROCESS_KEY": "process-789", } with patch.dict(os.environ, env, clear=True): @@ -135,22 +138,14 @@ def test_partial_env_vars(self) -> None: baggage = headers["x-uipath-tracebaggage"] assert "folderKey=folder-only" in baggage - def test_agent_id_from_agent_id_env(self) -> None: - env = {"UIPATH_AGENT_ID": "real-agent-id"} + def test_agent_id_from_process_uuid_env(self) -> None: + env = {"UIPATH_PROCESS_UUID": "real-agent-id"} with patch.dict(os.environ, env, clear=True): headers = build_trace_context_headers() baggage = headers["x-uipath-tracebaggage"] assert "agentId=real-agent-id" in baggage - def test_agent_id_falls_back_to_project_id(self) -> None: - env = {"UIPATH_PROJECT_ID": "project-123"} - with patch.dict(os.environ, env, clear=True): - headers = build_trace_context_headers() - - baggage = headers["x-uipath-tracebaggage"] - assert "agentId=project-123" in baggage - def test_no_agent_id_without_env_vars(self) -> None: env = {"UIPATH_FOLDER_KEY": "f1"} with patch.dict(os.environ, env, clear=True): @@ -169,7 +164,7 @@ def test_no_baggage_without_env_vars(self) -> None: def test_baggage_comma_separated(self) -> None: env = { "UIPATH_FOLDER_KEY": "f1", - "UIPATH_AGENT_ID": "a1", + "UIPATH_PROCESS_UUID": "a1", } with patch.dict(os.environ, env, clear=True): headers = build_trace_context_headers() diff --git a/packages/uipath-platform/tests/services/test_span_utils.py b/packages/uipath-platform/tests/services/test_span_utils.py index 03f728eb8..7839a4263 100644 --- a/packages/uipath-platform/tests/services/test_span_utils.py +++ b/packages/uipath-platform/tests/services/test_span_utils.py @@ -10,6 +10,16 @@ from uipath.platform.common import UiPathSpan, _SpanUtils +@pytest.fixture(autouse=True) +def _clear_id_cache(): + """Isolate the process-global id cache between tests.""" + from uipath.platform.common._span_utils import _read_config_id + + _read_config_id.cache_clear() + yield + _read_config_id.cache_clear() + + class TestOTelToUiPathSpan: """OTEL attribute -> top-level UiPathSpan field mapping. @@ -92,10 +102,11 @@ def test_verbosity_level_omitted_when_unset(self) -> None: class TestReferenceIdResolution: """`reference_id` resolution chain. - Priority: `UIPATH_AGENT_ID` env var > `agentId` attribute > `referenceId` - attribute. Falsy values (missing / empty string) at each step fall through - to the next source. The `referenceId` fallback exists for backwards - compatibility with older producers that only emit that attribute. + `reference_id` is derived from the span's resolved `agentId` attribute + (which itself goes through `resolve_id()`), falling back to the + `referenceId` attribute. Falsy values (missing / empty string) at each step + fall through to the next source. The `referenceId` fallback exists for + backwards compatibility with older producers that only emit that attribute. """ @pytest.mark.parametrize( @@ -105,7 +116,7 @@ class TestReferenceIdResolution: "env-agent", {"agentId": "attr-agent", "referenceId": "attr-ref"}, "env-agent", - id="env-var-wins", + id="env-var-overrides-attr", ), pytest.param( None, @@ -140,10 +151,13 @@ def test_reference_id_chain( expected: str | None, monkeypatch: pytest.MonkeyPatch, ) -> None: + from uipath.platform.common._span_utils import _read_config_id + + _read_config_id.cache_clear() if env_value is None: - monkeypatch.delenv("UIPATH_AGENT_ID", raising=False) + monkeypatch.delenv("UIPATH_PROCESS_UUID", raising=False) else: - monkeypatch.setenv("UIPATH_AGENT_ID", env_value) + monkeypatch.setenv("UIPATH_PROCESS_UUID", env_value) mock_span = Mock(spec=OTelSpan) mock_context = SpanContext( @@ -166,6 +180,96 @@ def test_reference_id_chain( assert uipath_span.reference_id == expected +class TestAgentIdResolution: + """`agentId` span attribute resolution via `resolve_id()`. + + Priority: `uipath.json#id` (cached, read once per process) > + `UIPATH_PROCESS_UUID` env var injected by the executor at runtime. When no + source is present the `agentId` attribute is omitted entirely. + """ + + @staticmethod + def _make_span() -> Mock: + mock_span = Mock(spec=OTelSpan) + mock_context = SpanContext( + trace_id=0x123456789ABCDEF0123456789ABCDEF0, + span_id=0x0123456789ABCDEF, + is_remote=False, + ) + mock_span.get_span_context.return_value = mock_context + mock_span.name = "test-span" + mock_span.parent = None + mock_span.status.status_code = StatusCode.OK + mock_span.attributes = {} + mock_span.events = [] + mock_span.links = [] + now_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = now_ns + mock_span.end_time = now_ns + 1_000_000 + return mock_span + + @staticmethod + def _resolve(monkeypatch: pytest.MonkeyPatch, tmp_path) -> object: + from uipath.platform.common._span_utils import _read_config_id + + _read_config_id.cache_clear() + monkeypatch.delenv("UIPATH_CONFIG_PATH", raising=False) + monkeypatch.chdir(tmp_path) + uipath_span = _SpanUtils.otel_span_to_uipath_span( + TestAgentIdResolution._make_span(), serialize_attributes=False + ) + attributes = uipath_span.attributes + assert isinstance(attributes, dict) + return attributes.get("agentId") + + def test_agent_id_from_uipath_json_wins_over_process_uuid( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + (tmp_path / "uipath.json").write_text(json.dumps({"id": "from-config"})) + monkeypatch.setenv("UIPATH_PROCESS_UUID", "from-env") + assert self._resolve(monkeypatch, tmp_path) == "from-config" + + def test_agent_id_falls_back_to_process_uuid( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + # No uipath.json on disk. + monkeypatch.setenv("UIPATH_PROCESS_UUID", "from-env") + assert self._resolve(monkeypatch, tmp_path) == "from-env" + + def test_agent_id_falls_back_when_config_has_no_id( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + (tmp_path / "uipath.json").write_text(json.dumps({"functions": {}})) + monkeypatch.setenv("UIPATH_PROCESS_UUID", "from-env") + assert self._resolve(monkeypatch, tmp_path) == "from-env" + + def test_agent_id_absent_when_no_source( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + monkeypatch.delenv("UIPATH_PROCESS_UUID", raising=False) + assert self._resolve(monkeypatch, tmp_path) is None + + def test_config_id_is_cached( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + from uipath.platform.common._span_utils import _read_config_id + + _read_config_id.cache_clear() + monkeypatch.delenv("UIPATH_CONFIG_PATH", raising=False) + monkeypatch.chdir(tmp_path) + config = tmp_path / "uipath.json" + + config.write_text(json.dumps({"id": "first"})) + assert _read_config_id() == "first" + + # A later edit is not observed: the value is read once and cached. + config.write_text(json.dumps({"id": "second"})) + assert _read_config_id() == "first" + + _read_config_id.cache_clear() + assert _read_config_id() == "second" + + class TestNormalizeIds: """Tests for OTEL ID normalization functions.""" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 3d9ac6c79..689662d4d 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.64" +version = "0.1.65" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index ffaa7f881..4d24b0cf0 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.10.81" +version = "2.10.82" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.17, <0.6.0", "uipath-runtime>=0.11.0, <0.12.0", - "uipath-platform>=0.1.63, <0.2.0", + "uipath-platform>=0.1.65, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/src/uipath/_cli/_evals/_telemetry.py b/packages/uipath/src/uipath/_cli/_evals/_telemetry.py index 04cb7e2c4..84c2bb4df 100644 --- a/packages/uipath/src/uipath/_cli/_evals/_telemetry.py +++ b/packages/uipath/src/uipath/_cli/_evals/_telemetry.py @@ -308,10 +308,12 @@ def _enrich_properties(self, properties: dict[str, Any]) -> None: Args: properties: The properties dictionary to enrich. """ + from uipath.platform.common._span_utils import resolve_id + if UiPathConfig.project_id: properties["ProjectId"] = UiPathConfig.project_id - if UiPathConfig.agent_id: - properties["AgentId"] = UiPathConfig.agent_id + if id := resolve_id(): + properties["AgentId"] = id if UiPathConfig.organization_id: properties["CloudOrganizationId"] = UiPathConfig.organization_id diff --git a/packages/uipath/src/uipath/_cli/_utils/_project_files.py b/packages/uipath/src/uipath/_cli/_utils/_project_files.py index c7c025197..15f5e53c5 100644 --- a/packages/uipath/src/uipath/_cli/_utils/_project_files.py +++ b/packages/uipath/src/uipath/_cli/_utils/_project_files.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Any, AsyncIterator, Dict, Literal, Optional, Tuple +import anyio from pydantic import BaseModel, Field, TypeAdapter from uipath._cli.models.uipath_json_schema import PackOptions, UiPathJsonConfig @@ -25,6 +26,34 @@ logger = logging.getLogger(__name__) +def resolve_existing_project_id(directory: str = ".") -> Optional[str]: + """Return an already-established project id for this project, if any. + + Checks the Studio Web project env var first, then falls back to the legacy + ``ProjectKey`` stored in ``.uipath/.telemetry.json``. Returns ``None`` when + neither is present. + + Args: + directory: The project root directory to look for the telemetry file in. + """ + from ...telemetry._constants import _PROJECT_KEY, _TELEMETRY_CONFIG_FILE + + if project_id := UiPathConfig.project_id: + return project_id + + telemetry_file = os.path.join(directory, ".uipath", _TELEMETRY_CONFIG_FILE) + if os.path.exists(telemetry_file): + try: + with open(telemetry_file, "r") as f: + telemetry_data = json.load(f) + if project_id := telemetry_data.get(_PROJECT_KEY): + return project_id + except (json.JSONDecodeError, IOError): + pass + + return None + + class Severity(IntEnum): LOG = 0 WARNING = 1 @@ -592,21 +621,21 @@ async def download_folder_files( collect_files_from_folder(folder, "", files_dict) for file_path, remote_file in files_dict.items(): - local_path = base_path / file_path - local_path.parent.mkdir(parents=True, exist_ok=True) + local_path = anyio.Path(base_path / file_path) + await local_path.parent.mkdir(parents=True, exist_ok=True) response = await studio_client.download_project_file_async(remote_file) remote_content = response.read().decode("utf-8") remote_hash = compute_normalized_hash(remote_content) - if os.path.exists(local_path): - with open(local_path, "r", encoding="utf-8") as f: - local_content = f.read() - local_hash = compute_normalized_hash(local_content) + if await local_path.exists(): + local_content = await local_path.read_text(encoding="utf-8") + local_hash = compute_normalized_hash(local_content) if local_hash != remote_hash: - with open(local_path, "w", encoding="utf-8", newline="\n") as f: - f.write(remote_content) + await local_path.write_text( + remote_content, encoding="utf-8", newline="\n" + ) yield UpdateEvent( file_path=file_path, @@ -620,8 +649,7 @@ async def download_folder_files( message=f"File '{file_path}' is up to date", ) else: - with open(local_path, "w", encoding="utf-8", newline="\n") as f: - f.write(remote_content) + await local_path.write_text(remote_content, encoding="utf-8", newline="\n") yield UpdateEvent( file_path=file_path, diff --git a/packages/uipath/src/uipath/_cli/cli_init.py b/packages/uipath/src/uipath/_cli/cli_init.py index 80396d8ff..272e39746 100644 --- a/packages/uipath/src/uipath/_cli/cli_init.py +++ b/packages/uipath/src/uipath/_cli/cli_init.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Any +import anyio import click from graphtty import RenderOptions, render from graphtty.themes import TOKYO_NIGHT @@ -36,7 +37,7 @@ from ._utils._common import determine_project_type from ._utils._console import ConsoleLogger from ._utils._constants import AGENT_INITIAL_CODE_VERSION, SCHEMA_VERSION -from ._utils._project_files import read_toml_project +from ._utils._project_files import read_toml_project, resolve_existing_project_id from .middlewares import Middlewares from .models.runtime_schema import Bindings, EntryPoint from .models.uipath_json_schema import UiPathJsonConfig @@ -429,10 +430,25 @@ async def initialize() -> list[UiPathRuntimeSchema]: config_path = UiPathConfig.config_file_path if not config_path.exists(): config = UiPathJsonConfig.create_default() + config.id = resolve_existing_project_id(current_directory) or str( + uuid.uuid4() + ) config.save_to_file(config_path) console.success(f"{Action.CREATED.value} '{config_path}' file.") else: - console.info(f"'{config_path}' already exists, skipping.") + # backfill id if not present + async_config_path = anyio.Path(config_path) + raw_config = json.loads(await async_config_path.read_text()) + if not raw_config.get("id"): + raw_config["id"] = resolve_existing_project_id( + current_directory + ) or str(uuid.uuid4()) + await async_config_path.write_text( + json.dumps(raw_config, indent=2) + ) + console.success( + f"{Action.UPDATED.value} '{config_path}' file with 'id'." + ) # Create bindings.json if it doesn't exist bindings_path = UiPathConfig.bindings_file_path diff --git a/packages/uipath/src/uipath/_cli/cli_pack.py b/packages/uipath/src/uipath/_cli/cli_pack.py index 83cedf870..bc988afcd 100644 --- a/packages/uipath/src/uipath/_cli/cli_pack.py +++ b/packages/uipath/src/uipath/_cli/cli_pack.py @@ -8,11 +8,10 @@ from pydantic import TypeAdapter from uipath._cli.models.runtime_schema import Bindings, EntryPoint, EntryPoints -from uipath._cli.models.uipath_json_schema import RuntimeOptions, UiPathJsonConfig +from uipath._cli.models.uipath_json_schema import UiPathJsonConfig from uipath.eval.constants import EVALS_FOLDER, LEGACY_EVAL_FOLDER from uipath.platform.common import UiPathConfig -from ..telemetry._constants import _PROJECT_KEY, _TELEMETRY_CONFIG_FILE from ._telemetry import track_command from ._utils._common import determine_project_type from ._utils._console import ConsoleLogger @@ -21,6 +20,7 @@ files_to_include, get_project_config, read_toml_project, + resolve_existing_project_id, validate_config, ) from ._utils._uv_helpers import handle_uv_operations @@ -30,31 +30,6 @@ schema = "https://cloud.uipath.com/draft/2024-12/entry-point" -def get_project_id() -> str: - """Get project ID from telemetry file if it exists, otherwise generate a new one. - - Returns: - Project ID string (either from telemetry file or newly generated). - """ - # first check if this is a studio project - if project_id := UiPathConfig.project_id: - return project_id - - telemetry_file = os.path.join(".uipath", _TELEMETRY_CONFIG_FILE) - - if os.path.exists(telemetry_file): - try: - with open(telemetry_file, "r") as f: - telemetry_data = json.load(f) - project_id = telemetry_data.get(_PROJECT_KEY) - if project_id: - return project_id - except (json.JSONDecodeError, IOError): - pass - - return str(uuid.uuid4()) - - def get_project_version(directory): toml_path = os.path.join(directory, "pyproject.toml") if not os.path.exists(toml_path): @@ -72,14 +47,21 @@ def validate_config_structure(config_data): def generate_operate_file( - entrypoints: list[EntryPoint], runtimeOptions: RuntimeOptions, dependencies=None + entrypoints: list[EntryPoint], + config: UiPathJsonConfig, + dependencies=None, + directory: str = ".", ): if not entrypoints: raise ValueError( "No entry points found in entry-points.json. Please run 'uipath init' to generate valid entry points." ) - project_id = get_project_id() + # prefer id from uipath.json; fall back to the legacy + # .telemetry.json or SW project id. + project_id = ( + config.id or resolve_existing_project_id(directory) or str(uuid.uuid4()) + ) project_type = determine_project_type(entrypoints) first_entry = entrypoints[0] @@ -94,7 +76,7 @@ def generate_operate_file( "runtimeOptions": { "requiresUserInteraction": False, "isAttended": False, - "isConversational": runtimeOptions.is_conversational, + "isConversational": config.runtime_options.is_conversational, }, } @@ -239,7 +221,7 @@ def pack_fn( config_data = TypeAdapter(UiPathJsonConfig).validate_python(json.load(f)) operate_file = generate_operate_file( - entrypoints, config_data.runtime_options, dependencies + entrypoints, config_data, dependencies, directory ) # try to read bindings from bindings.json diff --git a/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py b/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py index f1cd30202..4dd2f6700 100644 --- a/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py +++ b/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py @@ -68,6 +68,12 @@ class UiPathJsonConfig(BaseModelWithDefaultConfig): alias="$schema", description="Reference to the JSON schema for editor support", ) + id: str | None = Field( + default=None, + description="Stable unique identifier for the agent. Minted once at " + "project creation (by 'uipath init' or Studio Web) and preserved for the " + "lifetime of the project. Used as the package 'projectId' at pack time.", + ) runtime_options: RuntimeOptions = Field( default_factory=RuntimeOptions, alias="runtimeOptions", diff --git a/packages/uipath/tests/cli/eval/test_eval_telemetry.py b/packages/uipath/tests/cli/eval/test_eval_telemetry.py index 48911638a..6492fc2c9 100644 --- a/packages/uipath/tests/cli/eval/test_eval_telemetry.py +++ b/packages/uipath/tests/cli/eval/test_eval_telemetry.py @@ -422,6 +422,10 @@ def test_enrich_properties_adds_env_vars(self, mock_get_claim): """Test that environment variables are added when present.""" mock_get_claim.return_value = "user-789" + from uipath.platform.common._span_utils import _read_config_id + + _read_config_id.cache_clear() + subscriber = EvalTelemetrySubscriber() properties: dict[str, Any] = {} @@ -429,6 +433,7 @@ def test_enrich_properties_adds_env_vars(self, mock_get_claim): os.environ, { "UIPATH_PROJECT_ID": "project-123", + "UIPATH_PROCESS_UUID": "agent-123", "UIPATH_ORGANIZATION_ID": "org-456", "UIPATH_TENANT_ID": "tenant-abc", "UIPATH_EVAL_RUN_SOURCE": "FirstSuccessfulRun", @@ -437,7 +442,7 @@ def test_enrich_properties_adds_env_vars(self, mock_get_claim): subscriber._enrich_properties(properties) assert properties["ProjectId"] == "project-123" - assert properties["AgentId"] == "project-123" + assert properties["AgentId"] == "agent-123" assert properties["CloudOrganizationId"] == "org-456" assert properties["CloudUserId"] == "user-789" assert properties["TenantId"] == "tenant-abc" @@ -448,6 +453,10 @@ def test_enrich_properties_skips_missing_env_vars(self, mock_get_claim): """Test that missing environment variables are not added.""" mock_get_claim.side_effect = Exception("No token") + from uipath.platform.common._span_utils import _read_config_id + + _read_config_id.cache_clear() + subscriber = EvalTelemetrySubscriber() properties: dict[str, Any] = {} @@ -455,6 +464,7 @@ def test_enrich_properties_skips_missing_env_vars(self, mock_get_claim): # Remove env vars if they exist for key in [ "UIPATH_PROJECT_ID", + "UIPATH_PROCESS_UUID", "UIPATH_ORGANIZATION_ID", "UIPATH_TENANT_ID", "UIPATH_EVAL_RUN_SOURCE", diff --git a/packages/uipath/tests/cli/test_init.py b/packages/uipath/tests/cli/test_init.py index 59d4eaaa8..bd5a92ce6 100644 --- a/packages/uipath/tests/cli/test_init.py +++ b/packages/uipath/tests/cli/test_init.py @@ -1,5 +1,6 @@ import json import os +import uuid from unittest.mock import patch import pytest @@ -57,6 +58,103 @@ def test_init_creates_empty_uipath_json( assert isinstance(config["functions"], dict) assert len(config["functions"]) == 0 + def test_init_mints_agent_id_in_uipath_json( + self, runner: CliRunner, temp_dir: str + ) -> None: + """init writes a valid id into a newly created uipath.json.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + self._generate_pyproject() + result = runner.invoke(cli, ["init"], env={}) + assert result.exit_code == 0 + + with open("uipath.json", "r") as f: + config = json.load(f) + assert "id" in config + # Must be a valid UUID-shaped identifier. + uuid.UUID(config["id"]) + + def test_init_agent_id_matches_telemetry_project_key( + self, runner: CliRunner, temp_dir: str + ) -> None: + """With telemetry enabled, the minted id reuses the telemetry ProjectKey.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + self._generate_pyproject() + result = runner.invoke(cli, ["init"], env={}) + assert result.exit_code == 0 + + with open("uipath.json", "r") as f: + agent_id = json.load(f)["id"] + with open(os.path.join(".uipath", ".telemetry.json"), "r") as f: + project_key = json.load(f)["ProjectKey"] + assert agent_id == project_key + + def test_init_mints_agent_id_with_telemetry_disabled( + self, runner: CliRunner, temp_dir: str + ) -> None: + """id is still written when telemetry is opted out.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + self._generate_pyproject() + result = runner.invoke( + cli, ["init"], env={"UIPATH_TELEMETRY_ENABLED": "false"} + ) + assert result.exit_code == 0 + + assert not os.path.exists(os.path.join(".uipath", ".telemetry.json")) + with open("uipath.json", "r") as f: + config = json.load(f) + uuid.UUID(config["id"]) + + def test_init_preserves_existing_agent_id( + self, runner: CliRunner, temp_dir: str + ) -> None: + """init keeps an id already present in uipath.json (first writer wins).""" + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("main.py", "w") as f: + f.write("def main(input: str) -> str: return input") + with open("uipath.json", "w") as f: + json.dump( + { + "id": "existing-agent-id", + "functions": {"main": "main.py:main"}, + }, + f, + ) + self._generate_pyproject() + + result = runner.invoke(cli, ["init"], env={}) + assert result.exit_code == 0 + # Existing id must not be backfilled/overwritten. + assert "with 'id'" not in result.output + + with open("uipath.json", "r") as f: + assert json.load(f)["id"] == "existing-agent-id" + + def test_init_backfills_agent_id_from_telemetry( + self, runner: CliRunner, temp_dir: str + ) -> None: + """init backfills id on an existing uipath.json, reusing the telemetry key.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("main.py", "w") as f: + f.write("def main(input: str) -> str: return input") + with open("uipath.json", "w") as f: + json.dump({"functions": {"main": "main.py:main"}}, f) + os.makedirs(".uipath", exist_ok=True) + with open(os.path.join(".uipath", ".telemetry.json"), "w") as f: + json.dump({"ProjectKey": "legacy-project-key"}, f) + self._generate_pyproject() + + result = runner.invoke(cli, ["init"], env={}) + assert result.exit_code == 0 + assert "Updated 'uipath.json' file with 'id'" in result.output + + with open("uipath.json", "r") as f: + config = json.load(f) + assert config["id"] == "legacy-project-key" + # Existing fields are preserved. + assert config["functions"]["main"] == "main.py:main" + # The backfill is targeted: no defaulted fields are materialized. + assert set(config.keys()) == {"functions", "id"} + def test_init_with_existing_uipath_json( self, runner: CliRunner, temp_dir: str ) -> None: diff --git a/packages/uipath/tests/cli/test_pack.py b/packages/uipath/tests/cli/test_pack.py index cf6bd5cc1..5966a3d55 100644 --- a/packages/uipath/tests/cli/test_pack.py +++ b/packages/uipath/tests/cli/test_pack.py @@ -10,17 +10,17 @@ import uipath._cli.cli_pack as cli_pack from uipath._cli import cli from uipath._cli.middlewares import MiddlewareResult -from uipath._cli.models.uipath_json_schema import RuntimeOptions +from uipath._cli.models.uipath_json_schema import RuntimeOptions, UiPathJsonConfig -def create_bindings_file(): +def create_bindings_file(directory: str = "."): """Helper to create a default bindings.json file for tests.""" bindings_content = {"version": "2.0", "resources": []} - with open("bindings.json", "w") as f: + with open(os.path.join(directory, "bindings.json"), "w") as f: json.dump(bindings_content, f, indent=4) -def create_entry_points_file(entrypoint_type: str = "function"): +def create_entry_points_file(entrypoint_type: str = "function", directory: str = "."): """Helper to create a default entry-points.json file for tests.""" entry_points_content = { "$schema": "https://cloud.uipath.com/draft/2024-12/entry-point", @@ -38,7 +38,7 @@ def create_entry_points_file(entrypoint_type: str = "function"): } ], } - with open("entry-points.json", "w") as f: + with open(os.path.join(directory, "entry-points.json"), "w") as f: json.dump(entry_points_content, f, indent=4) @@ -1096,14 +1096,17 @@ def test_generate_operate_file(self, runner: CliRunner, temp_dir: str) -> None: ) ] - operate_data = cli_pack.generate_operate_file( - entrypoints, RuntimeOptions(is_conversational=False) + config = UiPathJsonConfig( + runtimeOptions=RuntimeOptions(is_conversational=False), + id="agent-id-from-uipath-json", ) + operate_data = cli_pack.generate_operate_file(entrypoints, config) assert ( operate_data["$schema"] == "https://cloud.uipath.com/draft/2024-12/entry-point" ) + assert operate_data["projectId"] == "agent-id-from-uipath-json" assert operate_data["main"] == "agent1.py" assert operate_data["contentType"] == "agent" assert operate_data["targetFramework"] == "Portable" @@ -1114,6 +1117,96 @@ def test_generate_operate_file(self, runner: CliRunner, temp_dir: str) -> None: "isConversational": False, } + def test_pack_uses_agent_id_as_project_id( + self, + runner: CliRunner, + temp_dir: str, + project_details: ProjectDetails, + ) -> None: + """operate.json projectId is sourced from uipath.json#id.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + config = create_uipath_json() + config["id"] = "my-stable-agent-id" + with open("uipath.json", "w") as f: + json.dump(config, f) + with open("pyproject.toml", "w") as f: + f.write(project_details.to_toml()) + with open("main.py", "w") as f: + f.write("def main(input): return input") + create_bindings_file() + create_entry_points_file() + + result = runner.invoke(cli, ["pack", "./"], env={}) + assert result.exit_code == 0 + + with zipfile.ZipFile( + f".uipath/{project_details.name}.{project_details.version}.nupkg", "r" + ) as z: + operate_data = json.loads(z.read("content/operate.json")) + assert operate_data["projectId"] == "my-stable-agent-id" + + def test_pack_falls_back_to_telemetry_project_key( + self, + runner: CliRunner, + temp_dir: str, + project_details: ProjectDetails, + ) -> None: + """Without id, operate.json projectId falls back to the telemetry key.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + # uipath.json deliberately has no id (legacy project). + with open("uipath.json", "w") as f: + json.dump(create_uipath_json(), f) + with open("pyproject.toml", "w") as f: + f.write(project_details.to_toml()) + with open("main.py", "w") as f: + f.write("def main(input): return input") + create_bindings_file() + create_entry_points_file() + os.makedirs(".uipath", exist_ok=True) + with open(os.path.join(".uipath", ".telemetry.json"), "w") as f: + json.dump({"ProjectKey": "telemetry-fallback-key"}, f) + + result = runner.invoke(cli, ["pack", "./"], env={}) + assert result.exit_code == 0 + + with zipfile.ZipFile( + f".uipath/{project_details.name}.{project_details.version}.nupkg", "r" + ) as z: + operate_data = json.loads(z.read("content/operate.json")) + assert operate_data["projectId"] == "telemetry-fallback-key" + + def test_pack_telemetry_fallback_from_outside_project_dir( + self, + runner: CliRunner, + temp_dir: str, + project_details: ProjectDetails, + ) -> None: + """The legacy telemetry fallback resolves against the packed directory, not CWD.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + os.makedirs("project") + with open(os.path.join("project", "uipath.json"), "w") as f: + json.dump(create_uipath_json(), f) + with open(os.path.join("project", "pyproject.toml"), "w") as f: + f.write(project_details.to_toml()) + with open(os.path.join("project", "main.py"), "w") as f: + f.write("def main(input): return input") + create_bindings_file(directory="project") + create_entry_points_file(directory="project") + os.makedirs(os.path.join("project", ".uipath"), exist_ok=True) + with open(os.path.join("project", ".uipath", ".telemetry.json"), "w") as f: + json.dump({"ProjectKey": "telemetry-fallback-key"}, f) + + result = runner.invoke(cli, ["pack", "./project"], env={}) + assert result.exit_code == 0 + + # the package itself is written under the caller's CWD + with zipfile.ZipFile( + f".uipath/{project_details.name}.{project_details.version}.nupkg", + "r", + ) as z: + operate_data = json.loads(z.read("content/operate.json")) + assert operate_data["projectId"] == "telemetry-fallback-key" + def test_generate_bindings_content(self, runner: CliRunner, temp_dir: str) -> None: """Test generating bindings content.""" bindings_data = cli_pack.generate_bindings_content() diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 62ecc13a0..ab6e034c6 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.81" +version = "2.10.82" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.64" +version = "0.1.65" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" },