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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lovelytics-cli
3
- Version: 0.4.3
3
+ Version: 0.4.8
4
4
  Summary: Development tools for Lovelytics Labs.
5
5
  License: LICENSE
6
6
  License-File: LICENSE
@@ -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
- LANDO_URL,
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
- click.echo(f"Installing {package} from our private repository...")
125
- user = auth.AuthHandler.get_auth()
126
- protocol = "https" if LANDO_URL.startswith("https") else "http"
127
- base_url = LANDO_URL.strip("https://").strip("http://")
128
- index_url = (
129
- f"{protocol}://lovelytics:{user['token']}@{base_url}/python" # noqa: E231
130
- )
131
- encoded_url = urllib3.util.parse_url(index_url).url
132
- subprocess.run( # nosec
133
- ["pip", "install", "--extra-index-url", encoded_url, package]
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"
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