tft-cli 0.0.28__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,
@@ -68,7 +69,10 @@ RESERVE_URL = os.getenv("TESTING_FARM_RESERVE_URL", "https://gitlab.com/testing-
68
69
  RESERVE_REF = os.getenv("TESTING_FARM_RESERVE_REF", "main")
69
70
  RESERVE_TMT_DISCOVER_EXTRA_ARGS = f"--insert --how fmf --url {RESERVE_URL} --ref {RESERVE_REF} --test {RESERVE_TEST}"
70
71
 
72
+ # NOTE(mvadkert): note that reservation duration is different per ranch,
73
+ # ignore this fact for now here for reservations
71
74
  DEFAULT_PIPELINE_TIMEOUT = 60 * 12
75
+
72
76
  DEFAULT_AGE = "7d"
73
77
 
74
78
  # SSH command options for reservation connections
@@ -80,16 +84,16 @@ SSH_RESERVATION_OPTIONS = (
80
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)")
81
85
 
82
86
 
83
- class WatchFormat(str, Enum):
87
+ class WatchFormat(StrEnum):
84
88
  text = 'text'
85
89
  json = 'json'
86
90
 
87
91
 
88
- class PipelineType(str, Enum):
92
+ class PipelineType(StrEnum):
89
93
  tmt_multihost = "tmt-multihost"
90
94
 
91
95
 
92
- class PipelineState(str, Enum):
96
+ class PipelineState(StrEnum):
93
97
  new = "new"
94
98
  queued = "queued"
95
99
  running = "running"
@@ -98,7 +102,7 @@ class PipelineState(str, Enum):
98
102
  canceled = "canceled"
99
103
 
100
104
 
101
- class Ranch(str, Enum):
105
+ class Ranch(StrEnum):
102
106
  public = "public"
103
107
  redhat = "redhat"
104
108
 
@@ -160,43 +164,32 @@ ARGUMENT_TARGET_API_TOKEN: str = typer.Argument(
160
164
  OPTION_TMT_PLAN_NAME: Optional[str] = typer.Option(
161
165
  None,
162
166
  "--plan",
163
- help=(
164
- 'Select plans to be executed. '
165
- 'Passed as `--name` option to the `tmt plan` command. '
166
- 'Can be a regular expression.'
167
- ),
167
+ help=('A regular expression to select plans to be executed. Passed to the `tmt plan ls` command. '),
168
168
  rich_help_panel=REQUEST_PANEL_TMT,
169
169
  )
170
170
  OPTION_TMT_PLAN_FILTER: Optional[str] = typer.Option(
171
171
  None,
172
172
  "--plan-filter",
173
173
  help=(
174
- 'Filter tmt plans. '
175
- 'Passed as `--filter` option to the `tmt plan` command. '
176
- 'By default, `enabled:true` filter is applied. '
177
- 'Plan filtering is similar to test filtering, '
178
- '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.'
179
177
  ),
180
178
  rich_help_panel=REQUEST_PANEL_TMT,
181
179
  )
182
180
  OPTION_TMT_TEST_NAME: Optional[str] = typer.Option(
183
181
  None,
184
182
  "--test",
185
- help=(
186
- 'Select tests to be executed. '
187
- 'Passed as `--name` option to the `tmt test` command. '
188
- 'Can be a regular expression.'
189
- ),
183
+ help=('Regular expression to select tests to be executed. Passed to the `tmt test ls` command. '),
190
184
  rich_help_panel=REQUEST_PANEL_TMT,
191
185
  )
192
186
  OPTION_TMT_TEST_FILTER: Optional[str] = typer.Option(
193
187
  None,
194
188
  "--test-filter",
195
189
  help=(
196
- 'Filter tmt tests. '
197
- 'Passed as `--filter` option to the `tmt test` command. '
198
- 'It overrides any test filter defined in the plan. '
199
- '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.'
200
193
  ),
201
194
  rich_help_panel=REQUEST_PANEL_TMT,
202
195
  )
@@ -271,6 +264,7 @@ OPTION_REPOSITORY: List[str] = typer.Option(
271
264
  OPTION_REPOSITORY_FILE: List[str] = typer.Option(
272
265
  None,
273
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,
274
268
  )
275
269
  OPTION_DRY_RUN: bool = typer.Option(
276
270
  False, help="Do not submit a request to Testing Farm, just print it.", rich_help_panel=RESERVE_PANEL_GENERAL
@@ -280,14 +274,14 @@ OPTION_VARIABLES: Optional[List[str]] = typer.Option(
280
274
  "-e",
281
275
  "--environment",
282
276
  metavar="key=value|@file",
283
- 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.",
284
278
  )
285
279
  OPTION_SECRETS: Optional[List[str]] = typer.Option(
286
280
  None,
287
281
  "-s",
288
282
  "--secret",
289
283
  metavar="key=value|@file",
290
- 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.",
291
285
  )
292
286
  OPTION_HARDWARE: List[str] = typer.Option(
293
287
  None,
@@ -329,6 +323,17 @@ OPTION_TMT_CONTEXT: Optional[List[str]] = typer.Option(
329
323
  metavar="key=value|@file",
330
324
  help="Context variables to pass to `tmt`. The @ prefix marks a yaml file to load.",
331
325
  )
326
+ OPTION_TMT_ENVIRONMENT: Optional[List[str]] = typer.Option(
327
+ None,
328
+ "-T",
329
+ "--tmt-environment",
330
+ metavar="key=value|@file",
331
+ help=(
332
+ "Environment variables to pass to the tmt process. "
333
+ "Used to configure tmt report plugins like reportportal or polarion. "
334
+ "The @ prefix marks a yaml file to load."
335
+ ),
336
+ )
332
337
 
333
338
 
334
339
  def _option_autoconnect(panel: str) -> bool:
@@ -478,6 +483,24 @@ def _localhost_ingress_rule(session: requests.Session) -> str:
478
483
  exit_error(f"Got {get_ip.status_code} while checking {settings.PUBLIC_IP_CHECKER_URL}")
479
484
 
480
485
 
486
+ def _extend_test_name_for_reservation(tmt_test_name: Optional[str]) -> Optional[str]:
487
+ """
488
+ Extend test name to include the reservation test when --reserve is used.
489
+ """
490
+ if tmt_test_name:
491
+ return f"{tmt_test_name}|{RESERVE_TEST}"
492
+ return None
493
+
494
+
495
+ def _extend_test_filter_for_reservation(tmt_test_filter: Optional[str]) -> Optional[str]:
496
+ """
497
+ Extend test filter to include the reservation test when --reserve is used.
498
+ """
499
+ if tmt_test_filter:
500
+ return f"{tmt_test_filter} | name:{RESERVE_TEST}" # noqa: E231
501
+ return None
502
+
503
+
481
504
  def _add_reservation(
482
505
  ssh_public_keys: List[str],
483
506
  rules: Dict[str, Any],
@@ -585,7 +608,7 @@ def _parse_security_group_rules(ingress_rules: List[str], egress_rules: List[str
585
608
  return security_group_rules
586
609
 
587
610
 
588
- def _parse_xunit(xunit: str):
611
+ def _parse_xunit(xunit: str, multihost: bool = False):
589
612
  """
590
613
  A helper that parses xunit file into sets of passed_plans/failed_plans/errored_plans per arch.
591
614
 
@@ -609,9 +632,12 @@ def _parse_xunit(xunit: str):
609
632
 
610
633
  results_root = ET.fromstring(xunit)
611
634
  for plan in results_root.findall('./testsuite'):
612
- # Try to get information about the environment (stored under ./testing-environment), may be
613
- # absent if state is undefined
614
- 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
+
615
641
  if not testing_environment:
616
642
  console_stderr.print(
617
643
  f'Could not find env specifications for {plan.get("name")}, assuming fail for all arches'
@@ -646,6 +672,7 @@ def _get_request_summary(request: dict, session: requests.Session):
646
672
  artifacts_url = (request.get('run') or {}).get('artifacts')
647
673
  xpath_url = f'{artifacts_url}/results.xml' if artifacts_url else ''
648
674
  xunit = (request.get('result') or {}).get('xunit') or '<testsuites></testsuites>'
675
+ multihost = ((request.get('settings') or {}).get('pipeline') or {}).get('type') == 'tmt-multihost'
649
676
  if state not in ['queued', 'running'] and artifacts_url:
650
677
  # NOTE(ivasilev) xunit can be None (ex. in case of timed out requests) so let's fetch results.xml and use it
651
678
  # as source of truth
@@ -655,7 +682,7 @@ def _get_request_summary(request: dict, session: requests.Session):
655
682
  xunit = response.text
656
683
  except requests.exceptions.ConnectionError:
657
684
  console_stderr.print("Could not get xunit results")
658
- 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)
659
686
  overall = (request.get("result") or {}).get("overall")
660
687
  arches_requested = [env['arch'] for env in request['environments_requested']]
661
688
 
@@ -874,13 +901,43 @@ def version():
874
901
  console.print(f"{cli_version}")
875
902
 
876
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
+
877
929
  def request(
878
930
  context: typer.Context,
879
931
  api_url: str = ARGUMENT_API_URL,
880
932
  api_token: str = ARGUMENT_API_TOKEN,
881
- timeout: int = typer.Option(
882
- DEFAULT_PIPELINE_TIMEOUT,
883
- help="Set the timeout for the request in minutes. If the test takes longer than this, it will be terminated.",
933
+ timeout: Optional[int] = typer.Option(
934
+ None,
935
+ help=(
936
+ "Set the timeout for the request in minutes. "
937
+ "If the request takes longer than this, it will be terminated. "
938
+ "The timeout is counted from the time the request is switched to the running state."
939
+ "For default timeout see https://docs.testing-farm.io/Testing%20Farm/0.1/test-request.html."
940
+ ),
884
941
  ),
885
942
  test_type: str = typer.Option("fmf", help="Test type to use, if not set autodetected."),
886
943
  tmt_plan_name: Optional[str] = OPTION_TMT_PLAN_NAME,
@@ -914,17 +971,7 @@ def request(
914
971
  cli_tmt_context: Optional[List[str]] = OPTION_TMT_CONTEXT,
915
972
  variables: Optional[List[str]] = OPTION_VARIABLES,
916
973
  secrets: Optional[List[str]] = OPTION_SECRETS,
917
- tmt_environment: Optional[List[str]] = typer.Option(
918
- None,
919
- "-T",
920
- "--tmt-environment",
921
- metavar="key=value|@file",
922
- help=(
923
- "Environment variables to pass to the tmt process. "
924
- "Used to configure tmt report plugins like reportportal or polarion. "
925
- "The @ prefix marks a yaml file to load."
926
- ),
927
- ),
974
+ tmt_environment: Optional[List[str]] = OPTION_TMT_ENVIRONMENT,
928
975
  no_wait: bool = typer.Option(False, help="Skip waiting for request completion."),
929
976
  worker_image: Optional[str] = OPTION_WORKER_IMAGE,
930
977
  redhat_brew_build: List[str] = OPTION_REDHAT_BREW_BUILD,
@@ -959,6 +1006,7 @@ def request(
959
1006
  parallel_limit: Optional[int] = OPTION_PARALLEL_LIMIT,
960
1007
  tmt_discover: Optional[List[str]] = _generate_tmt_extra_args("discover"),
961
1008
  tmt_prepare: Optional[List[str]] = _generate_tmt_extra_args("prepare"),
1009
+ tmt_report: Optional[List[str]] = _generate_tmt_extra_args("report"),
962
1010
  tmt_finish: Optional[List[str]] = _generate_tmt_extra_args("finish"),
963
1011
  reserve: bool = OPTION_RESERVE,
964
1012
  ssh_public_keys: List[str] = _option_ssh_public_keys(REQUEST_PANEL_RESERVE),
@@ -978,9 +1026,7 @@ def request(
978
1026
 
979
1027
  git_available = bool(shutil.which("git"))
980
1028
 
981
- # check for token
982
- if not api_token:
983
- exit_error("No API token found, export `TESTING_FARM_API_TOKEN` environment variable")
1029
+ api_token = check_token(api_url, api_token)
984
1030
 
985
1031
  if not compose and arches != ['x86_64']:
986
1032
  exit_error(
@@ -1070,10 +1116,16 @@ def request(
1070
1116
  test["plan_filter"] = tmt_plan_filter
1071
1117
 
1072
1118
  if tmt_test_name:
1073
- test["test_name"] = tmt_test_name
1119
+ if reserve:
1120
+ test["test_name"] = _extend_test_name_for_reservation(tmt_test_name)
1121
+ else:
1122
+ test["test_name"] = tmt_test_name
1074
1123
 
1075
1124
  if tmt_test_filter:
1076
- test["test_filter"] = tmt_test_filter
1125
+ if reserve:
1126
+ test["test_filter"] = _extend_test_filter_for_reservation(tmt_test_filter)
1127
+ else:
1128
+ test["test_filter"] = tmt_test_filter
1077
1129
 
1078
1130
  if sti_playbooks:
1079
1131
  test["playbooks"] = sti_playbooks
@@ -1130,7 +1182,7 @@ def request(
1130
1182
  if tmt_environment:
1131
1183
  environment["tmt"].update({"environment": options_to_dict("tmt environment variables", tmt_environment)})
1132
1184
 
1133
- if tmt_discover or tmt_prepare or tmt_finish:
1185
+ if tmt_discover or tmt_prepare or tmt_report or tmt_finish:
1134
1186
  if "extra_args" not in environment["tmt"]:
1135
1187
  environment["tmt"]["extra_args"] = {}
1136
1188
 
@@ -1140,6 +1192,9 @@ def request(
1140
1192
  if tmt_prepare:
1141
1193
  environment["tmt"]["extra_args"]["prepare"] = tmt_prepare
1142
1194
 
1195
+ if tmt_report:
1196
+ environment["tmt"]["extra_args"]["report"] = tmt_report
1197
+
1143
1198
  if tmt_finish:
1144
1199
  environment["tmt"]["extra_args"]["finish"] = tmt_finish
1145
1200
 
@@ -1217,11 +1272,14 @@ def request(
1217
1272
  request["environments"] = environments
1218
1273
  request["settings"] = {}
1219
1274
 
1220
- if reserve or pipeline_type or parallel_limit or timeout != DEFAULT_PIPELINE_TIMEOUT:
1275
+ forced_pipeline_timeout = context.get_parameter_source("timeout") == ParameterSource.COMMANDLINE
1276
+
1277
+ if reserve or pipeline_type or parallel_limit or forced_pipeline_timeout:
1221
1278
  request["settings"]["pipeline"] = {}
1222
1279
 
1223
1280
  # in case the reservation duration is more than the pipeline timeout, adjust also the pipeline timeout
1224
1281
  if reserve:
1282
+ timeout = timeout or DEFAULT_PIPELINE_TIMEOUT
1225
1283
  if reservation_duration > timeout:
1226
1284
  request["settings"]["pipeline"] = {"timeout": reservation_duration}
1227
1285
  console.print(f"⏳ Maximum reservation time is {reservation_duration} minutes")
@@ -1230,7 +1288,7 @@ def request(
1230
1288
  console.print(f"⏳ Maximum reservation time is {timeout} minutes")
1231
1289
 
1232
1290
  # forced pipeline timeout
1233
- elif timeout != DEFAULT_PIPELINE_TIMEOUT:
1291
+ elif forced_pipeline_timeout:
1234
1292
  console.print(f"⏳ Pipeline timeout forced to {timeout} minutes")
1235
1293
  request["settings"]["pipeline"] = {"timeout": timeout}
1236
1294
 
@@ -1264,7 +1322,7 @@ def request(
1264
1322
  # handle errors
1265
1323
  response = session.post(post_url, json=request, headers=authorization_headers(api_token))
1266
1324
  if response.status_code == 401:
1267
- exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
1325
+ handle_401_response(response)
1268
1326
 
1269
1327
  if response.status_code == 400:
1270
1328
  exit_error(
@@ -1315,6 +1373,7 @@ def restart(
1315
1373
  tmt_path: Optional[str] = OPTION_TMT_PATH,
1316
1374
  tmt_discover: Optional[List[str]] = _generate_tmt_extra_args("discover"),
1317
1375
  tmt_prepare: Optional[List[str]] = _generate_tmt_extra_args("prepare"),
1376
+ tmt_report: Optional[List[str]] = _generate_tmt_extra_args("report"),
1318
1377
  tmt_finish: Optional[List[str]] = _generate_tmt_extra_args("finish"),
1319
1378
  worker_image: Optional[str] = OPTION_WORKER_IMAGE,
1320
1379
  no_wait: bool = typer.Option(False, help="Skip waiting for request completion."),
@@ -1376,7 +1435,7 @@ def restart(
1376
1435
  response = session.get(get_url, headers=authorization_headers(effective_source_api_token))
1377
1436
 
1378
1437
  if response.status_code == 401:
1379
- exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
1438
+ handle_401_response(response)
1380
1439
 
1381
1440
  # The API token is valid, but it doesn't own the request
1382
1441
  if response.status_code == 403:
@@ -1423,10 +1482,16 @@ def restart(
1423
1482
  test["ref"] = git_ref
1424
1483
 
1425
1484
  if tmt_test_name:
1426
- test["test_name"] = tmt_test_name
1485
+ if reserve:
1486
+ test["test_name"] = _extend_test_name_for_reservation(tmt_test_name)
1487
+ else:
1488
+ test["test_name"] = tmt_test_name
1427
1489
 
1428
1490
  if tmt_test_filter:
1429
- test["test_filter"] = tmt_test_filter
1491
+ if reserve:
1492
+ test["test_filter"] = _extend_test_filter_for_reservation(tmt_test_filter)
1493
+ else:
1494
+ test["test_filter"] = tmt_test_filter
1430
1495
 
1431
1496
  merge_sha_info = ""
1432
1497
  if git_merge_sha:
@@ -1453,7 +1518,7 @@ def restart(
1453
1518
  for environment in request['environments']:
1454
1519
  environment["pool"] = pool
1455
1520
 
1456
- if tmt_discover or tmt_prepare or tmt_finish:
1521
+ if tmt_discover or tmt_prepare or tmt_report or tmt_finish:
1457
1522
  for environment in request["environments"]:
1458
1523
  if "tmt" not in environment:
1459
1524
  environment["tmt"] = {"extra_args": {}}
@@ -1468,6 +1533,10 @@ def restart(
1468
1533
  for environment in request["environments"]:
1469
1534
  environment["tmt"]["extra_args"]["prepare"] = tmt_prepare
1470
1535
 
1536
+ if tmt_report:
1537
+ for environment in request["environments"]:
1538
+ environment["tmt"]["extra_args"]["report"] = tmt_report
1539
+
1471
1540
  if tmt_finish:
1472
1541
  for environment in request["environments"]:
1473
1542
  environment["tmt"]["extra_args"]["finish"] = tmt_finish
@@ -1579,7 +1648,7 @@ def restart(
1579
1648
  # handle errors
1580
1649
  response = session.post(post_url, json=request, headers=authorization_headers(effective_target_api_token))
1581
1650
  if response.status_code == 401:
1582
- exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
1651
+ handle_401_response(response)
1583
1652
 
1584
1653
  if response.status_code == 400:
1585
1654
  exit_error(
@@ -1626,9 +1695,7 @@ def run(
1626
1695
  Run an arbitrary script via Testing Farm.
1627
1696
  """
1628
1697
 
1629
- # check for token
1630
- if not api_token:
1631
- exit_error("No API token found, export `TESTING_FARM_API_TOKEN` environment variable.")
1698
+ api_token = check_token(api_url, api_token)
1632
1699
 
1633
1700
  # create request
1634
1701
  request = TestingFarmRequestV1
@@ -1677,7 +1744,7 @@ def run(
1677
1744
  # handle errors
1678
1745
  response = session.post(post_url, json=request, headers=authorization_headers(api_token))
1679
1746
  if response.status_code == 401:
1680
- exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
1747
+ handle_401_response(response)
1681
1748
 
1682
1749
  if response.status_code == 400:
1683
1750
  exit_error(f"Request is invalid. Please file an issue to {settings.ISSUE_TRACKER}")
@@ -1793,8 +1860,10 @@ def reserve(
1793
1860
  repository: List[str] = OPTION_REPOSITORY,
1794
1861
  repository_file: List[str] = OPTION_REPOSITORY_FILE,
1795
1862
  redhat_brew_build: List[str] = OPTION_REDHAT_BREW_BUILD,
1863
+ tmt_environment: Optional[List[str]] = OPTION_TMT_ENVIRONMENT,
1796
1864
  tmt_discover: Optional[List[str]] = _generate_tmt_extra_args("discover"),
1797
1865
  tmt_prepare: Optional[List[str]] = _generate_tmt_extra_args("prepare"),
1866
+ tmt_report: Optional[List[str]] = _generate_tmt_extra_args("report"),
1798
1867
  tmt_finish: Optional[List[str]] = _generate_tmt_extra_args("finish"),
1799
1868
  dry_run: bool = OPTION_DRY_RUN,
1800
1869
  post_install_script: Optional[str] = OPTION_POST_INSTALL_SCRIPT,
@@ -1828,9 +1897,7 @@ def reserve(
1828
1897
  # Accept these arguments only via environment variables
1829
1898
  check_unexpected_arguments(context, "api_url", "api_token")
1830
1899
 
1831
- # check for token
1832
- if not settings.API_TOKEN:
1833
- exit_error("No API token found, export `TESTING_FARM_API_TOKEN` environment variable.")
1900
+ api_token = check_token(api_url, api_token)
1834
1901
 
1835
1902
  pool_info = f"via pool [blue]{pool}[/blue]" if pool else ""
1836
1903
  console.print(f"💻 [blue]{compose}[/blue] on [blue]{arch}[/blue] {pool_info}")
@@ -1896,7 +1963,7 @@ def reserve(
1896
1963
  if post_install_script:
1897
1964
  environment["settings"]["provisioning"]["post_install_script"] = post_install_script
1898
1965
 
1899
- if tmt_discover or tmt_prepare or tmt_finish:
1966
+ if tmt_discover or tmt_prepare or tmt_report or tmt_finish:
1900
1967
  environment["tmt"] = {"extra_args": {}}
1901
1968
 
1902
1969
  if tmt_discover:
@@ -1905,9 +1972,18 @@ def reserve(
1905
1972
  if tmt_prepare:
1906
1973
  environment["tmt"]["extra_args"]["prepare"] = tmt_prepare
1907
1974
 
1975
+ if tmt_report:
1976
+ environment["tmt"]["extra_args"]["report"] = tmt_report
1977
+
1908
1978
  if tmt_finish:
1909
1979
  environment["tmt"]["extra_args"]["finish"] = tmt_finish
1910
1980
 
1981
+ if tmt_environment:
1982
+ if "tmt" not in environment:
1983
+ environment["tmt"] = {}
1984
+
1985
+ environment["tmt"].update({"environment": options_to_dict("tmt environment variables", tmt_environment)})
1986
+
1911
1987
  # Setting up retries
1912
1988
  session = requests.Session()
1913
1989
  install_http_retries(session)
@@ -1974,7 +2050,7 @@ def reserve(
1974
2050
  # handle errors
1975
2051
  response = session.post(post_url, json=request, headers=authorization_headers(api_token))
1976
2052
  if response.status_code == 401:
1977
- exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
2053
+ handle_401_response(response)
1978
2054
 
1979
2055
  if response.status_code == 400:
1980
2056
  exit_error(
@@ -2141,9 +2217,7 @@ def cancel(
2141
2217
  # Extract the UUID from the request_id string
2142
2218
  _request_id = extract_uuid(request_id)
2143
2219
 
2144
- if not api_token:
2145
- exit_error("No API token found in the environment, please export 'TESTING_FARM_API_TOKEN' variable.")
2146
- return
2220
+ api_token = check_token(api_url, api_token)
2147
2221
 
2148
2222
  # Construct URL to the internal API
2149
2223
  request_url = urllib.parse.urljoin(str(api_url), f"v0.1/requests/{_request_id}")
@@ -2156,7 +2230,7 @@ def cancel(
2156
2230
  response = session.delete(request_url, headers=authorization_headers(api_token))
2157
2231
 
2158
2232
  if response.status_code == 401:
2159
- exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
2233
+ handle_401_response(response)
2160
2234
 
2161
2235
  if response.status_code == 403:
2162
2236
  exit_error(
@@ -2201,9 +2275,7 @@ def encrypt(
2201
2275
  # Accept these arguments only via environment variables
2202
2276
  check_unexpected_arguments(context, "api_url", "api_token")
2203
2277
 
2204
- # check for token
2205
- if not api_token:
2206
- exit_error("No API token found, export `TESTING_FARM_API_TOKEN` environment variable")
2278
+ api_token = check_token(api_url, api_token)
2207
2279
 
2208
2280
  git_available = bool(shutil.which("git"))
2209
2281
 
@@ -2235,7 +2307,7 @@ def encrypt(
2235
2307
 
2236
2308
  # handle errors
2237
2309
  if response.status_code == 401:
2238
- exit_error(f"API token is invalid. See {settings.ONBOARDING_DOCS} for more information.")
2310
+ handle_401_response(response)
2239
2311
 
2240
2312
  if response.status_code == 400:
2241
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.28
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=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,,