From 153d57e32acd8a3545f915a05c2a1c0ff3bf4158 Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Fri, 12 Jun 2026 13:28:19 +0300 Subject: [PATCH] feat: add stable agent id to uipath.json for packaging and spans --- packages/uipath-platform/pyproject.toml | 2 +- .../uipath/platform/chat/llm_trace_context.py | 10 +- .../src/uipath/platform/common/_span_utils.py | 37 ++++- .../tests/services/test_llm_trace_context.py | 20 ++- .../tests/services/test_span_utils.py | 128 ++++++++++++++++- packages/uipath-platform/uv.lock | 2 +- packages/uipath/docs/cli/index.md | 8 ++ packages/uipath/pyproject.toml | 4 +- packages/uipath/specs/uipath.schema.json | 5 + packages/uipath/specs/uipath.spec.md | 37 ++++- .../src/uipath/_cli/_evals/_telemetry.py | 6 +- .../src/uipath/_cli/_utils/_project_files.py | 48 +++++-- packages/uipath/src/uipath/_cli/cli_init.py | 47 +++---- packages/uipath/src/uipath/_cli/cli_pack.py | 50 +++---- .../uipath/_cli/models/uipath_json_schema.py | 6 + .../uipath/src/uipath/telemetry/_track.py | 17 ++- .../tests/cli/eval/test_eval_telemetry.py | 13 +- packages/uipath/tests/cli/test_init.py | 96 +++++++++++++ packages/uipath/tests/cli/test_pack.py | 130 +++++++++++++++++- packages/uipath/tests/telemetry/test_track.py | 45 ++++++ packages/uipath/uv.lock | 4 +- 21 files changed, 591 insertions(+), 124 deletions(-) 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..4b65d7a15 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( @@ -24,8 +24,10 @@ def build_trace_context_headers( Returns an empty dict when the ``EnableTraceContextHeaders`` feature flag is not enabled, or when no active span is present. """ + print("Building trace context headers") if not FeatureFlags.is_flag_enabled("EnableTraceContextHeaders"): - return {} + # todo: revert this + print("Not enabled trace context headers, continuing...") headers: dict[str, str] = {} llmops_span = UiPathSpanUtils.get_external_current_span() @@ -40,8 +42,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..759b3d09a 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,32 @@ 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 project id. + + Prefers ``uipath.json#id``, then falls back to the legacy runtime env vars + for backward compatibility with already-deployed agents: ``UIPATH_AGENT_ID`` + / ``UIPATH_PROJECT_ID`` (via ``agent_id``) then ``PROJECT_KEY``. + """ + from uipath.platform.common._config import UiPathConfig + + return _read_config_id() or UiPathConfig.agent_id or UiPathConfig.project_key + + class AttachmentProvider(IntEnum): ORCHESTRATOR = 0 @@ -281,9 +308,11 @@ def otel_span_to_uipath_span( ] attributes_dict["links"] = links_list + if id := resolve_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..2b078cd62 100644 --- a/packages/uipath-platform/tests/services/test_llm_trace_context.py +++ b/packages/uipath-platform/tests/services/test_llm_trace_context.py @@ -8,6 +8,7 @@ from uipath.core.feature_flags import FeatureFlags from uipath.platform.chat.llm_trace_context import build_trace_context_headers +from uipath.platform.common.constants import ENV_PROJECT_KEY FEATURE_FLAG = "EnableTraceContextHeaders" @@ -110,13 +111,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", + ENV_PROJECT_KEY: "agent-123", "UIPATH_PROCESS_KEY": "process-789", } with patch.dict(os.environ, env, clear=True): @@ -135,22 +139,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_project_key_env(self) -> None: + env = {ENV_PROJECT_KEY: "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 +165,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", + ENV_PROJECT_KEY: "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..9b23e1f7e 100644 --- a/packages/uipath-platform/tests/services/test_span_utils.py +++ b/packages/uipath-platform/tests/services/test_span_utils.py @@ -8,6 +8,21 @@ from opentelemetry.trace import SpanContext, StatusCode from uipath.platform.common import UiPathSpan, _SpanUtils +from uipath.platform.common.constants import ( + ENV_PROJECT_KEY, + ENV_UIPATH_AGENT_ID, + ENV_UIPATH_PROJECT_ID, +) + + +@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: @@ -92,10 +107,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 +121,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 +156,15 @@ 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() + monkeypatch.delenv(ENV_UIPATH_AGENT_ID, raising=False) + monkeypatch.delenv(ENV_UIPATH_PROJECT_ID, raising=False) if env_value is None: - monkeypatch.delenv("UIPATH_AGENT_ID", raising=False) + monkeypatch.delenv(ENV_PROJECT_KEY, raising=False) else: - monkeypatch.setenv("UIPATH_AGENT_ID", env_value) + monkeypatch.setenv(ENV_PROJECT_KEY, env_value) mock_span = Mock(spec=OTelSpan) mock_context = SpanContext( @@ -166,6 +187,99 @@ 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_AGENT_ID` + / `UIPATH_PROJECT_ID` > the legacy `PROJECT_KEY` 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.delenv(ENV_UIPATH_AGENT_ID, raising=False) + monkeypatch.delenv(ENV_UIPATH_PROJECT_ID, 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_env( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + (tmp_path / "uipath.json").write_text(json.dumps({"id": "from-config"})) + monkeypatch.setenv(ENV_PROJECT_KEY, "from-env") + assert self._resolve(monkeypatch, tmp_path) == "from-config" + + def test_agent_id_falls_back_to_project_key( + self, monkeypatch: pytest.MonkeyPatch, tmp_path + ) -> None: + # No uipath.json on disk. + monkeypatch.setenv(ENV_PROJECT_KEY, "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(ENV_PROJECT_KEY, "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(ENV_PROJECT_KEY, 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/docs/cli/index.md b/packages/uipath/docs/cli/index.md index f73afa0d1..e74a4d83e 100644 --- a/packages/uipath/docs/cli/index.md +++ b/packages/uipath/docs/cli/index.md @@ -121,6 +121,14 @@ Running `uipath init` will process these function definitions and create the cor `uipath init` generates one `.mermaid` file per function/agent containing a static call graph, rendered in the UiPath Orchestrator UI. These files are regenerated on every `uipath init`. /// + +/// warning +### About the `id` field + +The first `uipath init` mints a stable `id` (GUID) into `uipath.json` and preserves it across subsequent runs. It is what identifies your project consistently wherever it is deployed and run. + +Do not change or remove it. Changing it makes the project look like a brand-new, unrelated one, so you lose the link to everything previously published and tracked under the old id. `uipath pack` rejects an `id` that is not a valid GUID. +/// --- ::: mkdocs-click diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 5b63bd838..c04ca9d6c 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.10.82" +version = "2.10.83" 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/specs/uipath.schema.json b/packages/uipath/specs/uipath.schema.json index 8f2a550f8..2ee966e82 100644 --- a/packages/uipath/specs/uipath.schema.json +++ b/packages/uipath/specs/uipath.schema.json @@ -9,6 +9,11 @@ "type": "string", "description": "Reference to this JSON schema for editor support" }, + "id": { + "type": "string", + "format": "uuid", + "description": "Stable unique identifier for the project, minted once on the first 'uipath init' and preserved for its lifetime. Used as the package 'projectId' at pack time. Do not change it." + }, "runtimeOptions": { "type": "object", "description": "Runtime behavior configuration", diff --git a/packages/uipath/specs/uipath.spec.md b/packages/uipath/specs/uipath.spec.md index 5c7599faa..ee9978166 100644 --- a/packages/uipath/specs/uipath.spec.md +++ b/packages/uipath/specs/uipath.spec.md @@ -9,6 +9,7 @@ The `uipath.json` file is a configuration file for UiPath projects that defines ```json { "$schema": "https://cloud.uipath.com/draft/2024-12/uipath", + "id": "00000000-0000-0000-0000-000000000000", "runtimeOptions": { ... }, "designOptions": { ... }, "packOptions": { ... }, @@ -20,7 +21,29 @@ The `uipath.json` file is a configuration file for UiPath projects that defines ## Configuration Sections -### 1. `runtimeOptions` +### 1. `id` + +Stable unique identifier (GUID) for the project, minted once on the first `uipath init` and preserved for its lifetime. Used as the package `projectId` at pack time. + +**Properties:** + +| Property | Type | Required | Default | Description | +|----------|------|----------|---------|-------------| +| `id` | `string` (uuid) | No | minted on first `uipath init` | Stable identifier for the project. Do not change it. | + +> Do not change or remove `id`. It identifies your project consistently wherever it is deployed and run. Changing it makes the project look like a brand-new, unrelated one, so you lose the link to everything previously published and tracked under the old id. `uipath pack` rejects an `id` that is not a valid GUID. + +**Example:** + +```json +{ + "id": "bb78fd15-bc75-44b3-8d90-5d55c1204992" +} +``` + +--- + +### 2. `runtimeOptions` Controls runtime behavior of your UiPath project. @@ -42,7 +65,7 @@ Controls runtime behavior of your UiPath project. --- -### 2. `designOptions` +### 3. `designOptions` Design-time configuration and preferences. @@ -57,7 +80,7 @@ Design-time configuration and preferences. --- -### 3. `packOptions` +### 4. `packOptions` Controls which files and directories are included or excluded when packaging your project. @@ -87,7 +110,7 @@ Controls which files and directories are included or excluded when packaging you --- -### 4. `functions` +### 5. `functions` Defines entrypoints for pure Python scripts. Each key is a friendly name for the entrypoint, and each value specifies the file path and function name. @@ -128,6 +151,7 @@ Defines entrypoints for pure Python scripts. Each key is a friendly name for the ```json { "$schema": "https://cloud.uipath.com/draft/2024-12/uipath", + "id": "bb78fd15-bc75-44b3-8d90-5d55c1204992", "runtimeOptions": { "isConversational": false }, @@ -218,6 +242,11 @@ The complete JSON Schema is available in `uipath.schema.json`: "type": "string", "description": "Reference to this JSON schema for editor support" }, + "id": { + "type": "string", + "format": "uuid", + "description": "Stable unique identifier for the project, minted once on the first 'uipath init' and preserved for its lifetime. Used as the package 'projectId' at pack time. Do not change it." + }, "runtimeOptions": { "type": "object", "description": "Runtime behavior configuration", 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..90a4ca117 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 @@ -30,13 +31,11 @@ ) from uipath.runtime.schema import UiPathRuntimeGraph, UiPathRuntimeSchema -from .._utils.constants import ENV_TELEMETRY_ENABLED -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 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 @@ -54,30 +53,6 @@ class Action(str, enum.Enum): UPDATED = "Updated" -def create_telemetry_config_file(target_directory: str) -> None: - """Create telemetry file if telemetry is enabled. - - Args: - target_directory: The directory where the .uipath folder should be created. - """ - telemetry_enabled = os.getenv(ENV_TELEMETRY_ENABLED, "true").lower() == "true" - - if not telemetry_enabled: - return - - uipath_dir = os.path.join(target_directory, ".uipath") - telemetry_file = os.path.join(uipath_dir, _TELEMETRY_CONFIG_FILE) - - if os.path.exists(telemetry_file): - return - - os.makedirs(uipath_dir, exist_ok=True) - telemetry_data = {_PROJECT_KEY: UiPathConfig.project_id or str(uuid.uuid4())} - - with open(telemetry_file, "w") as f: - json.dump(telemetry_data, f, indent=4) - - def generate_env_file(target_directory): env_path = os.path.join(target_directory, ".env") @@ -421,7 +396,6 @@ def init(no_agents_md_override: bool) -> None: with console.spinner("Initializing UiPath project ..."): current_directory = os.getcwd() generate_env_file(current_directory) - create_telemetry_config_file(current_directory) async def initialize() -> list[UiPathRuntimeSchema]: try: @@ -429,10 +403,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..d51eec53c 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,27 @@ 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. + if config.id: + try: + uuid.UUID(config.id) + except ValueError: + console.error(f"uipath.json 'id' must be a valid GUID, got '{config.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 +82,7 @@ def generate_operate_file( "runtimeOptions": { "requiresUserInteraction": False, "isAttended": False, - "isConversational": runtimeOptions.is_conversational, + "isConversational": config.runtime_options.is_conversational, }, } @@ -239,7 +227,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/src/uipath/telemetry/_track.py b/packages/uipath/src/uipath/telemetry/_track.py index 2d3f11ebf..bc961f2b9 100644 --- a/packages/uipath/src/uipath/telemetry/_track.py +++ b/packages/uipath/src/uipath/telemetry/_track.py @@ -105,18 +105,23 @@ def _get_connection_string() -> str | None: def _get_project_key() -> str: - """Get project key from telemetry file if present. + """Get the id used to attribute telemetry. - Returns: - Project key string if available, otherwise empty string. + Resolves ``uipath.json#id`` (then the runtime env var) via the shared + ``resolve_id`` helper, falling back to a legacy ``.uipath/.telemetry.json`` + ``ProjectKey`` if present. + Returns ``_UNKNOWN`` when no id is available. """ + from uipath.platform.common._span_utils import resolve_id + + if project_id := resolve_id(): + return project_id + try: telemetry_file = os.path.join(".uipath", _TELEMETRY_CONFIG_FILE) if os.path.exists(telemetry_file): with open(telemetry_file, "r") as f: - telemetry_data = json.load(f) - project_id = telemetry_data.get(_PROJECT_KEY) - if project_id: + if project_id := json.load(f).get(_PROJECT_KEY): return project_id except (json.JSONDecodeError, IOError, KeyError): pass diff --git a/packages/uipath/tests/cli/eval/test_eval_telemetry.py b/packages/uipath/tests/cli/eval/test_eval_telemetry.py index 48911638a..49a71bc2f 100644 --- a/packages/uipath/tests/cli/eval/test_eval_telemetry.py +++ b/packages/uipath/tests/cli/eval/test_eval_telemetry.py @@ -25,6 +25,7 @@ EvalSetRunCreatedEvent, EvalSetRunUpdatedEvent, ) +from uipath.platform.common.constants import ENV_UIPATH_AGENT_ID class TestEventNameConstants: @@ -422,6 +423,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 +434,7 @@ def test_enrich_properties_adds_env_vars(self, mock_get_claim): os.environ, { "UIPATH_PROJECT_ID": "project-123", + ENV_UIPATH_AGENT_ID: "agent-123", "UIPATH_ORGANIZATION_ID": "org-456", "UIPATH_TENANT_ID": "tenant-abc", "UIPATH_EVAL_RUN_SOURCE": "FirstSuccessfulRun", @@ -437,7 +443,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 +454,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 +465,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", + ENV_UIPATH_AGENT_ID, "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..afa5d15fa 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,101 @@ 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_does_not_create_telemetry_file( + self, runner: CliRunner, temp_dir: str + ) -> None: + """init no longer writes .uipath/.telemetry.json; the id lives in uipath.json.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + self._generate_pyproject() + result = runner.invoke(cli, ["init"], env={}) + assert result.exit_code == 0 + + assert not os.path.exists(os.path.join(".uipath", ".telemetry.json")) + with open("uipath.json", "r") as f: + uuid.UUID(json.load(f)["id"]) + + 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..588602cf6 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="bb78fd15-bc75-44b3-8d90-5d55c1204992", ) + 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"] == "bb78fd15-bc75-44b3-8d90-5d55c1204992" assert operate_data["main"] == "agent1.py" assert operate_data["contentType"] == "agent" assert operate_data["targetFramework"] == "Portable" @@ -1114,6 +1117,119 @@ 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"] = "bb78fd15-bc75-44b3-8d90-5d55c1204992" + 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"] == "bb78fd15-bc75-44b3-8d90-5d55c1204992" + + def test_pack_fails_when_id_is_not_a_guid( + self, + runner: CliRunner, + temp_dir: str, + project_details: ProjectDetails, + ) -> None: + """pack fails when uipath.json#id is set but is not a valid GUID.""" + with runner.isolated_filesystem(temp_dir=temp_dir): + config = create_uipath_json() + config["id"] = "not-a-guid" + 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 + assert "must be a valid GUID" in result.output + + 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/tests/telemetry/test_track.py b/packages/uipath/tests/telemetry/test_track.py index fe72130d5..7849e5268 100644 --- a/packages/uipath/tests/telemetry/test_track.py +++ b/packages/uipath/tests/telemetry/test_track.py @@ -1,11 +1,20 @@ """Tests for telemetry tracking functionality.""" +import json import os from unittest.mock import MagicMock, patch +import pytest + +from uipath.platform.common.constants import ( + ENV_PROJECT_KEY, + ENV_UIPATH_AGENT_ID, + ENV_UIPATH_PROJECT_ID, +) from uipath.telemetry._track import ( _AppInsightsEventClient, _DiagnosticSender, + _get_project_key, _parse_connection_string, _TelemetryClient, flush_events, @@ -17,6 +26,42 @@ ) +class TestGetProjectKey: + """`_get_project_key` resolution: uipath.json#id, then legacy telemetry file.""" + + @pytest.fixture(autouse=True) + def _clear_cache(self, monkeypatch): + from uipath.platform.common._span_utils import _read_config_id + + _read_config_id.cache_clear() + for var in (ENV_UIPATH_AGENT_ID, ENV_UIPATH_PROJECT_ID, ENV_PROJECT_KEY): + monkeypatch.delenv(var, raising=False) + yield + _read_config_id.cache_clear() + + def test_prefers_uipath_json_id(self, monkeypatch, tmp_path): + (tmp_path / "uipath.json").write_text(json.dumps({"id": "from-uipath-json"})) + os.makedirs(tmp_path / ".uipath", exist_ok=True) + (tmp_path / ".uipath" / ".telemetry.json").write_text( + json.dumps({"ProjectKey": "from-telemetry"}) + ) + monkeypatch.chdir(tmp_path) + assert _get_project_key() == "from-uipath-json" + + def test_falls_back_to_legacy_telemetry_file(self, monkeypatch, tmp_path): + # No uipath.json#id and no env var; honor an existing .telemetry.json. + os.makedirs(tmp_path / ".uipath", exist_ok=True) + (tmp_path / ".uipath" / ".telemetry.json").write_text( + json.dumps({"ProjectKey": "from-telemetry"}) + ) + monkeypatch.chdir(tmp_path) + assert _get_project_key() == "from-telemetry" + + def test_unknown_when_no_source(self, monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + assert _get_project_key() == "" + + class TestParseConnectionString: """Test connection string parsing functionality.""" diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 773d25d8a..1c1556c06 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.10.82" +version = "2.10.83" 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" },