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.
@@ -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,5 @@
1
+ *.pyc
2
+ __pycache__
3
+ .venv
4
+ tmp
5
+ .env
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,11 @@
1
+ {
2
+ "sqltools.connections": [
3
+ {
4
+ "previewLimit": 50,
5
+ "driver": "SQLite",
6
+ "name": "lookerauth",
7
+ "database": "/Users/bryanweber/.lkr/auth.db"
8
+ }
9
+ ],
10
+ "python.analysis.typeCheckingMode": "basic"
11
+ }
@@ -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
@@ -0,0 +1,2 @@
1
+ options:
2
+ logging: CLOUD_LOGGING_ONLY
@@ -0,0 +1,5 @@
1
+ """
2
+ lkr: a command line interface for looker
3
+ """
4
+
5
+ __version__ = "0.0.1"
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()