lovelytics-cli 0.4.3__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.
- love/__main__.py +5 -0
- love/auth.py +169 -0
- love/cli.py +170 -0
- love/config.py +32 -0
- love/forecast.py +24 -0
- love/linguo.py +76 -0
- love/utils.py +67 -0
- lovelytics_cli-0.4.3.dist-info/METADATA +27 -0
- lovelytics_cli-0.4.3.dist-info/RECORD +12 -0
- lovelytics_cli-0.4.3.dist-info/WHEEL +4 -0
- lovelytics_cli-0.4.3.dist-info/entry_points.txt +3 -0
- lovelytics_cli-0.4.3.dist-info/licenses/LICENSE +116 -0
love/__main__.py
ADDED
love/auth.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
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
|
love/cli.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Definition of the CLI."""
|
|
2
|
+
import subprocess # nosec
|
|
3
|
+
import click
|
|
4
|
+
from love import auth
|
|
5
|
+
from love import linguo as linguo_commands
|
|
6
|
+
from love import forecast as forecast_commands
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import click_log
|
|
10
|
+
import os
|
|
11
|
+
import urllib3
|
|
12
|
+
from love.config import (
|
|
13
|
+
AIRFLOW_HOME,
|
|
14
|
+
SPARK_HOME,
|
|
15
|
+
CONFIG_OPTIONS,
|
|
16
|
+
CONFIG_PATH,
|
|
17
|
+
CONFIG_FILE_PATH,
|
|
18
|
+
LANDO_URL,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
click_log.basic_config(logger)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.group()
|
|
26
|
+
@click_log.simple_verbosity_option(logger)
|
|
27
|
+
@click.pass_context
|
|
28
|
+
def cli(ctx):
|
|
29
|
+
"""Run the main CLI entry point."""
|
|
30
|
+
logger.debug("Setting environment variables.")
|
|
31
|
+
os.environ["AIRFLOW_HOME"] = AIRFLOW_HOME
|
|
32
|
+
os.environ["SPARK_HOME"] = SPARK_HOME
|
|
33
|
+
os.environ[
|
|
34
|
+
"AIRFLOW__CORE__SQL_ALCHEMY_CONN"
|
|
35
|
+
] = "postgresql+psycopg2://postgres:postgres@localhost:5433/postgres"
|
|
36
|
+
if not os.path.exists(CONFIG_PATH):
|
|
37
|
+
logger.info(
|
|
38
|
+
"This appears to be your first time running 'love'. Setting some things up for you."
|
|
39
|
+
)
|
|
40
|
+
logger.info("Creating config file.")
|
|
41
|
+
os.makedirs(CONFIG_PATH, exist_ok=True)
|
|
42
|
+
config = dict()
|
|
43
|
+
for k, v in CONFIG_OPTIONS.items():
|
|
44
|
+
if v.get("default_value"):
|
|
45
|
+
config[k] = v["default_value"]
|
|
46
|
+
with open(CONFIG_FILE_PATH, "w") as f:
|
|
47
|
+
json.dump(config, f)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@cli.group()
|
|
51
|
+
def config():
|
|
52
|
+
"""Manage configuration for the CLI."""
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@config.command()
|
|
57
|
+
@click.argument("config_name")
|
|
58
|
+
@click.argument("config_value")
|
|
59
|
+
def set(config_name, config_value):
|
|
60
|
+
"""Set the appropriate config value."""
|
|
61
|
+
if config_name not in CONFIG_OPTIONS.keys():
|
|
62
|
+
raise click.ClickException(f"{config_name} is not a valid config value.")
|
|
63
|
+
|
|
64
|
+
with open(CONFIG_FILE_PATH, "r") as f:
|
|
65
|
+
config = json.load(f)
|
|
66
|
+
config[config_name] = config_value
|
|
67
|
+
with open(CONFIG_FILE_PATH, "w") as f:
|
|
68
|
+
json.dump(config, f)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@cli.group()
|
|
72
|
+
def linguo():
|
|
73
|
+
"""Entrypoint for Linguo commands."""
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@linguo.command()
|
|
78
|
+
@click.argument(
|
|
79
|
+
"target_table",
|
|
80
|
+
)
|
|
81
|
+
def sql(target_table):
|
|
82
|
+
"""Command for Linguo SQL generation."""
|
|
83
|
+
linguo_commands.mapping_sql(target_table)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@cli.group()
|
|
87
|
+
def df():
|
|
88
|
+
"""Entrypoint for demand forecasting commands."""
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@df.command(
|
|
93
|
+
name="run",
|
|
94
|
+
)
|
|
95
|
+
@click.option("--config", "-c", help="A JSON string of the forecast config.")
|
|
96
|
+
@click.option(
|
|
97
|
+
"--config-file", "-f", help="A path to a JSON file of the forecast config."
|
|
98
|
+
)
|
|
99
|
+
def run_forecast(config, config_file):
|
|
100
|
+
"""Command for running forecasts."""
|
|
101
|
+
if not config and not config_file:
|
|
102
|
+
raise click.ClickException("Must provide either --config or --config-file.")
|
|
103
|
+
|
|
104
|
+
if config:
|
|
105
|
+
config = json.loads(config)
|
|
106
|
+
|
|
107
|
+
if config_file:
|
|
108
|
+
with open(config_file) as f:
|
|
109
|
+
config = json.load(f)
|
|
110
|
+
|
|
111
|
+
forecast_commands.run(config)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@cli.group()
|
|
115
|
+
def install():
|
|
116
|
+
"""Entrypoint for install commands."""
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@install.command()
|
|
121
|
+
@click.argument("package")
|
|
122
|
+
def python_package(package):
|
|
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
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@cli.command()
|
|
138
|
+
@click.option("--user", help="A user to user for login.")
|
|
139
|
+
@click.option("--token", help="An access token to use for login.")
|
|
140
|
+
@click.option("--org", help="An organization ID to use for login.")
|
|
141
|
+
def login(user, token, org):
|
|
142
|
+
"""Login interactively to Lovelytics Labs."""
|
|
143
|
+
auth.AuthHandler.login(user, token, org)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@cli.command()
|
|
147
|
+
def me():
|
|
148
|
+
"""Get info about the currently authenticated user."""
|
|
149
|
+
user_info = auth.AuthHandler.get_me()
|
|
150
|
+
if "email" in user_info["data"]:
|
|
151
|
+
click.echo(
|
|
152
|
+
json.dumps(
|
|
153
|
+
{
|
|
154
|
+
"id": user_info["data"]["_id"],
|
|
155
|
+
"email": user_info["data"]["email"],
|
|
156
|
+
"firsName": user_info["data"]["firstName"],
|
|
157
|
+
"lastName": user_info["data"]["lastName"],
|
|
158
|
+
"isAdmin": user_info["data"]["isAdmin"],
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
else:
|
|
163
|
+
click.echo(
|
|
164
|
+
json.dumps(
|
|
165
|
+
{
|
|
166
|
+
"orgId": user_info["data"]["orgId"],
|
|
167
|
+
"tokenId": user_info["data"]["tokenId"],
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
)
|
love/config.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Configuration values for the CLI."""
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".lovelytics")
|
|
5
|
+
CONFIG_FILE_PATH = os.path.join(CONFIG_PATH, "config.json")
|
|
6
|
+
AUTH_FILE_PATH = os.path.join(CONFIG_PATH, ".auth.json")
|
|
7
|
+
LIB_PATH = os.path.abspath(os.path.join(CONFIG_PATH, "lib"))
|
|
8
|
+
SPARK_HOME = os.path.join(LIB_PATH, "pyspark")
|
|
9
|
+
AIRFLOW_HOME = os.path.join(CONFIG_PATH, "airflow")
|
|
10
|
+
LAKEHOUSE_PATH = os.path.join(CONFIG_PATH, "lakehouse")
|
|
11
|
+
POSTGRES_PATH = os.path.join(CONFIG_PATH, "postgres")
|
|
12
|
+
AWS_PATH = os.path.join(os.path.expanduser("~"), ".aws")
|
|
13
|
+
AWS_CRED_PATH = os.path.join(AWS_PATH, "sso", "cache")
|
|
14
|
+
DBT_PROFILE_DIR = os.path.join(CONFIG_PATH, "profiles")
|
|
15
|
+
LANDO_URL = os.getenv("LANDO_URL", "https://landoapi.saas.lovelytics.com")
|
|
16
|
+
HEIMDALL_URL = os.getenv("HEIMDALL_URL", "https://heimdall.lovelytics.com")
|
|
17
|
+
GATEWAY_URL = os.getenv("GATEWAY_URL", "https://api.lovelytics.com")
|
|
18
|
+
|
|
19
|
+
CONFIG_OPTIONS = {
|
|
20
|
+
"runtime": {
|
|
21
|
+
"valid_values": ["native", "docker"],
|
|
22
|
+
"default_value": "docker",
|
|
23
|
+
},
|
|
24
|
+
"spark_version": {
|
|
25
|
+
"valid_values": [
|
|
26
|
+
"3.1",
|
|
27
|
+
"3.2",
|
|
28
|
+
"3.3",
|
|
29
|
+
],
|
|
30
|
+
"default_value": "3.3",
|
|
31
|
+
},
|
|
32
|
+
}
|
love/forecast.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Artifacts for forecasting commands."""
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import requests
|
|
5
|
+
from love.auth import AuthHandler
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
API_URL = os.getenv("LOVE_API_URL", "https://api.lovelytics.com")
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run(forecast_config):
|
|
13
|
+
"""Run a forecast with the specified config."""
|
|
14
|
+
logger.info("Running forecast with given config.")
|
|
15
|
+
token = AuthHandler.get_auth()["token"]
|
|
16
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
17
|
+
res = requests.post(
|
|
18
|
+
f"{API_URL}/solutions/scf/forecasts", json=forecast_config, headers=headers
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if res.status_code != 201:
|
|
22
|
+
raise Exception(f"Error running forecast: {res.text}")
|
|
23
|
+
|
|
24
|
+
print(f"Forecast started with ID {res.json()['forecastID']}.")
|
love/linguo.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Artifacts for interacting with Linguo."""
|
|
2
|
+
import logging
|
|
3
|
+
from love.auth import AuthHandler
|
|
4
|
+
import requests
|
|
5
|
+
from love.config import GATEWAY_URL
|
|
6
|
+
import yaml
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_target(target_table):
|
|
14
|
+
"""Parse the target table."""
|
|
15
|
+
if "." in target_table:
|
|
16
|
+
model, table = target_table.split(".")
|
|
17
|
+
else:
|
|
18
|
+
raise Exception("Target table must be in the format <model>.<table>.")
|
|
19
|
+
|
|
20
|
+
return model, table
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check_dbt_project():
|
|
24
|
+
"""Check if the local directory is a dbt project."""
|
|
25
|
+
print("Parsing dbt project file.")
|
|
26
|
+
dbt_project_file = os.path.join(os.getcwd(), "dbt_project.yml")
|
|
27
|
+
if not os.path.exists(dbt_project_file):
|
|
28
|
+
raise Exception("Current directory is not a dbt project.")
|
|
29
|
+
|
|
30
|
+
with open(dbt_project_file, "r") as f:
|
|
31
|
+
dbt_project = yaml.safe_load(f)
|
|
32
|
+
|
|
33
|
+
return dbt_project
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_dbt_schema(dbt_project, model):
|
|
37
|
+
"""Get the dbt schema for the given model."""
|
|
38
|
+
print("Getting dbt schema.")
|
|
39
|
+
model_path = dbt_project["model-paths"][0]
|
|
40
|
+
with open(os.path.join(os.getcwd(), model_path, model, "schema.yml"), "r") as f:
|
|
41
|
+
schema_file = yaml.safe_load(f)
|
|
42
|
+
|
|
43
|
+
return schema_file
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def mapping_sql(target_table):
|
|
47
|
+
"""
|
|
48
|
+
Generate a SQL mapping.
|
|
49
|
+
|
|
50
|
+
Infers source data from tables in the warehouse
|
|
51
|
+
and generates a SQL mapping to the given target table.
|
|
52
|
+
"""
|
|
53
|
+
model, table = parse_target(target_table)
|
|
54
|
+
dbt_project = check_dbt_project()
|
|
55
|
+
schema = get_dbt_schema(dbt_project, model)
|
|
56
|
+
print(f"Parsing source tables from dbt schema for {model}.")
|
|
57
|
+
raw_tables = schema["sources"][0]["tables"]
|
|
58
|
+
target = list(filter(lambda x: x["name"] == table, schema["models"]))[0]
|
|
59
|
+
print(f"Parsing target table {target_table}.")
|
|
60
|
+
print("Calling Linguo to generate target SQL.")
|
|
61
|
+
token = AuthHandler.get_auth()["token"]
|
|
62
|
+
res = requests.post(
|
|
63
|
+
f"{GATEWAY_URL}/v2/linguo/mapping/sql",
|
|
64
|
+
json={"sources": raw_tables, "target": target},
|
|
65
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
66
|
+
)
|
|
67
|
+
data = res.json()
|
|
68
|
+
print("Generating a table according to the following logic:")
|
|
69
|
+
print(data["description"])
|
|
70
|
+
print("Writing to SQL file.")
|
|
71
|
+
model_path = dbt_project["model-paths"][0]
|
|
72
|
+
sql_path = os.path.join(os.getcwd(), model_path, model, f"{table}.sql")
|
|
73
|
+
with open(sql_path, "w") as f:
|
|
74
|
+
f.write(data["sql_code"])
|
|
75
|
+
|
|
76
|
+
print(f"Successfully wrote SQL to {sql_path}.")
|
love/utils.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Reusable utilities for the CLI."""
|
|
2
|
+
import botocore
|
|
3
|
+
from love.config import SPARK_HOME, CONFIG_PATH, POSTGRES_PATH
|
|
4
|
+
import base64
|
|
5
|
+
import docker
|
|
6
|
+
import boto3
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_docker_client():
|
|
10
|
+
"""Retrieve a Docker client with proper auth."""
|
|
11
|
+
try:
|
|
12
|
+
docker_client = docker.from_env()
|
|
13
|
+
except docker.errors.DockerException:
|
|
14
|
+
print("ERROR: Failed to connect to Docker. Is Docker running?")
|
|
15
|
+
exit(1)
|
|
16
|
+
ecr_client = boto3.client("ecr", region_name="us-east-2")
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
token = ecr_client.get_authorization_token()["token"]
|
|
20
|
+
username, password = (
|
|
21
|
+
base64.b64decode(token["authorizationData"][0]["authorizationToken"])
|
|
22
|
+
.decode()
|
|
23
|
+
.split(":")
|
|
24
|
+
)
|
|
25
|
+
registry = token["authorizationData"][0]["proxyEndpoint"]
|
|
26
|
+
except botocore.exceptions.NoCredentialsError:
|
|
27
|
+
print("ERROR: Could not find AWS credentials. Are you logged into AWS?")
|
|
28
|
+
exit(1)
|
|
29
|
+
|
|
30
|
+
docker_client.login(username, password, registry=registry)
|
|
31
|
+
return docker_client
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def check_and_run(
|
|
35
|
+
docker_client,
|
|
36
|
+
*args,
|
|
37
|
+
wait=True,
|
|
38
|
+
ignore_existing=False,
|
|
39
|
+
skip_if_exists=False,
|
|
40
|
+
**kwargs,
|
|
41
|
+
):
|
|
42
|
+
"""Check for an existing container and rerun."""
|
|
43
|
+
try:
|
|
44
|
+
networks = docker_client.networks.list(names=["nousot"])
|
|
45
|
+
network = networks[0]
|
|
46
|
+
except (docker.errors.NotFound, IndexError):
|
|
47
|
+
network = docker_client.networks.create("nousot", driver="bridge")
|
|
48
|
+
|
|
49
|
+
kwargs["network"] = network.name
|
|
50
|
+
container_name = f"nousot_{kwargs['name']}"
|
|
51
|
+
kwargs["name"] = container_name
|
|
52
|
+
kwargs["auto_remove"] = not skip_if_exists
|
|
53
|
+
if not ignore_existing:
|
|
54
|
+
try:
|
|
55
|
+
print(f"Searching for container {container_name}.")
|
|
56
|
+
container = docker_client.containers.get(container_name)
|
|
57
|
+
if skip_if_exists:
|
|
58
|
+
return
|
|
59
|
+
else:
|
|
60
|
+
container.stop()
|
|
61
|
+
except docker.errors.NotFound:
|
|
62
|
+
print("Container not running.")
|
|
63
|
+
container = docker_client.containers.run(*args, **kwargs)
|
|
64
|
+
if wait:
|
|
65
|
+
for line in container.logs(stream=True):
|
|
66
|
+
print(line.decode().strip())
|
|
67
|
+
return container
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lovelytics-cli
|
|
3
|
+
Version: 0.4.3
|
|
4
|
+
Summary: Development tools for Lovelytics Labs.
|
|
5
|
+
License: LICENSE
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: Lovelytics Labs
|
|
8
|
+
Author-email: labs@lovelytics.com
|
|
9
|
+
Requires-Python: >=3.10.0,<3.14
|
|
10
|
+
Classifier: License :: Other/Proprietary License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Requires-Dist: boto3 (>=1.26.11,<2.0.0)
|
|
17
|
+
Requires-Dist: click (>=8.1.3,<9.0.0)
|
|
18
|
+
Requires-Dist: click-log (>=0.4.0,<0.5.0)
|
|
19
|
+
Requires-Dist: docker (>=6.0.1,<7.0.0)
|
|
20
|
+
Requires-Dist: pyyaml (>=6.0.0,<6.1.0)
|
|
21
|
+
Requires-Dist: requests (>=2.32.0,<3.0.0)
|
|
22
|
+
Requires-Dist: virtualenv (>=20.17.0,<21.0.0)
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# love-cli
|
|
26
|
+
Developer command line interface for the Lovelytics Labs.
|
|
27
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
love/__main__.py,sha256=e7K3l74_UvZUv6tARPVSiEgJaGzWkkBvzQTwloTLQF0,103
|
|
2
|
+
love/auth.py,sha256=kpRDZA0ZRGDXbCtDPT2JKxPuSNtD-Dayv2_jFDTWgGU,5441
|
|
3
|
+
love/cli.py,sha256=z9ZSVuOHsngU2T2y2-BsLnm5V5IVtixbg5S5_ocWG7o,4702
|
|
4
|
+
love/config.py,sha256=nwY_s9y8rvFnPRpSvCziz6qSTec6FwEkWPN3Obd2HNU,1176
|
|
5
|
+
love/forecast.py,sha256=raJ9zwzW4f3tY0v3Ur8q-4ql95l4j98Jz-8XA-VQgXc,730
|
|
6
|
+
love/linguo.py,sha256=L_FweST5Cn1Juwmurgg9UPNpmSGveXKAAUs74Mt_9is,2441
|
|
7
|
+
love/utils.py,sha256=TjhGPJeJ6RwSHJa03zHEZSZjG38vBBHntzzJrqun5vA,2159
|
|
8
|
+
lovelytics_cli-0.4.3.dist-info/METADATA,sha256=bbEgZ_USzVGtTSR2zGzVRkekNk6kPPwAjMJjxkVzGs8,933
|
|
9
|
+
lovelytics_cli-0.4.3.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
|
|
10
|
+
lovelytics_cli-0.4.3.dist-info/entry_points.txt,sha256=mPPt1m9xjnyw2km8-I2YYT9t3I-a2w0Gblfhla3kLEo,37
|
|
11
|
+
lovelytics_cli-0.4.3.dist-info/licenses/LICENSE,sha256=5U_thn-lPwMgmZ0f0uMOONoLpqdxwkNA2-TSsbOEA2Q,4744
|
|
12
|
+
lovelytics_cli-0.4.3.dist-info/RECORD,,
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
PROPRIETARY SOFTWARE LICENSE
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lovelytics, LLC. All rights reserved.
|
|
4
|
+
|
|
5
|
+
NOTICE: This software and its source code are the exclusive property of
|
|
6
|
+
Lovelytics, LLC ("Licensor") and are protected by copyright law and
|
|
7
|
+
international treaties.
|
|
8
|
+
|
|
9
|
+
ACCEPTANCE OF TERMS
|
|
10
|
+
|
|
11
|
+
BY INSTALLING, COPYING, OR OTHERWISE USING THIS SOFTWARE, LICENSEE
|
|
12
|
+
AGREES TO BE BOUND BY THE TERMS OF THIS LICENSE. IF LICENSEE DOES NOT
|
|
13
|
+
AGREE TO THESE TERMS, LICENSEE MUST NOT INSTALL OR USE THE SOFTWARE.
|
|
14
|
+
|
|
15
|
+
1. GRANT OF LICENSE
|
|
16
|
+
|
|
17
|
+
Subject to the terms of this License, Licensor grants the recipient
|
|
18
|
+
("Licensee") a limited, non-exclusive, non-transferable, non-sublicensable
|
|
19
|
+
license to install and use this software solely for Licensee's internal
|
|
20
|
+
business purposes in connection with Licensee's authorized engagement
|
|
21
|
+
with Licensor.
|
|
22
|
+
|
|
23
|
+
2. RESTRICTIONS
|
|
24
|
+
|
|
25
|
+
Licensee shall not, and shall not permit any third party to:
|
|
26
|
+
|
|
27
|
+
a) copy, reproduce, distribute, publish, or otherwise transfer this
|
|
28
|
+
software or any portion thereof to any third party;
|
|
29
|
+
|
|
30
|
+
b) sublicense, sell, rent, lease, or otherwise commercially exploit
|
|
31
|
+
this software;
|
|
32
|
+
|
|
33
|
+
c) use, incorporate, or adapt this software or any portion thereof
|
|
34
|
+
in any other product, project, or software;
|
|
35
|
+
|
|
36
|
+
d) reverse engineer, decompile, disassemble, or attempt to derive
|
|
37
|
+
the source code of any compiled portion of this software, except
|
|
38
|
+
as expressly permitted by applicable law;
|
|
39
|
+
|
|
40
|
+
e) remove, alter, or obscure any proprietary notices, labels, or
|
|
41
|
+
marks on or in this software.
|
|
42
|
+
|
|
43
|
+
3. TERMINATION
|
|
44
|
+
|
|
45
|
+
This License is effective until terminated. It terminates immediately
|
|
46
|
+
and automatically, without notice, upon the cessation of Licensee's
|
|
47
|
+
authorized engagement with Licensor. Upon termination, Licensee will
|
|
48
|
+
no longer receive updates, patches, or new versions of the software.
|
|
49
|
+
Licensee may retain and continue to use only the version(s) of the
|
|
50
|
+
software obtained during the term of its authorized engagement with
|
|
51
|
+
Licensor, subject to all remaining terms of this License.
|
|
52
|
+
|
|
53
|
+
4. CONFIDENTIALITY
|
|
54
|
+
|
|
55
|
+
Licensee acknowledges that the software, including its design, interfaces,
|
|
56
|
+
and connection to Licensor's internal systems, constitutes confidential
|
|
57
|
+
information of Licensor ("Confidential Information"). Licensee shall:
|
|
58
|
+
|
|
59
|
+
a) hold the Confidential Information in strict confidence using at least
|
|
60
|
+
the same degree of care it uses to protect its own confidential
|
|
61
|
+
information, but no less than reasonable care;
|
|
62
|
+
|
|
63
|
+
b) not disclose the Confidential Information to any third party without
|
|
64
|
+
the prior written consent of Licensor;
|
|
65
|
+
|
|
66
|
+
c) limit access to the Confidential Information to those of its employees
|
|
67
|
+
or contractors who have a need to know and are bound by confidentiality
|
|
68
|
+
obligations no less restrictive than those in this License.
|
|
69
|
+
|
|
70
|
+
These obligations survive termination of this License.
|
|
71
|
+
|
|
72
|
+
5. OWNERSHIP
|
|
73
|
+
|
|
74
|
+
This software is licensed, not sold. Licensor retains all right, title,
|
|
75
|
+
and interest in and to the software, including all intellectual property
|
|
76
|
+
rights therein. No title or ownership of the software is transferred
|
|
77
|
+
to Licensee under this License.
|
|
78
|
+
|
|
79
|
+
6. NO WARRANTIES
|
|
80
|
+
|
|
81
|
+
THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
82
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
|
|
83
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT.
|
|
84
|
+
LICENSOR DOES NOT WARRANT THAT THE SOFTWARE WILL BE ERROR-FREE OR
|
|
85
|
+
UNINTERRUPTED.
|
|
86
|
+
|
|
87
|
+
7. LIMITATION OF LIABILITY
|
|
88
|
+
|
|
89
|
+
IN NO EVENT SHALL LICENSOR BE LIABLE FOR ANY INDIRECT, INCIDENTAL,
|
|
90
|
+
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES ARISING OUT OF OR IN
|
|
91
|
+
CONNECTION WITH THIS LICENSE OR THE USE OF THE SOFTWARE, EVEN IF
|
|
92
|
+
ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
|
93
|
+
|
|
94
|
+
8. SEVERABILITY AND WAIVER
|
|
95
|
+
|
|
96
|
+
If any provision of this License is held to be invalid, illegal, or
|
|
97
|
+
unenforceable under applicable law, that provision shall be modified to
|
|
98
|
+
the minimum extent necessary to make it enforceable, or if modification
|
|
99
|
+
is not possible, severed from this License. The remaining provisions
|
|
100
|
+
shall continue in full force and effect. The failure of either party to
|
|
101
|
+
enforce any right or provision of this License shall not constitute a
|
|
102
|
+
waiver of that right or provision.
|
|
103
|
+
|
|
104
|
+
9. GOVERNING LAW
|
|
105
|
+
|
|
106
|
+
This License shall be governed by and construed in accordance with the
|
|
107
|
+
laws of the State of Washington, United States of America, without regard
|
|
108
|
+
to its conflict of law provisions.
|
|
109
|
+
|
|
110
|
+
10. ENTIRE AGREEMENT
|
|
111
|
+
|
|
112
|
+
This License constitutes the entire agreement between the parties with
|
|
113
|
+
respect to the subject matter hereof and supersedes all prior and
|
|
114
|
+
contemporaneous understandings.
|
|
115
|
+
|
|
116
|
+
For licensing inquiries, contact: loveyourdata@lovelytics.com
|