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.
- {cloudcat-0.6.0 → cloudcat-0.6.2}/PKG-INFO +12 -2
- {cloudcat-0.6.0 → cloudcat-0.6.2}/README.md +11 -1
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/__init__.py +1 -1
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/cli.py +165 -52
- cloudcat-0.6.2/cloudcat/completion.py +248 -0
- cloudcat-0.6.2/cloudcat/storage/__init__.py +44 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat.egg-info/PKG-INFO +12 -2
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat.egg-info/SOURCES.txt +4 -0
- cloudcat-0.6.2/tests/test_completion.py +113 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_data_reading.py +12 -16
- cloudcat-0.6.2/tests/test_import_lightness.py +65 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_integration.py +6 -6
- {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_output_formatting.py +1 -1
- cloudcat-0.6.2/tests/test_v062_fixes.py +155 -0
- cloudcat-0.6.0/cloudcat/storage/__init__.py +0 -26
- {cloudcat-0.6.0 → cloudcat-0.6.2}/LICENSE +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/compression.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/config.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/data/SKILL.md +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/filtering.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/formatters.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/progress.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/readers/__init__.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/readers/avro.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/readers/csv.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/readers/json.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/readers/orc.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/readers/parquet.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/readers/text.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/storage/azure.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/storage/base.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/storage/gcs.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/storage/local.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/storage/s3.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/streaming/__init__.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/streaming/filesystems.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/streaming/stats.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/streaming/tracking.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/tables/__init__.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/tables/delta.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/tables/iceberg.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/tables/pushdown.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat/user_config.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat.egg-info/dependency_links.txt +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat.egg-info/entry_points.txt +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat.egg-info/requires.txt +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/cloudcat.egg-info/top_level.txt +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/pyproject.toml +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/setup.cfg +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_bugfix_round2.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_cli.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_cli_features.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_compression.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_config.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_config_stats_completion.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_file_filtering.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_filtering.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_format_detection.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_path_parsing.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_read_data_streaming.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_readers_roundtrip.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_regressions.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_skill_install.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_storage_listing.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_streaming_stats.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_tables.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_tracking.py +0 -0
- {cloudcat-0.6.0 → cloudcat-0.6.2}/tests/test_v05_features.py +0 -0
- {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.
|
|
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]
|
|
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]
|
|
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
|
|
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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 .
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
#
|
|
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 =
|
|
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
|
|
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
|
|
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 []
|