tft-cli 0.0.29__py3-none-any.whl → 0.0.31__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,200 @@
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 urllib.parse
8
+ from typing import Any, List, Optional
9
+
10
+ import requests
11
+ import typer
12
+ from rich.progress import Progress, SpinnerColumn
13
+ from rich.syntax import Syntax
14
+ from rich.table import Table # type: ignore
15
+ from ruamel.yaml import YAML # type: ignore
16
+
17
+ from tft.cli.commands import ARGUMENT_API_TOKEN, ARGUMENT_API_URL
18
+ from tft.cli.config import settings
19
+ from tft.cli.utils import (
20
+ OutputFormat,
21
+ StrEnum,
22
+ authorization_headers,
23
+ check_unexpected_arguments,
24
+ console,
25
+ exit_error,
26
+ handle_response_errors,
27
+ install_http_retries,
28
+ )
29
+
30
+
31
+ class Ranch(StrEnum):
32
+ public = "public"
33
+ redhat = "redhat"
34
+
35
+
36
+ def render_text(composes_json: Any, show_regex: bool) -> None:
37
+ """
38
+ Show list of composes as a text.
39
+ """
40
+
41
+ lines: list[str] = []
42
+
43
+ for compose in sorted(composes_json, key=lambda compose: compose["name"]):
44
+ text = f"{compose['name']}"
45
+
46
+ if show_regex:
47
+ if compose["type"] == "regex":
48
+ text += " [bold][green]regex[/green][/bold]"
49
+ else:
50
+ text += " [bold][green]compose[/green][/bold]"
51
+
52
+ lines.append(text)
53
+
54
+ console.print("\n".join(lines))
55
+
56
+
57
+ def render_table(composes_json: Any, show_regex: bool) -> None:
58
+ """
59
+ Show list of composes as a table.
60
+ """
61
+ table = Table(show_header=True, header_style="bold magenta")
62
+
63
+ table.add_column("name", justify="left")
64
+
65
+ if show_regex:
66
+ table.add_column("type", justify="left")
67
+
68
+ for compose in sorted(composes_json, key=lambda compose: compose["name"]):
69
+ row = [compose["name"]]
70
+
71
+ if show_regex:
72
+ row.append(compose["type"])
73
+
74
+ table.add_row(*row)
75
+
76
+ console.print(table)
77
+
78
+
79
+ def composes(
80
+ context: typer.Context,
81
+ api_token: str = ARGUMENT_API_TOKEN,
82
+ api_url: str = ARGUMENT_API_URL,
83
+ ranch: Optional[Ranch] = typer.Option(
84
+ None, help="List composes for this ranch, instead of the ranch of your token."
85
+ ),
86
+ search: Optional[str] = typer.Option(
87
+ None,
88
+ "-s",
89
+ "--search",
90
+ help="Search for composes based on the given regular expression. For searching `re.search` is used.",
91
+ ),
92
+ show_regex: bool = typer.Option(
93
+ False,
94
+ help="Show also regular expressions used to accept additional composes.",
95
+ ),
96
+ validate: Optional[List[str]] = typer.Option(
97
+ None,
98
+ "-v",
99
+ "--validate",
100
+ help="Verify that given compose would be accepted by Testing Farm. Can be specified multiple times.",
101
+ ),
102
+ format: OutputFormat = typer.Option(
103
+ "text", help=f"Output format to use. Possible formats: {OutputFormat.available_formats()}"
104
+ ),
105
+ ):
106
+ """
107
+ List composes accepted by Testing Farm.
108
+
109
+ When Testing Farm token is provided, the command uses the ranch corresponding to your token.
110
+ To force listing of composes for a specific ranch, use the `--ranch` option.
111
+ """
112
+
113
+ # Accept these arguments only via environment variables
114
+ check_unexpected_arguments(context, "api_url", "api_token")
115
+
116
+ # Setting up HTTP retries
117
+ session = requests.Session()
118
+ install_http_retries(session)
119
+
120
+ # check for token
121
+ if not api_token and not ranch:
122
+ exit_error("No API token found and no ranch specified. Cannot determine ranch.")
123
+
124
+ # Validate token if provided
125
+ if api_token and not ranch:
126
+ whoami_url = urllib.parse.urljoin(api_url, "v0.1/whoami")
127
+ try:
128
+ with Progress(SpinnerColumn(), transient=True) as progress:
129
+ progress.add_task(description="")
130
+
131
+ response = session.get(whoami_url, headers=authorization_headers(api_token))
132
+ handle_response_errors(response)
133
+
134
+ ranch = response.json()['token']['ranch']
135
+
136
+ except requests.RequestException as e:
137
+ exit_error(f"Failed to validate token: {e}")
138
+
139
+ # Compile the search regular expression
140
+ search_pattern = re.compile(search) if search else None
141
+
142
+ # Fetch composes
143
+ with Progress(SpinnerColumn(), transient=True) as progress:
144
+ progress.add_task(description="")
145
+
146
+ composes_url = urllib.parse.urljoin(api_url, f"v0.2/composes/{ranch}")
147
+
148
+ response = session.get(composes_url)
149
+ handle_response_errors(response)
150
+
151
+ composes_json = response.json().get("composes") or []
152
+
153
+ if not composes_json:
154
+ exit_error(f"No composes found in Testing Farm. Please file an issue to {settings.ISSUE_TRACKER}")
155
+
156
+ if search_pattern:
157
+ composes_json = [compose for compose in composes_json if search_pattern.search(compose["name"])]
158
+
159
+ if not composes_json:
160
+ exit_error(f"No composes found for '{search_pattern.pattern}'.")
161
+
162
+ if validate:
163
+
164
+ def _compose_accepted(compose: str):
165
+ console.print(f"✅ Compose '{compose}' is valid")
166
+
167
+ for validated_compose in validate:
168
+ for compose in composes_json:
169
+ if compose["type"] == "compose" and validated_compose == compose["name"]:
170
+ _compose_accepted(validated_compose)
171
+ break
172
+
173
+ if compose["type"] == "regex" and re.match(compose["name"], validated_compose):
174
+ _compose_accepted(validated_compose)
175
+ break
176
+ else:
177
+ console.print(f"❌ Compose '{validated_compose}' is invalid")
178
+
179
+ return
180
+
181
+ if not show_regex:
182
+ composes_json = [compose for compose in composes_json if compose["type"] != "regex"]
183
+
184
+ if format == OutputFormat.json:
185
+ json_dump = json.dumps(composes_json) or '[]'
186
+ console.print_json(json_dump)
187
+ return
188
+
189
+ if format == OutputFormat.yaml:
190
+ yaml_dump = io.StringIO()
191
+ YAML().dump(composes_json, yaml_dump)
192
+ syntax = Syntax(yaml_dump.getvalue(), "yaml")
193
+ console.print(syntax)
194
+ return
195
+
196
+ if format == OutputFormat.table:
197
+ render_table(composes_json, show_regex)
198
+ return
199
+
200
+ render_text(composes_json, show_regex)
@@ -7,7 +7,6 @@ import re
7
7
  import sys
8
8
  import urllib.parse
9
9
  from concurrent.futures import ThreadPoolExecutor
10
- from enum import Enum
11
10
  from typing import Any, List, Optional, Tuple
12
11
 
13
12
  import pendulum
@@ -24,16 +23,20 @@ from tft.cli.commands import (
24
23
  ARGUMENT_API_URL,
25
24
  ARGUMENT_INTERNAL_API_URL,
26
25
  PipelineState,
26
+ check_token,
27
27
  )
28
28
  from tft.cli.config import settings
29
29
  from tft.cli.utils import (
30
30
  Age,
31
31
  OutputFormat,
32
+ StrEnum,
32
33
  authorization_headers,
33
34
  check_unexpected_arguments,
34
35
  console,
35
36
  exit_error,
36
37
  extract_uuid,
38
+ handle_401_response,
39
+ handle_response_errors,
37
40
  install_http_retries,
38
41
  uuid_valid,
39
42
  )
@@ -42,7 +45,7 @@ from tft.cli.utils import (
42
45
  MAX_COMPOSE_LENGTH = 30
43
46
 
44
47
 
45
- class Ranch(str, Enum):
48
+ class Ranch(StrEnum):
46
49
  public = "public"
47
50
  redhat = "redhat"
48
51
 
@@ -201,11 +204,11 @@ def render_reservation_table(requests_json: Any, show_utc: bool) -> None:
201
204
  os_compose = os_info.get('compose')
202
205
  if os_compose:
203
206
  if len(os_compose) > MAX_COMPOSE_LENGTH:
204
- envs.append(f"{arch:>7} (<too-long>)")
207
+ envs.append(f"{arch:>7} (<too-long>)") # noqa: E231
205
208
  else:
206
- envs.append(f"{arch:>7} ({os_compose})")
209
+ envs.append(f"{arch:>7} ({os_compose})") # noqa: E231
207
210
  else:
208
- envs.append(f"{arch:>7} (container)")
211
+ envs.append(f"{arch:>7} (container)") # noqa: E231
209
212
  envs = list(dict.fromkeys(envs))
210
213
 
211
214
  # Get time info
@@ -311,11 +314,11 @@ def render_table(
311
314
  if os_compose:
312
315
  # Check if compose contains disk_image or boot_image and display <hidden-flasher-image> instead
313
316
  if len(os_compose) > 20:
314
- envs.append(f"{arch:>7} (<too-long>)")
317
+ envs.append(f"{arch:>7} (<too-long>)") # noqa: E231
315
318
  else:
316
- envs.append(f"{arch:>7} ({os_compose})")
319
+ envs.append(f"{arch:>7} ({os_compose})") # noqa: E231
317
320
  else:
318
- envs.append(f"{arch:>7} (container)")
321
+ envs.append(f"{arch:>7} (container)") # noqa: E231
319
322
  envs = list(dict.fromkeys(envs)) # Remove duplicates while preserving order
320
323
 
321
324
  git_type, git_url = shorten_git_url(url)
@@ -395,7 +398,7 @@ def _format_time(seconds):
395
398
  try:
396
399
  seconds = float(seconds)
397
400
  minutes, seconds = divmod(seconds, 60)
398
- return f"{int(minutes)}m {seconds:.2f}s"
401
+ return f"{int(minutes)}m {seconds:.2f}s" # noqa: E231
399
402
  except (ValueError, TypeError):
400
403
  return "N/A"
401
404
 
@@ -697,24 +700,13 @@ def listing(
697
700
  session = requests.Session()
698
701
  install_http_retries(session)
699
702
 
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
703
  # Handle minimum age
712
704
  if min_age:
713
705
  base_request_url = f"{base_request_url}&created_before={min_age.to_string()}"
714
706
 
715
707
  # check for token
716
708
  if not api_token and (mine or show_secrets):
717
- exit_error("No API token found, export `TESTING_FARM_API_TOKEN` environment variable")
709
+ api_token = check_token(api_url, api_token)
718
710
 
719
711
  # Validate token if provided
720
712
  if api_token:
@@ -722,7 +714,7 @@ def listing(
722
714
  try:
723
715
  response = session.get(whoami_url, headers=authorization_headers(api_token))
724
716
  if response.status_code == 401:
725
- exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
717
+ handle_401_response(response)
726
718
  elif response.status_code != 200:
727
719
  exit_error(
728
720
  f"Token validation failed with status {response.status_code}. "
tft/cli/commands.py CHANGED
@@ -15,7 +15,6 @@ import textwrap
15
15
  import time
16
16
  import urllib.parse
17
17
  import xml.etree.ElementTree as ET
18
- from enum import Enum
19
18
  from typing import Any, Dict, List, Optional
20
19
 
21
20
  import requests
@@ -27,6 +26,7 @@ from rich.table import Table # type: ignore
27
26
 
28
27
  from tft.cli.config import settings
29
28
  from tft.cli.utils import (
29
+ StrEnum,
30
30
  artifacts,
31
31
  authorization_headers,
32
32
  check_unexpected_arguments,
@@ -36,6 +36,7 @@ from tft.cli.utils import (
36
36
  edit_with_editor,
37
37
  exit_error,
38
38
  extract_uuid,
39
+ handle_401_response,
39
40
  hw_constraints,
40
41
  install_http_retries,
41
42
  normalize_multistring_option,
@@ -83,16 +84,16 @@ SSH_RESERVATION_OPTIONS = (
83
84
  SECURITY_GROUP_RULE_FORMAT = re.compile(r"(tcp|ip|icmp|udp|-1|[0-255]):(.*):(\d{1,5}-\d{1,5}|\d{1,5}|-1)")
84
85
 
85
86
 
86
- class WatchFormat(str, Enum):
87
+ class WatchFormat(StrEnum):
87
88
  text = 'text'
88
89
  json = 'json'
89
90
 
90
91
 
91
- class PipelineType(str, Enum):
92
+ class PipelineType(StrEnum):
92
93
  tmt_multihost = "tmt-multihost"
93
94
 
94
95
 
95
- class PipelineState(str, Enum):
96
+ class PipelineState(StrEnum):
96
97
  new = "new"
97
98
  queued = "queued"
98
99
  running = "running"
@@ -101,7 +102,7 @@ class PipelineState(str, Enum):
101
102
  canceled = "canceled"
102
103
 
103
104
 
104
- class Ranch(str, Enum):
105
+ class Ranch(StrEnum):
105
106
  public = "public"
106
107
  redhat = "redhat"
107
108
 
@@ -163,43 +164,32 @@ ARGUMENT_TARGET_API_TOKEN: str = typer.Argument(
163
164
  OPTION_TMT_PLAN_NAME: Optional[str] = typer.Option(
164
165
  None,
165
166
  "--plan",
166
- help=(
167
- 'Select plans to be executed. '
168
- 'Passed as `--name` option to the `tmt plan` command. '
169
- 'Can be a regular expression.'
170
- ),
167
+ help=('A regular expression to select plans to be executed. Passed to the `tmt plan ls` command. '),
171
168
  rich_help_panel=REQUEST_PANEL_TMT,
172
169
  )
173
170
  OPTION_TMT_PLAN_FILTER: Optional[str] = typer.Option(
174
171
  None,
175
172
  "--plan-filter",
176
173
  help=(
177
- 'Filter tmt plans. '
178
- 'Passed as `--filter` option to the `tmt plan` command. '
179
- 'By default, `enabled:true` filter is applied. '
180
- 'Plan filtering is similar to test filtering, '
181
- 'see https://tmt.readthedocs.io/en/stable/examples.html#filter-tests for more information.'
174
+ 'Apply an advanced filter using key:value pairs and logical operators to filter plans. '
175
+ 'Passed as `--filter` option to the `tmt plan ls` command. '
176
+ 'See `pydoc fmf.filter` for detailed documentation on the syntax.'
182
177
  ),
183
178
  rich_help_panel=REQUEST_PANEL_TMT,
184
179
  )
185
180
  OPTION_TMT_TEST_NAME: Optional[str] = typer.Option(
186
181
  None,
187
182
  "--test",
188
- help=(
189
- 'Select tests to be executed. '
190
- 'Passed as `--name` option to the `tmt test` command. '
191
- 'Can be a regular expression.'
192
- ),
183
+ help=('Regular expression to select tests to be executed. Passed to the `tmt test ls` command. '),
193
184
  rich_help_panel=REQUEST_PANEL_TMT,
194
185
  )
195
186
  OPTION_TMT_TEST_FILTER: Optional[str] = typer.Option(
196
187
  None,
197
188
  "--test-filter",
198
189
  help=(
199
- 'Filter tmt tests. '
200
- 'Passed as `--filter` option to the `tmt test` command. '
201
- 'It overrides any test filter defined in the plan. '
202
- 'See https://tmt.readthedocs.io/en/stable/examples.html#filter-tests for more information.'
190
+ 'Apply an advanced filter using key:value pairs and logical operators to filter tests. '
191
+ 'Passed as `--filter` option to the `tmt test ls` command. '
192
+ 'See `pydoc fmf.filter` for detailed documentation on the syntax.'
203
193
  ),
204
194
  rich_help_panel=REQUEST_PANEL_TMT,
205
195
  )
@@ -274,6 +264,7 @@ OPTION_REPOSITORY: List[str] = typer.Option(
274
264
  OPTION_REPOSITORY_FILE: List[str] = typer.Option(
275
265
  None,
276
266
  help="URL to a repository file which should be added to /etc/yum.repos.d, e.g. https://example.com/repository.repo", # noqa
267
+ rich_help_panel=RESERVE_PANEL_ENVIRONMENT,
277
268
  )
278
269
  OPTION_DRY_RUN: bool = typer.Option(
279
270
  False, help="Do not submit a request to Testing Farm, just print it.", rich_help_panel=RESERVE_PANEL_GENERAL
@@ -283,14 +274,14 @@ OPTION_VARIABLES: Optional[List[str]] = typer.Option(
283
274
  "-e",
284
275
  "--environment",
285
276
  metavar="key=value|@file",
286
- help="Variables to pass to the test environment. The @ prefix marks a yaml file to load.",
277
+ help="Variables to pass to the test environment. The @ prefix marks a yaml or dotenv file to load.",
287
278
  )
288
279
  OPTION_SECRETS: Optional[List[str]] = typer.Option(
289
280
  None,
290
281
  "-s",
291
282
  "--secret",
292
283
  metavar="key=value|@file",
293
- help="Secret variables to pass to the test environment. The @ prefix marks a yaml file to load.",
284
+ help="Secret variables to pass to the test environment. The @ prefix marks a yaml or dotenv file to load.",
294
285
  )
295
286
  OPTION_HARDWARE: List[str] = typer.Option(
296
287
  None,
@@ -506,7 +497,7 @@ def _extend_test_filter_for_reservation(tmt_test_filter: Optional[str]) -> Optio
506
497
  Extend test filter to include the reservation test when --reserve is used.
507
498
  """
508
499
  if tmt_test_filter:
509
- return f"{tmt_test_filter} | name:{RESERVE_TEST}"
500
+ return f"{tmt_test_filter} | name:{RESERVE_TEST}" # noqa: E231
510
501
  return None
511
502
 
512
503
 
@@ -617,7 +608,7 @@ def _parse_security_group_rules(ingress_rules: List[str], egress_rules: List[str
617
608
  return security_group_rules
618
609
 
619
610
 
620
- def _parse_xunit(xunit: str):
611
+ def _parse_xunit(xunit: str, multihost: bool = False):
621
612
  """
622
613
  A helper that parses xunit file into sets of passed_plans/failed_plans/errored_plans per arch.
623
614
 
@@ -641,9 +632,12 @@ def _parse_xunit(xunit: str):
641
632
 
642
633
  results_root = ET.fromstring(xunit)
643
634
  for plan in results_root.findall('./testsuite'):
644
- # Try to get information about the environment (stored under ./testing-environment), may be
645
- # absent if state is undefined
646
- testing_environment: Optional[ET.Element] = plan.find('./testing-environment[@name="requested"]')
635
+ testing_environment = plan.find(
636
+ './testing-environment[@name="requested"]'
637
+ if not multihost
638
+ else './guest/testing-environment[@name="provisioned"]'
639
+ )
640
+
647
641
  if not testing_environment:
648
642
  console_stderr.print(
649
643
  f'Could not find env specifications for {plan.get("name")}, assuming fail for all arches'
@@ -678,6 +672,7 @@ def _get_request_summary(request: dict, session: requests.Session):
678
672
  artifacts_url = (request.get('run') or {}).get('artifacts')
679
673
  xpath_url = f'{artifacts_url}/results.xml' if artifacts_url else ''
680
674
  xunit = (request.get('result') or {}).get('xunit') or '<testsuites></testsuites>'
675
+ multihost = ((request.get('settings') or {}).get('pipeline') or {}).get('type') == 'tmt-multihost'
681
676
  if state not in ['queued', 'running'] and artifacts_url:
682
677
  # NOTE(ivasilev) xunit can be None (ex. in case of timed out requests) so let's fetch results.xml and use it
683
678
  # as source of truth
@@ -687,7 +682,7 @@ def _get_request_summary(request: dict, session: requests.Session):
687
682
  xunit = response.text
688
683
  except requests.exceptions.ConnectionError:
689
684
  console_stderr.print("Could not get xunit results")
690
- passed_plans, failed_plans, skipped_plans, errored_plans = _parse_xunit(xunit)
685
+ passed_plans, failed_plans, skipped_plans, errored_plans = _parse_xunit(xunit, multihost=multihost)
691
686
  overall = (request.get("result") or {}).get("overall")
692
687
  arches_requested = [env['arch'] for env in request['environments_requested']]
693
688
 
@@ -906,6 +901,31 @@ def version():
906
901
  console.print(f"{cli_version}")
907
902
 
908
903
 
904
+ def check_token(api_url: str, api_token: Optional[str]):
905
+ """Check for API token, falling back to keyring if not provided. Exits on failure."""
906
+
907
+ running_in_container = os.path.exists(settings.CONTAINER_SIGN)
908
+
909
+ # Keyring is not supported inside container environment
910
+ if not api_token and not running_in_container:
911
+ try:
912
+ import keyring
913
+
914
+ api_token = keyring.get_password(api_url, 'api-token')
915
+ except ImportError as e:
916
+ console.print(f"⚠️ keyring import error: {e}", style="yellow")
917
+ except Exception as e:
918
+ console.print(f"⚠️ keyring error: {e}", style="yellow")
919
+
920
+ if not api_token:
921
+ message = "No API token found, export `TESTING_FARM_API_TOKEN` environment variable."
922
+ if not running_in_container:
923
+ message += f" Or store it in keyring using `keyring set {api_url} api-token`."
924
+ exit_error(message)
925
+
926
+ return api_token
927
+
928
+
909
929
  def request(
910
930
  context: typer.Context,
911
931
  api_url: str = ARGUMENT_API_URL,
@@ -986,6 +1006,7 @@ def request(
986
1006
  parallel_limit: Optional[int] = OPTION_PARALLEL_LIMIT,
987
1007
  tmt_discover: Optional[List[str]] = _generate_tmt_extra_args("discover"),
988
1008
  tmt_prepare: Optional[List[str]] = _generate_tmt_extra_args("prepare"),
1009
+ tmt_report: Optional[List[str]] = _generate_tmt_extra_args("report"),
989
1010
  tmt_finish: Optional[List[str]] = _generate_tmt_extra_args("finish"),
990
1011
  reserve: bool = OPTION_RESERVE,
991
1012
  ssh_public_keys: List[str] = _option_ssh_public_keys(REQUEST_PANEL_RESERVE),
@@ -1005,9 +1026,7 @@ def request(
1005
1026
 
1006
1027
  git_available = bool(shutil.which("git"))
1007
1028
 
1008
- # check for token
1009
- if not api_token:
1010
- exit_error("No API token found, export `TESTING_FARM_API_TOKEN` environment variable")
1029
+ api_token = check_token(api_url, api_token)
1011
1030
 
1012
1031
  if not compose and arches != ['x86_64']:
1013
1032
  exit_error(
@@ -1163,7 +1182,7 @@ def request(
1163
1182
  if tmt_environment:
1164
1183
  environment["tmt"].update({"environment": options_to_dict("tmt environment variables", tmt_environment)})
1165
1184
 
1166
- if tmt_discover or tmt_prepare or tmt_finish:
1185
+ if tmt_discover or tmt_prepare or tmt_report or tmt_finish:
1167
1186
  if "extra_args" not in environment["tmt"]:
1168
1187
  environment["tmt"]["extra_args"] = {}
1169
1188
 
@@ -1173,6 +1192,9 @@ def request(
1173
1192
  if tmt_prepare:
1174
1193
  environment["tmt"]["extra_args"]["prepare"] = tmt_prepare
1175
1194
 
1195
+ if tmt_report:
1196
+ environment["tmt"]["extra_args"]["report"] = tmt_report
1197
+
1176
1198
  if tmt_finish:
1177
1199
  environment["tmt"]["extra_args"]["finish"] = tmt_finish
1178
1200
 
@@ -1300,7 +1322,7 @@ def request(
1300
1322
  # handle errors
1301
1323
  response = session.post(post_url, json=request, headers=authorization_headers(api_token))
1302
1324
  if response.status_code == 401:
1303
- exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
1325
+ handle_401_response(response)
1304
1326
 
1305
1327
  if response.status_code == 400:
1306
1328
  exit_error(
@@ -1351,6 +1373,7 @@ def restart(
1351
1373
  tmt_path: Optional[str] = OPTION_TMT_PATH,
1352
1374
  tmt_discover: Optional[List[str]] = _generate_tmt_extra_args("discover"),
1353
1375
  tmt_prepare: Optional[List[str]] = _generate_tmt_extra_args("prepare"),
1376
+ tmt_report: Optional[List[str]] = _generate_tmt_extra_args("report"),
1354
1377
  tmt_finish: Optional[List[str]] = _generate_tmt_extra_args("finish"),
1355
1378
  worker_image: Optional[str] = OPTION_WORKER_IMAGE,
1356
1379
  no_wait: bool = typer.Option(False, help="Skip waiting for request completion."),
@@ -1412,7 +1435,7 @@ def restart(
1412
1435
  response = session.get(get_url, headers=authorization_headers(effective_source_api_token))
1413
1436
 
1414
1437
  if response.status_code == 401:
1415
- exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
1438
+ handle_401_response(response)
1416
1439
 
1417
1440
  # The API token is valid, but it doesn't own the request
1418
1441
  if response.status_code == 403:
@@ -1495,7 +1518,7 @@ def restart(
1495
1518
  for environment in request['environments']:
1496
1519
  environment["pool"] = pool
1497
1520
 
1498
- if tmt_discover or tmt_prepare or tmt_finish:
1521
+ if tmt_discover or tmt_prepare or tmt_report or tmt_finish:
1499
1522
  for environment in request["environments"]:
1500
1523
  if "tmt" not in environment:
1501
1524
  environment["tmt"] = {"extra_args": {}}
@@ -1510,6 +1533,10 @@ def restart(
1510
1533
  for environment in request["environments"]:
1511
1534
  environment["tmt"]["extra_args"]["prepare"] = tmt_prepare
1512
1535
 
1536
+ if tmt_report:
1537
+ for environment in request["environments"]:
1538
+ environment["tmt"]["extra_args"]["report"] = tmt_report
1539
+
1513
1540
  if tmt_finish:
1514
1541
  for environment in request["environments"]:
1515
1542
  environment["tmt"]["extra_args"]["finish"] = tmt_finish
@@ -1621,7 +1648,7 @@ def restart(
1621
1648
  # handle errors
1622
1649
  response = session.post(post_url, json=request, headers=authorization_headers(effective_target_api_token))
1623
1650
  if response.status_code == 401:
1624
- exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
1651
+ handle_401_response(response)
1625
1652
 
1626
1653
  if response.status_code == 400:
1627
1654
  exit_error(
@@ -1668,9 +1695,7 @@ def run(
1668
1695
  Run an arbitrary script via Testing Farm.
1669
1696
  """
1670
1697
 
1671
- # check for token
1672
- if not api_token:
1673
- exit_error("No API token found, export `TESTING_FARM_API_TOKEN` environment variable.")
1698
+ api_token = check_token(api_url, api_token)
1674
1699
 
1675
1700
  # create request
1676
1701
  request = TestingFarmRequestV1
@@ -1719,7 +1744,7 @@ def run(
1719
1744
  # handle errors
1720
1745
  response = session.post(post_url, json=request, headers=authorization_headers(api_token))
1721
1746
  if response.status_code == 401:
1722
- exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
1747
+ handle_401_response(response)
1723
1748
 
1724
1749
  if response.status_code == 400:
1725
1750
  exit_error(f"Request is invalid. Please file an issue to {settings.ISSUE_TRACKER}")
@@ -1838,6 +1863,7 @@ def reserve(
1838
1863
  tmt_environment: Optional[List[str]] = OPTION_TMT_ENVIRONMENT,
1839
1864
  tmt_discover: Optional[List[str]] = _generate_tmt_extra_args("discover"),
1840
1865
  tmt_prepare: Optional[List[str]] = _generate_tmt_extra_args("prepare"),
1866
+ tmt_report: Optional[List[str]] = _generate_tmt_extra_args("report"),
1841
1867
  tmt_finish: Optional[List[str]] = _generate_tmt_extra_args("finish"),
1842
1868
  dry_run: bool = OPTION_DRY_RUN,
1843
1869
  post_install_script: Optional[str] = OPTION_POST_INSTALL_SCRIPT,
@@ -1871,9 +1897,7 @@ def reserve(
1871
1897
  # Accept these arguments only via environment variables
1872
1898
  check_unexpected_arguments(context, "api_url", "api_token")
1873
1899
 
1874
- # check for token
1875
- if not settings.API_TOKEN:
1876
- exit_error("No API token found, export `TESTING_FARM_API_TOKEN` environment variable.")
1900
+ api_token = check_token(api_url, api_token)
1877
1901
 
1878
1902
  pool_info = f"via pool [blue]{pool}[/blue]" if pool else ""
1879
1903
  console.print(f"💻 [blue]{compose}[/blue] on [blue]{arch}[/blue] {pool_info}")
@@ -1939,7 +1963,7 @@ def reserve(
1939
1963
  if post_install_script:
1940
1964
  environment["settings"]["provisioning"]["post_install_script"] = post_install_script
1941
1965
 
1942
- if tmt_discover or tmt_prepare or tmt_finish:
1966
+ if tmt_discover or tmt_prepare or tmt_report or tmt_finish:
1943
1967
  environment["tmt"] = {"extra_args": {}}
1944
1968
 
1945
1969
  if tmt_discover:
@@ -1948,6 +1972,9 @@ def reserve(
1948
1972
  if tmt_prepare:
1949
1973
  environment["tmt"]["extra_args"]["prepare"] = tmt_prepare
1950
1974
 
1975
+ if tmt_report:
1976
+ environment["tmt"]["extra_args"]["report"] = tmt_report
1977
+
1951
1978
  if tmt_finish:
1952
1979
  environment["tmt"]["extra_args"]["finish"] = tmt_finish
1953
1980
 
@@ -2023,7 +2050,7 @@ def reserve(
2023
2050
  # handle errors
2024
2051
  response = session.post(post_url, json=request, headers=authorization_headers(api_token))
2025
2052
  if response.status_code == 401:
2026
- exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
2053
+ handle_401_response(response)
2027
2054
 
2028
2055
  if response.status_code == 400:
2029
2056
  exit_error(
@@ -2190,9 +2217,7 @@ def cancel(
2190
2217
  # Extract the UUID from the request_id string
2191
2218
  _request_id = extract_uuid(request_id)
2192
2219
 
2193
- if not api_token:
2194
- exit_error("No API token found in the environment, please export 'TESTING_FARM_API_TOKEN' variable.")
2195
- return
2220
+ api_token = check_token(api_url, api_token)
2196
2221
 
2197
2222
  # Construct URL to the internal API
2198
2223
  request_url = urllib.parse.urljoin(str(api_url), f"v0.1/requests/{_request_id}")
@@ -2205,7 +2230,7 @@ def cancel(
2205
2230
  response = session.delete(request_url, headers=authorization_headers(api_token))
2206
2231
 
2207
2232
  if response.status_code == 401:
2208
- exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
2233
+ handle_401_response(response)
2209
2234
 
2210
2235
  if response.status_code == 403:
2211
2236
  exit_error(
@@ -2250,9 +2275,7 @@ def encrypt(
2250
2275
  # Accept these arguments only via environment variables
2251
2276
  check_unexpected_arguments(context, "api_url", "api_token")
2252
2277
 
2253
- # check for token
2254
- if not api_token:
2255
- exit_error("No API token found, export `TESTING_FARM_API_TOKEN` environment variable")
2278
+ api_token = check_token(api_url, api_token)
2256
2279
 
2257
2280
  git_available = bool(shutil.which("git"))
2258
2281
 
@@ -2284,7 +2307,7 @@ def encrypt(
2284
2307
 
2285
2308
  # handle errors
2286
2309
  if response.status_code == 401:
2287
- exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
2310
+ handle_401_response(response)
2288
2311
 
2289
2312
  if response.status_code == 400:
2290
2313
  exit_error(
tft/cli/tool.py CHANGED
@@ -6,12 +6,14 @@ import os
6
6
  import typer
7
7
 
8
8
  import tft.cli.commands as commands
9
+ from tft.cli.command.composes import composes
9
10
  from tft.cli.command.listing import listing
10
11
  from tft.cli.config import settings
11
12
 
12
13
  app = typer.Typer()
13
14
 
14
15
  app.command()(commands.cancel)
16
+ app.command()(composes)
15
17
  app.command(name="list")(listing)
16
18
  app.command()(commands.request)
17
19
  app.command()(commands.restart)
tft/cli/utils.py CHANGED
@@ -20,6 +20,7 @@ import requests
20
20
  import requests.adapters
21
21
  import typer
22
22
  from click.core import ParameterSource
23
+ from dotenv import dotenv_values
23
24
  from rich.console import Console
24
25
  from ruamel.yaml import YAML # type: ignore
25
26
  from urllib3 import Retry
@@ -69,7 +70,16 @@ class Age:
69
70
  return f"{self.value}{self.unit}"
70
71
 
71
72
 
72
- class OutputFormat(str, Enum):
73
+ # Use built-in StrEnum for Python 3.11+, otherwise define a compatible version
74
+ if sys.version_info >= (3, 11):
75
+ from enum import StrEnum
76
+ else:
77
+
78
+ class StrEnum(str, Enum):
79
+ pass
80
+
81
+
82
+ class OutputFormat(StrEnum):
73
83
  text = "text"
74
84
  json = "json"
75
85
  yaml = "yaml"
@@ -77,7 +87,7 @@ class OutputFormat(str, Enum):
77
87
 
78
88
  @staticmethod
79
89
  def available_formats():
80
- return "text, json or table"
90
+ return "text, json, yaml or table"
81
91
 
82
92
 
83
93
  def exit_error(error: str) -> NoReturn:
@@ -86,6 +96,24 @@ def exit_error(error: str) -> NoReturn:
86
96
  raise typer.Exit(code=255)
87
97
 
88
98
 
99
+ def handle_401_response(response: requests.Response) -> NoReturn:
100
+ """Handle 401 Unauthorized responses with appropriate error message.
101
+
102
+ Differentiates between expired tokens and invalid tokens based on
103
+ the API response message.
104
+ """
105
+ try:
106
+ error_msg = response.json().get('message', '')
107
+ if error_msg == "Token has expired":
108
+ exit_error(
109
+ f"API token has expired. Please generate a new token at "
110
+ f"{settings.ONBOARDING_DOCS} and update your TESTING_FARM_API_TOKEN."
111
+ )
112
+ except requests.exceptions.JSONDecodeError:
113
+ pass
114
+ exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
115
+
116
+
89
117
  def cmd_output_or_exit(command: str, error: str) -> str:
90
118
  """Return local command output or exit with given error message"""
91
119
  try:
@@ -180,42 +208,68 @@ def hw_constraints(hardware: List[str]) -> Dict[Any, Any]:
180
208
  elif value.lower() in ['false']:
181
209
  value_mixed = False
182
210
 
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()
211
+ final_key = path_splitted.pop()
188
212
 
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
213
+ # Handle compatible.distro as a list
214
+ if final_key == 'distro':
215
+ if final_key not in container:
216
+ container[final_key] = []
217
+ container[final_key].append(value_mixed)
218
+ else:
219
+ container[final_key] = value_mixed
196
220
 
197
221
  return constraints
198
222
 
199
223
 
200
- def options_from_file(filepath) -> Dict[str, str]:
201
- """Read environment variables from a yaml file."""
224
+ def options_from_yaml(filepath: str) -> Optional[Dict[str, Optional[str]]]:
225
+ """Read environment variables from yaml content.
226
+
227
+ Raises:
228
+ ValueError: If the file cannot be parsed as YAML or has invalid structure.
229
+ """
202
230
 
203
231
  with open(filepath, 'r') as file:
204
- try:
205
- yaml = YAML(typ="safe").load(file.read())
206
- except Exception:
207
- exit_error(f"Failed to load variables from yaml file {filepath}.")
232
+ content = file.read()
208
233
 
209
- if not yaml: # pyre-ignore[61] # pyre ignores NoReturn in exit_error
210
- return {}
234
+ try:
235
+ yaml = YAML(typ="safe").load(content)
236
+ except Exception:
237
+ return None
238
+
239
+ if not yaml:
240
+ return {}
241
+
242
+ if not isinstance(yaml, dict):
243
+ exit_error(f"Environment file {filepath} is not a dict.")
244
+
245
+ if any([isinstance(value, (list, dict)) for value in yaml.values()]):
246
+ exit_error(f"Values of environment file {filepath} are not primitive types.")
247
+
248
+ return yaml
249
+
250
+
251
+ def options_from_dotenv(filepath: str) -> Dict[str, Optional[str]]:
252
+ """Read environment variables from dotenv file.
211
253
 
212
- if not isinstance(yaml, dict): # pyre-ignore[61] # pyre ignores NoReturn in exit_error
213
- exit_error(f"Environment file {filepath} is not a dict.")
254
+ Raises:
255
+ Exception: If the file cannot be parsed as dotenv.
256
+ """
257
+ try:
258
+ return dotenv_values(filepath)
259
+ except Exception:
260
+ exit_error(f"Failed to load variables from file {filepath}.")
261
+
262
+
263
+ def options_from_file(filepath) -> Dict[str, Optional[str]]:
264
+ """Read environment variables from a yaml or dotenv file."""
265
+ # Try to load from yaml first
266
+ # If that fails, try to load from dotenv
267
+ yaml = options_from_yaml(filepath)
214
268
 
215
- if any([isinstance(value, (list, dict)) for value in yaml.values()]):
216
- exit_error(f"Values of environment file {filepath} are not primitive types.")
269
+ if yaml is None:
270
+ return options_from_dotenv(filepath)
217
271
 
218
- return yaml # pyre-ignore[61] # pyre ignores NoReturn in exit_error
272
+ return yaml
219
273
 
220
274
 
221
275
  def options_to_dict(name: str, options: List[str]) -> Dict[str, str]:
@@ -412,3 +466,15 @@ def edit_with_editor(data: Any, description: Optional[str]) -> Any:
412
466
  # Read the modified content
413
467
  with open(temp_file.name, 'r') as modified_file:
414
468
  return modified_file.read()
469
+
470
+
471
+ def handle_response_errors(response: requests.Response) -> None:
472
+ if response.status_code == 401:
473
+ exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
474
+
475
+ if response.status_code != 200:
476
+ exit_error(
477
+ f"Unexpected error {response.text}. "
478
+ f"Check {settings.STATUS_PAGE}. "
479
+ f"File an issue to {settings.ISSUE_TRACKER} if needed."
480
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: tft-cli
3
- Version: 0.0.29
3
+ Version: 0.0.31
4
4
  Summary: Testing Farm CLI tool
5
5
  License: Apache-2.0
6
6
  Author: Miroslav Vadkerti
@@ -14,7 +14,9 @@ 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: keyring (>=25.7.0,<26.0.0)
17
18
  Requires-Dist: pendulum (>=3.0.0,<4.0.0)
19
+ Requires-Dist: python-dotenv (>=1.2.1,<2.0.0)
18
20
  Requires-Dist: requests (>=2.27.1,<3.0.0)
19
21
  Requires-Dist: rich (>=12)
20
22
  Requires-Dist: ruamel-yaml (>=0.18.5,<0.19.0)
@@ -0,0 +1,13 @@
1
+ tft/cli/__init__.py,sha256=uEJkNJbqC583PBtNI30kxWdeOr3Wj6zJzIYKf0AD72I,92
2
+ tft/cli/command/__init__.py,sha256=uEJkNJbqC583PBtNI30kxWdeOr3Wj6zJzIYKf0AD72I,92
3
+ tft/cli/command/composes.py,sha256=85xdTqgCIINl4XDhOAZq2EWI_KhitvGGMlneyK7xpc4,6126
4
+ tft/cli/command/listing.py,sha256=a0DDdXQ84qL5v6lpOEuF-UiBkD6Zr7yr02Oa3t1cd6I,30863
5
+ tft/cli/commands.py,sha256=fX08gLXDosZC06YBt5igiFsprJBOHAxclR44ptCwxu0,88374
6
+ tft/cli/config.py,sha256=9JWjEwBiS4_Eq1B53lxxCIceERJ7AeryUyqxj9fm9c4,1714
7
+ tft/cli/tool.py,sha256=xGBqfk8te63cijhhWtP1SopiHugTHPLp2crGx-LoLXg,1045
8
+ tft/cli/utils.py,sha256=9LRNTfG1zTD65_foF6IGD9H3JoYJVeJHzhI7siNv20I,15449
9
+ tft_cli-0.0.31.dist-info/LICENSE,sha256=YpVAQfXkIyzQAdm5LZkI6L5UWqLppa6O8_tgDSdoabQ,574
10
+ tft_cli-0.0.31.dist-info/METADATA,sha256=kpgBJALq0mpq8v1WntORVmdoNQYRD6mB_YQRADNf0ck,918
11
+ tft_cli-0.0.31.dist-info/WHEEL,sha256=7Z8_27uaHI_UZAc4Uox4PpBhQ9Y5_modZXWMxtUi4NU,88
12
+ tft_cli-0.0.31.dist-info/entry_points.txt,sha256=xzdebHkH5Bx-YRf-XPMsIoVpvgfUqqcRQGuo8DFkiao,49
13
+ tft_cli-0.0.31.dist-info/RECORD,,
@@ -1,12 +0,0 @@
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=8mFsKZwf485-bsDvQRcKFKFXgpV8-76J75XCV__knm8,87730
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.29.dist-info/LICENSE,sha256=YpVAQfXkIyzQAdm5LZkI6L5UWqLppa6O8_tgDSdoabQ,574
9
- tft_cli-0.0.29.dist-info/METADATA,sha256=ckMhExUPazwr36mUf_JBNqPk8uU7quU9Ge4RmKMjI_E,830
10
- tft_cli-0.0.29.dist-info/WHEEL,sha256=7Z8_27uaHI_UZAc4Uox4PpBhQ9Y5_modZXWMxtUi4NU,88
11
- tft_cli-0.0.29.dist-info/entry_points.txt,sha256=xzdebHkH5Bx-YRf-XPMsIoVpvgfUqqcRQGuo8DFkiao,49
12
- tft_cli-0.0.29.dist-info/RECORD,,