lkr-dev-cli 0.0.45__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.
Files changed (64) hide show
  1. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/PKG-INFO +1 -1
  2. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/main.py +21 -0
  3. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/tools/lookml.py +179 -98
  4. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/pyproject.toml +1 -1
  5. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/tests/test_lookml_tools.py +140 -0
  6. lkr_dev_cli-0.0.46/tests/test_version.py +12 -0
  7. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/.github/workflows/ci.yml +0 -0
  8. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/.github/workflows/release.yml +0 -0
  9. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/.github/workflows/website.yml +0 -0
  10. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/.gitignore +0 -0
  11. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/.python-version +0 -0
  12. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/.vscode/launch.json +0 -0
  13. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/.vscode/settings.json +0 -0
  14. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/Dockerfile +0 -0
  15. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/LICENSE +0 -0
  16. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/Makefile +0 -0
  17. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/README.md +0 -0
  18. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/cloudbuild.yaml +0 -0
  19. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/codemode.md +0 -0
  20. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/__init__.py +0 -0
  21. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/auth/__init__.py +0 -0
  22. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/auth/main.py +0 -0
  23. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/auth/oauth.py +0 -0
  24. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/auth_service.py +0 -0
  25. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/classes.py +0 -0
  26. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/codemode/LOCAL.md +0 -0
  27. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/codemode/__init__.py +0 -0
  28. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/codemode/constant.py +0 -0
  29. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/codemode/download_swagger.py +0 -0
  30. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/codemode/examples.py +0 -0
  31. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/codemode/help.py +0 -0
  32. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/codemode/main.py +0 -0
  33. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/codemode/readme.py +0 -0
  34. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/codemode/swagger.json +0 -0
  35. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/codemode/type.py +0 -0
  36. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/constants.py +0 -0
  37. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/custom_types.py +0 -0
  38. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/exceptions.py +0 -0
  39. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/extended_sdk_methods/__init__.py +0 -0
  40. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/extended_sdk_methods/classes.py +0 -0
  41. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/extended_sdk_methods/main.py +0 -0
  42. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/logger.py +0 -0
  43. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/mcp/classes.py +0 -0
  44. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/mcp/main.py +0 -0
  45. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/mcp/utils.py +0 -0
  46. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/observability/classes.py +0 -0
  47. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/observability/embed_container.html +0 -0
  48. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/observability/main.py +0 -0
  49. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/observability/utils.py +0 -0
  50. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/tools/classes.py +0 -0
  51. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/tools/main.py +0 -0
  52. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr/tools/permission_deprecation.py +0 -0
  53. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr.md +0 -0
  54. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/lkr_dev_cli/__init__.py +0 -0
  55. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/tests/TESTING.md +0 -0
  56. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/tests/test_codemode.py +0 -0
  57. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/tests/test_dependency_resolution.py +0 -0
  58. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/tests/test_deps.sh +0 -0
  59. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/tests/test_extended_sdk_methods.py +0 -0
  60. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/tests/test_init_sdk.py +0 -0
  61. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/tests/test_oauth_account.py +0 -0
  62. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/tests/test_permission_deprecation.py +0 -0
  63. {lkr_dev_cli-0.0.45 → lkr_dev_cli-0.0.46}/ty.toml +0 -0
  64. {lkr_dev_cli-0.0.45 → 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.45
3
+ Version: 0.0.46
4
4
  Summary: lkr: a command line interface for looker
5
5
  Author: bwebs
6
6
  License-Expression: MIT
@@ -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
- 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"):
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
- 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
- )
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
- 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}")
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
- 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)
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.warning(f"Path traversal detected and blocked: {rf_path}")
278
- continue
315
+ logger.error(f"Path traversal detected and blocked: {file_opt}")
316
+ raise typer.Exit(1)
279
317
  except ValueError:
280
- logger.warning(f"Path traversal detected and blocked: {rf_path}")
281
- continue
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
- 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)
331
+ logger.error(f"Failed to fetch content for remote file {rf_path}: {e}")
332
+ raise typer.Exit(1)
295
333
 
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:
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
- 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:
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"Local file '{file}' has an extension not supported by Looker and will not be synchronized or deleted."
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "lkr-dev-cli"
3
- version = "0.0.45"
3
+ version = "0.0.46"
4
4
  description = "lkr: a command line interface for looker"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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