lkr-dev-cli 0.0.2__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.2/.github/workflows/release.yml +76 -0
- lkr_dev_cli-0.0.2/.gitignore +5 -0
- lkr_dev_cli-0.0.2/.python-version +1 -0
- lkr_dev_cli-0.0.2/.vscode/settings.json +11 -0
- lkr_dev_cli-0.0.2/Dockerfile +13 -0
- lkr_dev_cli-0.0.2/LICENSE +21 -0
- lkr_dev_cli-0.0.2/PKG-INFO +19 -0
- lkr_dev_cli-0.0.2/README.md +1 -0
- lkr_dev_cli-0.0.2/cloudbuild.yaml +2 -0
- lkr_dev_cli-0.0.2/lkr/__init__.py +5 -0
- lkr_dev_cli-0.0.2/lkr/auth/__init__.py +0 -0
- lkr_dev_cli-0.0.2/lkr/auth/main.py +217 -0
- lkr_dev_cli-0.0.2/lkr/auth/oauth.py +145 -0
- lkr_dev_cli-0.0.2/lkr/auth_service.py +382 -0
- lkr_dev_cli-0.0.2/lkr/classes.py +48 -0
- lkr_dev_cli-0.0.2/lkr/constants.py +3 -0
- lkr_dev_cli-0.0.2/lkr/embed/main.py +31 -0
- lkr_dev_cli-0.0.2/lkr/embed/observability/constants.py +8 -0
- lkr_dev_cli-0.0.2/lkr/embed/observability/create_sso_embed_url.py +53 -0
- lkr_dev_cli-0.0.2/lkr/embed/observability/embed_container.html +88 -0
- lkr_dev_cli-0.0.2/lkr/embed/observability/embed_server.py +86 -0
- lkr_dev_cli-0.0.2/lkr/embed/observability/utils.py +104 -0
- lkr_dev_cli-0.0.2/lkr/exceptions.py +4 -0
- lkr_dev_cli-0.0.2/lkr/logging.py +59 -0
- lkr_dev_cli-0.0.2/lkr/main.py +51 -0
- lkr_dev_cli-0.0.2/lkr/types.py +13 -0
- lkr_dev_cli-0.0.2/lkr/utils.py +0 -0
- lkr_dev_cli-0.0.2/pyproject.toml +36 -0
- lkr_dev_cli-0.0.2/uv.lock +518 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
name: Release Docker Image
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [created]
|
|
6
|
+
|
|
7
|
+
env:
|
|
8
|
+
REGION: us-central1
|
|
9
|
+
SERVICE_NAME: cli
|
|
10
|
+
APP_NAME: lkr-cli
|
|
11
|
+
TAG: ${{ github.event.release.tag_name }}
|
|
12
|
+
UV_PUBLISH_TOKEN: ${{ secrets.UV_PUBLISH_TOKEN }}
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
build-and-push:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
permissions:
|
|
18
|
+
contents: "read"
|
|
19
|
+
id-token: "write"
|
|
20
|
+
|
|
21
|
+
steps:
|
|
22
|
+
- name: Set IMAGE_NAME
|
|
23
|
+
run: echo "IMAGE_NAME=${{ env.REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ env.APP_NAME }}/${{ env.SERVICE_NAME }}" >> $GITHUB_ENV
|
|
24
|
+
|
|
25
|
+
- name: Checkout
|
|
26
|
+
uses: actions/checkout@v4
|
|
27
|
+
|
|
28
|
+
- name: Replace version in pyproject.toml
|
|
29
|
+
run: |
|
|
30
|
+
find . -name "pyproject.toml" -exec sed -i "s/^version = .*/version = \"${{ env.TAG }}\"/" {} +
|
|
31
|
+
|
|
32
|
+
- name: Replace version in __init__.py
|
|
33
|
+
run: |
|
|
34
|
+
find . -name "__init__.py" -exec sed -i "s/^__version__ = .*/__version__ = \"${{ env.TAG }}\"/" {} +
|
|
35
|
+
|
|
36
|
+
- name: Authenticate to Google Cloud
|
|
37
|
+
uses: google-github-actions/auth@v2
|
|
38
|
+
with:
|
|
39
|
+
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
|
|
40
|
+
project_id: ${{ secrets.GCP_PROJECT_ID }}
|
|
41
|
+
|
|
42
|
+
- name: Set up Cloud SDK
|
|
43
|
+
uses: google-github-actions/setup-gcloud@v2
|
|
44
|
+
|
|
45
|
+
- name: Build and push Docker image
|
|
46
|
+
run: |
|
|
47
|
+
# Build and push the image with both the release tag and latest
|
|
48
|
+
gcloud builds submit . \
|
|
49
|
+
--project ${{ secrets.GCP_PROJECT_ID }} \
|
|
50
|
+
--tag ${{ env.IMAGE_NAME }}:${{ env.TAG }} \
|
|
51
|
+
--gcs-log-dir=gs://${{ secrets.GCP_LOGS_BUCKET_NAME }}/${{ env.APP_NAME }}
|
|
52
|
+
|
|
53
|
+
- name: Add latest tag
|
|
54
|
+
run: |
|
|
55
|
+
gcloud artifacts docker tags add ${{ env.IMAGE_NAME }}:${{ env.TAG }} \
|
|
56
|
+
${{ env.IMAGE_NAME }}:latest \
|
|
57
|
+
--project ${{ secrets.GCP_PROJECT_ID }}
|
|
58
|
+
|
|
59
|
+
publish:
|
|
60
|
+
name: python
|
|
61
|
+
runs-on: ubuntu-latest
|
|
62
|
+
|
|
63
|
+
steps:
|
|
64
|
+
- uses: actions/checkout@v4
|
|
65
|
+
|
|
66
|
+
- name: Install uv
|
|
67
|
+
uses: astral-sh/setup-uv@v5
|
|
68
|
+
|
|
69
|
+
- name: uv sync
|
|
70
|
+
run: uv sync --frozen --no-dev
|
|
71
|
+
|
|
72
|
+
- name: uv build
|
|
73
|
+
run: uv build
|
|
74
|
+
|
|
75
|
+
- name: uv publish
|
|
76
|
+
run: uv publish
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
FROM python:3.12-slim
|
|
2
|
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
|
3
|
+
|
|
4
|
+
# Set working directory and create necessary directories with proper permissions
|
|
5
|
+
WORKDIR /app
|
|
6
|
+
|
|
7
|
+
# Copy dependency files
|
|
8
|
+
COPY pyproject.toml uv.lock README.md LICENSE ./
|
|
9
|
+
COPY lkr ./lkr
|
|
10
|
+
ENV UV_PROJECT_ENVIRONMENT="/usr/local/"
|
|
11
|
+
RUN uv sync --frozen --no-dev --no-editable
|
|
12
|
+
|
|
13
|
+
CMD []
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 lkrdev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lkr-dev-cli
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: lkr: a command line interface for looker
|
|
5
|
+
Author: bwebs
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Requires-Dist: cryptography>=42.0.0
|
|
10
|
+
Requires-Dist: looker-sdk>=25.4.0
|
|
11
|
+
Requires-Dist: pick>=2.4.0
|
|
12
|
+
Requires-Dist: pydantic>=2.11.4
|
|
13
|
+
Requires-Dist: pydash>=8.0.5
|
|
14
|
+
Requires-Dist: requests>=2.31.0
|
|
15
|
+
Requires-Dist: structlog>=25.3.0
|
|
16
|
+
Requires-Dist: typer>=0.15.2
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# lkr cli
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# lkr cli
|
|
File without changes
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import urllib.parse
|
|
2
|
+
from typing import Annotated, List, Union, cast
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from looker_sdk.rtl.auth_token import AccessToken, AuthToken
|
|
6
|
+
from pick import Option, pick
|
|
7
|
+
from pydash import get
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from lkr.auth.oauth import OAuth2PKCE
|
|
12
|
+
from lkr.auth_service import get_auth
|
|
13
|
+
from lkr.logging import logger
|
|
14
|
+
|
|
15
|
+
__all__ = ["group"]
|
|
16
|
+
|
|
17
|
+
group = typer.Typer(name="auth", help="Authentication commands for LookML Repository")
|
|
18
|
+
|
|
19
|
+
@group.callback()
|
|
20
|
+
def callback(ctx: typer.Context):
|
|
21
|
+
if ctx.invoked_subcommand == "whoami":
|
|
22
|
+
return
|
|
23
|
+
if ctx.obj['ctx_lkr'].use_sdk == "api_key":
|
|
24
|
+
logger.error("API key authentication is not supported for auth commands")
|
|
25
|
+
raise typer.Exit(1)
|
|
26
|
+
|
|
27
|
+
@group.command()
|
|
28
|
+
def login(ctx: typer.Context):
|
|
29
|
+
"""
|
|
30
|
+
Login to Looker instance using OAuth2 PKCE flow
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
base_url = typer.prompt("Enter your Looker instance base URL")
|
|
34
|
+
if not base_url.startswith("http"):
|
|
35
|
+
base_url = f"https://{base_url}"
|
|
36
|
+
# Parse the URL and reconstruct it to get the origin (scheme://hostname[:port])
|
|
37
|
+
parsed_url = urllib.parse.urlparse(base_url)
|
|
38
|
+
origin = urllib.parse.urlunparse(
|
|
39
|
+
(parsed_url.scheme, parsed_url.netloc, "", "", "", "")
|
|
40
|
+
)
|
|
41
|
+
instance_name = typer.prompt(
|
|
42
|
+
"Enter a name for this Looker instance", default=parsed_url.netloc
|
|
43
|
+
)
|
|
44
|
+
auth = get_auth(ctx)
|
|
45
|
+
def add_auth(token: Union[AuthToken, AccessToken]):
|
|
46
|
+
auth.add_auth(instance_name, origin, token)
|
|
47
|
+
# Initialize OAuth2 PKCE flow
|
|
48
|
+
oauth = OAuth2PKCE(new_token_callback=add_auth)
|
|
49
|
+
|
|
50
|
+
# Open browser for authentication and wait for callback
|
|
51
|
+
logger.info(f"Opening browser for authentication at {origin + '/auth'}...")
|
|
52
|
+
|
|
53
|
+
login_response = oauth.initiate_login(origin)
|
|
54
|
+
if login_response["auth_code"]:
|
|
55
|
+
logger.info("Successfully received authorization code!")
|
|
56
|
+
try:
|
|
57
|
+
# Store the auth code in the OAuth instance
|
|
58
|
+
oauth.auth_code = login_response["auth_code"]
|
|
59
|
+
# Exchange the code for tokens
|
|
60
|
+
token = oauth.exchange_code_for_token()
|
|
61
|
+
if token:
|
|
62
|
+
logger.info("Successfully authenticated!")
|
|
63
|
+
else:
|
|
64
|
+
logger.error("Failed to exchange authorization code for tokens")
|
|
65
|
+
raise typer.Exit(1)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
logger.error(f"Failed to exchange authorization code for tokens: {str(e)}")
|
|
68
|
+
raise typer.Exit(1)
|
|
69
|
+
else:
|
|
70
|
+
logger.error("Failed to receive authorization code")
|
|
71
|
+
raise typer.Exit(1)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@group.command()
|
|
75
|
+
def logout(
|
|
76
|
+
ctx: typer.Context,
|
|
77
|
+
instance_name: Annotated[
|
|
78
|
+
str | None,
|
|
79
|
+
typer.Option(
|
|
80
|
+
help="Name of the Looker instance to logout from. If not provided, logs out from all instances."
|
|
81
|
+
),
|
|
82
|
+
] = None,
|
|
83
|
+
all: Annotated[
|
|
84
|
+
bool,
|
|
85
|
+
typer.Option(
|
|
86
|
+
"--all", help="Logout from all instances"
|
|
87
|
+
),
|
|
88
|
+
] = False,
|
|
89
|
+
):
|
|
90
|
+
"""
|
|
91
|
+
Logout and clear saved credentials
|
|
92
|
+
"""
|
|
93
|
+
auth = get_auth(ctx)
|
|
94
|
+
if instance_name:
|
|
95
|
+
message = f"Are you sure you want to logout from instance '{instance_name}'?"
|
|
96
|
+
elif all:
|
|
97
|
+
message = "Are you sure you want to logout from all instances?"
|
|
98
|
+
else:
|
|
99
|
+
instance_name = auth.get_current_instance()
|
|
100
|
+
if not instance_name:
|
|
101
|
+
logger.error("No instance currently authenticated")
|
|
102
|
+
raise typer.Exit(1)
|
|
103
|
+
message = f"Are you sure you want to logout from instance '{instance_name}'?"
|
|
104
|
+
|
|
105
|
+
if not typer.confirm(message, default=False):
|
|
106
|
+
logger.info("Logout cancelled")
|
|
107
|
+
raise typer.Exit()
|
|
108
|
+
|
|
109
|
+
if instance_name:
|
|
110
|
+
logger.info(f"Logging out from instance: {instance_name}")
|
|
111
|
+
auth.delete_auth(instance_name=instance_name)
|
|
112
|
+
else:
|
|
113
|
+
logger.info("Logging out from all instances...")
|
|
114
|
+
all_instances = auth.list_auth()
|
|
115
|
+
for instance in all_instances:
|
|
116
|
+
auth.delete_auth(instance_name=instance[0])
|
|
117
|
+
logger.info("Logged out successfully!")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@group.command()
|
|
121
|
+
def whoami(ctx: typer.Context):
|
|
122
|
+
"""
|
|
123
|
+
Check current authentication
|
|
124
|
+
"""
|
|
125
|
+
auth = get_auth(ctx)
|
|
126
|
+
sdk = auth.get_current_sdk(prompt_refresh_invalid_token=True)
|
|
127
|
+
if not sdk:
|
|
128
|
+
logger.error(
|
|
129
|
+
"Not currently authenticated - use `lkr auth login` or `lkr auth switch` to authenticate"
|
|
130
|
+
)
|
|
131
|
+
raise typer.Exit(1)
|
|
132
|
+
user = sdk.me()
|
|
133
|
+
logger.info(
|
|
134
|
+
f"Currently authenticated as {user.first_name} {user.last_name} ({user.email}) to {sdk.auth.settings.base_url}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@group.command()
|
|
139
|
+
def switch(
|
|
140
|
+
ctx: typer.Context,
|
|
141
|
+
instance_name: Annotated[
|
|
142
|
+
str | None,
|
|
143
|
+
typer.Option(
|
|
144
|
+
"-I", "--instance-name", help="Name of the Looker instance to switch to"
|
|
145
|
+
),
|
|
146
|
+
] = None,
|
|
147
|
+
):
|
|
148
|
+
"""
|
|
149
|
+
Switch to a different authenticated Looker instance
|
|
150
|
+
"""
|
|
151
|
+
auth = get_auth(ctx)
|
|
152
|
+
all_instances = auth.list_auth()
|
|
153
|
+
if not all_instances:
|
|
154
|
+
logger.error("No authenticated instances found")
|
|
155
|
+
raise typer.Exit(1)
|
|
156
|
+
|
|
157
|
+
if instance_name:
|
|
158
|
+
# If instance name provided, verify it exists
|
|
159
|
+
instance_names = [name for name, url, current in all_instances]
|
|
160
|
+
if instance_name not in instance_names:
|
|
161
|
+
logger.error(f"Instance '{instance_name}' not found")
|
|
162
|
+
raise typer.Exit(1)
|
|
163
|
+
else:
|
|
164
|
+
# If no instance name provided, show selection menu
|
|
165
|
+
current_index = 0
|
|
166
|
+
instance_names = []
|
|
167
|
+
options: List[Option] = []
|
|
168
|
+
max_name_length = 0
|
|
169
|
+
for index, (name, _, current) in enumerate(all_instances):
|
|
170
|
+
if current:
|
|
171
|
+
current_index = index
|
|
172
|
+
max_name_length = max(max_name_length, len(name))
|
|
173
|
+
instance_names.append(name)
|
|
174
|
+
options = [
|
|
175
|
+
Option(label=f"{name:{max_name_length}} ({url})", value=name)
|
|
176
|
+
for name, url, _ in all_instances
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
picked = pick(
|
|
180
|
+
options,
|
|
181
|
+
"Select instance to switch to",
|
|
182
|
+
min_selection_count=1,
|
|
183
|
+
default_index=current_index,
|
|
184
|
+
clear_screen=False,
|
|
185
|
+
)[0]
|
|
186
|
+
instance_name = cast(str, get(picked, "value"))
|
|
187
|
+
# Switch to selected instance
|
|
188
|
+
auth.set_current_instance(instance_name)
|
|
189
|
+
sdk = auth.get_current_sdk()
|
|
190
|
+
if not sdk:
|
|
191
|
+
logger.error("No looker instance currently authenticated")
|
|
192
|
+
raise typer.Exit(1)
|
|
193
|
+
user = sdk.me()
|
|
194
|
+
logger.info(
|
|
195
|
+
f"Successfully switched to {instance_name} ({sdk.auth.settings.base_url}) as {user.first_name} {user.last_name} ({user.email})"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@group.command()
|
|
200
|
+
def list(ctx: typer.Context):
|
|
201
|
+
"""
|
|
202
|
+
List all authenticated Looker instances
|
|
203
|
+
"""
|
|
204
|
+
console = Console()
|
|
205
|
+
auth = get_auth(ctx)
|
|
206
|
+
all_instances = auth.list_auth()
|
|
207
|
+
if not all_instances:
|
|
208
|
+
logger.error("No authenticated instances found")
|
|
209
|
+
raise typer.Exit(1)
|
|
210
|
+
table = Table(" ", "Instance", "URL")
|
|
211
|
+
for instance in all_instances:
|
|
212
|
+
table.add_row("*" if instance[2] else " ", instance[0], instance[1])
|
|
213
|
+
console.print(table)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__":
|
|
217
|
+
group()
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import http.server
|
|
2
|
+
import os
|
|
3
|
+
import secrets
|
|
4
|
+
import socket
|
|
5
|
+
import socketserver
|
|
6
|
+
import threading
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import webbrowser
|
|
9
|
+
from typing import Optional, TypedDict
|
|
10
|
+
from urllib.parse import parse_qs
|
|
11
|
+
|
|
12
|
+
from lkr.types import NewTokenCallback
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def kill_process_on_port(port: int) -> None:
|
|
16
|
+
"""Kill any process currently using the specified port."""
|
|
17
|
+
try:
|
|
18
|
+
# Try to create a socket binding to check if port is in use
|
|
19
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
20
|
+
sock.bind(('localhost', port))
|
|
21
|
+
sock.close()
|
|
22
|
+
return # Port is free, no need to kill anything
|
|
23
|
+
except socket.error:
|
|
24
|
+
# Port is in use, try to kill the process
|
|
25
|
+
if os.name == 'posix': # macOS/Linux
|
|
26
|
+
os.system(f'lsof -ti tcp:{port} | xargs kill -9 2>/dev/null')
|
|
27
|
+
elif os.name == 'nt': # Windows
|
|
28
|
+
os.system(f'for /f "tokens=5" %a in (\'netstat -aon ^| find ":{port}"\') do taskkill /F /PID %a 2>nul')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
32
|
+
def do_GET(self):
|
|
33
|
+
"""Handle the callback from OAuth authorization"""
|
|
34
|
+
self.send_response(200)
|
|
35
|
+
self.send_header('Content-type', 'text/html')
|
|
36
|
+
self.end_headers()
|
|
37
|
+
|
|
38
|
+
# Parse the authorization code from query parameters
|
|
39
|
+
query_components = parse_qs(urllib.parse.urlparse(self.path).query)
|
|
40
|
+
|
|
41
|
+
# Store the code in the server instance
|
|
42
|
+
if 'code' in query_components:
|
|
43
|
+
self.auth_code = query_components['code'][0]
|
|
44
|
+
|
|
45
|
+
# Display a success message to the user
|
|
46
|
+
self.wfile.write(b"Authentication successful! You can close this window.")
|
|
47
|
+
|
|
48
|
+
# Shutdown the server
|
|
49
|
+
threading.Thread(target=self.server.shutdown).start()
|
|
50
|
+
|
|
51
|
+
def log_message(self, format, *args):
|
|
52
|
+
"""Suppress logging of requests"""
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
class OAuthCallbackServer(socketserver.TCPServer):
|
|
56
|
+
def __init__(self, server_address):
|
|
57
|
+
super().__init__(server_address, OAuthCallbackHandler)
|
|
58
|
+
self.auth_code: Optional[str] = None
|
|
59
|
+
|
|
60
|
+
class LoginResponse(TypedDict):
|
|
61
|
+
auth_code: Optional[str]
|
|
62
|
+
code_verifier: Optional[str]
|
|
63
|
+
|
|
64
|
+
class OAuth2PKCE:
|
|
65
|
+
def __init__(self, new_token_callback: NewTokenCallback):
|
|
66
|
+
from lkr.auth_service import DbOAuthSession
|
|
67
|
+
self.auth_code: Optional[str] = None
|
|
68
|
+
self.state = secrets.token_urlsafe(16)
|
|
69
|
+
self.new_token_callback: NewTokenCallback = new_token_callback
|
|
70
|
+
self.auth_session: DbOAuthSession | None = None
|
|
71
|
+
self.server_thread: threading.Thread | None = None
|
|
72
|
+
self.server: OAuthCallbackServer | None = None
|
|
73
|
+
self.port: int = 8000
|
|
74
|
+
|
|
75
|
+
def cleanup(self):
|
|
76
|
+
"""Clean up the server and its thread."""
|
|
77
|
+
if self.server:
|
|
78
|
+
self.server.shutdown()
|
|
79
|
+
self.server.server_close()
|
|
80
|
+
self.server = None
|
|
81
|
+
if self.server_thread:
|
|
82
|
+
self.server_thread.join(timeout=1.0)
|
|
83
|
+
self.server_thread = None
|
|
84
|
+
|
|
85
|
+
def initiate_login(self, base_url: str) -> LoginResponse:
|
|
86
|
+
"""
|
|
87
|
+
Initiates the OAuth2 PKCE login flow by opening the browser with the authorization URL
|
|
88
|
+
and starting a local server to catch the callback.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Optional[str]: The authorization code if successful, None otherwise
|
|
92
|
+
"""
|
|
93
|
+
from lkr.auth_service import get_auth_session
|
|
94
|
+
|
|
95
|
+
# Kill any process using port 8000
|
|
96
|
+
kill_process_on_port(self.port)
|
|
97
|
+
|
|
98
|
+
# Start the local server
|
|
99
|
+
self.server = OAuthCallbackServer(('localhost', self.port))
|
|
100
|
+
|
|
101
|
+
# Start the server in a separate thread
|
|
102
|
+
self.server_thread = threading.Thread(target=self.server.serve_forever)
|
|
103
|
+
self.server_thread.daemon = True
|
|
104
|
+
self.server_thread.start()
|
|
105
|
+
|
|
106
|
+
# Construct and open the OAuth URL
|
|
107
|
+
self.auth_session = get_auth_session(base_url, self.new_token_callback)
|
|
108
|
+
oauth_url = self.auth_session.create_auth_code_request_url('cors_api', self.state)
|
|
109
|
+
|
|
110
|
+
webbrowser.open(oauth_url)
|
|
111
|
+
|
|
112
|
+
# Wait for the callback
|
|
113
|
+
self.server_thread.join()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# Get the authorization code
|
|
117
|
+
return LoginResponse(auth_code=self.server.auth_code, code_verifier=self.auth_session.code_verifier)
|
|
118
|
+
|
|
119
|
+
def exchange_code_for_token(self):
|
|
120
|
+
"""
|
|
121
|
+
Exchange the authorization code for access and refresh tokens.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
base_url: The base URL of the Looker instance
|
|
125
|
+
client_id: The OAuth client ID
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Dict containing access_token, refresh_token, token_type, and expires_in
|
|
129
|
+
"""
|
|
130
|
+
if not self.auth_code:
|
|
131
|
+
raise ValueError("No authorization code available. Must call initiate_login first.")
|
|
132
|
+
if not self.auth_session:
|
|
133
|
+
raise ValueError("No auth session available. Must call initiate_login first.")
|
|
134
|
+
self.auth_session.redeem_auth_code(self.auth_code, self.auth_session.code_verifier)
|
|
135
|
+
self.cleanup()
|
|
136
|
+
return self.auth_session.token
|
|
137
|
+
|
|
138
|
+
def __del__(self):
|
|
139
|
+
self.cleanup()
|
|
140
|
+
|
|
141
|
+
def __enter__(self):
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
145
|
+
self.cleanup()
|