cloudcat 0.6.0__tar.gz → 0.6.2__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 (69) hide show
  1. {cloudcat-0.6.0 → cloudcat-0.6.2}/PKG-INFO +12 -2
  2. {cloudcat-0.6.0 → cloudcat-0.6.2}/README.md +11 -1
  3. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/__init__.py +1 -1
  4. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/cli.py +165 -52
  5. cloudcat-0.6.2/cloudcat/completion.py +248 -0
  6. cloudcat-0.6.2/cloudcat/storage/__init__.py +44 -0
  7. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat.egg-info/PKG-INFO +12 -2
  8. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat.egg-info/SOURCES.txt +4 -0
  9. cloudcat-0.6.2/tests/test_completion.py +113 -0
  10. {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_data_reading.py +12 -16
  11. cloudcat-0.6.2/tests/test_import_lightness.py +65 -0
  12. {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_integration.py +6 -6
  13. {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_output_formatting.py +1 -1
  14. cloudcat-0.6.2/tests/test_v062_fixes.py +155 -0
  15. cloudcat-0.6.0/cloudcat/storage/__init__.py +0 -26
  16. {cloudcat-0.6.0 → cloudcat-0.6.2}/LICENSE +0 -0
  17. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/compression.py +0 -0
  18. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/config.py +0 -0
  19. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/data/SKILL.md +0 -0
  20. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/filtering.py +0 -0
  21. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/formatters.py +0 -0
  22. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/progress.py +0 -0
  23. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/readers/__init__.py +0 -0
  24. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/readers/avro.py +0 -0
  25. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/readers/csv.py +0 -0
  26. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/readers/json.py +0 -0
  27. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/readers/orc.py +0 -0
  28. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/readers/parquet.py +0 -0
  29. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/readers/text.py +0 -0
  30. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/storage/azure.py +0 -0
  31. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/storage/base.py +0 -0
  32. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/storage/gcs.py +0 -0
  33. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/storage/local.py +0 -0
  34. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/storage/s3.py +0 -0
  35. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/streaming/__init__.py +0 -0
  36. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/streaming/filesystems.py +0 -0
  37. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/streaming/stats.py +0 -0
  38. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/streaming/tracking.py +0 -0
  39. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/tables/__init__.py +0 -0
  40. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/tables/delta.py +0 -0
  41. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/tables/iceberg.py +0 -0
  42. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/tables/pushdown.py +0 -0
  43. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/user_config.py +0 -0
  44. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat.egg-info/dependency_links.txt +0 -0
  45. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat.egg-info/entry_points.txt +0 -0
  46. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat.egg-info/requires.txt +0 -0
  47. {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat.egg-info/top_level.txt +0 -0
  48. {cloudcat-0.6.0 → cloudcat-0.6.2}/pyproject.toml +0 -0
  49. {cloudcat-0.6.0 → cloudcat-0.6.2}/setup.cfg +0 -0
  50. {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_bugfix_round2.py +0 -0
  51. {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_cli.py +0 -0
  52. {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_cli_features.py +0 -0
  53. {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_compression.py +0 -0
  54. {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_config.py +0 -0
  55. {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_config_stats_completion.py +0 -0
  56. {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_file_filtering.py +0 -0
  57. {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_filtering.py +0 -0
  58. {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_format_detection.py +0 -0
  59. {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_path_parsing.py +0 -0
  60. {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_read_data_streaming.py +0 -0
  61. {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_readers_roundtrip.py +0 -0
  62. {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_regressions.py +0 -0
  63. {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_skill_install.py +0 -0
  64. {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_storage_listing.py +0 -0
  65. {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_streaming_stats.py +0 -0
  66. {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_tables.py +0 -0
  67. {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_tracking.py +0 -0
  68. {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_v05_features.py +0 -0
  69. {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_visual_output.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudcat
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: Preview and analyze data files in Google Cloud Storage, AWS S3, and Azure Data Lake Storage Gen2 from your terminal
5
5
  Author: Jonathan Sudhakar
6
6
  License: MIT
@@ -554,7 +554,17 @@ Options:
554
554
 
555
555
  --config-profile TEXT Named profile from ~/.config/cloudcat/config.toml
556
556
 
557
- --completion [bash|zsh|fish] Print the shell completion script and exit
557
+ --install-completion [bash|zsh|fish]
558
+ Enable completion with one command (adds one
559
+ line to your shell rc file; idempotent)
560
+
561
+ --completion [bash|zsh|fish] Print the shell completion script and exit.
562
+ Once enabled, TAB completes PATH too: local
563
+ files natively, s3://<TAB> lists buckets,
564
+ s3://bucket/dir/<TAB> lists children (uses
565
+ --profile/--project if already typed).
566
+ bash note: escape the URL colon issue with
567
+ COMP_WORDBREAKS=${COMP_WORDBREAKS//:/}
558
568
 
559
569
  --az-access-key TEXT Azure storage account access key
560
570
 
@@ -472,7 +472,17 @@ Options:
472
472
 
473
473
  --config-profile TEXT Named profile from ~/.config/cloudcat/config.toml
474
474
 
475
- --completion [bash|zsh|fish] Print the shell completion script and exit
475
+ --install-completion [bash|zsh|fish]
476
+ Enable completion with one command (adds one
477
+ line to your shell rc file; idempotent)
478
+
479
+ --completion [bash|zsh|fish] Print the shell completion script and exit.
480
+ Once enabled, TAB completes PATH too: local
481
+ files natively, s3://<TAB> lists buckets,
482
+ s3://bucket/dir/<TAB> lists children (uses
483
+ --profile/--project if already typed).
484
+ bash note: escape the URL colon issue with
485
+ COMP_WORDBREAKS=${COMP_WORDBREAKS//:/}
476
486
 
477
487
  --az-access-key TEXT Azure storage account access key
478
488
 
@@ -2,4 +2,4 @@
2
2
  #!/usr/bin/env python
3
3
  """cloudcat - A CLI utility to read and display files from cloud storage."""
4
4
 
5
- __version__ = "0.6.0"
5
+ __version__ = "0.6.2"
@@ -1,8 +1,16 @@
1
1
  #!/usr/bin/env python
2
- """CloudCat CLI - Preview and analyze data files in cloud storage."""
2
+ """CloudCat CLI - Preview and analyze data files in cloud storage.
3
+
4
+ Import discipline: this module keeps its import-time footprint light —
5
+ heavy dependencies (pandas, pyarrow, cloud SDKs, the readers) are imported
6
+ inside the functions that use them. Shell tab-completion re-imports this
7
+ module on every keypress, so module import must stay fast; the guard test
8
+ in tests/test_import_lightness.py enforces it.
9
+ """
10
+
11
+ from __future__ import annotations
3
12
 
4
13
  import click
5
- import pandas as pd
6
14
  import os
7
15
  import sys
8
16
  import io
@@ -16,7 +24,7 @@ from colorama import init, Fore, Style
16
24
  # Import version
17
25
  from . import __version__
18
26
 
19
- # Import from modular components
27
+ # Light modular components (no heavy transitive imports)
20
28
  from .config import cloud_config, SKIP_PATTERNS, FORMAT_EXTENSION_MAP
21
29
  from .compression import (
22
30
  detect_compression,
@@ -25,47 +33,16 @@ from .compression import (
25
33
  get_streaming_decompressor,
26
34
  supports_streaming_decompression,
27
35
  )
28
- from .filtering import parse_where_clause, apply_where_filter, where_columns
29
- from .formatters import colorize_json, format_table_with_colored_header
30
36
  from .progress import start_progress, update_progress, stop_progress
31
- from .storage import (
37
+ # storage.base dispatches to the cloud SDKs lazily per call, unlike the
38
+ # storage package __init__, which imports all of them eagerly.
39
+ from .storage.base import (
32
40
  parse_cloud_path,
33
41
  get_stream,
34
42
  list_directory,
35
43
  get_file_size,
36
44
  )
37
- from .storage.gcs import get_gcs_stream, list_gcs_directory
38
- from .storage.s3 import get_s3_stream, list_s3_directory
39
- from .storage.azure import get_azure_stream, list_azure_directory
40
- from .readers import (
41
- read_csv_data,
42
- read_csv_data_streaming,
43
- read_json_data,
44
- read_json_data_streaming,
45
- read_parquet_data,
46
- read_parquet_data_streaming,
47
- read_avro_data,
48
- read_avro_data_streaming,
49
- read_orc_data,
50
- read_orc_data_streaming,
51
- read_text_data,
52
- read_text_data_streaming,
53
- HAS_PARQUET,
54
- HAS_AVRO,
55
- HAS_ORC,
56
- )
57
- from .streaming import (
58
- StreamingStats,
59
- format_bytes,
60
- get_pyarrow_filesystem,
61
- supports_pyarrow_fs,
62
- )
63
-
64
- # Import pyarrow for parquet metadata if available
65
- try:
66
- import pyarrow.parquet as pq
67
- except ImportError:
68
- pq = None
45
+ from .completion import complete_path as _complete_path
69
46
 
70
47
 
71
48
  def info(message: str) -> None:
@@ -92,15 +69,23 @@ def _configure_color(no_color: bool) -> None:
92
69
  init(strip=not use_color)
93
70
 
94
71
 
95
- # Map of input format -> non-streaming reader (used for multi-file reads).
96
- _READERS = {
97
- 'csv': lambda stream, n, columns, delimiter: read_csv_data(stream, n, columns, delimiter),
98
- 'json': lambda stream, n, columns, delimiter: read_json_data(stream, n, columns),
99
- 'parquet': lambda stream, n, columns, delimiter: read_parquet_data(stream, n, columns),
100
- 'avro': lambda stream, n, columns, delimiter: read_avro_data(stream, n, columns),
101
- 'orc': lambda stream, n, columns, delimiter: read_orc_data(stream, n, columns),
102
- 'text': lambda stream, n, columns, delimiter: read_text_data(stream, n, columns),
103
- }
72
+ def _get_reader(input_format: str):
73
+ """Resolve the non-streaming reader for a format (lazy import).
74
+
75
+ Resolves through the readers package at call time so tests can patch
76
+ cloudcat.readers.read_<fmt>_data, and so importing this module never
77
+ pulls pandas/pyarrow.
78
+ """
79
+ from . import readers
80
+ table = {
81
+ 'csv': lambda stream, n, columns, delimiter: readers.read_csv_data(stream, n, columns, delimiter),
82
+ 'json': lambda stream, n, columns, delimiter: readers.read_json_data(stream, n, columns),
83
+ 'parquet': lambda stream, n, columns, delimiter: readers.read_parquet_data(stream, n, columns),
84
+ 'avro': lambda stream, n, columns, delimiter: readers.read_avro_data(stream, n, columns),
85
+ 'orc': lambda stream, n, columns, delimiter: readers.read_orc_data(stream, n, columns),
86
+ 'text': lambda stream, n, columns, delimiter: readers.read_text_data(stream, n, columns),
87
+ }
88
+ return table.get(input_format)
104
89
 
105
90
 
106
91
  def _list_non_empty_files(service: str, bucket: str, prefix: str) -> List[Tuple[str, int]]:
@@ -217,6 +202,35 @@ def get_files_for_multiread(
217
202
  return selected_files
218
203
 
219
204
 
205
+ _ICEBERG_NESTED_RE = re.compile(r'(.*)/metadata/(?:v\d+|\d+-[^/]*)\.metadata\.json$')
206
+
207
+
208
+ def _find_nested_tables(files: List[Tuple[str, int]]) -> dict:
209
+ """Find lakehouse table roots nested inside a directory listing.
210
+
211
+ Returns {root_path: format}. Used to guide users who point cloudcat at
212
+ a folder OF tables rather than at a table — merging data files across
213
+ different tables would silently produce wrong results.
214
+ """
215
+ tables = {}
216
+ for name, _size in files:
217
+ if '/_delta_log/' in f'/{name}':
218
+ root = name.split('/_delta_log/')[0]
219
+ if root: # empty means the listing prefix IS the table root
220
+ tables.setdefault(root, 'delta')
221
+ continue
222
+ match = _ICEBERG_NESTED_RE.match(name)
223
+ if match and match.group(1):
224
+ tables.setdefault(match.group(1), 'iceberg')
225
+ return tables
226
+
227
+
228
+ def _has_known_extension(name: str) -> bool:
229
+ """True if the filename matches any recognized data-format extension."""
230
+ return any(re.search(regex, name, re.IGNORECASE)
231
+ for regex in FORMAT_EXTENSION_MAP.values())
232
+
233
+
220
234
  def find_first_non_empty_file(
221
235
  service: str,
222
236
  bucket: str,
@@ -254,7 +268,40 @@ def find_first_non_empty_file(
254
268
  info(Fore.YELLOW + f"No files matching format '{input_format}' found in "
255
269
  f"{service}://{bucket}/{prefix}. Using first available file." + Style.RESET_ALL)
256
270
 
257
- # Use the first non-empty, non-metadata file
271
+ # No explicit format: the selection drives format INFERENCE, so only
272
+ # consider files whose extension we can actually infer from.
273
+ if not input_format:
274
+ nested_tables = _find_nested_tables(non_empty_files)
275
+ if nested_tables:
276
+ listing = '\n'.join(
277
+ f" {fmt}: {root}/" for root, fmt in sorted(nested_tables.items())
278
+ )
279
+ first_root = sorted(nested_tables)[0]
280
+ raise ValueError(
281
+ "this directory contains lakehouse table(s), not a single dataset:\n"
282
+ f"{listing}\n"
283
+ f"Point at a table root, e.g.: cloudcat {first_root}/ "
284
+ "(or pass --input-format to force reading loose files)"
285
+ )
286
+
287
+ for file_name, file_size in non_empty_files:
288
+ if any(re.search(pattern, file_name) for pattern in SKIP_PATTERNS):
289
+ continue
290
+ if not _has_known_extension(file_name):
291
+ continue # e.g. catalog.db, .lock — can't infer a format from it
292
+ if not quiet:
293
+ info(Fore.BLUE + f"Selected file: {file_name} ({file_size} bytes)" + Style.RESET_ALL)
294
+ return file_name, file_size
295
+
296
+ sample = ', '.join(name.rsplit('/', 1)[-1] for name, _ in non_empty_files[:5])
297
+ raise ValueError(
298
+ f"no files with a recognized data format in {service}://{bucket}/{prefix} "
299
+ f"(found: {sample}). Formats are inferred from extensions "
300
+ "(.csv/.json/.parquet/.avro/.orc/.txt, optionally compressed); "
301
+ "pass --input-format to force one."
302
+ )
303
+
304
+ # Explicit format requested but unmatched: first non-metadata file.
258
305
  for file_name, file_size in non_empty_files:
259
306
  if not any(re.search(pattern, file_name) for pattern in SKIP_PATTERNS):
260
307
  if not quiet:
@@ -327,6 +374,8 @@ def read_data_from_multiple_files(
327
374
  Returns:
328
375
  Tuple of (DataFrame, schema, total_rows).
329
376
  """
377
+ import pandas as pd
378
+
330
379
  dfs = []
331
380
  schemas = []
332
381
  rows_read = 0
@@ -359,7 +408,7 @@ def read_data_from_multiple_files(
359
408
  else:
360
409
  rows_to_read_from_file = 0 # read all rows from this file
361
410
 
362
- reader = _READERS.get(input_format)
411
+ reader = _get_reader(input_format)
363
412
  if reader is None:
364
413
  raise ValueError(f"Unsupported format: {input_format}")
365
414
  df, schema = reader(stream, rows_to_read_from_file, columns, delimiter)
@@ -477,6 +526,16 @@ def read_data_streaming(
477
526
  Returns:
478
527
  Tuple of (DataFrame, schema, StreamingStats).
479
528
  """
529
+ from .readers import (
530
+ read_csv_data_streaming,
531
+ read_json_data_streaming,
532
+ read_parquet_data_streaming,
533
+ read_avro_data_streaming,
534
+ read_orc_data_streaming,
535
+ read_text_data_streaming,
536
+ )
537
+ from .streaming import StreamingStats, get_pyarrow_filesystem, supports_pyarrow_fs
538
+
480
539
  # Get file size for stats
481
540
  try:
482
541
  file_size = get_file_size(service, bucket, object_path)
@@ -607,6 +666,14 @@ def get_record_count(
607
666
  Returns:
608
667
  Record count (int) or "Unknown" on failure.
609
668
  """
669
+ import pandas as pd
670
+ from .readers import HAS_PARQUET, HAS_AVRO, HAS_ORC
671
+ from .streaming import get_pyarrow_filesystem, supports_pyarrow_fs
672
+ try:
673
+ import pyarrow.parquet as pq
674
+ except ImportError:
675
+ pq = None
676
+
610
677
  # Lakehouse tables carry snapshot row counts in their metadata layer.
611
678
  if input_format in ('delta', 'iceberg'):
612
679
  from .tables import table_row_count
@@ -845,6 +912,44 @@ def _print_completion(ctx, param, value):
845
912
  ctx.exit()
846
913
 
847
914
 
915
+ _COMPLETION_SNIPPETS = {
916
+ 'zsh': ('~/.zshrc', 'eval "$(cloudcat --completion zsh)"'),
917
+ 'bash': ('~/.bashrc',
918
+ 'COMP_WORDBREAKS=${COMP_WORDBREAKS//:/} # let URLs like s3:// complete\n'
919
+ 'eval "$(cloudcat --completion bash)"'),
920
+ 'fish': ('~/.config/fish/config.fish', 'cloudcat --completion fish | source'),
921
+ }
922
+
923
+ _COMPLETION_MARKER = '# added by cloudcat --install-completion'
924
+
925
+
926
+ def _install_completion(ctx, param, value):
927
+ """Eager callback: enable shell completion in the user's rc file.
928
+
929
+ Idempotent — a marker comment prevents duplicate blocks on re-runs.
930
+ """
931
+ if not value or ctx.resilient_parsing:
932
+ return
933
+ rc_path, snippet = _COMPLETION_SNIPPETS[value]
934
+ rc_file = os.path.expanduser(rc_path)
935
+
936
+ existing = ''
937
+ if os.path.exists(rc_file):
938
+ with open(rc_file, 'r', encoding='utf-8', errors='replace') as f:
939
+ existing = f.read()
940
+ if _COMPLETION_MARKER in existing:
941
+ click.echo(f"Completion is already set up in {rc_file}")
942
+ ctx.exit()
943
+
944
+ os.makedirs(os.path.dirname(rc_file), exist_ok=True)
945
+ block = f"\n{_COMPLETION_MARKER}\n{snippet}\n"
946
+ with open(rc_file, 'a', encoding='utf-8') as f:
947
+ f.write(block)
948
+ click.echo(f"Added cloudcat completion to {rc_file} — restart your shell "
949
+ f"(or run: source {rc_path}) and try: cloudcat s3://<TAB>")
950
+ ctx.exit()
951
+
952
+
848
953
  # Where each agent expects personal skills (name -> path builder).
849
954
  _SKILL_DESTINATIONS = {
850
955
  'claude': lambda: os.path.join(os.path.expanduser('~'), '.claude', 'skills', 'cloudcat', 'SKILL.md'),
@@ -882,8 +987,9 @@ def _install_skill(ctx, param, value):
882
987
  ctx.exit()
883
988
 
884
989
 
885
- def _column_stats(df: pd.DataFrame) -> pd.DataFrame:
990
+ def _column_stats(df):
886
991
  """Profile each column of a frame: type, nulls, distinct values, range."""
992
+ import pandas as pd
887
993
  rows = []
888
994
  for position, col in enumerate(df.columns):
889
995
  series = df.iloc[:, position]
@@ -908,8 +1014,9 @@ def _column_stats(df: pd.DataFrame) -> pd.DataFrame:
908
1014
  return pd.DataFrame(rows)
909
1015
 
910
1016
 
911
- def _render_data(df: pd.DataFrame, output_format: str) -> str:
1017
+ def _render_data(df, output_format: str) -> str:
912
1018
  """Render a DataFrame to the requested output format string."""
1019
+ from .formatters import colorize_json, format_table_with_colored_header
913
1020
  if output_format == 'table':
914
1021
  return format_table_with_colored_header(df)
915
1022
  elif output_format == 'jsonp':
@@ -923,7 +1030,7 @@ def _render_data(df: pd.DataFrame, output_format: str) -> str:
923
1030
 
924
1031
  @click.command()
925
1032
  @click.version_option(version=__version__, prog_name='cloudcat')
926
- @click.argument('path_arg', required=False, metavar='[PATH]')
1033
+ @click.argument('path_arg', required=False, metavar='[PATH]', shell_complete=_complete_path)
927
1034
  @click.option('--path', '-p', 'path_opt',
928
1035
  help='Path to the file or directory (deprecated alias for the PATH argument)')
929
1036
  @click.option('--output-format', '-o', type=click.Choice(['json', 'jsonp', 'csv', 'table']), default='table',
@@ -955,6 +1062,9 @@ def _render_data(df: pd.DataFrame, output_format: str) -> str:
955
1062
  @click.option('--completion', type=click.Choice(['bash', 'zsh', 'fish']), is_eager=True,
956
1063
  expose_value=False, callback=_print_completion,
957
1064
  help='Print the shell completion script and exit (e.g. eval "$(cloudcat --completion zsh)")')
1065
+ @click.option('--install-completion', type=click.Choice(['bash', 'zsh', 'fish']), is_eager=True,
1066
+ expose_value=False, callback=_install_completion,
1067
+ help='Enable shell completion by adding one line to your shell rc file (idempotent)')
958
1068
  @click.option('--install-skill', type=click.Choice(['claude', 'claude-project', 'codex', 'print']),
959
1069
  is_eager=True, expose_value=False, callback=_install_skill,
960
1070
  help='Install the bundled AI-agent skill: claude (~/.claude/skills), '
@@ -1031,6 +1141,8 @@ def main(path_arg, path_opt, output_format, output_file, input_format, columns,
1031
1141
  click.echo(Fore.RED + "Error: Missing PATH. Usage: cloudcat [OPTIONS] PATH" + Style.RESET_ALL, err=True)
1032
1142
  sys.exit(2)
1033
1143
 
1144
+ from .filtering import parse_where_clause, apply_where_filter, where_columns
1145
+
1034
1146
  # Enable color only for an interactive stdout; writing to a file is never
1035
1147
  # colored. This must run before any colored output is produced.
1036
1148
  _configure_color(no_color or bool(output_file))
@@ -1179,6 +1291,7 @@ def main(path_arg, path_opt, output_format, output_file, input_format, columns,
1179
1291
  stop_progress()
1180
1292
 
1181
1293
  # Calculate total size for stats
1294
+ from .streaming import StreamingStats
1182
1295
  total_size = sum(f[1] for f in file_list)
1183
1296
  streaming_stats = StreamingStats(file_size=total_size, bytes_read=total_size, format_type=input_format)
1184
1297
 
@@ -0,0 +1,248 @@
1
+ """Shell tab-completion for PATH: local files, buckets, and cloud prefixes.
2
+
3
+ Wired into the PATH argument via Click's shell_complete. The generated
4
+ completion script re-invokes cloudcat on every TAB, so this module must be
5
+ cheap to import (SDKs load lazily, guarded by tests/test_import_lightness)
6
+ and must never hang or error the user's shell: cloud calls run with short
7
+ timeouts, no retries, capped result counts — and any failure completes to
8
+ nothing rather than raising.
9
+
10
+ Behavior mirrors `ls` completion, scheme-aware:
11
+ <TAB> -> native file completion
12
+ s3://<TAB> -> buckets (via --profile if typed earlier)
13
+ s3://bucket/dir/<TAB> -> immediate children under the prefix
14
+ """
15
+
16
+ from typing import List, Optional, Tuple
17
+
18
+ import os
19
+
20
+ from click.shell_completion import CompletionItem, ZshComplete, add_completion_class
21
+
22
+
23
+ def _debug(message: str) -> None:
24
+ """Optionally record why a completion produced nothing.
25
+
26
+ Completion swallows every error so it can never break the shell — which
27
+ makes "TAB does nothing" undiagnosable. Set CLOUDCAT_COMPLETE_DEBUG=1 and
28
+ failures are appended to ~/.cache/cloudcat/completion.log instead.
29
+ """
30
+ if not os.environ.get('CLOUDCAT_COMPLETE_DEBUG'):
31
+ return
32
+ try:
33
+ import datetime
34
+ cache_dir = os.path.join(
35
+ os.environ.get('XDG_CACHE_HOME', os.path.expanduser('~/.cache')), 'cloudcat')
36
+ os.makedirs(cache_dir, exist_ok=True)
37
+ with open(os.path.join(cache_dir, 'completion.log'), 'a', encoding='utf-8') as f:
38
+ f.write(f"{datetime.datetime.now().isoformat()} {message}\n")
39
+ except Exception:
40
+ pass
41
+
42
+
43
+ class CloudcatZshComplete(ZshComplete):
44
+ """Click's zsh completer, hardened for real-world .zshrc files.
45
+
46
+ The stock script calls compdef unconditionally; if the user evals it
47
+ before compinit has run, registration fails silently and TAB "does
48
+ nothing". This variant bootstraps compinit when needed.
49
+ """
50
+
51
+ name = 'zsh'
52
+ source_template = ZshComplete.source_template.replace(
53
+ " compdef %(complete_func)s %(prog_name)s",
54
+ """ if ! typeset -f compdef >/dev/null 2>&1; then
55
+ autoload -Uz compinit && compinit -u
56
+ fi
57
+ compdef %(complete_func)s %(prog_name)s""",
58
+ )
59
+
60
+
61
+ # Replace the registered zsh completer with the hardened variant.
62
+ add_completion_class(CloudcatZshComplete)
63
+
64
+ # Never return more candidates than a shell menu can usefully show.
65
+ LIMIT = 100
66
+ # Cloud calls must never hang a keypress.
67
+ CONNECT_TIMEOUT = 1.0
68
+ READ_TIMEOUT = 2.0
69
+
70
+ _SCHEMES = ('s3://', 'gs://', 'gcs://', 'abfss://', 'file://')
71
+
72
+
73
+ # --------------------------------------------------------------------------
74
+ # per-provider listing (small, patchable seams; SDK imports stay inside)
75
+ # --------------------------------------------------------------------------
76
+
77
+ def _s3_client(profile: Optional[str]):
78
+ import boto3
79
+ from botocore.config import Config
80
+ config = Config(connect_timeout=CONNECT_TIMEOUT, read_timeout=READ_TIMEOUT,
81
+ retries={'max_attempts': 0})
82
+ session = boto3.Session(profile_name=profile) if profile else boto3.Session()
83
+ # Without a configured region some boto3 versions raise NoRegionError at
84
+ # client creation; bucket listing is region-agnostic, so default it.
85
+ region = session.region_name or 'us-east-1'
86
+ return session.client('s3', region_name=region, config=config)
87
+
88
+
89
+ def _list_s3_buckets(profile: Optional[str]) -> List[str]:
90
+ response = _s3_client(profile).list_buckets()
91
+ return [b['Name'] for b in response.get('Buckets', [])]
92
+
93
+
94
+ def _shallow_list_s3(bucket: str, prefix: str, profile: Optional[str]) -> Tuple[List[str], List[str]]:
95
+ """Immediate children under a prefix: (dirs, files) as full keys."""
96
+ response = _s3_client(profile).list_objects_v2(
97
+ Bucket=bucket, Prefix=prefix, Delimiter='/', MaxKeys=LIMIT)
98
+ dirs = [p['Prefix'] for p in response.get('CommonPrefixes', [])]
99
+ files = [o['Key'] for o in response.get('Contents', []) if o['Key'] != prefix]
100
+ return dirs, files
101
+
102
+
103
+ def _gcs_client(project: Optional[str], credentials: Optional[str]):
104
+ from google.cloud import storage
105
+ kwargs = {}
106
+ if project:
107
+ kwargs['project'] = project
108
+ if credentials:
109
+ from google.oauth2 import service_account
110
+ kwargs['credentials'] = service_account.Credentials.from_service_account_file(credentials)
111
+ return storage.Client(**kwargs)
112
+
113
+
114
+ def _list_gcs_buckets(project: Optional[str], credentials: Optional[str]) -> List[str]:
115
+ client = _gcs_client(project, credentials)
116
+ return [b.name for b in client.list_buckets(max_results=LIMIT, timeout=READ_TIMEOUT)]
117
+
118
+
119
+ def _shallow_list_gcs(bucket: str, prefix: str, project: Optional[str],
120
+ credentials: Optional[str]) -> Tuple[List[str], List[str]]:
121
+ client = _gcs_client(project, credentials)
122
+ iterator = client.list_blobs(bucket, prefix=prefix, delimiter='/',
123
+ max_results=LIMIT, timeout=READ_TIMEOUT)
124
+ files = [b.name for b in iterator if b.name != prefix]
125
+ dirs = sorted(iterator.prefixes) # populated after iteration
126
+ return dirs, files
127
+
128
+
129
+ def _abfss_service(account: str, access_key: Optional[str]):
130
+ import os
131
+ from azure.storage.filedatalake import DataLakeServiceClient
132
+ url = f"https://{account}.dfs.core.windows.net"
133
+ key = access_key or os.environ.get('AZURE_STORAGE_ACCESS_KEY')
134
+ if key:
135
+ return DataLakeServiceClient(account_url=url, credential=key,
136
+ connection_timeout=CONNECT_TIMEOUT, read_timeout=READ_TIMEOUT)
137
+ from azure.identity import DefaultAzureCredential
138
+ return DataLakeServiceClient(account_url=url, credential=DefaultAzureCredential(),
139
+ connection_timeout=CONNECT_TIMEOUT, read_timeout=READ_TIMEOUT)
140
+
141
+
142
+ def _list_abfss_containers(account: str, access_key: Optional[str]) -> List[str]:
143
+ from itertools import islice
144
+ service = _abfss_service(account, access_key)
145
+ return [fs.name for fs in islice(service.list_file_systems(timeout=READ_TIMEOUT), LIMIT)]
146
+
147
+
148
+ def _shallow_list_abfss(container: str, account: str, prefix: str,
149
+ access_key: Optional[str]) -> Tuple[List[str], List[str]]:
150
+ from itertools import islice
151
+ service = _abfss_service(account, access_key)
152
+ fs = service.get_file_system_client(container)
153
+ dirs, files = [], []
154
+ parent = prefix.rsplit('/', 1)[0] if '/' in prefix else ''
155
+ paths = fs.get_paths(path=parent or None, recursive=False, timeout=READ_TIMEOUT)
156
+ for p in islice(paths, LIMIT * 2):
157
+ if not p.name.startswith(prefix):
158
+ continue
159
+ (dirs if p.is_directory else files).append(p.name + ('/' if p.is_directory else ''))
160
+ return dirs[:LIMIT], files[:LIMIT]
161
+
162
+
163
+ # --------------------------------------------------------------------------
164
+ # the completer
165
+ # --------------------------------------------------------------------------
166
+
167
+ def _param(ctx, name: str) -> Optional[str]:
168
+ """A parsed option from the partial command line, if the shell saw it."""
169
+ try:
170
+ value = ctx.params.get(name)
171
+ return value if isinstance(value, str) and value else None
172
+ except Exception:
173
+ return None
174
+
175
+
176
+ def _cloud_candidates(ctx, incomplete: str) -> List[str]:
177
+ """Full-word candidates for an incomplete cloud URL."""
178
+ scheme, rest = incomplete.split('://', 1)
179
+ scheme = scheme.lower()
180
+ profile = _param(ctx, 'profile')
181
+ project = _param(ctx, 'project')
182
+ credentials = _param(ctx, 'credentials')
183
+ az_key = _param(ctx, 'az_access_key')
184
+
185
+ if scheme == 'file':
186
+ return [] # handled by the file-completion fallback in complete_path
187
+
188
+ if '/' not in rest:
189
+ # Completing the bucket/container part.
190
+ if scheme == 's3':
191
+ names = _list_s3_buckets(profile)
192
+ elif scheme in ('gs', 'gcs'):
193
+ names = _list_gcs_buckets(project, credentials)
194
+ elif scheme == 'abfss':
195
+ if '@' not in rest:
196
+ return [] # abfss needs container@account; accounts aren't listable
197
+ container_part, host = rest.split('@', 1)
198
+ if '.' not in host:
199
+ return []
200
+ account = host.split('.')[0]
201
+ return [f"{scheme}://{name}@{host}/"
202
+ for name in _list_abfss_containers(account, az_key)
203
+ if name.startswith(container_part)][:LIMIT]
204
+ else:
205
+ return []
206
+ return [f"{scheme}://{name}/" for name in sorted(names)
207
+ if name.startswith(rest)][:LIMIT]
208
+
209
+ # Completing a key/prefix inside a bucket.
210
+ bucket, prefix = rest.split('/', 1)
211
+ if scheme == 's3':
212
+ dirs, files = _shallow_list_s3(bucket, prefix, profile)
213
+ elif scheme in ('gs', 'gcs'):
214
+ dirs, files = _shallow_list_gcs(bucket, prefix, project, credentials)
215
+ elif scheme == 'abfss' and '@' in bucket:
216
+ container, host = bucket.split('@', 1)
217
+ account = host.split('.')[0]
218
+ dirs, files = _shallow_list_abfss(container, account, prefix, az_key)
219
+ else:
220
+ return []
221
+
222
+ base = f"{scheme}://{bucket}/"
223
+ candidates = [base + d for d in dirs] + [base + f for f in files]
224
+ return sorted(candidates)[:LIMIT]
225
+
226
+
227
+ def complete_path(ctx, param, incomplete: str) -> List[CompletionItem]:
228
+ """shell_complete callback for the PATH argument."""
229
+ try:
230
+ if '://' in incomplete:
231
+ return [CompletionItem(c) for c in _cloud_candidates(ctx, incomplete)]
232
+
233
+ items: List[CompletionItem] = []
234
+ # Offer scheme prefixes while a scheme could still be being typed
235
+ # (e.g. "s3" or "gc"), alongside normal file completion.
236
+ if incomplete and '/' not in incomplete:
237
+ items.extend(
238
+ CompletionItem(scheme)
239
+ for scheme in _SCHEMES
240
+ if scheme.startswith(incomplete)
241
+ )
242
+ items.append(CompletionItem(incomplete, type='file'))
243
+ return items
244
+ except Exception:
245
+ # Completion must never break the shell: no candidates beats a stack.
246
+ import traceback
247
+ _debug(f"complete_path({incomplete!r}) failed:\n{traceback.format_exc()}")
248
+ return []