lkr-dev-cli 0.0.29__tar.gz → 0.0.31__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 (40) hide show
  1. lkr_dev_cli-0.0.31/.github/workflows/test-dependencies.yml +34 -0
  2. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/Dockerfile +1 -1
  3. lkr_dev_cli-0.0.31/Makefile +7 -0
  4. lkr_dev_cli-0.0.29/README.md → lkr_dev_cli-0.0.31/PKG-INFO +221 -2
  5. lkr_dev_cli-0.0.29/PKG-INFO → lkr_dev_cli-0.0.31/README.md +190 -24
  6. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/lkr/main.py +4 -1
  7. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/lkr/tools/classes.py +41 -49
  8. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/lkr/tools/main.py +2 -3
  9. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/lkr.md +1 -1
  10. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/pyproject.toml +18 -3
  11. lkr_dev_cli-0.0.31/tests/TESTING.md +96 -0
  12. lkr_dev_cli-0.0.31/tests/test_dependency_resolution.py +180 -0
  13. lkr_dev_cli-0.0.31/tests/test_deps.sh +119 -0
  14. lkr_dev_cli-0.0.31/uv.lock +954 -0
  15. lkr_dev_cli-0.0.29/Makefile +0 -4
  16. lkr_dev_cli-0.0.29/uv.lock +0 -842
  17. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/.github/workflows/release.yml +0 -0
  18. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/.gitignore +0 -0
  19. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/.python-version +0 -0
  20. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/.vscode/launch.json +0 -0
  21. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/.vscode/settings.json +0 -0
  22. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/LICENSE +0 -0
  23. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/cloudbuild.yaml +0 -0
  24. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/lkr/__init__.py +0 -0
  25. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/lkr/auth/__init__.py +0 -0
  26. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/lkr/auth/main.py +0 -0
  27. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/lkr/auth/oauth.py +0 -0
  28. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/lkr/auth_service.py +0 -0
  29. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/lkr/classes.py +0 -0
  30. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/lkr/constants.py +0 -0
  31. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/lkr/custom_types.py +0 -0
  32. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/lkr/exceptions.py +0 -0
  33. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/lkr/logger.py +0 -0
  34. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/lkr/mcp/classes.py +0 -0
  35. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/lkr/mcp/main.py +0 -0
  36. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/lkr/mcp/utils.py +0 -0
  37. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/lkr/observability/classes.py +0 -0
  38. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/lkr/observability/embed_container.html +0 -0
  39. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/lkr/observability/main.py +0 -0
  40. {lkr_dev_cli-0.0.29 → lkr_dev_cli-0.0.31}/lkr/observability/utils.py +0 -0
@@ -0,0 +1,34 @@
1
+ name: Test Dependency Resolution
2
+
3
+ on:
4
+ push:
5
+ branches: [ main, develop ]
6
+ pull_request:
7
+ branches: [ main, develop ]
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ test-dependencies:
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/test_dependency_resolution.py -v
@@ -9,6 +9,6 @@ WORKDIR /app
9
9
  COPY pyproject.toml uv.lock README.md LICENSE ./
10
10
  COPY lkr ./lkr
11
11
  ENV UV_PROJECT_ENVIRONMENT="/usr/local/"
12
- RUN uv sync --frozen --no-dev --no-editable
12
+ RUN uv sync --frozen --no-dev --no-editable --extra=all
13
13
 
14
14
  CMD []
@@ -0,0 +1,7 @@
1
+ .PHONY: docs test-deps
2
+
3
+ docs:
4
+ typer lkr/main.py utils docs --output lkr.md
5
+
6
+ test-deps:
7
+ python tests/test_dependency_resolution.py
@@ -1,3 +1,34 @@
1
+ Metadata-Version: 2.4
2
+ Name: lkr-dev-cli
3
+ Version: 0.0.31
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: pydantic>=2.11.4
12
+ Requires-Dist: pydash>=8.0.5
13
+ Requires-Dist: questionary>=2.1.0
14
+ Requires-Dist: requests>=2.31.0
15
+ Requires-Dist: structlog>=25.3.0
16
+ Requires-Dist: typer>=0.15.2
17
+ Provides-Extra: all
18
+ Requires-Dist: duckdb>=1.2.2; extra == 'all'
19
+ Requires-Dist: fastapi>=0.115.12; extra == 'all'
20
+ Requires-Dist: mcp[cli]>=1.9.2; extra == 'all'
21
+ Requires-Dist: selenium>=4.32.0; extra == 'all'
22
+ Provides-Extra: embed-observability
23
+ Requires-Dist: fastapi>=0.115.12; extra == 'embed-observability'
24
+ Requires-Dist: selenium>=4.32.0; extra == 'embed-observability'
25
+ Provides-Extra: mcp
26
+ Requires-Dist: duckdb>=1.2.2; extra == 'mcp'
27
+ Requires-Dist: mcp[cli]>=1.9.2; extra == 'mcp'
28
+ Provides-Extra: user-attribute-updater
29
+ Requires-Dist: fastapi>=0.115.12; extra == 'user-attribute-updater'
30
+ Description-Content-Type: text/markdown
31
+
1
32
  # lkr cli
2
33
 
3
34
  The `lkr` cli is a tool for interacting with Looker. It combines Looker's SDK and customer logic to interact with Looker in meaninful ways. For a full list of commands, see the full [cli docs](./lkr.md)
@@ -107,7 +138,7 @@ The observability command provides tools for monitoring and interacting with Loo
107
138
  - `GET /health`: Launches a headless browser to simulate embedding a dashboard, waits for a completion indicator, and logs the process for health checking. This endpoint accepts query parameters to help login users with custom attributes.
108
139
 
109
140
  > [!IMPORTANT]
110
- > Make sure you add the http://host:port to your domain allowlist in Admin Embed. [docs](https://cloud.google.com/looker/docs/embedded-javascript-events#adding_the_embed_domain_to_the_allowlist) Unless overridden, the default would be http://0.0.0.0:8080. These can also set via cli arguments. E.g., `lkr observability embed --host localhost --port 7777` or by setting the environment variables `HOST` and `PORT`. You can check the embed_domain by sending a request to the `/settings` endpoint.
141
+ > Make sure you add the `http://host:port` to your domain allowlist in Admin Embed. [docs](https://cloud.google.com/looker/docs/embedded-javascript-events#adding_the_embed_domain_to_the_allowlist) Unless overridden, the default would be http://0.0.0.0:8080. These can also set via cli arguments. E.g., `lkr observability embed --host localhost --port 7777` or by setting the environment variables `HOST` and `PORT`. You can check the embed_domain by sending a request to the `/settings` endpoint.
111
142
 
112
143
 
113
144
  For example:
@@ -187,4 +218,192 @@ gcloud monitoring uptime create lkr-observability-health-check \
187
218
  ```
188
219
 
189
220
  ### Alternative Usage
190
- This can also be used to stress test your Looker environment as it serves an API that logs into a Looker embedded dashboard and runs queries like a user would within Chromium. If you wrote a script to repeatedly call this API with different parameters, you could use it to stress test your Looker environment and/or your database.
221
+ This can also be used to stress test your Looker environment as it serves an API that logs into a Looker embedded dashboard and runs queries like a user would within Chromium. If you wrote a script to repeatedly call this API with different parameters, you could use it to stress test your Looker environment and/or your database.
222
+
223
+ ## User Attribute Updater (OIDC Token)
224
+
225
+ 1. Create a new cloud run using the `lkr-cli` public docker image `us-central1-docker.pkg.dev/lkr-dev-production/lkr-cli/cli:latest`
226
+ 2. Put in the environment variables LOOKERSDK_CLIENT_ID, LOOKERSDK_CLIENT_SECRET, LOOKERSDK_BASE_URL, LOOKER_WHITELISTED_BASE_URLS. The `LOOKER_WHITELISTED_BASE_URLS` would be the same url as the `LOOKERSDK_BASE_URL` if you are only using this for a single Looker instance. For more advanced use cases, you can set the `LOOKER_WHITELISTED_BASE_URLS` to a comma separated list of urls. The body of the request also accepts a `base_url`, `client_id`, and `client_secret` key that will override these settings. See example [`gcloud` command](#example-gcloud-command)
227
+ 3. For the command and arguments use:
228
+ - command: `lkr`
229
+ - args: `tools` `user-attribute-updater`
230
+ 4. Deploy the cloud run
231
+ 5. Retrieve the URL of the cloud run
232
+ 6. Create the user attribute
233
+ - Name: cloud_run_access_token
234
+ - Data Type: String
235
+ - User Access: None
236
+ - Hide values: Yes
237
+ - Domain Allowlist: The URL of the cloud run from step 4. Looker will only allow the user attribute to be set if the request is going to this URL
238
+
239
+ > [!NOTE]
240
+ > The user attribute name can be anything you want. Typically you will be using this with an extension, so you should follow the naming convention of the type of user attribute you will be using with the extension. I would recommend using a `scoped user attributes` for this. See [Extension User Attributes](https://www.npmjs.com/package/@looker/extension-sdk#user-attributes). If you are using a `global_user_attribute`, then you can just use the name of it like `cloud_run_access_token`.
241
+
242
+ 6. Create a new cloud scheduler
243
+ - cron: `0 * * * *`
244
+ - Target Type: Cloud Run
245
+ - URL: The URL of the cloud run from step 4 with a path of `/identity_token`. E.g. `https://your-cloud-run-url.com/identity_token`
246
+ - HTTP Method: POST
247
+ - Headers: `Content-Type: application/json`
248
+ - Body: Use the user attribute_name from step 5, or use the user_attribute_id found in the Looker URL after you created it or are editing it
249
+
250
+ ```json
251
+ {
252
+ "user_attribute": "cloud_run_access_token",
253
+ "update_type": "default"
254
+ }
255
+ ```
256
+ - Auth Header: OIDC Token
257
+ - Service Account: Choose the service account you want to use to run the cloud scheduler
258
+ - Audience: The URL of the cloud run
259
+ - Max Retries: >0
260
+ 7. Make sure the Service Account has the `Cloud Run Invoker` role
261
+ 8. Navigate the the cloud scheduler page, select the one you just created, and click Force Run
262
+ 9. Check the logs of the cloud run to see if there was a 200 response
263
+
264
+
265
+ ### Example `gcloud` command
266
+ ```bash
267
+ export REGION=<your region>
268
+ export PROJECT=<your project id>
269
+
270
+ gcloud run deploy lkr-access-token-updater \
271
+ --image us-central1-docker.pkg.dev/lkr-dev-production/lkr-cli/cli:latest \
272
+ --command lkr \
273
+ --args tools,user-attribute-updater \
274
+ --platform managed \
275
+ --region $REGION \
276
+ --project $PROJECT \
277
+ --cpu 1 \
278
+ --memory 2Gi \
279
+ --set-env-vars LOOKERSDK_CLIENT_ID=<your client id>,LOOKERSDK_CLIENT_SECRET=<your client secret>,LOOKERSDK_BASE_URL=<your instance url>,LOOKER_WHITELISTED_BASE_URLS=<your instance url>
280
+ ```
281
+
282
+ ## UserAttributeUpdater `lkr-dev-cli`
283
+
284
+ Exported from the `lkr-dev-cli` package is the `UserAttributeUpdater` pydantic class. This class has all the necessary logic to update a user attribute value.
285
+
286
+ It supports the following operations:
287
+ - Updating a default value
288
+ - Updating a group value
289
+ - Updating a user value
290
+ - Deleting a default value
291
+ - Deleting a group value
292
+ - Deleting a user value
293
+
294
+ It can also support looking up looker ids. It will lookup the following if the id is not provided:
295
+ - user_attribute_id by the name
296
+ - user_id by the email or external_user_id
297
+ - group_id by the name
298
+
299
+
300
+ ### Example Usage
301
+
302
+ ```python
303
+ from lkr import UserAttributeUpdater
304
+
305
+ # without credentials
306
+ updater = UserAttributeUpdater(
307
+ user_attribute="cloud_run_access_token",
308
+ update_type="default",
309
+ value="123",
310
+ )
311
+
312
+
313
+ # with credentials
314
+ updater = UserAttributeUpdater(
315
+ user_attribute="cloud_run_access_token",
316
+ update_type="default",
317
+ value="123",
318
+ base_url="https://your-looker-instance.com",
319
+ client_id="your-client-id",
320
+ client_secret="your-client-secret",
321
+ )
322
+
323
+ updater.update_user_attribute_value()
324
+
325
+ # Getting authorization header from a FastAPI request
326
+ from fastapi import Request
327
+ from lkr import UserAttributeUpdater
328
+
329
+ @app.post("/request_authorization")
330
+ def request_authorization(request: Request):
331
+ body = await request.json()
332
+ updater = UserAttributeUpdater.model_validate(body)
333
+ updater.get_request_authorization_for_value(request.headers.items())
334
+ updater.update_user_attribute_value()
335
+
336
+ @app.post("/as_body")
337
+ def as_body(request: Request, body: UserAttributeUpdater):
338
+ body.get_request_authorization_for_value(request.headers.items())
339
+ body.update_user_attribute_value()
340
+
341
+ @app.post("/assigning_value")
342
+ def assigning_value(request: Request):
343
+ updater = UserAttributeUpdater(
344
+ user_attribute="cloud_run_access_token",
345
+ update_type="default"
346
+ )
347
+ updater.value = request.headers.get("my_custom_header")
348
+ updater.update_user_attribute_value()
349
+
350
+ @app.delete("/:user_attribute_name/:email")
351
+ def delete_user_attribute(user_attribute_name: str, email: str):
352
+ updater = UserAttributeUpdater(
353
+ user_attribute=user_attribute_name,
354
+ update_type="user",
355
+ email=email,
356
+ )
357
+ updater.delete_user_attribute_value()
358
+
359
+ ## Optional Dependencies
360
+
361
+ The `lkr` CLI supports optional dependencies that enable additional functionality. You can install these individually or all at once.
362
+
363
+ ### Available Extras
364
+
365
+ - **`mcp`**: Enables the MCP (Model Context Protocol) server functionality
366
+ - Includes: `mcp[cli]>=1.9.2`, `duckdb>=1.2.2`
367
+ - **`embed-observability`**: Enables the observability embed monitoring features
368
+ - Includes: `fastapi>=0.115.12`, `selenium>=4.32.0`
369
+ - **`user-attribute-updater`**: Enables the user attribute updater functionality
370
+ - Includes: `fastapi>=0.115.12`
371
+
372
+ ### Installing Optional Dependencies
373
+
374
+ **Install all optional dependencies:**
375
+ ```bash
376
+ uv sync --extra all
377
+ ```
378
+
379
+ **Install specific extras:**
380
+ ```bash
381
+ # Install MCP functionality
382
+ uv sync --extra mcp
383
+
384
+ # Install observability features
385
+ uv sync --extra embed-observability
386
+
387
+ # Install user attribute updater
388
+ uv sync --extra user-attribute-updater
389
+
390
+ # Install multiple extras
391
+ uv sync --extra mcp --extra embed-observability
392
+ ```
393
+
394
+ **Using pip:**
395
+ ```bash
396
+ # Install all optional dependencies
397
+ pip install lkr-dev-cli[all]
398
+
399
+ # Install specific extras
400
+ pip install lkr-dev-cli[mcp,embed-observability,user-attribute-updater]
401
+ ```
402
+
403
+ ### What Each Extra Enables
404
+
405
+ - **`mcp`**: Use the MCP server with tools like Cursor for enhanced IDE integration
406
+ - **`embed-observability`**: Run the observability embed server for monitoring Looker dashboard performance
407
+ - **`user-attribute-updater`**: Deploy the user attribute updater service for OIDC token management
408
+
409
+ All extras are designed to work together seamlessly, and installing `all` is equivalent to installing all individual extras.
@@ -1,25 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: lkr-dev-cli
3
- Version: 0.0.29
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: duckdb>=1.2.2
11
- Requires-Dist: fastapi>=0.115.12
12
- Requires-Dist: looker-sdk>=25.4.0
13
- Requires-Dist: mcp[cli]>=1.9.2
14
- Requires-Dist: pydantic>=2.11.4
15
- Requires-Dist: pydash>=8.0.5
16
- Requires-Dist: questionary>=2.1.0
17
- Requires-Dist: requests>=2.31.0
18
- Requires-Dist: selenium>=4.32.0
19
- Requires-Dist: structlog>=25.3.0
20
- Requires-Dist: typer>=0.15.2
21
- Description-Content-Type: text/markdown
22
-
23
1
  # lkr cli
24
2
 
25
3
  The `lkr` cli is a tool for interacting with Looker. It combines Looker's SDK and customer logic to interact with Looker in meaninful ways. For a full list of commands, see the full [cli docs](./lkr.md)
@@ -129,7 +107,7 @@ The observability command provides tools for monitoring and interacting with Loo
129
107
  - `GET /health`: Launches a headless browser to simulate embedding a dashboard, waits for a completion indicator, and logs the process for health checking. This endpoint accepts query parameters to help login users with custom attributes.
130
108
 
131
109
  > [!IMPORTANT]
132
- > Make sure you add the http://host:port to your domain allowlist in Admin Embed. [docs](https://cloud.google.com/looker/docs/embedded-javascript-events#adding_the_embed_domain_to_the_allowlist) Unless overridden, the default would be http://0.0.0.0:8080. These can also set via cli arguments. E.g., `lkr observability embed --host localhost --port 7777` or by setting the environment variables `HOST` and `PORT`. You can check the embed_domain by sending a request to the `/settings` endpoint.
110
+ > Make sure you add the `http://host:port` to your domain allowlist in Admin Embed. [docs](https://cloud.google.com/looker/docs/embedded-javascript-events#adding_the_embed_domain_to_the_allowlist) Unless overridden, the default would be http://0.0.0.0:8080. These can also set via cli arguments. E.g., `lkr observability embed --host localhost --port 7777` or by setting the environment variables `HOST` and `PORT`. You can check the embed_domain by sending a request to the `/settings` endpoint.
133
111
 
134
112
 
135
113
  For example:
@@ -209,4 +187,192 @@ gcloud monitoring uptime create lkr-observability-health-check \
209
187
  ```
210
188
 
211
189
  ### Alternative Usage
212
- This can also be used to stress test your Looker environment as it serves an API that logs into a Looker embedded dashboard and runs queries like a user would within Chromium. If you wrote a script to repeatedly call this API with different parameters, you could use it to stress test your Looker environment and/or your database.
190
+ This can also be used to stress test your Looker environment as it serves an API that logs into a Looker embedded dashboard and runs queries like a user would within Chromium. If you wrote a script to repeatedly call this API with different parameters, you could use it to stress test your Looker environment and/or your database.
191
+
192
+ ## User Attribute Updater (OIDC Token)
193
+
194
+ 1. Create a new cloud run using the `lkr-cli` public docker image `us-central1-docker.pkg.dev/lkr-dev-production/lkr-cli/cli:latest`
195
+ 2. Put in the environment variables LOOKERSDK_CLIENT_ID, LOOKERSDK_CLIENT_SECRET, LOOKERSDK_BASE_URL, LOOKER_WHITELISTED_BASE_URLS. The `LOOKER_WHITELISTED_BASE_URLS` would be the same url as the `LOOKERSDK_BASE_URL` if you are only using this for a single Looker instance. For more advanced use cases, you can set the `LOOKER_WHITELISTED_BASE_URLS` to a comma separated list of urls. The body of the request also accepts a `base_url`, `client_id`, and `client_secret` key that will override these settings. See example [`gcloud` command](#example-gcloud-command)
196
+ 3. For the command and arguments use:
197
+ - command: `lkr`
198
+ - args: `tools` `user-attribute-updater`
199
+ 4. Deploy the cloud run
200
+ 5. Retrieve the URL of the cloud run
201
+ 6. Create the user attribute
202
+ - Name: cloud_run_access_token
203
+ - Data Type: String
204
+ - User Access: None
205
+ - Hide values: Yes
206
+ - Domain Allowlist: The URL of the cloud run from step 4. Looker will only allow the user attribute to be set if the request is going to this URL
207
+
208
+ > [!NOTE]
209
+ > The user attribute name can be anything you want. Typically you will be using this with an extension, so you should follow the naming convention of the type of user attribute you will be using with the extension. I would recommend using a `scoped user attributes` for this. See [Extension User Attributes](https://www.npmjs.com/package/@looker/extension-sdk#user-attributes). If you are using a `global_user_attribute`, then you can just use the name of it like `cloud_run_access_token`.
210
+
211
+ 6. Create a new cloud scheduler
212
+ - cron: `0 * * * *`
213
+ - Target Type: Cloud Run
214
+ - URL: The URL of the cloud run from step 4 with a path of `/identity_token`. E.g. `https://your-cloud-run-url.com/identity_token`
215
+ - HTTP Method: POST
216
+ - Headers: `Content-Type: application/json`
217
+ - Body: Use the user attribute_name from step 5, or use the user_attribute_id found in the Looker URL after you created it or are editing it
218
+
219
+ ```json
220
+ {
221
+ "user_attribute": "cloud_run_access_token",
222
+ "update_type": "default"
223
+ }
224
+ ```
225
+ - Auth Header: OIDC Token
226
+ - Service Account: Choose the service account you want to use to run the cloud scheduler
227
+ - Audience: The URL of the cloud run
228
+ - Max Retries: >0
229
+ 7. Make sure the Service Account has the `Cloud Run Invoker` role
230
+ 8. Navigate the the cloud scheduler page, select the one you just created, and click Force Run
231
+ 9. Check the logs of the cloud run to see if there was a 200 response
232
+
233
+
234
+ ### Example `gcloud` command
235
+ ```bash
236
+ export REGION=<your region>
237
+ export PROJECT=<your project id>
238
+
239
+ gcloud run deploy lkr-access-token-updater \
240
+ --image us-central1-docker.pkg.dev/lkr-dev-production/lkr-cli/cli:latest \
241
+ --command lkr \
242
+ --args tools,user-attribute-updater \
243
+ --platform managed \
244
+ --region $REGION \
245
+ --project $PROJECT \
246
+ --cpu 1 \
247
+ --memory 2Gi \
248
+ --set-env-vars LOOKERSDK_CLIENT_ID=<your client id>,LOOKERSDK_CLIENT_SECRET=<your client secret>,LOOKERSDK_BASE_URL=<your instance url>,LOOKER_WHITELISTED_BASE_URLS=<your instance url>
249
+ ```
250
+
251
+ ## UserAttributeUpdater `lkr-dev-cli`
252
+
253
+ Exported from the `lkr-dev-cli` package is the `UserAttributeUpdater` pydantic class. This class has all the necessary logic to update a user attribute value.
254
+
255
+ It supports the following operations:
256
+ - Updating a default value
257
+ - Updating a group value
258
+ - Updating a user value
259
+ - Deleting a default value
260
+ - Deleting a group value
261
+ - Deleting a user value
262
+
263
+ It can also support looking up looker ids. It will lookup the following if the id is not provided:
264
+ - user_attribute_id by the name
265
+ - user_id by the email or external_user_id
266
+ - group_id by the name
267
+
268
+
269
+ ### Example Usage
270
+
271
+ ```python
272
+ from lkr import UserAttributeUpdater
273
+
274
+ # without credentials
275
+ updater = UserAttributeUpdater(
276
+ user_attribute="cloud_run_access_token",
277
+ update_type="default",
278
+ value="123",
279
+ )
280
+
281
+
282
+ # with credentials
283
+ updater = UserAttributeUpdater(
284
+ user_attribute="cloud_run_access_token",
285
+ update_type="default",
286
+ value="123",
287
+ base_url="https://your-looker-instance.com",
288
+ client_id="your-client-id",
289
+ client_secret="your-client-secret",
290
+ )
291
+
292
+ updater.update_user_attribute_value()
293
+
294
+ # Getting authorization header from a FastAPI request
295
+ from fastapi import Request
296
+ from lkr import UserAttributeUpdater
297
+
298
+ @app.post("/request_authorization")
299
+ def request_authorization(request: Request):
300
+ body = await request.json()
301
+ updater = UserAttributeUpdater.model_validate(body)
302
+ updater.get_request_authorization_for_value(request.headers.items())
303
+ updater.update_user_attribute_value()
304
+
305
+ @app.post("/as_body")
306
+ def as_body(request: Request, body: UserAttributeUpdater):
307
+ body.get_request_authorization_for_value(request.headers.items())
308
+ body.update_user_attribute_value()
309
+
310
+ @app.post("/assigning_value")
311
+ def assigning_value(request: Request):
312
+ updater = UserAttributeUpdater(
313
+ user_attribute="cloud_run_access_token",
314
+ update_type="default"
315
+ )
316
+ updater.value = request.headers.get("my_custom_header")
317
+ updater.update_user_attribute_value()
318
+
319
+ @app.delete("/:user_attribute_name/:email")
320
+ def delete_user_attribute(user_attribute_name: str, email: str):
321
+ updater = UserAttributeUpdater(
322
+ user_attribute=user_attribute_name,
323
+ update_type="user",
324
+ email=email,
325
+ )
326
+ updater.delete_user_attribute_value()
327
+
328
+ ## Optional Dependencies
329
+
330
+ The `lkr` CLI supports optional dependencies that enable additional functionality. You can install these individually or all at once.
331
+
332
+ ### Available Extras
333
+
334
+ - **`mcp`**: Enables the MCP (Model Context Protocol) server functionality
335
+ - Includes: `mcp[cli]>=1.9.2`, `duckdb>=1.2.2`
336
+ - **`embed-observability`**: Enables the observability embed monitoring features
337
+ - Includes: `fastapi>=0.115.12`, `selenium>=4.32.0`
338
+ - **`user-attribute-updater`**: Enables the user attribute updater functionality
339
+ - Includes: `fastapi>=0.115.12`
340
+
341
+ ### Installing Optional Dependencies
342
+
343
+ **Install all optional dependencies:**
344
+ ```bash
345
+ uv sync --extra all
346
+ ```
347
+
348
+ **Install specific extras:**
349
+ ```bash
350
+ # Install MCP functionality
351
+ uv sync --extra mcp
352
+
353
+ # Install observability features
354
+ uv sync --extra embed-observability
355
+
356
+ # Install user attribute updater
357
+ uv sync --extra user-attribute-updater
358
+
359
+ # Install multiple extras
360
+ uv sync --extra mcp --extra embed-observability
361
+ ```
362
+
363
+ **Using pip:**
364
+ ```bash
365
+ # Install all optional dependencies
366
+ pip install lkr-dev-cli[all]
367
+
368
+ # Install specific extras
369
+ pip install lkr-dev-cli[mcp,embed-observability,user-attribute-updater]
370
+ ```
371
+
372
+ ### What Each Extra Enables
373
+
374
+ - **`mcp`**: Use the MCP server with tools like Cursor for enhanced IDE integration
375
+ - **`embed-observability`**: Run the observability embed server for monitoring Looker dashboard performance
376
+ - **`user-attribute-updater`**: Deploy the user attribute updater service for OIDC token management
377
+
378
+ All extras are designed to work together seamlessly, and installing `all` is equivalent to installing all individual extras.
@@ -12,7 +12,10 @@ from lkr.observability.main import group as observability_group
12
12
  from lkr.tools.main import group as tools_group
13
13
 
14
14
  app = typer.Typer(
15
- name="lkr", help="LookML Repository CLI", add_completion=True, no_args_is_help=True
15
+ name="lkr",
16
+ help="A CLI for Looker with helpful tools",
17
+ add_completion=True,
18
+ no_args_is_help=True,
16
19
  )
17
20
 
18
21
  app.add_typer(auth_group, name="auth")
@@ -1,6 +1,5 @@
1
1
  from typing import Literal, Optional, Self, cast
2
2
 
3
- from fastapi import Request
4
3
  from looker_sdk.sdk.api40.methods import Looker40SDK
5
4
  from looker_sdk.sdk.api40.models import (
6
5
  UserAttributeGroupValue,
@@ -46,10 +45,12 @@ class UserAttributeUpdater(BaseModel):
46
45
  )
47
46
  return self
48
47
 
49
- def get_request_authorization_for_value(self, request: Request):
50
- authorization_token = request.headers.get("Authorization")
48
+ def get_request_authorization_for_value(self, headers: list[tuple[str, str]]):
49
+ authorization_token = next(
50
+ (header for header in headers if header[0] == "Authorization"), None
51
+ )
51
52
  if authorization_token:
52
- self.value = authorization_token
53
+ self.value = authorization_token[1]
53
54
  else:
54
55
  logger.error("No authorization token found")
55
56
 
@@ -68,6 +69,17 @@ class UserAttributeUpdater(BaseModel):
68
69
  return None
69
70
  return init_api_key_sdk(api_key, True)
70
71
 
72
+ def _get_looker_user_id(self, sdk: Looker40SDK) -> str | None:
73
+ if self.looker_user_id:
74
+ return self.looker_user_id
75
+ elif self.email:
76
+ user = sdk.user_for_credential("email", self.email)
77
+ return user.id if user else None
78
+ elif self.external_user_id:
79
+ user = sdk.user_for_credential("embed", self.external_user_id)
80
+ return user.id if user else None
81
+ return None
82
+
71
83
  def _get_group_id(self, sdk: Looker40SDK) -> str | None:
72
84
  if self.group_id:
73
85
  return self.group_id
@@ -107,25 +119,24 @@ class UserAttributeUpdater(BaseModel):
107
119
  else:
108
120
  raise ValueError("Group not found")
109
121
  elif self.update_type == "default":
110
- sdk.delete_user_attribute(user_attribute_id)
122
+ user_attribute = sdk.user_attribute(user_attribute_id, "name,label,type")
123
+ sdk.update_user_attribute(
124
+ user_attribute_id,
125
+ WriteUserAttribute(
126
+ default_value=None,
127
+ name=user_attribute.name,
128
+ label=user_attribute.label,
129
+ type=user_attribute.type,
130
+ ),
131
+ )
111
132
  elif self.update_type == "user":
112
- if self.looker_user_id:
113
- sdk.delete_user_attribute_user_value(
114
- user_id=self.looker_user_id,
115
- user_attribute_id=user_attribute_id,
116
- )
117
- elif self.external_user_id:
118
- sdk.delete_user_attribute_user_value(
119
- user_id=self.external_user_id,
120
- user_attribute_id=user_attribute_id,
121
- )
122
- elif self.email:
123
- sdk.delete_user_attribute_user_value(
124
- user_id=self.email,
125
- user_attribute_id=user_attribute_id,
126
- )
127
- else:
133
+ looker_user_id = self._get_looker_user_id(sdk)
134
+ if not looker_user_id:
128
135
  raise ValueError("User not found")
136
+ sdk.delete_user_attribute_user_value(
137
+ user_id=looker_user_id,
138
+ user_attribute_id=user_attribute_id,
139
+ )
129
140
 
130
141
  def update_user_attribute_value(self):
131
142
  if not self.value:
@@ -170,35 +181,16 @@ class UserAttributeUpdater(BaseModel):
170
181
  ),
171
182
  )
172
183
  elif self.update_type == "user":
173
-
174
- def set_user_attribute_user_value(user_id: str):
175
- sdk.set_user_attribute_user_value(
176
- user_id=user_id,
177
- user_attribute_id=user_attribute_id,
178
- body=WriteUserAttributeWithValue(
179
- value=self.value,
180
- ),
181
- )
182
-
183
- if self.looker_user_id:
184
- set_user_attribute_user_value(self.looker_user_id)
185
-
186
- elif self.external_user_id:
187
- user = sdk.user_for_credential("embed", self.external_user_id)
188
- if not (user and user.id):
189
- raise ValueError("User not found")
190
- set_user_attribute_user_value(user.id)
191
-
192
- elif self.email:
193
- user = sdk.user_for_credential("email", self.email)
194
- if not (user and user.id):
195
- raise ValueError("User not found")
196
- set_user_attribute_user_value(user.id)
197
-
198
- else:
184
+ looker_user_id = self._get_looker_user_id(sdk)
185
+ if not looker_user_id:
199
186
  raise ValueError("User not found")
200
- else:
201
- raise ValueError("Invalid update_type")
187
+ sdk.set_user_attribute_user_value(
188
+ user_id=looker_user_id,
189
+ user_attribute_id=user_attribute_id,
190
+ body=WriteUserAttributeWithValue(
191
+ value=self.value,
192
+ ),
193
+ )
202
194
 
203
195
 
204
196
  class AttributeUpdaterResponse(BaseModel):
@@ -14,16 +14,15 @@ group = typer.Typer()
14
14
 
15
15
  @group.command()
16
16
  def user_attribute_updater(
17
- ctx: typer.Context,
18
17
  host: str = typer.Option(default="127.0.0.1", envvar="HOST"),
19
- port: int = typer.Option(default=8080, envvar="x`"),
18
+ port: int = typer.Option(default=8080, envvar="PORT"),
20
19
  ):
21
20
  api = FastAPI()
22
21
 
23
22
  @api.post("/identity_token")
24
23
  def identity_token(request: Request, body: UserAttributeUpdater):
25
24
  try:
26
- body.get_request_authorization_for_value(request)
25
+ body.get_request_authorization_for_value(request.headers.items())
27
26
  body.update_user_attribute_value()
28
27
  raw_urls = os.getenv("LOOKER_WHITELISTED_BASE_URLS", "")
29
28
  whitelisted_base_urls = (
@@ -1,6 +1,6 @@
1
1
  # `lkr`
2
2
 
3
- LookML Repository CLI
3
+ A CLI for Looker with helpful tools
4
4
 
5
5
  **Usage**:
6
6