lkr-dev-cli 0.0.22__py3-none-any.whl → 0.0.24__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/auth/main.py CHANGED
@@ -9,27 +9,31 @@ from rich.table import Table
9
9
 
10
10
  from lkr.auth.oauth import OAuth2PKCE
11
11
  from lkr.auth_service import get_auth
12
- from lkr.logging import logger
12
+ from lkr.logger import logger
13
13
 
14
14
  __all__ = ["group"]
15
15
 
16
16
  group = typer.Typer(name="auth", help="Authentication commands for LookML Repository")
17
17
 
18
+
18
19
  @group.callback()
19
20
  def callback(ctx: typer.Context):
20
21
  if ctx.invoked_subcommand == "whoami":
21
22
  return
22
- if ctx.obj['ctx_lkr'].use_sdk == "api_key":
23
+ if ctx.obj["ctx_lkr"].use_sdk == "api_key":
23
24
  logger.error("API key authentication is not supported for auth commands")
24
25
  raise typer.Exit(1)
25
26
 
27
+
26
28
  @group.command()
27
29
  def login(
28
30
  ctx: typer.Context,
29
31
  instance_name: Annotated[
30
32
  str | None,
31
33
  typer.Option(
32
- "-I", "--instance-name", help="Name of the Looker instance to login or switch to"
34
+ "-I",
35
+ "--instance-name",
36
+ help="Name of the Looker instance to login or switch to",
33
37
  ),
34
38
  ] = None,
35
39
  ):
@@ -38,7 +42,7 @@ def login(
38
42
  """
39
43
  auth = get_auth(ctx)
40
44
  all_instances = auth.list_auth()
41
-
45
+
42
46
  def do_switch(instance_name: str):
43
47
  auth.set_current_instance(instance_name)
44
48
  sdk = auth.get_current_sdk()
@@ -61,17 +65,19 @@ def login(
61
65
  else:
62
66
  options: List[questionary.Choice] = []
63
67
  max_name_length = 0
64
- for (name, url, current, up) in all_instances:
68
+ for name, url, current, up in all_instances:
65
69
  max_name_length = max(max_name_length, len(name))
66
70
  options = [
67
- questionary.Choice(title=f"{name:{max_name_length}} ({url})", value=name, checked=current)
71
+ questionary.Choice(
72
+ title=f"{name:{max_name_length}} ({url})", value=name, checked=current
73
+ )
68
74
  for name, url, current, up in all_instances
69
75
  ]
70
- options.append(questionary.Choice(title="+ Add new instance", value="_add_new_instance"))
76
+ options.append(
77
+ questionary.Choice(title="+ Add new instance", value="_add_new_instance")
78
+ )
71
79
  picked = questionary.select(
72
- "Select instance to login/switch to",
73
- choices=options,
74
- pointer=">"
80
+ "Select instance to login/switch to", choices=options, pointer=">"
75
81
  ).ask()
76
82
  if picked != "_add_new_instance":
77
83
  do_switch(picked)
@@ -88,17 +94,19 @@ def login(
88
94
  )
89
95
  use_production = typer.confirm("Use production mode?", default=False)
90
96
  instance_name = typer.prompt(
91
- "Enter a name for this Looker instance", default=f"{'dev' if not use_production else 'prod'}-{parsed_url.netloc}"
97
+ "Enter a name for this Looker instance",
98
+ default=f"{'dev' if not use_production else 'prod'}-{parsed_url.netloc}",
92
99
  )
93
100
  # Ensure instance_name is str, not None
94
- assert instance_name is not None, "Instance name must be set before adding auth."
101
+ assert instance_name is not None, (
102
+ "Instance name must be set before adding auth."
103
+ )
95
104
 
96
105
  def auth_callback(token: Union[AuthToken, AccessToken]):
97
106
  auth.add_auth(instance_name, origin, token, use_production)
98
-
107
+
99
108
  oauth = OAuth2PKCE(
100
- new_token_callback=auth_callback,
101
- use_production=use_production
109
+ new_token_callback=auth_callback, use_production=use_production
102
110
  )
103
111
  logger.info(f"Opening browser for authentication at {origin + '/auth'}...")
104
112
  login_response = oauth.initiate_login(origin)
@@ -114,13 +122,16 @@ def login(
114
122
  logger.error("Failed to exchange authorization code for tokens")
115
123
  raise typer.Exit(1)
116
124
  except Exception as e:
117
- logger.error(f"Failed to exchange authorization code for tokens: {str(e)}")
125
+ logger.error(
126
+ f"Failed to exchange authorization code for tokens: {str(e)}"
127
+ )
118
128
  raise typer.Exit(1)
119
129
  else:
120
130
  logger.error("Failed to receive authorization code")
121
131
  raise typer.Exit(1)
122
132
  do_switch(instance_name)
123
133
 
134
+
124
135
  @group.command()
125
136
  def logout(
126
137
  ctx: typer.Context,
@@ -132,9 +143,7 @@ def logout(
132
143
  ] = None,
133
144
  all: Annotated[
134
145
  bool,
135
- typer.Option(
136
- "--all", help="Logout from all instances"
137
- ),
146
+ typer.Option("--all", help="Logout from all instances"),
138
147
  ] = False,
139
148
  ):
140
149
  """
@@ -156,7 +165,7 @@ def logout(
156
165
  logger.info("Logout cancelled")
157
166
  raise typer.Exit()
158
167
 
159
- if instance_name:
168
+ if instance_name:
160
169
  logger.info(f"Logging out from instance: {instance_name}")
161
170
  auth.delete_auth(instance_name=instance_name)
162
171
  else:
@@ -198,7 +207,12 @@ def list(ctx: typer.Context):
198
207
  raise typer.Exit(1)
199
208
  table = Table(" ", "Instance", "URL", "Production")
200
209
  for instance in all_instances:
201
- table.add_row("*" if instance[2] else " ", instance[0], instance[1], "Yes" if instance[3] else "No")
210
+ table.add_row(
211
+ "*" if instance[2] else " ",
212
+ instance[0],
213
+ instance[1],
214
+ "Yes" if instance[3] else "No",
215
+ )
202
216
  console.print(table)
203
217
 
204
218
 
lkr/auth/oauth.py CHANGED
@@ -10,7 +10,7 @@ import webbrowser
10
10
  from typing import Optional, TypedDict
11
11
  from urllib.parse import parse_qs
12
12
 
13
- from lkr.types import NewTokenCallback
13
+ from lkr.custom_types import NewTokenCallback
14
14
 
15
15
 
16
16
  def kill_process_on_port(port: int, retries: int = 5, delay: float = 1) -> None:
@@ -19,21 +19,23 @@ def kill_process_on_port(port: int, retries: int = 5, delay: float = 1) -> None:
19
19
  # Try to create a socket binding to check if port is in use
20
20
  sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
21
21
  sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
22
- sock.bind(('localhost', port))
22
+ sock.bind(("localhost", port))
23
23
  sock.close()
24
24
  return # Port is free, no need to kill anything
25
25
  except socket.error:
26
26
  # Port is in use, try to kill the process
27
- if os.name == 'posix': # macOS/Linux
28
- os.system(f'lsof -ti tcp:{port} | xargs kill -9 2>/dev/null')
29
- elif os.name == 'nt': # Windows
30
- os.system(f'for /f "tokens=5" %a in (\'netstat -aon ^| find ":{port}"\') do taskkill /F /PID %a 2>nul')
27
+ if os.name == "posix": # macOS/Linux
28
+ os.system(f"lsof -ti tcp:{port} | xargs kill -9 2>/dev/null")
29
+ elif os.name == "nt": # Windows
30
+ os.system(
31
+ f'for /f "tokens=5" %a in (\'netstat -aon ^| find ":{port}"\') do taskkill /F /PID %a 2>nul'
32
+ )
31
33
  # After killing, wait for the port to be free
32
34
  for _ in range(retries):
33
35
  try:
34
36
  sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
35
37
  sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
36
- sock.bind(('localhost', port))
38
+ sock.bind(("localhost", port))
37
39
  sock.close()
38
40
  return
39
41
  except socket.error:
@@ -45,19 +47,21 @@ class OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
45
47
  def do_GET(self):
46
48
  """Handle the callback from OAuth authorization"""
47
49
  self.send_response(200)
48
- self.send_header('Content-type', 'text/html')
50
+ self.send_header("Content-type", "text/html")
49
51
  self.end_headers()
50
-
52
+
51
53
  # Parse the authorization code from query parameters
52
54
  query_components = parse_qs(urllib.parse.urlparse(self.path).query)
53
-
55
+
54
56
  # Store the code in the server instance
55
- if 'code' in query_components:
56
- self.server.auth_code = query_components['code'][0] # type: ignore
57
-
57
+ if "code" in query_components:
58
+ self.server.auth_code = query_components["code"][0] # type: ignore
59
+
58
60
  # Display a success message to the user
59
- self.wfile.write(b"Authentication successful! You can close this window.")
60
-
61
+ self.wfile.write(
62
+ b"Successfully authenticated to Looker OAuth! You can close this window."
63
+ )
64
+
61
65
  # Shutdown the server
62
66
  threading.Thread(target=self.server.shutdown).start()
63
67
 
@@ -65,18 +69,22 @@ class OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
65
69
  """Suppress logging of requests"""
66
70
  pass
67
71
 
72
+
68
73
  class OAuthCallbackServer(socketserver.TCPServer):
69
74
  def __init__(self, server_address):
70
75
  super().__init__(server_address, OAuthCallbackHandler)
71
76
  self.auth_code: str | None = None
72
77
 
78
+
73
79
  class LoginResponse(TypedDict):
74
80
  auth_code: Optional[str]
75
81
  code_verifier: Optional[str]
76
82
 
83
+
77
84
  class OAuth2PKCE:
78
85
  def __init__(self, new_token_callback: NewTokenCallback, use_production: bool):
79
86
  from lkr.auth_service import DbOAuthSession
87
+
80
88
  self.auth_code: Optional[str] = None
81
89
  self.state = secrets.token_urlsafe(16)
82
90
  self.new_token_callback: NewTokenCallback = new_token_callback
@@ -85,7 +93,7 @@ class OAuth2PKCE:
85
93
  self.server: OAuthCallbackServer | None = None
86
94
  self.port: int = 8000
87
95
  self.use_production: bool = use_production
88
-
96
+
89
97
  def cleanup(self):
90
98
  """Clean up the server and its thread."""
91
99
  if self.server:
@@ -100,51 +108,91 @@ class OAuth2PKCE:
100
108
  """
101
109
  Initiates the OAuth2 PKCE login flow by opening the browser with the authorization URL
102
110
  and starting a local server to catch the callback.
103
-
111
+
104
112
  Returns:
105
113
  Optional[str]: The authorization code if successful, None otherwise
106
114
  """
107
115
  from lkr.auth_service import get_auth_session
108
-
116
+
109
117
  # Kill any process using port 8000
110
118
  kill_process_on_port(self.port)
111
-
112
- # Start the local server
113
- self.server = OAuthCallbackServer(('localhost', self.port))
114
-
119
+
120
+ # Wait until the port is actually free and we can bind the real server (up to 20 seconds)
121
+ server_created = False
122
+ last_exception = None
123
+ for _ in range(20): # 20 x 1s = 20s
124
+ try:
125
+ self.server = OAuthCallbackServer(("localhost", self.port))
126
+ server_created = True
127
+ break
128
+ except OSError as e:
129
+ if getattr(e, "errno", None) == 48 or "Address already in use" in str(
130
+ e
131
+ ):
132
+ last_exception = e
133
+ time.sleep(1)
134
+ else:
135
+ raise
136
+ if not server_created:
137
+ import logging
138
+
139
+ logging.error(
140
+ f"Failed to bind to port {self.port} after waiting: {last_exception}"
141
+ )
142
+ raise RuntimeError(
143
+ f"Failed to bind to port {self.port} after waiting: {last_exception}"
144
+ )
145
+
115
146
  # Start the server in a separate thread
147
+ if self.server is None:
148
+ raise RuntimeError("Internal error: server was not created successfully.")
116
149
  self.server_thread = threading.Thread(target=self.server.serve_forever)
117
150
  self.server_thread.daemon = True
118
151
  self.server_thread.start()
119
152
 
120
153
  # Construct and open the OAuth URL
121
- self.auth_session = get_auth_session(base_url, self.new_token_callback, use_production=self.use_production)
122
- oauth_url = self.auth_session.create_auth_code_request_url('cors_api', self.state)
123
-
154
+ self.auth_session = get_auth_session(
155
+ base_url, self.new_token_callback, use_production=self.use_production
156
+ )
157
+ oauth_url = self.auth_session.create_auth_code_request_url(
158
+ "cors_api", self.state
159
+ )
160
+
124
161
  webbrowser.open(oauth_url)
125
-
162
+
126
163
  # Wait for the callback
127
164
  self.server_thread.join()
128
-
165
+
129
166
  # Get the authorization code
130
- return LoginResponse(auth_code=self.server.auth_code, code_verifier=self.auth_session.code_verifier)
167
+ if self.server is None:
168
+ raise RuntimeError("Internal error: server was not created successfully.")
169
+ return LoginResponse(
170
+ auth_code=self.server.auth_code,
171
+ code_verifier=self.auth_session.code_verifier,
172
+ )
131
173
 
132
174
  def exchange_code_for_token(self):
133
175
  """
134
176
  Exchange the authorization code for access and refresh tokens.
135
-
177
+
136
178
  Args:
137
179
  base_url: The base URL of the Looker instance
138
180
  client_id: The OAuth client ID
139
-
181
+
140
182
  Returns:
141
183
  Dict containing access_token, refresh_token, token_type, and expires_in
142
184
  """
143
185
  if not self.auth_code:
144
- raise ValueError("No authorization code available. Must call initiate_login first.")
186
+ raise ValueError(
187
+ "No authorization code available. Must call initiate_login first."
188
+ )
145
189
  if not self.auth_session:
146
- raise ValueError("No auth session available. Must call initiate_login first.")
147
- self.auth_session.redeem_auth_code(self.auth_code, self.auth_session.code_verifier)
190
+ raise ValueError(
191
+ "No auth session available. Must call initiate_login first."
192
+ )
193
+ self.auth_session.redeem_auth_code(
194
+ self.auth_code, self.auth_session.code_verifier
195
+ )
148
196
  self.cleanup()
149
197
  return self.auth_session.token
150
198
 
lkr/auth_service.py CHANGED
@@ -1,26 +1,28 @@
1
1
  import json
2
2
  import os
3
3
  import sqlite3
4
+ import types
4
5
  from datetime import datetime, timedelta, timezone
5
6
  from typing import List, Self, Tuple, Union
6
7
 
8
+ import requests
7
9
  import typer
8
10
  from looker_sdk.rtl import serialize
9
11
  from looker_sdk.rtl.api_settings import ApiSettings, SettingsConfig
10
12
  from looker_sdk.rtl.auth_session import AuthSession, CryptoHash, OAuthSession
11
13
  from looker_sdk.rtl.auth_token import AccessToken, AuthToken
12
14
  from looker_sdk.rtl.requests_transport import RequestsTransport
13
- from looker_sdk.rtl.transport import HttpMethod
15
+ from looker_sdk.rtl.transport import LOOKER_API_ID, HttpMethod
14
16
  from looker_sdk.sdk.api40.methods import Looker40SDK
15
17
  from pydantic import BaseModel, Field, computed_field
16
18
  from pydash import get
17
19
 
18
20
  from lkr.classes import LkrCtxObj, LookerApiKey
19
21
  from lkr.constants import LOOKER_API_VERSION, OAUTH_CLIENT_ID, OAUTH_REDIRECT_URI
20
- from lkr.logging import logger
21
- from lkr.types import NewTokenCallback
22
+ from lkr.custom_types import NewTokenCallback
23
+ from lkr.logger import logger
22
24
 
23
- __all__ = ["get_auth"]
25
+ __all__ = ["get_auth", "ApiKeyAuthSession", "DbOAuthSession"]
24
26
 
25
27
 
26
28
  def get_auth(ctx: typer.Context | LkrCtxObj) -> Union["SqlLiteAuth", "ApiKeyAuth"]:
@@ -33,7 +35,7 @@ def get_auth(ctx: typer.Context | LkrCtxObj) -> Union["SqlLiteAuth", "ApiKeyAuth
33
35
  raise typer.Exit(1)
34
36
  if lkr_ctx.use_sdk == "api_key" and lkr_ctx.api_key:
35
37
  logger.info("Using API key authentication")
36
- return ApiKeyAuth(lkr_ctx.api_key)
38
+ return ApiKeyAuth(lkr_ctx.api_key, use_production=lkr_ctx.use_production)
37
39
  else:
38
40
  return SqlLiteAuth(lkr_ctx)
39
41
 
@@ -43,7 +45,6 @@ class ApiKeyApiSettings(ApiSettings):
43
45
  self.api_key = api_key
44
46
  super().__init__()
45
47
  self.agent_tag = "lkr-cli-api-key"
46
- self.headers = {"X-Looker-AppId": "lkr-cli"}
47
48
 
48
49
  def read_config(self) -> SettingsConfig:
49
50
  return SettingsConfig(
@@ -58,7 +59,6 @@ class OAuthApiSettings(ApiSettings):
58
59
  self.base_url = base_url
59
60
  super().__init__()
60
61
  self.agent_tag = "lkr-cli-oauth"
61
- self.headers = {"X-Looker-AppId": "lkr-cli"}
62
62
 
63
63
  def read_config(self) -> SettingsConfig:
64
64
  return SettingsConfig(
@@ -70,6 +70,36 @@ class OAuthApiSettings(ApiSettings):
70
70
  )
71
71
 
72
72
 
73
+ class ApiKeyAuthSession(AuthSession):
74
+ def __init__(self, *args, use_production: bool, **kwargs):
75
+ super().__init__(*args, **kwargs)
76
+ self.use_production = use_production
77
+
78
+ def _login(self, *args, **kwargs):
79
+ super()._login(*args, **kwargs)
80
+ if not self.use_production:
81
+ self._switch_to_dev_mode()
82
+
83
+ def _switch_to_dev_mode(self):
84
+ logger.debug("Switching to dev mode")
85
+ config = self.settings.read_config()
86
+ if "base_url" in config:
87
+ url = f"{config['base_url']}/api/{LOOKER_API_VERSION}/session"
88
+ return self.transport.request(
89
+ method=HttpMethod.PATCH,
90
+ path=url,
91
+ body=json.dumps({"workspace_id": "dev"}).encode("utf-8"),
92
+ transport_options={
93
+ "headers": {
94
+ "Content-Type": "application/json",
95
+ "Authorization": f"Bearer {self.token.access_token}",
96
+ }
97
+ },
98
+ )
99
+ else:
100
+ raise ValueError("Base URL not found in settings")
101
+
102
+
73
103
  class DbOAuthSession(OAuthSession):
74
104
  def __init__(
75
105
  self,
@@ -121,7 +151,7 @@ def get_auth_session(
121
151
  access_token: AccessToken | None = None,
122
152
  ) -> DbOAuthSession:
123
153
  settings = OAuthApiSettings(base_url)
124
- transport = RequestsTransport.configure(settings)
154
+ transport = MonkeyPatchTransport.configure(settings)
125
155
  auth = DbOAuthSession(
126
156
  settings=settings,
127
157
  transport=transport,
@@ -137,18 +167,19 @@ def get_auth_session(
137
167
  return auth
138
168
 
139
169
 
140
- def init_api_key_sdk(api_key: LookerApiKey) -> Looker40SDK:
170
+ def init_api_key_sdk(api_key: LookerApiKey, use_production: bool) -> Looker40SDK:
141
171
  from looker_sdk.rtl import serialize
142
172
 
143
173
  settings = ApiKeyApiSettings(api_key)
144
174
  settings.is_configured()
145
175
  transport = RequestsTransport.configure(settings)
146
176
  return Looker40SDK(
147
- auth=AuthSession(
177
+ auth=ApiKeyAuthSession(
148
178
  settings,
149
179
  transport,
150
180
  serialize.deserialize40, # type: ignore
151
181
  LOOKER_API_VERSION,
182
+ use_production=use_production,
152
183
  ),
153
184
  deserialize=serialize.deserialize40, # type: ignore
154
185
  serialize=serialize.serialize40, # type: ignore
@@ -157,6 +188,29 @@ def init_api_key_sdk(api_key: LookerApiKey) -> Looker40SDK:
157
188
  )
158
189
 
159
190
 
191
+ # monkey patch to remove the LOOKER_API_ID header when exchanging the code for a token
192
+ def monkey_patch_prepare_request(session: requests.Session):
193
+ original_prepare_request = session.prepare_request
194
+
195
+ def prepare_request(self, request, *args, **kwargs):
196
+ x = original_prepare_request(request, *args, **kwargs)
197
+ if (
198
+ x.headers.get(LOOKER_API_ID)
199
+ and x.path_url.endswith("/api/token")
200
+ and request.method == "POST"
201
+ ):
202
+ x.headers.pop(LOOKER_API_ID)
203
+ return x
204
+
205
+ session.prepare_request = types.MethodType(prepare_request, session)
206
+
207
+
208
+ class MonkeyPatchTransport(RequestsTransport):
209
+ def __init__(self, *args, **kwargs):
210
+ super().__init__(*args, **kwargs)
211
+ monkey_patch_prepare_request(self.session)
212
+
213
+
160
214
  def init_oauth_sdk(
161
215
  base_url: str,
162
216
  new_token_callback: NewTokenCallback,
@@ -167,7 +221,8 @@ def init_oauth_sdk(
167
221
  """Default dependency configuration"""
168
222
  settings = OAuthApiSettings(base_url)
169
223
  settings.is_configured()
170
- transport = RequestsTransport.configure(settings)
224
+ transport = MonkeyPatchTransport.configure(settings)
225
+
171
226
  auth = get_auth_session(
172
227
  base_url,
173
228
  new_token_callback,
@@ -395,9 +450,7 @@ class SqlLiteAuth:
395
450
  return current_auth.instance_name
396
451
  return None
397
452
 
398
- def get_current_sdk(
399
- self, prompt_refresh_invalid_token: bool = False
400
- ) -> Looker40SDK:
453
+ def get_current_sdk(self, prompt_refresh_invalid_token: bool = True) -> Looker40SDK:
401
454
  current_auth = self._get_current_auth()
402
455
  if current_auth:
403
456
  if not current_auth.valid_refresh_token:
@@ -471,7 +524,7 @@ class SqlLiteAuth:
471
524
  if not token:
472
525
  raise InvalidRefreshTokenError(current_auth.instance_name)
473
526
  else:
474
- from lkr.logging import logger
527
+ from lkr.logger import logger
475
528
 
476
529
  logger.info(
477
530
  f"Successfully refreshed token for {current_auth.instance_name}"
@@ -480,8 +533,9 @@ class SqlLiteAuth:
480
533
 
481
534
 
482
535
  class ApiKeyAuth:
483
- def __init__(self, api_key: LookerApiKey):
536
+ def __init__(self, api_key: LookerApiKey, use_production: bool):
484
537
  self.api_key = api_key
538
+ self.use_production = use_production
485
539
 
486
540
  def __enter__(self):
487
541
  return self
@@ -518,7 +572,7 @@ class ApiKeyAuth:
518
572
  )
519
573
 
520
574
  def get_current_sdk(self, **kwargs) -> Looker40SDK:
521
- return init_api_key_sdk(self.api_key)
575
+ return init_api_key_sdk(self.api_key, self.use_production)
522
576
 
523
577
  def get_current_instance(self) -> str | None:
524
578
  raise NotImplementedError(
lkr/classes.py CHANGED
@@ -13,24 +13,25 @@ class LookerApiKey(BaseModel):
13
13
  def from_env(cls):
14
14
  try:
15
15
  return cls(
16
- client_id=os.environ.get("LOOKERSDK_CLIENT_ID"), # type: ignore
17
- client_secret=os.environ.get("LOOKERSDK_CLIENT_SECRET"), # type: ignore
18
- base_url=os.environ.get("LOOKERSDK_BASE_URL"), # type: ignore
16
+ client_id=os.environ.get("LOOKERSDK_CLIENT_ID"), # type: ignore
17
+ client_secret=os.environ.get("LOOKERSDK_CLIENT_SECRET"), # type: ignore
18
+ base_url=os.environ.get("LOOKERSDK_BASE_URL"), # type: ignore
19
19
  )
20
20
  except Exception:
21
21
  return None
22
22
 
23
-
23
+
24
24
  class LkrCtxObj(BaseModel):
25
25
  api_key: LookerApiKey | None
26
26
  force_oauth: bool = False
27
-
27
+ use_production: bool = True
28
+
28
29
  @property
29
30
  def use_sdk(self) -> Literal["oauth", "api_key"]:
30
31
  if self.force_oauth:
31
32
  return "oauth"
32
33
  return "api_key" if self.api_key else "oauth"
33
-
34
+
34
35
  def __init__(self, api_key: LookerApiKey | None = None, *args, **kwargs):
35
36
  super().__init__(api_key=api_key, *args, **kwargs)
36
37
  if not self.api_key:
@@ -6,16 +6,18 @@ from rich.console import Console
6
6
  from rich.logging import RichHandler
7
7
  from rich.theme import Theme
8
8
 
9
- from lkr.types import LogLevel
9
+ from lkr.custom_types import LogLevel
10
10
 
11
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
- })
12
+ theme = Theme(
13
+ {
14
+ "logging.level.debug": "dim blue",
15
+ "logging.level.info": "bold green",
16
+ "logging.level.warning": "bold yellow",
17
+ "logging.level.error": "bold red",
18
+ "logging.level.critical": "bold white on red",
19
+ }
20
+ )
19
21
 
20
22
  # Create a console for logging
21
23
  console = Console(theme=theme)
@@ -36,7 +38,9 @@ log_level = os.getenv("LOG_LEVEL", DEFAULT_LOG_LEVEL).upper()
36
38
 
37
39
  # Configure the root logger
38
40
  logging.basicConfig(
39
- level=getattr(logging, log_level, logging.INFO), # Fallback to INFO if invalid level
41
+ level=getattr(
42
+ logging, log_level, logging.INFO
43
+ ), # Fallback to INFO if invalid level
40
44
  format="%(message)s",
41
45
  datefmt="[%X]",
42
46
  handlers=[handler],
@@ -51,9 +55,12 @@ requests_logger = logging.getLogger("looker_sdk.rtl.requests_transport")
51
55
  if log_level != "DEBUG":
52
56
  requests_logger.setLevel(logging.WARNING)
53
57
 
58
+
54
59
  def set_log_level(level: LogLevel):
55
60
  """Set the logging level for the application."""
56
61
  logger.setLevel(getattr(logging, level.value))
57
62
  logging.getLogger("lkr.structured").setLevel(getattr(logging, level.value))
58
63
  # Update requests_transport logger level based on the new level
59
- requests_logger.setLevel(logging.DEBUG if level == LogLevel.DEBUG else logging.WARNING)
64
+ requests_logger.setLevel(
65
+ logging.DEBUG if level == LogLevel.DEBUG else logging.WARNING
66
+ )
lkr/main.py CHANGED
@@ -1,14 +1,14 @@
1
1
  import os
2
- from typing import Annotated
2
+ from typing import Annotated, Optional
3
3
 
4
4
  import typer
5
5
 
6
6
  from lkr.auth.main import group as auth_group
7
7
  from lkr.classes import LkrCtxObj
8
- from lkr.logging import logger
8
+ from lkr.custom_types import LogLevel
9
+ from lkr.logger import logger
9
10
  from lkr.mcp.main import group as mcp_group
10
11
  from lkr.observability.main import group as observability_group
11
- from lkr.types import LogLevel
12
12
 
13
13
  app = typer.Typer(
14
14
  name="lkr", help="LookML Repository CLI", add_completion=True, no_args_is_help=True
@@ -30,6 +30,7 @@ def callback(
30
30
  log_level: Annotated[LogLevel | None, typer.Option(envvar="LOG_LEVEL")] = None,
31
31
  quiet: Annotated[bool, typer.Option("--quiet")] = False,
32
32
  force_oauth: Annotated[bool, typer.Option("--force-oauth")] = False,
33
+ dev: Annotated[Optional[bool], typer.Option("--dev")] = None,
33
34
  ):
34
35
  if client_id:
35
36
  os.environ["LOOKERSDK_CLIENT_ID"] = client_id
@@ -43,13 +44,22 @@ def callback(
43
44
  # Initialize ctx.obj as a dictionary if it's None
44
45
  if ctx.obj is None:
45
46
  ctx.obj = {}
46
- ctx.obj["ctx_lkr"] = LkrCtxObj(force_oauth=force_oauth)
47
+
48
+ ctx_obj = LkrCtxObj(
49
+ force_oauth=force_oauth,
50
+ use_production=not dev if dev is not None else True,
51
+ )
52
+ ctx.obj["ctx_lkr"] = ctx_obj
53
+ # if the user passes --dev, but lkrCtxObj.use_sdk is oauth, then we need to log a warning saying we're ignoring the --dev flag
54
+ if dev and ctx_obj.use_sdk == "oauth":
55
+ logger.warning("Ignoring --dev flag because OAuth token tracks dev/prod mode.")
56
+
47
57
  if log_level:
48
- from lkr.logging import set_log_level
58
+ from lkr.logger import set_log_level
49
59
 
50
60
  set_log_level(log_level)
51
61
  if quiet:
52
- from lkr.logging import set_log_level
62
+ from lkr.logger import set_log_level
53
63
 
54
64
  set_log_level(LogLevel.ERROR)
55
65
 
lkr/mcp/main.py CHANGED
@@ -17,7 +17,7 @@ from pydash import get
17
17
 
18
18
  from lkr.auth_service import get_auth
19
19
  from lkr.classes import LkrCtxObj
20
- from lkr.logging import logger
20
+ from lkr.logger import logger
21
21
 
22
22
  __all__ = ["group"]
23
23
 
@@ -101,7 +101,7 @@ class SpectaclesRequest(BaseModel):
101
101
 
102
102
 
103
103
  def get_mcp_sdk(ctx: LkrCtxObj | typer.Context):
104
- sdk = get_auth(ctx).get_current_sdk()
104
+ sdk = get_auth(ctx).get_current_sdk(prompt_refresh_invalid_token=False)
105
105
  sdk.auth.settings.agent_tag += "-mcp"
106
106
  return sdk
107
107
 
@@ -8,7 +8,7 @@ from looker_sdk.sdk.api40.methods import LookerSDK
8
8
  from looker_sdk.sdk.api40.models import EmbedSsoParams
9
9
  from pydantic import BaseModel, ConfigDict, Field
10
10
 
11
- from lkr.logging import structured_logger
11
+ from lkr.logger import structured_logger
12
12
 
13
13
  DEFAULT_PERMISSIONS = [
14
14
  "access_data",
@@ -84,14 +84,6 @@ class LogEvent(BaseModel):
84
84
  dashboard_id: str
85
85
 
86
86
 
87
- class EventCollector:
88
- def __init__(self):
89
- self.events = []
90
-
91
- def log_event(self, event: dict[str, Any], event_type: str):
92
- self.events.append(LogEvent(**event, event_type=event_type))
93
-
94
-
95
87
  class ObservabilityCtxObj(BaseModel):
96
88
  event_prefix: str = Field(
97
89
  default="lkr-observability", description="The prefix of the event", min_length=1
@@ -32,10 +32,12 @@
32
32
  const urlParams = new URLSearchParams(window.location.search);
33
33
  const iframeUrl = urlParams.get('iframe_url');
34
34
  const origin = new URL(iframeUrl).origin;
35
- console.log(origin)
36
- console.log(iframeUrl)
37
35
  const sessionId = urlParams.get('session_id');
36
+ const debug = urlParams.get('debug');
38
37
  // Set the iframe source
38
+ if (debug) {
39
+ console.log({debug, iframeUrl, origin, sessionId})
40
+ }
39
41
  document.getElementById('looker-iframe').src = iframeUrl;
40
42
 
41
43
  // Track which events we've received
@@ -55,7 +57,7 @@
55
57
  return;
56
58
  }
57
59
  const {type, ...data} = JSON.parse(event.data)
58
- if (!trackedEvents.has(type)) {
60
+ if (!trackedEvents.has(type) && !debug) {
59
61
  return;
60
62
  }
61
63
  if (type) {
@@ -65,6 +67,9 @@
65
67
  event_data: data,
66
68
  timestamp: now.toISOString(),
67
69
  };
70
+ if (debug) {
71
+ console.log(eventData)
72
+ }
68
73
  // Send event data to the server
69
74
  fetch(`/log_event?session_id=${sessionId}`, {
70
75
  method: 'POST',
lkr/observability/main.py CHANGED
@@ -17,7 +17,7 @@ from selenium.webdriver.remote.webdriver import WebDriver
17
17
  from selenium.webdriver.support import expected_conditions as EC
18
18
  from selenium.webdriver.support.ui import WebDriverWait
19
19
 
20
- from lkr.logging import structured_logger
20
+ from lkr.logger import structured_logger
21
21
  from lkr.observability.classes import (
22
22
  EmbedSDKObj,
23
23
  IframeRequestEvent,
@@ -158,14 +158,30 @@ def health_check(
158
158
  params.model_dump(mode="json"), "health_check_start", session_id
159
159
  )
160
160
  chrome_options = Options()
161
+ chrome_options.set_capability("goog:loggingPrefs", {"browser": "ALL"})
161
162
  chrome_options.add_argument("--headless=new")
162
163
  chrome_options.add_argument("--no-sandbox")
164
+ chrome_options.add_argument("--disable-dev-shm-usage")
165
+ chrome_options.add_argument("--disable-gpu")
166
+ chrome_options.add_argument("--enable-logging")
167
+ chrome_options.add_argument("--v=1")
168
+
169
+ chrome_options.add_experimental_option(
170
+ "prefs",
171
+ {
172
+ "profile.default_content_settings.cookies": 1,
173
+ "profile.cookie_controls_mode": 0,
174
+ },
175
+ )
163
176
  driver = webdriver.Chrome(options=chrome_options)
164
177
  url = observability_ctx.sdk.create_sso_embed_url(
165
178
  body=params.to_embed_sso_params(
166
179
  observability_ctx.origin, observability_ctx.base_url or ""
167
180
  )
168
181
  )
182
+ observability_ctx.log_event(
183
+ {"sso_url": url.url}, "create_sso_embed_url", session_id
184
+ )
169
185
 
170
186
  if not (url and url.url):
171
187
  raise HTTPException(status_code=500, detail="No URL found")
@@ -175,11 +191,16 @@ def health_check(
175
191
  else:
176
192
  quoted_url = quote(url.url, safe="")
177
193
  embed_url = f"{observability_ctx.origin}/?iframe_url={quoted_url}&session_id={session_id}"
178
- if True:
179
- driver.get(embed_url)
180
- WebDriverWait(driver, observability_ctx.timeout).until(
181
- EC.presence_of_element_located((By.ID, "completion-indicator"))
182
- )
194
+ driver.get(embed_url)
195
+ observability_ctx.log_event(
196
+ {"url": embed_url}, "chromium_driver_get", session_id
197
+ )
198
+ WebDriverWait(driver, observability_ctx.timeout).until(
199
+ EC.presence_of_element_located((By.ID, "completion-indicator"))
200
+ )
201
+ observability_ctx.log_event(
202
+ {"session_id": session_id}, "chromium_driver_get_complete", session_id
203
+ )
183
204
 
184
205
  except TimeoutException:
185
206
  observability_ctx.log_event(
@@ -209,6 +230,9 @@ def health_check(
209
230
  session_id,
210
231
  )
211
232
  finally:
233
+ observability_ctx.log_event(
234
+ {"session_id": session_id}, "health_check_complete", session_id
235
+ )
212
236
  if driver:
213
237
  driver.quit()
214
238
  if not redirect:
@@ -3,10 +3,9 @@ import re
3
3
  from datetime import datetime, timezone
4
4
  from typing import Dict, List, Tuple
5
5
 
6
- from pydantic import BaseModel, Field
7
6
  from pydash import set_
8
7
 
9
- from lkr.logging import logger
8
+ from lkr.logger import logger
10
9
 
11
10
  MAX_SESSION_LENGTH = 2592000
12
11
 
@@ -75,30 +74,6 @@ def format_attributes(
75
74
  def now():
76
75
  return datetime.now(timezone.utc)
77
76
 
77
+
78
78
  def ms_diff(start: datetime):
79
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
- )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lkr-dev-cli
3
- Version: 0.0.22
3
+ Version: 0.0.24
4
4
  Summary: lkr: a command line interface for looker
5
5
  Author: bwebs
6
6
  License-Expression: MIT
@@ -0,0 +1,21 @@
1
+ lkr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ lkr/auth_service.py,sha256=NIGlSVtzS3ajpWYx2gTJVDknuF_KxwYsJEj88Hc05dQ,19887
3
+ lkr/classes.py,sha256=f2TJOXFta0s8LJLEXOqPdWPLg-EIIntUSDS6gDOon7M,1163
4
+ lkr/constants.py,sha256=DdCfsV6q8wgs2iHpIQeb6oDP_2XejusEHyPvCbaM3yY,108
5
+ lkr/custom_types.py,sha256=feJ-W2U61PJTiotMLuZJqxrotA53er95kO1O30mooy4,323
6
+ lkr/exceptions.py,sha256=M_aR4YaCZtY4wyxhcoqJCVkxVu9z3Wwo5KgSDyOoEnI,210
7
+ lkr/logger.py,sha256=wLEVluF-2lVMPJ228D7O4-shHaydEGXmn47j567nUgw,1887
8
+ lkr/main.py,sha256=wpnSiCpIScEi-jmrLuwcSJrMX3tMkuaYNwlvNkVf5N8,2375
9
+ lkr/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ lkr/auth/main.py,sha256=7tWGPWokzbBnrX1enZ9YP4rdDJqYBlGfuYe0Wg-fXT4,7532
11
+ lkr/auth/oauth.py,sha256=n2yAcccdBZaloGVtFRTwCPBfh1cvVYNbXLsFCxmWc5M,7207
12
+ lkr/mcp/main.py,sha256=fz7Bvbkj1CTD185lGwNdMYP7bOTDadWL1xdD4JZW8qQ,22886
13
+ lkr/observability/classes.py,sha256=LgGuUnY-J1csPrlAKnw4PPOqOfbvaOx2cxENlQgJYcE,5816
14
+ lkr/observability/embed_container.html,sha256=IcDG-QVsYYNGQGrkDrx9OMZ2Pmo4C8oAjRHddFQ7Tlw,2939
15
+ lkr/observability/main.py,sha256=XbejIdqhNNUMqHVezb5EnLaJ32dO9-Bt0o5d8lc0kyw,9544
16
+ lkr/observability/utils.py,sha256=UpaBrp_ufaXLoz4p3xG3K6lHKBpP9wBhvP8rDmeGoWg,2148
17
+ lkr_dev_cli-0.0.24.dist-info/METADATA,sha256=PKOjKBiQqRlg4nfYzcFjbjYHSzaRNCslIlBiBGaaZPw,10663
18
+ lkr_dev_cli-0.0.24.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
19
+ lkr_dev_cli-0.0.24.dist-info/entry_points.txt,sha256=nn2sFMGDpwUVE61ZUpbDPnQZkW7Gc08nV-tyLGo8q34,37
20
+ lkr_dev_cli-0.0.24.dist-info/licenses/LICENSE,sha256=hKnCOORW1JRE_M2vStz8dblS5u1iR-2VpqS9xagKNa0,1063
21
+ lkr_dev_cli-0.0.24.dist-info/RECORD,,
@@ -1,21 +0,0 @@
1
- lkr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- lkr/auth_service.py,sha256=_enIsYxpL1IILtBfNG4YAA_H9cqwnwcvrGMyfmzgH7U,17815
3
- lkr/classes.py,sha256=0NKttc5kAhW_omuxwnN9Jy6oQnRKgI7b2c7HZUfI0lc,1144
4
- lkr/constants.py,sha256=DdCfsV6q8wgs2iHpIQeb6oDP_2XejusEHyPvCbaM3yY,108
5
- lkr/exceptions.py,sha256=M_aR4YaCZtY4wyxhcoqJCVkxVu9z3Wwo5KgSDyOoEnI,210
6
- lkr/logging.py,sha256=bBGdxkVrUidRTJAvqGHyQ5KS6IJGYDCoool5tTB_JxM,1822
7
- lkr/main.py,sha256=5m036LAS4PTLIzpWW5ohxIs2FGYtVf-OwYnTmtrpnxU,1933
8
- lkr/types.py,sha256=feJ-W2U61PJTiotMLuZJqxrotA53er95kO1O30mooy4,323
9
- lkr/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- lkr/auth/main.py,sha256=ebGp-NdgULodHaDrLKspnGSFf_sOkV2Vanil1NbOJKY,7382
11
- lkr/auth/oauth.py,sha256=-QCb7YGIqmuTiy6SZcUTbVZrDo7itOpUUOK1VuvfMqM,5987
12
- lkr/embed/observability/utils.py,sha256=Hv3g60cI03cQwyEe8QV7bUMssE6pyrZKnDyl9rxm5b8,2915
13
- lkr/mcp/main.py,sha256=39VO0_bdscrCW7BC3kdlgjfIWaNigpT38HZ74_Drm-A,22853
14
- lkr/observability/classes.py,sha256=5kQnHxa3pgUartrbccuG0yMClfoFqYCXY8iCTJTZvgk,6025
15
- lkr/observability/embed_container.html,sha256=BMzV_jfBhcussUExf0KccOmOTAO3UEV7SuZ4lznQ0no,2758
16
- lkr/observability/main.py,sha256=6xAcaW4ruxGZDiGHNTi0oIaQnI6HhKxsjCZ5jq_ldsE,8557
17
- lkr_dev_cli-0.0.22.dist-info/METADATA,sha256=Yp81tfskRQC4vKRCecxXGmmBK7eiT5Djf_fBzj9VFyo,10663
18
- lkr_dev_cli-0.0.22.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
19
- lkr_dev_cli-0.0.22.dist-info/entry_points.txt,sha256=nn2sFMGDpwUVE61ZUpbDPnQZkW7Gc08nV-tyLGo8q34,37
20
- lkr_dev_cli-0.0.22.dist-info/licenses/LICENSE,sha256=hKnCOORW1JRE_M2vStz8dblS5u1iR-2VpqS9xagKNa0,1063
21
- lkr_dev_cli-0.0.22.dist-info/RECORD,,
File without changes