lkr-dev-cli 0.0.41__tar.gz → 0.0.43__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.
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/PKG-INFO +45 -3
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/README.md +44 -2
- lkr_dev_cli-0.0.43/lkr/__init__.py +5 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/auth_service.py +62 -1
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/classes.py +4 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/extended_sdk_methods/__init__.py +2 -0
- lkr_dev_cli-0.0.43/lkr/tools/lookml.py +386 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/tools/main.py +3 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr.md +98 -0
- lkr_dev_cli-0.0.43/lkr_dev_cli/__init__.py +5 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/pyproject.toml +3 -2
- lkr_dev_cli-0.0.43/tests/test_init_sdk.py +167 -0
- lkr_dev_cli-0.0.43/tests/test_lookml_tools.py +268 -0
- lkr_dev_cli-0.0.41/lkr/__init__.py +0 -3
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/.github/workflows/ci.yml +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/.github/workflows/release.yml +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/.gitignore +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/.python-version +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/.vscode/launch.json +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/.vscode/settings.json +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/Dockerfile +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/LICENSE +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/Makefile +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/cloudbuild.yaml +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/codemode.md +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/auth/__init__.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/auth/main.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/auth/oauth.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/codemode/LOCAL.md +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/codemode/__init__.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/codemode/constant.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/codemode/download_swagger.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/codemode/examples.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/codemode/help.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/codemode/main.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/codemode/readme.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/codemode/swagger.json +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/codemode/type.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/constants.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/custom_types.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/exceptions.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/extended_sdk_methods/classes.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/extended_sdk_methods/main.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/logger.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/main.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/mcp/classes.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/mcp/main.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/mcp/utils.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/observability/classes.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/observability/embed_container.html +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/observability/main.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/observability/utils.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/tools/classes.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/lkr/tools/permission_deprecation.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/tests/TESTING.md +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/tests/test_codemode.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/tests/test_dependency_resolution.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/tests/test_deps.sh +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/tests/test_extended_sdk_methods.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/tests/test_oauth_account.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/tests/test_permission_deprecation.py +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/ty.toml +0 -0
- {lkr_dev_cli-0.0.41 → lkr_dev_cli-0.0.43}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lkr-dev-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.43
|
|
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
|
-
##
|
|
291
|
+
## Python SDK & Library Usage
|
|
292
292
|
|
|
293
|
-
|
|
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
|
-
##
|
|
255
|
+
## Python SDK & Library Usage
|
|
256
256
|
|
|
257
|
-
|
|
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
|
|
@@ -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__ = [
|
|
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")
|