lkr-dev-cli 0.0.29__py3-none-any.whl → 0.0.31__py3-none-any.whl

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/main.py CHANGED
@@ -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")
lkr/tools/classes.py CHANGED
@@ -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):
lkr/tools/main.py CHANGED
@@ -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,23 +1,32 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lkr-dev-cli
3
- Version: 0.0.29
3
+ Version: 0.0.31
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
9
  Requires-Dist: cryptography>=42.0.0
10
- Requires-Dist: duckdb>=1.2.2
11
- Requires-Dist: fastapi>=0.115.12
12
10
  Requires-Dist: looker-sdk>=25.4.0
13
- Requires-Dist: mcp[cli]>=1.9.2
14
11
  Requires-Dist: pydantic>=2.11.4
15
12
  Requires-Dist: pydash>=8.0.5
16
13
  Requires-Dist: questionary>=2.1.0
17
14
  Requires-Dist: requests>=2.31.0
18
- Requires-Dist: selenium>=4.32.0
19
15
  Requires-Dist: structlog>=25.3.0
20
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'
21
30
  Description-Content-Type: text/markdown
22
31
 
23
32
  # lkr cli
@@ -129,7 +138,7 @@ The observability command provides tools for monitoring and interacting with Loo
129
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.
130
139
 
131
140
  > [!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.
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.
133
142
 
134
143
 
135
144
  For example:
@@ -209,4 +218,192 @@ gcloud monitoring uptime create lkr-observability-health-check \
209
218
  ```
210
219
 
211
220
  ### 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.
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.
@@ -5,7 +5,7 @@ lkr/constants.py,sha256=DdCfsV6q8wgs2iHpIQeb6oDP_2XejusEHyPvCbaM3yY,108
5
5
  lkr/custom_types.py,sha256=feJ-W2U61PJTiotMLuZJqxrotA53er95kO1O30mooy4,323
6
6
  lkr/exceptions.py,sha256=M_aR4YaCZtY4wyxhcoqJCVkxVu9z3Wwo5KgSDyOoEnI,210
7
7
  lkr/logger.py,sha256=vKlJZqiMzJbYBzmiiD0HzJp-J-rHd4nWX-7P4ZKgh78,2033
8
- lkr/main.py,sha256=pTVibYNb7Wh-dKVbqAozUGWPbeofqIo0gQceZSXoySQ,2464
8
+ lkr/main.py,sha256=TZR6GQ0Yc5Tp6XlUAVHi6Zpj3JcQ_DSZp9YuGhymu-c,2491
9
9
  lkr/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  lkr/auth/main.py,sha256=7tWGPWokzbBnrX1enZ9YP4rdDJqYBlGfuYe0Wg-fXT4,7532
11
11
  lkr/auth/oauth.py,sha256=n2yAcccdBZaloGVtFRTwCPBfh1cvVYNbXLsFCxmWc5M,7207
@@ -16,10 +16,10 @@ lkr/observability/classes.py,sha256=LgGuUnY-J1csPrlAKnw4PPOqOfbvaOx2cxENlQgJYcE,
16
16
  lkr/observability/embed_container.html,sha256=IcDG-QVsYYNGQGrkDrx9OMZ2Pmo4C8oAjRHddFQ7Tlw,2939
17
17
  lkr/observability/main.py,sha256=XbejIdqhNNUMqHVezb5EnLaJ32dO9-Bt0o5d8lc0kyw,9544
18
18
  lkr/observability/utils.py,sha256=UpaBrp_ufaXLoz4p3xG3K6lHKBpP9wBhvP8rDmeGoWg,2148
19
- lkr/tools/classes.py,sha256=7QJwm0Ywm8QIqkvgCWnpRI12C-DxLUznXj_BNF_gifI,7759
20
- lkr/tools/main.py,sha256=5Ak_VWziE-U2RiXy0bfGkAGHcjyD36OFxILdS06L0qg,2689
21
- lkr_dev_cli-0.0.29.dist-info/METADATA,sha256=Ldb4fpu2ztX7-07ynB3crjqhA9LxWx2IJwFMhYeJIWk,10953
22
- lkr_dev_cli-0.0.29.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
23
- lkr_dev_cli-0.0.29.dist-info/entry_points.txt,sha256=nn2sFMGDpwUVE61ZUpbDPnQZkW7Gc08nV-tyLGo8q34,37
24
- lkr_dev_cli-0.0.29.dist-info/licenses/LICENSE,sha256=hKnCOORW1JRE_M2vStz8dblS5u1iR-2VpqS9xagKNa0,1063
25
- lkr_dev_cli-0.0.29.dist-info/RECORD,,
19
+ lkr/tools/classes.py,sha256=ZyRRCQjjwV4WVWGmKlTfXiLiOGUf67XgrboYhOLuLts,7508
20
+ lkr/tools/main.py,sha256=u9O5JgGVFscav9wFcrHd5ZPUUue_KpeK0QukYKqzros,2683
21
+ lkr_dev_cli-0.0.31.dist-info/METADATA,sha256=8QimDm78NO_wvLUt3XJ_SlwdMkCY3pk5Jt8KY1FuGxo,18527
22
+ lkr_dev_cli-0.0.31.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
23
+ lkr_dev_cli-0.0.31.dist-info/entry_points.txt,sha256=nn2sFMGDpwUVE61ZUpbDPnQZkW7Gc08nV-tyLGo8q34,37
24
+ lkr_dev_cli-0.0.31.dist-info/licenses/LICENSE,sha256=hKnCOORW1JRE_M2vStz8dblS5u1iR-2VpqS9xagKNa0,1063
25
+ lkr_dev_cli-0.0.31.dist-info/RECORD,,