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 ADDED
@@ -0,0 +1,5 @@
1
+ """Main method to support debugging."""
2
+ if __name__ == "__main__":
3
+ from .cli import cli
4
+
5
+ cli()
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.3.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ love=love.cli:cli
3
+
@@ -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