tft-cli 0.0.26__py3-none-any.whl → 0.0.28__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.
@@ -0,0 +1,836 @@
1
+ # Copyright Contributors to the Testing Farm project.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import io
5
+ import json
6
+ import re
7
+ import sys
8
+ import urllib.parse
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from enum import Enum
11
+ from typing import Any, List, Optional, Tuple
12
+
13
+ import pendulum
14
+ import requests
15
+ import typer
16
+ from click.core import ParameterSource
17
+ from rich.progress import Progress, SpinnerColumn
18
+ from rich.syntax import Syntax
19
+ from rich.table import Table # type: ignore
20
+ from ruamel.yaml import YAML # type: ignore
21
+
22
+ from tft.cli.commands import (
23
+ ARGUMENT_API_TOKEN,
24
+ ARGUMENT_API_URL,
25
+ ARGUMENT_INTERNAL_API_URL,
26
+ PipelineState,
27
+ )
28
+ from tft.cli.config import settings
29
+ from tft.cli.utils import (
30
+ Age,
31
+ OutputFormat,
32
+ authorization_headers,
33
+ check_unexpected_arguments,
34
+ console,
35
+ exit_error,
36
+ extract_uuid,
37
+ install_http_retries,
38
+ uuid_valid,
39
+ )
40
+
41
+ # Maximum lenght of compose which is still shown in table listing
42
+ MAX_COMPOSE_LENGTH = 30
43
+
44
+
45
+ class Ranch(str, Enum):
46
+ public = "public"
47
+ redhat = "redhat"
48
+
49
+
50
+ def get_artifacts_url(request):
51
+ """
52
+ Extract artifacts URL from request.
53
+ """
54
+ return (request.get('run', {}) or {}).get('artifacts', '<unavailable>')
55
+
56
+
57
+ def get_ranch(artifacts_url):
58
+ """
59
+ Deduce ranch according to artifacts url.
60
+ """
61
+ if 'unavailable' in artifacts_url:
62
+ return '<unknown>'
63
+
64
+ if 'redhat.com' in artifacts_url:
65
+ return 'redhat'
66
+
67
+ if 'testing-farm.io' in artifacts_url:
68
+ return 'public'
69
+
70
+ return '<unrecognized ranch>'
71
+
72
+
73
+ def get_ranch_colored(artifacts_url):
74
+ """
75
+ Deduce ranch according to artifacts url and return with color formatting.
76
+ """
77
+ ranch = get_ranch(artifacts_url)
78
+
79
+ if ranch == 'redhat':
80
+ return '[red]redhat[/red]'
81
+ elif ranch == 'public':
82
+ return '[blue]public[/blue]'
83
+ else:
84
+ return ranch
85
+
86
+
87
+ def calculate_started_time(request):
88
+ """
89
+ Calculate started time as: created + queued_time
90
+ Returns None if calculation cannot be performed (missing data)
91
+ """
92
+ try:
93
+ created_str = request.get('created')
94
+ queued_time = request.get('queued_time')
95
+
96
+ if not created_str or queued_time is None:
97
+ return None
98
+
99
+ # Parse created datetime
100
+ created_dt = pendulum.parse(created_str, tz="UTC")
101
+
102
+ # Add queued_time (in seconds)
103
+ started_dt = created_dt.add(seconds=float(queued_time))
104
+
105
+ return started_dt
106
+
107
+ except (ValueError, TypeError, AttributeError):
108
+ return None
109
+
110
+
111
+ def calculate_finished_time(request):
112
+ """
113
+ Calculate finished time as: created + queued_time + run_time
114
+ Returns None if calculation cannot be performed (missing data or request not finished)
115
+ """
116
+ try:
117
+ # Only calculate for completed requests
118
+ if request.get('state') not in ['complete', 'error', 'canceled']:
119
+ return None
120
+
121
+ created_str = request.get('created')
122
+ queued_time = request.get('queued_time')
123
+ run_time = request.get('run_time')
124
+
125
+ if not created_str or queued_time is None or run_time is None:
126
+ return None
127
+
128
+ # Parse created datetime
129
+ created_dt = pendulum.parse(created_str, tz="UTC")
130
+
131
+ # Add queued_time and run_time (both in seconds)
132
+ total_seconds = float(queued_time) + float(run_time)
133
+ finished_dt = created_dt.add(seconds=total_seconds)
134
+
135
+ return finished_dt
136
+
137
+ except (ValueError, TypeError, AttributeError):
138
+ return None
139
+
140
+
141
+ def render_reservation_table(requests_json: Any, show_utc: bool) -> None:
142
+ """Show list of reservation requests as a special table."""
143
+ table = Table(show_header=True, header_style="bold magenta", expand=True)
144
+
145
+ for column in ["state", "id", "ranch", "env", "user@ip", "started"]:
146
+ table.add_column(column, justify="left" if column in ["env", "user@ip", "id"] else "center")
147
+
148
+ def extract_guest_ip(request):
149
+ """Extract guest IP from pipeline log."""
150
+ artifacts_url = get_artifacts_url(request)
151
+ if artifacts_url == '<unavailable>':
152
+ return request, "<not-yet-available>"
153
+
154
+ try:
155
+ import requests
156
+
157
+ session = requests.Session()
158
+ pipeline_log = session.get(f"{artifacts_url}/pipeline.log").text
159
+ guests = re.findall(r'Guest is ready.*root@([\d\w\.-]+)', pipeline_log)
160
+ return request, f"root@{guests[0]}" if guests else "<not-yet-available>"
161
+ except: # noqa: E722
162
+ return request, "<not-yet-available>"
163
+
164
+ # Filter only active reservation requests (new, queued, running)
165
+ reservation_requests = []
166
+ for request in requests_json:
167
+ # Only include active states
168
+ if request.get('state') not in ['new', 'queued', 'running']:
169
+ continue
170
+ # Check if this is a reservation request by looking for reservation indicators
171
+ environments = request.get('environments_requested', [])
172
+ for env in environments:
173
+ variables = env.get('variables') or {}
174
+ if 'TF_RESERVATION_DURATION' in variables:
175
+ reservation_requests.append(request)
176
+ break
177
+
178
+ if not reservation_requests:
179
+ console.print("No active reservations found")
180
+ return
181
+
182
+ # Sort reservations
183
+ sorted_requests = sorted(reservation_requests, key=lambda request: request['created'], reverse=True)
184
+
185
+ # Extract IPs in parallel
186
+ with ThreadPoolExecutor(max_workers=5) as executor:
187
+ ip_results = list(executor.map(extract_guest_ip, sorted_requests))
188
+
189
+ # Create IP lookup map
190
+ ip_map = {req['id']: ip for req, ip in ip_results}
191
+
192
+ for request in sorted_requests:
193
+ artifacts_url = get_artifacts_url(request)
194
+ ranch = get_ranch_colored(artifacts_url)
195
+
196
+ # Get environment info
197
+ envs = []
198
+ for environment in request['environments_requested']:
199
+ arch = environment['arch']
200
+ os_info = environment.get('os') or {}
201
+ os_compose = os_info.get('compose')
202
+ if os_compose:
203
+ if len(os_compose) > MAX_COMPOSE_LENGTH:
204
+ envs.append(f"{arch:>7} (<too-long>)")
205
+ else:
206
+ envs.append(f"{arch:>7} ({os_compose})")
207
+ else:
208
+ envs.append(f"{arch:>7} (container)")
209
+ envs = list(dict.fromkeys(envs))
210
+
211
+ # Get time info
212
+ parsed_time = pendulum.parse(request['created'], tz="UTC")
213
+ if show_utc:
214
+ localized_time = parsed_time
215
+ else:
216
+ localized_time = parsed_time.in_timezone(pendulum.local_timezone()) # type: ignore
217
+ time_display = localized_time.to_datetime_string()
218
+ if show_utc:
219
+ time_display += " UTC"
220
+
221
+ # Get guest IP from pre-computed map
222
+ user_ip = ip_map.get(request['id'], "<not-yet-available>")
223
+
224
+ row = [
225
+ request.get('state', 'unknown'),
226
+ request['id'], # Show full request ID
227
+ ranch,
228
+ "\n".join(envs),
229
+ user_ip,
230
+ time_display,
231
+ ]
232
+
233
+ table.add_row(*row)
234
+
235
+ if sys.stdin.isatty():
236
+ console.print(table)
237
+ return
238
+
239
+ print(table)
240
+
241
+
242
+ def render_table(
243
+ requests_json: Any, show_token_id: bool, show_time: bool, show_utc: bool, ranch: Optional[str]
244
+ ) -> None:
245
+ """
246
+ Show list of requests as a table.
247
+ """
248
+ table = Table(show_header=True, header_style="bold magenta", expand=True)
249
+
250
+ for column in ["artifacts", "state", "ranch", "type", "env", "git", "created", "started", "finished"]:
251
+ table.add_column(column, justify="left" if column in ["env", "git"] else "center")
252
+
253
+ if show_token_id:
254
+ table.add_column("token id")
255
+
256
+ def shorten_git_url(url: str) -> Tuple[str, ...]:
257
+ orig_url = url
258
+
259
+ url = url.replace("https://github.com/", "[green] github[/green] ")
260
+ url = url.replace("https://gitlab.com/", "[orange_red1] gitlab[/orange_red1] ")
261
+ url = url.replace("https://*****@gitlab.com/redhat/", "[dark_orange3] gitlab-rh[/dark_orange3] ")
262
+ url = url.replace("https://*****@gitlab.com/", "[orange_red1] gitlab[/orange_red1] ")
263
+ url = url.replace("https://gitlab.cee.redhat.com/", "[dark_orange] gitlab-cee[/dark_orange] ")
264
+ url = url.replace("https://*****@gitlab.cee.redhat.com/", "[dark_orange] gitlab-cee[/dark_orange] ")
265
+ url = url.replace("https://pkgs.devel.redhat.com/", "[red3] rhel[/red3] ")
266
+ url = url.replace("https://src.fedoraproject.org/", "[bright_blue] fedora[/bright_blue] ")
267
+
268
+ if url == orig_url:
269
+ return "", orig_url
270
+
271
+ return tuple(url.rsplit(maxsplit=1))
272
+
273
+ def get_state_icon(request):
274
+ """
275
+ Transforms the state and result into a single state of the request
276
+ """
277
+ if request["state"] == "new":
278
+ return "🆕"
279
+ if request["state"] == "queued":
280
+ return "⌛️"
281
+ if request["state"] == "running":
282
+ return "🚀"
283
+ if request["state"] in ("canceled", "cancel-requested"):
284
+ return "🚫"
285
+ if request["state"] == "error":
286
+ return "🔥"
287
+ if request["state"] != "complete":
288
+ exit_error("Invalid state {state}")
289
+ if request["result"]["overall"] == "passed":
290
+ return "✅"
291
+ if request["result"]["overall"] == "failed":
292
+ return "❌"
293
+ if request["result"]["overall"] == "error":
294
+ return "⛔️"
295
+ if request["result"]["overall"] == "skipped":
296
+ return "⤼"
297
+ return "<unknown>"
298
+
299
+ for request in sorted(requests_json, key=lambda request: request['created'], reverse=True):
300
+ request_type = "fmf" if request["test"].get("fmf") else "sti"
301
+ request_type_human = "[blue]tmt[/blue]" if request_type == "fmf" else "[yellow]sti[/yellow]"
302
+ url = request['test'][request_type].get('url')
303
+ ref = request['test'][request_type].get('ref')
304
+ artifacts_url = get_artifacts_url(request)
305
+ short_ref = ref[:8] if len(ref) == 40 else ref
306
+ envs = []
307
+ for environment in request['environments_requested']:
308
+ arch = environment['arch']
309
+ os_info = environment.get('os') or {}
310
+ os_compose = os_info.get('compose')
311
+ if os_compose:
312
+ # Check if compose contains disk_image or boot_image and display <hidden-flasher-image> instead
313
+ if len(os_compose) > 20:
314
+ envs.append(f"{arch:>7} (<too-long>)")
315
+ else:
316
+ envs.append(f"{arch:>7} ({os_compose})")
317
+ else:
318
+ envs.append(f"{arch:>7} (container)")
319
+ envs = list(dict.fromkeys(envs)) # Remove duplicates while preserving order
320
+
321
+ git_type, git_url = shorten_git_url(url)
322
+
323
+ # Calculate all three times: created, started, finished
324
+ created_dt = pendulum.parse(request['created'], tz="UTC")
325
+ started_dt = calculate_started_time(request)
326
+ finished_dt = calculate_finished_time(request)
327
+
328
+ def format_time_display(dt):
329
+ if dt is None:
330
+ return "N/A"
331
+ localized_time = dt if show_utc else dt.in_timezone(pendulum.local_timezone())
332
+ if show_time:
333
+ display = localized_time.to_datetime_string()
334
+ if show_utc:
335
+ display += " UTC"
336
+ return display
337
+ else:
338
+ return dt.diff_for_humans()
339
+
340
+ created_display = format_time_display(created_dt)
341
+ started_display = format_time_display(started_dt)
342
+ finished_display = format_time_display(finished_dt)
343
+
344
+ row = [
345
+ f"[link={artifacts_url}]{request['id']}[/link]" if artifacts_url != '<unavailable>' else '<unavailable>',
346
+ get_state_icon(request),
347
+ get_ranch_colored(artifacts_url),
348
+ f"[yellow]{request_type_human}[/yellow]",
349
+ "\n".join(envs),
350
+ f"{git_type} [link={url}]{git_url}[/link] [green]({short_ref})[/green]",
351
+ created_display,
352
+ started_display,
353
+ finished_display,
354
+ ]
355
+
356
+ if show_token_id:
357
+ row.append(request.get('token_id', 'N/A'))
358
+
359
+ table.add_row(*row)
360
+
361
+ if sys.stdin.isatty():
362
+ console.print(table)
363
+ return
364
+
365
+ print(table)
366
+
367
+
368
+ def _format_datetime_str(dt_str, show_utc=False):
369
+ """Formats an ISO datetime string to a more readable format."""
370
+
371
+ if not dt_str or dt_str == 'N/A':
372
+ return "N/A"
373
+ try:
374
+ # Parse with pendulum, handling UTC properly
375
+ dt_obj = pendulum.parse(dt_str, tz="UTC")
376
+
377
+ if show_utc:
378
+ # Show in UTC
379
+ return dt_obj.format("YYYY-MM-DD [at] HH:mm:ss") + " UTC"
380
+ else:
381
+ # Show in local timezone by default
382
+ local_dt = dt_obj.in_timezone(pendulum.local_timezone())
383
+ tz_name = local_dt.timezone_name
384
+ return local_dt.format("YYYY-MM-DD [at] HH:mm:ss") + f" {tz_name}"
385
+ except (ValueError, TypeError):
386
+ # if parsing fails, return the original string
387
+ return dt_str
388
+
389
+
390
+ def _format_time(seconds):
391
+ """Converts seconds to a human-readable format."""
392
+
393
+ if seconds is None:
394
+ return "N/A"
395
+ try:
396
+ seconds = float(seconds)
397
+ minutes, seconds = divmod(seconds, 60)
398
+ return f"{int(minutes)}m {seconds:.2f}s"
399
+ except (ValueError, TypeError):
400
+ return "N/A"
401
+
402
+
403
+ def _has_meaningful_content(data: dict[str, Any]) -> bool:
404
+ """Check if a dictionary has any meaningful content (non-None, non-empty values)."""
405
+ for value in data.values():
406
+ if value is None:
407
+ continue
408
+ if isinstance(value, (list, dict)) and len(value) == 0:
409
+ continue
410
+ if isinstance(value, dict):
411
+ if _has_meaningful_content(value):
412
+ return True
413
+ else:
414
+ return True
415
+ return False
416
+
417
+
418
+ def _print_nested_dict(table: Any, data: dict[str, Any], indent_level: int = 0):
419
+ """Recursively prints a nested dictionary, skipping None values and empty collections."""
420
+
421
+ prefix = " " * indent_level
422
+ for key, value in data.items():
423
+ if value is None:
424
+ continue
425
+ # Skip empty collections (lists, dicts)
426
+ if isinstance(value, (list, dict)) and len(value) == 0:
427
+ continue
428
+ if isinstance(value, dict):
429
+ table.add_row(f"{prefix}[bold]{key}[/bold]", "")
430
+ _print_nested_dict(table, value, indent_level + 1)
431
+ else:
432
+ table.add_row(f"{prefix}{key}", str(value))
433
+
434
+
435
+ def render_text(requests_json: Any, brief: bool, show_utc: bool = False, show_token_id: bool = False) -> None:
436
+ """Show list of requests as a text."""
437
+
438
+ header_style = "bold magenta"
439
+
440
+ # enumerate and print request metadata
441
+ for i, request_item in enumerate(requests_json):
442
+ if not request_item:
443
+ continue
444
+
445
+ table = Table(show_header=False, box=None, padding=(0, 1))
446
+ table.add_column()
447
+ table.add_column()
448
+
449
+ # Get artifacts URL and ranch
450
+ artifacts_url = get_artifacts_url(request_item)
451
+ ranch = get_ranch_colored(artifacts_url)
452
+
453
+ table.add_row(f"[{header_style}]Artifacts[/{header_style}]", f"[link={artifacts_url}]{artifacts_url}[/link]")
454
+ table.add_row(f"[{header_style}]Ranch[/{header_style}]", ranch)
455
+ table.add_row(f"[{header_style}]State[/{header_style}]", request_item.get('state'))
456
+
457
+ if show_token_id:
458
+ table.add_row(f"[{header_style}]Token ID[/{header_style}]", request_item.get('token_id', 'N/A'))
459
+ result = request_item.get('result', {})
460
+ if result:
461
+ table.add_row(f"[{header_style}]Result[/{header_style}]", result.get('overall'))
462
+ if result.get('summary'):
463
+ table.add_row(f"[{header_style}]Summary[/{header_style}]", result.get('summary'))
464
+
465
+ table.add_row(
466
+ f"[{header_style}]Created[/{header_style}]",
467
+ _format_datetime_str(request_item.get('created'), show_utc=show_utc),
468
+ )
469
+
470
+ # Add started time if available
471
+ started_dt = calculate_started_time(request_item)
472
+ if started_dt:
473
+ started_localized = started_dt if show_utc else started_dt.in_timezone(pendulum.local_timezone())
474
+ started_display = started_localized.format("YYYY-MM-DD [at] HH:mm:ss")
475
+ if show_utc:
476
+ started_display += " UTC"
477
+ else:
478
+ started_display += f" {started_localized.timezone_name}"
479
+ table.add_row(f"[{header_style}]Started[/{header_style}]", started_display)
480
+
481
+ # Add finished time if available
482
+ finished_dt = calculate_finished_time(request_item)
483
+ if finished_dt:
484
+ finished_localized = finished_dt if show_utc else finished_dt.in_timezone(pendulum.local_timezone())
485
+ finished_display = finished_localized.format("YYYY-MM-DD [at] HH:mm:ss")
486
+ if show_utc:
487
+ finished_display += " UTC"
488
+ else:
489
+ finished_display += f" {finished_localized.timezone_name}"
490
+ table.add_row(f"[{header_style}]Finished[/{header_style}]", finished_display)
491
+
492
+ if not brief:
493
+ table.add_row(
494
+ f"[{header_style}]Queued Time[/{header_style}]", _format_time(request_item.get('queued_time'))
495
+ )
496
+ table.add_row(f"[{header_style}]Run Time[/{header_style}]", _format_time(request_item.get('run_time')))
497
+
498
+ if 'test' in request_item and request_item['test']:
499
+ table.add_row(f"[{header_style}]Test[/{header_style}]", "")
500
+ _print_nested_dict(table, request_item['test'], 1)
501
+
502
+ if 'environments_requested' in request_item and request_item['environments_requested']:
503
+ table.add_row(f"[{header_style}]Environments[/{header_style}]", "")
504
+ for i, env in enumerate(request_item['environments_requested']):
505
+ table.add_row(f" [bold]Environment {i+1}[/bold]", "")
506
+ _print_nested_dict(table, env, 2)
507
+
508
+ if (
509
+ 'settings' in request_item
510
+ and request_item.get('settings')
511
+ and _has_meaningful_content(request_item['settings'])
512
+ ):
513
+ table.add_row(f"[{header_style}]Settings[/{header_style}]", "")
514
+ _print_nested_dict(table, request_item['settings'], 1)
515
+
516
+ if 'user' in request_item and request_item.get('user') and _has_meaningful_content(request_item['user']):
517
+ table.add_row(f"[{header_style}]User[/{header_style}]", "")
518
+ _print_nested_dict(table, request_item['user'], 1)
519
+
520
+ console.print(table)
521
+
522
+ # visual boundary between test requests
523
+ if i < len(requests_json) - 1:
524
+ console.print("─" * 15)
525
+ else:
526
+ console.print()
527
+
528
+
529
+ def listing(
530
+ context: typer.Context,
531
+ api_token: str = ARGUMENT_API_TOKEN,
532
+ api_url: str = ARGUMENT_API_URL,
533
+ internal_api_url: str = ARGUMENT_INTERNAL_API_URL,
534
+ states: List[PipelineState] = typer.Option(
535
+ [state for state in PipelineState],
536
+ "--state",
537
+ help=(
538
+ "State of requests to show, by default all requests created in the past day are shown. "
539
+ "Can be specified multiple times."
540
+ ),
541
+ ),
542
+ mine: bool = typer.Option(
543
+ True, '--mine/--all', help="Show only my requests or all requests. By default only your requests are shown."
544
+ ),
545
+ age: Age = typer.Option(
546
+ "1d",
547
+ parser=lambda value: Age.from_string(value),
548
+ metavar="AGE",
549
+ help=(
550
+ "Maximum age of the request (based on created time) represented in [VALUE][UNIT] format. "
551
+ f"Accepted units are: {Age.available_units()}"
552
+ ),
553
+ ),
554
+ min_age: Optional[Age] = typer.Option(
555
+ None,
556
+ parser=lambda value: Age.from_string(value),
557
+ metavar="MINIMUM_AGE",
558
+ help=(
559
+ "Minimum age of the request (based on created time) represented in [VALUE][UNIT] format. "
560
+ f"Accepted units are: {Age.available_units()}"
561
+ ),
562
+ ),
563
+ format: OutputFormat = typer.Option(
564
+ "table", help=f"Output format to use. Possible formats: {OutputFormat.available_formats()}"
565
+ ),
566
+ show_time: bool = typer.Option(
567
+ False, help="Show date instead of human readable diff in text output, i.e. 1 hour ago"
568
+ ),
569
+ show_utc: bool = typer.Option(False, help="Show UTC time instead of local timezone"),
570
+ show_secrets: bool = typer.Option(
571
+ False, help="Show secrets. When listing all requests this requires 'admin' privileges."
572
+ ),
573
+ show_token_id: bool = typer.Option(
574
+ False, "--show-token-id", help="Show token ID submitting the request. Requires admin token."
575
+ ),
576
+ ranch: Optional[Ranch] = typer.Option(
577
+ None,
578
+ help=(
579
+ "For your requests ranch is enforced by the given token. "
580
+ "When listing all requests, you can use this option to restrict listing for a specific ranch"
581
+ ),
582
+ ),
583
+ brief: bool = typer.Option(False, "--brief", help="Show brief output (only basic information)."),
584
+ ids: Optional[List[str]] = typer.Option(
585
+ None, "--id", help="Request ID(s) to show. Can be specified multiple times or contain partial UUID strings."
586
+ ),
587
+ token_id: Optional[str] = typer.Option(
588
+ None, "--token-id", help="Show requests for a specific token ID. Must be a valid UUID4."
589
+ ),
590
+ reserve: bool = typer.Option(False, "-r", "--reservations", help="Show active reservations."),
591
+ ):
592
+ """
593
+ List Testing Farm requests.
594
+
595
+ By default all your requests are shown.
596
+
597
+ The ranch is detected from your token.
598
+
599
+ State emojis in table format (combine request state and overall result):
600
+ 🆕 new, ⌛️ queued, 🚀 running, 🚫 canceled, 🔥 infrastructure error,
601
+ ✅ passed, ❌ failed, ⛔️ test error, ⤼ skipped
602
+ """
603
+ # Accept these arguments only via environment variables
604
+ check_unexpected_arguments(context, "api_url", "api_token")
605
+
606
+ # Validate conflicting options
607
+ if ids:
608
+ if context.get_parameter_source("mine") == ParameterSource.COMMANDLINE:
609
+ if mine:
610
+ exit_error(
611
+ "The '--id' option conflicts with '--mine'. "
612
+ "When specifying request IDs, ownership filtering is not applicable."
613
+ )
614
+ else:
615
+ exit_error(
616
+ "The '--id' option conflicts with '--all'. "
617
+ "When specifying request IDs, ownership filtering is not applicable."
618
+ )
619
+
620
+ if context.get_parameter_source("age") == ParameterSource.COMMANDLINE:
621
+ exit_error(
622
+ "The '--id' option conflicts with '--age'. "
623
+ "When specifying request IDs, age filtering is not applicable."
624
+ )
625
+
626
+ if context.get_parameter_source("min_age") == ParameterSource.COMMANDLINE:
627
+ exit_error(
628
+ "The '--id' option conflicts with '--min-age'. "
629
+ "When specifying request IDs, age filtering is not applicable."
630
+ )
631
+
632
+ if reserve:
633
+ exit_error(
634
+ "The '--reservations' option cannot be used with '--id'. "
635
+ "Use '--reservations' without specifying request IDs."
636
+ )
637
+
638
+ elif show_secrets:
639
+ exit_error("The '--show-secrets' option can be used only with '--id' option.")
640
+
641
+ # Validate reserve conflicts with explicit format
642
+ if reserve and context.get_parameter_source("format") == ParameterSource.COMMANDLINE:
643
+ exit_error(
644
+ "The '--reservations' option conflicts with explicit '--format'. "
645
+ "Reservations use a specialized table format that cannot be changed."
646
+ )
647
+
648
+ # Validate ranch conflicts with mine
649
+ if mine and ranch:
650
+ exit_error(
651
+ "The '--ranch' option conflicts with '--mine'. "
652
+ "When showing your own requests, ranch filtering is not applicable."
653
+ )
654
+
655
+ # Validate token_id
656
+ if token_id:
657
+ # Validate UUID4 format
658
+ if not uuid_valid(token_id, version=4):
659
+ exit_error(f"Invalid token ID '{token_id}'. Token ID must be a valid UUID4.")
660
+
661
+ # Check conflicts with mine/all
662
+ if context.get_parameter_source("mine") == ParameterSource.COMMANDLINE:
663
+ if mine:
664
+ exit_error(
665
+ "The '--token-id' option conflicts with '--mine'. "
666
+ "Token filtering shows requests for any token, not just yours."
667
+ )
668
+ else:
669
+ exit_error(
670
+ "The '--token-id' option conflicts with '--all'. "
671
+ "Token filtering is already specific to the given token."
672
+ )
673
+
674
+ # Use internal API if showing secrets, otherwise use public API
675
+ base_url = internal_api_url if show_secrets else api_url
676
+
677
+ # Build base URL with age parameter
678
+ url_params = f"created_after={age.to_string()}"
679
+
680
+ # Add ranch parameter if specified (only for --all, not --mine)
681
+ if ranch and not mine and not token_id:
682
+ url_params += f"&ranch={ranch.value}"
683
+
684
+ # Add token_id parameter if specified
685
+ if token_id:
686
+ url_params += f"&token_id={token_id}"
687
+ # When using token_id, behave like --all (don't use authentication)
688
+ mine = False
689
+
690
+ # When using specific request IDs, behave like --all (don't use authentication)
691
+ if ids:
692
+ mine = False
693
+
694
+ base_request_url = urllib.parse.urljoin(base_url, f"v0.1/requests?{url_params}")
695
+
696
+ # Setting up HTTP retries
697
+ session = requests.Session()
698
+ install_http_retries(session)
699
+
700
+ def handle_response_errors(response: requests.Response) -> None:
701
+ if response.status_code == 401:
702
+ exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
703
+
704
+ if response.status_code != 200:
705
+ exit_error(
706
+ f"Unexpected error {response.text}. "
707
+ f"Check {settings.STATUS_PAGE}. "
708
+ f"File an issue to {settings.ISSUE_TRACKER} if needed."
709
+ )
710
+
711
+ # Handle minimum age
712
+ if min_age:
713
+ base_request_url = f"{base_request_url}&created_before={min_age.to_string()}"
714
+
715
+ # check for token
716
+ if not api_token and (mine or show_secrets):
717
+ exit_error("No API token found, export `TESTING_FARM_API_TOKEN` environment variable")
718
+
719
+ # Validate token if provided
720
+ if api_token:
721
+ whoami_url = urllib.parse.urljoin(api_url, "v0.1/whoami")
722
+ try:
723
+ response = session.get(whoami_url, headers=authorization_headers(api_token))
724
+ if response.status_code == 401:
725
+ exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
726
+ elif response.status_code != 200:
727
+ exit_error(
728
+ f"Token validation failed with status {response.status_code}. "
729
+ f"Check {settings.STATUS_PAGE}. "
730
+ f"File an issue to {settings.ISSUE_TRACKER} if needed."
731
+ )
732
+ except requests.RequestException as e:
733
+ exit_error(f"Failed to validate token: {e}")
734
+
735
+ # Handle specific request IDs
736
+ if ids:
737
+ extracted_ids = [extract_uuid(id_string) for id_string in ids]
738
+
739
+ # Fetch individual requests
740
+ with Progress(SpinnerColumn(), transient=True) as progress:
741
+ progress.add_task(description="")
742
+
743
+ def fetch_individual_request(request_id: str):
744
+ # Use internal API if showing secrets, otherwise use public API
745
+
746
+ request_url = urllib.parse.urljoin(base_url, f"v0.1/requests/{request_id}")
747
+ if mine or show_secrets:
748
+ response = session.get(request_url, headers=authorization_headers(api_token))
749
+ else:
750
+ response = session.get(request_url)
751
+
752
+ if response.status_code == 404:
753
+ console.print(f"Request {request_id} not found", style="yellow")
754
+ return None
755
+
756
+ handle_response_errors(response)
757
+ return response.json()
758
+
759
+ requests_json = []
760
+ with ThreadPoolExecutor(max_workers=5) as executor:
761
+ results = executor.map(fetch_individual_request, extracted_ids)
762
+
763
+ for result in results:
764
+ if result:
765
+ requests_json.append(result)
766
+ else:
767
+ # Original logic for fetching by states and age
768
+ with Progress(SpinnerColumn(), transient=True) as progress:
769
+ progress.add_task(description="")
770
+
771
+ # Lookup only current users requests
772
+ def fetch(url: str):
773
+ if mine:
774
+ response = session.get(url, headers=authorization_headers(api_token))
775
+ else:
776
+ response = session.get(url)
777
+
778
+ handle_response_errors(response)
779
+
780
+ return response.json() or []
781
+
782
+ requests_json: List[dict[str, Any]] = []
783
+
784
+ urls = (f"{base_request_url}&state={state.value}" for state in states)
785
+
786
+ with ThreadPoolExecutor(max_workers=5) as executor:
787
+ results = executor.map(fetch, urls)
788
+
789
+ for result in results:
790
+ requests_json.extend(result)
791
+
792
+ # For single request ID, default to text format and verbose mode if not explicitly set
793
+ if ids and len(requests_json) == 1:
794
+ if context.get_parameter_source("format") == ParameterSource.DEFAULT:
795
+ format = OutputFormat.text
796
+ if context.get_parameter_source("brief") == ParameterSource.DEFAULT:
797
+ brief = False # Ensure verbose mode for single requests
798
+
799
+ # Validate show-secrets only works with text format (after format adjustments)
800
+ if show_secrets and format != OutputFormat.text:
801
+ exit_error("The '--show-secrets' option only works with text output format. Use '--format' text to force.")
802
+
803
+ # Validate brief only works with text format
804
+ if brief and format != OutputFormat.text:
805
+ exit_error("The '--brief' option only works with text output format. Use '--format' text.")
806
+
807
+ if format == OutputFormat.json:
808
+ json_dump = json.dumps(requests_json) or '[]'
809
+ console.print_json(json_dump)
810
+ return
811
+
812
+ if not requests_json:
813
+ console.print("No requests found")
814
+ return
815
+
816
+ if format == OutputFormat.yaml:
817
+ yaml_dump = io.StringIO()
818
+ YAML().dump(requests_json, yaml_dump)
819
+ syntax = Syntax(yaml_dump.getvalue(), 'yaml')
820
+ console.print(syntax)
821
+ return
822
+
823
+ if format == OutputFormat.table:
824
+ if reserve:
825
+ render_reservation_table(requests_json=requests_json, show_utc=show_utc)
826
+ else:
827
+ render_table(
828
+ requests_json=requests_json,
829
+ show_token_id=show_token_id,
830
+ show_time=show_time,
831
+ show_utc=show_utc,
832
+ ranch=ranch,
833
+ )
834
+ return
835
+
836
+ render_text(requests_json=requests_json, brief=brief, show_utc=show_utc, show_token_id=show_token_id)