lkr-dev-cli 0.0.41__tar.gz → 0.0.42__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 (63) hide show
  1. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/PKG-INFO +45 -3
  2. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/README.md +44 -2
  3. lkr_dev_cli-0.0.42/lkr/__init__.py +5 -0
  4. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/auth_service.py +62 -1
  5. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/classes.py +4 -0
  6. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/extended_sdk_methods/__init__.py +2 -0
  7. lkr_dev_cli-0.0.42/lkr/tools/lookml.py +386 -0
  8. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/tools/main.py +3 -0
  9. lkr_dev_cli-0.0.42/lkr_dev_cli/__init__.py +5 -0
  10. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/pyproject.toml +2 -2
  11. lkr_dev_cli-0.0.42/tests/test_init_sdk.py +167 -0
  12. lkr_dev_cli-0.0.42/tests/test_lookml_tools.py +268 -0
  13. lkr_dev_cli-0.0.41/lkr/__init__.py +0 -3
  14. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/.github/workflows/ci.yml +0 -0
  15. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/.github/workflows/release.yml +0 -0
  16. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/.gitignore +0 -0
  17. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/.python-version +0 -0
  18. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/.vscode/launch.json +0 -0
  19. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/.vscode/settings.json +0 -0
  20. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/Dockerfile +0 -0
  21. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/LICENSE +0 -0
  22. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/Makefile +0 -0
  23. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/cloudbuild.yaml +0 -0
  24. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/codemode.md +0 -0
  25. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/auth/__init__.py +0 -0
  26. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/auth/main.py +0 -0
  27. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/auth/oauth.py +0 -0
  28. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/codemode/LOCAL.md +0 -0
  29. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/codemode/__init__.py +0 -0
  30. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/codemode/constant.py +0 -0
  31. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/codemode/download_swagger.py +0 -0
  32. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/codemode/examples.py +0 -0
  33. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/codemode/help.py +0 -0
  34. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/codemode/main.py +0 -0
  35. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/codemode/readme.py +0 -0
  36. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/codemode/swagger.json +0 -0
  37. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/codemode/type.py +0 -0
  38. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/constants.py +0 -0
  39. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/custom_types.py +0 -0
  40. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/exceptions.py +0 -0
  41. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/extended_sdk_methods/classes.py +0 -0
  42. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/extended_sdk_methods/main.py +0 -0
  43. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/logger.py +0 -0
  44. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/main.py +0 -0
  45. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/mcp/classes.py +0 -0
  46. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/mcp/main.py +0 -0
  47. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/mcp/utils.py +0 -0
  48. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/observability/classes.py +0 -0
  49. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/observability/embed_container.html +0 -0
  50. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/observability/main.py +0 -0
  51. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/observability/utils.py +0 -0
  52. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/tools/classes.py +0 -0
  53. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr/tools/permission_deprecation.py +0 -0
  54. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/lkr.md +0 -0
  55. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/tests/TESTING.md +0 -0
  56. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/tests/test_codemode.py +0 -0
  57. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/tests/test_dependency_resolution.py +0 -0
  58. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/tests/test_deps.sh +0 -0
  59. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/tests/test_extended_sdk_methods.py +0 -0
  60. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/tests/test_oauth_account.py +0 -0
  61. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/tests/test_permission_deprecation.py +0 -0
  62. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/ty.toml +0 -0
  63. {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.42}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lkr-dev-cli
3
- Version: 0.0.41
3
+ Version: 0.0.42
4
4
  Summary: lkr: a command line interface for looker
5
5
  Author: bwebs
6
6
  License-Expression: MIT
@@ -288,9 +288,51 @@ gcloud run deploy lkr-access-token-updater \
288
288
  --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>
289
289
  ```
290
290
 
291
- ## UserAttributeUpdater `lkr-dev-cli`
291
+ ## Python SDK & Library Usage
292
292
 
293
- 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.
293
+ When installing `lkr-dev-cli` in your Python environment, you can import tools and pre-authenticated Looker SDK clients directly into your scripts, apps, or Jupyter notebooks.
294
+
295
+ ### Package Import Aliases (`lkr` vs `lkr_dev_cli`)
296
+ To maximize convenience and match user intuition, both `lkr` and `lkr_dev_cli` are available as identical top-level Python packages. You can use them completely interchangeably:
297
+
298
+ ```python
299
+ from lkr import init_sdk, UserAttributeUpdater
300
+ # works exactly identical to:
301
+ from lkr_dev_cli import init_sdk, UserAttributeUpdater
302
+ ```
303
+
304
+ ### Initializing the Looker SDK (`init_sdk`)
305
+ The `init_sdk()` function initializes and returns an authenticated `ExtendedLooker40SDK` instance. It automatically handles credential discovery and authentication:
306
+
307
+ ```python
308
+ from lkr_dev_cli import init_sdk
309
+
310
+ # Option 1: Explicit API Authentication
311
+ sdk = init_sdk(
312
+ base_url="https://acme.cloud.looker.com",
313
+ client_id="your-client-id",
314
+ client_secret="your-client-secret",
315
+ verify_ssl=True, # optional, defaults to True
316
+ )
317
+
318
+ # Option 2: Environment Variable API Authentication
319
+ # Automatically uses LOOKERSDK_BASE_URL, LOOKERSDK_CLIENT_ID, LOOKERSDK_CLIENT_SECRET
320
+ sdk = init_sdk()
321
+
322
+ # Option 3: Database (SQLite) OAuth Login Profile
323
+ # Uses your saved active session from running `lkr auth login`
324
+ sdk = init_sdk() # Picks the active current_instance = 1
325
+ # Or pick a specific named login profile:
326
+ sdk = init_sdk(instance_name="my_dev_instance")
327
+
328
+ # Make an API call
329
+ me = sdk.me()
330
+ print(f"Hello, {me.display_name}")
331
+ ```
332
+
333
+ ### UserAttributeUpdater
334
+
335
+ Exported from the package is the `UserAttributeUpdater` pydantic class. This class has all the necessary logic to update a user attribute value.
294
336
 
295
337
  It supports the following operations:
296
338
  - Updating a default value
@@ -252,9 +252,51 @@ gcloud run deploy lkr-access-token-updater \
252
252
  --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>
253
253
  ```
254
254
 
255
- ## UserAttributeUpdater `lkr-dev-cli`
255
+ ## Python SDK & Library Usage
256
256
 
257
- 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.
257
+ When installing `lkr-dev-cli` in your Python environment, you can import tools and pre-authenticated Looker SDK clients directly into your scripts, apps, or Jupyter notebooks.
258
+
259
+ ### Package Import Aliases (`lkr` vs `lkr_dev_cli`)
260
+ To maximize convenience and match user intuition, both `lkr` and `lkr_dev_cli` are available as identical top-level Python packages. You can use them completely interchangeably:
261
+
262
+ ```python
263
+ from lkr import init_sdk, UserAttributeUpdater
264
+ # works exactly identical to:
265
+ from lkr_dev_cli import init_sdk, UserAttributeUpdater
266
+ ```
267
+
268
+ ### Initializing the Looker SDK (`init_sdk`)
269
+ The `init_sdk()` function initializes and returns an authenticated `ExtendedLooker40SDK` instance. It automatically handles credential discovery and authentication:
270
+
271
+ ```python
272
+ from lkr_dev_cli import init_sdk
273
+
274
+ # Option 1: Explicit API Authentication
275
+ sdk = init_sdk(
276
+ base_url="https://acme.cloud.looker.com",
277
+ client_id="your-client-id",
278
+ client_secret="your-client-secret",
279
+ verify_ssl=True, # optional, defaults to True
280
+ )
281
+
282
+ # Option 2: Environment Variable API Authentication
283
+ # Automatically uses LOOKERSDK_BASE_URL, LOOKERSDK_CLIENT_ID, LOOKERSDK_CLIENT_SECRET
284
+ sdk = init_sdk()
285
+
286
+ # Option 3: Database (SQLite) OAuth Login Profile
287
+ # Uses your saved active session from running `lkr auth login`
288
+ sdk = init_sdk() # Picks the active current_instance = 1
289
+ # Or pick a specific named login profile:
290
+ sdk = init_sdk(instance_name="my_dev_instance")
291
+
292
+ # Make an API call
293
+ me = sdk.me()
294
+ print(f"Hello, {me.display_name}")
295
+ ```
296
+
297
+ ### UserAttributeUpdater
298
+
299
+ Exported from the package is the `UserAttributeUpdater` pydantic class. This class has all the necessary logic to update a user attribute value.
258
300
 
259
301
  It supports the following operations:
260
302
  - Updating a default value
@@ -0,0 +1,5 @@
1
+ from lkr.tools.classes import UserAttributeUpdater
2
+ from lkr.extended_sdk_methods import ExtendedLooker40SDK
3
+ from lkr.auth_service import init_sdk
4
+
5
+ __all__ = ["UserAttributeUpdater", "ExtendedLooker40SDK", "init_sdk"]
@@ -22,7 +22,13 @@ from lkr.constants import LOOKER_API_VERSION, OAUTH_CLIENT_ID
22
22
  from lkr.custom_types import NewTokenCallback
23
23
  from lkr.logger import logger
24
24
 
25
- __all__ = ["get_auth", "ApiKeyAuthSession", "DbOAuthSession", "is_auth_expired"]
25
+ __all__ = [
26
+ "get_auth",
27
+ "ApiKeyAuthSession",
28
+ "DbOAuthSession",
29
+ "is_auth_expired",
30
+ "init_sdk",
31
+ ]
26
32
 
27
33
 
28
34
  def is_auth_expired(e: Exception) -> bool:
@@ -57,6 +63,7 @@ class ApiKeyApiSettings(ApiSettings):
57
63
  base_url=self.api_key.base_url,
58
64
  client_id=self.api_key.client_id,
59
65
  client_secret=self.api_key.client_secret,
66
+ verify_ssl=str(self.api_key.verify_ssl).lower(),
60
67
  )
61
68
 
62
69
 
@@ -624,3 +631,57 @@ class ApiKeyAuth:
624
631
  raise NotImplementedError(
625
632
  "ApiKeyAuth does not support getting current instance"
626
633
  )
634
+
635
+
636
+ def init_sdk(
637
+ base_url: str | None = None,
638
+ client_id: str | None = None,
639
+ client_secret: str | None = None,
640
+ verify_ssl: bool | None = None,
641
+ use_production: bool = True,
642
+ instance_name: str | None = None,
643
+ ) -> Looker40SDK:
644
+ resolved_base_url = base_url or os.environ.get("LOOKERSDK_BASE_URL")
645
+ resolved_client_id = client_id or os.environ.get("LOOKERSDK_CLIENT_ID")
646
+ resolved_client_secret = (
647
+ client_secret or os.environ.get("LOOKERSDK_CLIENT_SECRET")
648
+ )
649
+
650
+ resolved_verify_ssl: bool
651
+ if verify_ssl is not None:
652
+ resolved_verify_ssl = verify_ssl
653
+ else:
654
+ if "LOOKERSDK_VERIFY_SSL" in os.environ:
655
+ verify_ssl_str = os.environ.get("LOOKERSDK_VERIFY_SSL", "true").lower()
656
+ resolved_verify_ssl = verify_ssl_str not in ("false", "0", "f")
657
+ else:
658
+ resolved_verify_ssl = True
659
+
660
+ if resolved_base_url or resolved_client_id or resolved_client_secret:
661
+ if not (
662
+ resolved_base_url and resolved_client_id and resolved_client_secret
663
+ ):
664
+ missing = []
665
+ if not resolved_base_url:
666
+ missing.append("base_url")
667
+ if not resolved_client_id:
668
+ missing.append("client_id")
669
+ if not resolved_client_secret:
670
+ missing.append("client_secret")
671
+ raise ValueError(
672
+ f"Incomplete API key configuration. Missing: {', '.join(missing)}"
673
+ )
674
+ api_key = LookerApiKey(
675
+ client_id=resolved_client_id,
676
+ client_secret=resolved_client_secret,
677
+ base_url=resolved_base_url,
678
+ verify_ssl=resolved_verify_ssl,
679
+ )
680
+ return init_api_key_sdk(api_key, use_production=use_production)
681
+
682
+ ctx = LkrCtxObj(
683
+ force_oauth=True, oauth_account=instance_name, use_production=use_production
684
+ )
685
+ sql_auth = SqlLiteAuth(ctx)
686
+ return sql_auth.get_current_sdk()
687
+
@@ -8,14 +8,18 @@ class LookerApiKey(BaseModel):
8
8
  client_id: str = Field(..., min_length=1)
9
9
  client_secret: str = Field(..., min_length=1)
10
10
  base_url: str = Field(..., min_length=1)
11
+ verify_ssl: bool = True
11
12
 
12
13
  @classmethod
13
14
  def from_env(cls):
14
15
  try:
16
+ verify_ssl_str = os.environ.get("LOOKERSDK_VERIFY_SSL", "true").lower()
17
+ verify_ssl = verify_ssl_str not in ("false", "0", "f")
15
18
  return cls(
16
19
  client_id=os.environ.get("LOOKERSDK_CLIENT_ID"), # type: ignore
17
20
  client_secret=os.environ.get("LOOKERSDK_CLIENT_SECRET"), # type: ignore
18
21
  base_url=os.environ.get("LOOKERSDK_BASE_URL"), # type: ignore
22
+ verify_ssl=verify_ssl,
19
23
  )
20
24
  except Exception:
21
25
  return None
@@ -8,6 +8,7 @@ from lkr.extended_sdk_methods.classes import (
8
8
  ProjectGeneratorColumn,
9
9
  ProjectGeneratorTable,
10
10
  SelectedTable,
11
+ ProjectCommitRequest,
11
12
  )
12
13
  from lkr.extended_sdk_methods.main import ExtendedLooker40SDK
13
14
 
@@ -22,4 +23,5 @@ __all__ = [
22
23
  "GenerateLookMLParameters",
23
24
  "SelectedTable",
24
25
  "GenerateLookMLWithNewFilesResponse",
26
+ "ProjectCommitRequest",
25
27
  ]
@@ -0,0 +1,386 @@
1
+ import os
2
+ import re
3
+ from typing import Annotated, Any, Optional
4
+
5
+ import typer
6
+
7
+ from lkr.auth_service import get_auth
8
+ from lkr.extended_sdk_methods import (
9
+ ExtendedLooker40SDK,
10
+ FileContent,
11
+ ProjectCommitRequest,
12
+ )
13
+ from lkr.logger import logger
14
+
15
+ __all__ = ["lookml_group"]
16
+
17
+ lookml_group = typer.Typer(
18
+ name="lookml",
19
+ help="LookML synchronization and deployment tools",
20
+ no_args_is_help=True,
21
+ )
22
+
23
+ VALID_EXTENSIONS = (
24
+ ".lkml",
25
+ ".lookml",
26
+ ".json",
27
+ ".geojson",
28
+ ".md",
29
+ ".gitkeep",
30
+ )
31
+
32
+
33
+ def _get_file_id(f: Any) -> str:
34
+ if isinstance(f, dict):
35
+ return str(f.get("id") or f.get("path") or str(f))
36
+ if hasattr(f, "id") and f.id is not None:
37
+ return str(f.id)
38
+ if hasattr(f, "path") and f.path is not None:
39
+ return str(f.path)
40
+ return str(f)
41
+
42
+
43
+ def _resolve_project_id(
44
+ folder_name: Optional[str], project_id_opt: Optional[str]
45
+ ) -> str:
46
+ if project_id_opt:
47
+ return project_id_opt
48
+ if folder_name:
49
+ return os.path.basename(os.path.abspath(folder_name.rstrip("/\\")))
50
+ env_proj = os.getenv("LOOKER_PROJECT_NAME")
51
+ if env_proj:
52
+ return env_proj
53
+ return os.path.basename(os.getcwd())
54
+
55
+
56
+ def _ensure_remote_directory(
57
+ sdk: ExtendedLooker40SDK, project_id: str, file_path: str
58
+ ) -> None:
59
+ dir_path = os.path.dirname(file_path.replace("\\", "/"))
60
+ if not dir_path or dir_path == "." or dir_path == "/":
61
+ return
62
+
63
+ parts = [p for p in dir_path.split("/") if p]
64
+ current_path = ""
65
+ for part in parts:
66
+ current_path = f"{current_path}/{part}" if current_path else part
67
+ try:
68
+ sdk.create_project_directory(
69
+ project_id=project_id,
70
+ directory_path=current_path,
71
+ )
72
+ except Exception as e:
73
+ logger.debug(f"Directory creation notice for '{current_path}': {e}")
74
+
75
+
76
+ @lookml_group.command(name="push")
77
+ def push(
78
+ ctx: typer.Context,
79
+ folder_name: Annotated[
80
+ str, typer.Argument(help="Local folder name / Looker project ID to push")
81
+ ],
82
+ project_id_opt: Annotated[
83
+ Optional[str],
84
+ typer.Option(
85
+ "--project-id",
86
+ "--project",
87
+ help="Looker project ID to push to (if different from folder name)",
88
+ ),
89
+ ] = None,
90
+ deploy: Annotated[
91
+ bool,
92
+ typer.Option("--deploy", help="Commit and deploy to production after push"),
93
+ ] = False,
94
+ message: Annotated[
95
+ str, typer.Option("--message", help="Commit message when deploying")
96
+ ] = "push from lkr cli",
97
+ ):
98
+ """
99
+ Push local files to Looker, removing files on the instance that aren't being pushed.
100
+ """
101
+ auth = get_auth(ctx)
102
+ sdk: ExtendedLooker40SDK = auth.get_current_sdk()
103
+ project_id = _resolve_project_id(folder_name, project_id_opt)
104
+
105
+ lookml_dir = os.path.abspath(folder_name)
106
+ if not os.path.exists(lookml_dir):
107
+ logger.error(f"Local folder does not exist: {lookml_dir}")
108
+ raise typer.Exit(1)
109
+
110
+ logger.info(f"Reading local files from {lookml_dir} for project {project_id}...")
111
+ files_to_push = []
112
+ for root, _, files in os.walk(lookml_dir):
113
+ if ".git" in root.split(os.sep):
114
+ continue
115
+ for file in files:
116
+ if file in (".gitignore", ".DS_Store"):
117
+ continue
118
+ if any(file.endswith(ext) for ext in VALID_EXTENSIONS):
119
+ full_path = os.path.join(root, file)
120
+ rel_path = os.path.relpath(full_path, lookml_dir).replace("\\", "/")
121
+ with open(full_path, "r", encoding="utf-8") as f:
122
+ content = f.read()
123
+ files_to_push.append({
124
+ "path": rel_path,
125
+ "root_name": file,
126
+ "content": content,
127
+ })
128
+ else:
129
+ logger.warning(
130
+ f"Local file '{file}' has an extension not supported by Looker and will be skipped."
131
+ )
132
+
133
+ successfully_pushed_paths = set()
134
+ for f in files_to_push:
135
+ structured_path = f["path"]
136
+ root_path = f["root_name"]
137
+ content = f["content"]
138
+
139
+ try:
140
+ logger.info(f"Uploading/Overwriting remote file: {structured_path}")
141
+ file_content = FileContent(path=structured_path, content=content)
142
+ try:
143
+ sdk.update_file(
144
+ project_id=project_id,
145
+ file_content=file_content,
146
+ )
147
+ except Exception:
148
+ try:
149
+ sdk.create_file(
150
+ project_id=project_id,
151
+ file_content=file_content,
152
+ )
153
+ except Exception:
154
+ _ensure_remote_directory(
155
+ sdk=sdk, project_id=project_id, file_path=structured_path
156
+ )
157
+ sdk.create_file(
158
+ project_id=project_id,
159
+ file_content=file_content,
160
+ )
161
+ successfully_pushed_paths.add(structured_path)
162
+ continue
163
+ except Exception as struct_err:
164
+ logger.debug(
165
+ f"Structured upload notice ({struct_err}), initiating fallback to project root..."
166
+ )
167
+
168
+ if root_path.endswith(".model.lkml"):
169
+ content = re.sub(
170
+ r'include:\s*["\']/views/\*\*/\*\.view\.lkml["\']',
171
+ 'include: "/*.view.lkml"',
172
+ content,
173
+ )
174
+
175
+ root_file_content = FileContent(path=root_path, content=content)
176
+ try:
177
+ sdk.update_file(
178
+ project_id=project_id,
179
+ file_content=root_file_content,
180
+ )
181
+ except Exception:
182
+ sdk.create_file(
183
+ project_id=project_id,
184
+ file_content=root_file_content,
185
+ )
186
+ successfully_pushed_paths.add(root_path)
187
+
188
+ logger.info("Retrieving remote file inventory to enforce one-way mirror...")
189
+ existing_remote = sdk.all_project_files(project_id=project_id) or []
190
+ for rf in existing_remote:
191
+ rf_path = _get_file_id(rf)
192
+ if not any(rf_path.endswith(ext) for ext in VALID_EXTENSIONS):
193
+ logger.warning(
194
+ f"Remote file '{rf_path}' has an extension not supported by Looker."
195
+ )
196
+ if (
197
+ rf_path not in successfully_pushed_paths
198
+ and not rf_path.endswith(".gitkeep")
199
+ and rf_path != "manifest.lkml"
200
+ ):
201
+ logger.info(f"Deleting remote orphan file: {rf_path}")
202
+ try:
203
+ sdk.delete_file(project_id=project_id, file_path=rf_path)
204
+ except Exception as dpe:
205
+ logger.debug(f"Deletion notice for {rf_path}: {dpe}")
206
+
207
+ logger.info("Push completed successfully.")
208
+
209
+ if deploy:
210
+ logger.info(f"Committing and deploying project {project_id} to production...")
211
+ try:
212
+ commit_req = ProjectCommitRequest(message=message).model_dump(
213
+ exclude_none=True
214
+ )
215
+ sdk.commit(project_id=project_id, body=commit_req)
216
+ except Exception as commit_err:
217
+ logger.debug(f"Commit notice: {commit_err}")
218
+
219
+ sdk.deploy_to_production(project_id=project_id)
220
+ logger.info("Deployment completed successfully.")
221
+
222
+
223
+ @lookml_group.command(name="pull")
224
+ def pull(
225
+ ctx: typer.Context,
226
+ folder_name: Annotated[
227
+ str, typer.Argument(help="Local folder name / Looker project ID to pull into")
228
+ ],
229
+ project_id_opt: Annotated[
230
+ Optional[str],
231
+ typer.Option(
232
+ "--project-id",
233
+ "--project",
234
+ help="Looker project ID to pull from (if different from folder name)",
235
+ ),
236
+ ] = None,
237
+ deploy: Annotated[
238
+ bool,
239
+ typer.Option(
240
+ "--deploy", help="Commit and deploy to production on Looker after pull"
241
+ ),
242
+ ] = False,
243
+ message: Annotated[
244
+ str, typer.Option("--message", help="Commit message when deploying")
245
+ ] = "pull from lkr cli then commit and deploy",
246
+ ):
247
+ """
248
+ Pull remote files from Looker to local disk, removing local files that aren't on the instance.
249
+ """
250
+ auth = get_auth(ctx)
251
+ sdk: ExtendedLooker40SDK = auth.get_current_sdk()
252
+ project_id = _resolve_project_id(folder_name, project_id_opt)
253
+
254
+ target_dir = os.path.abspath(folder_name)
255
+ os.makedirs(target_dir, exist_ok=True)
256
+
257
+ logger.info(
258
+ f"Retrieving remote files for project {project_id} to pull into {target_dir}..."
259
+ )
260
+ existing_remote = sdk.all_project_files(project_id=project_id) or []
261
+
262
+ remote_paths = set()
263
+ for rf in existing_remote:
264
+ rf_path = _get_file_id(rf)
265
+ if not any(rf_path.endswith(ext) for ext in VALID_EXTENSIONS):
266
+ logger.warning(
267
+ f"Remote file '{rf_path}' has an extension not supported by Looker."
268
+ )
269
+ continue
270
+ if rf_path.endswith(".gitkeep"):
271
+ continue
272
+
273
+ local_path = os.path.join(target_dir, rf_path)
274
+ resolved_local = os.path.abspath(local_path)
275
+ try:
276
+ if os.path.commonpath([target_dir, resolved_local]) != target_dir:
277
+ logger.warning(f"Path traversal detected and blocked: {rf_path}")
278
+ continue
279
+ except ValueError:
280
+ logger.warning(f"Path traversal detected and blocked: {rf_path}")
281
+ continue
282
+
283
+ try:
284
+ content = sdk.get_file_content(project_id=project_id, file_path=rf_path)
285
+ except Exception as e:
286
+ if "not found" in str(e).lower():
287
+ logger.debug(
288
+ f"Remote file {rf_path} not found in dev workspace (likely a deleted ghost file in git tracking)."
289
+ )
290
+ else:
291
+ logger.error(f"Failed to fetch content for remote file {rf_path}: {e}")
292
+ continue
293
+
294
+ remote_paths.add(rf_path)
295
+
296
+ local_path = os.path.join(target_dir, rf_path)
297
+ os.makedirs(os.path.dirname(local_path), exist_ok=True)
298
+ with open(local_path, "w", encoding="utf-8") as f:
299
+ f.write(content)
300
+ logger.info(f"Pulled file: {rf_path}")
301
+
302
+ logger.info("Cleaning up local orphans not present on the Looker instance...")
303
+ for root, _, files in os.walk(target_dir):
304
+ if ".git" in root.split(os.sep):
305
+ continue
306
+ for file in files:
307
+ if file in (".gitignore", ".DS_Store"):
308
+ continue
309
+ if any(file.endswith(ext) for ext in VALID_EXTENSIONS):
310
+ full_path = os.path.join(root, file)
311
+ rel_path = os.path.relpath(full_path, target_dir).replace("\\", "/")
312
+ if (
313
+ rel_path not in remote_paths
314
+ and not rel_path.endswith(".gitkeep")
315
+ and rel_path != "manifest.lkml"
316
+ ):
317
+ logger.info(f"Deleting local orphan file: {rel_path}")
318
+ try:
319
+ os.remove(full_path)
320
+ except Exception as err:
321
+ logger.debug(f"Local deletion notice for {rel_path}: {err}")
322
+ else:
323
+ logger.warning(
324
+ f"Local file '{file}' has an extension not supported by Looker and will not be synchronized or deleted."
325
+ )
326
+
327
+ logger.info("Pull completed successfully.")
328
+
329
+ if deploy:
330
+ logger.info(
331
+ f"Committing and deploying project {project_id} to production on Looker..."
332
+ )
333
+ try:
334
+ commit_req = ProjectCommitRequest(message=message).model_dump(
335
+ exclude_none=True
336
+ )
337
+ sdk.commit(project_id=project_id, body=commit_req)
338
+ except Exception as commit_err:
339
+ logger.debug(f"Commit notice: {commit_err}")
340
+
341
+ sdk.deploy_to_production(project_id=project_id)
342
+ logger.info("Deployment completed successfully.")
343
+
344
+
345
+ @lookml_group.command(name="deploy")
346
+ def deploy_cmd(
347
+ ctx: typer.Context,
348
+ folder_name: Annotated[
349
+ Optional[str],
350
+ typer.Argument(help="Local folder name / Looker project ID to deploy"),
351
+ ] = None,
352
+ project_id: Annotated[
353
+ Optional[str],
354
+ typer.Option(
355
+ "--project-id",
356
+ "--project",
357
+ help="Looker project ID to deploy (if folder_name not specified)",
358
+ ),
359
+ ] = None,
360
+ message: Annotated[
361
+ str, typer.Option("--message", help="Commit message")
362
+ ] = "commit and deploy from lkr cli",
363
+ ):
364
+ """
365
+ Commit dev workspace and deploy Looker project to production.
366
+ """
367
+ auth = get_auth(ctx)
368
+ sdk: ExtendedLooker40SDK = auth.get_current_sdk()
369
+ resolved_project_id = _resolve_project_id(folder_name, project_id)
370
+
371
+ logger.info(
372
+ f"Committing and deploying project {resolved_project_id} to production..."
373
+ )
374
+ try:
375
+ commit_req = ProjectCommitRequest(message=message).model_dump(
376
+ exclude_none=True
377
+ )
378
+ sdk.commit(
379
+ project_id=resolved_project_id,
380
+ body=commit_req,
381
+ )
382
+ except Exception as commit_err:
383
+ logger.debug(f"Commit notice: {commit_err}")
384
+
385
+ sdk.deploy_to_production(project_id=resolved_project_id)
386
+ logger.info("Deployment completed successfully.")
@@ -9,10 +9,13 @@ from fastapi import FastAPI, Request
9
9
  from lkr.logger import structured_logger as logger
10
10
  from lkr.tools.classes import AttributeUpdaterResponse, UserAttributeUpdater
11
11
  from lkr.tools.permission_deprecation import schedule_download_deprecation
12
+ from lkr.tools.lookml import lookml_group
12
13
 
13
14
  __all__ = ["group"]
14
15
 
15
16
  group = typer.Typer()
17
+ group.add_typer(lookml_group, name="lookml")
18
+
16
19
 
17
20
  if not logger:
18
21
  raise Exception("Logger is not available")
@@ -0,0 +1,5 @@
1
+ from lkr.tools.classes import UserAttributeUpdater
2
+ from lkr.extended_sdk_methods import ExtendedLooker40SDK
3
+ from lkr.auth_service import init_sdk
4
+
5
+ __all__ = ["UserAttributeUpdater", "ExtendedLooker40SDK", "init_sdk"]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lkr-dev-cli"
3
- version = "0.0.41"
3
+ version = "0.0.42"
4
4
  description = "lkr: a command line interface for looker"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -60,5 +60,5 @@ dev = [
60
60
  ]
61
61
 
62
62
  [tool.hatch.build.targets.wheel]
63
- packages = ["lkr"]
63
+ packages = ["lkr", "lkr_dev_cli"]
64
64
 
@@ -0,0 +1,167 @@
1
+ import sqlite3
2
+ from unittest.mock import MagicMock, patch
3
+ import pytest
4
+
5
+ from lkr_dev_cli import init_sdk
6
+ from lkr.auth_service import ApiKeyApiSettings, SqlLiteAuth
7
+ from lkr.classes import LkrCtxObj, LookerApiKey
8
+
9
+
10
+ def test_init_sdk_explicit_args(monkeypatch):
11
+ mock_sdk = MagicMock()
12
+ with patch("lkr.auth_service.init_api_key_sdk", return_value=mock_sdk) as mock_init:
13
+ sdk = init_sdk(
14
+ base_url="https://explicit.looker.com",
15
+ client_id="test_id",
16
+ client_secret="test_secret",
17
+ verify_ssl=False,
18
+ )
19
+ assert sdk == mock_sdk
20
+ mock_init.assert_called_once()
21
+ api_key, use_prod = (
22
+ mock_init.call_args[0][0],
23
+ mock_init.call_args[1]["use_production"],
24
+ )
25
+ assert api_key.base_url == "https://explicit.looker.com"
26
+ assert api_key.client_id == "test_id"
27
+ assert api_key.client_secret == "test_secret"
28
+ assert api_key.verify_ssl is False
29
+ assert use_prod is True
30
+
31
+
32
+ def test_init_sdk_env_vars(monkeypatch):
33
+ monkeypatch.setenv("LOOKERSDK_BASE_URL", "https://env.looker.com")
34
+ monkeypatch.setenv("LOOKERSDK_CLIENT_ID", "env_id")
35
+ monkeypatch.setenv("LOOKERSDK_CLIENT_SECRET", "env_secret")
36
+ monkeypatch.setenv("LOOKERSDK_VERIFY_SSL", "false")
37
+
38
+ mock_sdk = MagicMock()
39
+ with patch("lkr.auth_service.init_api_key_sdk", return_value=mock_sdk) as mock_init:
40
+ sdk = init_sdk()
41
+ assert sdk == mock_sdk
42
+ mock_init.assert_called_once()
43
+ api_key = mock_init.call_args[0][0]
44
+ assert api_key.base_url == "https://env.looker.com"
45
+ assert api_key.client_id == "env_id"
46
+ assert api_key.client_secret == "env_secret"
47
+ assert api_key.verify_ssl is False
48
+
49
+
50
+ def test_api_key_api_settings():
51
+ api_key = LookerApiKey(
52
+ client_id="my_id",
53
+ client_secret="my_secret",
54
+ base_url="https://settings.looker.com",
55
+ verify_ssl=False,
56
+ )
57
+ settings = ApiKeyApiSettings(api_key)
58
+ config = settings.read_config()
59
+ assert config["base_url"] == "https://settings.looker.com"
60
+ assert config["client_id"] == "my_id"
61
+ assert config["client_secret"] == "my_secret"
62
+ assert config["verify_ssl"] == "false"
63
+
64
+
65
+ def test_init_sdk_db_auth_fallback(tmp_path, monkeypatch):
66
+ monkeypatch.delenv("LOOKERSDK_BASE_URL", raising=False)
67
+ monkeypatch.delenv("LOOKERSDK_CLIENT_ID", raising=False)
68
+ monkeypatch.delenv("LOOKERSDK_CLIENT_SECRET", raising=False)
69
+
70
+ db_path = tmp_path / "auth.db"
71
+
72
+ conn = sqlite3.connect(db_path)
73
+ conn.execute(
74
+ """
75
+ CREATE TABLE IF NOT EXISTS auth (
76
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
77
+ instance_name TEXT,
78
+ access_token TEXT,
79
+ refresh_token TEXT,
80
+ refresh_expires_at TEXT,
81
+ token_type TEXT,
82
+ expires_at TEXT,
83
+ current_instance BOOLEAN,
84
+ base_url TEXT,
85
+ use_production BOOLEAN
86
+ )
87
+ """
88
+ )
89
+ conn.execute(
90
+ """
91
+ INSERT INTO auth (instance_name, access_token, refresh_token, refresh_expires_at, token_type, expires_at, current_instance, base_url, use_production)
92
+ VALUES ('my_db_instance', 'acc_tok', 'ref_tok', '2030-01-01T00:00:00+00:00', 'Bearer', '2030-01-01T00:00:00+00:00', 1, 'https://db.looker.com', 1)
93
+ """
94
+ )
95
+ conn.commit()
96
+ conn.close()
97
+
98
+ mock_sdk = MagicMock()
99
+ with patch(
100
+ "lkr.auth_service.SqlLiteAuth.get_current_sdk", return_value=mock_sdk
101
+ ) as mock_get_sdk:
102
+ with patch("lkr.auth_service.os.path.expanduser", return_value=str(db_path)):
103
+ sdk = init_sdk(instance_name="my_db_instance")
104
+ assert sdk == mock_sdk
105
+ mock_get_sdk.assert_called_once()
106
+
107
+
108
+ def test_sqlite_auth_match_instance_name(tmp_path, monkeypatch):
109
+ db_path = tmp_path / "auth.db"
110
+ conn = sqlite3.connect(db_path)
111
+ conn.execute(
112
+ """
113
+ CREATE TABLE IF NOT EXISTS auth (
114
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
115
+ instance_name TEXT,
116
+ access_token TEXT,
117
+ refresh_token TEXT,
118
+ refresh_expires_at TEXT,
119
+ token_type TEXT,
120
+ expires_at TEXT,
121
+ current_instance BOOLEAN,
122
+ base_url TEXT,
123
+ use_production BOOLEAN
124
+ )
125
+ """
126
+ )
127
+ conn.execute(
128
+ """
129
+ INSERT INTO auth (instance_name, access_token, refresh_token, refresh_expires_at, token_type, expires_at, current_instance, base_url, use_production)
130
+ VALUES ('matched_inst', 'acc_tok', 'ref_tok', '2030-01-01T00:00:00+00:00', 'Bearer', '2030-01-01T00:00:00+00:00', 0, 'https://matchme.looker.com', 1)
131
+ """
132
+ )
133
+ conn.commit()
134
+ conn.close()
135
+
136
+ ctx = LkrCtxObj(force_oauth=True, oauth_account="matched_inst")
137
+ with patch("lkr.auth_service.os.path.expanduser", return_value=str(db_path)):
138
+ sql_auth = SqlLiteAuth(ctx, db_path=str(db_path))
139
+ current_auth = sql_auth._get_current_auth()
140
+ assert current_auth is not None
141
+ assert current_auth.instance_name == "matched_inst"
142
+ assert current_auth.base_url == "https://matchme.looker.com"
143
+
144
+
145
+ def test_init_sdk_incomplete_api_key(monkeypatch):
146
+ monkeypatch.delenv("LOOKERSDK_BASE_URL", raising=False)
147
+ monkeypatch.delenv("LOOKERSDK_CLIENT_ID", raising=False)
148
+ monkeypatch.delenv("LOOKERSDK_CLIENT_SECRET", raising=False)
149
+
150
+ with pytest.raises(
151
+ ValueError,
152
+ match="Incomplete API key configuration. Missing: client_id, client_secret",
153
+ ):
154
+ init_sdk(base_url="https://incomplete.looker.com")
155
+
156
+ with pytest.raises(
157
+ ValueError,
158
+ match="Incomplete API key configuration. Missing: base_url, client_secret",
159
+ ):
160
+ init_sdk(client_id="my_id")
161
+
162
+ with pytest.raises(
163
+ ValueError,
164
+ match="Incomplete API key configuration. Missing: base_url, client_id",
165
+ ):
166
+ init_sdk(client_secret="my_secret")
167
+
@@ -0,0 +1,268 @@
1
+ from unittest.mock import MagicMock, patch
2
+
3
+ import pytest
4
+ from typer.testing import CliRunner
5
+
6
+ from lkr.main import app
7
+ from lkr.extended_sdk_methods import ExtendedLooker40SDK, FileContent
8
+
9
+ runner = CliRunner()
10
+
11
+
12
+ @pytest.fixture
13
+ def mock_sdk():
14
+ sdk = MagicMock(spec=ExtendedLooker40SDK)
15
+ return sdk
16
+
17
+
18
+ @pytest.fixture
19
+ def mock_auth(mock_sdk):
20
+ auth = MagicMock()
21
+ auth.get_current_sdk.return_value = mock_sdk
22
+ return auth
23
+
24
+
25
+ def test_push_command(tmp_path, mock_sdk, mock_auth):
26
+ # Setup local folder and files
27
+ project_dir = tmp_path / "test_project"
28
+ project_dir.mkdir()
29
+
30
+ models_dir = project_dir / "models"
31
+ models_dir.mkdir()
32
+
33
+ model_file = models_dir / "test.model.lkml"
34
+ model_file.write_text('connection: "my_conn"\ninclude: "/views/**/*.view.lkml"')
35
+
36
+ # Mock remote inventory containing an orphan
37
+ mock_sdk.all_project_files.return_value = [
38
+ {"id": "models/test.model.lkml"},
39
+ {"id": "orphan_file.lkml"},
40
+ ]
41
+
42
+ with patch("lkr.tools.lookml.get_auth", return_value=mock_auth):
43
+ result = runner.invoke(
44
+ app, ["tools", "lookml", "push", str(project_dir), "--deploy"]
45
+ )
46
+ assert result.exit_code == 0
47
+
48
+ # Verify orphan deletion
49
+ mock_sdk.delete_file.assert_called_once_with(
50
+ project_id="test_project", file_path="orphan_file.lkml"
51
+ )
52
+
53
+ expected_content = 'connection: "my_conn"\ninclude: "/views/**/*.view.lkml"'
54
+ expected_fc = FileContent(
55
+ path="models/test.model.lkml", content=expected_content
56
+ )
57
+ mock_sdk.create_file.assert_not_called()
58
+ mock_sdk.update_file.assert_called_with(
59
+ project_id="test_project", file_content=expected_fc
60
+ )
61
+
62
+ # Verify deploy flags
63
+ mock_sdk.commit.assert_called_once()
64
+ mock_sdk.deploy_to_production.assert_called_once_with(
65
+ project_id="test_project"
66
+ )
67
+
68
+
69
+ def test_pull_command(tmp_path, mock_sdk, mock_auth):
70
+ project_dir = tmp_path / "test_project"
71
+ project_dir.mkdir()
72
+
73
+ # Create a local orphan that should be removed
74
+ local_orphan = project_dir / "local_orphan.lkml"
75
+ local_orphan.write_text("old content")
76
+
77
+ mock_sdk.all_project_files.return_value = [
78
+ {"id": "models/pulled.model.lkml"},
79
+ ]
80
+ mock_sdk.get_file_content.return_value = "pulled lookml content"
81
+
82
+ with patch("lkr.tools.lookml.get_auth", return_value=mock_auth):
83
+ result = runner.invoke(
84
+ app, ["tools", "lookml", "pull", str(project_dir), "--deploy"]
85
+ )
86
+ assert result.exit_code == 0
87
+
88
+ # Verify new file created locally
89
+ pulled_file = project_dir / "models" / "pulled.model.lkml"
90
+ assert pulled_file.exists()
91
+ assert pulled_file.read_text() == "pulled lookml content"
92
+
93
+ # Verify local orphan deleted
94
+ assert not local_orphan.exists()
95
+
96
+ # Verify commit and deploy
97
+ mock_sdk.commit.assert_called_once()
98
+ mock_sdk.deploy_to_production.assert_called_once_with(
99
+ project_id="test_project"
100
+ )
101
+
102
+
103
+ def test_pull_ghost_file(tmp_path, mock_sdk, mock_auth):
104
+ project_dir = tmp_path / "test_project"
105
+ project_dir.mkdir()
106
+
107
+ views_dir = project_dir / "views"
108
+ views_dir.mkdir()
109
+
110
+ affinity_file = views_dir / "affinity.view.lkml"
111
+ affinity_file.write_text("old content")
112
+
113
+ mock_sdk.all_project_files.return_value = [
114
+ {"id": "views/affinity.view.lkml"},
115
+ ]
116
+ mock_sdk.get_file_content.side_effect = Exception("Not found")
117
+
118
+ with patch("lkr.tools.lookml.get_auth", return_value=mock_auth):
119
+ result = runner.invoke(app, ["tools", "lookml", "pull", str(project_dir)])
120
+ assert result.exit_code == 0
121
+
122
+ # Verify ghost file was deleted locally
123
+ assert not affinity_file.exists()
124
+
125
+
126
+ def test_deploy_command(mock_sdk, mock_auth):
127
+ with patch("lkr.tools.lookml.get_auth", return_value=mock_auth):
128
+ result = runner.invoke(
129
+ app, ["tools", "lookml", "deploy", "my_deploy_project"]
130
+ )
131
+ assert result.exit_code == 0
132
+
133
+ mock_sdk.commit.assert_called_once()
134
+ mock_sdk.deploy_to_production.assert_called_once_with(
135
+ project_id="my_deploy_project"
136
+ )
137
+
138
+
139
+ def test_push_warning(tmp_path, mock_sdk, mock_auth):
140
+ project_dir = tmp_path / "test_project"
141
+ project_dir.mkdir()
142
+
143
+ # Create an unsupported file locally
144
+ unsupported_file = project_dir / "unsupported.xyz"
145
+ unsupported_file.write_text("random")
146
+
147
+ mock_sdk.all_project_files.return_value = [
148
+ {"id": "remote_unsupported.abc"},
149
+ ]
150
+
151
+ with patch("lkr.tools.lookml.get_auth", return_value=mock_auth), patch("lkr.tools.lookml.logger.warning") as mock_warn:
152
+ result = runner.invoke(app, ["tools", "lookml", "push", str(project_dir)])
153
+ assert result.exit_code == 0
154
+
155
+ # Check warnings called for both local and remote unsupported extensions
156
+ mock_warn.assert_any_call(
157
+ "Local file 'unsupported.xyz' has an extension not supported by Looker and will be skipped."
158
+ )
159
+ mock_warn.assert_any_call(
160
+ "Remote file 'remote_unsupported.abc' has an extension not supported by Looker."
161
+ )
162
+
163
+
164
+ def test_pull_warning(tmp_path, mock_sdk, mock_auth):
165
+ project_dir = tmp_path / "test_project"
166
+ project_dir.mkdir()
167
+
168
+ # Create an unsupported local orphan
169
+ unsupported_local = project_dir / "unsupported_local.py"
170
+ unsupported_local.write_text("print('hello')")
171
+
172
+ mock_sdk.all_project_files.return_value = [
173
+ {"id": "remote_unsupported.doc"},
174
+ ]
175
+
176
+ with patch("lkr.tools.lookml.get_auth", return_value=mock_auth), patch("lkr.tools.lookml.logger.warning") as mock_warn:
177
+ result = runner.invoke(app, ["tools", "lookml", "pull", str(project_dir)])
178
+ assert result.exit_code == 0
179
+
180
+ # Check warnings called
181
+ mock_warn.assert_any_call(
182
+ "Remote file 'remote_unsupported.doc' has an extension not supported by Looker."
183
+ )
184
+ mock_warn.assert_any_call(
185
+ "Local file 'unsupported_local.py' has an extension not supported by Looker and will not be synchronized or deleted."
186
+ )
187
+
188
+ # Verify unsupported local orphan was NOT deleted
189
+ assert unsupported_local.exists()
190
+
191
+
192
+ def test_pull_path_traversal(tmp_path, mock_sdk, mock_auth):
193
+ project_dir = tmp_path / "test_project"
194
+ project_dir.mkdir()
195
+ outside_dir = tmp_path / "outside"
196
+ outside_dir.mkdir()
197
+
198
+ mock_sdk.all_project_files.return_value = [
199
+ {"id": "../outside/traversal.lkml"},
200
+ ]
201
+ mock_sdk.get_file_content.return_value = "malicious content"
202
+
203
+ with patch("lkr.tools.lookml.get_auth", return_value=mock_auth), patch(
204
+ "lkr.tools.lookml.logger.warning"
205
+ ) as mock_warn:
206
+ result = runner.invoke(app, ["tools", "lookml", "pull", str(project_dir)])
207
+ assert result.exit_code == 0
208
+
209
+ # Verify the file was not written outside
210
+ outside_file = outside_dir / "traversal.lkml"
211
+ assert not outside_file.exists()
212
+ mock_warn.assert_any_call(
213
+ "Path traversal detected and blocked: ../outside/traversal.lkml"
214
+ )
215
+
216
+
217
+ def test_push_orphan_cleanup_after_upload(tmp_path, mock_sdk, mock_auth):
218
+ project_dir = tmp_path / "test_project"
219
+ project_dir.mkdir()
220
+
221
+ views_dir = project_dir / "views"
222
+ views_dir.mkdir()
223
+
224
+ view_file = views_dir / "my_view.view.lkml"
225
+ view_file.write_text("view: my_view {}")
226
+
227
+ mock_sdk.all_project_files.return_value = [
228
+ {"id": "views/my_view.view.lkml"},
229
+ {"id": "my_view.view.lkml"},
230
+ ]
231
+
232
+ with patch("lkr.tools.lookml.get_auth", return_value=mock_auth):
233
+ result = runner.invoke(app, ["tools", "lookml", "push", str(project_dir)])
234
+ assert result.exit_code == 0
235
+
236
+ mock_sdk.delete_file.assert_called_once_with(
237
+ project_id="test_project", file_path="my_view.view.lkml"
238
+ )
239
+
240
+
241
+ def test_push_create_directory_recursively_fallback(tmp_path, mock_sdk, mock_auth):
242
+ project_dir = tmp_path / "test_project"
243
+ project_dir.mkdir()
244
+
245
+ nested_dir = project_dir / "views2" / "subfolder"
246
+ nested_dir.mkdir(parents=True)
247
+
248
+ view_file = nested_dir / "my_view.view.lkml"
249
+ view_file.write_text("view: my_view {}")
250
+
251
+ mock_sdk.update_file.side_effect = Exception("File not found")
252
+ # First create_file fails (missing directory), second succeeds
253
+ mock_sdk.create_file.side_effect = [Exception("Directory not found"), None]
254
+
255
+ with patch("lkr.tools.lookml.get_auth", return_value=mock_auth):
256
+ result = runner.invoke(app, ["tools", "lookml", "push", str(project_dir)])
257
+ assert result.exit_code == 0
258
+
259
+ mock_sdk.create_project_directory.assert_any_call(
260
+ project_id="test_project", directory_path="views2"
261
+ )
262
+ mock_sdk.create_project_directory.assert_any_call(
263
+ project_id="test_project", directory_path="views2/subfolder"
264
+ )
265
+ assert mock_sdk.create_file.call_count == 2
266
+
267
+
268
+
@@ -1,3 +0,0 @@
1
- from lkr.tools.classes import UserAttributeUpdater
2
-
3
- __all__ = ["UserAttributeUpdater"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes