lkr-dev-cli 0.0.32__tar.gz → 0.0.33__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 (42) hide show
  1. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/PKG-INFO +35 -17
  2. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/README.md +29 -5
  3. lkr_dev_cli-0.0.33/lkr/tools/main.py +232 -0
  4. lkr_dev_cli-0.0.33/lkr/tools/permission_deprecation.py +193 -0
  5. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr.md +21 -0
  6. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/pyproject.toml +4 -12
  7. lkr_dev_cli-0.0.33/tests/test_permission_deprecation.py +176 -0
  8. lkr_dev_cli-0.0.32/lkr/tools/main.py +0 -87
  9. lkr_dev_cli-0.0.32/test.py +0 -16
  10. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/.github/workflows/release.yml +0 -0
  11. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/.github/workflows/test-dependencies.yml +0 -0
  12. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/.gitignore +0 -0
  13. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/.python-version +0 -0
  14. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/.vscode/launch.json +0 -0
  15. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/.vscode/settings.json +0 -0
  16. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/Dockerfile +0 -0
  17. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/LICENSE +0 -0
  18. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/Makefile +0 -0
  19. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/cloudbuild.yaml +0 -0
  20. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/__init__.py +0 -0
  21. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/auth/__init__.py +0 -0
  22. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/auth/main.py +0 -0
  23. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/auth/oauth.py +0 -0
  24. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/auth_service.py +0 -0
  25. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/classes.py +0 -0
  26. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/constants.py +0 -0
  27. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/custom_types.py +0 -0
  28. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/exceptions.py +0 -0
  29. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/logger.py +0 -0
  30. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/main.py +0 -0
  31. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/mcp/classes.py +0 -0
  32. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/mcp/main.py +0 -0
  33. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/mcp/utils.py +0 -0
  34. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/observability/classes.py +0 -0
  35. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/observability/embed_container.html +0 -0
  36. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/observability/main.py +0 -0
  37. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/observability/utils.py +0 -0
  38. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/lkr/tools/classes.py +0 -0
  39. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/tests/TESTING.md +0 -0
  40. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/tests/test_dependency_resolution.py +0 -0
  41. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/tests/test_deps.sh +0 -0
  42. {lkr_dev_cli-0.0.32 → lkr_dev_cli-0.0.33}/uv.lock +0 -0
@@ -1,30 +1,24 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lkr-dev-cli
3
- Version: 0.0.32
3
+ Version: 0.0.33
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>=42.0.0
9
10
  Requires-Dist: looker-sdk>=25.4.0
10
11
  Requires-Dist: pydantic>=2.11.4
11
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
12
17
  Provides-Extra: all
13
- Requires-Dist: cryptography>=42.0.0; extra == 'all'
14
18
  Requires-Dist: duckdb>=1.2.2; extra == 'all'
15
19
  Requires-Dist: fastapi[standard]>=0.115.12; extra == 'all'
16
20
  Requires-Dist: mcp[cli]>=1.9.2; extra == 'all'
17
- Requires-Dist: questionary>=2.1.0; extra == 'all'
18
- Requires-Dist: requests>=2.31.0; extra == 'all'
19
21
  Requires-Dist: selenium>=4.32.0; extra == 'all'
20
- Requires-Dist: structlog>=25.3.0; extra == 'all'
21
- Requires-Dist: typer>=0.15.2; extra == 'all'
22
- Provides-Extra: cli
23
- Requires-Dist: cryptography>=42.0.0; extra == 'cli'
24
- Requires-Dist: questionary>=2.1.0; extra == 'cli'
25
- Requires-Dist: requests>=2.31.0; extra == 'cli'
26
- Requires-Dist: structlog>=25.3.0; extra == 'cli'
27
- Requires-Dist: typer>=0.15.2; extra == 'cli'
28
22
  Provides-Extra: mcp
29
23
  Requires-Dist: duckdb>=1.2.2; extra == 'mcp'
30
24
  Requires-Dist: fastapi[standard]>=0.115.12; extra == 'mcp'
@@ -42,7 +36,7 @@ The `lkr` cli is a tool for interacting with Looker. It combines Looker's SDK an
42
36
 
43
37
  ## Usage
44
38
 
45
- `uv` makes everyone's life easier. Go [install it](https://docs.astral.sh/uv/getting-started/installation/). You can start using `lkr` by running `uv run --with lkr-dev-cli[all] lkr --help`.
39
+ `uv` makes everyone's life easier. Go [install it](https://docs.astral.sh/uv/getting-started/installation/). You can start using `lkr` by running `uvx --from lkr-dev-cli[all] lkr --help`.
46
40
 
47
41
  Alternatively, you can install `lkr` with `pip install lkr-dev-cli[all]` and use commands directly like `lkr <command>`.
48
42
 
@@ -62,7 +56,7 @@ See the [prerequisites section](#oauth2-prerequisites)
62
56
  Login to `lkr`
63
57
 
64
58
  ```bash
65
- uv run --with lkr-dev-cli[all] lkr auth login
59
+ uvx --from lkr-dev-cli[all] lkr auth login
66
60
  ```
67
61
 
68
62
  - Select a new instance
@@ -70,12 +64,12 @@ uv run --with lkr-dev-cli[all] lkr auth login
70
64
  - Choose whether you want this login to use production or development mode
71
65
  - Give it a name
72
66
 
73
- You will be redirected to the Looker OAuth authorization page, click Allow. If you do not see an allow button, the [prerequisites](#prerequisites) were not done properly.
67
+ You will be redirected to the Looker OAuth authorization page, click Allow. If you do not see an allow button, the [prerequisites](#oauth2-prerequisites) were not done properly.
74
68
 
75
69
  If everything is successful, you will see `Successfully authenticated!`. Test it with
76
70
 
77
71
  ```bash
78
- uv run --with lkr-dev-cli[all] lkr auth whoami
72
+ uvx --from lkr-dev-cli[all] lkr auth whoami
79
73
  ```
80
74
 
81
75
  ### Using API Key
@@ -83,7 +77,7 @@ uv run --with lkr-dev-cli[all] lkr auth whoami
83
77
  If you provide environment variables for `LOOKERSDK_CLIENT_ID`, `LOOKERSDK_CLIENT_SECRET`, and `LOOKERSDK_BASE_URL`, `lkr` will use the API key to authenticate and the commands. We also support command line arguments to pass in the client id, client secret, and base url.
84
78
 
85
79
  ```bash
86
- uv run --with lkr-dev-cli[all] lkr --client-id <your client id> --client-secret <your client secret> --base-url <your instance url> auth whoami
80
+ uvx --from lkr-dev-cli[all] lkr --client-id <your client id> --client-secret <your client secret> --base-url <your instance url> auth whoami
87
81
  ```
88
82
 
89
83
 
@@ -363,6 +357,30 @@ def delete_user_attribute(user_attribute_name: str, email: str):
363
357
  )
364
358
  updater.delete_user_attribute_value()
365
359
 
360
+ ## Permission Deprecation Tool
361
+
362
+ The `schedule-download-deprecation` tool helps Looker admins ensure that users do not lose access to models they already have when Looker moves towards more granular model-specific permissions for scheduling and downloading.
363
+
364
+ ### How it helps
365
+ Currently, some permissions in Looker can be granted instance-wide. In the future, these permissions may need to be explicitly granted at the model level (via Model Sets). This tool audits all active users and identifies those who:
366
+ - Have "target permissions" (like `download_with_limit`, `schedule_look_emails`, etc.) instance-wide.
367
+ - Do **not** have those same permissions for specific models they otherwise have access to.
368
+
369
+ By running this tool, an admin can proactively identify and fix permission gaps before any deprecation takes effect, ensuring a seamless experience for end-users.
370
+
371
+ ### Usage
372
+ This command should be run by a **Looker Admin**.
373
+
374
+ ```bash
375
+ uvx --from lkr-dev-cli[all] lkr tools schedule-download-deprecation
376
+ ```
377
+
378
+ Options:
379
+ - `--csv`: Export the results to a CSV file for easier analysis of large instances.
380
+ - `--unfiltered`: Show all users, including those who have all required permissions across all models.
381
+ - `--model-offset`: Slice the table output to show different sets of models (the table shows 5 models at a time).
382
+
383
+
366
384
  ## Optional Dependencies
367
385
 
368
386
  The `lkr` CLI supports optional dependencies that enable additional functionality. You can install these individually or all at once.
@@ -4,7 +4,7 @@ The `lkr` cli is a tool for interacting with Looker. It combines Looker's SDK an
4
4
 
5
5
  ## Usage
6
6
 
7
- `uv` makes everyone's life easier. Go [install it](https://docs.astral.sh/uv/getting-started/installation/). You can start using `lkr` by running `uv run --with lkr-dev-cli[all] lkr --help`.
7
+ `uv` makes everyone's life easier. Go [install it](https://docs.astral.sh/uv/getting-started/installation/). You can start using `lkr` by running `uvx --from lkr-dev-cli[all] lkr --help`.
8
8
 
9
9
  Alternatively, you can install `lkr` with `pip install lkr-dev-cli[all]` and use commands directly like `lkr <command>`.
10
10
 
@@ -24,7 +24,7 @@ See the [prerequisites section](#oauth2-prerequisites)
24
24
  Login to `lkr`
25
25
 
26
26
  ```bash
27
- uv run --with lkr-dev-cli[all] lkr auth login
27
+ uvx --from lkr-dev-cli[all] lkr auth login
28
28
  ```
29
29
 
30
30
  - Select a new instance
@@ -32,12 +32,12 @@ uv run --with lkr-dev-cli[all] lkr auth login
32
32
  - Choose whether you want this login to use production or development mode
33
33
  - Give it a name
34
34
 
35
- You will be redirected to the Looker OAuth authorization page, click Allow. If you do not see an allow button, the [prerequisites](#prerequisites) were not done properly.
35
+ You will be redirected to the Looker OAuth authorization page, click Allow. If you do not see an allow button, the [prerequisites](#oauth2-prerequisites) were not done properly.
36
36
 
37
37
  If everything is successful, you will see `Successfully authenticated!`. Test it with
38
38
 
39
39
  ```bash
40
- uv run --with lkr-dev-cli[all] lkr auth whoami
40
+ uvx --from lkr-dev-cli[all] lkr auth whoami
41
41
  ```
42
42
 
43
43
  ### Using API Key
@@ -45,7 +45,7 @@ uv run --with lkr-dev-cli[all] lkr auth whoami
45
45
  If you provide environment variables for `LOOKERSDK_CLIENT_ID`, `LOOKERSDK_CLIENT_SECRET`, and `LOOKERSDK_BASE_URL`, `lkr` will use the API key to authenticate and the commands. We also support command line arguments to pass in the client id, client secret, and base url.
46
46
 
47
47
  ```bash
48
- uv run --with lkr-dev-cli[all] lkr --client-id <your client id> --client-secret <your client secret> --base-url <your instance url> auth whoami
48
+ uvx --from lkr-dev-cli[all] lkr --client-id <your client id> --client-secret <your client secret> --base-url <your instance url> auth whoami
49
49
  ```
50
50
 
51
51
 
@@ -325,6 +325,30 @@ def delete_user_attribute(user_attribute_name: str, email: str):
325
325
  )
326
326
  updater.delete_user_attribute_value()
327
327
 
328
+ ## Permission Deprecation Tool
329
+
330
+ The `schedule-download-deprecation` tool helps Looker admins ensure that users do not lose access to models they already have when Looker moves towards more granular model-specific permissions for scheduling and downloading.
331
+
332
+ ### How it helps
333
+ Currently, some permissions in Looker can be granted instance-wide. In the future, these permissions may need to be explicitly granted at the model level (via Model Sets). This tool audits all active users and identifies those who:
334
+ - Have "target permissions" (like `download_with_limit`, `schedule_look_emails`, etc.) instance-wide.
335
+ - Do **not** have those same permissions for specific models they otherwise have access to.
336
+
337
+ By running this tool, an admin can proactively identify and fix permission gaps before any deprecation takes effect, ensuring a seamless experience for end-users.
338
+
339
+ ### Usage
340
+ This command should be run by a **Looker Admin**.
341
+
342
+ ```bash
343
+ uvx --from lkr-dev-cli[all] lkr tools schedule-download-deprecation
344
+ ```
345
+
346
+ Options:
347
+ - `--csv`: Export the results to a CSV file for easier analysis of large instances.
348
+ - `--unfiltered`: Show all users, including those who have all required permissions across all models.
349
+ - `--model-offset`: Slice the table output to show different sets of models (the table shows 5 models at a time).
350
+
351
+
328
352
  ## Optional Dependencies
329
353
 
330
354
  The `lkr` CLI supports optional dependencies that enable additional functionality. You can install these individually or all at once.
@@ -0,0 +1,232 @@
1
+ import os
2
+ import csv
3
+ import sys
4
+ from typing import Annotated, Optional
5
+
6
+ import typer
7
+ import uvicorn
8
+ from fastapi import FastAPI, Request
9
+
10
+ from lkr.logger import structured_logger as logger
11
+ from lkr.tools.classes import AttributeUpdaterResponse, UserAttributeUpdater
12
+ from lkr.tools.permission_deprecation import schedule_download_deprecation
13
+
14
+ __all__ = ["group"]
15
+
16
+ group = typer.Typer()
17
+
18
+ if not logger:
19
+ raise Exception("Logger is not available")
20
+
21
+
22
+ @group.command()
23
+ def user_attribute_updater(
24
+ host: str = typer.Option(default="127.0.0.1", envvar="HOST"),
25
+ port: int = typer.Option(default=8080, envvar="PORT"),
26
+ ):
27
+ api = FastAPI()
28
+
29
+ @api.post("/identity_token")
30
+ def identity_token(request: Request, body: UserAttributeUpdater):
31
+ try:
32
+ body.get_request_authorization_for_value(request.headers.items())
33
+ body.update_user_attribute_value()
34
+ raw_urls = os.getenv("LOOKER_WHITELISTED_BASE_URLS", "")
35
+ whitelisted_base_urls = (
36
+ [url.strip() for url in raw_urls.split(",") if url.strip()]
37
+ if raw_urls
38
+ else []
39
+ )
40
+ logger.debug(
41
+ "identify_token_user_attribute",
42
+ **body.model_dump(),
43
+ whitelisted_base_urls=whitelisted_base_urls,
44
+ )
45
+
46
+ if body.base_url not in whitelisted_base_urls:
47
+ raise Exception(f"Base URL {body.base_url} not whitelisted")
48
+
49
+ return AttributeUpdaterResponse(
50
+ success=True, message="User attribute updated"
51
+ )
52
+ except Exception as e:
53
+ return AttributeUpdaterResponse(success=False, message=str(e))
54
+
55
+ @api.delete("/value")
56
+ def delete_user_attribute_value(request: Request, body: UserAttributeUpdater):
57
+ try:
58
+ body.delete_user_attribute_value()
59
+ logger.debug(
60
+ "user_attribute_delete",
61
+ **body.model_dump(),
62
+ )
63
+ return AttributeUpdaterResponse(
64
+ success=True, message="User attribute value deleted"
65
+ )
66
+ except Exception as e:
67
+ return AttributeUpdaterResponse(success=False, message=str(e))
68
+
69
+ @api.post("/value")
70
+ def update_user_attribute_value(request: Request, body: UserAttributeUpdater):
71
+ try:
72
+ body.update_user_attribute_value()
73
+ logger.debug(
74
+ "user_attribute_update",
75
+ **body.model_dump(),
76
+ )
77
+ return AttributeUpdaterResponse(
78
+ success=True, message="User attribute value updated"
79
+ )
80
+ except Exception as e:
81
+ return AttributeUpdaterResponse(success=False, message=str(e))
82
+
83
+ @api.get("")
84
+ def health():
85
+ return {"status": "ok"}
86
+
87
+ uvicorn.run(api, host=host, port=port)
88
+
89
+
90
+ PERMISSION_MAP = {
91
+ "download_with_limit": "dwl",
92
+ "download_without_limit": "dwol",
93
+ "schedule_look_emails": "sle",
94
+ "schedule_external_look_emails": "sele",
95
+ "send_to_s3": "s3",
96
+ "send_to_sftp": "sftp",
97
+ "send_outgoing_webhook": "hook",
98
+ "send_to_integration": "intg",
99
+ }
100
+
101
+
102
+ def get_visual_length(s: str) -> int:
103
+ """Return the visual length of a string, accounting for double-width characters like ✅."""
104
+ # This is a simple heuristic for common emojis used in this tool.
105
+ # We count '✅' as width 2, while len() returns 1.
106
+ return len(s) + s.count("✅")
107
+
108
+
109
+ def visual_ljust(s: str, width: int) -> str:
110
+ """Left justify a string based on its visual length."""
111
+ return s + " " * (width - get_visual_length(s))
112
+
113
+
114
+ @group.command(name="schedule-download-deprecation")
115
+ def schedule_download_deprecation_command(
116
+ ctx: typer.Context,
117
+ limit: Annotated[Optional[int], typer.Option(help="Search batch size")] = 500,
118
+ model_offset: Annotated[int, typer.Option(help="Offset for model columns")] = 0,
119
+ csv_output: Annotated[bool, typer.Option("--csv", help="Output as CSV instead of a table")] = False,
120
+ csv_file_name: Annotated[Optional[str], typer.Option("--csv-file-name", help="Name for the output CSV file (without extension)")] = "schedule_download_deprecation",
121
+ unfiltered: Annotated[bool, typer.Option("--unfiltered", help="Show all rows, including those with no missing permissions")] = False,
122
+ email: Annotated[Optional[bool], typer.Option("--email", help="Use Email instead of Name")] = False,
123
+ ):
124
+ """
125
+ Build a table of users and their scheduling/downloading permissions per model.
126
+ """
127
+ result = schedule_download_deprecation(ctx, limit, unfiltered=unfiltered)
128
+ if not result:
129
+ typer.echo("No matching users found.")
130
+ return
131
+
132
+ if csv_output:
133
+ # For CSV, we ignore pagination and truncation
134
+ with open(csv_file_name + ".csv", "w", newline="") as f:
135
+ writer = csv.writer(f)
136
+ csv_headers = ["User ID", "Email" if email else "Name", "Instance Wide"] + result.model_names
137
+ writer.writerow(csv_headers)
138
+
139
+ for row in result.rows:
140
+ instance_wide = ", ".join(row.instance_wide) if row.instance_wide else " "
141
+ model_results = []
142
+ for m_name in result.model_names:
143
+ missing = row.model_permissions.get(m_name)
144
+ if missing is None:
145
+ model_results.append("N/A")
146
+ elif not row.has_target_perms:
147
+ model_results.append(" ")
148
+ elif not missing:
149
+ model_results.append("✅")
150
+ else:
151
+ model_results.append(", ".join(missing))
152
+
153
+ writer.writerow([row.user_id, row.email if email else row.name, instance_wide] + model_results)
154
+ typer.echo(f"CSV output written to {csv_file_name}.csv")
155
+ return
156
+
157
+ # Slice models to only show 5 at a time
158
+ total_models = len(result.model_names)
159
+ visible_models = result.model_names[model_offset : model_offset + 5]
160
+
161
+ # Truncate model names for display
162
+ display_model_names = [
163
+ (m if len(m) <= 10 else m[:7] + "...") for m in visible_models
164
+ ]
165
+
166
+ headers = ["User ID", "Email" if email else "Name", "Instance Wide"] + display_model_names
167
+
168
+ # Transform Pydantic rows into visual table rows
169
+ table_rows = []
170
+ for row in result.rows:
171
+ instance_wide_abbrev = [PERMISSION_MAP.get(p, p) for p in row.instance_wide]
172
+ instance_wide_str = "\n".join(instance_wide_abbrev) if instance_wide_abbrev else " "
173
+ model_results = []
174
+ for m_name in visible_models:
175
+ missing = row.model_permissions.get(m_name)
176
+ if missing is None:
177
+ model_results.append("N/A")
178
+ elif not row.has_target_perms:
179
+ model_results.append(" ")
180
+ elif not missing:
181
+ model_results.append("✅")
182
+ else:
183
+ missing_abbrev = [PERMISSION_MAP.get(p, p) for p in missing]
184
+ model_results.append("\n".join(missing_abbrev))
185
+
186
+ table_rows.append([row.user_id, row.email if email else row.name, instance_wide_str] + model_results)
187
+
188
+ # 5. Format and echo the table
189
+ col_widths = [
190
+ max(get_visual_length(str(line)) for r in ([headers] + table_rows) for line in str(r[i]).split("\n"))
191
+ for i in range(len(headers))
192
+ ]
193
+
194
+ def format_line(parts, widths):
195
+ return " | ".join(visual_ljust(str(p), w) for p, w in zip(parts, widths))
196
+
197
+ typer.echo(format_line(headers, col_widths))
198
+ typer.echo("-" * (sum(col_widths) + 3 * (len(headers) - 1)))
199
+
200
+ if not table_rows:
201
+ typer.echo("No users found matching the criteria. 🎉")
202
+ return
203
+
204
+ for row in table_rows:
205
+ max_lines = max(str(cell).count("\n") + 1 for cell in row)
206
+ row_lines = [str(cell).split("\n") for cell in row]
207
+
208
+ for line_idx in range(max_lines):
209
+ line_parts = [
210
+ rl[line_idx] if line_idx < len(rl) else ""
211
+ for rl in row_lines
212
+ ]
213
+ typer.echo(format_line(line_parts, col_widths))
214
+ typer.echo("-" * (sum(col_widths) + 3 * (len(headers) - 1)))
215
+
216
+ typer.echo("\n" + "=" * 30)
217
+ typer.echo("LEGEND (Shortcuts)")
218
+ typer.echo("-" * 30)
219
+ for full, short in PERMISSION_MAP.items():
220
+ typer.echo(f"{short.ljust(8)} = {full}")
221
+ typer.echo("=" * 30)
222
+
223
+ if model_offset + 5 < total_models:
224
+ next_offset = model_offset + 5
225
+ typer.echo(f"\nShowing models {model_offset+1}-{min(model_offset+5, total_models)} of {total_models}.")
226
+ typer.echo(f"Use --model-offset {next_offset} to see the next 5 models. Or use --csv for the full table.")
227
+ elif model_offset > 0:
228
+ typer.echo(f"\nShowing models {model_offset+1}-{total_models} of {total_models}.")
229
+
230
+
231
+ if __name__ == "__main__":
232
+ group()
@@ -0,0 +1,193 @@
1
+ import typer
2
+ from typing import List, Optional, Set, Dict
3
+ from concurrent.futures import ThreadPoolExecutor
4
+ from pydantic import BaseModel
5
+ from lkr.auth_service import get_auth
6
+ from lkr.logger import logger
7
+
8
+ TARGET_PERMISSIONS = frozenset([
9
+ "download_with_limit",
10
+ "download_without_limit",
11
+ "schedule_look_emails",
12
+ "schedule_external_look_emails",
13
+ "send_to_s3",
14
+ "send_to_sftp",
15
+ "send_outgoing_webhook",
16
+ "send_to_integration",
17
+ ])
18
+
19
+ class AuditRow(BaseModel):
20
+ user_id: str
21
+ name: str
22
+ email: Optional[str] = None
23
+ instance_wide: List[str]
24
+ model_permissions: Dict[str, Optional[List[str]]] # model_name -> missing permissions. None if no 'access_data'.
25
+ has_target_perms: bool
26
+
27
+ class DeprecationAuditResult(BaseModel):
28
+ model_names: List[str] # The columns
29
+ rows: List[AuditRow]
30
+
31
+ def schedule_download_deprecation(
32
+ ctx: typer.Context,
33
+ limit: int = 500,
34
+ unfiltered: bool = False,
35
+ ) -> Optional[DeprecationAuditResult]:
36
+ """
37
+ Build a audit result of users and their scheduling/downloading permissions per model.
38
+ """
39
+ sdk = get_auth(ctx).get_current_sdk()
40
+
41
+ # 1. Query models to define columns
42
+ logger.info("Fetching LookML models...")
43
+ models = sdk.all_lookml_models(fields="name")
44
+ model_names = sorted([m.name for m in models if m.name])
45
+
46
+ # 2. Query all roles, permission sets, and model sets
47
+ logger.info("Fetching roles and permissions...")
48
+ roles = sdk.all_roles(fields="id,name,permission_set,model_set")
49
+
50
+ # Pre-process roles for faster lookup
51
+ role_map = {}
52
+ for role in roles:
53
+ target_perms_in_role = set()
54
+ has_access_data = False
55
+ is_admin = False
56
+
57
+ # Check if it's the Admin role (Admin permission set)
58
+ if role.permission_set:
59
+ if role.permission_set.name == "Admin":
60
+ is_admin = True
61
+ target_perms_in_role = set(TARGET_PERMISSIONS)
62
+ has_access_data = True
63
+ elif role.permission_set.permissions:
64
+ role_perms = set(role.permission_set.permissions)
65
+ target_perms_in_role = set([p for p in TARGET_PERMISSIONS if p in role_perms])
66
+ has_access_data = "access_data" in role_perms
67
+
68
+ role_models = set()
69
+ all_models = False
70
+ if is_admin:
71
+ all_models = True
72
+ elif role.model_set:
73
+ if role.model_set.name == "All" or (role.model_set.models and "*" in role.model_set.models):
74
+ all_models = True
75
+ elif role.model_set.models:
76
+ role_models = set(role.model_set.models)
77
+
78
+ role_map[str(role.id)] = {
79
+ "target_perms": target_perms_in_role,
80
+ "has_access_data": has_access_data,
81
+ "models": role_models,
82
+ "all_models": all_models
83
+ }
84
+
85
+ # 3. Pagination - Fetch all non-embed, active users using ThreadPoolExecutor
86
+ all_users = []
87
+ offset = 0
88
+ max_workers = 10
89
+
90
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
91
+ while True:
92
+ logger.info(f"Fetching users batch starting at offset {offset}...")
93
+ futures = []
94
+ for i in range(max_workers):
95
+ futures.append(executor.submit(
96
+ sdk.search_users,
97
+ is_disabled=False,
98
+ embed_user=False,
99
+ limit=limit,
100
+ offset=offset + (i * limit),
101
+ fields="id,first_name,last_name,role_ids,external_id,email"
102
+ ))
103
+
104
+ batch_done = False
105
+ for future in futures:
106
+ users = future.result()
107
+ if not users:
108
+ batch_done = True
109
+ continue
110
+ # Filter out embed users locally (embed users have an external_id)
111
+ active_non_embed = [u for u in users if not getattr(u, 'external_id', None)]
112
+ all_users.extend(active_non_embed)
113
+ if len(users) < limit:
114
+ batch_done = True
115
+
116
+ if batch_done:
117
+ break
118
+ offset += (max_workers * limit)
119
+
120
+ if not all_users:
121
+ return None
122
+
123
+ # 4. Prepare Result Data
124
+ audit_rows = []
125
+
126
+ for user in all_users:
127
+ user_id = user.id
128
+ name = f"{user.first_name or ''} {user.last_name or ''}".strip()
129
+
130
+ user_role_ids = user.role_ids or []
131
+ user_instance_perms: Set[str] = set()
132
+ # Track permissions per model
133
+ user_model_perms = {m: set() for m in model_names}
134
+ # Track if user has access_data per model
135
+ user_model_access_data = {m: False for m in model_names}
136
+
137
+ for r_id in user_role_ids:
138
+ role_info = role_map.get(str(r_id))
139
+ if not role_info:
140
+ continue
141
+
142
+ p_set = role_info["target_perms"]
143
+ has_ad = role_info["has_access_data"]
144
+
145
+ # If the role has target perms, add them to instance wide
146
+ user_instance_perms.update(p_set)
147
+
148
+ # Update per-model tracking
149
+ target_models = model_names if role_info["all_models"] else role_info["models"]
150
+ for m_name in target_models:
151
+ if m_name in model_names:
152
+ user_model_perms[m_name].update(p_set)
153
+ if has_ad:
154
+ user_model_access_data[m_name] = True
155
+
156
+ model_results = {}
157
+ for m_name in model_names:
158
+ if not user_model_access_data[m_name]:
159
+ # No access_data for this model -> N/A
160
+ model_results[m_name] = None
161
+ elif not user_instance_perms:
162
+ # User has access_data but no target perms instance-wide
163
+ model_results[m_name] = []
164
+ else:
165
+ # User has access_data and some target perms instance-wide
166
+ # List missing target perms for this model
167
+ missing = user_instance_perms - user_model_perms[m_name]
168
+ model_results[m_name] = sorted(list(missing))
169
+
170
+ audit_rows.append(AuditRow(
171
+ user_id=str(user_id),
172
+ name=name,
173
+ email=user.email,
174
+ instance_wide=sorted(list(user_instance_perms)),
175
+ model_permissions=model_results,
176
+ has_target_perms=len(user_instance_perms) > 0
177
+ ))
178
+
179
+ # 5. Filter Results unless unfiltered is True
180
+ if not unfiltered:
181
+ # Only show rows where someone has a value in any one of the model_name columns.
182
+ # Hide if all are check marks (empty list) or all are N/A (None).
183
+ # We check if there's ANY model where the user has missing permissions (non-empty list).
184
+ filtered_rows = [
185
+ row for row in audit_rows
186
+ if any(row.model_permissions[m] for m in model_names if row.model_permissions[m] is not None)
187
+ ]
188
+ audit_rows = filtered_rows
189
+
190
+ return DeprecationAuditResult(
191
+ model_names=model_names,
192
+ rows=audit_rows
193
+ )
@@ -186,6 +186,7 @@ $ lkr tools [OPTIONS] COMMAND [ARGS]...
186
186
  **Commands**:
187
187
 
188
188
  * `user-attribute-updater`
189
+ * `schedule-download-deprecation`: Build a table of users and their...
189
190
 
190
191
  ### `lkr tools user-attribute-updater`
191
192
 
@@ -200,3 +201,23 @@ $ lkr tools user-attribute-updater [OPTIONS]
200
201
  * `--host TEXT`: [env var: HOST; default: 127.0.0.1]
201
202
  * `--port INTEGER`: [env var: PORT; default: 8080]
202
203
  * `--help`: Show this message and exit.
204
+
205
+ ### `lkr tools schedule-download-deprecation`
206
+
207
+ Build a table of users and their scheduling/downloading permissions per model.
208
+
209
+ **Usage**:
210
+
211
+ ```console
212
+ $ lkr tools schedule-download-deprecation [OPTIONS]
213
+ ```
214
+
215
+ **Options**:
216
+
217
+ * `--limit INTEGER`: Search batch size [default: 500]
218
+ * `--model-offset INTEGER`: Offset for model columns [default: 0]
219
+ * `--csv`: Output as CSV instead of a table
220
+ * `--csv-file-name TEXT`: Name for the output CSV file (without extension) [default: schedule_download_deprecation]
221
+ * `--unfiltered`: Show all rows, including those with no missing permissions
222
+ * `--email`: Use Email instead of Name
223
+ * `--help`: Show this message and exit.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lkr-dev-cli"
3
- version = "0.0.32"
3
+ version = "0.0.33"
4
4
  description = "lkr: a command line interface for looker"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -11,18 +11,15 @@ requires-python = ">=3.12"
11
11
  dependencies = [
12
12
  "looker-sdk>=25.4.0",
13
13
  "pydantic>=2.11.4",
14
- "pydash>=8.0.5"
15
- ]
16
-
17
- [project.optional-dependencies]
18
-
19
- cli = [
14
+ "pydash>=8.0.5",
20
15
  "typer>=0.15.2",
21
16
  "requests>=2.31.0",
22
17
  "cryptography>=42.0.0",
23
18
  "structlog>=25.3.0",
24
19
  "questionary>=2.1.0"
25
20
  ]
21
+
22
+ [project.optional-dependencies]
26
23
  mcp = [
27
24
  "mcp[cli]>=1.9.2",
28
25
  "duckdb>=1.2.2",
@@ -36,11 +33,6 @@ tools = [
36
33
  "fastapi[standard]>=0.115.12"
37
34
  ]
38
35
  all = [
39
- "typer>=0.15.2",
40
- "requests>=2.31.0",
41
- "cryptography>=42.0.0",
42
- "structlog>=25.3.0",
43
- "questionary>=2.1.0",
44
36
  "mcp[cli]>=1.9.2",
45
37
  "fastapi[standard]>=0.115.12",
46
38
  "selenium>=4.32.0",
@@ -0,0 +1,176 @@
1
+ import pytest
2
+ from unittest.mock import MagicMock, patch
3
+ from lkr.tools.permission_deprecation import schedule_download_deprecation
4
+
5
+ @pytest.fixture
6
+ def mock_sdk():
7
+ sdk = MagicMock()
8
+
9
+ # Mock models
10
+ model_thelook = MagicMock()
11
+ model_thelook.name = "thelook"
12
+ model_finance = MagicMock()
13
+ model_finance.name = "finance"
14
+ sdk.all_lookml_models.return_value = [model_thelook, model_finance]
15
+
16
+ # User 1: has download_with_limit + access_data on thelook, but ONLY access_data on finance
17
+ # Role 1: target + data on thelook
18
+ role1 = MagicMock()
19
+ role1.id = "1"
20
+ role1.permission_set.permissions = ["download_with_limit", "access_data"]
21
+ role1.model_set.models = ["thelook"]
22
+
23
+ # Role 2: access_data on finance
24
+ role2 = MagicMock()
25
+ role2.id = "2"
26
+ role2.permission_set.permissions = ["access_data"]
27
+ role2.model_set.models = ["finance"]
28
+
29
+ # User 2: has access_data on thelook, no access_data on finance, has download_with_limit globally or on some other model
30
+ # Role 3: download_with_limit on some other model (or we can just use Role 1 but not assign Role 1 to User 2)
31
+ # Role 4: access_data on thelook
32
+ role4 = MagicMock()
33
+ role4.id = "4"
34
+ role4.permission_set.permissions = ["access_data"]
35
+ role4.model_set.models = ["thelook"]
36
+
37
+ # Role 5: download_with_limit on a model User 2 doesn't have access_data for (e.g. they just have the perm)
38
+ role5 = MagicMock()
39
+ role5.id = "5"
40
+ role5.permission_set.permissions = ["download_with_limit"]
41
+ role5.model_set.models = ["finance"] # they have the target but NO access_data on finance
42
+
43
+ sdk.all_roles.return_value = [role1, role2, role4, role5]
44
+
45
+ return sdk
46
+
47
+ @patch("lkr.tools.permission_deprecation.get_auth")
48
+ def test_user_scenario_with_access_data(mock_get_auth, mock_sdk):
49
+ mock_get_auth.return_value.get_current_sdk.return_value = mock_sdk
50
+
51
+ # User 1: Role 1 (Target+Data on thelook), Role 2 (Data on finance)
52
+ user1 = MagicMock()
53
+ user1.id = 1
54
+ user1.first_name = "User"
55
+ user1.last_name = "One"
56
+ user1.role_ids = ["1", "2"]
57
+ user1.external_id = None
58
+
59
+ # User 2: Role 4 (Data on thelook), Role 5 (Target on finance)
60
+ user2 = MagicMock()
61
+ user2.id = 2
62
+ user2.first_name = "User"
63
+ user2.last_name = "Two"
64
+ user2.role_ids = ["4", "5"]
65
+ user2.external_id = None
66
+
67
+ def search_side_effect(*args, **kwargs):
68
+ offset = kwargs.get('offset', 0)
69
+ if offset == 0:
70
+ return [user1, user2]
71
+ return []
72
+ mock_sdk.search_users.side_effect = search_side_effect
73
+
74
+ ctx = MagicMock()
75
+ result = schedule_download_deprecation(ctx, limit=500)
76
+
77
+ assert result is not None
78
+
79
+ # User 1 check
80
+ u1 = next(r for r in result.rows if r.user_id == "1")
81
+ assert "download_with_limit" in u1.instance_wide
82
+ # thelook: Role 1 gives both -> ✅
83
+ assert u1.model_permissions["thelook"] == []
84
+ # finance: Role 2 gives access_data, No role gives download_with_limit on finance -> MISSING
85
+ assert "download_with_limit" in u1.model_permissions["finance"]
86
+
87
+ # User 2 check
88
+ u2 = next(r for r in result.rows if r.user_id == "2")
89
+ assert "download_with_limit" in u2.instance_wide
90
+ # thelook: Role 4 gives access_data, No role gives target on thelook -> MISSING
91
+ assert "download_with_limit" in u2.model_permissions["thelook"]
92
+ # finance: Role 5 gives target, but NO ONE gives access_data -> N/A
93
+ assert u2.model_permissions["finance"] is None
94
+
95
+ @patch("lkr.tools.permission_deprecation.get_auth")
96
+ def test_schedule_download_deprecation_admin(mock_get_auth, mock_sdk):
97
+ admin_role = MagicMock()
98
+ admin_role.id = "admin_id"
99
+ admin_role.permission_set.name = "Admin"
100
+ admin_role.model_set.models = ["*"]
101
+
102
+ user = MagicMock()
103
+ user.id = 3
104
+ user.role_ids = ["admin_id"]
105
+ user.external_id = None
106
+
107
+ mock_sdk.all_roles.return_value = [admin_role]
108
+
109
+ def search_side_effect(*args, **kwargs):
110
+ offset = kwargs.get('offset', 0)
111
+ if offset == 0:
112
+ return [user]
113
+ return []
114
+ mock_sdk.search_users.side_effect = search_side_effect
115
+
116
+ mock_get_auth.return_value.get_current_sdk.return_value = mock_sdk
117
+
118
+ ctx = MagicMock()
119
+ # Default behavior: Admin is filtered out because they have no missing permissions
120
+ result = schedule_download_deprecation(ctx, limit=500)
121
+ assert len(result.rows) == 0
122
+
123
+ # With unfiltered=True: Admin should be present
124
+ result_unfiltered = schedule_download_deprecation(ctx, limit=500, unfiltered=True)
125
+ assert len(result_unfiltered.rows) == 1
126
+ for model in result_unfiltered.model_names:
127
+ assert result_unfiltered.rows[0].model_permissions[model] == []
128
+
129
+ @patch("lkr.tools.permission_deprecation.get_auth")
130
+ def test_schedule_download_deprecation_filtering_logic(mock_get_auth, mock_sdk):
131
+ mock_get_auth.return_value.get_current_sdk.return_value = mock_sdk
132
+
133
+ # User A: Has missing permissions (Should always show)
134
+ # Role 4 gives access_data on 'thelook'
135
+ # Role 5 gives 'download_with_limit' on 'finance' (but not on 'thelook')
136
+ # Result: User A will show 'download_with_limit' MISSING on 'thelook'
137
+ user_a = MagicMock()
138
+ user_a.id = "A"
139
+ user_a.role_ids = ["4", "5"]
140
+ user_a.external_id = None
141
+
142
+ # User B: Has all permissions (Should be filtered)
143
+ user_b = MagicMock()
144
+ user_b.id = "B"
145
+ user_b.role_ids = ["1"] # role 1 has target + access_data on thelook
146
+ user_b.external_id = None
147
+
148
+ # User C: No access data (Should be filtered)
149
+ user_c = MagicMock()
150
+ user_c.id = "C"
151
+ user_c.role_ids = [] # No roles -> All N/A
152
+ user_c.external_id = None
153
+
154
+ def search_side_effect(*args, **kwargs):
155
+ offset = kwargs.get('offset', 0)
156
+ if offset == 0:
157
+ return [user_a, user_b, user_c]
158
+ return []
159
+ mock_sdk.search_users.side_effect = search_side_effect
160
+
161
+ ctx = MagicMock()
162
+
163
+ # Test Default (Filtered)
164
+ result = schedule_download_deprecation(ctx, limit=500, unfiltered=False)
165
+ # Only User A should remain
166
+ assert len(result.rows) == 1
167
+ assert result.rows[0].user_id == "A"
168
+
169
+ # Test Unfiltered
170
+ result_unfiltered = schedule_download_deprecation(ctx, limit=500, unfiltered=True)
171
+ # All 3 users should remain
172
+ assert len(result_unfiltered.rows) == 3
173
+ ids = [r.user_id for r in result_unfiltered.rows]
174
+ assert "A" in ids
175
+ assert "B" in ids
176
+ assert "C" in ids
@@ -1,87 +0,0 @@
1
- import os
2
-
3
- import typer
4
- import uvicorn
5
- from fastapi import FastAPI, Request
6
-
7
- from lkr.logger import structured_logger as logger
8
- from lkr.tools.classes import AttributeUpdaterResponse, UserAttributeUpdater
9
-
10
- __all__ = ["group"]
11
-
12
- group = typer.Typer()
13
-
14
- if not logger:
15
- raise Exception("Logger is not available")
16
-
17
-
18
- @group.command()
19
- def user_attribute_updater(
20
- host: str = typer.Option(default="127.0.0.1", envvar="HOST"),
21
- port: int = typer.Option(default=8080, envvar="PORT"),
22
- ):
23
- api = FastAPI()
24
-
25
- @api.post("/identity_token")
26
- def identity_token(request: Request, body: UserAttributeUpdater):
27
- try:
28
- body.get_request_authorization_for_value(request.headers.items())
29
- body.update_user_attribute_value()
30
- raw_urls = os.getenv("LOOKER_WHITELISTED_BASE_URLS", "")
31
- whitelisted_base_urls = (
32
- [url.strip() for url in raw_urls.split(",") if url.strip()]
33
- if raw_urls
34
- else []
35
- )
36
- logger.debug(
37
- "identify_token_user_attribute",
38
- **body.model_dump(),
39
- whitelisted_base_urls=whitelisted_base_urls,
40
- )
41
-
42
- if body.base_url not in whitelisted_base_urls:
43
- raise Exception(f"Base URL {body.base_url} not whitelisted")
44
-
45
- return AttributeUpdaterResponse(
46
- success=True, message="User attribute updated"
47
- )
48
- except Exception as e:
49
- return AttributeUpdaterResponse(success=False, message=str(e))
50
-
51
- @api.delete("/value")
52
- def delete_user_attribute_value(request: Request, body: UserAttributeUpdater):
53
- try:
54
- body.delete_user_attribute_value()
55
- logger.debug(
56
- "user_attribute_delete",
57
- **body.model_dump(),
58
- )
59
- return AttributeUpdaterResponse(
60
- success=True, message="User attribute value deleted"
61
- )
62
- except Exception as e:
63
- return AttributeUpdaterResponse(success=False, message=str(e))
64
-
65
- @api.post("/value")
66
- def update_user_attribute_value(request: Request, body: UserAttributeUpdater):
67
- try:
68
- body.update_user_attribute_value()
69
- logger.debug(
70
- "user_attribute_update",
71
- **body.model_dump(),
72
- )
73
- return AttributeUpdaterResponse(
74
- success=True, message="User attribute value updated"
75
- )
76
- except Exception as e:
77
- return AttributeUpdaterResponse(success=False, message=str(e))
78
-
79
- @api.get("")
80
- def health():
81
- return {"status": "ok"}
82
-
83
- uvicorn.run(api, host=host, port=port)
84
-
85
-
86
- if __name__ == "__main__":
87
- group()
@@ -1,16 +0,0 @@
1
- from lkr.tools.classes import UserAttributeUpdater
2
-
3
-
4
- updater = UserAttributeUpdater(
5
- user_attribute="test",
6
- value="test",
7
- update_type="default",
8
- )
9
- print(updater.model_dump_json())
10
-
11
-
12
-
13
-
14
-
15
-
16
-
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes