lkr-dev-cli 0.0.38__tar.gz → 0.0.39__tar.gz

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.
Files changed (62) hide show
  1. lkr_dev_cli-0.0.39/.github/workflows/ci.yml +43 -0
  2. lkr_dev_cli-0.0.39/.vscode/settings.json +16 -0
  3. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/PKG-INFO +10 -11
  4. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/auth/main.py +48 -5
  5. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/auth/oauth.py +69 -18
  6. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/auth_service.py +43 -15
  7. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/classes.py +1 -0
  8. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/codemode/help.py +82 -9
  9. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/codemode/main.py +17 -2
  10. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/codemode/type.py +72 -1
  11. lkr_dev_cli-0.0.39/lkr/constants.py +2 -0
  12. lkr_dev_cli-0.0.39/lkr/extended_sdk_methods/__init__.py +25 -0
  13. lkr_dev_cli-0.0.39/lkr/extended_sdk_methods/classes.py +86 -0
  14. lkr_dev_cli-0.0.39/lkr/extended_sdk_methods/main.py +312 -0
  15. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/logger.py +10 -3
  16. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/main.py +27 -5
  17. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/mcp/main.py +29 -23
  18. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/mcp/utils.py +2 -1
  19. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/tools/main.py +3 -3
  20. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/tools/permission_deprecation.py +12 -10
  21. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/pyproject.toml +13 -17
  22. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/tests/test_codemode.py +94 -2
  23. lkr_dev_cli-0.0.39/tests/test_extended_sdk_methods.py +188 -0
  24. lkr_dev_cli-0.0.39/tests/test_oauth_account.py +118 -0
  25. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/tests/test_permission_deprecation.py +6 -0
  26. lkr_dev_cli-0.0.39/ty.toml +2 -0
  27. lkr_dev_cli-0.0.39/uv.lock +1875 -0
  28. lkr_dev_cli-0.0.38/.github/workflows/test-dependencies.yml +0 -34
  29. lkr_dev_cli-0.0.38/.vscode/settings.json +0 -11
  30. lkr_dev_cli-0.0.38/lkr/constants.py +0 -3
  31. lkr_dev_cli-0.0.38/uv.lock +0 -1305
  32. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/.github/workflows/release.yml +0 -0
  33. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/.gitignore +0 -0
  34. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/.python-version +0 -0
  35. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/.vscode/launch.json +0 -0
  36. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/Dockerfile +0 -0
  37. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/LICENSE +0 -0
  38. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/Makefile +0 -0
  39. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/README.md +0 -0
  40. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/cloudbuild.yaml +0 -0
  41. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/codemode.md +0 -0
  42. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/__init__.py +0 -0
  43. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/auth/__init__.py +0 -0
  44. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/codemode/LOCAL.md +0 -0
  45. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/codemode/__init__.py +0 -0
  46. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/codemode/constant.py +0 -0
  47. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/codemode/download_swagger.py +0 -0
  48. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/codemode/examples.py +0 -0
  49. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/codemode/readme.py +0 -0
  50. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/codemode/swagger.json +0 -0
  51. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/custom_types.py +0 -0
  52. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/exceptions.py +0 -0
  53. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/mcp/classes.py +0 -0
  54. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/observability/classes.py +0 -0
  55. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/observability/embed_container.html +0 -0
  56. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/observability/main.py +0 -0
  57. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/observability/utils.py +0 -0
  58. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/tools/classes.py +0 -0
  59. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr.md +0 -0
  60. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/tests/TESTING.md +0 -0
  61. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/tests/test_dependency_resolution.py +0 -0
  62. {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/tests/test_deps.sh +0 -0
@@ -0,0 +1,43 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main, develop]
6
+ pull_request:
7
+ branches: [main, develop]
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ validate:
12
+ runs-on: ubuntu-latest
13
+
14
+ steps:
15
+ - name: Checkout code
16
+ uses: actions/checkout@v4
17
+
18
+ - name: Setup Python
19
+ uses: actions/setup-python@v4
20
+ with:
21
+ python-version: "3.12"
22
+
23
+ - name: Install uv
24
+ uses: astral-sh/setup-uv@v2
25
+ with:
26
+ version: latest
27
+
28
+ - name: Install dependencies
29
+ run: |
30
+ uv sync --extra all
31
+
32
+ - name: Run with pytest
33
+ run: |
34
+ uv run pytest tests/ -v
35
+
36
+ - name: Run ruff
37
+ run: |
38
+ uv run ruff check .
39
+
40
+ - name: Run ty
41
+ run: |
42
+ uv run ty check
43
+
@@ -0,0 +1,16 @@
1
+ {
2
+ "[python]": {
3
+ "editor.formatOnSave": true,
4
+ "editor.defaultFormatter": "charliermarsh.ruff",
5
+ "editor.codeActionsOnSave": {
6
+ "source.fixAll.ruff": "always",
7
+ "source.organizeImports.ruff": "always"
8
+ }
9
+ },
10
+ "python.defaultInterpreterPath": "${workspaceFolder}/backend/.venv/bin/python",
11
+ "ruff.interpreter": ["${workspaceFolder}/backend/.venv/bin/python"],
12
+ "ty.configurationFile": "./ty.toml",
13
+ "python.pyrefly.typeCheckingMode": "off",
14
+ "python.pyrefly.disableLanguageServices": true,
15
+ "python.pyrefly.disableTypeErrors": true
16
+ }
@@ -1,38 +1,37 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lkr-dev-cli
3
- Version: 0.0.38
3
+ Version: 0.0.39
4
4
  Summary: lkr: a command line interface for looker
5
5
  Author: bwebs
6
6
  License-Expression: MIT
7
7
  License-File: LICENSE
8
8
  Requires-Python: >=3.12
9
- Requires-Dist: cryptography>=45.0.4
9
+ Requires-Dist: cryptography>=48.0.1
10
10
  Requires-Dist: looker-sdk>=25.10.0
11
11
  Requires-Dist: pydantic>=2.11.7
12
12
  Requires-Dist: pydash>=8.0.5
13
- Requires-Dist: python-dotenv>=1.1.1
14
13
  Requires-Dist: questionary>=2.1.0
15
- Requires-Dist: requests>=2.32.4
14
+ Requires-Dist: requests>=2.34.2
16
15
  Requires-Dist: structlog>=25.4.0
17
16
  Requires-Dist: typer>=0.16.0
18
17
  Provides-Extra: all
19
18
  Requires-Dist: duckdb>=1.3.1; extra == 'all'
20
- Requires-Dist: fastapi[standard]>=0.115.14; extra == 'all'
21
- Requires-Dist: mcp[cli]>=1.10.1; extra == 'all'
19
+ Requires-Dist: fastapi[standard]>=0.136.3; extra == 'all'
20
+ Requires-Dist: mcp[cli]>=1.27.2; extra == 'all'
22
21
  Requires-Dist: pydantic-monty; extra == 'all'
23
22
  Requires-Dist: selenium>=4.34.0; extra == 'all'
24
23
  Provides-Extra: codemode
25
- Requires-Dist: mcp[cli]>=1.10.1; extra == 'codemode'
24
+ Requires-Dist: mcp[cli]>=1.27.2; extra == 'codemode'
26
25
  Requires-Dist: pydantic-monty; extra == 'codemode'
27
26
  Provides-Extra: mcp
28
27
  Requires-Dist: duckdb>=1.3.1; extra == 'mcp'
29
- Requires-Dist: fastapi[standard]>=0.115.14; extra == 'mcp'
30
- Requires-Dist: mcp[cli]>=1.10.1; extra == 'mcp'
28
+ Requires-Dist: fastapi[standard]>=0.136.3; extra == 'mcp'
29
+ Requires-Dist: mcp[cli]>=1.27.2; extra == 'mcp'
31
30
  Provides-Extra: observability
32
- Requires-Dist: fastapi[standard]>=0.115.14; extra == 'observability'
31
+ Requires-Dist: fastapi[standard]>=0.136.3; extra == 'observability'
33
32
  Requires-Dist: selenium>=4.34.0; extra == 'observability'
34
33
  Provides-Extra: tools
35
- Requires-Dist: fastapi[standard]>=0.115.14; extra == 'tools'
34
+ Requires-Dist: fastapi[standard]>=0.136.3; extra == 'tools'
36
35
  Description-Content-Type: text/markdown
37
36
 
38
37
  # lkr cli
@@ -45,6 +45,16 @@ def login(
45
45
  help="Name of the Looker instance to login or switch to",
46
46
  ),
47
47
  ] = None,
48
+ port: Annotated[
49
+ int | None,
50
+ typer.Option(
51
+ "-p",
52
+ "--port",
53
+ min=1,
54
+ max=65535,
55
+ help="Port to run the local OAuth redirect web server on",
56
+ ),
57
+ ] = None,
48
58
  ):
49
59
  """
50
60
  Login to Looker instance using OAuth2 or switch to an existing authenticated instance
@@ -101,10 +111,28 @@ def login(
101
111
  origin = urllib.parse.urlunparse(
102
112
  (parsed_url.scheme, parsed_url.netloc, "", "", "", "")
103
113
  )
104
- use_production = typer.confirm("Use production mode?", default=False)
114
+ if QUESTIONARY_AVAILABLE:
115
+ mode = questionary.select(
116
+ "Do you want this instance to be used for production or development mode?",
117
+ choices=[
118
+ questionary.Choice("Development", value=False),
119
+ questionary.Choice("Production", value=True),
120
+ ],
121
+ pointer=">",
122
+ ).ask()
123
+ if mode is None:
124
+ raise typer.Exit()
125
+ use_production = mode
126
+ else:
127
+ use_production = typer.confirm("Use production mode?", default=False)
128
+ clean_netloc = (
129
+ parsed_url.netloc.split(":")[0]
130
+ .removesuffix(".cloud.looker.com")
131
+ .removesuffix(".looker.com")
132
+ )
105
133
  instance_name = typer.prompt(
106
134
  "Enter a name for this Looker instance",
107
- default=f"{'dev' if not use_production else 'prod'}-{parsed_url.netloc}",
135
+ default=f"{'prod' if use_production else 'dev'}-{clean_netloc}",
108
136
  )
109
137
  # Ensure instance_name is str, not None
110
138
  assert instance_name is not None, (
@@ -115,13 +143,21 @@ def login(
115
143
  auth.add_auth(instance_name, origin, token, use_production)
116
144
 
117
145
  oauth = OAuth2PKCE(
118
- new_token_callback=auth_callback, use_production=use_production
146
+ new_token_callback=auth_callback,
147
+ use_production=use_production,
148
+ port=port,
119
149
  )
120
150
  logger.info(f"Opening browser for authentication at {origin + '/auth'}...")
151
+ logger.info(
152
+ f"Listening for OAuth callback on http://localhost:{oauth.port}/callback..."
153
+ )
121
154
  login_response = oauth.initiate_login(origin)
122
155
 
123
156
  if login_response["auth_code"]:
124
157
  logger.info("Successfully received authorization code!")
158
+ logger.debug(
159
+ "Exchanging authorization code for tokens at /api/token..."
160
+ )
125
161
  try:
126
162
  oauth.auth_code = login_response["auth_code"]
127
163
  token = oauth.exchange_code_for_token()
@@ -136,7 +172,12 @@ def login(
136
172
  )
137
173
  raise typer.Exit(1)
138
174
  else:
139
- logger.error("Failed to receive authorization code")
175
+ logger.error(
176
+ "Failed to receive authorization code from OAuth callback."
177
+ )
178
+ logger.debug(
179
+ "Check if the browser redirected correctly or if an error was returned."
180
+ )
140
181
  raise typer.Exit(1)
141
182
  do_switch(instance_name)
142
183
 
@@ -199,8 +240,10 @@ def whoami(ctx: typer.Context):
199
240
  )
200
241
  raise typer.Exit(1)
201
242
  user = sdk.me()
243
+ session_info = sdk.session()
244
+
202
245
  logger.info(
203
- f"Currently authenticated as {user.first_name} {user.last_name} ({user.email}) to {sdk.auth.settings.base_url}"
246
+ f"Currently authenticated as {user.first_name} {user.last_name} ({user.email}) to {sdk.auth.settings.base_url} in {session_info.workspace_id}"
204
247
  )
205
248
  except Exception as e:
206
249
  if "invalid_grant" in str(e) or "token expired" in str(e):
@@ -3,14 +3,16 @@ import os
3
3
  import secrets
4
4
  import socket
5
5
  import socketserver
6
+ import subprocess
6
7
  import threading
7
8
  import time
8
9
  import urllib.parse
9
10
  import webbrowser
10
- from typing import Optional, TypedDict
11
+ from typing import Optional, TypedDict, cast
11
12
  from urllib.parse import parse_qs
12
13
 
13
14
  from lkr.custom_types import NewTokenCallback
15
+ from lkr.logger import logger
14
16
 
15
17
 
16
18
  def kill_process_on_port(port: int, retries: int = 5, delay: float = 1) -> None:
@@ -25,10 +27,16 @@ def kill_process_on_port(port: int, retries: int = 5, delay: float = 1) -> None:
25
27
  except socket.error:
26
28
  # Port is in use, try to kill the process
27
29
  if os.name == "posix": # macOS/Linux
28
- os.system(f"lsof -ti tcp:{port} | xargs kill -9 2>/dev/null")
30
+ subprocess.run(
31
+ f"lsof -ti tcp:{port} | xargs kill -9",
32
+ shell=True,
33
+ capture_output=True,
34
+ )
29
35
  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'
36
+ subprocess.run(
37
+ f'for /f "tokens=5" %a in (\'netstat -aon ^| find ":{port}"\') do taskkill /F /PID %a',
38
+ shell=True,
39
+ capture_output=True,
32
40
  )
33
41
  # After killing, wait for the port to be free
34
42
  for _ in range(retries):
@@ -46,24 +54,56 @@ def kill_process_on_port(port: int, retries: int = 5, delay: float = 1) -> None:
46
54
  class OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
47
55
  def do_GET(self):
48
56
  """Handle the callback from OAuth authorization"""
57
+ parsed_url = urllib.parse.urlparse(self.path)
58
+ logger.debug(f"OAuthCallbackHandler received GET request for path: {self.path}")
59
+
60
+ # Ignore requests that are not the callback (e.g. /favicon.ico)
61
+ if parsed_url.path != "/callback":
62
+ logger.debug(f"Ignoring non-callback request: {self.path}")
63
+ self.send_response(404)
64
+ self.end_headers()
65
+ return
66
+
49
67
  self.send_response(200)
50
68
  self.send_header("Content-type", "text/html")
51
69
  self.end_headers()
52
70
 
53
- # Parse the authorization code from query parameters
54
- query_components = parse_qs(urllib.parse.urlparse(self.path).query)
71
+ # Parse the authorization code or error from query parameters
72
+ query_components = parse_qs(parsed_url.query)
73
+ logger.debug(f"OAuth callback query parameters: {query_components}")
55
74
 
56
75
  # Store the code in the server instance
76
+ server = cast(OAuthCallbackServer, self.server)
57
77
  if "code" in query_components:
58
- self.server.auth_code = query_components["code"][0] # type: ignore
59
-
60
- # Display a success message to the user
61
- self.wfile.write(
62
- b"Successfully authenticated to Looker OAuth! You can close this window."
63
- )
64
-
65
- # Shutdown the server
66
- threading.Thread(target=self.server.shutdown).start()
78
+ server.auth_code = query_components["code"][0]
79
+ logger.debug(
80
+ f"Authorization code received successfully: {server.auth_code[:5]}..."
81
+ )
82
+ self.wfile.write(
83
+ b"Successfully authenticated to Looker OAuth! You can close this window."
84
+ )
85
+ # Shutdown the server
86
+ threading.Thread(target=server.shutdown).start()
87
+ elif "error" in query_components:
88
+ error = query_components["error"][0]
89
+ error_description = query_components.get("error_description", [""])[0]
90
+ logger.error(
91
+ f"Looker OAuth returned error: {error} - {error_description}"
92
+ )
93
+ self.wfile.write(
94
+ f"OAuth Authentication Failed: {error} - {error_description}. You can close this window.".encode(
95
+ "utf-8"
96
+ )
97
+ )
98
+ threading.Thread(target=server.shutdown).start()
99
+ else:
100
+ logger.warning(
101
+ f"Callback received without 'code' or 'error' query parameters: {self.path}"
102
+ )
103
+ self.wfile.write(
104
+ b"OAuth Authentication Failed: Invalid callback request. You can close this window."
105
+ )
106
+ threading.Thread(target=server.shutdown).start()
67
107
 
68
108
  def log_message(self, format, *args):
69
109
  """Suppress logging of requests"""
@@ -71,6 +111,8 @@ class OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
71
111
 
72
112
 
73
113
  class OAuthCallbackServer(socketserver.TCPServer):
114
+ allow_reuse_address = True
115
+
74
116
  def __init__(self, server_address):
75
117
  super().__init__(server_address, OAuthCallbackHandler)
76
118
  self.auth_code: str | None = None
@@ -82,7 +124,12 @@ class LoginResponse(TypedDict):
82
124
 
83
125
 
84
126
  class OAuth2PKCE:
85
- def __init__(self, new_token_callback: NewTokenCallback, use_production: bool):
127
+ def __init__(
128
+ self,
129
+ new_token_callback: NewTokenCallback,
130
+ use_production: bool,
131
+ port: int | None = None,
132
+ ):
86
133
  from lkr.auth_service import DbOAuthSession
87
134
 
88
135
  self.auth_code: Optional[str] = None
@@ -91,7 +138,7 @@ class OAuth2PKCE:
91
138
  self.auth_session: DbOAuthSession | None = None
92
139
  self.server_thread: threading.Thread | None = None
93
140
  self.server: OAuthCallbackServer | None = None
94
- self.port: int = 8000
141
+ self.port: int = port if port is not None else 8000
95
142
  self.use_production: bool = use_production
96
143
 
97
144
  def cleanup(self):
@@ -152,11 +199,15 @@ class OAuth2PKCE:
152
199
 
153
200
  # Construct and open the OAuth URL
154
201
  self.auth_session = get_auth_session(
155
- base_url, self.new_token_callback, use_production=self.use_production
202
+ base_url,
203
+ self.new_token_callback,
204
+ use_production=self.use_production,
205
+ port=self.port,
156
206
  )
157
207
  oauth_url = self.auth_session.create_auth_code_request_url(
158
208
  "cors_api", self.state
159
209
  )
210
+ logger.debug(f"Constructed OAuth URL: {oauth_url}")
160
211
 
161
212
  webbrowser.open(oauth_url)
162
213
 
@@ -3,23 +3,22 @@ import os
3
3
  import sqlite3
4
4
  import types
5
5
  from datetime import datetime, timedelta, timezone
6
- from typing import List, Self, Tuple, Union, TYPE_CHECKING
6
+ from typing import List, Self, Tuple, Union
7
7
 
8
8
  import requests
9
- if TYPE_CHECKING:
10
- import typer
9
+ import typer
11
10
  from looker_sdk.rtl import serialize
12
11
  from looker_sdk.rtl.api_settings import ApiSettings, SettingsConfig
13
12
  from looker_sdk.rtl.auth_session import AuthSession, CryptoHash, OAuthSession
14
13
  from looker_sdk.rtl.auth_token import AccessToken, AuthToken
15
14
  from looker_sdk.rtl.requests_transport import RequestsTransport
16
15
  from looker_sdk.rtl.transport import LOOKER_API_ID, HttpMethod
17
- from looker_sdk.sdk.api40.methods import Looker40SDK
16
+ from lkr.extended_sdk_methods import ExtendedLooker40SDK as Looker40SDK
18
17
  from pydantic import BaseModel, Field, computed_field
19
18
  from pydash import get
20
19
 
21
20
  from lkr.classes import LkrCtxObj, LookerApiKey
22
- from lkr.constants import LOOKER_API_VERSION, OAUTH_CLIENT_ID, OAUTH_REDIRECT_URI
21
+ from lkr.constants import LOOKER_API_VERSION, OAUTH_CLIENT_ID
23
22
  from lkr.custom_types import NewTokenCallback
24
23
  from lkr.logger import logger
25
24
 
@@ -30,8 +29,9 @@ def is_auth_expired(e: Exception) -> bool:
30
29
  return "invalid_grant" in str(e) or "token expired" in str(e)
31
30
 
32
31
 
33
-
34
- def get_auth(ctx: Union["typer.Context", LkrCtxObj]) -> Union["SqlLiteAuth", "ApiKeyAuth"]:
32
+ def get_auth(
33
+ ctx: Union["typer.Context", LkrCtxObj],
34
+ ) -> Union["SqlLiteAuth", "ApiKeyAuth"]:
35
35
  if isinstance(ctx, LkrCtxObj):
36
36
  lkr_ctx = ctx
37
37
  else:
@@ -61,8 +61,9 @@ class ApiKeyApiSettings(ApiSettings):
61
61
 
62
62
 
63
63
  class OAuthApiSettings(ApiSettings):
64
- def __init__(self, base_url: str):
64
+ def __init__(self, base_url: str, port: int = 8000):
65
65
  self.base_url = base_url
66
+ self.port = port
66
67
  super().__init__()
67
68
  self.agent_tag = "lkr-cli-oauth"
68
69
 
@@ -72,7 +73,7 @@ class OAuthApiSettings(ApiSettings):
72
73
  looker_url=self.base_url,
73
74
  client_id=OAUTH_CLIENT_ID,
74
75
  client_secret="", # PKCE doesn't need client secret
75
- redirect_uri=OAUTH_REDIRECT_URI,
76
+ redirect_uri=f"http://localhost:{self.port}/callback",
76
77
  )
77
78
 
78
79
 
@@ -127,6 +128,12 @@ class DbOAuthSession(OAuthSession):
127
128
 
128
129
  def redeem_auth_code(self, *args, **kwargs):
129
130
  super().redeem_auth_code(*args, **kwargs)
131
+ if not self.use_production:
132
+ try:
133
+ self._switch_to_dev_mode()
134
+ except Exception as e:
135
+ logger.error(f"Failed to switch to development mode: {e}")
136
+ raise typer.Exit(1)
130
137
  self.new_token_callback(self.token)
131
138
 
132
139
  def _switch_to_dev_mode(self):
@@ -155,8 +162,9 @@ def get_auth_session(
155
162
  *,
156
163
  use_production: bool,
157
164
  access_token: AccessToken | None = None,
165
+ port: int = 8000,
158
166
  ) -> DbOAuthSession:
159
- settings = OAuthApiSettings(base_url)
167
+ settings = OAuthApiSettings(base_url, port=port)
160
168
  transport = MonkeyPatchTransport.configure(settings)
161
169
  auth = DbOAuthSession(
162
170
  settings=settings,
@@ -183,7 +191,7 @@ def init_api_key_sdk(api_key: LookerApiKey, use_production: bool) -> Looker40SDK
183
191
  auth=ApiKeyAuthSession(
184
192
  settings,
185
193
  transport,
186
- serialize.deserialize40, # type: ignore
194
+ serialize.deserialize40,
187
195
  LOOKER_API_VERSION,
188
196
  use_production=use_production,
189
197
  ),
@@ -208,7 +216,9 @@ def monkey_patch_prepare_request(session: requests.Session):
208
216
  x.headers.pop(LOOKER_API_ID)
209
217
  return x
210
218
 
211
- session.prepare_request = types.MethodType(prepare_request, session)
219
+ setattr(
220
+ session, "prepare_request", types.MethodType(prepare_request, session)
221
+ )
212
222
 
213
223
 
214
224
  class MonkeyPatchTransport(RequestsTransport):
@@ -223,9 +233,10 @@ def init_oauth_sdk(
223
233
  *,
224
234
  access_token: AccessToken | None = None,
225
235
  use_production: bool = False,
236
+ port: int = 8000,
226
237
  ) -> Looker40SDK:
227
238
  """Default dependency configuration"""
228
- settings = OAuthApiSettings(base_url)
239
+ settings = OAuthApiSettings(base_url, port=port)
229
240
  settings.is_configured()
230
241
  transport = MonkeyPatchTransport.configure(settings)
231
242
 
@@ -234,6 +245,7 @@ def init_oauth_sdk(
234
245
  new_token_callback,
235
246
  access_token=access_token,
236
247
  use_production=use_production,
248
+ port=port,
237
249
  )
238
250
  return Looker40SDK(
239
251
  auth=auth,
@@ -357,11 +369,12 @@ class CurrentAuth(BaseModel):
357
369
  ).isoformat()
358
370
  if self.from_db and new_token:
359
371
  connection.execute(
360
- "UPDATE auth SET access_token = ?, token_type = ?, expires_at = ? WHERE current_instance = 1",
372
+ "UPDATE auth SET access_token = ?, token_type = ?, expires_at = ? WHERE instance_name = ?",
361
373
  (
362
374
  new_token.access_token,
363
375
  new_token.token_type,
364
376
  expires_at,
377
+ self.instance_name,
365
378
  ),
366
379
  )
367
380
  else:
@@ -385,6 +398,7 @@ class CurrentAuth(BaseModel):
385
398
 
386
399
  class SqlLiteAuth:
387
400
  def __init__(self, ctx: LkrCtxObj, db_path: str = "~/.lkr/auth.db"):
401
+ self.ctx = ctx
388
402
  self.db_path = os.path.expanduser(db_path)
389
403
  # Ensure the directory exists
390
404
  db_dir = os.path.dirname(self.db_path)
@@ -442,6 +456,16 @@ class SqlLiteAuth:
442
456
  self.conn.commit()
443
457
 
444
458
  def _get_current_auth(self) -> CurrentAuth | None:
459
+ if self.ctx.oauth_account:
460
+ cursor = self.conn.execute(
461
+ "SELECT instance_name, access_token, refresh_token, refresh_expires_at, token_type, expires_at, base_url, use_production FROM auth WHERE instance_name = ?",
462
+ (self.ctx.oauth_account,),
463
+ )
464
+ row = cursor.fetchone()
465
+ if row:
466
+ return CurrentAuth.from_db_row(row)
467
+ logger.error("couldnt find oauth account in db, run lkr auth login")
468
+ raise typer.Exit(1)
445
469
  cursor = self.conn.execute(
446
470
  "SELECT instance_name, access_token, refresh_token, refresh_expires_at, token_type, expires_at, base_url, use_production FROM auth WHERE current_instance = 1"
447
471
  )
@@ -474,16 +498,20 @@ class SqlLiteAuth:
474
498
  current_auth.base_url,
475
499
  new_token_callback=refresh_current_token,
476
500
  access_token=current_auth.to_access_token(),
501
+ use_production=current_auth.use_production,
477
502
  )
478
503
  if prompt_refresh_invalid_token:
479
504
  import sys
505
+
480
506
  try:
481
507
  sdk.auth.authenticate({})
482
508
  except Exception as e:
483
509
  if is_auth_expired(e):
484
510
  if sys.stdin.isatty():
485
511
  self._cli_confirm_refresh_token(current_auth, quiet=False)
486
- return self.get_current_sdk(prompt_refresh_invalid_token=False)
512
+ return self.get_current_sdk(
513
+ prompt_refresh_invalid_token=False
514
+ )
487
515
  raise e
488
516
 
489
517
  return sdk
@@ -25,6 +25,7 @@ class LkrCtxObj(BaseModel):
25
25
  api_key: LookerApiKey | None
26
26
  force_oauth: bool = False
27
27
  use_production: bool = True
28
+ oauth_account: str | None = None
28
29
 
29
30
  @property
30
31
  def use_sdk(self) -> Literal["oauth", "api_key"]:
@@ -1,6 +1,5 @@
1
1
  import re
2
2
  import inspect
3
- import fnmatch
4
3
  import json
5
4
  import os
6
5
  from lkr.codemode.constant import EXCLUDED_FUNCS
@@ -126,14 +125,61 @@ def _get_matches(query: str, external_funcs: dict, sdk) -> list:
126
125
 
127
126
  return matches
128
127
 
128
+ def _get_type_matches(query: str) -> list:
129
+ escaped_query = re.escape(query).replace(r'\*', '.*').replace(r'\?', '.')
130
+ try:
131
+ pattern = re.compile(escaped_query, re.IGNORECASE)
132
+ except re.error:
133
+ pattern = re.compile(re.escape(query), re.IGNORECASE)
134
+
135
+ try:
136
+ from lkr.codemode.type import _get_swagger_data, _get_ext_definitions
137
+ swagger = _get_swagger_data()
138
+ definitions = dict(swagger.get('definitions', {}))
139
+ definitions.update(_get_ext_definitions())
140
+ except Exception:
141
+ return []
142
+
143
+ matches = []
144
+ for type_name, def_obj in definitions.items():
145
+ if not isinstance(def_obj, dict):
146
+ continue
147
+ hit_in_name = bool(pattern.search(type_name))
148
+
149
+ matching_props = []
150
+ matching_desc = []
151
+
152
+ properties = def_obj.get('properties', {})
153
+ if isinstance(properties, dict):
154
+ for prop_name, prop_val in properties.items():
155
+ if not isinstance(prop_val, dict):
156
+ continue
157
+ if pattern.search(prop_name):
158
+ matching_props.append(prop_name)
159
+ desc = prop_val.get('description', '')
160
+ if desc and pattern.search(desc):
161
+ matching_desc.append(f"{prop_name}: {desc.strip().splitlines()[0]}")
162
+
163
+ if hit_in_name or matching_props or matching_desc:
164
+ matches.append({
165
+ 'name': type_name,
166
+ 'hit_in_name': hit_in_name,
167
+ 'matching_props': matching_props,
168
+ 'matching_desc': matching_desc
169
+ })
170
+ return sorted(matches, key=lambda x: x['name'])
171
+
172
+
129
173
  def search_help(query: str, external_funcs: dict, sdk) -> str:
130
174
  """Search for functions and return a summary string with snippets."""
131
- matches = _get_matches(query, external_funcs, sdk)
132
- if not matches:
175
+ func_matches = _get_matches(query, external_funcs, sdk)
176
+ type_matches = _get_type_matches(query)
177
+
178
+ if not func_matches and not type_matches:
133
179
  return f"No matches found for '{query}'."
134
180
 
135
181
  lines = []
136
- for m in matches:
182
+ for m in func_matches:
137
183
  hit_info = []
138
184
  if m['hit_in_name']:
139
185
  hit_info.append("Name match")
@@ -144,22 +190,49 @@ def search_help(query: str, external_funcs: dict, sdk) -> str:
144
190
  if m['output_fields']:
145
191
  hit_info.append(f"Output field match: {', '.join(m['output_fields'][:2])}")
146
192
 
147
- lines.append(f"- {m['name']} ({' | '.join(hit_info)})")
193
+ lines.append(f"- Function: {m['name']} ({' | '.join(hit_info)})")
194
+
195
+ for m in type_matches:
196
+ hit_info = []
197
+ if m['hit_in_name']:
198
+ hit_info.append("Name match")
199
+ if m['matching_props']:
200
+ hit_info.append(f"Property match: {', '.join(m['matching_props'][:2])}")
201
+ if m['matching_desc']:
202
+ hit_info.append(f"Comment hit: \"{m['matching_desc'][0]}\"")
203
+
204
+ lines.append(f"- Type: {m['name']} ({' | '.join(hit_info)})")
148
205
 
149
206
  return f"Matches found for '{query}':\n" + "\n".join(lines)
150
207
 
151
208
  def search_with_lookups(query: str, external_funcs: dict, sdk) -> list:
152
209
  """Search for functions and return the array of lookups for matches."""
153
- matches = _get_matches(query, external_funcs, sdk)
210
+ func_matches = _get_matches(query, external_funcs, sdk)
211
+ type_matches = _get_type_matches(query)
212
+
154
213
  results = []
155
- for m in matches:
214
+ for m in func_matches:
156
215
  results.append(lookup_function(m['name'], external_funcs, sdk))
216
+ for m in type_matches:
217
+ try:
218
+ from lkr.codemode.type import lookup_type
219
+ results.append(lookup_type(m['name']))
220
+ except Exception:
221
+ pass
157
222
  return results
158
223
 
159
224
  def lookup_function(name: str, external_funcs: dict, sdk) -> str:
160
- """Look up the exact name of a function and return its docstring, inputs, and outputs."""
225
+ """Look up the exact name of a function or type and return its docstring, inputs, and outputs."""
161
226
  if name not in external_funcs:
162
- return f"Function '{name}' not found."
227
+ # Check if it's a type name
228
+ try:
229
+ from lkr.codemode.type import lookup_type
230
+ res = lookup_type(name)
231
+ if not res.startswith("Type '"):
232
+ return res
233
+ except Exception:
234
+ pass
235
+ return f"Function or type '{name}' not found."
163
236
 
164
237
  if not hasattr(sdk, name):
165
238
  return f"{name} is a built-in helper function."