lkr-dev-cli 0.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lkr/__init__.py +0 -0
- lkr/auth/__init__.py +0 -0
- lkr/auth/main.py +217 -0
- lkr/auth/oauth.py +145 -0
- lkr/auth_service.py +382 -0
- lkr/classes.py +48 -0
- lkr/constants.py +3 -0
- lkr/embed/observability/utils.py +104 -0
- lkr/exceptions.py +4 -0
- lkr/logging.py +59 -0
- lkr/main.py +51 -0
- lkr/types.py +13 -0
- lkr/utils.py +0 -0
- lkr_dev_cli-0.0.0.dist-info/METADATA +19 -0
- lkr_dev_cli-0.0.0.dist-info/RECORD +18 -0
- lkr_dev_cli-0.0.0.dist-info/WHEEL +4 -0
- lkr_dev_cli-0.0.0.dist-info/entry_points.txt +2 -0
- lkr_dev_cli-0.0.0.dist-info/licenses/LICENSE +21 -0
lkr/__init__.py
ADDED
|
File without changes
|
lkr/auth/__init__.py
ADDED
|
File without changes
|
lkr/auth/main.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import urllib.parse
|
|
2
|
+
from typing import Annotated, List, Union, cast
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from looker_sdk.rtl.auth_token import AccessToken, AuthToken
|
|
6
|
+
from pick import Option, pick
|
|
7
|
+
from pydash import get
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from lkr.auth.oauth import OAuth2PKCE
|
|
12
|
+
from lkr.auth_service import get_auth
|
|
13
|
+
from lkr.logging import logger
|
|
14
|
+
|
|
15
|
+
__all__ = ["group"]
|
|
16
|
+
|
|
17
|
+
group = typer.Typer(name="auth", help="Authentication commands for LookML Repository")
|
|
18
|
+
|
|
19
|
+
@group.callback()
|
|
20
|
+
def callback(ctx: typer.Context):
|
|
21
|
+
if ctx.invoked_subcommand == "whoami":
|
|
22
|
+
return
|
|
23
|
+
if ctx.obj['ctx_lkr'].use_sdk == "api_key":
|
|
24
|
+
logger.error("API key authentication is not supported for auth commands")
|
|
25
|
+
raise typer.Exit(1)
|
|
26
|
+
|
|
27
|
+
@group.command()
|
|
28
|
+
def login(ctx: typer.Context):
|
|
29
|
+
"""
|
|
30
|
+
Login to Looker instance using OAuth2 PKCE flow
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
base_url = typer.prompt("Enter your Looker instance base URL")
|
|
34
|
+
if not base_url.startswith("http"):
|
|
35
|
+
base_url = f"https://{base_url}"
|
|
36
|
+
# Parse the URL and reconstruct it to get the origin (scheme://hostname[:port])
|
|
37
|
+
parsed_url = urllib.parse.urlparse(base_url)
|
|
38
|
+
origin = urllib.parse.urlunparse(
|
|
39
|
+
(parsed_url.scheme, parsed_url.netloc, "", "", "", "")
|
|
40
|
+
)
|
|
41
|
+
instance_name = typer.prompt(
|
|
42
|
+
"Enter a name for this Looker instance", default=parsed_url.netloc
|
|
43
|
+
)
|
|
44
|
+
auth = get_auth(ctx)
|
|
45
|
+
def add_auth(token: Union[AuthToken, AccessToken]):
|
|
46
|
+
auth.add_auth(instance_name, origin, token)
|
|
47
|
+
# Initialize OAuth2 PKCE flow
|
|
48
|
+
oauth = OAuth2PKCE(new_token_callback=add_auth)
|
|
49
|
+
|
|
50
|
+
# Open browser for authentication and wait for callback
|
|
51
|
+
logger.info(f"Opening browser for authentication at {origin + '/auth'}...")
|
|
52
|
+
|
|
53
|
+
login_response = oauth.initiate_login(origin)
|
|
54
|
+
if login_response["auth_code"]:
|
|
55
|
+
logger.info("Successfully received authorization code!")
|
|
56
|
+
try:
|
|
57
|
+
# Store the auth code in the OAuth instance
|
|
58
|
+
oauth.auth_code = login_response["auth_code"]
|
|
59
|
+
# Exchange the code for tokens
|
|
60
|
+
token = oauth.exchange_code_for_token()
|
|
61
|
+
if token:
|
|
62
|
+
logger.info("Successfully authenticated!")
|
|
63
|
+
else:
|
|
64
|
+
logger.error("Failed to exchange authorization code for tokens")
|
|
65
|
+
raise typer.Exit(1)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
logger.error(f"Failed to exchange authorization code for tokens: {str(e)}")
|
|
68
|
+
raise typer.Exit(1)
|
|
69
|
+
else:
|
|
70
|
+
logger.error("Failed to receive authorization code")
|
|
71
|
+
raise typer.Exit(1)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@group.command()
|
|
75
|
+
def logout(
|
|
76
|
+
ctx: typer.Context,
|
|
77
|
+
instance_name: Annotated[
|
|
78
|
+
str | None,
|
|
79
|
+
typer.Option(
|
|
80
|
+
help="Name of the Looker instance to logout from. If not provided, logs out from all instances."
|
|
81
|
+
),
|
|
82
|
+
] = None,
|
|
83
|
+
all: Annotated[
|
|
84
|
+
bool,
|
|
85
|
+
typer.Option(
|
|
86
|
+
"--all", help="Logout from all instances"
|
|
87
|
+
),
|
|
88
|
+
] = False,
|
|
89
|
+
):
|
|
90
|
+
"""
|
|
91
|
+
Logout and clear saved credentials
|
|
92
|
+
"""
|
|
93
|
+
auth = get_auth(ctx)
|
|
94
|
+
if instance_name:
|
|
95
|
+
message = f"Are you sure you want to logout from instance '{instance_name}'?"
|
|
96
|
+
elif all:
|
|
97
|
+
message = "Are you sure you want to logout from all instances?"
|
|
98
|
+
else:
|
|
99
|
+
instance_name = auth.get_current_instance()
|
|
100
|
+
if not instance_name:
|
|
101
|
+
logger.error("No instance currently authenticated")
|
|
102
|
+
raise typer.Exit(1)
|
|
103
|
+
message = f"Are you sure you want to logout from instance '{instance_name}'?"
|
|
104
|
+
|
|
105
|
+
if not typer.confirm(message, default=False):
|
|
106
|
+
logger.info("Logout cancelled")
|
|
107
|
+
raise typer.Exit()
|
|
108
|
+
|
|
109
|
+
if instance_name:
|
|
110
|
+
logger.info(f"Logging out from instance: {instance_name}")
|
|
111
|
+
auth.delete_auth(instance_name=instance_name)
|
|
112
|
+
else:
|
|
113
|
+
logger.info("Logging out from all instances...")
|
|
114
|
+
all_instances = auth.list_auth()
|
|
115
|
+
for instance in all_instances:
|
|
116
|
+
auth.delete_auth(instance_name=instance[0])
|
|
117
|
+
logger.info("Logged out successfully!")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@group.command()
|
|
121
|
+
def whoami(ctx: typer.Context):
|
|
122
|
+
"""
|
|
123
|
+
Check current authentication
|
|
124
|
+
"""
|
|
125
|
+
auth = get_auth(ctx)
|
|
126
|
+
sdk = auth.get_current_sdk(prompt_refresh_invalid_token=True)
|
|
127
|
+
if not sdk:
|
|
128
|
+
logger.error(
|
|
129
|
+
"Not currently authenticated - use `lkr auth login` or `lkr auth switch` to authenticate"
|
|
130
|
+
)
|
|
131
|
+
raise typer.Exit(1)
|
|
132
|
+
user = sdk.me()
|
|
133
|
+
logger.info(
|
|
134
|
+
f"Currently authenticated as {user.first_name} {user.last_name} ({user.email}) to {sdk.auth.settings.base_url}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@group.command()
|
|
139
|
+
def switch(
|
|
140
|
+
ctx: typer.Context,
|
|
141
|
+
instance_name: Annotated[
|
|
142
|
+
str | None,
|
|
143
|
+
typer.Option(
|
|
144
|
+
"-I", "--instance-name", help="Name of the Looker instance to switch to"
|
|
145
|
+
),
|
|
146
|
+
] = None,
|
|
147
|
+
):
|
|
148
|
+
"""
|
|
149
|
+
Switch to a different authenticated Looker instance
|
|
150
|
+
"""
|
|
151
|
+
auth = get_auth(ctx)
|
|
152
|
+
all_instances = auth.list_auth()
|
|
153
|
+
if not all_instances:
|
|
154
|
+
logger.error("No authenticated instances found")
|
|
155
|
+
raise typer.Exit(1)
|
|
156
|
+
|
|
157
|
+
if instance_name:
|
|
158
|
+
# If instance name provided, verify it exists
|
|
159
|
+
instance_names = [name for name, url, current in all_instances]
|
|
160
|
+
if instance_name not in instance_names:
|
|
161
|
+
logger.error(f"Instance '{instance_name}' not found")
|
|
162
|
+
raise typer.Exit(1)
|
|
163
|
+
else:
|
|
164
|
+
# If no instance name provided, show selection menu
|
|
165
|
+
current_index = 0
|
|
166
|
+
instance_names = []
|
|
167
|
+
options: List[Option] = []
|
|
168
|
+
max_name_length = 0
|
|
169
|
+
for index, (name, _, current) in enumerate(all_instances):
|
|
170
|
+
if current:
|
|
171
|
+
current_index = index
|
|
172
|
+
max_name_length = max(max_name_length, len(name))
|
|
173
|
+
instance_names.append(name)
|
|
174
|
+
options = [
|
|
175
|
+
Option(label=f"{name:{max_name_length}} ({url})", value=name)
|
|
176
|
+
for name, url, _ in all_instances
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
picked = pick(
|
|
180
|
+
options,
|
|
181
|
+
"Select instance to switch to",
|
|
182
|
+
min_selection_count=1,
|
|
183
|
+
default_index=current_index,
|
|
184
|
+
clear_screen=False,
|
|
185
|
+
)[0]
|
|
186
|
+
instance_name = cast(str, get(picked, "value"))
|
|
187
|
+
# Switch to selected instance
|
|
188
|
+
auth.set_current_instance(instance_name)
|
|
189
|
+
sdk = auth.get_current_sdk()
|
|
190
|
+
if not sdk:
|
|
191
|
+
logger.error("No looker instance currently authenticated")
|
|
192
|
+
raise typer.Exit(1)
|
|
193
|
+
user = sdk.me()
|
|
194
|
+
logger.info(
|
|
195
|
+
f"Successfully switched to {instance_name} ({sdk.auth.settings.base_url}) as {user.first_name} {user.last_name} ({user.email})"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@group.command()
|
|
200
|
+
def list(ctx: typer.Context):
|
|
201
|
+
"""
|
|
202
|
+
List all authenticated Looker instances
|
|
203
|
+
"""
|
|
204
|
+
console = Console()
|
|
205
|
+
auth = get_auth(ctx)
|
|
206
|
+
all_instances = auth.list_auth()
|
|
207
|
+
if not all_instances:
|
|
208
|
+
logger.error("No authenticated instances found")
|
|
209
|
+
raise typer.Exit(1)
|
|
210
|
+
table = Table(" ", "Instance", "URL")
|
|
211
|
+
for instance in all_instances:
|
|
212
|
+
table.add_row("*" if instance[2] else " ", instance[0], instance[1])
|
|
213
|
+
console.print(table)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__":
|
|
217
|
+
group()
|
lkr/auth/oauth.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import http.server
|
|
2
|
+
import os
|
|
3
|
+
import secrets
|
|
4
|
+
import socket
|
|
5
|
+
import socketserver
|
|
6
|
+
import threading
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import webbrowser
|
|
9
|
+
from typing import Optional, TypedDict
|
|
10
|
+
from urllib.parse import parse_qs
|
|
11
|
+
|
|
12
|
+
from lkr.types import NewTokenCallback
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def kill_process_on_port(port: int) -> None:
|
|
16
|
+
"""Kill any process currently using the specified port."""
|
|
17
|
+
try:
|
|
18
|
+
# Try to create a socket binding to check if port is in use
|
|
19
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
20
|
+
sock.bind(('localhost', port))
|
|
21
|
+
sock.close()
|
|
22
|
+
return # Port is free, no need to kill anything
|
|
23
|
+
except socket.error:
|
|
24
|
+
# Port is in use, try to kill the process
|
|
25
|
+
if os.name == 'posix': # macOS/Linux
|
|
26
|
+
os.system(f'lsof -ti tcp:{port} | xargs kill -9 2>/dev/null')
|
|
27
|
+
elif os.name == 'nt': # Windows
|
|
28
|
+
os.system(f'for /f "tokens=5" %a in (\'netstat -aon ^| find ":{port}"\') do taskkill /F /PID %a 2>nul')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
32
|
+
def do_GET(self):
|
|
33
|
+
"""Handle the callback from OAuth authorization"""
|
|
34
|
+
self.send_response(200)
|
|
35
|
+
self.send_header('Content-type', 'text/html')
|
|
36
|
+
self.end_headers()
|
|
37
|
+
|
|
38
|
+
# Parse the authorization code from query parameters
|
|
39
|
+
query_components = parse_qs(urllib.parse.urlparse(self.path).query)
|
|
40
|
+
|
|
41
|
+
# Store the code in the server instance
|
|
42
|
+
if 'code' in query_components:
|
|
43
|
+
self.auth_code = query_components['code'][0]
|
|
44
|
+
|
|
45
|
+
# Display a success message to the user
|
|
46
|
+
self.wfile.write(b"Authentication successful! You can close this window.")
|
|
47
|
+
|
|
48
|
+
# Shutdown the server
|
|
49
|
+
threading.Thread(target=self.server.shutdown).start()
|
|
50
|
+
|
|
51
|
+
def log_message(self, format, *args):
|
|
52
|
+
"""Suppress logging of requests"""
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
class OAuthCallbackServer(socketserver.TCPServer):
|
|
56
|
+
def __init__(self, server_address):
|
|
57
|
+
super().__init__(server_address, OAuthCallbackHandler)
|
|
58
|
+
self.auth_code: Optional[str] = None
|
|
59
|
+
|
|
60
|
+
class LoginResponse(TypedDict):
|
|
61
|
+
auth_code: Optional[str]
|
|
62
|
+
code_verifier: Optional[str]
|
|
63
|
+
|
|
64
|
+
class OAuth2PKCE:
|
|
65
|
+
def __init__(self, new_token_callback: NewTokenCallback):
|
|
66
|
+
from lkr.auth_service import DbOAuthSession
|
|
67
|
+
self.auth_code: Optional[str] = None
|
|
68
|
+
self.state = secrets.token_urlsafe(16)
|
|
69
|
+
self.new_token_callback: NewTokenCallback = new_token_callback
|
|
70
|
+
self.auth_session: DbOAuthSession | None = None
|
|
71
|
+
self.server_thread: threading.Thread | None = None
|
|
72
|
+
self.server: OAuthCallbackServer | None = None
|
|
73
|
+
self.port: int = 8000
|
|
74
|
+
|
|
75
|
+
def cleanup(self):
|
|
76
|
+
"""Clean up the server and its thread."""
|
|
77
|
+
if self.server:
|
|
78
|
+
self.server.shutdown()
|
|
79
|
+
self.server.server_close()
|
|
80
|
+
self.server = None
|
|
81
|
+
if self.server_thread:
|
|
82
|
+
self.server_thread.join(timeout=1.0)
|
|
83
|
+
self.server_thread = None
|
|
84
|
+
|
|
85
|
+
def initiate_login(self, base_url: str) -> LoginResponse:
|
|
86
|
+
"""
|
|
87
|
+
Initiates the OAuth2 PKCE login flow by opening the browser with the authorization URL
|
|
88
|
+
and starting a local server to catch the callback.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Optional[str]: The authorization code if successful, None otherwise
|
|
92
|
+
"""
|
|
93
|
+
from lkr.auth_service import get_auth_session
|
|
94
|
+
|
|
95
|
+
# Kill any process using port 8000
|
|
96
|
+
kill_process_on_port(self.port)
|
|
97
|
+
|
|
98
|
+
# Start the local server
|
|
99
|
+
self.server = OAuthCallbackServer(('localhost', self.port))
|
|
100
|
+
|
|
101
|
+
# Start the server in a separate thread
|
|
102
|
+
self.server_thread = threading.Thread(target=self.server.serve_forever)
|
|
103
|
+
self.server_thread.daemon = True
|
|
104
|
+
self.server_thread.start()
|
|
105
|
+
|
|
106
|
+
# Construct and open the OAuth URL
|
|
107
|
+
self.auth_session = get_auth_session(base_url, self.new_token_callback)
|
|
108
|
+
oauth_url = self.auth_session.create_auth_code_request_url('cors_api', self.state)
|
|
109
|
+
|
|
110
|
+
webbrowser.open(oauth_url)
|
|
111
|
+
|
|
112
|
+
# Wait for the callback
|
|
113
|
+
self.server_thread.join()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Get the authorization code
|
|
117
|
+
return LoginResponse(auth_code=self.server.auth_code, code_verifier=self.auth_session.code_verifier)
|
|
118
|
+
|
|
119
|
+
def exchange_code_for_token(self):
|
|
120
|
+
"""
|
|
121
|
+
Exchange the authorization code for access and refresh tokens.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
base_url: The base URL of the Looker instance
|
|
125
|
+
client_id: The OAuth client ID
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Dict containing access_token, refresh_token, token_type, and expires_in
|
|
129
|
+
"""
|
|
130
|
+
if not self.auth_code:
|
|
131
|
+
raise ValueError("No authorization code available. Must call initiate_login first.")
|
|
132
|
+
if not self.auth_session:
|
|
133
|
+
raise ValueError("No auth session available. Must call initiate_login first.")
|
|
134
|
+
self.auth_session.redeem_auth_code(self.auth_code, self.auth_session.code_verifier)
|
|
135
|
+
self.cleanup()
|
|
136
|
+
return self.auth_session.token
|
|
137
|
+
|
|
138
|
+
def __del__(self):
|
|
139
|
+
self.cleanup()
|
|
140
|
+
|
|
141
|
+
def __enter__(self):
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
145
|
+
self.cleanup()
|
lkr/auth_service.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sqlite3
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from typing import List, Self, Tuple, Union
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from looker_sdk.rtl import serialize
|
|
8
|
+
from looker_sdk.rtl.api_settings import ApiSettings, SettingsConfig
|
|
9
|
+
from looker_sdk.rtl.auth_session import AuthSession, CryptoHash, OAuthSession
|
|
10
|
+
from looker_sdk.rtl.auth_token import AccessToken, AuthToken
|
|
11
|
+
from looker_sdk.rtl.requests_transport import RequestsTransport
|
|
12
|
+
from looker_sdk.sdk.api40.methods import Looker40SDK
|
|
13
|
+
from pydantic import BaseModel, Field, computed_field
|
|
14
|
+
from pydash import get
|
|
15
|
+
|
|
16
|
+
from lkr.classes import LkrCtxObj, LookerApiKey
|
|
17
|
+
from lkr.constants import LOOKER_API_VERSION, OAUTH_CLIENT_ID, OAUTH_REDIRECT_URI
|
|
18
|
+
from lkr.logging import logger
|
|
19
|
+
from lkr.types import NewTokenCallback
|
|
20
|
+
|
|
21
|
+
__all__ = ["get_auth"]
|
|
22
|
+
|
|
23
|
+
def get_auth(ctx: typer.Context) -> Union["SqlLiteAuth", "ApiKeyAuth"]:
|
|
24
|
+
lkr_ctx = get(ctx, ["obj", "ctx_lkr"])
|
|
25
|
+
if not lkr_ctx:
|
|
26
|
+
logger.error("No Looker context found")
|
|
27
|
+
raise typer.Exit(1)
|
|
28
|
+
elif lkr_ctx.use_sdk == "api_key":
|
|
29
|
+
logger.info("Using API key authentication")
|
|
30
|
+
return ApiKeyAuth(lkr_ctx.api_key)
|
|
31
|
+
else:
|
|
32
|
+
return SqlLiteAuth(lkr_ctx)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ApiKeyApiSettings(ApiSettings):
|
|
36
|
+
def __init__(self, api_key: LookerApiKey):
|
|
37
|
+
self.api_key = api_key
|
|
38
|
+
super().__init__()
|
|
39
|
+
|
|
40
|
+
def read_config(self) -> SettingsConfig:
|
|
41
|
+
return SettingsConfig(
|
|
42
|
+
base_url=self.api_key.base_url,
|
|
43
|
+
client_id=self.api_key.client_id,
|
|
44
|
+
client_secret=self.api_key.client_secret,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
class OAuthApiSettings(ApiSettings):
|
|
48
|
+
def __init__(self, base_url: str):
|
|
49
|
+
self.base_url = base_url
|
|
50
|
+
super().__init__()
|
|
51
|
+
|
|
52
|
+
def read_config(self) -> SettingsConfig:
|
|
53
|
+
return SettingsConfig(
|
|
54
|
+
base_url=self.base_url,
|
|
55
|
+
looker_url=self.base_url,
|
|
56
|
+
client_id=OAUTH_CLIENT_ID,
|
|
57
|
+
client_secret="", # PKCE doesn't need client secret
|
|
58
|
+
redirect_uri=OAUTH_REDIRECT_URI
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
class DbOAuthSession(OAuthSession):
|
|
62
|
+
def __init__(self, *args, new_token_callback: NewTokenCallback, **kwargs):
|
|
63
|
+
super().__init__(*args, **kwargs)
|
|
64
|
+
self.new_token_callback = new_token_callback
|
|
65
|
+
|
|
66
|
+
def _login(self, *args, **kwargs):
|
|
67
|
+
super()._login(*args, **kwargs)
|
|
68
|
+
self.new_token_callback(self.token)
|
|
69
|
+
|
|
70
|
+
def redeem_auth_code(self, *args, **kwargs):
|
|
71
|
+
super().redeem_auth_code(*args, **kwargs)
|
|
72
|
+
self.new_token_callback(self.token)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_auth_session(
|
|
76
|
+
base_url: str,
|
|
77
|
+
new_token_callback: NewTokenCallback,
|
|
78
|
+
*,
|
|
79
|
+
access_token: AccessToken | None = None,
|
|
80
|
+
) -> DbOAuthSession:
|
|
81
|
+
settings = OAuthApiSettings(base_url)
|
|
82
|
+
transport = RequestsTransport.configure(settings)
|
|
83
|
+
auth = DbOAuthSession(
|
|
84
|
+
settings=settings,
|
|
85
|
+
transport=transport,
|
|
86
|
+
deserialize=serialize.deserialize40,
|
|
87
|
+
serialize=serialize.serialize40,
|
|
88
|
+
crypto=CryptoHash(),
|
|
89
|
+
version=LOOKER_API_VERSION,
|
|
90
|
+
new_token_callback=new_token_callback,
|
|
91
|
+
)
|
|
92
|
+
if access_token:
|
|
93
|
+
auth.token.set_token(access_token)
|
|
94
|
+
return auth
|
|
95
|
+
|
|
96
|
+
def init_api_key_sdk(api_key: LookerApiKey) -> Looker40SDK:
|
|
97
|
+
from looker_sdk.rtl import serialize
|
|
98
|
+
settings = ApiKeyApiSettings(api_key)
|
|
99
|
+
settings.is_configured()
|
|
100
|
+
transport = RequestsTransport.configure(settings)
|
|
101
|
+
return Looker40SDK(
|
|
102
|
+
auth = AuthSession(settings, transport, serialize.deserialize40, LOOKER_API_VERSION), # type: ignore
|
|
103
|
+
deserialize = serialize.deserialize40, # type: ignore
|
|
104
|
+
serialize = serialize.serialize40, # type: ignore
|
|
105
|
+
transport = transport,
|
|
106
|
+
api_version = LOOKER_API_VERSION,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def init_oauth_sdk(
|
|
111
|
+
base_url: str,
|
|
112
|
+
new_token_callback: NewTokenCallback,
|
|
113
|
+
*,
|
|
114
|
+
access_token: AccessToken | None = None,
|
|
115
|
+
) -> Looker40SDK:
|
|
116
|
+
"""Default dependency configuration"""
|
|
117
|
+
settings = OAuthApiSettings(base_url)
|
|
118
|
+
settings.is_configured()
|
|
119
|
+
transport = RequestsTransport.configure(settings)
|
|
120
|
+
auth = get_auth_session(base_url, new_token_callback, access_token=access_token)
|
|
121
|
+
|
|
122
|
+
return Looker40SDK(
|
|
123
|
+
auth=auth,
|
|
124
|
+
deserialize=serialize.deserialize40, # type: ignore
|
|
125
|
+
serialize=serialize.serialize40, # type: ignore
|
|
126
|
+
transport=transport,
|
|
127
|
+
api_version=LOOKER_API_VERSION,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class CurrentAuth(BaseModel):
|
|
132
|
+
instance_name: str
|
|
133
|
+
access_token: str
|
|
134
|
+
refresh_token: str
|
|
135
|
+
refresh_expires_at: str = Field(default_factory=lambda: (datetime.now(timezone.utc) + timedelta(days=30)).isoformat())
|
|
136
|
+
token_type: str
|
|
137
|
+
expires_in: int
|
|
138
|
+
base_url: str
|
|
139
|
+
from_db: bool = False
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def valid_refresh_token(self) -> bool:
|
|
143
|
+
if not self.refresh_expires_at:
|
|
144
|
+
return False
|
|
145
|
+
return datetime.fromisoformat(self.refresh_expires_at).replace(tzinfo=timezone.utc) > (datetime.now(timezone.utc) + timedelta(hours=24))
|
|
146
|
+
|
|
147
|
+
@computed_field
|
|
148
|
+
@property
|
|
149
|
+
def expires_at(self) -> str:
|
|
150
|
+
return (datetime.now() + timedelta(seconds=self.expires_in)).isoformat()
|
|
151
|
+
|
|
152
|
+
def __add__(self, other: Union[AccessToken, AuthToken]) -> Self:
|
|
153
|
+
self.access_token = other.access_token or ""
|
|
154
|
+
self.refresh_token = other.refresh_token or ""
|
|
155
|
+
self.token_type = other.token_type or ""
|
|
156
|
+
self.expires_in = other.expires_in or 0
|
|
157
|
+
self.refresh_expires_at = (datetime.now(timezone.utc) + timedelta(days=30)).isoformat()
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def from_access_token(
|
|
162
|
+
cls, *, access_token: Union[AccessToken, AuthToken], instance_name: str, base_url: str
|
|
163
|
+
) -> "CurrentAuth":
|
|
164
|
+
return cls(
|
|
165
|
+
instance_name=instance_name,
|
|
166
|
+
access_token=access_token.access_token or "",
|
|
167
|
+
refresh_token=access_token.refresh_token or "",
|
|
168
|
+
token_type=access_token.token_type or "",
|
|
169
|
+
expires_in=access_token.expires_in or 0,
|
|
170
|
+
base_url=base_url,
|
|
171
|
+
from_db=False,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
@classmethod
|
|
175
|
+
def from_db_row(cls, row: sqlite3.Row) -> "CurrentAuth":
|
|
176
|
+
expires_in = int(
|
|
177
|
+
(datetime.fromisoformat(row["expires_at"]) - datetime.now()).total_seconds()
|
|
178
|
+
)
|
|
179
|
+
return cls(
|
|
180
|
+
instance_name=row["instance_name"],
|
|
181
|
+
access_token=row["access_token"],
|
|
182
|
+
refresh_token=row["refresh_token"],
|
|
183
|
+
token_type=row["token_type"],
|
|
184
|
+
expires_in=max(1, expires_in),
|
|
185
|
+
refresh_expires_at=row["refresh_expires_at"],
|
|
186
|
+
base_url=row["base_url"],
|
|
187
|
+
from_db=True,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def to_access_token(self) -> AccessToken:
|
|
191
|
+
return AccessToken(
|
|
192
|
+
access_token=self.access_token,
|
|
193
|
+
refresh_token=self.refresh_token,
|
|
194
|
+
token_type=self.token_type,
|
|
195
|
+
expires_in=self.expires_in,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def update_refresh_expires_at(self, connection: sqlite3.Connection, commit: bool = True):
|
|
199
|
+
connection.execute(
|
|
200
|
+
"UPDATE auth SET refresh_expires_at = ? WHERE instance_name = ?",
|
|
201
|
+
(self.refresh_expires_at, self.instance_name),
|
|
202
|
+
)
|
|
203
|
+
if commit:
|
|
204
|
+
connection.commit()
|
|
205
|
+
|
|
206
|
+
def set_token(
|
|
207
|
+
self, connection: sqlite3.Connection, *, new_token: Union[AccessToken, AuthToken] | None = None, commit: bool = True
|
|
208
|
+
):
|
|
209
|
+
expires_at = (
|
|
210
|
+
datetime.now() + timedelta(seconds=(new_token.expires_in or 0) if new_token else 0)
|
|
211
|
+
).isoformat()
|
|
212
|
+
refresh_expires_at = (
|
|
213
|
+
datetime.fromisoformat(expires_at) + timedelta(days=30)
|
|
214
|
+
).isoformat()
|
|
215
|
+
if self.from_db and new_token:
|
|
216
|
+
connection.execute(
|
|
217
|
+
"UPDATE auth SET access_token = ?, token_type = ?, expires_at = ? WHERE current_instance = 1",
|
|
218
|
+
(
|
|
219
|
+
new_token.access_token,
|
|
220
|
+
new_token.token_type,
|
|
221
|
+
expires_at,
|
|
222
|
+
),
|
|
223
|
+
)
|
|
224
|
+
else:
|
|
225
|
+
connection.execute(
|
|
226
|
+
"INSERT INTO auth (instance_name, access_token, refresh_token, refresh_expires_at, token_type, expires_at, current_instance, base_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
227
|
+
(
|
|
228
|
+
self.instance_name,
|
|
229
|
+
self.access_token,
|
|
230
|
+
self.refresh_token,
|
|
231
|
+
refresh_expires_at,
|
|
232
|
+
self.token_type,
|
|
233
|
+
expires_at,
|
|
234
|
+
1,
|
|
235
|
+
self.base_url,
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
if commit:
|
|
239
|
+
connection.commit()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class SqlLiteAuth:
|
|
244
|
+
def __init__(self, ctx: LkrCtxObj, db_path: str = "~/.lkr/auth.db"):
|
|
245
|
+
self.db_path = os.path.expanduser(db_path)
|
|
246
|
+
# Ensure the directory exists
|
|
247
|
+
db_dir = os.path.dirname(self.db_path)
|
|
248
|
+
os.makedirs(db_dir, exist_ok=True)
|
|
249
|
+
self.conn = sqlite3.connect(self.db_path)
|
|
250
|
+
self.conn.row_factory = sqlite3.Row
|
|
251
|
+
self.conn.execute(
|
|
252
|
+
"CREATE TABLE IF NOT EXISTS auth (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_name TEXT, access_token TEXT, refresh_token TEXT, refresh_expires_at TEXT, token_type TEXT, expires_at TEXT, current_instance BOOLEAN, base_url TEXT)"
|
|
253
|
+
)
|
|
254
|
+
self.conn.execute(
|
|
255
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_instance_name ON auth(instance_name)"
|
|
256
|
+
)
|
|
257
|
+
self.conn.commit()
|
|
258
|
+
|
|
259
|
+
def __enter__(self):
|
|
260
|
+
return self
|
|
261
|
+
|
|
262
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
def add_auth(self, instance_name: str, base_url: str, access_token: Union[AuthToken, AccessToken]):
|
|
266
|
+
self.conn.execute("UPDATE auth SET current_instance = 0")
|
|
267
|
+
CurrentAuth.from_access_token(
|
|
268
|
+
access_token=access_token, instance_name=instance_name, base_url=base_url
|
|
269
|
+
).set_token(self.conn, commit=False, new_token=access_token)
|
|
270
|
+
self.conn.commit()
|
|
271
|
+
|
|
272
|
+
def set_current_instance(self, instance_name: str):
|
|
273
|
+
self.conn.execute(
|
|
274
|
+
"UPDATE auth SET current_instance = CASE WHEN instance_name = ? THEN 1 ELSE 0 END",
|
|
275
|
+
(instance_name,),
|
|
276
|
+
)
|
|
277
|
+
self.conn.commit()
|
|
278
|
+
|
|
279
|
+
def _get_current_auth(self) -> CurrentAuth | None:
|
|
280
|
+
cursor = self.conn.execute(
|
|
281
|
+
"SELECT instance_name, access_token, refresh_token, refresh_expires_at, token_type, expires_at, base_url FROM auth WHERE current_instance = 1"
|
|
282
|
+
)
|
|
283
|
+
row = cursor.fetchone()
|
|
284
|
+
if row:
|
|
285
|
+
return CurrentAuth.from_db_row(row)
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
def get_current_instance(self) -> str | None:
|
|
289
|
+
current_auth = self._get_current_auth()
|
|
290
|
+
if current_auth:
|
|
291
|
+
return current_auth.instance_name
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
def get_current_sdk(self, prompt_refresh_invalid_token: bool = False) -> Looker40SDK | None:
|
|
295
|
+
current_auth = self._get_current_auth()
|
|
296
|
+
if current_auth:
|
|
297
|
+
if not current_auth.valid_refresh_token:
|
|
298
|
+
from lkr.exceptions import InvalidRefreshTokenError
|
|
299
|
+
if prompt_refresh_invalid_token:
|
|
300
|
+
self._cli_confirm_refresh_token(current_auth)
|
|
301
|
+
else:
|
|
302
|
+
raise InvalidRefreshTokenError(current_auth.instance_name)
|
|
303
|
+
def refresh_current_token(token: Union[AccessToken, AuthToken]):
|
|
304
|
+
current_auth.set_token(self.conn, new_token=token, commit=True)
|
|
305
|
+
return init_oauth_sdk(
|
|
306
|
+
current_auth.base_url,
|
|
307
|
+
new_token_callback=refresh_current_token,
|
|
308
|
+
access_token=current_auth.to_access_token()
|
|
309
|
+
)
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
def delete_auth(self, instance_name: str):
|
|
313
|
+
self.conn.execute("DELETE FROM auth WHERE instance_name = ?", (instance_name,))
|
|
314
|
+
self.conn.commit()
|
|
315
|
+
|
|
316
|
+
def list_auth(self) -> List[Tuple[str, str, bool]]:
|
|
317
|
+
cursor = self.conn.execute(
|
|
318
|
+
"SELECT instance_name, base_url, current_instance FROM auth ORDER BY instance_name ASC"
|
|
319
|
+
)
|
|
320
|
+
rows = cursor.fetchall()
|
|
321
|
+
return [
|
|
322
|
+
(row["instance_name"], row["base_url"], row["current_instance"])
|
|
323
|
+
for row in rows
|
|
324
|
+
]
|
|
325
|
+
|
|
326
|
+
def _cli_confirm_refresh_token(self, current_auth: CurrentAuth):
|
|
327
|
+
from typer import confirm
|
|
328
|
+
|
|
329
|
+
from lkr.auth.oauth import OAuth2PKCE
|
|
330
|
+
from lkr.exceptions import InvalidRefreshTokenError
|
|
331
|
+
if confirm(f"Press enter to refresh the token for {current_auth.instance_name}", default=True):
|
|
332
|
+
def add_auth(token: Union[AccessToken, AuthToken]):
|
|
333
|
+
current_auth + token
|
|
334
|
+
current_auth.update_refresh_expires_at(self.conn, commit=False)
|
|
335
|
+
current_auth.set_token(self.conn, commit=True, new_token=token)
|
|
336
|
+
# Initialize OAuth2 PKCE flow
|
|
337
|
+
oauth = OAuth2PKCE(new_token_callback=add_auth)
|
|
338
|
+
login_response = oauth.initiate_login(current_auth.base_url)
|
|
339
|
+
oauth.auth_code = login_response["auth_code"]
|
|
340
|
+
token = oauth.exchange_code_for_token()
|
|
341
|
+
if not token:
|
|
342
|
+
raise InvalidRefreshTokenError(current_auth.instance_name)
|
|
343
|
+
else:
|
|
344
|
+
from lkr.logging import logger
|
|
345
|
+
logger.info(f"Successfully refreshed token for {current_auth.instance_name}")
|
|
346
|
+
return self.get_current_sdk(prompt_refresh_invalid_token=False)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class ApiKeyAuth:
|
|
351
|
+
def __init__(self, api_key: LookerApiKey):
|
|
352
|
+
self.api_key = api_key
|
|
353
|
+
|
|
354
|
+
def __enter__(self):
|
|
355
|
+
return self
|
|
356
|
+
|
|
357
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
358
|
+
pass
|
|
359
|
+
|
|
360
|
+
def add_auth(self, instance_name: str, base_url: str, access_token: Union[AuthToken, AccessToken]):
|
|
361
|
+
raise NotImplementedError("ApiKeyAuth does not support adding auth")
|
|
362
|
+
|
|
363
|
+
def delete_auth(self, instance_name: str):
|
|
364
|
+
raise NotImplementedError("ApiKeyAuth does not support deleting auth")
|
|
365
|
+
|
|
366
|
+
def list_auth(self) -> List[Tuple[str, str, bool]]:
|
|
367
|
+
raise NotImplementedError("ApiKeyAuth does not support listing auth")
|
|
368
|
+
|
|
369
|
+
def set_current_instance(self, instance_name: str):
|
|
370
|
+
raise NotImplementedError("ApiKeyAuth does not support setting current instance")
|
|
371
|
+
|
|
372
|
+
def _get_current_auth(self) -> CurrentAuth | None:
|
|
373
|
+
raise NotImplementedError("ApiKeyAuth does not support getting current auth")
|
|
374
|
+
|
|
375
|
+
def _cli_confirm_refresh_token(self, current_auth: CurrentAuth):
|
|
376
|
+
raise NotImplementedError("ApiKeyAuth does not support confirming refresh token")
|
|
377
|
+
|
|
378
|
+
def get_current_sdk(self, **kwargs) -> Looker40SDK:
|
|
379
|
+
return init_api_key_sdk(self.api_key)
|
|
380
|
+
|
|
381
|
+
def get_current_instance(self) -> str | None:
|
|
382
|
+
raise NotImplementedError("ApiKeyAuth does not support getting current instance")
|
lkr/classes.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LogLevel(str, Enum):
|
|
9
|
+
DEBUG = "DEBUG"
|
|
10
|
+
INFO = "INFO"
|
|
11
|
+
WARNING = "WARNING"
|
|
12
|
+
ERROR = "ERROR"
|
|
13
|
+
CRITICAL = "CRITICAL"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LookerApiKey(BaseModel):
|
|
19
|
+
client_id: str = Field(..., min_length=1)
|
|
20
|
+
client_secret: str = Field(..., min_length=1)
|
|
21
|
+
base_url: str = Field(..., min_length=1)
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_env(cls):
|
|
25
|
+
try:
|
|
26
|
+
return cls(
|
|
27
|
+
client_id=os.environ.get("LOOKERSDK_CLIENT_ID"), # type: ignore
|
|
28
|
+
client_secret=os.environ.get("LOOKERSDK_CLIENT_SECRET"), # type: ignore
|
|
29
|
+
base_url=os.environ.get("LOOKERSDK_BASE_URL"), # type: ignore
|
|
30
|
+
)
|
|
31
|
+
except Exception:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LkrCtxObj(BaseModel):
|
|
36
|
+
api_key: LookerApiKey | None
|
|
37
|
+
force_oauth: bool = False
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def use_sdk(self) -> Literal["oauth", "api_key"]:
|
|
41
|
+
if self.force_oauth:
|
|
42
|
+
return "oauth"
|
|
43
|
+
return "api_key" if self.api_key else "oauth"
|
|
44
|
+
|
|
45
|
+
def __init__(self, api_key: LookerApiKey | None = None, *args, **kwargs):
|
|
46
|
+
super().__init__(api_key=api_key, *args, **kwargs)
|
|
47
|
+
if not self.api_key:
|
|
48
|
+
self.api_key = LookerApiKey.from_env()
|
lkr/constants.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import re
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Dict, List, Tuple
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
from pydash import set_
|
|
8
|
+
|
|
9
|
+
from lkr.logging import logger
|
|
10
|
+
|
|
11
|
+
MAX_SESSION_LENGTH = 2592000
|
|
12
|
+
|
|
13
|
+
PERMISSIONS = [
|
|
14
|
+
"access_data",
|
|
15
|
+
"see_user_dashboards",
|
|
16
|
+
"see_lookml_dashboards",
|
|
17
|
+
"see_looks",
|
|
18
|
+
"explore",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_user_id() -> str:
|
|
23
|
+
return "embed-" + str(random.randint(1000000000, 9999999999))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def invalid_attribute_format(attr: str) -> bool:
|
|
27
|
+
logger.error(f"Invalid attribute: {attr}")
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def check_random_int_format(val: str) -> Tuple[bool, str | None]:
|
|
32
|
+
if re.match(r"^random\.randint\(\d+,\d+\)$", val):
|
|
33
|
+
# check if # random.randint(0, 1000000) 0 and 100000 are integers
|
|
34
|
+
numbers = re.findall(r"\d+", val.split("(")[1])
|
|
35
|
+
if len(numbers) == 2:
|
|
36
|
+
return True, str(
|
|
37
|
+
random.randint(
|
|
38
|
+
int(numbers[0]),
|
|
39
|
+
int(numbers[1]),
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
else:
|
|
43
|
+
return False, None
|
|
44
|
+
else:
|
|
45
|
+
return False, None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def format_attributes(
|
|
49
|
+
attributes: List[str] = [], seperator: str = ":"
|
|
50
|
+
) -> Dict[str, str]:
|
|
51
|
+
formatted_attributes: Dict[str, str] = {}
|
|
52
|
+
if attributes:
|
|
53
|
+
for attr in attributes:
|
|
54
|
+
valid = True
|
|
55
|
+
split_attr = [x.strip() for x in attr.split(seperator) if x.strip()]
|
|
56
|
+
if len(split_attr) == 2:
|
|
57
|
+
val = split_attr[1]
|
|
58
|
+
# regex to check if for string random.randint(0,1000000)
|
|
59
|
+
is_valid, val = check_random_int_format(val)
|
|
60
|
+
if is_valid:
|
|
61
|
+
set_(split_attr, 1, val)
|
|
62
|
+
set_(formatted_attributes, [split_attr[0]], split_attr[1])
|
|
63
|
+
else:
|
|
64
|
+
valid = False
|
|
65
|
+
else:
|
|
66
|
+
valid = False
|
|
67
|
+
if valid:
|
|
68
|
+
formatted_attributes[split_attr[0]] = split_attr[1]
|
|
69
|
+
else:
|
|
70
|
+
invalid_attribute_format(attr)
|
|
71
|
+
|
|
72
|
+
return formatted_attributes
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def now():
|
|
76
|
+
return datetime.now(timezone.utc)
|
|
77
|
+
|
|
78
|
+
def ms_diff(start: datetime):
|
|
79
|
+
return int((now() - start).total_seconds() * 1000)
|
|
80
|
+
|
|
81
|
+
class LogEventResponse(BaseModel):
|
|
82
|
+
timestamp: datetime = Field(default_factory=now)
|
|
83
|
+
event_name: str
|
|
84
|
+
event_prefix: str
|
|
85
|
+
|
|
86
|
+
def make_previous(self):
|
|
87
|
+
return dict(
|
|
88
|
+
previous_event_name=self.event_name,
|
|
89
|
+
previous_event_timestamp=self.timestamp,
|
|
90
|
+
previous_event_duration_ms=ms_diff(self.timestamp),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def log_event(event_name: str, prefix: str, **kwargs):
|
|
94
|
+
if "timestamp" not in kwargs:
|
|
95
|
+
kwargs["timestamp"] = now().isoformat()
|
|
96
|
+
logger.info(
|
|
97
|
+
f"{prefix}:{event_name}",
|
|
98
|
+
**kwargs
|
|
99
|
+
)
|
|
100
|
+
return LogEventResponse(
|
|
101
|
+
timestamp=datetime.fromisoformat(kwargs["timestamp"]),
|
|
102
|
+
event_name=event_name,
|
|
103
|
+
event_prefix=prefix
|
|
104
|
+
)
|
lkr/exceptions.py
ADDED
lkr/logging.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
import structlog
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.logging import RichHandler
|
|
7
|
+
from rich.theme import Theme
|
|
8
|
+
|
|
9
|
+
from lkr.types import LogLevel
|
|
10
|
+
|
|
11
|
+
# Define a custom theme for our logging
|
|
12
|
+
theme = Theme({
|
|
13
|
+
"logging.level.debug": "dim blue",
|
|
14
|
+
"logging.level.info": "bold green",
|
|
15
|
+
"logging.level.warning": "bold yellow",
|
|
16
|
+
"logging.level.error": "bold red",
|
|
17
|
+
"logging.level.critical": "bold white on red",
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
# Create a console for logging
|
|
21
|
+
console = Console(theme=theme)
|
|
22
|
+
|
|
23
|
+
# Configure the logging handler
|
|
24
|
+
handler = RichHandler(
|
|
25
|
+
console=console,
|
|
26
|
+
show_time=True,
|
|
27
|
+
show_path=True,
|
|
28
|
+
markup=True,
|
|
29
|
+
rich_tracebacks=True,
|
|
30
|
+
tracebacks_show_locals=True,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Get log level from environment variable, defaulting to INFO
|
|
34
|
+
DEFAULT_LOG_LEVEL = "INFO"
|
|
35
|
+
log_level = os.getenv("LOG_LEVEL", DEFAULT_LOG_LEVEL).upper()
|
|
36
|
+
|
|
37
|
+
# Configure the root logger
|
|
38
|
+
logging.basicConfig(
|
|
39
|
+
level=getattr(logging, log_level, logging.INFO), # Fallback to INFO if invalid level
|
|
40
|
+
format="%(message)s",
|
|
41
|
+
datefmt="[%X]",
|
|
42
|
+
handlers=[handler],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Create a logger for the application
|
|
46
|
+
logger = logging.getLogger("lkr")
|
|
47
|
+
structured_logger = structlog.get_logger("lkr.structured")
|
|
48
|
+
|
|
49
|
+
# Configure the requests_transport logger to only show debug messages when LOG_LEVEL is DEBUG
|
|
50
|
+
requests_logger = logging.getLogger("looker_sdk.rtl.requests_transport")
|
|
51
|
+
if log_level != "DEBUG":
|
|
52
|
+
requests_logger.setLevel(logging.WARNING)
|
|
53
|
+
|
|
54
|
+
def set_log_level(level: LogLevel):
|
|
55
|
+
"""Set the logging level for the application."""
|
|
56
|
+
logger.setLevel(getattr(logging, level.value))
|
|
57
|
+
structured_logger.setLevel(getattr(logging, level.value))
|
|
58
|
+
# Update requests_transport logger level based on the new level
|
|
59
|
+
requests_logger.setLevel(logging.DEBUG if level == LogLevel.DEBUG else logging.WARNING)
|
lkr/main.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from lkr.auth.main import group as auth_group
|
|
7
|
+
from lkr.classes import LkrCtxObj, LogLevel
|
|
8
|
+
from lkr.logging import logger
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(
|
|
11
|
+
name="lkr", help="LookML Repository CLI", add_completion=True, no_args_is_help=True
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
app.add_typer(auth_group, name="auth")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.callback()
|
|
18
|
+
def callback(
|
|
19
|
+
ctx: typer.Context,
|
|
20
|
+
client_id: Annotated[str | None, typer.Option(envvar="LOOKERSDK_CLIENT_ID")] = None,
|
|
21
|
+
client_secret: Annotated[
|
|
22
|
+
str | None, typer.Option(envvar="LOOKERSDK_CLIENT_SECRET")
|
|
23
|
+
] = None,
|
|
24
|
+
base_url: Annotated[str | None, typer.Option(envvar="LOOKERSDK_BASE_URL")] = None,
|
|
25
|
+
log_level: Annotated[LogLevel | None, typer.Option(envvar="LOG_LEVEL")] = None,
|
|
26
|
+
quiet: Annotated[bool, typer.Option("--quiet")] = False,
|
|
27
|
+
force_oauth: Annotated[bool, typer.Option("--force-oauth")] = False,
|
|
28
|
+
):
|
|
29
|
+
if client_id:
|
|
30
|
+
os.environ["LOOKERSDK_CLIENT_ID"] = client_id
|
|
31
|
+
logger.debug("Set LOOKERSDK_CLIENT_ID from command line")
|
|
32
|
+
if client_secret:
|
|
33
|
+
os.environ["LOOKERSDK_CLIENT_SECRET"] = client_secret
|
|
34
|
+
logger.debug("Set LOOKERSDK_CLIENT_SECRET from command line")
|
|
35
|
+
if base_url:
|
|
36
|
+
os.environ["LOOKERSDK_BASE_URL"] = base_url
|
|
37
|
+
logger.debug("Set LOOKERSDK_BASE_URL from command line")
|
|
38
|
+
# Initialize ctx.obj as a dictionary if it's None
|
|
39
|
+
if ctx.obj is None:
|
|
40
|
+
ctx.obj = {}
|
|
41
|
+
ctx.obj["ctx_lkr"] = LkrCtxObj(force_oauth=force_oauth)
|
|
42
|
+
if log_level:
|
|
43
|
+
from lkr.logging import set_log_level
|
|
44
|
+
set_log_level(log_level)
|
|
45
|
+
if quiet:
|
|
46
|
+
from lkr.logging import set_log_level
|
|
47
|
+
set_log_level(LogLevel.ERROR)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
app()
|
lkr/types.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Callable, Union
|
|
3
|
+
|
|
4
|
+
from looker_sdk.rtl.auth_token import AccessToken, AuthToken
|
|
5
|
+
|
|
6
|
+
NewTokenCallback = Callable[[Union[AuthToken, AccessToken]], None]
|
|
7
|
+
|
|
8
|
+
class LogLevel(str, Enum):
|
|
9
|
+
DEBUG = "DEBUG"
|
|
10
|
+
INFO = "INFO"
|
|
11
|
+
WARNING = "WARNING"
|
|
12
|
+
ERROR = "ERROR"
|
|
13
|
+
CRITICAL = "CRITICAL"
|
lkr/utils.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lkr-dev-cli
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: lkr: a command line interface for looker
|
|
5
|
+
Author: bwebs
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Requires-Dist: cryptography>=42.0.0
|
|
10
|
+
Requires-Dist: looker-sdk>=25.4.0
|
|
11
|
+
Requires-Dist: pick>=2.4.0
|
|
12
|
+
Requires-Dist: pydantic>=2.11.4
|
|
13
|
+
Requires-Dist: pydash>=8.0.5
|
|
14
|
+
Requires-Dist: requests>=2.31.0
|
|
15
|
+
Requires-Dist: structlog>=25.3.0
|
|
16
|
+
Requires-Dist: typer>=0.15.2
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# lkr cli
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
lkr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
lkr/auth_service.py,sha256=O8a0ZqNHnQNEZAbXXTQdbYx9V9hv_VVqvZCCC-s2EOs,14623
|
|
3
|
+
lkr/classes.py,sha256=Exz4jIA3GIfsebXrakDqrWJzFgpDcO6HBenMitkrj6g,1305
|
|
4
|
+
lkr/constants.py,sha256=DdCfsV6q8wgs2iHpIQeb6oDP_2XejusEHyPvCbaM3yY,108
|
|
5
|
+
lkr/exceptions.py,sha256=M_aR4YaCZtY4wyxhcoqJCVkxVu9z3Wwo5KgSDyOoEnI,210
|
|
6
|
+
lkr/logging.py,sha256=cuqIiYUVTAknWLjcu7eiu8Ji7Pl6o7nWLLUJeoKFtMs,1804
|
|
7
|
+
lkr/main.py,sha256=hVUEk2Acn2iD46dR6kDUADbhMH3wDFVyH-vyw6t0hpA,1708
|
|
8
|
+
lkr/types.py,sha256=feJ-W2U61PJTiotMLuZJqxrotA53er95kO1O30mooy4,323
|
|
9
|
+
lkr/utils.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
lkr/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
lkr/auth/main.py,sha256=qKC7qnzFR_yYcplA9oWksrhTpNnktg4mP_glS7YBFsU,7103
|
|
12
|
+
lkr/auth/oauth.py,sha256=S57mJdp66Q-MXvT8K1_mzhw6xu0kJZ3gR11mDrNdjRI,5261
|
|
13
|
+
lkr/embed/observability/utils.py,sha256=Hv3g60cI03cQwyEe8QV7bUMssE6pyrZKnDyl9rxm5b8,2915
|
|
14
|
+
lkr_dev_cli-0.0.0.dist-info/METADATA,sha256=BTAYb6jwCAfxGleSZxKQCUBFtUvVgelyGP_CV4eezAA,491
|
|
15
|
+
lkr_dev_cli-0.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
16
|
+
lkr_dev_cli-0.0.0.dist-info/entry_points.txt,sha256=nn2sFMGDpwUVE61ZUpbDPnQZkW7Gc08nV-tyLGo8q34,37
|
|
17
|
+
lkr_dev_cli-0.0.0.dist-info/licenses/LICENSE,sha256=hKnCOORW1JRE_M2vStz8dblS5u1iR-2VpqS9xagKNa0,1063
|
|
18
|
+
lkr_dev_cli-0.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 lkrdev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|