uipath-robot 0.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- uipath/robot/__init__.py +472 -0
- uipath/robot/infra/__init__.py +5 -0
- uipath/robot/infra/logger.py +181 -0
- uipath/robot/models/__init__.py +21 -0
- uipath/robot/models/heartbeat.py +166 -0
- uipath/robot/py.typed +0 -0
- uipath/robot/services/__init__.py +6 -0
- uipath/robot/services/identity.py +79 -0
- uipath/robot/services/orchestrator.py +303 -0
- uipath_robot-0.0.3.dist-info/METADATA +26 -0
- uipath_robot-0.0.3.dist-info/RECORD +14 -0
- uipath_robot-0.0.3.dist-info/WHEEL +4 -0
- uipath_robot-0.0.3.dist-info/entry_points.txt +2 -0
- uipath_robot-0.0.3.dist-info/licenses/LICENSE +9 -0
uipath/robot/__init__.py
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
"""UiPath Robot Package Initialization."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import uuid
|
|
8
|
+
import zipfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from dotenv import load_dotenv
|
|
13
|
+
|
|
14
|
+
from uipath.robot.infra import get_logger, init_logger
|
|
15
|
+
from uipath.robot.models import (
|
|
16
|
+
Command,
|
|
17
|
+
HeartbeatData,
|
|
18
|
+
HeartbeatResponse,
|
|
19
|
+
JobError,
|
|
20
|
+
JobState,
|
|
21
|
+
SessionState,
|
|
22
|
+
)
|
|
23
|
+
from uipath.robot.services import OrchestratorService
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Robot:
|
|
27
|
+
"""Main Robot class to manage interactions with UiPath Orchestrator."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, verbose: bool = False):
|
|
30
|
+
"""Initialize the Robot with necessary services.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
verbose: Enable verbose logging
|
|
34
|
+
"""
|
|
35
|
+
self.logger = get_logger(verbose)
|
|
36
|
+
self.orchestrator = OrchestratorService()
|
|
37
|
+
self.heartbeat_interval = 5 # seconds
|
|
38
|
+
self.license_key: str | None = None
|
|
39
|
+
|
|
40
|
+
base_dir = Path.cwd() / ".uipath"
|
|
41
|
+
self.packages_dir = base_dir / "packages"
|
|
42
|
+
self.processes_dir = base_dir / "processes"
|
|
43
|
+
|
|
44
|
+
self.packages_dir.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
self.processes_dir.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
|
|
47
|
+
async def initialize(self) -> bool:
|
|
48
|
+
"""Initialize the service by getting connection data and license key.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
True if initialization successful, False otherwise
|
|
52
|
+
"""
|
|
53
|
+
self.logger.info("Initializing robot service")
|
|
54
|
+
|
|
55
|
+
self.license_key = await self.orchestrator.get_shared_connection_data()
|
|
56
|
+
|
|
57
|
+
if not self.license_key:
|
|
58
|
+
self.logger.error("Failed to get connection data")
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
self.logger.success(f"Connected (key: {self.license_key[:10]}...)")
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
def get_package_path(self, package_id: str, package_version: str) -> Path:
|
|
65
|
+
"""Get the path for a package zip file."""
|
|
66
|
+
return self.packages_dir / f"{package_id}_{package_version}.zip"
|
|
67
|
+
|
|
68
|
+
def get_process_path(self, package_id: str, package_version: str) -> Path:
|
|
69
|
+
"""Get the path for an extracted process."""
|
|
70
|
+
return self.processes_dir / f"{package_id}_{package_version}"
|
|
71
|
+
|
|
72
|
+
async def download_and_setup_package(
|
|
73
|
+
self, package_id: str, package_version: str, feed_id: str | None = None
|
|
74
|
+
) -> Path | None:
|
|
75
|
+
"""Download package if needed, extract it, and setup environment.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
package_id: The package identifier
|
|
79
|
+
package_version: The package version
|
|
80
|
+
feed_id: Optional feed identifier
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Path to the extracted process directory, or None if failed
|
|
84
|
+
"""
|
|
85
|
+
package_path = self.get_package_path(package_id, package_version)
|
|
86
|
+
process_path = self.get_process_path(package_id, package_version)
|
|
87
|
+
|
|
88
|
+
# Check if already downloaded and extracted
|
|
89
|
+
if process_path.exists():
|
|
90
|
+
self.logger.package_status(package_id, package_version, "cached")
|
|
91
|
+
return process_path
|
|
92
|
+
|
|
93
|
+
# Download package if not exists
|
|
94
|
+
if not package_path.exists():
|
|
95
|
+
self.logger.package_status(package_id, package_version, "downloading")
|
|
96
|
+
success = await self.orchestrator.download_package(
|
|
97
|
+
package_id,
|
|
98
|
+
package_version,
|
|
99
|
+
package_path,
|
|
100
|
+
feed_id,
|
|
101
|
+
)
|
|
102
|
+
if not success:
|
|
103
|
+
self.logger.error("Failed to download package")
|
|
104
|
+
return None
|
|
105
|
+
self.logger.debug(f"Downloaded to {package_path}", indent=1)
|
|
106
|
+
|
|
107
|
+
# Extract package - extract only the 'content' folder
|
|
108
|
+
self.logger.package_status(package_id, package_version, "extracting")
|
|
109
|
+
try:
|
|
110
|
+
process_path.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
|
|
112
|
+
with zipfile.ZipFile(package_path, "r") as zip_ref:
|
|
113
|
+
# Get all files in the 'content' folder
|
|
114
|
+
content_files = [
|
|
115
|
+
f for f in zip_ref.namelist() if f.startswith("content/")
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
if not content_files:
|
|
119
|
+
self.logger.error("No 'content' folder found in package")
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
# Extract each file, removing the 'content/' prefix
|
|
123
|
+
for file in content_files:
|
|
124
|
+
if file == "content/":
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
# Remove 'content/' prefix from the path
|
|
128
|
+
target_path = file[len("content/") :]
|
|
129
|
+
|
|
130
|
+
# Extract to process_path with adjusted path
|
|
131
|
+
if target_path:
|
|
132
|
+
source = zip_ref.open(file)
|
|
133
|
+
target = process_path / target_path
|
|
134
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
|
|
136
|
+
with source, open(target, "wb") as target_file:
|
|
137
|
+
target_file.write(source.read())
|
|
138
|
+
|
|
139
|
+
self.logger.package_status(package_id, package_version, "extracted")
|
|
140
|
+
except Exception as e:
|
|
141
|
+
self.logger.error(f"Failed to extract: {e}")
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
# Setup virtual environment only if pyproject.toml exists
|
|
145
|
+
pyproject_path = process_path / "pyproject.toml"
|
|
146
|
+
if pyproject_path.exists():
|
|
147
|
+
self.logger.environment_setup("creating")
|
|
148
|
+
venv_path = process_path / ".venv"
|
|
149
|
+
try:
|
|
150
|
+
subprocess.run(
|
|
151
|
+
["uv", "venv", str(venv_path)],
|
|
152
|
+
cwd=process_path,
|
|
153
|
+
check=True,
|
|
154
|
+
capture_output=True,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Copy existing environment and add custom vars
|
|
158
|
+
env_with_temp = os.environ.copy()
|
|
159
|
+
env_with_temp["VIRTUAL_ENV"] = str(venv_path)
|
|
160
|
+
env_with_temp["TMPDIR"] = str(process_path / "temp") # Linux/Mac
|
|
161
|
+
env_with_temp["TEMP"] = str(process_path / "temp") # Windows
|
|
162
|
+
env_with_temp["TMP"] = str(process_path / "temp") # Windows
|
|
163
|
+
|
|
164
|
+
(process_path / "temp").mkdir(exist_ok=True)
|
|
165
|
+
|
|
166
|
+
self.logger.environment_setup("syncing")
|
|
167
|
+
subprocess.run(
|
|
168
|
+
["uv", "sync"],
|
|
169
|
+
cwd=process_path,
|
|
170
|
+
env=env_with_temp,
|
|
171
|
+
check=True,
|
|
172
|
+
capture_output=True,
|
|
173
|
+
text=True,
|
|
174
|
+
)
|
|
175
|
+
self.logger.environment_setup("ready")
|
|
176
|
+
|
|
177
|
+
except subprocess.CalledProcessError as e:
|
|
178
|
+
self.logger.error(f"Environment setup failed: {e}")
|
|
179
|
+
self.logger.debug(f"stdout: {e.stdout}", indent=1)
|
|
180
|
+
self.logger.debug(f"stderr: {e.stderr}", indent=1)
|
|
181
|
+
return None
|
|
182
|
+
except Exception as e:
|
|
183
|
+
self.logger.error(f"Unexpected error: {e}")
|
|
184
|
+
return None
|
|
185
|
+
else:
|
|
186
|
+
self.logger.environment_setup("skipped")
|
|
187
|
+
|
|
188
|
+
return process_path
|
|
189
|
+
|
|
190
|
+
async def execute_process(self, process_path: Path, command: Command) -> None:
|
|
191
|
+
"""Execute a process using uipath run.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
process_path: Path to the process directory
|
|
195
|
+
command: The command data containing job details
|
|
196
|
+
"""
|
|
197
|
+
assert self.license_key is not None
|
|
198
|
+
self.logger.process_execution("starting")
|
|
199
|
+
try:
|
|
200
|
+
# Create __uipath directory and uipath.json
|
|
201
|
+
uipath_dir = process_path / "__uipath"
|
|
202
|
+
uipath_dir.mkdir(exist_ok=True)
|
|
203
|
+
|
|
204
|
+
uipath_config = {
|
|
205
|
+
"runtime": self._parse_json_or_empty(command.data.internal_arguments),
|
|
206
|
+
"fpsProperties": self._parse_json_or_empty(command.data.fps_properties),
|
|
207
|
+
"fpsContext": self._parse_json_or_empty(command.data.fps_context),
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
uipath_json_path = uipath_dir / "uipath.json"
|
|
211
|
+
with open(uipath_json_path, "w") as f:
|
|
212
|
+
json.dump(uipath_config, f, indent=2)
|
|
213
|
+
|
|
214
|
+
# Prepare environment variables
|
|
215
|
+
env = os.environ.copy()
|
|
216
|
+
env["UIPATH_JOB_KEY"] = command.data.job_key
|
|
217
|
+
env["UIPATH_FOLDER_KEY"] = command.data.folder_key
|
|
218
|
+
env["UIPATH_FOLDER_PATH"] = command.data.fully_qualified_folder_name
|
|
219
|
+
env["UIPATH_PROCESS_UUID"] = command.data.process_key
|
|
220
|
+
env["VIRTUAL_ENV"] = str(process_path / ".venv")
|
|
221
|
+
|
|
222
|
+
trace_id = command.data.trace_id or str(uuid.uuid4())
|
|
223
|
+
env["UIPATH_TRACE_ID"] = trace_id
|
|
224
|
+
if command.data.parent_span_id:
|
|
225
|
+
env["UIPATH_PARENT_SPAN_ID"] = command.data.parent_span_id
|
|
226
|
+
if command.data.root_span_id:
|
|
227
|
+
env["UIPATH_ROOT_SPAN_ID"] = command.data.root_span_id
|
|
228
|
+
|
|
229
|
+
# Ensure required env vars are present
|
|
230
|
+
required_vars = [
|
|
231
|
+
"UIPATH_ACCESS_TOKEN",
|
|
232
|
+
"UIPATH_URL",
|
|
233
|
+
"UIPATH_TENANT_ID",
|
|
234
|
+
"UIPATH_ORGANIZATION_ID",
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
missing_vars = [var for var in required_vars if not env.get(var)]
|
|
238
|
+
if missing_vars:
|
|
239
|
+
self.logger.error(f"Missing env vars: {', '.join(missing_vars)}")
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
await self.orchestrator.submit_job_state(
|
|
243
|
+
heartbeats=[
|
|
244
|
+
HeartbeatData(
|
|
245
|
+
robot_key=command.robot_key,
|
|
246
|
+
robot_state=SessionState.BUSY,
|
|
247
|
+
process_key=command.data.process_key,
|
|
248
|
+
job_key=command.data.job_key,
|
|
249
|
+
job_state=JobState.RUNNING,
|
|
250
|
+
trace_id=trace_id,
|
|
251
|
+
)
|
|
252
|
+
],
|
|
253
|
+
license_key=self.license_key,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
self.logger.process_execution("running")
|
|
257
|
+
|
|
258
|
+
cmd = ["uv", "run", "uipath", "run"]
|
|
259
|
+
if command.data.entry_point_path:
|
|
260
|
+
cmd.extend([command.data.entry_point_path])
|
|
261
|
+
if command.data.input_arguments:
|
|
262
|
+
cmd.extend([command.data.input_arguments])
|
|
263
|
+
|
|
264
|
+
result = subprocess.run(
|
|
265
|
+
cmd,
|
|
266
|
+
cwd=process_path,
|
|
267
|
+
env=env,
|
|
268
|
+
check=True,
|
|
269
|
+
capture_output=True,
|
|
270
|
+
text=True,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
output_file = process_path / "__uipath" / "output.json"
|
|
274
|
+
if output_file.exists():
|
|
275
|
+
output_data: dict[str, Any] = {}
|
|
276
|
+
with open(output_file, "r") as f:
|
|
277
|
+
output_data = json.load(f)
|
|
278
|
+
|
|
279
|
+
status = output_data.get("status", "successful")
|
|
280
|
+
output_args = json.dumps(output_data.get("output", {}))
|
|
281
|
+
|
|
282
|
+
if status == "successful":
|
|
283
|
+
job_state = JobState.SUCCESSFUL
|
|
284
|
+
error = None
|
|
285
|
+
self.logger.process_execution("success", result.stdout.strip())
|
|
286
|
+
else:
|
|
287
|
+
job_state = JobState.FAULTED
|
|
288
|
+
error_data: dict[str, Any] = output_data.get("error", {})
|
|
289
|
+
error = JobError(
|
|
290
|
+
code=error_data.get("code", "PYTHON.PROCESS_FAILED"),
|
|
291
|
+
title=error_data.get("title", "Process Failed"),
|
|
292
|
+
category=error_data.get("category", "USER"),
|
|
293
|
+
detail=error_data.get(
|
|
294
|
+
"detail", "Process completed with failure status"
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
self.logger.process_execution("failed", error_data.get("detail"))
|
|
298
|
+
else:
|
|
299
|
+
# No output file, assume success
|
|
300
|
+
job_state = JobState.SUCCESSFUL
|
|
301
|
+
output_args = "{}"
|
|
302
|
+
error = None
|
|
303
|
+
self.logger.process_execution("success", result.stdout.strip())
|
|
304
|
+
|
|
305
|
+
await self.orchestrator.submit_job_state(
|
|
306
|
+
heartbeats=[
|
|
307
|
+
HeartbeatData(
|
|
308
|
+
robot_key=command.robot_key,
|
|
309
|
+
robot_state=SessionState.AVAILABLE,
|
|
310
|
+
process_key=command.data.process_key,
|
|
311
|
+
job_key=command.data.job_key,
|
|
312
|
+
job_state=job_state,
|
|
313
|
+
output_arguments=output_args,
|
|
314
|
+
error=error,
|
|
315
|
+
)
|
|
316
|
+
],
|
|
317
|
+
license_key=self.license_key,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
except subprocess.CalledProcessError as e:
|
|
321
|
+
self.logger.process_execution("failed", e.stderr)
|
|
322
|
+
await self.orchestrator.submit_job_state(
|
|
323
|
+
heartbeats=[
|
|
324
|
+
HeartbeatData(
|
|
325
|
+
robot_key=command.robot_key,
|
|
326
|
+
robot_state=SessionState.AVAILABLE,
|
|
327
|
+
process_key=command.data.process_key,
|
|
328
|
+
job_key=command.data.job_key,
|
|
329
|
+
job_state=JobState.FAULTED,
|
|
330
|
+
error=JobError(
|
|
331
|
+
code="PYTHON.AGENT_EXECUTION_FAILED",
|
|
332
|
+
title="Agent Execution Failed",
|
|
333
|
+
category="SYSTEM",
|
|
334
|
+
detail=e.stderr,
|
|
335
|
+
),
|
|
336
|
+
)
|
|
337
|
+
],
|
|
338
|
+
license_key=self.license_key,
|
|
339
|
+
)
|
|
340
|
+
except Exception as e:
|
|
341
|
+
self.logger.process_execution("failed", str(e))
|
|
342
|
+
await self.orchestrator.submit_job_state(
|
|
343
|
+
heartbeats=[
|
|
344
|
+
HeartbeatData(
|
|
345
|
+
robot_key=command.robot_key,
|
|
346
|
+
robot_state=SessionState.AVAILABLE,
|
|
347
|
+
process_key=command.data.process_key,
|
|
348
|
+
job_key=command.data.job_key,
|
|
349
|
+
job_state=JobState.FAULTED,
|
|
350
|
+
error=JobError(
|
|
351
|
+
code="PYTHON.AGENT_EXECUTION_FAILED",
|
|
352
|
+
title="Agent Execution Failed",
|
|
353
|
+
category="SYSTEM",
|
|
354
|
+
detail=str(e),
|
|
355
|
+
),
|
|
356
|
+
)
|
|
357
|
+
],
|
|
358
|
+
license_key=self.license_key,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
async def process_commands(self, response: HeartbeatResponse) -> None:
|
|
362
|
+
"""Process commands from heartbeat response.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
response: The heartbeat response containing commands
|
|
366
|
+
"""
|
|
367
|
+
for command in response.commands:
|
|
368
|
+
if command.data.type == "StartProcess":
|
|
369
|
+
self.logger.job_start(
|
|
370
|
+
command.data.job_key,
|
|
371
|
+
command.data.package_id,
|
|
372
|
+
command.data.package_version,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# Download and setup package
|
|
376
|
+
process_path = await self.download_and_setup_package(
|
|
377
|
+
command.data.package_id,
|
|
378
|
+
command.data.package_version,
|
|
379
|
+
command.data.feed_id,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
if process_path:
|
|
383
|
+
# Execute the process asynchronously without blocking
|
|
384
|
+
asyncio.create_task(
|
|
385
|
+
self.execute_process(
|
|
386
|
+
process_path,
|
|
387
|
+
command,
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
else:
|
|
391
|
+
self.logger.error("Failed to setup package")
|
|
392
|
+
|
|
393
|
+
def _parse_json_or_empty(self, raw_json: str | None) -> dict[str, Any]:
|
|
394
|
+
"""Parse JSON string or return empty dict.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
raw_json: JSON string to parse
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
Parsed JSON as dict or empty dict if parsing fails
|
|
401
|
+
"""
|
|
402
|
+
if not raw_json or not raw_json.strip():
|
|
403
|
+
return {}
|
|
404
|
+
|
|
405
|
+
try:
|
|
406
|
+
return json.loads(raw_json)
|
|
407
|
+
except (json.JSONDecodeError, Exception):
|
|
408
|
+
return {}
|
|
409
|
+
|
|
410
|
+
async def run(self) -> None:
|
|
411
|
+
"""Main loop: Initialize once, then send heartbeats every 10 seconds."""
|
|
412
|
+
self.logger.section("UiPath Robot Service")
|
|
413
|
+
|
|
414
|
+
if not await self.initialize():
|
|
415
|
+
self.logger.error("Initialization failed")
|
|
416
|
+
return
|
|
417
|
+
|
|
418
|
+
assert self.license_key is not None
|
|
419
|
+
|
|
420
|
+
await self.orchestrator.start_service(self.license_key)
|
|
421
|
+
self.logger.success("Service started")
|
|
422
|
+
|
|
423
|
+
self.logger.info(f"Listening for jobs (heartbeat: {self.heartbeat_interval}s)")
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
while True:
|
|
427
|
+
response = await self.orchestrator.heartbeat(self.license_key)
|
|
428
|
+
self.logger.heartbeat()
|
|
429
|
+
|
|
430
|
+
if response and response.commands:
|
|
431
|
+
await self.process_commands(response)
|
|
432
|
+
|
|
433
|
+
await asyncio.sleep(self.heartbeat_interval)
|
|
434
|
+
finally:
|
|
435
|
+
self.logger.info("Stopping service...")
|
|
436
|
+
await self.orchestrator.stop_service(self.license_key)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
async def start_robot(verbose: bool = False) -> None:
|
|
440
|
+
"""Run the robot service.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
verbose: Enable verbose logging
|
|
444
|
+
"""
|
|
445
|
+
robot = Robot(verbose=verbose)
|
|
446
|
+
await robot.run()
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def main() -> None:
|
|
450
|
+
"""Main entry point for running the robot service."""
|
|
451
|
+
import argparse
|
|
452
|
+
|
|
453
|
+
parser = argparse.ArgumentParser(description="UiPath Robot Service")
|
|
454
|
+
parser.add_argument(
|
|
455
|
+
"-v",
|
|
456
|
+
"--verbose",
|
|
457
|
+
action="store_true",
|
|
458
|
+
help="Enable verbose logging",
|
|
459
|
+
)
|
|
460
|
+
args = parser.parse_args()
|
|
461
|
+
|
|
462
|
+
load_dotenv()
|
|
463
|
+
init_logger(verbose=args.verbose)
|
|
464
|
+
|
|
465
|
+
try:
|
|
466
|
+
asyncio.run(start_robot(verbose=args.verbose))
|
|
467
|
+
except KeyboardInterrupt:
|
|
468
|
+
pass
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
if __name__ == "__main__":
|
|
472
|
+
main()
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""CLI Logger for UiPath Robot."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LogLevel(Enum):
|
|
8
|
+
"""Log levels with visual indicators."""
|
|
9
|
+
|
|
10
|
+
INFO = ("●", "\033[36m") # Cyan dot
|
|
11
|
+
SUCCESS = ("✓", "\033[32m") # Green checkmark
|
|
12
|
+
WARNING = ("⚠", "\033[33m") # Yellow warning
|
|
13
|
+
ERROR = ("✗", "\033[31m") # Red X
|
|
14
|
+
DEBUG = ("·", "\033[90m") # Gray dot
|
|
15
|
+
SYSTEM = ("→", "\033[35m") # Magenta arrow
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CLILogger:
|
|
19
|
+
"""Clean CLI logger with structured output."""
|
|
20
|
+
|
|
21
|
+
RESET = "\033[0m"
|
|
22
|
+
BOLD = "\033[1m"
|
|
23
|
+
DIM = "\033[2m"
|
|
24
|
+
|
|
25
|
+
def __init__(self, verbose: bool = False):
|
|
26
|
+
"""Initialize logger.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
verbose: If True, show debug messages
|
|
30
|
+
"""
|
|
31
|
+
self.verbose: bool = verbose
|
|
32
|
+
self._last_section: str | None = None
|
|
33
|
+
|
|
34
|
+
def _format_time(self) -> str:
|
|
35
|
+
"""Get formatted timestamp."""
|
|
36
|
+
return datetime.now().strftime("%H:%M:%S")
|
|
37
|
+
|
|
38
|
+
def _log(self, level: LogLevel, message: str, indent: int = 0):
|
|
39
|
+
"""Internal log method.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
level: The log level
|
|
43
|
+
message: The message to log
|
|
44
|
+
indent: Indentation level (0-2)
|
|
45
|
+
"""
|
|
46
|
+
if level == LogLevel.DEBUG and not self.verbose:
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
symbol, color = level.value
|
|
50
|
+
indent_str = " " * indent
|
|
51
|
+
time_str = f"{self.DIM}{self._format_time()}{self.RESET}"
|
|
52
|
+
|
|
53
|
+
print(f"{time_str} {color}{symbol}{self.RESET} {indent_str}{message}")
|
|
54
|
+
|
|
55
|
+
def section(self, title: str):
|
|
56
|
+
"""Print a section header.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
title: Section title
|
|
60
|
+
"""
|
|
61
|
+
if self._last_section:
|
|
62
|
+
print() # Add spacing between sections
|
|
63
|
+
print(f"\n{self.BOLD}{title}{self.RESET}")
|
|
64
|
+
print("─" * len(title))
|
|
65
|
+
self._last_section = title
|
|
66
|
+
|
|
67
|
+
def info(self, message: str, indent: int = 0):
|
|
68
|
+
"""Log info message."""
|
|
69
|
+
self._log(LogLevel.INFO, message, indent)
|
|
70
|
+
|
|
71
|
+
def success(self, message: str, indent: int = 0):
|
|
72
|
+
"""Log success message."""
|
|
73
|
+
self._log(LogLevel.SUCCESS, message, indent)
|
|
74
|
+
|
|
75
|
+
def warning(self, message: str, indent: int = 0):
|
|
76
|
+
"""Log warning message."""
|
|
77
|
+
self._log(LogLevel.WARNING, message, indent)
|
|
78
|
+
|
|
79
|
+
def error(self, message: str, indent: int = 0):
|
|
80
|
+
"""Log error message."""
|
|
81
|
+
self._log(LogLevel.ERROR, message, indent)
|
|
82
|
+
|
|
83
|
+
def debug(self, message: str, indent: int = 0):
|
|
84
|
+
"""Log debug message (only if verbose)."""
|
|
85
|
+
self._log(LogLevel.DEBUG, message, indent)
|
|
86
|
+
|
|
87
|
+
def system(self, message: str, indent: int = 0):
|
|
88
|
+
"""Log system/internal message."""
|
|
89
|
+
self._log(LogLevel.SYSTEM, message, indent)
|
|
90
|
+
|
|
91
|
+
def job_start(self, job_key: str, package: str, version: str):
|
|
92
|
+
"""Log job start with formatted output."""
|
|
93
|
+
self.section(f"Job {job_key[:8]}...")
|
|
94
|
+
self.info(f"Package: {self.BOLD}{package}{self.RESET} v{version}")
|
|
95
|
+
|
|
96
|
+
def heartbeat(self, count: int | None = None):
|
|
97
|
+
"""Log heartbeat in a minimal way."""
|
|
98
|
+
if self.verbose:
|
|
99
|
+
msg = f"Heartbeat {count}" if count else "Heartbeat"
|
|
100
|
+
self.debug(msg)
|
|
101
|
+
|
|
102
|
+
def package_status(self, package: str, version: str, status: str):
|
|
103
|
+
"""Log package download/extraction status.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
package: Package ID
|
|
107
|
+
version: Package version
|
|
108
|
+
status: Status message (downloading/extracting/cached)
|
|
109
|
+
"""
|
|
110
|
+
if status == "cached":
|
|
111
|
+
self.debug(f"Using cached {package}:{version}")
|
|
112
|
+
elif status == "downloading":
|
|
113
|
+
self.info(f"Downloading {package}:{version}")
|
|
114
|
+
elif status == "extracting":
|
|
115
|
+
self.info("Extracting package", indent=1)
|
|
116
|
+
elif status == "extracted":
|
|
117
|
+
self.success("Package ready", indent=1)
|
|
118
|
+
|
|
119
|
+
def environment_setup(self, status: str):
|
|
120
|
+
"""Log environment setup status.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
status: Status (creating/syncing/ready/skipped)
|
|
124
|
+
"""
|
|
125
|
+
if status == "creating":
|
|
126
|
+
self.info("Creating virtual environment", indent=1)
|
|
127
|
+
elif status == "syncing":
|
|
128
|
+
self.info("Installing dependencies", indent=1)
|
|
129
|
+
elif status == "ready":
|
|
130
|
+
self.success("Environment ready", indent=1)
|
|
131
|
+
elif status == "skipped":
|
|
132
|
+
self.debug("No pyproject.toml, skipping venv", indent=1)
|
|
133
|
+
|
|
134
|
+
def process_execution(self, status: str, detail: str | None = None):
|
|
135
|
+
"""Log process execution status.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
status: Status (starting/running/success/failed)
|
|
139
|
+
detail: Optional detail message
|
|
140
|
+
"""
|
|
141
|
+
if status == "starting":
|
|
142
|
+
self.info("Starting process execution")
|
|
143
|
+
elif status == "running":
|
|
144
|
+
self.system("Process running", indent=1)
|
|
145
|
+
elif status == "success":
|
|
146
|
+
self.success("Process completed successfully")
|
|
147
|
+
if detail:
|
|
148
|
+
self.debug(f"Output: {detail}", indent=1)
|
|
149
|
+
elif status == "failed":
|
|
150
|
+
self.error("Process execution failed")
|
|
151
|
+
if detail:
|
|
152
|
+
self.error(detail, indent=1)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# Global logger instance
|
|
156
|
+
_logger: CLILogger | None = None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def get_logger(verbose: bool = False) -> CLILogger:
|
|
160
|
+
"""Get or create the global logger instance.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
verbose: Enable verbose logging
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
The CLI logger instance
|
|
167
|
+
"""
|
|
168
|
+
global _logger
|
|
169
|
+
if _logger is None:
|
|
170
|
+
_logger = CLILogger(verbose=verbose)
|
|
171
|
+
return _logger
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def init_logger(verbose: bool = False):
|
|
175
|
+
"""Initialize the global logger.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
verbose: Enable verbose logging
|
|
179
|
+
"""
|
|
180
|
+
global _logger
|
|
181
|
+
_logger = CLILogger(verbose=verbose)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Models for the robot heartbeat functionality."""
|
|
2
|
+
|
|
3
|
+
from .heartbeat import (
|
|
4
|
+
Command,
|
|
5
|
+
CommandData,
|
|
6
|
+
HeartbeatData,
|
|
7
|
+
HeartbeatResponse,
|
|
8
|
+
JobError,
|
|
9
|
+
JobState,
|
|
10
|
+
SessionState,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Command",
|
|
15
|
+
"CommandData",
|
|
16
|
+
"HeartbeatData",
|
|
17
|
+
"JobError",
|
|
18
|
+
"JobState",
|
|
19
|
+
"SessionState",
|
|
20
|
+
"HeartbeatResponse",
|
|
21
|
+
]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Models for the heartbeat response from Orchestrator."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class JobState(str, Enum):
|
|
10
|
+
"""Job state enumeration."""
|
|
11
|
+
|
|
12
|
+
PENDING = "Pending"
|
|
13
|
+
RUNNING = "Running"
|
|
14
|
+
SUCCESSFUL = "Successful"
|
|
15
|
+
FAULTED = "Faulted"
|
|
16
|
+
STOPPING = "Stopping"
|
|
17
|
+
TERMINATING = "Terminating"
|
|
18
|
+
STOPPED = "Stopped"
|
|
19
|
+
SUSPENDED = "Suspended"
|
|
20
|
+
RESUMED = "Resumed"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SessionState(str, Enum):
|
|
24
|
+
"""Robot session state enumeration."""
|
|
25
|
+
|
|
26
|
+
AVAILABLE = "Available"
|
|
27
|
+
BUSY = "Busy"
|
|
28
|
+
DISCONNECTED = "Disconnected"
|
|
29
|
+
UNRESPONSIVE = "Unresponsive"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ExecutionSettings(BaseModel):
|
|
33
|
+
"""Execution settings for the robot."""
|
|
34
|
+
|
|
35
|
+
model_config = ConfigDict(extra="allow")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AuthSettings(BaseModel):
|
|
39
|
+
"""Authentication settings for the robot."""
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(extra="allow")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CommandData(BaseModel):
|
|
45
|
+
"""Data payload for a robot command."""
|
|
46
|
+
|
|
47
|
+
model_config = ConfigDict(
|
|
48
|
+
extra="allow", validate_by_name=True, validate_by_alias=True
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
process_settings: Any = Field(None, alias="processSettings")
|
|
52
|
+
video_recording_settings: Any = Field(None, alias="videoRecordingSettings")
|
|
53
|
+
package_id: str = Field(alias="packageId")
|
|
54
|
+
package_version: str = Field(alias="packageVersion")
|
|
55
|
+
target_framework: str = Field(alias="targetFramework")
|
|
56
|
+
username: str | None = None
|
|
57
|
+
password: str | None = None
|
|
58
|
+
credential_connection_data: Any = Field(None, alias="credentialConnectionData")
|
|
59
|
+
credential_type: int = Field(alias="credentialType")
|
|
60
|
+
process_name: str = Field(alias="processName")
|
|
61
|
+
persistence_id: str | None = Field(None, alias="persistenceId")
|
|
62
|
+
resume_version: str | None = Field(None, alias="resumeVersion")
|
|
63
|
+
resume_source: str | None = Field(None, alias="resumeSource")
|
|
64
|
+
suspend_blob_type: str | None = Field(None, alias="suspendBlobType")
|
|
65
|
+
input_arguments: str = Field(alias="inputArguments")
|
|
66
|
+
input_file: str | None = Field(None, alias="inputFile")
|
|
67
|
+
default_input_arguments: str | None = Field(None, alias="defaultInputArguments")
|
|
68
|
+
environment_variables: str | None = Field(None, alias="environmentVariables")
|
|
69
|
+
internal_arguments: str = Field(alias="internalArguments")
|
|
70
|
+
entry_point_path: str = Field(alias="entryPointPath")
|
|
71
|
+
feed_id: str | None = Field(alias="feedId")
|
|
72
|
+
feed_url: str = Field(alias="feedUrl")
|
|
73
|
+
requires_user_interaction: bool = Field(alias="requiresUserInteraction")
|
|
74
|
+
source: str
|
|
75
|
+
profiling_options: dict[str, Any] | None = Field(
|
|
76
|
+
alias="profilingOptions", default=None
|
|
77
|
+
)
|
|
78
|
+
job_source: str = Field(alias="jobSource")
|
|
79
|
+
job_key: str = Field(alias="jobKey")
|
|
80
|
+
process_key: str = Field(alias="processKey")
|
|
81
|
+
folder_id: int = Field(alias="folderId")
|
|
82
|
+
fully_qualified_folder_name: str = Field(alias="fullyQualifiedFolderName")
|
|
83
|
+
folder_key: str = Field(alias="folderKey")
|
|
84
|
+
folder_path: str = Field(alias="folderPath")
|
|
85
|
+
fps_context: str | None = Field(None, alias="fpsContext")
|
|
86
|
+
fps_properties: str | None = Field(None, alias="fpsProperties")
|
|
87
|
+
trace_id: str | None = Field(None, alias="traceId")
|
|
88
|
+
parent_span_id: str | None = Field(None, alias="parentSpanId")
|
|
89
|
+
root_span_id: str | None = Field(None, alias="rootSpanId")
|
|
90
|
+
id: str
|
|
91
|
+
type: str
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class UserDetails(BaseModel):
|
|
95
|
+
"""User details associated with the command."""
|
|
96
|
+
|
|
97
|
+
model_config = ConfigDict(extra="allow")
|
|
98
|
+
|
|
99
|
+
key: str
|
|
100
|
+
email: str
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Command(BaseModel):
|
|
104
|
+
"""Robot command from Orchestrator."""
|
|
105
|
+
|
|
106
|
+
model_config = ConfigDict(
|
|
107
|
+
extra="allow", validate_by_name=True, validate_by_alias=True
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
robot_key: str = Field(alias="robotKey")
|
|
111
|
+
username: str | None = None
|
|
112
|
+
robot_name: str = Field(alias="robotName")
|
|
113
|
+
robot_type: int = Field(alias="robotType")
|
|
114
|
+
machine_id: int = Field(alias="machineId")
|
|
115
|
+
has_license: bool = Field(alias="hasLicense")
|
|
116
|
+
is_external_licensed: bool = Field(alias="isExternalLicensed")
|
|
117
|
+
execution_settings: ExecutionSettings = Field(alias="executionSettings")
|
|
118
|
+
auth_settings: AuthSettings = Field(alias="authSettings")
|
|
119
|
+
data: CommandData
|
|
120
|
+
user_details: UserDetails = Field(alias="userDetails")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class HeartbeatResponse(BaseModel):
|
|
124
|
+
"""Response from the heartbeat endpoint containing robot commands."""
|
|
125
|
+
|
|
126
|
+
model_config = ConfigDict(
|
|
127
|
+
extra="allow", validate_by_name=True, validate_by_alias=True
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
commands: list[Command]
|
|
131
|
+
service_settings_stamp: str = Field(alias="serviceSettingsStamp")
|
|
132
|
+
is_unattended: bool = Field(alias="isUnattended")
|
|
133
|
+
is_unattended_licensed: bool = Field(alias="isUnattendedLicensed")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class JobError(BaseModel):
|
|
137
|
+
"""Job error details."""
|
|
138
|
+
|
|
139
|
+
model_config = ConfigDict(
|
|
140
|
+
extra="allow", validate_by_name=True, validate_by_alias=True
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
code: str
|
|
144
|
+
title: str
|
|
145
|
+
category: str
|
|
146
|
+
detail: str | None = None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class HeartbeatData(BaseModel):
|
|
150
|
+
"""Heartbeat data for job state submission."""
|
|
151
|
+
|
|
152
|
+
model_config = ConfigDict(
|
|
153
|
+
extra="allow", validate_by_name=True, validate_by_alias=True
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
robot_key: str = Field(alias="robotKey")
|
|
157
|
+
job_state: JobState = Field(alias="jobState")
|
|
158
|
+
job_key: str = Field(alias="jobKey")
|
|
159
|
+
info: str | None = None
|
|
160
|
+
process_key: str = Field(alias="processKey")
|
|
161
|
+
output_arguments: str | None = Field(None, alias="outputArguments")
|
|
162
|
+
robot_state: SessionState = Field(alias="robotState")
|
|
163
|
+
error: JobError | None = Field(None, alias="error")
|
|
164
|
+
trace_id: str | None = Field(None, alias="traceId")
|
|
165
|
+
parent_span_id: str | None = Field(None, alias="parentSpanId")
|
|
166
|
+
root_span_id: str | None = Field(None, alias="rootSpanId")
|
uipath/robot/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Module for handling machine authentication and token management."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class IdentityService:
|
|
11
|
+
"""Service for handling machine authentication and token management."""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
"""Initialize the IdentityService with environment variables."""
|
|
15
|
+
self.client_id = os.getenv("MACHINE_CLIENT_ID")
|
|
16
|
+
self.client_secret = os.getenv("MACHINE_CLIENT_SECRET")
|
|
17
|
+
self.base_url = os.getenv("UIPATH_URL")
|
|
18
|
+
self.organization_id = os.getenv("UIPATH_ORGANIZATION_ID")
|
|
19
|
+
self.tenant_id = os.getenv("UIPATH_TENANT_ID")
|
|
20
|
+
|
|
21
|
+
if not all(
|
|
22
|
+
[
|
|
23
|
+
self.client_id,
|
|
24
|
+
self.client_secret,
|
|
25
|
+
self.base_url,
|
|
26
|
+
self.organization_id,
|
|
27
|
+
self.tenant_id,
|
|
28
|
+
]
|
|
29
|
+
):
|
|
30
|
+
raise ValueError(
|
|
31
|
+
"Missing required environment variables: MACHINE_CLIENT_ID, MACHINE_CLIENT_SECRET, UIPATH_URL, UIPATH_ORGANIZATION_ID, UIPATH_TENANT_ID"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
self.access_token: str | None = None
|
|
35
|
+
self.token_expiry: datetime | None = None
|
|
36
|
+
|
|
37
|
+
async def get_token(self) -> str:
|
|
38
|
+
"""Get Service-to-Service access token using client credentials."""
|
|
39
|
+
assert self.base_url is not None
|
|
40
|
+
|
|
41
|
+
token_endpoint = f"{self.base_url.rstrip('/')}/identity_/connect/token"
|
|
42
|
+
|
|
43
|
+
data = {
|
|
44
|
+
"grant_type": "client_credentials",
|
|
45
|
+
"client_id": self.client_id,
|
|
46
|
+
"client_secret": self.client_secret,
|
|
47
|
+
"scope": "OrchestratorApiUserAccess",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async with httpx.AsyncClient() as client:
|
|
51
|
+
response = await client.post(
|
|
52
|
+
token_endpoint,
|
|
53
|
+
data=data,
|
|
54
|
+
)
|
|
55
|
+
response.raise_for_status()
|
|
56
|
+
|
|
57
|
+
token_data: dict[str, Any] = response.json()
|
|
58
|
+
self.access_token = token_data["access_token"]
|
|
59
|
+
|
|
60
|
+
expires_in = token_data.get("expires_in", 3600)
|
|
61
|
+
self.token_expiry = datetime.now() + timedelta(seconds=expires_in - 60)
|
|
62
|
+
|
|
63
|
+
os.environ["MACHINE_ACCESS_TOKEN"] = self.access_token
|
|
64
|
+
|
|
65
|
+
print(f"[{datetime.now()}] Token acquired successfully")
|
|
66
|
+
return self.access_token
|
|
67
|
+
|
|
68
|
+
def is_token_expired(self) -> bool:
|
|
69
|
+
"""Check if the current token is expired or about to expire."""
|
|
70
|
+
if not self.access_token or not self.token_expiry:
|
|
71
|
+
return True
|
|
72
|
+
return datetime.now() >= self.token_expiry
|
|
73
|
+
|
|
74
|
+
async def ensure_valid_token(self) -> str:
|
|
75
|
+
"""Ensure we have a valid token, refresh if needed."""
|
|
76
|
+
if self.is_token_expired():
|
|
77
|
+
await self.get_token()
|
|
78
|
+
assert self.access_token is not None
|
|
79
|
+
return self.access_token
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Orchestrator service to interact with UiPath Orchestrator APIs."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from uipath.robot.infra import get_logger
|
|
12
|
+
from uipath.robot.models import HeartbeatData, HeartbeatResponse
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OrchestratorService:
|
|
16
|
+
"""Service for interacting with UiPath Orchestrator APIs."""
|
|
17
|
+
|
|
18
|
+
def __init__(self):
|
|
19
|
+
"""Initialize the OrchestratorService with environment variables."""
|
|
20
|
+
self.logger = get_logger()
|
|
21
|
+
|
|
22
|
+
self.access_token = os.getenv("UIPATH_ACCESS_TOKEN")
|
|
23
|
+
self.base_url = os.getenv("UIPATH_URL")
|
|
24
|
+
|
|
25
|
+
if not self.access_token:
|
|
26
|
+
raise ValueError("Missing UIPATH_ACCESS_TOKEN environment variable")
|
|
27
|
+
if not self.base_url:
|
|
28
|
+
raise ValueError("Missing UIPATH_URL environment variable")
|
|
29
|
+
|
|
30
|
+
async def get_shared_connection_data(self) -> str | None:
|
|
31
|
+
"""Get shared connection data from Orchestrator.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
License key string if successful, None otherwise
|
|
35
|
+
"""
|
|
36
|
+
assert self.base_url is not None
|
|
37
|
+
|
|
38
|
+
url = f"{self.base_url.rstrip('/')}/orchestrator_/api/robotsservice/getsharedconnectiondata"
|
|
39
|
+
|
|
40
|
+
headers = {
|
|
41
|
+
"Authorization": f"Bearer {self.access_token}",
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async with httpx.AsyncClient() as client:
|
|
46
|
+
try:
|
|
47
|
+
response = await client.get(url, headers=headers)
|
|
48
|
+
response.raise_for_status()
|
|
49
|
+
|
|
50
|
+
data: dict[str, Any] = response.json()
|
|
51
|
+
license_key = data.get("licenseKey")
|
|
52
|
+
|
|
53
|
+
if license_key:
|
|
54
|
+
return license_key
|
|
55
|
+
else:
|
|
56
|
+
self.logger.error("No LicenseKey found in response")
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
except httpx.HTTPStatusError as e:
|
|
60
|
+
if e.response.status_code == 401:
|
|
61
|
+
self.logger.error("Unauthorized: Invalid or expired token")
|
|
62
|
+
elif e.response.status_code == 409:
|
|
63
|
+
self.logger.error(f"Conflict error occurred: {e.response.text}")
|
|
64
|
+
else:
|
|
65
|
+
self.logger.error(f"HTTP error: {e.response.status_code} - {e}")
|
|
66
|
+
return None
|
|
67
|
+
except httpx.HTTPError as e:
|
|
68
|
+
self.logger.error(f"Request failed: {e}")
|
|
69
|
+
return None
|
|
70
|
+
except Exception as e:
|
|
71
|
+
self.logger.error(f"Unexpected error: {e}")
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
async def download_package(
|
|
75
|
+
self,
|
|
76
|
+
package_id: str,
|
|
77
|
+
package_version: str,
|
|
78
|
+
output_path: Path,
|
|
79
|
+
feed_id: str | None = None,
|
|
80
|
+
) -> bool:
|
|
81
|
+
"""Download a package from Orchestrator.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
package_id: The package identifier
|
|
85
|
+
package_version: The package version
|
|
86
|
+
output_path: Path where to save the downloaded package
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
True if successful, False otherwise
|
|
90
|
+
"""
|
|
91
|
+
assert self.base_url is not None
|
|
92
|
+
|
|
93
|
+
url = f"{self.base_url.rstrip('/')}/orchestrator_/odata/Processes/UiPath.Server.Configuration.OData.DownloadPackage(key='{package_id}:{package_version}')"
|
|
94
|
+
|
|
95
|
+
params = {}
|
|
96
|
+
|
|
97
|
+
if feed_id:
|
|
98
|
+
params["feedId"] = feed_id
|
|
99
|
+
|
|
100
|
+
headers = {
|
|
101
|
+
"Authorization": f"Bearer {self.access_token}",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async with httpx.AsyncClient() as client:
|
|
105
|
+
try:
|
|
106
|
+
response = await client.get(url, params=params, headers=headers)
|
|
107
|
+
response.raise_for_status()
|
|
108
|
+
|
|
109
|
+
# Write the package content to file
|
|
110
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
with open(output_path, "wb") as f:
|
|
112
|
+
f.write(response.content)
|
|
113
|
+
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
except httpx.HTTPStatusError as e:
|
|
117
|
+
self.logger.error(
|
|
118
|
+
f"Failed to download package - HTTP {e.response.status_code}: {e}"
|
|
119
|
+
)
|
|
120
|
+
return False
|
|
121
|
+
except httpx.HTTPError as e:
|
|
122
|
+
self.logger.error(f"Failed to download package - Request error: {e}")
|
|
123
|
+
return False
|
|
124
|
+
except Exception as e:
|
|
125
|
+
self.logger.error(f"Failed to download package - Unexpected error: {e}")
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
async def heartbeat(self, license_key: str) -> HeartbeatResponse:
|
|
129
|
+
"""Send heartbeat to Orchestrator.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
license_key: The robot license key. If None, uses stored license_key.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
HeartbeatResponse object containing commands from Orchestrator
|
|
136
|
+
"""
|
|
137
|
+
assert self.base_url is not None
|
|
138
|
+
|
|
139
|
+
url = f"{self.base_url.rstrip('/')}/orchestrator_/api/robotsservice/heartbeatv2"
|
|
140
|
+
|
|
141
|
+
headers = {
|
|
142
|
+
**self._get_robot_headers(license_key),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
payload = {"CommandState": "All", "Heartbeats": []}
|
|
146
|
+
|
|
147
|
+
async with httpx.AsyncClient() as client:
|
|
148
|
+
try:
|
|
149
|
+
response = await client.post(url, headers=headers, json=payload)
|
|
150
|
+
response.raise_for_status()
|
|
151
|
+
data = response.json()
|
|
152
|
+
return HeartbeatResponse.model_validate(data)
|
|
153
|
+
|
|
154
|
+
except httpx.HTTPStatusError as e:
|
|
155
|
+
self.logger.error(
|
|
156
|
+
f"Heartbeat failed - HTTP {e.response.status_code}: {e}"
|
|
157
|
+
)
|
|
158
|
+
raise e
|
|
159
|
+
except httpx.HTTPError as e:
|
|
160
|
+
self.logger.error(f"Heartbeat failed - Request error: {e}")
|
|
161
|
+
raise e
|
|
162
|
+
except Exception as e:
|
|
163
|
+
self.logger.error(f"Heartbeat failed - Unexpected error: {e}")
|
|
164
|
+
raise e
|
|
165
|
+
|
|
166
|
+
async def start_service(self, license_key: str) -> bool:
|
|
167
|
+
"""Send start service request to Orchestrator.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
license_key: The robot license key.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
True if successful, False otherwise
|
|
174
|
+
"""
|
|
175
|
+
assert self.base_url is not None
|
|
176
|
+
|
|
177
|
+
url = (
|
|
178
|
+
f"{self.base_url.rstrip('/')}/orchestrator_/api/robotsservice/startservice"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
headers = {
|
|
182
|
+
**self._get_robot_headers(license_key),
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
payload: dict[str, Any] = {}
|
|
186
|
+
|
|
187
|
+
async with httpx.AsyncClient() as client:
|
|
188
|
+
try:
|
|
189
|
+
response = await client.post(url, headers=headers, json=payload)
|
|
190
|
+
response.raise_for_status()
|
|
191
|
+
return True
|
|
192
|
+
|
|
193
|
+
except httpx.HTTPStatusError as e:
|
|
194
|
+
self.logger.error(
|
|
195
|
+
f"Start service failed - HTTP {e.response.status_code}: {e}"
|
|
196
|
+
)
|
|
197
|
+
return False
|
|
198
|
+
except httpx.HTTPError as e:
|
|
199
|
+
self.logger.error(f"Start service failed - Request error: {e}")
|
|
200
|
+
return False
|
|
201
|
+
except Exception as e:
|
|
202
|
+
self.logger.error(f"Start service failed - Unexpected error: {e}")
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
async def stop_service(self, license_key: str) -> bool:
|
|
206
|
+
"""Send stop service request to Orchestrator.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
license_key: The robot license key.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
True if successful, False otherwise
|
|
213
|
+
"""
|
|
214
|
+
assert self.base_url is not None
|
|
215
|
+
|
|
216
|
+
url = f"{self.base_url.rstrip('/')}/orchestrator_/api/robotsservice/stopservice"
|
|
217
|
+
|
|
218
|
+
headers = {
|
|
219
|
+
**self._get_robot_headers(license_key),
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
payload: dict[str, Any] = {}
|
|
223
|
+
|
|
224
|
+
async with httpx.AsyncClient() as client:
|
|
225
|
+
try:
|
|
226
|
+
response = await client.post(url, headers=headers, json=payload)
|
|
227
|
+
response.raise_for_status()
|
|
228
|
+
return True
|
|
229
|
+
|
|
230
|
+
except httpx.HTTPStatusError as e:
|
|
231
|
+
self.logger.error(
|
|
232
|
+
f"Stop service failed - HTTP {e.response.status_code}: {e}"
|
|
233
|
+
)
|
|
234
|
+
return False
|
|
235
|
+
except httpx.HTTPError as e:
|
|
236
|
+
self.logger.error(f"Stop service failed - Request error: {e}")
|
|
237
|
+
return False
|
|
238
|
+
except Exception as e:
|
|
239
|
+
self.logger.error(f"Stop service failed - Unexpected error: {e}")
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
async def submit_job_state(
|
|
243
|
+
self, heartbeats: list[HeartbeatData], license_key: str
|
|
244
|
+
) -> bool:
|
|
245
|
+
"""Submit job states to Orchestrator.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
heartbeats: List of heartbeat data containing job states
|
|
249
|
+
license_key: The robot license key
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
True if successful, False otherwise
|
|
253
|
+
"""
|
|
254
|
+
assert self.base_url is not None
|
|
255
|
+
|
|
256
|
+
url = f"{self.base_url.rstrip('/')}/orchestrator_/api/robotsservice/SubmitJobState"
|
|
257
|
+
|
|
258
|
+
headers = {
|
|
259
|
+
**self._get_robot_headers(license_key),
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
payload = [heartbeat.model_dump(by_alias=True) for heartbeat in heartbeats]
|
|
263
|
+
|
|
264
|
+
async with httpx.AsyncClient() as client:
|
|
265
|
+
try:
|
|
266
|
+
response = await client.post(url, headers=headers, json=payload)
|
|
267
|
+
response.raise_for_status()
|
|
268
|
+
return True
|
|
269
|
+
|
|
270
|
+
except httpx.HTTPStatusError as e:
|
|
271
|
+
self.logger.error(
|
|
272
|
+
f"Submit job state failed - HTTP {e.response.status_code}: {e}"
|
|
273
|
+
)
|
|
274
|
+
self.logger.error(f"Response: {e.response.text}")
|
|
275
|
+
return False
|
|
276
|
+
except httpx.HTTPError as e:
|
|
277
|
+
self.logger.error(f"Submit job state failed - Request error: {e}")
|
|
278
|
+
return False
|
|
279
|
+
except Exception as e:
|
|
280
|
+
self.logger.error(f"Submit job state failed - Unexpected error: {e}")
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
def _get_robot_headers(self, license_key: str) -> dict[str, str]:
|
|
284
|
+
"""Get robot-specific headers for heartbeat calls.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
license_key: The robot license key
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
Dictionary of robot headers
|
|
291
|
+
"""
|
|
292
|
+
machine = platform.node()
|
|
293
|
+
hostname = f"{machine}-py"
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
"X-ROBOT-VERSION": "24.10",
|
|
297
|
+
"X-ROBOT-MACHINE": hostname,
|
|
298
|
+
"X-ROBOT-MACHINE-ENCODED": base64.b64encode(
|
|
299
|
+
hostname.encode("utf-8")
|
|
300
|
+
).decode("utf-8"),
|
|
301
|
+
"X-ROBOT-LICENSE": license_key,
|
|
302
|
+
"X-ROBOT-AGENT": "OS=Windows",
|
|
303
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: uipath-robot
|
|
3
|
+
Version: 0.0.3
|
|
4
|
+
Summary: UiPath Robot Simulator
|
|
5
|
+
Project-URL: Homepage, https://uipath.com
|
|
6
|
+
Project-URL: Documentation, https://uipath.github.io/uipath-python/
|
|
7
|
+
Maintainer-email: Cristian Pufu <cristian.pufu@uipath.com>
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: httpx>=0.28.1
|
|
16
|
+
Requires-Dist: pydantic<3.0.0,>=2.12.5
|
|
17
|
+
Requires-Dist: uipath>=2.4.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# UiPath Robot
|
|
21
|
+
|
|
22
|
+
[](https://pypi.org/project/uipath-robot/)
|
|
23
|
+
[](https://img.shields.io/pypi/v/uipath-robot)
|
|
24
|
+
[](https://pypi.org/project/uipath-robot/)
|
|
25
|
+
|
|
26
|
+
Robot simulator
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
uipath/robot/__init__.py,sha256=gepn5ptexouVNXPLg57lUh3hZk_mNKJigt228mn09Wg,17486
|
|
2
|
+
uipath/robot/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
uipath/robot/infra/__init__.py,sha256=r3uLvwBRZ9KiAjKPv71bAAdCMXA2yD7TcJuYPir4n7s,147
|
|
4
|
+
uipath/robot/infra/logger.py,sha256=rrHINv5zn18Cu41OAf1sSRPNisEmlST_zBDfmDnRr2s,5654
|
|
5
|
+
uipath/robot/models/__init__.py,sha256=MrFMohE9xtbXZTYyvvUm2vKXhT46M1ykHciIl_xFyGI,345
|
|
6
|
+
uipath/robot/models/heartbeat.py,sha256=_9SMyMfgJQYOWAZfnOObrPLwF4_zxZO0IPkM3NAzRhw,5677
|
|
7
|
+
uipath/robot/services/__init__.py,sha256=zLA_yMsrXTexcCE_lIa3TkMb9aN-LUgHXxL1ddPR58w,175
|
|
8
|
+
uipath/robot/services/identity.py,sha256=1PGrSBxtCyKhET8wpSl_-b3b2zkskYRTiV_NpDjnUnU,2774
|
|
9
|
+
uipath/robot/services/orchestrator.py,sha256=obX2hFzmwtBVemX_k1nD4c64HaZNausXeWrNRFhBU3g,10342
|
|
10
|
+
uipath_robot-0.0.3.dist-info/METADATA,sha256=oFOP7rCwsfehrgancc8szE2YOlVJlhU_4bhCzjcyjrU,1063
|
|
11
|
+
uipath_robot-0.0.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
12
|
+
uipath_robot-0.0.3.dist-info/entry_points.txt,sha256=AvnQfA63XXwIbb4EuWYd_J2Iqc2zwX3MGNglfUIxMpw,60
|
|
13
|
+
uipath_robot-0.0.3.dist-info/licenses/LICENSE,sha256=-KBavWXepyDjimmzH5fVAsi-6jNVpIKFc2kZs0Ri4ng,1058
|
|
14
|
+
uipath_robot-0.0.3.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright 2025 UiPath
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|