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.
tft/cli/commands.py CHANGED
@@ -20,19 +20,22 @@ from typing import Any, Dict, List, Optional
20
20
 
21
21
  import requests
22
22
  import typer
23
- from click.core import ParameterSource # pyre-ignore[21]
23
+ from click.core import ParameterSource
24
24
  from rich import print, print_json
25
25
  from rich.progress import Progress, SpinnerColumn, TextColumn
26
- from rich.table import Table
26
+ from rich.table import Table # type: ignore
27
27
 
28
28
  from tft.cli.config import settings
29
29
  from tft.cli.utils import (
30
30
  artifacts,
31
+ authorization_headers,
31
32
  check_unexpected_arguments,
32
33
  cmd_output_or_exit,
33
34
  console,
34
35
  console_stderr,
36
+ edit_with_editor,
35
37
  exit_error,
38
+ extract_uuid,
36
39
  hw_constraints,
37
40
  install_http_retries,
38
41
  normalize_multistring_option,
@@ -66,6 +69,7 @@ RESERVE_REF = os.getenv("TESTING_FARM_RESERVE_REF", "main")
66
69
  RESERVE_TMT_DISCOVER_EXTRA_ARGS = f"--insert --how fmf --url {RESERVE_URL} --ref {RESERVE_REF} --test {RESERVE_TEST}"
67
70
 
68
71
  DEFAULT_PIPELINE_TIMEOUT = 60 * 12
72
+ DEFAULT_AGE = "7d"
69
73
 
70
74
  # SSH command options for reservation connections
71
75
  SSH_RESERVATION_OPTIONS = (
@@ -85,6 +89,20 @@ class PipelineType(str, Enum):
85
89
  tmt_multihost = "tmt-multihost"
86
90
 
87
91
 
92
+ class PipelineState(str, Enum):
93
+ new = "new"
94
+ queued = "queued"
95
+ running = "running"
96
+ complete = "complete"
97
+ error = "error"
98
+ canceled = "canceled"
99
+
100
+
101
+ class Ranch(str, Enum):
102
+ public = "public"
103
+ redhat = "redhat"
104
+
105
+
88
106
  # Arguments and options that are shared among multiple commands
89
107
  ARGUMENT_API_URL: str = typer.Argument(
90
108
  settings.API_URL, envvar="TESTING_FARM_API_URL", metavar='', rich_help_panel='Environment variables'
@@ -99,6 +117,12 @@ ARGUMENT_API_TOKEN: str = typer.Argument(
99
117
  metavar='',
100
118
  rich_help_panel='Environment variables',
101
119
  )
120
+ ARGUMENT_INTERNAL_API_URL: str = typer.Argument(
121
+ settings.INTERNAL_API_URL,
122
+ envvar="TESTING_FARM_INTERNAL_API_URL",
123
+ metavar='',
124
+ rich_help_panel='Environment variables',
125
+ )
102
126
  OPTION_API_TOKEN: str = typer.Option(
103
127
  settings.API_TOKEN,
104
128
  envvar="TESTING_FARM_API_TOKEN",
@@ -106,6 +130,33 @@ OPTION_API_TOKEN: str = typer.Option(
106
130
  metavar='',
107
131
  rich_help_panel='Environment variables',
108
132
  )
133
+
134
+ # Restart command specific arguments for source operations
135
+ ARGUMENT_SOURCE_API_URL: str = typer.Argument(
136
+ None, envvar="TESTING_FARM_SOURCE_API_URL", metavar='', rich_help_panel='Environment variables'
137
+ )
138
+ ARGUMENT_INTERNAL_SOURCE_API_URL: str = typer.Argument(
139
+ None, envvar="TESTING_FARM_INTERNAL_SOURCE_API_URL", metavar='', rich_help_panel='Environment variables'
140
+ )
141
+ ARGUMENT_SOURCE_API_TOKEN: str = typer.Argument(
142
+ None,
143
+ envvar="TESTING_FARM_SOURCE_API_TOKEN",
144
+ show_default=False,
145
+ metavar='',
146
+ rich_help_panel='Environment variables',
147
+ )
148
+
149
+ # Restart command specific arguments for target operations
150
+ ARGUMENT_TARGET_API_URL: str = typer.Argument(
151
+ None, envvar="TESTING_FARM_TARGET_API_URL", metavar='', rich_help_panel='Environment variables'
152
+ )
153
+ ARGUMENT_TARGET_API_TOKEN: str = typer.Argument(
154
+ None,
155
+ envvar="TESTING_FARM_TARGET_API_TOKEN",
156
+ show_default=False,
157
+ metavar='',
158
+ rich_help_panel='Environment variables',
159
+ )
109
160
  OPTION_TMT_PLAN_NAME: Optional[str] = typer.Option(
110
161
  None,
111
162
  "--plan",
@@ -534,14 +585,6 @@ def _parse_security_group_rules(ingress_rules: List[str], egress_rules: List[str
534
585
  return security_group_rules
535
586
 
536
587
 
537
- def _get_headers(api_key: str) -> Dict[str, str]:
538
- """
539
- Return a dict with headers for a request to Testing Farm API.
540
- Used for authentication.
541
- """
542
- return {'Authorization': f'Bearer {api_key}'}
543
-
544
-
545
588
  def _parse_xunit(xunit: str):
546
589
  """
547
590
  A helper that parses xunit file into sets of passed_plans/failed_plans/errored_plans per arch.
@@ -1066,7 +1109,7 @@ def request(
1066
1109
 
1067
1110
  if kickstart:
1068
1111
  # Typer escapes newlines in options, we need to unescape them
1069
- kickstart = [codecs.decode(value, 'unicode_escape') for value in kickstart] # pyre-ignore[6]
1112
+ kickstart = [codecs.decode(value, 'unicode_escape') for value in kickstart]
1070
1113
  environment["kickstart"] = options_to_dict("environment kickstart", kickstart)
1071
1114
 
1072
1115
  if redhat_brew_build:
@@ -1113,7 +1156,10 @@ def request(
1113
1156
  if len(environments) > 1:
1114
1157
  exit_error("Reservations are currently supported for a single plan, cannot continue")
1115
1158
 
1116
- rules = _parse_security_group_rules([_localhost_ingress_rule(session)], [])
1159
+ # support cases where the user has multiple localhost addresses
1160
+ rules = _parse_security_group_rules(
1161
+ list({_localhost_ingress_rule(requests.Session()) for _ in range(0, settings.PUBLIC_IP_RESOLVE_TRIES)}), []
1162
+ )
1117
1163
 
1118
1164
  for environment in environments:
1119
1165
  _add_reservation(
@@ -1216,7 +1262,7 @@ def request(
1216
1262
  raise typer.Exit()
1217
1263
 
1218
1264
  # handle errors
1219
- response = session.post(post_url, json=request, headers=_get_headers(api_token))
1265
+ response = session.post(post_url, json=request, headers=authorization_headers(api_token))
1220
1266
  if response.status_code == 401:
1221
1267
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
1222
1268
 
@@ -1240,13 +1286,13 @@ def restart(
1240
1286
  context: typer.Context,
1241
1287
  request_id: str = typer.Argument(..., help="Testing Farm request ID or a string containing it."),
1242
1288
  api_url: str = ARGUMENT_API_URL,
1243
- internal_api_url: str = typer.Argument(
1244
- settings.INTERNAL_API_URL,
1245
- envvar="TESTING_FARM_INTERNAL_API_URL",
1246
- metavar='',
1247
- rich_help_panel='Environment variables',
1248
- ),
1289
+ internal_api_url: str = ARGUMENT_INTERNAL_API_URL,
1249
1290
  api_token: str = ARGUMENT_API_TOKEN,
1291
+ source_api_url: Optional[str] = ARGUMENT_SOURCE_API_URL,
1292
+ internal_source_api_url: Optional[str] = ARGUMENT_INTERNAL_SOURCE_API_URL,
1293
+ source_api_token: Optional[str] = ARGUMENT_SOURCE_API_TOKEN,
1294
+ target_api_url: Optional[str] = ARGUMENT_TARGET_API_URL,
1295
+ target_api_token: Optional[str] = ARGUMENT_TARGET_API_TOKEN,
1250
1296
  compose: Optional[str] = typer.Option(
1251
1297
  None,
1252
1298
  help="Force compose used to provision test environment.", # noqa
@@ -1280,6 +1326,13 @@ def restart(
1280
1326
  autoconnect: bool = _option_autoconnect(REQUEST_PANEL_RESERVE),
1281
1327
  reservation_duration: int = _option_reservation_duration(REQUEST_PANEL_RESERVE),
1282
1328
  debug_reservation: bool = _option_debug_reservation(REQUEST_PANEL_RESERVE),
1329
+ edit: bool = typer.Option(
1330
+ False,
1331
+ help=(
1332
+ "Edit the request JSON in editor before submitting. "
1333
+ "Use the EDITOR environment variable to adjust the editor if needed."
1334
+ ),
1335
+ ),
1283
1336
  ):
1284
1337
  """
1285
1338
  Restart a Testing Farm request.
@@ -1288,30 +1341,39 @@ def restart(
1288
1341
  """
1289
1342
 
1290
1343
  # Accept these arguments only via environment variables
1291
- check_unexpected_arguments(context, "api_url", "api_token", "internal_api_url")
1292
-
1293
- # UUID pattern
1294
- uuid_pattern = re.compile('[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}')
1344
+ check_unexpected_arguments(
1345
+ context,
1346
+ "api_url",
1347
+ "api_token",
1348
+ "internal_api_url",
1349
+ "source_api_url",
1350
+ "internal_source_api_url",
1351
+ "source_api_token",
1352
+ "target_api_url",
1353
+ "target_api_token",
1354
+ )
1295
1355
 
1296
- # Find the UUID in the string
1297
- uuid_match = uuid_pattern.search(request_id)
1356
+ # Determine source configuration (fallback to general settings)
1357
+ effective_source_api_url = source_api_url or api_url
1358
+ effective_internal_source_api_url = internal_source_api_url or internal_api_url
1359
+ effective_source_api_token = source_api_token or api_token
1298
1360
 
1299
- if not uuid_match:
1300
- exit_error(f"Could not find a valid Testing Farm request id in '{request_id}'.")
1301
- return
1361
+ # Determine target configuration (fallback to general settings)
1362
+ effective_target_api_url = target_api_url or api_url
1363
+ effective_target_api_token = target_api_token or api_token
1302
1364
 
1303
- # Extract the UUID from the match object
1304
- _request_id = uuid_match.group()
1365
+ # Extract the UUID from the request_id string
1366
+ _request_id = extract_uuid(request_id)
1305
1367
 
1306
1368
  # Construct URL to the internal API
1307
- get_url = urllib.parse.urljoin(str(internal_api_url), f"v0.1/requests/{_request_id}")
1369
+ get_url = urllib.parse.urljoin(str(effective_internal_source_api_url), f"v0.1/requests/{_request_id}")
1308
1370
 
1309
1371
  # Setting up retries
1310
1372
  session = requests.Session()
1311
1373
  install_http_retries(session)
1312
1374
 
1313
1375
  # Get the request details
1314
- response = session.get(get_url, headers=_get_headers(api_token))
1376
+ response = session.get(get_url, headers=authorization_headers(effective_source_api_token))
1315
1377
 
1316
1378
  if response.status_code == 401:
1317
1379
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
@@ -1319,10 +1381,11 @@ def restart(
1319
1381
  # The API token is valid, but it doesn't own the request
1320
1382
  if response.status_code == 403:
1321
1383
  console.print(
1322
- "⚠️ [yellow] You are not the owner of this request. Any secrets associated with the request will not be included on the restart.[/yellow]" # noqa: E501
1384
+ "⚠️ [yellow] You are not the owner of this request. Any secrets associated with the "
1385
+ "request will not be included on the restart.[/yellow]"
1323
1386
  )
1324
- # Construct URL to the internal API
1325
- get_url = urllib.parse.urljoin(str(api_url), f"v0.1/requests/{_request_id}")
1387
+ # Construct URL to the API
1388
+ get_url = urllib.parse.urljoin(str(effective_source_api_url), f"v0.1/requests/{_request_id}")
1326
1389
 
1327
1390
  # Get the request details
1328
1391
  response = session.get(get_url)
@@ -1431,7 +1494,7 @@ def restart(
1431
1494
 
1432
1495
  if test_type == "fmf":
1433
1496
  # The method explained in https://github.com/fastapi/typer/discussions/668
1434
- if context.get_parameter_source("tmt_path") == ParameterSource.COMMANDLINE: # pyre-ignore[16]
1497
+ if context.get_parameter_source("tmt_path") == ParameterSource.COMMANDLINE:
1435
1498
  request["test"][test_type]["path"] = tmt_path
1436
1499
 
1437
1500
  # worker image
@@ -1471,7 +1534,10 @@ def restart(
1471
1534
  if len(request["environments"]) > 1:
1472
1535
  exit_error("Reservations are currently supported for a single plan, cannot continue")
1473
1536
 
1474
- rules = _parse_security_group_rules([_localhost_ingress_rule(session)], [])
1537
+ # support cases where the user has multiple localhost addresses
1538
+ rules = _parse_security_group_rules(
1539
+ list({_localhost_ingress_rule(requests.Session()) for _ in range(0, settings.PUBLIC_IP_RESOLVE_TRIES)}), []
1540
+ )
1475
1541
 
1476
1542
  for environment in request["environments"]:
1477
1543
  _add_reservation(
@@ -1489,6 +1555,18 @@ def restart(
1489
1555
  f"🕗 {machine_pre} will be reserved after testing for [blue]{str(reservation_duration)}[/blue] minutes"
1490
1556
  )
1491
1557
 
1558
+ # edit request if requested
1559
+ if edit:
1560
+ while True:
1561
+ try:
1562
+ request = json.loads(edit_with_editor(json.dumps(request, indent=2), "editing request"))
1563
+ break
1564
+ except (TypeError, ValueError) as error:
1565
+ console.print(f"⛔ Edited request is not a valid JSON, cannot continue: {error}", style="red")
1566
+ if typer.confirm("❓️ Edit again?"):
1567
+ continue
1568
+ raise typer.Exit(code=255)
1569
+
1492
1570
  # dry run
1493
1571
  if dry_run:
1494
1572
  console.print("🔍 Dry run, showing POST json only", style="bright_yellow")
@@ -1496,10 +1574,10 @@ def restart(
1496
1574
  raise typer.Exit()
1497
1575
 
1498
1576
  # submit request to Testing Farm
1499
- post_url = urllib.parse.urljoin(str(api_url), "v0.1/requests")
1577
+ post_url = urllib.parse.urljoin(str(effective_target_api_url), "v0.1/requests")
1500
1578
 
1501
1579
  # handle errors
1502
- response = session.post(post_url, json=request, headers=_get_headers(api_token))
1580
+ response = session.post(post_url, json=request, headers=authorization_headers(effective_target_api_token))
1503
1581
  if response.status_code == 401:
1504
1582
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
1505
1583
 
@@ -1516,7 +1594,7 @@ def restart(
1516
1594
  # watch
1517
1595
  watch(
1518
1596
  context,
1519
- str(api_url),
1597
+ str(effective_target_api_url),
1520
1598
  response.json()['id'],
1521
1599
  no_wait,
1522
1600
  reserve=reserve,
@@ -1597,7 +1675,7 @@ def run(
1597
1675
  raise typer.Exit()
1598
1676
 
1599
1677
  # handle errors
1600
- response = session.post(post_url, json=request, headers=_get_headers(api_token))
1678
+ response = session.post(post_url, json=request, headers=authorization_headers(api_token))
1601
1679
  if response.status_code == 401:
1602
1680
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
1603
1681
 
@@ -1797,7 +1875,7 @@ def reserve(
1797
1875
 
1798
1876
  if kickstart:
1799
1877
  # Typer escapes newlines in options, we need to unescape them
1800
- kickstart = [codecs.decode(value, 'unicode_escape') for value in kickstart] # pyre-ignore[6]
1878
+ kickstart = [codecs.decode(value, 'unicode_escape') for value in kickstart]
1801
1879
  environment["kickstart"] = options_to_dict("environment kickstart", kickstart)
1802
1880
 
1803
1881
  if redhat_brew_build:
@@ -1837,7 +1915,10 @@ def reserve(
1837
1915
  if not skip_workstation_access or security_group_rule_ingress or security_group_rule_egress:
1838
1916
  ingress_rules = security_group_rule_ingress or []
1839
1917
  if not skip_workstation_access:
1840
- ingress_rules.append(_localhost_ingress_rule(session))
1918
+ # support cases where the user has multiple localhost addresses
1919
+ ingress_rules.extend(
1920
+ {_localhost_ingress_rule(requests.Session()) for _ in range(0, settings.PUBLIC_IP_RESOLVE_TRIES)}
1921
+ )
1841
1922
 
1842
1923
  rules = _parse_security_group_rules(ingress_rules, security_group_rule_egress or [])
1843
1924
  environment["settings"]["provisioning"].update(rules)
@@ -1891,7 +1972,7 @@ def reserve(
1891
1972
  raise typer.Exit()
1892
1973
 
1893
1974
  # handle errors
1894
- response = session.post(post_url, json=request, headers=_get_headers(api_token))
1975
+ response = session.post(post_url, json=request, headers=authorization_headers(api_token))
1895
1976
  if response.status_code == 401:
1896
1977
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
1897
1978
 
@@ -2057,23 +2138,13 @@ def cancel(
2057
2138
  # Accept these arguments only via environment variables
2058
2139
  check_unexpected_arguments(context, "api_url", "api_token")
2059
2140
 
2060
- # UUID pattern
2061
- uuid_pattern = re.compile('[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}')
2062
-
2063
- # Find the UUID in the string
2064
- uuid_match = uuid_pattern.search(request_id)
2065
-
2066
- if not uuid_match:
2067
- exit_error(f"Could not find a valid Testing Farm request id in '{request_id}'.")
2068
- return
2141
+ # Extract the UUID from the request_id string
2142
+ _request_id = extract_uuid(request_id)
2069
2143
 
2070
2144
  if not api_token:
2071
2145
  exit_error("No API token found in the environment, please export 'TESTING_FARM_API_TOKEN' variable.")
2072
2146
  return
2073
2147
 
2074
- # Extract the UUID from the match object
2075
- _request_id = uuid_match.group()
2076
-
2077
2148
  # Construct URL to the internal API
2078
2149
  request_url = urllib.parse.urljoin(str(api_url), f"v0.1/requests/{_request_id}")
2079
2150
 
@@ -2082,11 +2153,17 @@ def cancel(
2082
2153
  install_http_retries(session)
2083
2154
 
2084
2155
  # Get the request details
2085
- response = session.delete(request_url, headers=_get_headers(api_token))
2156
+ response = session.delete(request_url, headers=authorization_headers(api_token))
2086
2157
 
2087
2158
  if response.status_code == 401:
2088
2159
  exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
2089
2160
 
2161
+ if response.status_code == 403:
2162
+ exit_error(
2163
+ "You cannot cancel foreign requests. You can only cancel your own requests "
2164
+ "or must have 'admin' permissions."
2165
+ )
2166
+
2090
2167
  if response.status_code == 404:
2091
2168
  exit_error("Request was not found. Verify the request ID is correct.")
2092
2169
 
tft/cli/config.py CHANGED
@@ -1,7 +1,7 @@
1
1
  # Copyright Contributors to the Testing Farm project.
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
 
4
- from dynaconf import LazySettings
4
+ from dynaconf import LazySettings # type: ignore
5
5
 
6
6
  settings = LazySettings(
7
7
  # all environment variables have `TESTING_FARM_` prefix
@@ -10,6 +10,13 @@ settings = LazySettings(
10
10
  API_URL="https://api.dev.testing-farm.io/v0.1",
11
11
  INTERNAL_API_URL="https://internal.api.dev.testing-farm.io/v0.1",
12
12
  API_TOKEN=None,
13
+ # Restart command specific source API configuration (fallback to general settings)
14
+ SOURCE_API_URL=None,
15
+ INTERNAL_SOURCE_API_URL=None,
16
+ SOURCE_API_TOKEN=None,
17
+ # Restart command specific target API configuration (fallback to general settings)
18
+ TARGET_API_URL=None,
19
+ TARGET_API_TOKEN=None,
13
20
  ISSUE_TRACKER="https://gitlab.com/testing-farm/general/-/issues/new",
14
21
  STATUS_PAGE="https://status.testing-farm.io",
15
22
  ONBOARDING_DOCS="https://docs.testing-farm.io/Testing%20Farm/0.1/onboarding.html",
@@ -27,4 +34,6 @@ settings = LazySettings(
27
34
  TESTING_FARM_TESTS_GIT_URL="https://gitlab.com/testing-farm/tests",
28
35
  TESTING_FARM_SANITY_PLAN="/testing-farm/sanity",
29
36
  PUBLIC_IP_CHECKER_URL="https://ipv4.icanhazip.com",
37
+ # number or tries for resolving localhost public IP, useful if the user has multiple IPs
38
+ PUBLIC_IP_RESOLVE_TRIES=1,
30
39
  )
tft/cli/tool.py CHANGED
@@ -6,11 +6,13 @@ import os
6
6
  import typer
7
7
 
8
8
  import tft.cli.commands as commands
9
+ from tft.cli.command.listing import listing
9
10
  from tft.cli.config import settings
10
11
 
11
12
  app = typer.Typer()
12
13
 
13
14
  app.command()(commands.cancel)
15
+ app.command(name="list")(listing)
14
16
  app.command()(commands.request)
15
17
  app.command()(commands.restart)
16
18
  app.command()(commands.reserve)
tft/cli/utils.py CHANGED
@@ -4,18 +4,24 @@
4
4
  import glob
5
5
  import itertools
6
6
  import os
7
+ import re
7
8
  import shlex
9
+ import shutil
8
10
  import subprocess
9
11
  import sys
12
+ import tempfile
10
13
  import uuid
14
+ from dataclasses import dataclass
15
+ from enum import Enum
11
16
  from typing import Any, Dict, List, NoReturn, Optional, Union
12
17
 
18
+ import pendulum
13
19
  import requests
14
20
  import requests.adapters
15
21
  import typer
16
- from click.core import ParameterSource # pyre-ignore[21]
22
+ from click.core import ParameterSource
17
23
  from rich.console import Console
18
- from ruamel.yaml import YAML
24
+ from ruamel.yaml import YAML # type: ignore
19
25
  from urllib3 import Retry
20
26
 
21
27
  from tft.cli.config import settings
@@ -24,6 +30,56 @@ console = Console(soft_wrap=True)
24
30
  console_stderr = Console(soft_wrap=True, file=sys.stderr)
25
31
 
26
32
 
33
+ @dataclass
34
+ class Age:
35
+ value: int
36
+ unit: str
37
+
38
+ _unit_multiplier = {"s": 1, "m": 60, "h": 3600, "d": 86400}
39
+ _unit_human = {"s": "second", "m": "minute", "h": "hour", "d": "day"}
40
+
41
+ @classmethod
42
+ def from_string(cls, age_string: str) -> 'Age':
43
+ value, unit = age_string[:-1], age_string[-1]
44
+ if unit not in cls._unit_multiplier:
45
+ raise typer.BadParameter(f"Age must end with {', '.join(cls._unit_multiplier.keys())}")
46
+
47
+ if not value.isdigit():
48
+ raise typer.BadParameter(f"Invalid age value {value}")
49
+
50
+ return cls(value=int(age_string[:-1]), unit=age_string[-1])
51
+
52
+ @property
53
+ def birth_date(self) -> pendulum.DateTime:
54
+ now = pendulum.now(tz="UTC")
55
+ return now - pendulum.duration(seconds=self.value * self._unit_multiplier[self.unit])
56
+
57
+ @property
58
+ def human(self) -> str:
59
+ return f"{self.value} {self._unit_human[self.unit]}{'s' if self.value > 1 else ''}"
60
+
61
+ @staticmethod
62
+ def available_units() -> str:
63
+ return "s (seconds), m (minutes), h (hours) or d (days)"
64
+
65
+ def to_string(self, format="%Y-%m-%dT%H:%M:%S") -> str:
66
+ return self.birth_date.strftime(format)
67
+
68
+ def __str__(self):
69
+ return f"{self.value}{self.unit}"
70
+
71
+
72
+ class OutputFormat(str, Enum):
73
+ text = "text"
74
+ json = "json"
75
+ yaml = "yaml"
76
+ table = "table"
77
+
78
+ @staticmethod
79
+ def available_formats():
80
+ return "text, json or table"
81
+
82
+
27
83
  def exit_error(error: str) -> NoReturn:
28
84
  """Exit with given error message"""
29
85
  console.print(f"⛔ {error}", style="red")
@@ -125,6 +181,19 @@ def hw_constraints(hardware: List[str]) -> Dict[Any, Any]:
125
181
  value_mixed = False
126
182
 
127
183
  container[path_splitted.pop()] = value_mixed
184
+
185
+ # Only process additional path elements if they exist
186
+ if path_splitted:
187
+ next_path = path_splitted.pop()
188
+
189
+ # handle compatible.distro
190
+ if next_path == 'distro':
191
+ container[next_path] = (
192
+ container[next_path].append(value_mixed) if next_path in container else [value_mixed]
193
+ )
194
+ else:
195
+ container[next_path] = value_mixed
196
+
128
197
  return constraints
129
198
 
130
199
 
@@ -187,6 +256,27 @@ def uuid_valid(value: str, version: int = 4) -> bool:
187
256
  return False
188
257
 
189
258
 
259
+ def extract_uuid(value: str) -> str:
260
+ """
261
+ Extracts a UUID from a string. If the string is already a valid UUID, returns it.
262
+ If the string contains a UUID, extracts and returns it.
263
+ Raises typer.Exit with error message if no valid UUID is found.
264
+ """
265
+ # Check if the value is already a valid UUID
266
+ if uuid_valid(value):
267
+ return value
268
+
269
+ # UUID pattern for extracting UUIDs from strings
270
+ uuid_pattern = re.compile('[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}')
271
+
272
+ # Try to extract UUID from string
273
+ uuid_match = uuid_pattern.search(value)
274
+ if uuid_match:
275
+ return uuid_match.group()
276
+
277
+ exit_error(f"Could not find a valid Testing Farm request id in '{value}'.")
278
+
279
+
190
280
  class TimeoutHTTPAdapter(requests.adapters.HTTPAdapter):
191
281
  def __init__(self, *args: Any, **kwargs: Any) -> None:
192
282
  self.timeout = kwargs.pop('timeout', settings.DEFAULT_API_TIMEOUT)
@@ -216,7 +306,9 @@ def install_http_retries(
216
306
 
217
307
  status_forcelist_extend = status_forcelist_extend or []
218
308
 
219
- params = {
309
+ from typing import Any, Dict
310
+
311
+ params: Dict[str, Any] = {
220
312
  "total": retries,
221
313
  "status_forcelist": [
222
314
  429, # Too Many Requests
@@ -226,9 +318,9 @@ def install_http_retries(
226
318
  504, # Gateway Timeout
227
319
  ]
228
320
  + status_forcelist_extend,
229
- allowed_retry_parameter: ['HEAD', 'GET', 'POST', 'DELETE', 'PUT'],
230
321
  "backoff_factor": retry_backoff_factor,
231
322
  }
323
+ params[allowed_retry_parameter] = ['HEAD', 'GET', 'POST', 'DELETE', 'PUT']
232
324
  retry_strategy = Retry(**params)
233
325
 
234
326
  timeout_adapter = TimeoutHTTPAdapter(timeout=timeout, max_retries=retry_strategy)
@@ -263,8 +355,60 @@ def read_glob_paths(glob_paths: List[str]) -> str:
263
355
 
264
356
  def check_unexpected_arguments(context: typer.Context, *args: str) -> Union[None, NoReturn]:
265
357
  for argument in args:
266
- if context.get_parameter_source(argument) == ParameterSource.COMMANDLINE: # pyre-ignore[16]
358
+ if context.get_parameter_source(argument) == ParameterSource.COMMANDLINE:
267
359
  exit_error(
268
360
  f"Unexpected argument '{context.params.get(argument)}'. "
269
361
  "Please make sure you are passing the parameters correctly."
270
362
  )
363
+
364
+
365
+ def validate_age(value: str) -> Age:
366
+ if value.endswith("m"):
367
+ return Age(int(value[:-1]), "m")
368
+ elif value.endswith("d"):
369
+ return Age(int(value[:-1]), "d")
370
+ else:
371
+ raise ValueError("Age must end with 'm' for months or 'd' for days.")
372
+
373
+
374
+ def authorization_headers(api_key: str) -> Dict[str, str]:
375
+ """
376
+ Return a dict with headers for a request to Testing Farm API.
377
+ Used for authentication.
378
+ """
379
+ return {'Authorization': f'Bearer {api_key}'}
380
+
381
+
382
+ def edit_with_editor(data: Any, description: Optional[str]) -> Any:
383
+ """
384
+ Open data in an editor for user modification and return it back.
385
+ If description specified, print it as a user message together with the used editor.
386
+ """
387
+ # Get the editor from environment variable, fallback to sensible defaults
388
+ editor = os.environ.get('EDITOR')
389
+ if not editor:
390
+ # Try common editors in order of preference
391
+ for candidate in ['vim', 'vi', 'nano', 'emacs']:
392
+ if shutil.which(candidate):
393
+ editor = candidate
394
+ break
395
+
396
+ if not editor:
397
+ exit_error("No editor found. Please set the 'EDITOR' environment variable.")
398
+
399
+ # Create a temporary file with the JSON content
400
+ with tempfile.NamedTemporaryFile(mode='w') as temp_file:
401
+ temp_file.write(data)
402
+ temp_file.flush()
403
+
404
+ # Open the editor
405
+ if description:
406
+ console.print(f"✏️ {description}, editor '{editor}'")
407
+ result = subprocess.run([editor, temp_file.name])
408
+
409
+ if result.returncode != 0:
410
+ exit_error(f"Editor '{editor}' exited with non-zero status: {result.returncode}")
411
+
412
+ # Read the modified content
413
+ with open(temp_file.name, 'r') as modified_file:
414
+ return modified_file.read()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: tft-cli
3
- Version: 0.0.26
3
+ Version: 0.0.28
4
4
  Summary: Testing Farm CLI tool
5
5
  License: Apache-2.0
6
6
  Author: Miroslav Vadkerti
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3.11
14
14
  Requires-Dist: click (>=8.0.4,<9.0.0)
15
15
  Requires-Dist: colorama (>=0.4.4,<0.5.0)
16
16
  Requires-Dist: dynaconf (>=3.1.2,<4.0.0)
17
+ Requires-Dist: pendulum (>=3.0.0,<4.0.0)
17
18
  Requires-Dist: requests (>=2.27.1,<3.0.0)
18
19
  Requires-Dist: rich (>=12)
19
20
  Requires-Dist: ruamel-yaml (>=0.18.5,<0.19.0)
@@ -0,0 +1,12 @@
1
+ tft/cli/__init__.py,sha256=uEJkNJbqC583PBtNI30kxWdeOr3Wj6zJzIYKf0AD72I,92
2
+ tft/cli/command/__init__.py,sha256=uEJkNJbqC583PBtNI30kxWdeOr3Wj6zJzIYKf0AD72I,92
3
+ tft/cli/command/listing.py,sha256=gEkoeKHOcEiBt2TyLzSAongOKHKi7BgEO7GQ0AAa0Ss,31276
4
+ tft/cli/commands.py,sha256=aZ_P7B9z4IorT6ksQH0ciCY6KxvL5q5NJdWuHNv9fHE,85890
5
+ tft/cli/config.py,sha256=9JWjEwBiS4_Eq1B53lxxCIceERJ7AeryUyqxj9fm9c4,1714
6
+ tft/cli/tool.py,sha256=AaJ7RZwWEoP7WA_f2NbmcZSolNETkTWvVAt9pgt9j-o,975
7
+ tft/cli/utils.py,sha256=j8B30qpieBUieaiVo_3h5bRqoKnGeGKxR9eJ7GZcNiM,13702
8
+ tft_cli-0.0.28.dist-info/LICENSE,sha256=YpVAQfXkIyzQAdm5LZkI6L5UWqLppa6O8_tgDSdoabQ,574
9
+ tft_cli-0.0.28.dist-info/METADATA,sha256=MybsQ9HApc32-yILZ14mMu8YcWCSlhtH0gioJoMI8ZM,830
10
+ tft_cli-0.0.28.dist-info/WHEEL,sha256=7Z8_27uaHI_UZAc4Uox4PpBhQ9Y5_modZXWMxtUi4NU,88
11
+ tft_cli-0.0.28.dist-info/entry_points.txt,sha256=xzdebHkH5Bx-YRf-XPMsIoVpvgfUqqcRQGuo8DFkiao,49
12
+ tft_cli-0.0.28.dist-info/RECORD,,