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 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,3 @@
1
+ OAUTH_CLIENT_ID = "lkr-cli"
2
+ OAUTH_REDIRECT_URI = "http://localhost:8000/callback"
3
+ LOOKER_API_VERSION = "4.0"
@@ -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
@@ -0,0 +1,4 @@
1
+ class InvalidRefreshTokenError(Exception):
2
+ def __init__(self, instance_name: str):
3
+ self.instance_name = instance_name
4
+ super().__init__(f"Invalid refresh token for instance {instance_name}")
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lkr = lkr.main:app
@@ -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.