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.
- lkr_dev_cli-0.0.39/.github/workflows/ci.yml +43 -0
- lkr_dev_cli-0.0.39/.vscode/settings.json +16 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/PKG-INFO +10 -11
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/auth/main.py +48 -5
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/auth/oauth.py +69 -18
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/auth_service.py +43 -15
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/classes.py +1 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/codemode/help.py +82 -9
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/codemode/main.py +17 -2
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/codemode/type.py +72 -1
- lkr_dev_cli-0.0.39/lkr/constants.py +2 -0
- lkr_dev_cli-0.0.39/lkr/extended_sdk_methods/__init__.py +25 -0
- lkr_dev_cli-0.0.39/lkr/extended_sdk_methods/classes.py +86 -0
- lkr_dev_cli-0.0.39/lkr/extended_sdk_methods/main.py +312 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/logger.py +10 -3
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/main.py +27 -5
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/mcp/main.py +29 -23
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/mcp/utils.py +2 -1
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/tools/main.py +3 -3
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/tools/permission_deprecation.py +12 -10
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/pyproject.toml +13 -17
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/tests/test_codemode.py +94 -2
- lkr_dev_cli-0.0.39/tests/test_extended_sdk_methods.py +188 -0
- lkr_dev_cli-0.0.39/tests/test_oauth_account.py +118 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/tests/test_permission_deprecation.py +6 -0
- lkr_dev_cli-0.0.39/ty.toml +2 -0
- lkr_dev_cli-0.0.39/uv.lock +1875 -0
- lkr_dev_cli-0.0.38/.github/workflows/test-dependencies.yml +0 -34
- lkr_dev_cli-0.0.38/.vscode/settings.json +0 -11
- lkr_dev_cli-0.0.38/lkr/constants.py +0 -3
- lkr_dev_cli-0.0.38/uv.lock +0 -1305
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/.github/workflows/release.yml +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/.gitignore +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/.python-version +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/.vscode/launch.json +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/Dockerfile +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/LICENSE +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/Makefile +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/README.md +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/cloudbuild.yaml +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/codemode.md +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/__init__.py +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/auth/__init__.py +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/codemode/LOCAL.md +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/codemode/__init__.py +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/codemode/constant.py +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/codemode/download_swagger.py +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/codemode/examples.py +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/codemode/readme.py +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/codemode/swagger.json +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/custom_types.py +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/exceptions.py +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/mcp/classes.py +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/observability/classes.py +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/observability/embed_container.html +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/observability/main.py +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/observability/utils.py +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr/tools/classes.py +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/lkr.md +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/tests/TESTING.md +0 -0
- {lkr_dev_cli-0.0.38 → lkr_dev_cli-0.0.39}/tests/test_dependency_resolution.py +0 -0
- {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.
|
|
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>=
|
|
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.
|
|
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.
|
|
21
|
-
Requires-Dist: mcp[cli]>=1.
|
|
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.
|
|
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.
|
|
30
|
-
Requires-Dist: mcp[cli]>=1.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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"{'
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
f'for /f "tokens=5" %a in (\'netstat -aon ^| find ":{port}"\') do taskkill /F /PID %a
|
|
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(
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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__(
|
|
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,
|
|
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
|
|
6
|
+
from typing import List, Self, Tuple, Union
|
|
7
7
|
|
|
8
8
|
import requests
|
|
9
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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=
|
|
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,
|
|
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
|
-
|
|
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
|
|
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(
|
|
512
|
+
return self.get_current_sdk(
|
|
513
|
+
prompt_refresh_invalid_token=False
|
|
514
|
+
)
|
|
487
515
|
raise e
|
|
488
516
|
|
|
489
517
|
return sdk
|
|
@@ -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
|
-
|
|
132
|
-
|
|
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
|
|
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
|
-
|
|
210
|
+
func_matches = _get_matches(query, external_funcs, sdk)
|
|
211
|
+
type_matches = _get_type_matches(query)
|
|
212
|
+
|
|
154
213
|
results = []
|
|
155
|
-
for m in
|
|
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
|
-
|
|
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."
|