lovelytics-cli 0.4.3__tar.gz → 0.4.8__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.
- {lovelytics_cli-0.4.3 → lovelytics_cli-0.4.8}/PKG-INFO +1 -1
- lovelytics_cli-0.4.8/love/auth.py +155 -0
- {lovelytics_cli-0.4.3 → lovelytics_cli-0.4.8}/love/cli.py +91 -14
- {lovelytics_cli-0.4.3 → lovelytics_cli-0.4.8}/love/config.py +11 -0
- lovelytics_cli-0.4.8/love/crate.py +163 -0
- lovelytics_cli-0.4.8/love/install.py +142 -0
- {lovelytics_cli-0.4.3 → lovelytics_cli-0.4.8}/pyproject.toml +4 -1
- lovelytics_cli-0.4.3/love/auth.py +0 -169
- {lovelytics_cli-0.4.3 → lovelytics_cli-0.4.8}/LICENSE +0 -0
- {lovelytics_cli-0.4.3 → lovelytics_cli-0.4.8}/README.md +0 -0
- {lovelytics_cli-0.4.3 → lovelytics_cli-0.4.8}/love/__main__.py +0 -0
- {lovelytics_cli-0.4.3 → lovelytics_cli-0.4.8}/love/forecast.py +0 -0
- {lovelytics_cli-0.4.3 → lovelytics_cli-0.4.8}/love/linguo.py +0 -0
- {lovelytics_cli-0.4.3 → lovelytics_cli-0.4.8}/love/utils.py +0 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Code to handle retrieving and storing authentication."""
|
|
2
|
+
import requests
|
|
3
|
+
import json
|
|
4
|
+
from love.config import HEIMDALL_URL, AUTH_FILE_PATH, CONFIG_PATH
|
|
5
|
+
import time
|
|
6
|
+
import os
|
|
7
|
+
import webbrowser
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthHandler:
|
|
11
|
+
"""Handles authentication for the application."""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
"""Initialize the AuthHandler."""
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def _login_with_pat(user, token, org=None):
|
|
19
|
+
payload = {"username": user, "password": token}
|
|
20
|
+
if org:
|
|
21
|
+
payload["orgId"] = org
|
|
22
|
+
|
|
23
|
+
res = requests.post(
|
|
24
|
+
f"{HEIMDALL_URL}/api/users/login-with-pat",
|
|
25
|
+
data=payload,
|
|
26
|
+
)
|
|
27
|
+
res.raise_for_status()
|
|
28
|
+
data = res.json()
|
|
29
|
+
|
|
30
|
+
auth_data = {"email": data["data"]["email"], "token": data["token"]}
|
|
31
|
+
if org:
|
|
32
|
+
auth_data["orgId"] = org
|
|
33
|
+
return auth_data
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def _login_with_org_token(org, token):
|
|
37
|
+
res = requests.post(
|
|
38
|
+
f"{HEIMDALL_URL}/api/organization/login-with-token",
|
|
39
|
+
data={"orgId": org, "token": token},
|
|
40
|
+
)
|
|
41
|
+
res.raise_for_status()
|
|
42
|
+
data = res.json()
|
|
43
|
+
return {"orgId": org, "token": data["token"]}
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def _login_with_oidc():
|
|
47
|
+
client_id = "love-cli"
|
|
48
|
+
res = requests.post(
|
|
49
|
+
f"{HEIMDALL_URL}/oidc/device/auth",
|
|
50
|
+
data={"client_id": client_id, "scope": "openid profile"},
|
|
51
|
+
)
|
|
52
|
+
res.raise_for_status()
|
|
53
|
+
data = res.json()
|
|
54
|
+
print(
|
|
55
|
+
f"Please visit {data['verification_uri_complete']} and enter code {data['user_code']} to login to Lovelytics Labs."
|
|
56
|
+
)
|
|
57
|
+
print("Opening browser...")
|
|
58
|
+
webbrowser.open(data["verification_uri_complete"])
|
|
59
|
+
|
|
60
|
+
authenticated = False
|
|
61
|
+
access_token = None
|
|
62
|
+
while not authenticated:
|
|
63
|
+
token_res = requests.post(
|
|
64
|
+
f"{HEIMDALL_URL}/oidc/token",
|
|
65
|
+
data={
|
|
66
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
67
|
+
"device_code": data["device_code"],
|
|
68
|
+
"client_id": client_id,
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
token_data = token_res.json()
|
|
72
|
+
if token_res.status_code == 200:
|
|
73
|
+
authenticated = True
|
|
74
|
+
access_token = token_data["access_token"]
|
|
75
|
+
elif token_data["error"] not in ("authorization_pending", "slow_down"):
|
|
76
|
+
print(token_data["error_description"])
|
|
77
|
+
raise Exception(token_data["error_description"])
|
|
78
|
+
else:
|
|
79
|
+
time.sleep(5)
|
|
80
|
+
|
|
81
|
+
exchange_res = requests.post(
|
|
82
|
+
f"{HEIMDALL_URL}/api/users/login-with-oidc",
|
|
83
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
84
|
+
)
|
|
85
|
+
exchange_res.raise_for_status()
|
|
86
|
+
exchange_data = exchange_res.json()
|
|
87
|
+
return {
|
|
88
|
+
"email": exchange_data["data"]["email"],
|
|
89
|
+
"token": exchange_data["token"],
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def login(user=None, token=None, org=None):
|
|
94
|
+
"""
|
|
95
|
+
Handle logging in and storing authentication.
|
|
96
|
+
|
|
97
|
+
Goes through OIDC device flow to retreive login info.
|
|
98
|
+
"""
|
|
99
|
+
if user and token:
|
|
100
|
+
auth_data = AuthHandler._login_with_pat(user, token, org)
|
|
101
|
+
elif org and token:
|
|
102
|
+
auth_data = AuthHandler._login_with_org_token(org, token)
|
|
103
|
+
else:
|
|
104
|
+
auth_data = AuthHandler._login_with_oidc()
|
|
105
|
+
|
|
106
|
+
os.makedirs(CONFIG_PATH, exist_ok=True)
|
|
107
|
+
with open(AUTH_FILE_PATH, "w") as f:
|
|
108
|
+
json.dump(auth_data, f)
|
|
109
|
+
print("Login successful.")
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def get_auth():
|
|
113
|
+
"""
|
|
114
|
+
Get existing authentication.
|
|
115
|
+
|
|
116
|
+
Handle retrieving existing authentication from the auth file
|
|
117
|
+
or from an exported token.
|
|
118
|
+
"""
|
|
119
|
+
if os.getenv("LOVELYTICS_USERNAME") and os.getenv("LOVELYTICS_TOKEN"):
|
|
120
|
+
return AuthHandler._login_with_pat(
|
|
121
|
+
os.environ["LOVELYTICS_USERNAME"],
|
|
122
|
+
os.environ["LOVELYTICS_TOKEN"],
|
|
123
|
+
)
|
|
124
|
+
elif os.getenv("LOVELYTICS_ORG") and os.getenv("LOVELYTICS_TOKEN"):
|
|
125
|
+
return AuthHandler._login_with_org_token(
|
|
126
|
+
os.environ["LOVELYTICS_ORG"],
|
|
127
|
+
os.environ["LOVELYTICS_TOKEN"],
|
|
128
|
+
)
|
|
129
|
+
else:
|
|
130
|
+
try:
|
|
131
|
+
with open(AUTH_FILE_PATH) as f:
|
|
132
|
+
return json.load(f)
|
|
133
|
+
except FileNotFoundError:
|
|
134
|
+
raise Exception(
|
|
135
|
+
"Not logged in. Please run 'love login' to log in to your account."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def get_me():
|
|
140
|
+
"""Get current user info."""
|
|
141
|
+
token = AuthHandler.get_auth()["token"]
|
|
142
|
+
res = requests.post(
|
|
143
|
+
f"{HEIMDALL_URL}/api/users/authenticated/me",
|
|
144
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
145
|
+
)
|
|
146
|
+
res.raise_for_status()
|
|
147
|
+
return res.json()
|
|
148
|
+
|
|
149
|
+
def store_auth(self):
|
|
150
|
+
"""Store the authentication for the application."""
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
def refresh_auth(self):
|
|
154
|
+
"""Refresh the authentication for the application."""
|
|
155
|
+
pass
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
"""Definition of the CLI."""
|
|
2
|
-
import subprocess # nosec
|
|
3
2
|
import click
|
|
4
3
|
from love import auth
|
|
5
4
|
from love import linguo as linguo_commands
|
|
6
5
|
from love import forecast as forecast_commands
|
|
6
|
+
from love import crate as crate_commands
|
|
7
|
+
from love import install as install_commands
|
|
7
8
|
import json
|
|
8
9
|
import logging
|
|
9
10
|
import click_log
|
|
10
11
|
import os
|
|
11
|
-
import urllib3
|
|
12
12
|
from love.config import (
|
|
13
13
|
AIRFLOW_HOME,
|
|
14
14
|
SPARK_HOME,
|
|
15
15
|
CONFIG_OPTIONS,
|
|
16
16
|
CONFIG_PATH,
|
|
17
17
|
CONFIG_FILE_PATH,
|
|
18
|
-
|
|
18
|
+
CRATE_CACHE_DIR,
|
|
19
19
|
)
|
|
20
20
|
|
|
21
21
|
logger = logging.getLogger(__name__)
|
|
@@ -121,17 +121,94 @@ def install():
|
|
|
121
121
|
@click.argument("package")
|
|
122
122
|
def python_package(package):
|
|
123
123
|
"""Install a package from our private repository."""
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
)
|
|
124
|
+
install_commands.install_python_package(package)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@install.command(name="cli-tool")
|
|
128
|
+
@click.argument("name")
|
|
129
|
+
@click.option(
|
|
130
|
+
"--force", is_flag=True, default=False, help="Overwrite existing installation."
|
|
131
|
+
)
|
|
132
|
+
def cli_tool(name, force):
|
|
133
|
+
"""Install a CLI tool binary from CodeArtifact via the Lando proxy."""
|
|
134
|
+
install_commands.install_cli_tool(name, force)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# We use the name solution to reference to crates as it is more user-firendly
|
|
138
|
+
@install.command(name="solution")
|
|
139
|
+
@click.argument("crate_name")
|
|
140
|
+
@click.option(
|
|
141
|
+
"--params-file",
|
|
142
|
+
"-p",
|
|
143
|
+
default=None,
|
|
144
|
+
help="Path to a YAML or JSON file containing parameters for the solution installation.",
|
|
145
|
+
)
|
|
146
|
+
@click.option(
|
|
147
|
+
"--download",
|
|
148
|
+
"-d",
|
|
149
|
+
is_flag=True,
|
|
150
|
+
default=False,
|
|
151
|
+
help="Force re-download of the solution even if a cached version exists.",
|
|
152
|
+
)
|
|
153
|
+
def install_crate(crate_name, params_file, download):
|
|
154
|
+
"""Install a solution from CodeArtifact.
|
|
155
|
+
|
|
156
|
+
Downloads and installs the specified solution from the Lovelytics CodeArtifact
|
|
157
|
+
repository via Lando. Parameters can be provided via a config file (--params-file) and/or
|
|
158
|
+
environment variables prefixed with CRATE_ (e.g., CRATE_CATALOG_NAME). Environment
|
|
159
|
+
variables take precedence over values in the config file.
|
|
160
|
+
|
|
161
|
+
Downloaded solution archives are cached under ~/.lovelytics/crates/. Use --download
|
|
162
|
+
to force a fresh download even when a cached archive exists.
|
|
163
|
+
"""
|
|
164
|
+
import subprocess # nosec
|
|
165
|
+
import tempfile
|
|
166
|
+
|
|
167
|
+
# Load parameters from file and CRATE_ env vars
|
|
168
|
+
click.echo("Loading parameters...")
|
|
169
|
+
params = crate_commands.load_params(params_file)
|
|
170
|
+
if params:
|
|
171
|
+
click.echo(f"Loaded parameters: {list(params.keys())}")
|
|
172
|
+
|
|
173
|
+
click.echo(f"Installing solution '{crate_name}'...")
|
|
174
|
+
|
|
175
|
+
archive_path = crate_commands.get_cached_archive_path(crate_name)
|
|
176
|
+
|
|
177
|
+
if os.path.exists(archive_path) and not download:
|
|
178
|
+
click.echo(f"Using cached solution archive: {archive_path}")
|
|
179
|
+
else:
|
|
180
|
+
# Authenticate using JWT from love login (only needed for download)
|
|
181
|
+
user = auth.AuthHandler.get_auth()
|
|
182
|
+
jwt_token = user["token"]
|
|
183
|
+
|
|
184
|
+
os.makedirs(CRATE_CACHE_DIR, exist_ok=True)
|
|
185
|
+
click.echo("Downloading solution from CodeArtifact...")
|
|
186
|
+
crate_commands.download_crate(crate_name, CRATE_CACHE_DIR, jwt_token)
|
|
187
|
+
click.echo(f"Downloaded archive: {archive_path}")
|
|
188
|
+
|
|
189
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
190
|
+
# Extract the crate archive
|
|
191
|
+
click.echo("Extracting solution...")
|
|
192
|
+
crate_path = crate_commands.extract_crate(archive_path, tmp_dir)
|
|
193
|
+
click.echo(f"Extracted to: {crate_path}")
|
|
194
|
+
|
|
195
|
+
# Ensure the crate binary is available
|
|
196
|
+
click.echo("Checking for solution binary...")
|
|
197
|
+
crate_bin = crate_commands.ensure_crate_binary()
|
|
198
|
+
click.echo(f"Using crate binary at: {crate_bin}")
|
|
199
|
+
|
|
200
|
+
# Build and run the crate install command
|
|
201
|
+
args = crate_commands.build_crate_install_args(crate_path, params)
|
|
202
|
+
cmd = [crate_bin, "install"] + args
|
|
203
|
+
click.echo(f"Running: {' '.join(cmd)}")
|
|
204
|
+
|
|
205
|
+
result = subprocess.run(cmd) # nosec
|
|
206
|
+
if result.returncode != 0:
|
|
207
|
+
raise click.ClickException(
|
|
208
|
+
f"Solution installation failed with exit code {result.returncode}."
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
click.echo(f"Successfully installed solution '{crate_name}'.")
|
|
135
212
|
|
|
136
213
|
|
|
137
214
|
@cli.command()
|
|
@@ -30,3 +30,14 @@ CONFIG_OPTIONS = {
|
|
|
30
30
|
"default_value": "3.3",
|
|
31
31
|
},
|
|
32
32
|
}
|
|
33
|
+
|
|
34
|
+
# CodeArtifact configuration for crate downloads
|
|
35
|
+
CODEARTIFACT_DOMAIN = "nousot"
|
|
36
|
+
CODEARTIFACT_DOMAIN_OWNER = "316967775432"
|
|
37
|
+
CODEARTIFACT_REPOSITORY = "common"
|
|
38
|
+
CODEARTIFACT_NAMESPACE = "crates"
|
|
39
|
+
|
|
40
|
+
# Crate binary configuration
|
|
41
|
+
CRATE_BINARY_NAME = "crate"
|
|
42
|
+
CRATE_INSTALL_DIR = os.path.join(os.path.expanduser("~"), ".local", "bin")
|
|
43
|
+
CRATE_CACHE_DIR = os.path.join(CONFIG_PATH, "crates")
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Core logic for installing crates from CodeArtifact."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import tarfile
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
import requests
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from love.config import (
|
|
15
|
+
CODEARTIFACT_NAMESPACE,
|
|
16
|
+
CRATE_BINARY_NAME,
|
|
17
|
+
CRATE_CACHE_DIR,
|
|
18
|
+
CRATE_INSTALL_DIR,
|
|
19
|
+
LANDO_URL,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# Crate configuration - only CRATE_ENV_VAR_PREFIX is defined here as it's only used in this module
|
|
25
|
+
CRATE_ENV_VAR_PREFIX = "CRATE_"
|
|
26
|
+
|
|
27
|
+
# Mapping of parameter keys to crate binary CLI flags
|
|
28
|
+
PARAM_FLAG_MAP = {
|
|
29
|
+
"catalog_name": "--catalog-name",
|
|
30
|
+
"sql_warehouse_id": "--sql-warehouse-id",
|
|
31
|
+
"instance_id": "--instance-id",
|
|
32
|
+
"deployed_by": "--deployed-by",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_cached_archive_path(name: str) -> str:
|
|
37
|
+
"""Return the path where a named crate archive is/should be cached."""
|
|
38
|
+
return os.path.join(CRATE_CACHE_DIR, f"{name}.tar.gz")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def download_crate(name: str, dest_dir: str, jwt_token: str) -> str:
|
|
42
|
+
"""Download a crate archive via Lando's registry proxy."""
|
|
43
|
+
dest_path = os.path.join(dest_dir, f"{name}.tar.gz")
|
|
44
|
+
|
|
45
|
+
url = f"{LANDO_URL}/{CODEARTIFACT_NAMESPACE}/{name}"
|
|
46
|
+
try:
|
|
47
|
+
resp = requests.get(
|
|
48
|
+
url,
|
|
49
|
+
auth=("lovelytics", jwt_token),
|
|
50
|
+
stream=True,
|
|
51
|
+
timeout=120,
|
|
52
|
+
)
|
|
53
|
+
resp.raise_for_status()
|
|
54
|
+
except requests.RequestException as e:
|
|
55
|
+
raise click.ClickException(f"Failed to download crate '{name}': {e}")
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
with open(dest_path, "wb") as f:
|
|
59
|
+
for chunk in resp.iter_content(chunk_size=8192):
|
|
60
|
+
f.write(chunk)
|
|
61
|
+
except Exception as e:
|
|
62
|
+
raise click.ClickException(f"Error saving crate archive to {dest_path}: {e}")
|
|
63
|
+
|
|
64
|
+
return dest_path
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def extract_crate(archive_path: str, dest_dir: str) -> str:
|
|
68
|
+
"""Extract a crate tar.gz archive to the destination directory."""
|
|
69
|
+
try:
|
|
70
|
+
with tarfile.open(archive_path, "r:gz") as tar:
|
|
71
|
+
members = tar.getnames()
|
|
72
|
+
if not members:
|
|
73
|
+
raise click.ClickException(f"Archive '{archive_path}' is empty.")
|
|
74
|
+
top_level_dir = members[0].split("/")[0]
|
|
75
|
+
tar.extractall(dest_dir) # nosec
|
|
76
|
+
|
|
77
|
+
return os.path.join(dest_dir, top_level_dir)
|
|
78
|
+
except tarfile.TarError as e:
|
|
79
|
+
raise click.ClickException(f"Failed to extract archive '{archive_path}': {e}")
|
|
80
|
+
except click.ClickException:
|
|
81
|
+
raise
|
|
82
|
+
except Exception as e:
|
|
83
|
+
raise click.ClickException(f"Unexpected error extracting '{archive_path}': {e}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_params(params_file: Optional[str] = None) -> dict:
|
|
87
|
+
"""Load crate parameters from a file and/or environment variables.
|
|
88
|
+
|
|
89
|
+
Parameters from environment variables (prefixed with CRATE_) take
|
|
90
|
+
precedence over values loaded from the file.
|
|
91
|
+
|
|
92
|
+
Raises a ClickException if no parameters are found from either source.
|
|
93
|
+
"""
|
|
94
|
+
params = {}
|
|
95
|
+
|
|
96
|
+
if params_file is not None:
|
|
97
|
+
try:
|
|
98
|
+
with open(params_file, "r") as f:
|
|
99
|
+
content = f.read()
|
|
100
|
+
except (OSError, IOError) as e:
|
|
101
|
+
raise click.ClickException(
|
|
102
|
+
f"Failed to read params file '{params_file}': {e}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
parsed = yaml.safe_load(content)
|
|
107
|
+
except yaml.YAMLError:
|
|
108
|
+
try:
|
|
109
|
+
parsed = json.loads(content)
|
|
110
|
+
except json.JSONDecodeError:
|
|
111
|
+
raise click.ClickException(
|
|
112
|
+
f"Failed to parse params file '{params_file}' as YAML or JSON."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if isinstance(parsed, dict):
|
|
116
|
+
params.update(parsed)
|
|
117
|
+
|
|
118
|
+
for key, value in os.environ.items():
|
|
119
|
+
if key.startswith(CRATE_ENV_VAR_PREFIX):
|
|
120
|
+
param_key = key[len(CRATE_ENV_VAR_PREFIX) :].lower()
|
|
121
|
+
if param_key:
|
|
122
|
+
params[param_key] = value
|
|
123
|
+
|
|
124
|
+
if not params:
|
|
125
|
+
raise click.ClickException(
|
|
126
|
+
"No crate parameters were found. Please provide parameters via a params file "
|
|
127
|
+
"(--params-file) or environment variables prefixed with 'CRATE_'."
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return params
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def ensure_crate_binary() -> str:
|
|
134
|
+
"""Ensure the crate binary is available, raising an error if not found."""
|
|
135
|
+
existing = shutil.which(CRATE_BINARY_NAME)
|
|
136
|
+
if existing:
|
|
137
|
+
return existing
|
|
138
|
+
|
|
139
|
+
local_bin_path = os.path.join(CRATE_INSTALL_DIR, CRATE_BINARY_NAME)
|
|
140
|
+
if os.path.isfile(local_bin_path) and os.access(local_bin_path, os.X_OK):
|
|
141
|
+
return local_bin_path
|
|
142
|
+
|
|
143
|
+
raise click.ClickException(
|
|
144
|
+
f"Crate binary '{CRATE_BINARY_NAME}' not found. "
|
|
145
|
+
f"Please install it and ensure it is available in your PATH or at '{local_bin_path}'."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def build_crate_install_args(source_path: str, params: dict) -> list:
|
|
150
|
+
"""Build the list of CLI arguments for the crate install command."""
|
|
151
|
+
args = ["--path", source_path]
|
|
152
|
+
|
|
153
|
+
for key, value in params.items():
|
|
154
|
+
flag = PARAM_FLAG_MAP.get(key)
|
|
155
|
+
if flag:
|
|
156
|
+
args.extend([flag, str(value)])
|
|
157
|
+
else:
|
|
158
|
+
logger.warning(
|
|
159
|
+
f"Ignoring unrecognized parameter '{key}' "
|
|
160
|
+
f"(no matching crate CLI flag)."
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return args
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Logic for downloading and installing CLI tools and packages."""
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess # nosec
|
|
6
|
+
import tarfile
|
|
7
|
+
import tempfile
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import requests
|
|
11
|
+
import urllib3
|
|
12
|
+
|
|
13
|
+
from love import auth
|
|
14
|
+
from love.config import LANDO_URL
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_platform_asset(name):
|
|
18
|
+
"""Determine the platform-specific asset filename for the given tool name."""
|
|
19
|
+
system = platform.system()
|
|
20
|
+
machine = platform.machine()
|
|
21
|
+
|
|
22
|
+
if system == "Linux" and machine == "x86_64":
|
|
23
|
+
return f"{name}-linux-amd64.tar.gz"
|
|
24
|
+
elif system == "Darwin" and machine == "arm64":
|
|
25
|
+
return f"{name}-darwin-arm64.tar.gz"
|
|
26
|
+
else:
|
|
27
|
+
raise click.ClickException(
|
|
28
|
+
f"Unsupported platform: {system} {machine}. "
|
|
29
|
+
"Supported platforms: Linux x86_64, macOS ARM64."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_platform_info():
|
|
34
|
+
"""Determine the platform-specific os and arch values."""
|
|
35
|
+
system = platform.system()
|
|
36
|
+
machine = platform.machine()
|
|
37
|
+
|
|
38
|
+
if system == "Linux" and machine == "x86_64":
|
|
39
|
+
return ("linux", "amd64")
|
|
40
|
+
elif system == "Darwin" and machine == "arm64":
|
|
41
|
+
return ("darwin", "arm64")
|
|
42
|
+
else:
|
|
43
|
+
raise click.ClickException(
|
|
44
|
+
f"Unsupported platform: {system} {machine}. "
|
|
45
|
+
"Supported platforms: Linux x86_64, macOS ARM64."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_installed_version(install_path):
|
|
50
|
+
"""Run the binary with --version and return the version string, or None."""
|
|
51
|
+
try:
|
|
52
|
+
result = subprocess.run( # nosec
|
|
53
|
+
[install_path, "--version"],
|
|
54
|
+
capture_output=True,
|
|
55
|
+
text=True,
|
|
56
|
+
)
|
|
57
|
+
output = (result.stdout + result.stderr).strip()
|
|
58
|
+
return output if output else None
|
|
59
|
+
except (FileNotFoundError, OSError):
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def install_cli_tool(name, force):
|
|
64
|
+
"""Download and install a CLI tool binary from CodeArtifact via the Lando proxy."""
|
|
65
|
+
os_name, arch = get_platform_info()
|
|
66
|
+
local_bin = os.path.expanduser("~/.local/bin")
|
|
67
|
+
install_path = os.path.join(local_bin, name)
|
|
68
|
+
|
|
69
|
+
if os.path.exists(install_path) and not force:
|
|
70
|
+
version = get_installed_version(install_path)
|
|
71
|
+
click.echo(
|
|
72
|
+
f"{name} is already installed (version: {version}). "
|
|
73
|
+
"Use --force to overwrite."
|
|
74
|
+
)
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
auth_info = auth.AuthHandler.get_auth()
|
|
78
|
+
|
|
79
|
+
click.echo(f"Downloading {name}...")
|
|
80
|
+
download_url = f"{LANDO_URL}/cli/{name}?os={os_name}&arch={arch}"
|
|
81
|
+
res = requests.get( # nosec
|
|
82
|
+
download_url,
|
|
83
|
+
auth=("lovelytics", auth_info["token"]),
|
|
84
|
+
stream=True,
|
|
85
|
+
timeout=300,
|
|
86
|
+
)
|
|
87
|
+
if not res.ok:
|
|
88
|
+
raise click.ClickException(f"Failed to download {name}: HTTP {res.status_code}")
|
|
89
|
+
|
|
90
|
+
tmp_tarball = tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False)
|
|
91
|
+
try:
|
|
92
|
+
for chunk in res.iter_content(chunk_size=8192):
|
|
93
|
+
tmp_tarball.write(chunk)
|
|
94
|
+
tmp_tarball.close()
|
|
95
|
+
|
|
96
|
+
tmp_dir = tempfile.mkdtemp()
|
|
97
|
+
try:
|
|
98
|
+
click.echo(f"Unpacking {name}...")
|
|
99
|
+
with tarfile.open(tmp_tarball.name, "r:gz") as tar:
|
|
100
|
+
members = [m for m in tar.getmembers() if m.isfile()]
|
|
101
|
+
if not members:
|
|
102
|
+
raise click.ClickException(
|
|
103
|
+
f"No files found in the tarball for '{name}'."
|
|
104
|
+
)
|
|
105
|
+
binary_member = members[0]
|
|
106
|
+
|
|
107
|
+
tar.extract(binary_member, path=tmp_dir)
|
|
108
|
+
extracted_path = os.path.join(tmp_dir, binary_member.name)
|
|
109
|
+
|
|
110
|
+
click.echo(f"Installing {name}...")
|
|
111
|
+
os.makedirs(local_bin, exist_ok=True)
|
|
112
|
+
shutil.copy2(extracted_path, install_path)
|
|
113
|
+
os.chmod(install_path, 0o755) # nosec
|
|
114
|
+
finally:
|
|
115
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
116
|
+
finally:
|
|
117
|
+
os.unlink(tmp_tarball.name)
|
|
118
|
+
|
|
119
|
+
if local_bin not in os.environ.get("PATH", ""):
|
|
120
|
+
click.echo(
|
|
121
|
+
"WARNING: ~/.local/bin is not on your PATH. "
|
|
122
|
+
"Add the following to your shell profile:\n"
|
|
123
|
+
' export PATH="$HOME/.local/bin:$PATH"'
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
version = get_installed_version(install_path)
|
|
127
|
+
click.echo(f"{name} {version} installed successfully.")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def install_python_package(package):
|
|
131
|
+
"""Download and install a Python package from the private repository."""
|
|
132
|
+
click.echo(f"Installing {package} from our private repository...")
|
|
133
|
+
user = auth.AuthHandler.get_auth()
|
|
134
|
+
protocol = "https" if LANDO_URL.startswith("https") else "http"
|
|
135
|
+
base_url = LANDO_URL.strip("https://").strip("http://")
|
|
136
|
+
index_url = (
|
|
137
|
+
f"{protocol}://lovelytics:{user['token']}@{base_url}/python" # noqa: E231
|
|
138
|
+
)
|
|
139
|
+
encoded_url = urllib3.util.parse_url(index_url).url
|
|
140
|
+
subprocess.run( # nosec
|
|
141
|
+
["pip", "install", "--extra-index-url", encoded_url, package]
|
|
142
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "lovelytics-cli"
|
|
3
|
-
version = "0.4.
|
|
3
|
+
version = "0.4.8"
|
|
4
4
|
description = "Development tools for Lovelytics Labs."
|
|
5
5
|
authors = ["Lovelytics Labs <labs@lovelytics.com>"]
|
|
6
6
|
readme = "README.md"
|
|
@@ -23,6 +23,9 @@ pre-commit = "^2.20.0"
|
|
|
23
23
|
pytest = "^7.2.0"
|
|
24
24
|
flake8 = "^5.0.4"
|
|
25
25
|
twine = "^6.2.0"
|
|
26
|
+
pydocstyle = "^6.3.0"
|
|
27
|
+
bandit = "^1.9.4"
|
|
28
|
+
pbr = "^7.0.3"
|
|
26
29
|
|
|
27
30
|
[build-system]
|
|
28
31
|
requires = ["poetry-core"]
|
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
"""Code to handle retrieving and storing authentication."""
|
|
2
|
-
import requests
|
|
3
|
-
import json
|
|
4
|
-
from love.config import HEIMDALL_URL, AUTH_FILE_PATH, CONFIG_PATH
|
|
5
|
-
import time
|
|
6
|
-
import os
|
|
7
|
-
import webbrowser
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class AuthHandler:
|
|
11
|
-
"""Handles authentication for the application."""
|
|
12
|
-
|
|
13
|
-
def __init__(self):
|
|
14
|
-
"""Initialize the AuthHandler."""
|
|
15
|
-
pass
|
|
16
|
-
|
|
17
|
-
@staticmethod
|
|
18
|
-
def login(user=None, token=None, org=None):
|
|
19
|
-
"""
|
|
20
|
-
Handle logging in and storing authentication.
|
|
21
|
-
|
|
22
|
-
Goes through OIDC device flow to retreive login info.
|
|
23
|
-
"""
|
|
24
|
-
if user and token:
|
|
25
|
-
payload = {
|
|
26
|
-
"username": user,
|
|
27
|
-
"password": token,
|
|
28
|
-
}
|
|
29
|
-
if org:
|
|
30
|
-
payload["orgId"] = org
|
|
31
|
-
|
|
32
|
-
res = requests.post(
|
|
33
|
-
f"{HEIMDALL_URL}/api/users/login-with-pat",
|
|
34
|
-
data=payload,
|
|
35
|
-
)
|
|
36
|
-
res.raise_for_status()
|
|
37
|
-
data = res.json()
|
|
38
|
-
email = data["data"]["email"]
|
|
39
|
-
jwt = data["token"]
|
|
40
|
-
|
|
41
|
-
else:
|
|
42
|
-
client_id = "love-cli"
|
|
43
|
-
res = requests.post(
|
|
44
|
-
f"{HEIMDALL_URL}/oidc/device/auth",
|
|
45
|
-
data={"client_id": client_id, "scope": "openid profile"},
|
|
46
|
-
)
|
|
47
|
-
res.raise_for_status()
|
|
48
|
-
data = res.json()
|
|
49
|
-
print(
|
|
50
|
-
f"Please visit {data['verification_uri_complete']} and enter code {data['user_code']} to login to Lovelytics Labs."
|
|
51
|
-
)
|
|
52
|
-
print("Opening browser...")
|
|
53
|
-
webbrowser.open(data["verification_uri_complete"])
|
|
54
|
-
|
|
55
|
-
authenticated = False
|
|
56
|
-
access_token = None
|
|
57
|
-
while not authenticated:
|
|
58
|
-
token_res = requests.post(
|
|
59
|
-
f"{HEIMDALL_URL}/oidc/token",
|
|
60
|
-
data={
|
|
61
|
-
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
62
|
-
"device_code": data["device_code"],
|
|
63
|
-
"client_id": client_id,
|
|
64
|
-
},
|
|
65
|
-
)
|
|
66
|
-
token_data = token_res.json()
|
|
67
|
-
if token_res.status_code == 200:
|
|
68
|
-
authenticated = True
|
|
69
|
-
access_token = token_data["access_token"]
|
|
70
|
-
|
|
71
|
-
elif token_data["error"] not in ("authorization_pending", "slow_down"):
|
|
72
|
-
print(token_data["error_description"])
|
|
73
|
-
raise Exception(token_data["error_description"])
|
|
74
|
-
|
|
75
|
-
else:
|
|
76
|
-
time.sleep(5)
|
|
77
|
-
|
|
78
|
-
exchange_res = requests.post(
|
|
79
|
-
f"{HEIMDALL_URL}/api/users/login-with-oidc",
|
|
80
|
-
headers={"Authorization": f"Bearer {access_token}"},
|
|
81
|
-
)
|
|
82
|
-
exchange_res.raise_for_status()
|
|
83
|
-
exchange_data = exchange_res.json()
|
|
84
|
-
email = exchange_data["data"]["email"]
|
|
85
|
-
jwt = exchange_data["token"]
|
|
86
|
-
|
|
87
|
-
os.makedirs(CONFIG_PATH, exist_ok=True)
|
|
88
|
-
with open(AUTH_FILE_PATH, "w") as f:
|
|
89
|
-
json.dump(
|
|
90
|
-
{
|
|
91
|
-
"email": email,
|
|
92
|
-
"token": jwt,
|
|
93
|
-
},
|
|
94
|
-
f,
|
|
95
|
-
)
|
|
96
|
-
print("Login successful.")
|
|
97
|
-
|
|
98
|
-
@staticmethod
|
|
99
|
-
def get_auth():
|
|
100
|
-
"""
|
|
101
|
-
Get existing authentication.
|
|
102
|
-
|
|
103
|
-
Handle retrieving existing authentication from the auth file
|
|
104
|
-
or from an exported token.
|
|
105
|
-
"""
|
|
106
|
-
if os.getenv("LOVELYTICS_USERNAME") and os.getenv("LOVELYTICS_TOKEN"):
|
|
107
|
-
payload = {
|
|
108
|
-
"username": os.environ["LOVELYTICS_USERNAME"],
|
|
109
|
-
"password": os.environ["LOVELYTICS_TOKEN"],
|
|
110
|
-
}
|
|
111
|
-
res = requests.post(
|
|
112
|
-
f"{HEIMDALL_URL}/api/users/login-with-pat",
|
|
113
|
-
data=payload,
|
|
114
|
-
)
|
|
115
|
-
res.raise_for_status()
|
|
116
|
-
data = res.json()
|
|
117
|
-
email = data["data"]["email"]
|
|
118
|
-
jwt = data["token"]
|
|
119
|
-
auth = {
|
|
120
|
-
"email": email,
|
|
121
|
-
"token": jwt,
|
|
122
|
-
}
|
|
123
|
-
elif os.getenv("LOVELYTICS_ORG") and os.getenv("LOVELYTICS_TOKEN"):
|
|
124
|
-
payload = {
|
|
125
|
-
"orgId": os.environ["LOVELYTICS_ORG"],
|
|
126
|
-
"token": os.environ["LOVELYTICS_TOKEN"],
|
|
127
|
-
}
|
|
128
|
-
res = requests.post(
|
|
129
|
-
f"{HEIMDALL_URL}/api/organization/login-with-token",
|
|
130
|
-
data=payload,
|
|
131
|
-
)
|
|
132
|
-
res.raise_for_status()
|
|
133
|
-
data = res.json()
|
|
134
|
-
jwt = data["token"]
|
|
135
|
-
auth = {
|
|
136
|
-
"orgId": os.environ["LOVELYTICS_ORG"],
|
|
137
|
-
"token": jwt,
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
else:
|
|
141
|
-
try:
|
|
142
|
-
with open(AUTH_FILE_PATH) as f:
|
|
143
|
-
auth = json.load(f)
|
|
144
|
-
|
|
145
|
-
except FileNotFoundError:
|
|
146
|
-
raise Exception(
|
|
147
|
-
"Not logged in. Please run 'love login' to log in to your account."
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
return auth
|
|
151
|
-
|
|
152
|
-
@staticmethod
|
|
153
|
-
def get_me():
|
|
154
|
-
"""Get current user info."""
|
|
155
|
-
token = AuthHandler.get_auth()["token"]
|
|
156
|
-
res = requests.post(
|
|
157
|
-
f"{HEIMDALL_URL}/api/users/authenticated/me",
|
|
158
|
-
headers={"Authorization": f"Bearer {token}"},
|
|
159
|
-
)
|
|
160
|
-
res.raise_for_status()
|
|
161
|
-
return res.json()
|
|
162
|
-
|
|
163
|
-
def store_auth(self):
|
|
164
|
-
"""Store the authentication for the application."""
|
|
165
|
-
pass
|
|
166
|
-
|
|
167
|
-
def refresh_auth(self):
|
|
168
|
-
"""Refresh the authentication for the application."""
|
|
169
|
-
pass
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|