lkr-dev-cli 0.0.44__tar.gz → 0.0.46__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.44 → lkr_dev_cli-0.0.46}/PKG-INFO +14 -2
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/README.md +13 -1
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/codemode.md +13 -2
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/codemode/main.py +60 -2
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/main.py +21 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/tools/lookml.py +179 -98
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/pyproject.toml +1 -1
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/tests/test_codemode.py +38 -1
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/tests/test_lookml_tools.py +140 -0
- lkr_dev_cli-0.0.46/tests/test_version.py +12 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/.github/workflows/ci.yml +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/.github/workflows/release.yml +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/.github/workflows/website.yml +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/.gitignore +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/.python-version +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/.vscode/launch.json +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/.vscode/settings.json +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/Dockerfile +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/LICENSE +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/Makefile +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/cloudbuild.yaml +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/__init__.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/auth/__init__.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/auth/main.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/auth/oauth.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/auth_service.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/classes.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/codemode/LOCAL.md +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/codemode/__init__.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/codemode/constant.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/codemode/download_swagger.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/codemode/examples.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/codemode/help.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/codemode/readme.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/codemode/swagger.json +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/codemode/type.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/constants.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/custom_types.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/exceptions.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/extended_sdk_methods/__init__.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/extended_sdk_methods/classes.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/extended_sdk_methods/main.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/logger.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/mcp/classes.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/mcp/main.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/mcp/utils.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/observability/classes.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/observability/embed_container.html +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/observability/main.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/observability/utils.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/tools/classes.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/tools/main.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr/tools/permission_deprecation.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr.md +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/lkr_dev_cli/__init__.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/tests/TESTING.md +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/tests/test_dependency_resolution.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/tests/test_deps.sh +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/tests/test_extended_sdk_methods.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/tests/test_init_sdk.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/tests/test_oauth_account.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/tests/test_permission_deprecation.py +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/ty.toml +0 -0
- {lkr_dev_cli-0.0.44 → lkr_dev_cli-0.0.46}/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.46
|
|
4
4
|
Summary: lkr: a command line interface for looker
|
|
5
5
|
Author: bwebs
|
|
6
6
|
License-Expression: MIT
|
|
@@ -111,7 +111,19 @@ Go to the Looker API Explorer for Register OAuth App (https://your.looker.instan
|
|
|
111
111
|
|
|
112
112
|
## Code Mode
|
|
113
113
|
|
|
114
|
-
Execute Python code safely with full Looker SDK coverage within a secure sandbox environment.
|
|
114
|
+
Execute Python code safely with full Looker SDK coverage within a secure sandbox environment (`Monty`). It dynamically inspects the Looker SDK for all public methods and injects them into the sandbox safely, supporting both Model Context Protocol (MCP) server mode and direct standalone CLI execution.
|
|
115
|
+
|
|
116
|
+
### Direct CLI Execution (Sandbox Mode)
|
|
117
|
+
Run scripts or inline code directly against your active Looker instance:
|
|
118
|
+
```bash
|
|
119
|
+
# Execute inline Python code
|
|
120
|
+
uvx lkr-dev-cli code-mode sandbox --code="return me()"
|
|
121
|
+
|
|
122
|
+
# Execute a Python script file
|
|
123
|
+
uvx lkr-dev-cli code-mode sandbox --file=./path/to/script.py
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
For detailed options, safe primitive transformations, MCP server configurations, and PKCE recovery, view the full [Code Mode Docs](./codemode.md).
|
|
115
127
|
|
|
116
128
|
## MCP
|
|
117
129
|
Built into the `lkr` is an MCP server. Right now its tools are based on helping you work within an IDE. To use it a tool like [Cursor](https://www.cursor.com/), add this to your mcp.json
|
|
@@ -75,7 +75,19 @@ Go to the Looker API Explorer for Register OAuth App (https://your.looker.instan
|
|
|
75
75
|
|
|
76
76
|
## Code Mode
|
|
77
77
|
|
|
78
|
-
Execute Python code safely with full Looker SDK coverage within a secure sandbox environment.
|
|
78
|
+
Execute Python code safely with full Looker SDK coverage within a secure sandbox environment (`Monty`). It dynamically inspects the Looker SDK for all public methods and injects them into the sandbox safely, supporting both Model Context Protocol (MCP) server mode and direct standalone CLI execution.
|
|
79
|
+
|
|
80
|
+
### Direct CLI Execution (Sandbox Mode)
|
|
81
|
+
Run scripts or inline code directly against your active Looker instance:
|
|
82
|
+
```bash
|
|
83
|
+
# Execute inline Python code
|
|
84
|
+
uvx lkr-dev-cli code-mode sandbox --code="return me()"
|
|
85
|
+
|
|
86
|
+
# Execute a Python script file
|
|
87
|
+
uvx lkr-dev-cli code-mode sandbox --file=./path/to/script.py
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
For detailed options, safe primitive transformations, MCP server configurations, and PKCE recovery, view the full [Code Mode Docs](./codemode.md).
|
|
79
91
|
|
|
80
92
|
## MCP
|
|
81
93
|
Built into the `lkr` is an MCP server. Right now its tools are based on helping you work within an IDE. To use it a tool like [Cursor](https://www.cursor.com/), add this to your mcp.json
|
|
@@ -16,8 +16,19 @@ The tool instantiates Looker SDK natively, searches all bound methods, and passe
|
|
|
16
16
|
|
|
17
17
|
## Continuous Usage
|
|
18
18
|
|
|
19
|
-
### 1.
|
|
20
|
-
|
|
19
|
+
### 1. Direct CLI Execution (Sandbox Mode)
|
|
20
|
+
If you want to execute Python scripts or inline code directly against your Looker instance without setting up an MCP client, use the `sandbox` command:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# Execute inline Python code
|
|
24
|
+
uvx lkr-dev-cli code-mode sandbox --code="return me()"
|
|
25
|
+
|
|
26
|
+
# Execute Python script file
|
|
27
|
+
uvx lkr-dev-cli code-mode sandbox --file=./path/to/script.py
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### 2. Starting the Server
|
|
31
|
+
To immediately trigger the MCP stdio listener, use:
|
|
21
32
|
```bash
|
|
22
33
|
uvx -q lkr-dev-cli[codemode] code-mode run
|
|
23
34
|
```
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import ast
|
|
1
2
|
import inspect
|
|
2
3
|
import json
|
|
3
4
|
import os
|
|
5
|
+
import re
|
|
4
6
|
import sys
|
|
5
7
|
import tempfile
|
|
6
8
|
from contextlib import contextmanager
|
|
@@ -21,7 +23,7 @@ from lkr.logger import logger
|
|
|
21
23
|
__all__ = ["group"]
|
|
22
24
|
|
|
23
25
|
mcp = FastMCP("lkr:codemode")
|
|
24
|
-
group = typer.Typer()
|
|
26
|
+
group = typer.Typer(no_args_is_help=True)
|
|
25
27
|
ctx_lkr: LkrCtxObj | None = None
|
|
26
28
|
class OSCapture:
|
|
27
29
|
def __init__(self):
|
|
@@ -156,6 +158,24 @@ def run_python_code(code: str, dev_mode: bool = False) -> str:
|
|
|
156
158
|
setattr(SDK, name, staticmethod(func))
|
|
157
159
|
external_funcs['sdk'] = SDK
|
|
158
160
|
|
|
161
|
+
# Monty external_functions do not support attribute lookups on objects.
|
|
162
|
+
# Pre-process the code to replace `sdk.method_name` with `method_name` safely using AST.
|
|
163
|
+
try:
|
|
164
|
+
class SDKAttributeRewriter(ast.NodeTransformer):
|
|
165
|
+
def visit_Attribute(self, node):
|
|
166
|
+
self.generic_visit(node)
|
|
167
|
+
if isinstance(node.value, ast.Name) and node.value.id == 'sdk':
|
|
168
|
+
return ast.copy_location(ast.Name(id=node.attr, ctx=node.ctx), node)
|
|
169
|
+
return node
|
|
170
|
+
|
|
171
|
+
tree = ast.parse(code)
|
|
172
|
+
tree = SDKAttributeRewriter().visit(tree)
|
|
173
|
+
ast.fix_missing_locations(tree)
|
|
174
|
+
code = ast.unparse(tree)
|
|
175
|
+
except Exception:
|
|
176
|
+
# Fallback to regex if parsing fails
|
|
177
|
+
code = re.sub(r"\bsdk\.([a-zA-Z_][a-zA-Z0-9_]*)\b", r"\1", code)
|
|
178
|
+
|
|
159
179
|
m = pydantic_monty.Monty(code)
|
|
160
180
|
|
|
161
181
|
# Use low-level OS stdout capture to ensure any print() statements
|
|
@@ -191,7 +211,45 @@ def run_python_code(code: str, dev_mode: bool = False) -> str:
|
|
|
191
211
|
return f"Error: {str(e)}"
|
|
192
212
|
|
|
193
213
|
|
|
214
|
+
@group.command(name="sandbox")
|
|
215
|
+
def sandbox(
|
|
216
|
+
ctx: typer.Context,
|
|
217
|
+
code: str | None = typer.Option(
|
|
218
|
+
None, "--code", "-c", help="Execute Python code directly in the sandbox"
|
|
219
|
+
),
|
|
220
|
+
file: str | None = typer.Option(
|
|
221
|
+
None, "--file", "-f", help="Execute Python code from a file in the sandbox"
|
|
222
|
+
),
|
|
223
|
+
dev_mode: bool = typer.Option(
|
|
224
|
+
False, "--dev-mode", help="Run in dev mode"
|
|
225
|
+
),
|
|
226
|
+
):
|
|
227
|
+
if not code and not file:
|
|
228
|
+
logger.error("Must specify either --code or --file")
|
|
229
|
+
raise typer.Exit(1)
|
|
230
|
+
|
|
231
|
+
if code and file:
|
|
232
|
+
logger.error("Cannot specify both --code and --file")
|
|
233
|
+
raise typer.Exit(1)
|
|
234
|
+
|
|
235
|
+
if file:
|
|
236
|
+
try:
|
|
237
|
+
with open(file, "r", encoding="utf-8") as f:
|
|
238
|
+
code_to_run = f.read()
|
|
239
|
+
except Exception as e:
|
|
240
|
+
logger.error(f"Failed to read file {file}: {e}")
|
|
241
|
+
raise typer.Exit(1)
|
|
242
|
+
else:
|
|
243
|
+
code_to_run = code
|
|
194
244
|
|
|
245
|
+
global ctx_lkr
|
|
246
|
+
ctx_lkr = (
|
|
247
|
+
ctx.obj.get("ctx_lkr")
|
|
248
|
+
if (ctx and ctx.obj and "ctx_lkr" in ctx.obj)
|
|
249
|
+
else LkrCtxObj(force_oauth=False)
|
|
250
|
+
)
|
|
251
|
+
result = run_python_code(code_to_run, dev_mode=dev_mode)
|
|
252
|
+
typer.echo(result)
|
|
195
253
|
|
|
196
254
|
|
|
197
255
|
@group.command(name="run")
|
|
@@ -202,7 +260,7 @@ def run(
|
|
|
202
260
|
global ctx_lkr
|
|
203
261
|
ctx_lkr = (
|
|
204
262
|
ctx.obj.get("ctx_lkr")
|
|
205
|
-
if (ctx.obj and "ctx_lkr" in ctx.obj)
|
|
263
|
+
if (ctx and ctx.obj and "ctx_lkr" in ctx.obj)
|
|
206
264
|
else LkrCtxObj(force_oauth=False)
|
|
207
265
|
)
|
|
208
266
|
mcp.run()
|
|
@@ -52,9 +52,30 @@ add_optional_typer_group(app, "lkr.codemode.main.group", "code-mode")
|
|
|
52
52
|
add_optional_typer_group(app, "lkr.db_template.main.group", "db-template")
|
|
53
53
|
|
|
54
54
|
|
|
55
|
+
def version_callback(value: bool):
|
|
56
|
+
if value:
|
|
57
|
+
import importlib.metadata
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
version = importlib.metadata.version("lkr-dev-cli")
|
|
61
|
+
except importlib.metadata.PackageNotFoundError:
|
|
62
|
+
version = "0.0.0"
|
|
63
|
+
typer.echo(version)
|
|
64
|
+
raise typer.Exit()
|
|
65
|
+
|
|
66
|
+
|
|
55
67
|
@app.callback()
|
|
56
68
|
def callback(
|
|
57
69
|
ctx: typer.Context,
|
|
70
|
+
version: Annotated[
|
|
71
|
+
Optional[bool],
|
|
72
|
+
typer.Option(
|
|
73
|
+
"--version",
|
|
74
|
+
callback=version_callback,
|
|
75
|
+
is_eager=True,
|
|
76
|
+
help="Show the version and exit.",
|
|
77
|
+
),
|
|
78
|
+
] = None,
|
|
58
79
|
client_id: Annotated[
|
|
59
80
|
str | None, typer.Option(envvar="LOOKERSDK_CLIENT_ID")
|
|
60
81
|
] = None,
|
|
@@ -87,6 +87,14 @@ def push(
|
|
|
87
87
|
help="Looker project ID to push to (if different from folder name)",
|
|
88
88
|
),
|
|
89
89
|
] = None,
|
|
90
|
+
file_opt: Annotated[
|
|
91
|
+
Optional[str],
|
|
92
|
+
typer.Option(
|
|
93
|
+
"--file",
|
|
94
|
+
"-f",
|
|
95
|
+
help="Single file relative path (or absolute path) to push",
|
|
96
|
+
),
|
|
97
|
+
] = None,
|
|
90
98
|
deploy: Annotated[
|
|
91
99
|
bool,
|
|
92
100
|
typer.Option("--deploy", help="Commit and deploy to production after push"),
|
|
@@ -97,38 +105,73 @@ def push(
|
|
|
97
105
|
):
|
|
98
106
|
"""
|
|
99
107
|
Push local files to Looker, removing files on the instance that aren't being pushed.
|
|
108
|
+
If --file / -f is specified (or folder_name is a file), only that single file is pushed without deleting remote orphans.
|
|
100
109
|
"""
|
|
101
110
|
auth = get_auth(ctx)
|
|
102
111
|
sdk: ExtendedLooker40SDK = auth.get_current_sdk()
|
|
103
|
-
project_id = _resolve_project_id(folder_name, project_id_opt)
|
|
104
112
|
|
|
113
|
+
if file_opt is None and os.path.isfile(folder_name):
|
|
114
|
+
file_opt = folder_name
|
|
115
|
+
folder_name = os.path.dirname(os.path.abspath(folder_name))
|
|
116
|
+
|
|
117
|
+
project_id = _resolve_project_id(folder_name, project_id_opt)
|
|
105
118
|
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
119
|
|
|
110
|
-
logger.info(f"Reading local files from {lookml_dir} for project {project_id}...")
|
|
111
120
|
files_to_push = []
|
|
112
|
-
|
|
113
|
-
if
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if
|
|
121
|
+
if file_opt:
|
|
122
|
+
full_path = file_opt if os.path.isabs(file_opt) else os.path.join(lookml_dir, file_opt)
|
|
123
|
+
full_path = os.path.abspath(full_path)
|
|
124
|
+
try:
|
|
125
|
+
if os.path.commonpath([lookml_dir, full_path]) != lookml_dir:
|
|
126
|
+
logger.error(f"Path traversal detected and blocked: {file_opt}")
|
|
127
|
+
raise typer.Exit(1)
|
|
128
|
+
except ValueError:
|
|
129
|
+
logger.error(f"Path traversal detected and blocked: {file_opt}")
|
|
130
|
+
raise typer.Exit(1)
|
|
131
|
+
|
|
132
|
+
if not os.path.exists(full_path):
|
|
133
|
+
logger.error(f"Local file does not exist: {full_path}")
|
|
134
|
+
raise typer.Exit(1)
|
|
135
|
+
|
|
136
|
+
filename = os.path.basename(full_path)
|
|
137
|
+
if not any(filename.endswith(ext) for ext in VALID_EXTENSIONS):
|
|
138
|
+
logger.error(f"Local file '{filename}' has an extension not supported by Looker.")
|
|
139
|
+
raise typer.Exit(1)
|
|
140
|
+
|
|
141
|
+
rel_path = os.path.relpath(full_path, lookml_dir).replace("\\", "/")
|
|
142
|
+
with open(full_path, "r", encoding="utf-8") as f:
|
|
143
|
+
content = f.read()
|
|
144
|
+
files_to_push.append({
|
|
145
|
+
"path": rel_path,
|
|
146
|
+
"root_name": filename,
|
|
147
|
+
"content": content,
|
|
148
|
+
})
|
|
149
|
+
else:
|
|
150
|
+
if not os.path.exists(lookml_dir):
|
|
151
|
+
logger.error(f"Local folder does not exist: {lookml_dir}")
|
|
152
|
+
raise typer.Exit(1)
|
|
153
|
+
|
|
154
|
+
logger.info(f"Reading local files from {lookml_dir} for project {project_id}...")
|
|
155
|
+
for root, _, files in os.walk(lookml_dir):
|
|
156
|
+
if ".git" in root.split(os.sep):
|
|
117
157
|
continue
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
"
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
158
|
+
for file in files:
|
|
159
|
+
if file in (".gitignore", ".DS_Store"):
|
|
160
|
+
continue
|
|
161
|
+
if any(file.endswith(ext) for ext in VALID_EXTENSIONS):
|
|
162
|
+
full_path = os.path.join(root, file)
|
|
163
|
+
rel_path = os.path.relpath(full_path, lookml_dir).replace("\\", "/")
|
|
164
|
+
with open(full_path, "r", encoding="utf-8") as f:
|
|
165
|
+
content = f.read()
|
|
166
|
+
files_to_push.append({
|
|
167
|
+
"path": rel_path,
|
|
168
|
+
"root_name": file,
|
|
169
|
+
"content": content,
|
|
170
|
+
})
|
|
171
|
+
else:
|
|
172
|
+
logger.warning(
|
|
173
|
+
f"Local file '{file}' has an extension not supported by Looker and will be skipped."
|
|
174
|
+
)
|
|
132
175
|
|
|
133
176
|
successfully_pushed_paths = set()
|
|
134
177
|
for f in files_to_push:
|
|
@@ -185,24 +228,25 @@ def push(
|
|
|
185
228
|
)
|
|
186
229
|
successfully_pushed_paths.add(root_path)
|
|
187
230
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
231
|
+
if not file_opt:
|
|
232
|
+
logger.info("Retrieving remote file inventory to enforce one-way mirror...")
|
|
233
|
+
existing_remote = sdk.all_project_files(project_id=project_id) or []
|
|
234
|
+
for rf in existing_remote:
|
|
235
|
+
rf_path = _get_file_id(rf)
|
|
236
|
+
if not any(rf_path.endswith(ext) for ext in VALID_EXTENSIONS):
|
|
237
|
+
logger.warning(
|
|
238
|
+
f"Remote file '{rf_path}' has an extension not supported by Looker."
|
|
239
|
+
)
|
|
240
|
+
if (
|
|
241
|
+
rf_path not in successfully_pushed_paths
|
|
242
|
+
and not rf_path.endswith(".gitkeep")
|
|
243
|
+
and rf_path != "manifest.lkml"
|
|
244
|
+
):
|
|
245
|
+
logger.info(f"Deleting remote orphan file: {rf_path}")
|
|
246
|
+
try:
|
|
247
|
+
sdk.delete_file(project_id=project_id, file_path=rf_path)
|
|
248
|
+
except Exception as dpe:
|
|
249
|
+
logger.debug(f"Deletion notice for {rf_path}: {dpe}")
|
|
206
250
|
|
|
207
251
|
logger.info("Push completed successfully.")
|
|
208
252
|
|
|
@@ -234,6 +278,14 @@ def pull(
|
|
|
234
278
|
help="Looker project ID to pull from (if different from folder name)",
|
|
235
279
|
),
|
|
236
280
|
] = None,
|
|
281
|
+
file_opt: Annotated[
|
|
282
|
+
Optional[str],
|
|
283
|
+
typer.Option(
|
|
284
|
+
"--file",
|
|
285
|
+
"-f",
|
|
286
|
+
help="Single file relative path to pull from Looker",
|
|
287
|
+
),
|
|
288
|
+
] = None,
|
|
237
289
|
deploy: Annotated[
|
|
238
290
|
bool,
|
|
239
291
|
typer.Option(
|
|
@@ -246,6 +298,7 @@ def pull(
|
|
|
246
298
|
):
|
|
247
299
|
"""
|
|
248
300
|
Pull remote files from Looker to local disk, removing local files that aren't on the instance.
|
|
301
|
+
If --file / -f is specified, only that single file is pulled without deleting local orphans.
|
|
249
302
|
"""
|
|
250
303
|
auth = get_auth(ctx)
|
|
251
304
|
sdk: ExtendedLooker40SDK = auth.get_current_sdk()
|
|
@@ -254,75 +307,103 @@ def pull(
|
|
|
254
307
|
target_dir = os.path.abspath(folder_name)
|
|
255
308
|
os.makedirs(target_dir, exist_ok=True)
|
|
256
309
|
|
|
257
|
-
|
|
258
|
-
|
|
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)
|
|
310
|
+
if file_opt:
|
|
311
|
+
full_path = file_opt if os.path.isabs(file_opt) else os.path.join(target_dir, file_opt)
|
|
312
|
+
resolved_local = os.path.abspath(full_path)
|
|
275
313
|
try:
|
|
276
314
|
if os.path.commonpath([target_dir, resolved_local]) != target_dir:
|
|
277
|
-
logger.
|
|
278
|
-
|
|
315
|
+
logger.error(f"Path traversal detected and blocked: {file_opt}")
|
|
316
|
+
raise typer.Exit(1)
|
|
279
317
|
except ValueError:
|
|
280
|
-
logger.
|
|
281
|
-
|
|
318
|
+
logger.error(f"Path traversal detected and blocked: {file_opt}")
|
|
319
|
+
raise typer.Exit(1)
|
|
320
|
+
|
|
321
|
+
rf_path = os.path.relpath(resolved_local, target_dir).replace("\\", "/")
|
|
322
|
+
if not any(rf_path.endswith(ext) for ext in VALID_EXTENSIONS):
|
|
323
|
+
logger.error(
|
|
324
|
+
f"File '{rf_path}' has an extension not supported by Looker."
|
|
325
|
+
)
|
|
326
|
+
raise typer.Exit(1)
|
|
282
327
|
|
|
283
328
|
try:
|
|
284
329
|
content = sdk.get_file_content(project_id=project_id, file_path=rf_path)
|
|
285
330
|
except Exception as e:
|
|
286
|
-
|
|
287
|
-
|
|
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)
|
|
331
|
+
logger.error(f"Failed to fetch content for remote file {rf_path}: {e}")
|
|
332
|
+
raise typer.Exit(1)
|
|
295
333
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
with open(local_path, "w", encoding="utf-8") as f:
|
|
334
|
+
os.makedirs(os.path.dirname(resolved_local), exist_ok=True)
|
|
335
|
+
with open(resolved_local, "w", encoding="utf-8") as f:
|
|
299
336
|
f.write(content)
|
|
300
337
|
logger.info(f"Pulled file: {rf_path}")
|
|
338
|
+
else:
|
|
339
|
+
logger.info(
|
|
340
|
+
f"Retrieving remote files for project {project_id} to pull into {target_dir}..."
|
|
341
|
+
)
|
|
342
|
+
existing_remote = sdk.all_project_files(project_id=project_id) or []
|
|
301
343
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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:
|
|
344
|
+
remote_paths = set()
|
|
345
|
+
for rf in existing_remote:
|
|
346
|
+
rf_path = _get_file_id(rf)
|
|
347
|
+
if not any(rf_path.endswith(ext) for ext in VALID_EXTENSIONS):
|
|
323
348
|
logger.warning(
|
|
324
|
-
f"
|
|
349
|
+
f"Remote file '{rf_path}' has an extension not supported by Looker."
|
|
325
350
|
)
|
|
351
|
+
continue
|
|
352
|
+
if rf_path.endswith(".gitkeep"):
|
|
353
|
+
continue
|
|
354
|
+
|
|
355
|
+
local_path = os.path.join(target_dir, rf_path)
|
|
356
|
+
resolved_local = os.path.abspath(local_path)
|
|
357
|
+
try:
|
|
358
|
+
if os.path.commonpath([target_dir, resolved_local]) != target_dir:
|
|
359
|
+
logger.warning(f"Path traversal detected and blocked: {rf_path}")
|
|
360
|
+
continue
|
|
361
|
+
except ValueError:
|
|
362
|
+
logger.warning(f"Path traversal detected and blocked: {rf_path}")
|
|
363
|
+
continue
|
|
364
|
+
|
|
365
|
+
try:
|
|
366
|
+
content = sdk.get_file_content(project_id=project_id, file_path=rf_path)
|
|
367
|
+
except Exception as e:
|
|
368
|
+
if "not found" in str(e).lower():
|
|
369
|
+
logger.debug(
|
|
370
|
+
f"Remote file {rf_path} not found in dev workspace (likely a deleted ghost file in git tracking)."
|
|
371
|
+
)
|
|
372
|
+
else:
|
|
373
|
+
logger.error(f"Failed to fetch content for remote file {rf_path}: {e}")
|
|
374
|
+
continue
|
|
375
|
+
|
|
376
|
+
remote_paths.add(rf_path)
|
|
377
|
+
|
|
378
|
+
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
|
379
|
+
with open(local_path, "w", encoding="utf-8") as f:
|
|
380
|
+
f.write(content)
|
|
381
|
+
logger.info(f"Pulled file: {rf_path}")
|
|
382
|
+
|
|
383
|
+
logger.info("Cleaning up local orphans not present on the Looker instance...")
|
|
384
|
+
for root, _, files in os.walk(target_dir):
|
|
385
|
+
if ".git" in root.split(os.sep):
|
|
386
|
+
continue
|
|
387
|
+
for file in files:
|
|
388
|
+
if file in (".gitignore", ".DS_Store"):
|
|
389
|
+
continue
|
|
390
|
+
if any(file.endswith(ext) for ext in VALID_EXTENSIONS):
|
|
391
|
+
full_path = os.path.join(root, file)
|
|
392
|
+
rel_path = os.path.relpath(full_path, target_dir).replace("\\", "/")
|
|
393
|
+
if (
|
|
394
|
+
rel_path not in remote_paths
|
|
395
|
+
and not rel_path.endswith(".gitkeep")
|
|
396
|
+
and rel_path != "manifest.lkml"
|
|
397
|
+
):
|
|
398
|
+
logger.info(f"Deleting local orphan file: {rel_path}")
|
|
399
|
+
try:
|
|
400
|
+
os.remove(full_path)
|
|
401
|
+
except Exception as err:
|
|
402
|
+
logger.debug(f"Local deletion notice for {rel_path}: {err}")
|
|
403
|
+
else:
|
|
404
|
+
logger.warning(
|
|
405
|
+
f"Local file '{file}' has an extension not supported by Looker and will not be synchronized or deleted."
|
|
406
|
+
)
|
|
326
407
|
|
|
327
408
|
logger.info("Pull completed successfully.")
|
|
328
409
|
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
from typing import Any, cast
|
|
2
2
|
import json
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
3
5
|
import pytest
|
|
4
6
|
from unittest.mock import patch, MagicMock
|
|
7
|
+
from typer.testing import CliRunner
|
|
5
8
|
from looker_sdk.rtl.auth_session import AuthSession
|
|
6
9
|
from looker_sdk.rtl.transport import Transport
|
|
7
10
|
from lkr.codemode.main import run_python_code
|
|
8
11
|
from lkr.extended_sdk_methods import ExtendedLooker40SDK
|
|
12
|
+
from lkr.main import app
|
|
9
13
|
|
|
10
14
|
|
|
11
15
|
class DummyAuth:
|
|
@@ -67,7 +71,7 @@ me_obj = sdk.me()
|
|
|
67
71
|
return me_obj["first_name"]
|
|
68
72
|
"""
|
|
69
73
|
result = run_python_code(code_sdk)
|
|
70
|
-
assert
|
|
74
|
+
assert result == "Test"
|
|
71
75
|
|
|
72
76
|
def test_examples():
|
|
73
77
|
code_examples = """
|
|
@@ -283,3 +287,36 @@ return {"status": "clean"}
|
|
|
283
287
|
|
|
284
288
|
out, err = capfd.readouterr()
|
|
285
289
|
assert out == ""
|
|
290
|
+
|
|
291
|
+
runner = CliRunner()
|
|
292
|
+
|
|
293
|
+
def test_cli_code_mode_sandbox_code():
|
|
294
|
+
result = runner.invoke(app, ["code-mode", "sandbox", "--code", "return me()"])
|
|
295
|
+
assert result.exit_code == 0
|
|
296
|
+
assert "Test" in result.stdout
|
|
297
|
+
|
|
298
|
+
def test_cli_code_mode_sandbox_sdk_code():
|
|
299
|
+
result = runner.invoke(app, ["code-mode", "sandbox", "--code", "return sdk.me()"])
|
|
300
|
+
assert result.exit_code == 0
|
|
301
|
+
assert "Test" in result.stdout
|
|
302
|
+
|
|
303
|
+
def test_cli_code_mode_sandbox_file():
|
|
304
|
+
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
|
305
|
+
f.write("return me()")
|
|
306
|
+
temp_path = f.name
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
result = runner.invoke(app, ["code-mode", "sandbox", "--file", temp_path])
|
|
310
|
+
assert result.exit_code == 0
|
|
311
|
+
assert "Test" in result.stdout
|
|
312
|
+
finally:
|
|
313
|
+
if os.path.exists(temp_path):
|
|
314
|
+
os.remove(temp_path)
|
|
315
|
+
|
|
316
|
+
def test_cli_code_mode_sandbox_both_error():
|
|
317
|
+
result = runner.invoke(app, ["code-mode", "sandbox", "--code", "return me()", "--file", "dummy.py"])
|
|
318
|
+
assert result.exit_code == 1
|
|
319
|
+
|
|
320
|
+
def test_cli_code_mode_sandbox_neither_error():
|
|
321
|
+
result = runner.invoke(app, ["code-mode", "sandbox"])
|
|
322
|
+
assert result.exit_code == 1
|
|
@@ -265,4 +265,144 @@ def test_push_create_directory_recursively_fallback(tmp_path, mock_sdk, mock_aut
|
|
|
265
265
|
assert mock_sdk.create_file.call_count == 2
|
|
266
266
|
|
|
267
267
|
|
|
268
|
+
def test_push_single_file_option(tmp_path, mock_sdk, mock_auth):
|
|
269
|
+
project_dir = tmp_path / "test_project"
|
|
270
|
+
project_dir.mkdir()
|
|
271
|
+
|
|
272
|
+
views_dir = project_dir / "views"
|
|
273
|
+
views_dir.mkdir()
|
|
274
|
+
|
|
275
|
+
view_file = views_dir / "target.view.lkml"
|
|
276
|
+
view_file.write_text("view: target {}")
|
|
277
|
+
|
|
278
|
+
other_file = views_dir / "other.view.lkml"
|
|
279
|
+
other_file.write_text("view: other {}")
|
|
280
|
+
|
|
281
|
+
with patch("lkr.tools.lookml.get_auth", return_value=mock_auth):
|
|
282
|
+
result = runner.invoke(
|
|
283
|
+
app,
|
|
284
|
+
[
|
|
285
|
+
"tools",
|
|
286
|
+
"lookml",
|
|
287
|
+
"push",
|
|
288
|
+
str(project_dir),
|
|
289
|
+
"--file",
|
|
290
|
+
"views/target.view.lkml",
|
|
291
|
+
],
|
|
292
|
+
)
|
|
293
|
+
assert result.exit_code == 0
|
|
294
|
+
|
|
295
|
+
# Verify update_file was called only for target.view.lkml
|
|
296
|
+
expected_fc = FileContent(
|
|
297
|
+
path="views/target.view.lkml", content="view: target {}"
|
|
298
|
+
)
|
|
299
|
+
mock_sdk.update_file.assert_called_once_with(
|
|
300
|
+
project_id="test_project", file_content=expected_fc
|
|
301
|
+
)
|
|
302
|
+
# Verify no orphan cleanup inventory check occurred
|
|
303
|
+
mock_sdk.all_project_files.assert_not_called()
|
|
304
|
+
mock_sdk.delete_file.assert_not_called()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_push_file_as_argument(tmp_path, mock_sdk, mock_auth):
|
|
308
|
+
project_dir = tmp_path / "test_project"
|
|
309
|
+
project_dir.mkdir()
|
|
310
|
+
|
|
311
|
+
view_file = project_dir / "standalone.view.lkml"
|
|
312
|
+
view_file.write_text("view: standalone {}")
|
|
313
|
+
|
|
314
|
+
with patch("lkr.tools.lookml.get_auth", return_value=mock_auth):
|
|
315
|
+
result = runner.invoke(
|
|
316
|
+
app,
|
|
317
|
+
["tools", "lookml", "push", str(view_file), "--project", "test_project"],
|
|
318
|
+
)
|
|
319
|
+
assert result.exit_code == 0
|
|
320
|
+
|
|
321
|
+
expected_fc = FileContent(
|
|
322
|
+
path="standalone.view.lkml", content="view: standalone {}"
|
|
323
|
+
)
|
|
324
|
+
mock_sdk.update_file.assert_called_once_with(
|
|
325
|
+
project_id="test_project", file_content=expected_fc
|
|
326
|
+
)
|
|
327
|
+
mock_sdk.all_project_files.assert_not_called()
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_pull_single_file_option(tmp_path, mock_sdk, mock_auth):
|
|
331
|
+
project_dir = tmp_path / "test_project"
|
|
332
|
+
project_dir.mkdir()
|
|
333
|
+
|
|
334
|
+
local_orphan = project_dir / "orphan.view.lkml"
|
|
335
|
+
local_orphan.write_text("orphan content")
|
|
336
|
+
|
|
337
|
+
mock_sdk.get_file_content.return_value = "view: pulled_single {}"
|
|
338
|
+
|
|
339
|
+
with patch("lkr.tools.lookml.get_auth", return_value=mock_auth):
|
|
340
|
+
result = runner.invoke(
|
|
341
|
+
app,
|
|
342
|
+
[
|
|
343
|
+
"tools",
|
|
344
|
+
"lookml",
|
|
345
|
+
"pull",
|
|
346
|
+
str(project_dir),
|
|
347
|
+
"-f",
|
|
348
|
+
"views/single.view.lkml",
|
|
349
|
+
],
|
|
350
|
+
)
|
|
351
|
+
assert result.exit_code == 0
|
|
352
|
+
|
|
353
|
+
pulled_file = project_dir / "views" / "single.view.lkml"
|
|
354
|
+
assert pulled_file.exists()
|
|
355
|
+
assert pulled_file.read_text() == "view: pulled_single {}"
|
|
356
|
+
|
|
357
|
+
# Local orphan must NOT be deleted in single file pull mode
|
|
358
|
+
assert local_orphan.exists()
|
|
359
|
+
mock_sdk.all_project_files.assert_not_called()
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def test_push_single_file_path_traversal(tmp_path, mock_sdk, mock_auth):
|
|
363
|
+
project_dir = tmp_path / "test_project"
|
|
364
|
+
project_dir.mkdir()
|
|
365
|
+
|
|
366
|
+
with patch("lkr.tools.lookml.get_auth", return_value=mock_auth), patch(
|
|
367
|
+
"lkr.tools.lookml.logger.error"
|
|
368
|
+
) as mock_err:
|
|
369
|
+
result = runner.invoke(
|
|
370
|
+
app,
|
|
371
|
+
[
|
|
372
|
+
"tools",
|
|
373
|
+
"lookml",
|
|
374
|
+
"push",
|
|
375
|
+
str(project_dir),
|
|
376
|
+
"-f",
|
|
377
|
+
"../outside.view.lkml",
|
|
378
|
+
],
|
|
379
|
+
)
|
|
380
|
+
assert result.exit_code == 1
|
|
381
|
+
mock_err.assert_called_with("Path traversal detected and blocked: ../outside.view.lkml")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def test_pull_single_file_path_traversal(tmp_path, mock_sdk, mock_auth):
|
|
385
|
+
project_dir = tmp_path / "test_project"
|
|
386
|
+
project_dir.mkdir()
|
|
387
|
+
|
|
388
|
+
with patch("lkr.tools.lookml.get_auth", return_value=mock_auth), patch(
|
|
389
|
+
"lkr.tools.lookml.logger.error"
|
|
390
|
+
) as mock_err:
|
|
391
|
+
result = runner.invoke(
|
|
392
|
+
app,
|
|
393
|
+
[
|
|
394
|
+
"tools",
|
|
395
|
+
"lookml",
|
|
396
|
+
"pull",
|
|
397
|
+
str(project_dir),
|
|
398
|
+
"-f",
|
|
399
|
+
"../outside.view.lkml",
|
|
400
|
+
],
|
|
401
|
+
)
|
|
402
|
+
assert result.exit_code == 1
|
|
403
|
+
mock_err.assert_called_with("Path traversal detected and blocked: ../outside.view.lkml")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
|
|
268
408
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from typer.testing import CliRunner
|
|
2
|
+
from lkr.main import app
|
|
3
|
+
|
|
4
|
+
runner = CliRunner()
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_version_option():
|
|
8
|
+
result = runner.invoke(app, ["--version"])
|
|
9
|
+
assert result.exit_code == 0
|
|
10
|
+
# In test environment it should print "0.0.0" (default from importlib.metadata.PackageNotFoundError)
|
|
11
|
+
# or the installed package version.
|
|
12
|
+
assert "0.0.0" in result.stdout or "0.0." in result.stdout
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|