tft-cli 0.0.29__tar.gz → 0.0.31__tar.gz
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-0.0.29 → tft_cli-0.0.31}/PKG-INFO +3 -1
- {tft_cli-0.0.29 → tft_cli-0.0.31}/pyproject.toml +3 -1
- tft_cli-0.0.31/src/tft/cli/command/composes.py +200 -0
- {tft_cli-0.0.29 → tft_cli-0.0.31}/src/tft/cli/command/listing.py +14 -22
- {tft_cli-0.0.29 → tft_cli-0.0.31}/src/tft/cli/commands.py +80 -57
- {tft_cli-0.0.29 → tft_cli-0.0.31}/src/tft/cli/tool.py +2 -0
- {tft_cli-0.0.29 → tft_cli-0.0.31}/src/tft/cli/utils.py +93 -27
- {tft_cli-0.0.29 → tft_cli-0.0.31}/LICENSE +0 -0
- {tft_cli-0.0.29 → tft_cli-0.0.31}/LICENSE_SPDX +0 -0
- {tft_cli-0.0.29 → tft_cli-0.0.31}/src/tft/cli/__init__.py +0 -0
- {tft_cli-0.0.29 → tft_cli-0.0.31}/src/tft/cli/command/__init__.py +0 -0
- {tft_cli-0.0.29 → tft_cli-0.0.31}/src/tft/cli/config.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: tft-cli
|
|
3
|
-
Version: 0.0.
|
|
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)
|
|
@@ -4,7 +4,7 @@ name = "tft-cli"
|
|
|
4
4
|
|
|
5
5
|
[tool.poetry]
|
|
6
6
|
name = "tft-cli"
|
|
7
|
-
version = "0.0.
|
|
7
|
+
version = "0.0.31"
|
|
8
8
|
description = "Testing Farm CLI tool"
|
|
9
9
|
authors = ["Miroslav Vadkerti <mvadkert@redhat.com>"]
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -31,6 +31,8 @@ setuptools = "*"
|
|
|
31
31
|
# can be removed after bumping typer to ">=0.12"
|
|
32
32
|
shellingham = ">=1.3.0,<2.0.0"
|
|
33
33
|
pendulum = "^3.0.0"
|
|
34
|
+
python-dotenv = "^1.2.1"
|
|
35
|
+
keyring = "^25.7.0"
|
|
34
36
|
|
|
35
37
|
[tool.poetry.dev-dependencies]
|
|
36
38
|
pyre-check = "^0.9.10"
|
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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}. "
|
|
@@ -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(
|
|
87
|
+
class WatchFormat(StrEnum):
|
|
87
88
|
text = 'text'
|
|
88
89
|
json = 'json'
|
|
89
90
|
|
|
90
91
|
|
|
91
|
-
class PipelineType(
|
|
92
|
+
class PipelineType(StrEnum):
|
|
92
93
|
tmt_multihost = "tmt-multihost"
|
|
93
94
|
|
|
94
95
|
|
|
95
|
-
class PipelineState(
|
|
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(
|
|
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
|
-
'
|
|
178
|
-
'Passed as `--filter` option to the `tmt plan` command. '
|
|
179
|
-
'
|
|
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
|
-
'
|
|
200
|
-
'Passed as `--filter` option to the `tmt test` command. '
|
|
201
|
-
'
|
|
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
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2310
|
+
handle_401_response(response)
|
|
2288
2311
|
|
|
2289
2312
|
if response.status_code == 400:
|
|
2290
2313
|
exit_error(
|
|
@@ -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)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
201
|
-
"""Read environment variables from
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
|
|
216
|
-
|
|
269
|
+
if yaml is None:
|
|
270
|
+
return options_from_dotenv(filepath)
|
|
217
271
|
|
|
218
|
-
|
|
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
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|