clarity-cli 0.1.0__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.
- clarity_cli/__init__.py +0 -0
- clarity_cli/commands.py +174 -0
- clarity_cli/defs.py +47 -0
- clarity_cli/exceptions.py +14 -0
- clarity_cli/helpers.py +217 -0
- clarity_cli/input.py +47 -0
- clarity_cli/main.py +73 -0
- clarity_cli/outputs.py +67 -0
- clarity_cli-0.1.0.dist-info/METADATA +22 -0
- clarity_cli-0.1.0.dist-info/RECORD +12 -0
- clarity_cli-0.1.0.dist-info/WHEEL +4 -0
- clarity_cli-0.1.0.dist-info/entry_points.txt +3 -0
clarity_cli/__init__.py
ADDED
|
File without changes
|
clarity_cli/commands.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from clarity_cli.outputs import CliOutput, CliTable
|
|
2
|
+
from clarity_cli.input import CliInputs
|
|
3
|
+
from clarity_cli.helpers import (is_logged_in, get_tests, trigger_test_execution, write_new_profile_to_config,
|
|
4
|
+
get_profile_configuration, get_clarity_access_token, get_devices, format_device_state, get_params)
|
|
5
|
+
from clarity_cli.defs import TableColumn, ThemeColors, CONFIG_FILE, ProfileConfig, InputTypes
|
|
6
|
+
from clarity_cli.exceptions import StopCommand
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CliCommands():
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self.out = CliOutput()
|
|
12
|
+
self.input = CliInputs()
|
|
13
|
+
|
|
14
|
+
def _get_current_profile_and_config(self, profile=None, override_config_path=None):
|
|
15
|
+
config, profile_name = self.out.run_sync_function("Validate profile...",
|
|
16
|
+
get_profile_configuration, profile=profile, override_config_path=override_config_path)
|
|
17
|
+
return config, profile_name
|
|
18
|
+
|
|
19
|
+
def execute(self, ctx, test_id=None, profile=None, override_config_path=None, project=None, workspace=None, agent_id=None, parameters_file=None):
|
|
20
|
+
"""Execute a test from the available tests"""
|
|
21
|
+
self.out.setup_context(ctx)
|
|
22
|
+
config, profile_name = self._get_current_profile_and_config(profile, override_config_path)
|
|
23
|
+
web_token = is_logged_in(profile_name)
|
|
24
|
+
|
|
25
|
+
project = project or config.project
|
|
26
|
+
workspace = workspace or config.workspace
|
|
27
|
+
agent_id = agent_id or config.agent
|
|
28
|
+
|
|
29
|
+
if not project:
|
|
30
|
+
self.out.vprint("project wasn't provided, asking user")
|
|
31
|
+
project = self.input.ask_for_text_input("Project id")
|
|
32
|
+
if not workspace:
|
|
33
|
+
self.out.vprint("workspace id wasn't provided, asking user")
|
|
34
|
+
workspace = self.input.ask_for_text_input("Workspace id")
|
|
35
|
+
|
|
36
|
+
self.out.main_message("Test Execution")
|
|
37
|
+
|
|
38
|
+
# Display available tests in a table
|
|
39
|
+
test_execution_columns = [TableColumn("ID", ThemeColors.CYAN),
|
|
40
|
+
TableColumn("Name", ThemeColors.GREEN),
|
|
41
|
+
TableColumn("Version", ThemeColors.RED),
|
|
42
|
+
TableColumn("Description", ThemeColors.YELLOW)]
|
|
43
|
+
tests_table = CliTable("Available Tests", test_execution_columns)
|
|
44
|
+
available_tests, params = self.out.run_sync_function("Getting Tests...", get_tests, token=web_token, workspace_id=workspace, domain=config.domain)
|
|
45
|
+
|
|
46
|
+
selected_test = None
|
|
47
|
+
|
|
48
|
+
# If test ID is provided directly, use it
|
|
49
|
+
if test_id:
|
|
50
|
+
selected_test = next((test for test in available_tests if test["test_id"] == test_id), None)
|
|
51
|
+
if not selected_test:
|
|
52
|
+
raise StopCommand(f"Error: Test with ID '{test_id}' not found")
|
|
53
|
+
|
|
54
|
+
self.input.ask_for_confirmation(f"Execute test '{selected_test['test_name']}' ({test_id})?")
|
|
55
|
+
|
|
56
|
+
# If no test ID provided, use interactive selection
|
|
57
|
+
else:
|
|
58
|
+
tests_table.add_data(available_tests,
|
|
59
|
+
headers_mapping={"ID": "test_id", "Name": "test_name", "Version": "test_version", "Description": "description"})
|
|
60
|
+
tests_table.print_table()
|
|
61
|
+
# Create choices for the test selection
|
|
62
|
+
choices = [f"{test['test_id']} - {test['test_name']}, {test['test_version']}" for test in available_tests]
|
|
63
|
+
selected = self.input.ask_for_input_from_list("Select a test to execute", choices)
|
|
64
|
+
|
|
65
|
+
# Get the selected test ID
|
|
66
|
+
selected_test_id = selected.split(' - ')[0]
|
|
67
|
+
selected_test = next((test for test in available_tests if test["test_id"] == selected_test_id), None)
|
|
68
|
+
|
|
69
|
+
if not selected_test:
|
|
70
|
+
raise StopCommand("Error: Could not find the selected test")
|
|
71
|
+
|
|
72
|
+
if selected_test.get('iot_device_required'):
|
|
73
|
+
if not agent_id:
|
|
74
|
+
self.out.vprint("agent id wasn't provided, asking user")
|
|
75
|
+
devices_column = [TableColumn("Name", ThemeColors.CYAN),
|
|
76
|
+
TableColumn("ID"),
|
|
77
|
+
TableColumn("Version"),
|
|
78
|
+
TableColumn("Status")]
|
|
79
|
+
available_devices = self.out.run_sync_function("Getting devices...", get_devices, token=web_token, workspace_id=workspace, domain=config.domain)
|
|
80
|
+
devices_by_name = {}
|
|
81
|
+
for device in available_devices:
|
|
82
|
+
format_device_state(device)
|
|
83
|
+
devices_by_name[device['device_name']] = device
|
|
84
|
+
devices_table = CliTable("Available Devices", devices_column)
|
|
85
|
+
devices_table.add_data(available_devices,
|
|
86
|
+
headers_mapping={"ID": "device_id", "Name": "device_name", "Version": "iot_client_version", "Status": "state"})
|
|
87
|
+
devices_table.print_table()
|
|
88
|
+
agent_name = self.input.ask_for_input_from_list("Please select an agent id", devices_by_name.keys())
|
|
89
|
+
agent_id = devices_by_name.get(agent_name)
|
|
90
|
+
if not agent_id:
|
|
91
|
+
raise StopCommand("Agent was not provided, please ")
|
|
92
|
+
|
|
93
|
+
self.out.main_message("Configure flow parameters")
|
|
94
|
+
self.out.vprint(f"test flow param config:\n{params[selected_test_id].get('properties', {})}")
|
|
95
|
+
if parameters_file:
|
|
96
|
+
self.out.vprint(f"Loading parameters file: {parameters_file}")
|
|
97
|
+
params_from_config = get_params(parameters_file)
|
|
98
|
+
|
|
99
|
+
for param_name, param in params[selected_test_id].get('properties', {}).items():
|
|
100
|
+
if param.get('type') in list(InputTypes.__members__):
|
|
101
|
+
input_value = self.input.ask_for_text_input(param_name, default=param.get('default'))
|
|
102
|
+
if params_from_config.get(param_name):
|
|
103
|
+
self.out.vprint(f"Got param: {param_name} from file, value: {params_from_config[param_name]}")
|
|
104
|
+
param['default'] = params_from_config[param_name]
|
|
105
|
+
else:
|
|
106
|
+
self.out.vprint(f"Got param: {param_name} user: { InputTypes[param['type']].value(input_value)}")
|
|
107
|
+
param['default'] = InputTypes[param['type']].value(input_value)
|
|
108
|
+
if not params[selected_test_id]:
|
|
109
|
+
self.out.warning("Flow variables weren't found")
|
|
110
|
+
# Execute the test (in a real app, this would trigger an actual test)
|
|
111
|
+
web_token = is_logged_in(profile_name)
|
|
112
|
+
execution_url = self.out.run_sync_function(f"Executing: {selected_test['test_name']}...",
|
|
113
|
+
function=trigger_test_execution, domain=config.domain, token=web_token, workspace=workspace,
|
|
114
|
+
project_id=project, agent_id=agent_id, test_id=selected_test_id, params=params[selected_test_id])
|
|
115
|
+
|
|
116
|
+
self.out.ok(f"Successfully executed test: {CliOutput.bold(selected_test['test_name'])}")
|
|
117
|
+
self.out.print(f"[dim]Results will be available in the dashboard: {execution_url}[/dim]")
|
|
118
|
+
|
|
119
|
+
def write_profile_to_config(self, ctx, profile_name=None, client_id=None, client_secret=None, token_endpoint=None,
|
|
120
|
+
scope=None, project=None, workspace=None, agent_id=None, domain=None, default=None):
|
|
121
|
+
self.out.setup_context(ctx)
|
|
122
|
+
self.out.main_message("New Profile")
|
|
123
|
+
if not profile_name:
|
|
124
|
+
self.out.vprint("profile name wasn't provided, asking user")
|
|
125
|
+
profile_name = self.input.ask_for_text_input("Profile name")
|
|
126
|
+
if not client_id:
|
|
127
|
+
self.out.vprint("client_id wasn't provided, asking user")
|
|
128
|
+
client_id = self.input.ask_for_text_input("Client id")
|
|
129
|
+
if not client_secret:
|
|
130
|
+
self.out.vprint("client_secret wasn't provided, asking user")
|
|
131
|
+
client_secret = self.input.ask_for_password_input("Client secret")
|
|
132
|
+
if not token_endpoint:
|
|
133
|
+
self.out.vprint("token endpoint wasn't provided, asking user")
|
|
134
|
+
token_endpoint = self.input.ask_for_password_input("Token endpoint")
|
|
135
|
+
if not scope:
|
|
136
|
+
self.out.vprint("scope wasn't provided, asking user")
|
|
137
|
+
scope = self.input.ask_for_password_input("Scope")
|
|
138
|
+
if not domain:
|
|
139
|
+
self.out.vprint("domain wasn't provided, asking user")
|
|
140
|
+
domain = self.input.ask_for_text_input("Clarity domain")
|
|
141
|
+
if not project:
|
|
142
|
+
self.out.vprint("project wasn't provided, asking user")
|
|
143
|
+
project = self.input.ask_for_text_input("Project id", optional=True)
|
|
144
|
+
if not workspace:
|
|
145
|
+
self.out.vprint("workspace provided, asking user")
|
|
146
|
+
workspace = self.input.ask_for_text_input("Workspace id", optional=True)
|
|
147
|
+
if not agent_id:
|
|
148
|
+
self.out.vprint("agent_id wasn't provided, asking user")
|
|
149
|
+
agent_id = self.input.ask_for_text_input("Agent id", optional=True)
|
|
150
|
+
if default is None:
|
|
151
|
+
default = self.input.ask_for_confirmation("Set this profile as default?", default=False)
|
|
152
|
+
if profile_name and client_id and client_secret and token_endpoint and scope and domain:
|
|
153
|
+
columns = [TableColumn("Property", ThemeColors.CYAN), TableColumn("Value", ThemeColors.GREEN)]
|
|
154
|
+
table = CliTable("New Profile", columns)
|
|
155
|
+
table.add_data([("Profile name", profile_name), ("Client id", client_id), ("Client secret", client_secret), ("Clarity domain", domain),
|
|
156
|
+
("Default Project id", project), ("Default workspace id", workspace), ("Default agent id", agent_id), ("Is default", str(default))])
|
|
157
|
+
table.print_table()
|
|
158
|
+
self.input.ask_for_confirmation("Setup the new profile?", default=True, hard_stop=True)
|
|
159
|
+
else:
|
|
160
|
+
raise StopCommand("Missing property")
|
|
161
|
+
|
|
162
|
+
config = ProfileConfig(client_id=client_id, client_secret=client_secret, domain=domain, token_endpoint=token_endpoint, scope=scope,
|
|
163
|
+
project=project, workspace=workspace, agent_id=agent_id)
|
|
164
|
+
self.out.run_sync_function(f"Configuring new profile {profile_name}...",
|
|
165
|
+
write_new_profile_to_config, profile_name=profile_name, default=default, config=config)
|
|
166
|
+
self.out.ok(f"New profile {CliOutput.bold(profile_name)} successfully configured")
|
|
167
|
+
self.out.vprint(f"All profiles can be found at: {CONFIG_FILE}")
|
|
168
|
+
|
|
169
|
+
def login_using_config_file(self, ctx, profile=None, override_config_path=None):
|
|
170
|
+
self.out.setup_context(ctx)
|
|
171
|
+
config, profile = self._get_current_profile_and_config(profile, override_config_path)
|
|
172
|
+
self.out.run_sync_function("Logging in", get_clarity_access_token, profile=profile, client_id=config.client_id,
|
|
173
|
+
client_secret=config.client_secret, token_endpoint=config.token_endpoint, scope=config.scope)
|
|
174
|
+
self.out.ok("Successfully logged in!")
|
clarity_cli/defs.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Define the base directory for storing configuration
|
|
9
|
+
CONFIG_DIR = Path.home() / '.clarity'
|
|
10
|
+
CONFIG_FILE = CONFIG_DIR / 'config.json'
|
|
11
|
+
TOKEN_FILE = CONFIG_DIR / 'token.json'
|
|
12
|
+
|
|
13
|
+
EXECUTION_VIEW_PATH = 'testing/executions/view?executionId={execution_id}&projectId={project_id}'
|
|
14
|
+
DEVICES_LIST = 'inv-api/devices/status'
|
|
15
|
+
TEST_LIST = 'inv-api/testing/catalog'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ThemeColors(Enum):
|
|
19
|
+
GREEN = "green"
|
|
20
|
+
YELLOW = "yellow"
|
|
21
|
+
BLUE = "blue"
|
|
22
|
+
CYAN = "cyan"
|
|
23
|
+
WHITE = "white"
|
|
24
|
+
RED = "red"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class TableColumn:
|
|
29
|
+
name: str
|
|
30
|
+
color: Optional[ThemeColors] = ThemeColors.WHITE
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ProfileConfig(BaseModel):
|
|
34
|
+
client_id: str
|
|
35
|
+
client_secret: str
|
|
36
|
+
token_endpoint: str
|
|
37
|
+
scope: str
|
|
38
|
+
project: Optional[str] = ""
|
|
39
|
+
workspace: Optional[str] = ""
|
|
40
|
+
agent: Optional[str] = ""
|
|
41
|
+
domain: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class InputTypes(Enum):
|
|
45
|
+
integer = int
|
|
46
|
+
string = str
|
|
47
|
+
boolean = bool
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from clarity_cli.outputs import CliOutput
|
|
2
|
+
|
|
3
|
+
out = CliOutput()
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class StopCommand(Exception):
|
|
7
|
+
def __init__(self, *args):
|
|
8
|
+
out.error(*args)
|
|
9
|
+
exit(0)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UnAuthenticated(Exception):
|
|
13
|
+
def __init__(self, *args):
|
|
14
|
+
raise StopCommand(f"You need to login first. Run {CliOutput.bold('clarity login')}")
|
clarity_cli/helpers.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import jwt
|
|
3
|
+
import datetime
|
|
4
|
+
import base64
|
|
5
|
+
import requests
|
|
6
|
+
from clarity_cli.outputs import CliOutput
|
|
7
|
+
from clarity_cli.defs import CONFIG_DIR, TOKEN_FILE, CONFIG_FILE, ProfileConfig, EXECUTION_VIEW_PATH, TEST_LIST, DEVICES_LIST
|
|
8
|
+
from clarity_cli.exceptions import StopCommand, UnAuthenticated
|
|
9
|
+
out = CliOutput()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def ensure_config_dir():
|
|
13
|
+
"""Ensure the config directory exists"""
|
|
14
|
+
if not CONFIG_DIR.exists():
|
|
15
|
+
CONFIG_DIR.mkdir(parents=True)
|
|
16
|
+
out.ok("Created configuration directory at ~/.clarity")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_logged_in(profile):
|
|
20
|
+
"""Check if the user is logged in by verifying the token"""
|
|
21
|
+
if not TOKEN_FILE.exists():
|
|
22
|
+
raise UnAuthenticated()
|
|
23
|
+
out.vprint("Token file found")
|
|
24
|
+
try:
|
|
25
|
+
with open(TOKEN_FILE, 'r') as f:
|
|
26
|
+
token_data = json.load(f)
|
|
27
|
+
|
|
28
|
+
# Verify token hasn't expired
|
|
29
|
+
token = token_data.get(profile)
|
|
30
|
+
if not token:
|
|
31
|
+
raise UnAuthenticated()
|
|
32
|
+
out.vprint("Token was loaded successfully")
|
|
33
|
+
# Decode the token without verification to check expiration
|
|
34
|
+
decoded = jwt.decode(token, options={"verify_signature": False})
|
|
35
|
+
out.vprint("Token was parsed successfully")
|
|
36
|
+
exp_timestamp = decoded.get('exp', datetime.datetime.now(datetime.timezone.utc))
|
|
37
|
+
|
|
38
|
+
# Check if token has expired
|
|
39
|
+
if datetime.datetime.fromtimestamp(exp_timestamp, tz=datetime.timezone.utc) < datetime.datetime.now(datetime.timezone.utc):
|
|
40
|
+
out.warning(
|
|
41
|
+
"Your session has expired. Please login again.")
|
|
42
|
+
raise UnAuthenticated()
|
|
43
|
+
|
|
44
|
+
return token
|
|
45
|
+
|
|
46
|
+
except Exception as e:
|
|
47
|
+
out.error(f"Error checking login status: {str(e)}")
|
|
48
|
+
raise UnAuthenticated()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def format_device_state(device):
|
|
52
|
+
if device['state'] == 'connected':
|
|
53
|
+
device['state'] = f"[green]{device['state']}[/green]"
|
|
54
|
+
if device['state'] == 'disconnected':
|
|
55
|
+
device['state'] = f"[red]{device['state']}[/red]"
|
|
56
|
+
else:
|
|
57
|
+
device['state'] = f"[yellow]{device['state']}[/yellow]"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_devices(token, workspace_id, domain):
|
|
61
|
+
headers = {"Authorization": f"Bearer {token}", "workspace": workspace_id}
|
|
62
|
+
url = f"{domain}/{DEVICES_LIST}"
|
|
63
|
+
response = requests.get(url, headers=headers)
|
|
64
|
+
if response.status_code == 200:
|
|
65
|
+
res = response.json()
|
|
66
|
+
devices = res['device_list']
|
|
67
|
+
return devices
|
|
68
|
+
else:
|
|
69
|
+
out.vprint("Error getting devices list")
|
|
70
|
+
out.vprint(response.text)
|
|
71
|
+
raise StopCommand("Couldn't get devices list")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_tests(token, workspace_id, domain):
|
|
75
|
+
headers = {"Authorization": f"Bearer {token}", "workspace": workspace_id}
|
|
76
|
+
url = f"{domain}/{TEST_LIST}"
|
|
77
|
+
response = requests.get(url, headers=headers)
|
|
78
|
+
# Check if the request was successful
|
|
79
|
+
param_res = {}
|
|
80
|
+
if response.status_code == 200:
|
|
81
|
+
res = response.json()
|
|
82
|
+
for test in res:
|
|
83
|
+
param_res[test['test_id']] = test['params_schema']
|
|
84
|
+
return res, param_res
|
|
85
|
+
else:
|
|
86
|
+
out.vprint("Error getting tests list")
|
|
87
|
+
out.vprint(response.text)
|
|
88
|
+
raise StopCommand("Couldn't get Test list")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def trigger_test_execution(domain, token, workspace, project_id, agent_id, test_id, params):
|
|
92
|
+
headers = {"Authorization": f"Bearer {token}", "workspace": workspace}
|
|
93
|
+
url = f"{domain}/inv-api/testing/executions/handler"
|
|
94
|
+
data = {
|
|
95
|
+
"project_id": project_id,
|
|
96
|
+
"agent_id": agent_id,
|
|
97
|
+
"test_plan_config": [{
|
|
98
|
+
"params_config": {},
|
|
99
|
+
"test_id": test_id,
|
|
100
|
+
"test_config": {'variables_config': params}
|
|
101
|
+
}
|
|
102
|
+
],
|
|
103
|
+
}
|
|
104
|
+
response = requests.post(url, headers=headers, json=data)
|
|
105
|
+
# Check if the request was successful
|
|
106
|
+
if response.status_code == 202:
|
|
107
|
+
res = response.json()
|
|
108
|
+
out.ok("Execution started")
|
|
109
|
+
out.vprint(f"execution id: {res['execution_id']}, project id: {res['project_id']}")
|
|
110
|
+
# return URL to track the execution
|
|
111
|
+
return f"{domain}/{EXECUTION_VIEW_PATH.format(execution_id=res['execution_id'], project_id=res['project_id'])}"
|
|
112
|
+
else:
|
|
113
|
+
out.vprint("Status code:", response.status_code)
|
|
114
|
+
raise StopCommand(f"Failed to start execution, {response.text}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_clarity_access_token(profile, client_id, client_secret, token_endpoint, scope):
|
|
118
|
+
# The client credentials grant requires HTTP Basic Authentication,
|
|
119
|
+
# where the username is the client ID and the password is the client secret
|
|
120
|
+
try:
|
|
121
|
+
client_auth = base64.b64encode(f"{client_id}:{client_secret}".encode("utf-8")).decode("utf-8")
|
|
122
|
+
headers = {
|
|
123
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
124
|
+
"Authorization": f"Basic {client_auth}",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# The grant_type must be client_credentials
|
|
128
|
+
data = {
|
|
129
|
+
"grant_type": "client_credentials",
|
|
130
|
+
"scope": scope,
|
|
131
|
+
}
|
|
132
|
+
response = requests.post(token_endpoint, headers=headers, data=data)
|
|
133
|
+
|
|
134
|
+
if response.status_code != 200:
|
|
135
|
+
out.verbose(response.text)
|
|
136
|
+
raise StopCommand("Failed to get access token")
|
|
137
|
+
|
|
138
|
+
response_data = response.json()
|
|
139
|
+
out.vprint(f"Got token: {response_data['access_token']}")
|
|
140
|
+
write_token(response_data["access_token"], profile)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
out.vprint(f"Login error - {e}")
|
|
143
|
+
raise StopCommand("Unable to login")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def read_config(config_override=None):
|
|
147
|
+
config_to_use = config_override or CONFIG_FILE
|
|
148
|
+
try:
|
|
149
|
+
out.vprint(f"Reading config file: {config_to_use}")
|
|
150
|
+
with open(config_to_use, "r") as f:
|
|
151
|
+
return json.load(f)
|
|
152
|
+
except Exception:
|
|
153
|
+
return {}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def read_token():
|
|
157
|
+
try:
|
|
158
|
+
with open(TOKEN_FILE, "r") as f:
|
|
159
|
+
return json.load(f)
|
|
160
|
+
except Exception:
|
|
161
|
+
return {}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def get_params(params_file):
|
|
165
|
+
try:
|
|
166
|
+
with open(params_file, "r") as f:
|
|
167
|
+
return json.load(f)
|
|
168
|
+
except Exception:
|
|
169
|
+
return {}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def write_new_profile_to_config(profile_name: str, default: bool, config: ProfileConfig):
|
|
173
|
+
try:
|
|
174
|
+
cur_config = read_config()
|
|
175
|
+
new_profile = {
|
|
176
|
+
profile_name: config.model_dump()
|
|
177
|
+
}
|
|
178
|
+
full_config = {**cur_config, **new_profile}
|
|
179
|
+
full_config['default'] = profile_name if default else full_config.get('default', "")
|
|
180
|
+
with open(CONFIG_FILE, "w") as f:
|
|
181
|
+
json.dump(full_config, f)
|
|
182
|
+
except Exception as e:
|
|
183
|
+
StopCommand(f"An error occurred while writing new profile to config file - {e}")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def write_token(token, profile):
|
|
187
|
+
tokens = read_token()
|
|
188
|
+
tokens[profile] = token
|
|
189
|
+
try:
|
|
190
|
+
out.vprint(f"writing token to {TOKEN_FILE}, for profile: {profile}")
|
|
191
|
+
with open(TOKEN_FILE, 'w') as f:
|
|
192
|
+
json.dump(tokens, f)
|
|
193
|
+
except Exception as e:
|
|
194
|
+
StopCommand(f"Unable to write tmp token to: {TOKEN_FILE} - {e}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def get_profile_configuration(profile=None, override_config_path=None):
|
|
198
|
+
current_configuration = read_config(override_config_path)
|
|
199
|
+
if not profile:
|
|
200
|
+
out.warning("Profile was not provided, using default profile")
|
|
201
|
+
profile = current_configuration.get('default')
|
|
202
|
+
if profile:
|
|
203
|
+
out.ok(f"Default profile found, using {profile} profile")
|
|
204
|
+
else:
|
|
205
|
+
raise StopCommand("Default profile wasn't configured, please specify a profile")
|
|
206
|
+
try:
|
|
207
|
+
profile_config = ProfileConfig(**current_configuration.get(profile, {}))
|
|
208
|
+
if not profile_config:
|
|
209
|
+
out.error(f"profile {CliOutput.bold(profile)} was not found")
|
|
210
|
+
out.warning("use "+{CliOutput.bold('\'clarity profile-setup\'')} + "command to set it up")
|
|
211
|
+
except Exception as e:
|
|
212
|
+
# is_valid_config = all([True if key in profile_config else False for key in ['client_id', 'client_secret', 'token_endpoint', 'scope']])
|
|
213
|
+
# if not is_valid_config:
|
|
214
|
+
out.vprint(f"Error parsing config - {e}")
|
|
215
|
+
out.warning("use "+{CliOutput.bold('\'clarity profile-setup\'')} + "command to set it up")
|
|
216
|
+
raise StopCommand("Profile configuration is not valid, please reconfigure or use another profile")
|
|
217
|
+
return profile_config, profile
|
clarity_cli/input.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import inquirer
|
|
2
|
+
from inquirer import themes
|
|
3
|
+
from clarity_cli.exceptions import StopCommand
|
|
4
|
+
from clarity_cli.outputs import CliOutput
|
|
5
|
+
|
|
6
|
+
out = CliOutput()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CliInputs():
|
|
10
|
+
|
|
11
|
+
def ask_for_confirmation(self, confirmation_message, default=True, hard_stop=False):
|
|
12
|
+
questions = [
|
|
13
|
+
inquirer.Confirm('confirm', message=f"{confirmation_message}", default=default)]
|
|
14
|
+
|
|
15
|
+
answers = inquirer.prompt(questions, theme=themes.GreenPassion())
|
|
16
|
+
if not answers:
|
|
17
|
+
out.vprint("Command was canceled")
|
|
18
|
+
raise StopCommand()
|
|
19
|
+
if hard_stop and not answers['confirm']:
|
|
20
|
+
out.vprint("Command was not confirmed")
|
|
21
|
+
raise StopCommand()
|
|
22
|
+
return answers['confirm']
|
|
23
|
+
|
|
24
|
+
def ask_for_input_from_list(self, message, options: list):
|
|
25
|
+
questions = [inquirer.List('list_input', message=message, choices=options)]
|
|
26
|
+
answers = inquirer.prompt(questions, theme=themes.GreenPassion())
|
|
27
|
+
if not answers or not answers['list_input']:
|
|
28
|
+
out.warning("Command was canceled")
|
|
29
|
+
raise StopCommand()
|
|
30
|
+
return answers.get('list_input')
|
|
31
|
+
|
|
32
|
+
def ask_for_text_input(self, message, default="", optional=False):
|
|
33
|
+
final_message = f"{message} [Optional]" if optional else message
|
|
34
|
+
questions = [inquirer.Text('text_input', message=final_message, default=default)]
|
|
35
|
+
answers = inquirer.prompt(questions, theme=themes.Default())
|
|
36
|
+
if not answers or (optional and not answers['text_input']):
|
|
37
|
+
out.warning("Command was canceled")
|
|
38
|
+
raise StopCommand()
|
|
39
|
+
return answers.get('text_input')
|
|
40
|
+
|
|
41
|
+
def ask_for_password_input(self, message):
|
|
42
|
+
questions = [inquirer.Password('pass_input', message=message)]
|
|
43
|
+
answers = inquirer.prompt(questions, theme=themes.Default())
|
|
44
|
+
if not answers or not answers['pass_input']:
|
|
45
|
+
out.warning("Command was canceled")
|
|
46
|
+
raise StopCommand()
|
|
47
|
+
return answers.get('pass_input')
|
clarity_cli/main.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Clarity CLI - Interactive CLI for test management and execution
|
|
4
|
+
|
|
5
|
+
This CLI tool provides functionality for:
|
|
6
|
+
- Login (generates and stores JWT tokens)
|
|
7
|
+
- Executing tests (displays and allows selection from available tests)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
# import json
|
|
11
|
+
# import datetime
|
|
12
|
+
# import uuid
|
|
13
|
+
import click
|
|
14
|
+
# import inquirer
|
|
15
|
+
# from inquirer import themes
|
|
16
|
+
# from rich.panel import Panel
|
|
17
|
+
# from rich.table import Table
|
|
18
|
+
# import jwt
|
|
19
|
+
from clarity_cli.helpers import ensure_config_dir
|
|
20
|
+
from clarity_cli.commands import CliCommands
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
commands = CliCommands()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@click.group()
|
|
27
|
+
@click.option('--verbose', '-v', is_flag=True, help='Verbose mode')
|
|
28
|
+
@click.pass_context
|
|
29
|
+
def cli(ctx, verbose=False):
|
|
30
|
+
"""Clarity CLI - Interactive test management and execution tool"""
|
|
31
|
+
ctx.ensure_object(dict)
|
|
32
|
+
ctx.obj['VERBOSE'] = verbose
|
|
33
|
+
ensure_config_dir()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@cli.command()
|
|
37
|
+
@click.option('--test-id', '-t', help='Specify a test ID to execute directly')
|
|
38
|
+
@click.option('--profile', '-p', help='Specify a name for the new profile')
|
|
39
|
+
@click.option('--override-config-path', '-c', help='Path to config.json, default config stored in ~/.config.json')
|
|
40
|
+
@click.option('--project', '-proj', help='Specify the default project id for clarity operations')
|
|
41
|
+
@click.option('--workspace', '-w', help='Specify the default workspace id for clarity operations')
|
|
42
|
+
@click.option('--agent-id', '-a', help='Specify the default agent id for executions')
|
|
43
|
+
@click.option('--parameters-file', help="Path to a json file that includes all the flow variables values for execution, if flow var doesn't exist using the default value")
|
|
44
|
+
@click.pass_context
|
|
45
|
+
def execute(ctx, test_id=None, profile=None, override_config_path=None, project=None, workspace=None, agent_id=None, parameters_file=None):
|
|
46
|
+
commands.execute(ctx, test_id, profile, override_config_path, project, workspace, agent_id, parameters_file)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@cli.command()
|
|
50
|
+
@click.option('--profile', '-p', help='Specify a name for the new profile')
|
|
51
|
+
@click.option('--client-id', '-id', help='Specify the client ID you got from cyclarity portal')
|
|
52
|
+
@click.option('--client-secret', '-cs', help='Specify the client secret you got from cyclarity portal')
|
|
53
|
+
@click.option('--token-endpoint', '-e', help='Specify the token endpoint you got from cyclarity portal')
|
|
54
|
+
@click.option('--scope', '-s', help='Specify the scope you got from cyclarity portal')
|
|
55
|
+
@click.option('--project', '-proj', help='Specify the default project id for clarity operations')
|
|
56
|
+
@click.option('--workspace', '-w', help='Specify the default workspace id for clarity operations')
|
|
57
|
+
@click.option('--agent_id', '-a', help='Specify the default agent id for executions')
|
|
58
|
+
@click.option('--default', '-d', help='Use this profile as default profile')
|
|
59
|
+
@click.pass_context
|
|
60
|
+
def profile_setup(ctx, profile=None, client_id=None, client_secret=None, token_endpoint=None, scope=None, project=None, workspace=None, agent_id=None, default=None):
|
|
61
|
+
commands.write_profile_to_config(ctx, profile, client_id, client_secret, token_endpoint, scope, project, workspace, agent_id, default)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@cli.command()
|
|
65
|
+
@click.option('--profile', '-p', help='Profile for login, if not provided use default profile')
|
|
66
|
+
@click.option('--override-config-path', '-c', help='Path to config.json, default config stored in ~/.config.json')
|
|
67
|
+
@click.pass_context
|
|
68
|
+
def login(ctx, profile=None, override_config_path=None):
|
|
69
|
+
commands.login_using_config_file(ctx, profile, override_config_path)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == '__main__':
|
|
73
|
+
cli()
|
clarity_cli/outputs.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from rich.console import Console
|
|
2
|
+
from rich.panel import Panel
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
from clarity_cli.defs import TableColumn
|
|
5
|
+
|
|
6
|
+
verbose = True # or False, depending on your use case
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CliTable():
|
|
10
|
+
def __init__(self, table_name, columns: list[TableColumn]):
|
|
11
|
+
self.out = CliOutput()
|
|
12
|
+
self.table = Table(title=table_name)
|
|
13
|
+
self.columns = columns
|
|
14
|
+
for column in columns:
|
|
15
|
+
self.table.add_column(column.name, style=column.color.value)
|
|
16
|
+
|
|
17
|
+
def add_data(self, data: list[dict[str, str]], headers_mapping: dict[str, str]):
|
|
18
|
+
for row in data:
|
|
19
|
+
self.table.add_row(*[row.get(headers_mapping[col.header], "") for col in self.table.columns])
|
|
20
|
+
|
|
21
|
+
def print_table(self):
|
|
22
|
+
self.out.print(self.table)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CliOutput():
|
|
26
|
+
_instance = None
|
|
27
|
+
console: Console = None
|
|
28
|
+
|
|
29
|
+
def __new__(cls, *args, **kwargs):
|
|
30
|
+
if cls._instance is None:
|
|
31
|
+
cls._instance = super(CliOutput, cls).__new__(cls)
|
|
32
|
+
cls._instance.verbose = False # Default value
|
|
33
|
+
cls._instance.console = Console()
|
|
34
|
+
return cls._instance
|
|
35
|
+
|
|
36
|
+
def setup_context(self, ctx):
|
|
37
|
+
self.verbose = ctx.obj.get('VERBOSE')
|
|
38
|
+
|
|
39
|
+
def ok(self, message=None):
|
|
40
|
+
if message:
|
|
41
|
+
self.print(f"[green]{message}[/green]")
|
|
42
|
+
|
|
43
|
+
def error(self, message=None):
|
|
44
|
+
if message:
|
|
45
|
+
self.print(f"[red][bold]{message}[bold][/red]")
|
|
46
|
+
|
|
47
|
+
def warning(self, message=None):
|
|
48
|
+
if message:
|
|
49
|
+
self.print(f"[yellow]{message}[/yellow]")
|
|
50
|
+
|
|
51
|
+
def main_message(self, message):
|
|
52
|
+
self.print(Panel.fit(message, style="blue"))
|
|
53
|
+
|
|
54
|
+
def print(self, message):
|
|
55
|
+
self.console.print(message)
|
|
56
|
+
|
|
57
|
+
def vprint(self, message):
|
|
58
|
+
if self.verbose:
|
|
59
|
+
self.print(f"[dim]{message}[/dim]")
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def bold(message):
|
|
63
|
+
return f"[bold]{message}[/bold]"
|
|
64
|
+
|
|
65
|
+
def run_sync_function(self, message, function, **args):
|
|
66
|
+
with self.console.status(f"[bold green]{message}\n"):
|
|
67
|
+
return function(**args)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: clarity-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Interactive CLI for test management and execution
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Clarity Team
|
|
7
|
+
Requires-Python: >=3.10,<4.0
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Requires-Dist: click (>=8.1.3,<9.0.0)
|
|
15
|
+
Requires-Dist: inquirer (>=3.1.3,<4.0.0)
|
|
16
|
+
Requires-Dist: pydantic (>=2.11.4,<3.0.0)
|
|
17
|
+
Requires-Dist: pyjwt (>=2.6.0,<3.0.0)
|
|
18
|
+
Requires-Dist: requests (>=2.32.3,<3.0.0)
|
|
19
|
+
Requires-Dist: rich (>=13.3.5,<14.0.0)
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
clarity_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
clarity_cli/commands.py,sha256=SF_S2LqL_NO4kTlpzgbR7PmPGSlVDN78Su9zLJk0xkk,10824
|
|
3
|
+
clarity_cli/defs.py,sha256=qtZqH5ZaY1uLBw9Ok3JHPVgTH--rwicpGGfzdOeU7Xg,1038
|
|
4
|
+
clarity_cli/exceptions.py,sha256=WCZDMf3WsvC0bpyEQJ--ag1nQbB7HeqR0RY90oKN_jM,325
|
|
5
|
+
clarity_cli/helpers.py,sha256=L_B_4ckXnyrGEE7bXd3atkD9jWXcZ8yJbXZtzbLAEEI,8101
|
|
6
|
+
clarity_cli/input.py,sha256=k_gknkG9YjiF20oV86Nt6_7Pw1_82rrLXLZIvdSbHQk,1974
|
|
7
|
+
clarity_cli/main.py,sha256=x_hsp3Y__kNY-MsjH4diD-4IItEJeRS5Rr4S4zdSr6c,3448
|
|
8
|
+
clarity_cli/outputs.py,sha256=le52pwNQI1bW3e9X7M6iN8GleKEMdRlGj7EqMNsUKs0,2023
|
|
9
|
+
clarity_cli-0.1.0.dist-info/METADATA,sha256=6ue_CAF6R3SRGjD_uUdyl5o692ARdGsxmAXIzl6NgpY,762
|
|
10
|
+
clarity_cli-0.1.0.dist-info/WHEEL,sha256=fGIA9gx4Qxk2KDKeNJCbOEwSrmLtjWCwzBz351GyrPQ,88
|
|
11
|
+
clarity_cli-0.1.0.dist-info/entry_points.txt,sha256=aDG82xIqh9xMP0O9nRcQan9ifvufwFZFNbNVJA7PlVU,48
|
|
12
|
+
clarity_cli-0.1.0.dist-info/RECORD,,
|