cloudcat 0.3.1__tar.gz → 0.3.3__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.3.1 → cloudcat-0.3.3}/PKG-INFO +4 -4
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/__init__.py +1 -1
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/cli.py +94 -37
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/config.py +3 -1
- cloudcat-0.3.3/cloudcat/progress.py +102 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/storage/__init__.py +3 -2
- cloudcat-0.3.3/cloudcat/storage/azure.py +133 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/storage/base.py +1 -1
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/streaming/filesystems.py +13 -14
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat.egg-info/PKG-INFO +4 -4
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat.egg-info/SOURCES.txt +1 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat.egg-info/requires.txt +1 -1
- {cloudcat-0.3.1 → cloudcat-0.3.3}/setup.py +3 -3
- cloudcat-0.3.1/cloudcat/storage/azure.py +0 -125
- {cloudcat-0.3.1 → cloudcat-0.3.3}/LICENSE +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/README.md +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/compression.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/filtering.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/formatters.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/readers/__init__.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/readers/avro.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/readers/csv.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/readers/json.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/readers/orc.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/readers/parquet.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/readers/text.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/storage/gcs.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/storage/s3.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/streaming/__init__.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/streaming/stats.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/streaming/tracking.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat.egg-info/dependency_links.txt +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat.egg-info/entry_points.txt +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat.egg-info/top_level.txt +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/pyproject.toml +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/setup.cfg +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/tests/__init__.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/tests/conftest.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/tests/test_cli.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/tests/test_compression.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/tests/test_config.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/tests/test_data_reading.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/tests/test_file_filtering.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/tests/test_filtering.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/tests/test_format_detection.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/tests/test_integration.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/tests/test_output_formatting.py +0 -0
- {cloudcat-0.3.1 → cloudcat-0.3.3}/tests/test_path_parsing.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cloudcat
|
|
3
|
-
Version: 0.3.
|
|
4
|
-
Summary: Preview and analyze data files in Google Cloud Storage, AWS S3, and Azure
|
|
3
|
+
Version: 0.3.3
|
|
4
|
+
Summary: Preview and analyze data files in Google Cloud Storage, AWS S3, and Azure Data Lake Storage Gen2 from your terminal
|
|
5
5
|
Home-page: https://github.com/jonathansudhakar1/cloudcat
|
|
6
6
|
Author: Jonathan Sudhakar
|
|
7
7
|
Author-email: jonathan@example.com
|
|
@@ -9,7 +9,7 @@ Project-URL: Homepage, https://github.com/jonathansudhakar1/cloudcat
|
|
|
9
9
|
Project-URL: Documentation, https://cloudcatcli.com
|
|
10
10
|
Project-URL: Bug Reports, https://github.com/jonathansudhakar1/cloudcat/issues
|
|
11
11
|
Project-URL: Source, https://github.com/jonathansudhakar1/cloudcat
|
|
12
|
-
Keywords: cloud,gcs,s3,azure,cli,storage,data,parquet,csv,json,avro,orc,google-cloud,aws,
|
|
12
|
+
Keywords: cloud,gcs,s3,azure,cli,storage,data,parquet,csv,json,avro,orc,google-cloud,aws,adls,datalake,data-engineering,etl,spark,bigquery,databricks,kafka,hive
|
|
13
13
|
Classifier: Development Status :: 4 - Beta
|
|
14
14
|
Classifier: Intended Audience :: Developers
|
|
15
15
|
Classifier: Intended Audience :: System Administrators
|
|
@@ -38,7 +38,7 @@ Requires-Dist: tabulate>=0.8.9
|
|
|
38
38
|
Requires-Dist: colorama>=0.4.4
|
|
39
39
|
Requires-Dist: google-cloud-storage>=2.0.0
|
|
40
40
|
Requires-Dist: boto3>=1.18.0
|
|
41
|
-
Requires-Dist: azure-storage-
|
|
41
|
+
Requires-Dist: azure-storage-file-datalake>=12.0.0
|
|
42
42
|
Requires-Dist: azure-identity>=1.0.0
|
|
43
43
|
Provides-Extra: parquet
|
|
44
44
|
Requires-Dist: pyarrow>=5.0.0; extra == "parquet"
|
|
@@ -29,6 +29,7 @@ from .compression import (
|
|
|
29
29
|
)
|
|
30
30
|
from .filtering import parse_where_clause, apply_where_filter
|
|
31
31
|
from .formatters import colorize_json, format_table_with_colored_header
|
|
32
|
+
from .progress import start_progress, update_progress, stop_progress
|
|
32
33
|
from .storage import (
|
|
33
34
|
parse_cloud_path,
|
|
34
35
|
get_stream,
|
|
@@ -75,7 +76,8 @@ def get_files_for_multiread(
|
|
|
75
76
|
bucket: str,
|
|
76
77
|
prefix: str,
|
|
77
78
|
input_format: Optional[str] = None,
|
|
78
|
-
max_size_mb: int = 25
|
|
79
|
+
max_size_mb: int = 25,
|
|
80
|
+
quiet: bool = False
|
|
79
81
|
) -> List[Tuple[str, int]]:
|
|
80
82
|
"""Get a list of files to read up to max_size_mb.
|
|
81
83
|
|
|
@@ -85,6 +87,7 @@ def get_files_for_multiread(
|
|
|
85
87
|
prefix: Directory prefix.
|
|
86
88
|
input_format: Optional format filter.
|
|
87
89
|
max_size_mb: Maximum total size in MB.
|
|
90
|
+
quiet: If True, suppress progress messages.
|
|
88
91
|
|
|
89
92
|
Returns:
|
|
90
93
|
List of (filename, size) tuples.
|
|
@@ -137,18 +140,21 @@ def get_files_for_multiread(
|
|
|
137
140
|
total_size = 0
|
|
138
141
|
|
|
139
142
|
for file_name, file_size in filtered_files:
|
|
143
|
+
# Check if adding this file would exceed the limit
|
|
144
|
+
# Always include at least the first file even if it exceeds the limit
|
|
145
|
+
if selected_files and total_size + file_size > max_size_bytes:
|
|
146
|
+
break
|
|
147
|
+
|
|
140
148
|
selected_files.append((file_name, file_size))
|
|
141
149
|
total_size += file_size
|
|
142
150
|
|
|
143
|
-
if total_size >= max_size_bytes:
|
|
144
|
-
break
|
|
145
|
-
|
|
146
151
|
if not selected_files:
|
|
147
152
|
raise ValueError(f"No suitable files found in {service}://{bucket}/{prefix}")
|
|
148
153
|
|
|
149
154
|
# Report on selected files
|
|
150
|
-
|
|
151
|
-
|
|
155
|
+
if not quiet:
|
|
156
|
+
total_mb = total_size / (1024 * 1024)
|
|
157
|
+
click.echo(Fore.BLUE + f"Reading {len(selected_files)} files totaling {total_mb:.2f} MB" + Style.RESET_ALL)
|
|
152
158
|
|
|
153
159
|
return selected_files
|
|
154
160
|
|
|
@@ -252,7 +258,8 @@ def read_data_from_multiple_files(
|
|
|
252
258
|
num_rows: int,
|
|
253
259
|
columns: Optional[str] = None,
|
|
254
260
|
delimiter: Optional[str] = None,
|
|
255
|
-
offset: int = 0
|
|
261
|
+
offset: int = 0,
|
|
262
|
+
quiet: bool = False
|
|
256
263
|
) -> Tuple[pd.DataFrame, pd.Series, int]:
|
|
257
264
|
"""Read data from multiple files and concatenate the results.
|
|
258
265
|
|
|
@@ -265,6 +272,7 @@ def read_data_from_multiple_files(
|
|
|
265
272
|
columns: Columns to select.
|
|
266
273
|
delimiter: CSV delimiter.
|
|
267
274
|
offset: Rows to skip.
|
|
275
|
+
quiet: Suppress progress messages.
|
|
268
276
|
|
|
269
277
|
Returns:
|
|
270
278
|
Tuple of (DataFrame, schema, total_rows).
|
|
@@ -275,16 +283,22 @@ def read_data_from_multiple_files(
|
|
|
275
283
|
rows_skipped = 0
|
|
276
284
|
total_rows = 0
|
|
277
285
|
|
|
278
|
-
def process_file(file_info, remaining_to_skip, remaining_to_read):
|
|
286
|
+
def process_file(file_info, remaining_to_skip, remaining_to_read, file_index, total_files):
|
|
279
287
|
file_name, file_size = file_info
|
|
280
|
-
|
|
288
|
+
if not quiet:
|
|
289
|
+
click.echo(Fore.BLUE + f"Reading file: {file_name} ({file_size/1024:.1f} KB)" + Style.RESET_ALL)
|
|
290
|
+
else:
|
|
291
|
+
# Update progress indicator with current file
|
|
292
|
+
short_name = file_name.split('/')[-1]
|
|
293
|
+
update_progress(f"Reading file {file_index + 1}/{total_files}: {short_name}")
|
|
281
294
|
|
|
282
295
|
stream = get_stream(service, bucket, file_name)
|
|
283
296
|
|
|
284
297
|
# Check for compression and decompress if needed
|
|
285
298
|
compression = detect_compression(file_name)
|
|
286
299
|
if compression:
|
|
287
|
-
|
|
300
|
+
if not quiet:
|
|
301
|
+
click.echo(Fore.BLUE + f"Detected {compression} compression, decompressing..." + Style.RESET_ALL)
|
|
288
302
|
stream = decompress_stream(stream, compression)
|
|
289
303
|
|
|
290
304
|
# Calculate how many rows to read from this file
|
|
@@ -311,10 +325,11 @@ def read_data_from_multiple_files(
|
|
|
311
325
|
# Process files in order until we have enough rows
|
|
312
326
|
remaining_offset = offset
|
|
313
327
|
remaining_rows = num_rows if num_rows > 0 else float('inf')
|
|
328
|
+
total_files = len(file_list)
|
|
314
329
|
|
|
315
|
-
for file_info in file_list:
|
|
330
|
+
for file_index, file_info in enumerate(file_list):
|
|
316
331
|
try:
|
|
317
|
-
df, schema, file_rows = process_file(file_info, remaining_offset, remaining_rows if remaining_rows != float('inf') else 0)
|
|
332
|
+
df, schema, file_rows = process_file(file_info, remaining_offset, remaining_rows if remaining_rows != float('inf') else 0, file_index, total_files)
|
|
318
333
|
|
|
319
334
|
if not df.empty:
|
|
320
335
|
total_rows += len(df)
|
|
@@ -457,7 +472,8 @@ def read_data_streaming(
|
|
|
457
472
|
aws_profile=cloud_config.aws_profile,
|
|
458
473
|
gcp_project=cloud_config.gcp_project,
|
|
459
474
|
gcp_credentials=cloud_config.gcp_credentials,
|
|
460
|
-
azure_account=cloud_config.azure_account
|
|
475
|
+
azure_account=cloud_config.azure_account,
|
|
476
|
+
azure_access_key=cloud_config.azure_access_key
|
|
461
477
|
)
|
|
462
478
|
pyarrow_path = f"{bucket}/{object_path}"
|
|
463
479
|
|
|
@@ -706,7 +722,7 @@ def get_record_count_multiple_files(
|
|
|
706
722
|
|
|
707
723
|
@click.command()
|
|
708
724
|
@click.version_option(version=__version__, prog_name='cloudcat')
|
|
709
|
-
@click.option('--path', '-p', required=True, help='Path to the file or directory (gcs://, s3://, or
|
|
725
|
+
@click.option('--path', '-p', required=True, help='Path to the file or directory (gcs://, s3://, or abfss://)')
|
|
710
726
|
@click.option('--output-format', '-o', type=click.Choice(['json', 'jsonp', 'csv', 'table']), default='table',
|
|
711
727
|
help='Output format (default: table)')
|
|
712
728
|
@click.option('--input-format', '-i', type=click.Choice(['json', 'csv', 'parquet', 'avro', 'orc', 'text']),
|
|
@@ -726,10 +742,11 @@ def get_record_count_multiple_files(
|
|
|
726
742
|
@click.option('--profile', help='AWS profile name (for S3 access)')
|
|
727
743
|
@click.option('--project', help='GCP project ID (for GCS access)')
|
|
728
744
|
@click.option('--credentials', help='Path to GCP service account JSON file')
|
|
729
|
-
@click.option('--
|
|
745
|
+
@click.option('--az-access-key', help='Azure storage account access key')
|
|
746
|
+
@click.option('--yes', '-y', is_flag=True, help='Skip confirmation prompts (for scripting)')
|
|
730
747
|
def main(path, output_format, input_format, columns, num_rows, offset, where, schema, count,
|
|
731
|
-
multi_file_mode, max_size_mb, delimiter, profile, project, credentials,
|
|
732
|
-
"""Display data from files in Google Cloud Storage, AWS S3, or Azure
|
|
748
|
+
multi_file_mode, max_size_mb, delimiter, profile, project, credentials, az_access_key, yes):
|
|
749
|
+
"""Display data from files in Google Cloud Storage, AWS S3, or Azure Data Lake Storage Gen2.
|
|
733
750
|
|
|
734
751
|
Supported formats: CSV, JSON, Parquet, Avro, ORC, and plain text.
|
|
735
752
|
Supports compressed files: .gz, .zst, .lz4, .snappy, .bz2
|
|
@@ -745,8 +762,8 @@ def main(path, output_format, input_format, columns, num_rows, offset, where, sc
|
|
|
745
762
|
cloudcat --path s3://my-bucket/data.parquet --columns id,name,value
|
|
746
763
|
|
|
747
764
|
\b
|
|
748
|
-
# Read from Azure
|
|
749
|
-
cloudcat --path
|
|
765
|
+
# Read from Azure Data Lake Storage Gen2
|
|
766
|
+
cloudcat --path abfss://container@account.dfs.core.windows.net/data.json --output-format jsonp
|
|
750
767
|
|
|
751
768
|
\b
|
|
752
769
|
# Read Avro files from Kafka exports
|
|
@@ -758,7 +775,7 @@ def main(path, output_format, input_format, columns, num_rows, offset, where, sc
|
|
|
758
775
|
|
|
759
776
|
\b
|
|
760
777
|
# Read log files as plain text
|
|
761
|
-
cloudcat --path
|
|
778
|
+
cloudcat --path abfss://logs@account.dfs.core.windows.net/app.log --input-format text
|
|
762
779
|
|
|
763
780
|
\b
|
|
764
781
|
# Read from a directory (reads first non-empty data file)
|
|
@@ -800,8 +817,8 @@ def main(path, output_format, input_format, columns, num_rows, offset, where, sc
|
|
|
800
817
|
cloudcat --path gcs://bucket/data.csv --credentials /path/to/service-account.json
|
|
801
818
|
|
|
802
819
|
\b
|
|
803
|
-
# Use
|
|
804
|
-
cloudcat --path
|
|
820
|
+
# Use Azure storage account access key
|
|
821
|
+
cloudcat --path abfss://container@account.dfs.core.windows.net/data.csv --az-access-key YOUR_KEY
|
|
805
822
|
"""
|
|
806
823
|
try:
|
|
807
824
|
# Configure cloud credentials from CLI options
|
|
@@ -811,8 +828,8 @@ def main(path, output_format, input_format, columns, num_rows, offset, where, sc
|
|
|
811
828
|
cloud_config.gcp_project = project
|
|
812
829
|
if credentials:
|
|
813
830
|
cloud_config.gcp_credentials = credentials
|
|
814
|
-
if
|
|
815
|
-
cloud_config.
|
|
831
|
+
if az_access_key:
|
|
832
|
+
cloud_config.azure_access_key = az_access_key
|
|
816
833
|
|
|
817
834
|
# Handle special characters in delimiter
|
|
818
835
|
if delimiter == "\\t":
|
|
@@ -830,50 +847,81 @@ def main(path, output_format, input_format, columns, num_rows, offset, where, sc
|
|
|
830
847
|
|
|
831
848
|
# Handle directory paths based on multi-file-mode
|
|
832
849
|
if is_directory:
|
|
833
|
-
click.echo(Fore.BLUE + f"Path is a directory" + Style.RESET_ALL)
|
|
834
|
-
|
|
835
850
|
if multi_file_mode == 'first' or (multi_file_mode == 'auto' and max_size_mb <= 0):
|
|
836
851
|
# Use a single file
|
|
837
|
-
|
|
852
|
+
start_progress("Listing files...")
|
|
838
853
|
object_path = find_first_non_empty_file(service, bucket, object_path, input_format)
|
|
839
854
|
|
|
840
855
|
# Determine input format if not specified
|
|
841
856
|
if not input_format:
|
|
842
857
|
input_format = detect_format_from_path(object_path)
|
|
843
|
-
|
|
858
|
+
|
|
859
|
+
# Get file name for display
|
|
860
|
+
file_name = object_path.split('/')[-1]
|
|
861
|
+
update_progress(f"Reading {file_name}...")
|
|
844
862
|
|
|
845
863
|
# Read the data from the single file with streaming
|
|
846
864
|
df, full_schema, streaming_stats = read_data_streaming(service, bucket, object_path, input_format, num_rows, columns, delimiter, offset)
|
|
847
865
|
total_record_count = None # Will be computed later if needed
|
|
866
|
+
|
|
867
|
+
# Stop progress and show format info
|
|
868
|
+
stop_progress()
|
|
869
|
+
click.echo(Fore.BLUE + f"Inferred input format: {input_format}" + Style.RESET_ALL)
|
|
848
870
|
else:
|
|
849
871
|
# Read from multiple files
|
|
850
|
-
|
|
872
|
+
start_progress("Listing files...")
|
|
851
873
|
|
|
852
874
|
# Determine input format if not specified (use the first file to infer)
|
|
853
875
|
if not input_format:
|
|
854
876
|
first_file = find_first_non_empty_file(service, bucket, object_path)
|
|
855
877
|
input_format = detect_format_from_path(first_file)
|
|
856
|
-
click.echo(Fore.BLUE + f"Inferred input format from first file: {input_format}" + Style.RESET_ALL)
|
|
857
878
|
|
|
858
|
-
|
|
859
|
-
|
|
879
|
+
update_progress(f"Selecting {input_format} files...")
|
|
880
|
+
|
|
881
|
+
# Get files to read for preview (limited by max_size_mb)
|
|
882
|
+
file_list = get_files_for_multiread(service, bucket, object_path, input_format, max_size_mb, quiet=True)
|
|
860
883
|
|
|
861
|
-
# Read data from multiple files
|
|
884
|
+
# Read data from multiple files with progress updates
|
|
885
|
+
update_progress(f"Reading {len(file_list)} files...")
|
|
862
886
|
df, full_schema, rows_in_files = read_data_from_multiple_files(
|
|
863
|
-
service, bucket, file_list, input_format, num_rows, columns, delimiter, offset
|
|
887
|
+
service, bucket, file_list, input_format, num_rows, columns, delimiter, offset, quiet=True
|
|
864
888
|
)
|
|
865
889
|
|
|
866
890
|
# Calculate total size for stats
|
|
867
891
|
total_size = sum(f[1] for f in file_list)
|
|
868
892
|
streaming_stats = StreamingStats(file_size=total_size, bytes_read=total_size, format_type=input_format)
|
|
869
893
|
|
|
870
|
-
#
|
|
871
|
-
|
|
894
|
+
# For --count, get ALL files (not limited by max_size_mb)
|
|
895
|
+
# so we can count records across the entire directory
|
|
896
|
+
if count:
|
|
897
|
+
stop_progress()
|
|
898
|
+
all_files = get_files_for_multiread(service, bucket, object_path, input_format, max_size_mb=999999, quiet=True)
|
|
899
|
+
all_files_size = sum(f[1] for f in all_files)
|
|
900
|
+
all_files_size_mb = all_files_size / (1024 * 1024)
|
|
901
|
+
|
|
902
|
+
# Warn user about counting all files in directory
|
|
903
|
+
if not yes:
|
|
904
|
+
click.echo(Fore.YELLOW + f"\nWarning: --count will scan {len(all_files)} files ({all_files_size_mb:.1f} MB total)." + Style.RESET_ALL)
|
|
905
|
+
if not click.confirm("Continue?", default=True):
|
|
906
|
+
click.echo("Aborted.")
|
|
907
|
+
return
|
|
908
|
+
|
|
909
|
+
multi_file_list = all_files
|
|
910
|
+
# Update stats to reflect all files
|
|
911
|
+
streaming_stats.file_size = all_files_size
|
|
912
|
+
else:
|
|
913
|
+
stop_progress()
|
|
914
|
+
multi_file_list = file_list
|
|
915
|
+
|
|
916
|
+
# Show format info
|
|
917
|
+
click.echo(Fore.BLUE + f"Inferred input format: {input_format}" + Style.RESET_ALL)
|
|
918
|
+
|
|
872
919
|
# total_record_count will be computed later if --count is specified
|
|
873
920
|
total_record_count = None
|
|
874
921
|
|
|
875
922
|
# Update object_path for display/logging purposes
|
|
876
|
-
|
|
923
|
+
num_files_display = len(multi_file_list) if count else len(file_list)
|
|
924
|
+
object_path = f"{object_path} ({num_files_display} files)"
|
|
877
925
|
else:
|
|
878
926
|
# Single file path
|
|
879
927
|
# Determine input format if not specified
|
|
@@ -881,10 +929,16 @@ def main(path, output_format, input_format, columns, num_rows, offset, where, sc
|
|
|
881
929
|
input_format = detect_format_from_path(object_path)
|
|
882
930
|
click.echo(Fore.BLUE + f"Inferred input format: {input_format}" + Style.RESET_ALL)
|
|
883
931
|
|
|
932
|
+
# Get file name for display
|
|
933
|
+
file_name = object_path.split('/')[-1]
|
|
934
|
+
start_progress(f"Reading {file_name}...")
|
|
935
|
+
|
|
884
936
|
# Read the data with streaming
|
|
885
937
|
df, full_schema, streaming_stats = read_data_streaming(service, bucket, object_path, input_format, num_rows, columns, delimiter, offset)
|
|
886
938
|
total_record_count = None # Will be computed later if needed
|
|
887
939
|
|
|
940
|
+
stop_progress()
|
|
941
|
+
|
|
888
942
|
# Apply WHERE filter if specified
|
|
889
943
|
if where and not df.empty:
|
|
890
944
|
original_count = len(df)
|
|
@@ -942,6 +996,9 @@ def main(path, output_format, input_format, columns, num_rows, offset, where, sc
|
|
|
942
996
|
else:
|
|
943
997
|
# Single file count
|
|
944
998
|
total_record_count = get_record_count(service, bucket, object_path, input_format, delimiter)
|
|
999
|
+
# Update stats to reflect full file was read for counting
|
|
1000
|
+
if streaming_stats:
|
|
1001
|
+
streaming_stats.bytes_read = streaming_stats.file_size
|
|
945
1002
|
click.echo(Fore.CYAN + f"\nTotal records: {total_record_count:,}" + Style.RESET_ALL)
|
|
946
1003
|
except Exception as e:
|
|
947
1004
|
click.echo(Fore.YELLOW + f"\nCould not count records: {str(e)}" + Style.RESET_ALL)
|
|
@@ -14,7 +14,8 @@ class CloudConfig:
|
|
|
14
14
|
self.aws_profile: Optional[str] = None
|
|
15
15
|
self.gcp_project: Optional[str] = None
|
|
16
16
|
self.gcp_credentials: Optional[str] = None # Path to service account JSON
|
|
17
|
-
self.azure_account: Optional[str] = None
|
|
17
|
+
self.azure_account: Optional[str] = None # Extracted from abfss:// URL
|
|
18
|
+
self.azure_access_key: Optional[str] = None # Storage account access key
|
|
18
19
|
|
|
19
20
|
def reset(self) -> None:
|
|
20
21
|
"""Reset all configuration to defaults. Useful for testing."""
|
|
@@ -22,6 +23,7 @@ class CloudConfig:
|
|
|
22
23
|
self.gcp_project = None
|
|
23
24
|
self.gcp_credentials = None
|
|
24
25
|
self.azure_account = None
|
|
26
|
+
self.azure_access_key = None
|
|
25
27
|
|
|
26
28
|
|
|
27
29
|
# Global configuration instance
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Progress indicator for CLI operations."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from colorama import Fore, Style
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProgressIndicator:
|
|
11
|
+
"""A simple animated progress indicator that updates in place.
|
|
12
|
+
|
|
13
|
+
Shows a spinner animation with a status message that updates on a single line.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
17
|
+
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self._message: str = ""
|
|
20
|
+
self._running: bool = False
|
|
21
|
+
self._thread: Optional[threading.Thread] = None
|
|
22
|
+
self._frame_index: int = 0
|
|
23
|
+
self._lock = threading.Lock()
|
|
24
|
+
|
|
25
|
+
def start(self, message: str = "Working...") -> None:
|
|
26
|
+
"""Start the progress indicator with an initial message."""
|
|
27
|
+
with self._lock:
|
|
28
|
+
self._message = message
|
|
29
|
+
self._running = True
|
|
30
|
+
self._frame_index = 0
|
|
31
|
+
|
|
32
|
+
self._thread = threading.Thread(target=self._animate, daemon=True)
|
|
33
|
+
self._thread.start()
|
|
34
|
+
|
|
35
|
+
def update(self, message: str) -> None:
|
|
36
|
+
"""Update the progress message."""
|
|
37
|
+
with self._lock:
|
|
38
|
+
self._message = message
|
|
39
|
+
|
|
40
|
+
def stop(self, final_message: Optional[str] = None) -> None:
|
|
41
|
+
"""Stop the progress indicator and optionally show a final message."""
|
|
42
|
+
with self._lock:
|
|
43
|
+
self._running = False
|
|
44
|
+
|
|
45
|
+
if self._thread:
|
|
46
|
+
self._thread.join(timeout=0.5)
|
|
47
|
+
self._thread = None
|
|
48
|
+
|
|
49
|
+
# Clear the current line
|
|
50
|
+
sys.stderr.write('\r' + ' ' * 80 + '\r')
|
|
51
|
+
sys.stderr.flush()
|
|
52
|
+
|
|
53
|
+
# Show final message if provided
|
|
54
|
+
if final_message:
|
|
55
|
+
sys.stderr.write(final_message + '\n')
|
|
56
|
+
sys.stderr.flush()
|
|
57
|
+
|
|
58
|
+
def _animate(self) -> None:
|
|
59
|
+
"""Animation loop running in a separate thread."""
|
|
60
|
+
while True:
|
|
61
|
+
with self._lock:
|
|
62
|
+
if not self._running:
|
|
63
|
+
break
|
|
64
|
+
message = self._message
|
|
65
|
+
frame = self.SPINNER_FRAMES[self._frame_index]
|
|
66
|
+
self._frame_index = (self._frame_index + 1) % len(self.SPINNER_FRAMES)
|
|
67
|
+
|
|
68
|
+
# Write spinner and message, overwriting the line
|
|
69
|
+
output = f'\r{Fore.CYAN}{frame}{Style.RESET_ALL} {message}'
|
|
70
|
+
# Pad to clear any leftover characters from longer previous messages
|
|
71
|
+
output = output.ljust(80)
|
|
72
|
+
sys.stderr.write(output)
|
|
73
|
+
sys.stderr.flush()
|
|
74
|
+
|
|
75
|
+
time.sleep(0.08)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Global instance for easy access
|
|
79
|
+
_progress: Optional[ProgressIndicator] = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def start_progress(message: str = "Working...") -> None:
|
|
83
|
+
"""Start a global progress indicator."""
|
|
84
|
+
global _progress
|
|
85
|
+
if _progress is None:
|
|
86
|
+
_progress = ProgressIndicator()
|
|
87
|
+
_progress.start(message)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def update_progress(message: str) -> None:
|
|
91
|
+
"""Update the global progress indicator message."""
|
|
92
|
+
global _progress
|
|
93
|
+
if _progress:
|
|
94
|
+
_progress.update(message)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def stop_progress(final_message: Optional[str] = None) -> None:
|
|
98
|
+
"""Stop the global progress indicator."""
|
|
99
|
+
global _progress
|
|
100
|
+
if _progress:
|
|
101
|
+
_progress.stop(final_message)
|
|
102
|
+
_progress = None
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from .base import parse_cloud_path, get_stream, list_directory, get_file_size
|
|
4
4
|
from .gcs import get_gcs_client, get_gcs_stream, list_gcs_directory, HAS_GCS
|
|
5
5
|
from .s3 import get_s3_client, get_s3_stream, list_s3_directory, HAS_S3
|
|
6
|
-
from .azure import get_azure_blob_service_client, get_azure_stream, list_azure_directory, HAS_AZURE
|
|
6
|
+
from .azure import get_azure_datalake_service_client, get_azure_blob_service_client, get_azure_stream, list_azure_directory, HAS_AZURE
|
|
7
7
|
|
|
8
8
|
__all__ = [
|
|
9
9
|
'parse_cloud_path',
|
|
@@ -18,7 +18,8 @@ __all__ = [
|
|
|
18
18
|
'get_s3_stream',
|
|
19
19
|
'list_s3_directory',
|
|
20
20
|
'HAS_S3',
|
|
21
|
-
'
|
|
21
|
+
'get_azure_datalake_service_client',
|
|
22
|
+
'get_azure_blob_service_client', # Alias for backwards compatibility
|
|
22
23
|
'get_azure_stream',
|
|
23
24
|
'list_azure_directory',
|
|
24
25
|
'HAS_AZURE',
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Azure Data Lake Storage Gen2 (ADLS Gen2) client and operations."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from typing import List, Tuple
|
|
7
|
+
|
|
8
|
+
from colorama import Fore, Style
|
|
9
|
+
|
|
10
|
+
from ..config import cloud_config
|
|
11
|
+
|
|
12
|
+
# Try to import Azure Data Lake client
|
|
13
|
+
try:
|
|
14
|
+
from azure.storage.filedatalake import DataLakeServiceClient
|
|
15
|
+
HAS_AZURE = True
|
|
16
|
+
except ImportError:
|
|
17
|
+
DataLakeServiceClient = None
|
|
18
|
+
HAS_AZURE = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_azure_datalake_service_client():
|
|
22
|
+
"""Get an Azure DataLakeServiceClient with optional account configuration.
|
|
23
|
+
|
|
24
|
+
Authentication priority:
|
|
25
|
+
1. Access key (--az-access-key or AZURE_STORAGE_ACCESS_KEY env var)
|
|
26
|
+
2. DefaultAzureCredential (az login, managed identity, etc.)
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
azure.storage.filedatalake.DataLakeServiceClient instance.
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
SystemExit: If azure-storage-file-datalake is not installed.
|
|
33
|
+
ValueError: If Azure credentials are not configured.
|
|
34
|
+
"""
|
|
35
|
+
if not HAS_AZURE:
|
|
36
|
+
sys.stderr.write(
|
|
37
|
+
Fore.RED + "Error: azure-storage-file-datalake package is required for Azure access.\n" +
|
|
38
|
+
"Install it with: pip install azure-storage-file-datalake\n" + Style.RESET_ALL
|
|
39
|
+
)
|
|
40
|
+
sys.exit(1)
|
|
41
|
+
|
|
42
|
+
# Get account name (set by abfss:// URL parsing)
|
|
43
|
+
account_name = cloud_config.azure_account
|
|
44
|
+
if not account_name:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
"Azure storage account not found. Use abfss:// URL format: "
|
|
47
|
+
"abfss://container@account.dfs.core.windows.net/path"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
account_url = f"https://{account_name}.dfs.core.windows.net"
|
|
51
|
+
|
|
52
|
+
# Check for access key (CLI option or environment variable)
|
|
53
|
+
access_key = cloud_config.azure_access_key or os.environ.get('AZURE_STORAGE_ACCESS_KEY')
|
|
54
|
+
|
|
55
|
+
if access_key:
|
|
56
|
+
# Use access key authentication
|
|
57
|
+
return DataLakeServiceClient(account_url=account_url, credential=access_key)
|
|
58
|
+
else:
|
|
59
|
+
# Fall back to DefaultAzureCredential (az login, managed identity, etc.)
|
|
60
|
+
from azure.identity import DefaultAzureCredential
|
|
61
|
+
credential = DefaultAzureCredential()
|
|
62
|
+
return DataLakeServiceClient(account_url=account_url, credential=credential)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Keep old function name as alias for backwards compatibility
|
|
66
|
+
get_azure_blob_service_client = get_azure_datalake_service_client
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_azure_stream(container_name: str, file_path: str) -> io.BytesIO:
|
|
70
|
+
"""Get a file stream from Azure Data Lake Storage Gen2.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
container_name: Azure filesystem (container) name.
|
|
74
|
+
file_path: File path within the filesystem.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
BytesIO buffer containing the file content.
|
|
78
|
+
"""
|
|
79
|
+
datalake_service_client = get_azure_datalake_service_client()
|
|
80
|
+
file_system_client = datalake_service_client.get_file_system_client(file_system=container_name)
|
|
81
|
+
file_client = file_system_client.get_file_client(file_path)
|
|
82
|
+
|
|
83
|
+
# Download file to a BytesIO buffer
|
|
84
|
+
buffer = io.BytesIO()
|
|
85
|
+
download = file_client.download_file()
|
|
86
|
+
buffer.write(download.readall())
|
|
87
|
+
buffer.seek(0)
|
|
88
|
+
|
|
89
|
+
return buffer
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_azure_file_size(container_name: str, file_path: str) -> int:
|
|
93
|
+
"""Get the size of an Azure Data Lake file without downloading it.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
container_name: Azure filesystem (container) name.
|
|
97
|
+
file_path: File path within the filesystem.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
File size in bytes.
|
|
101
|
+
"""
|
|
102
|
+
datalake_service_client = get_azure_datalake_service_client()
|
|
103
|
+
file_system_client = datalake_service_client.get_file_system_client(file_system=container_name)
|
|
104
|
+
file_client = file_system_client.get_file_client(file_path)
|
|
105
|
+
properties = file_client.get_file_properties()
|
|
106
|
+
return properties.size
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def list_azure_directory(container_name: str, prefix: str) -> List[Tuple[str, int]]:
|
|
110
|
+
"""List files in an Azure Data Lake Storage directory.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
container_name: Azure filesystem (container) name.
|
|
114
|
+
prefix: Directory prefix.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
List of (filename, size) tuples.
|
|
118
|
+
"""
|
|
119
|
+
datalake_service_client = get_azure_datalake_service_client()
|
|
120
|
+
file_system_client = datalake_service_client.get_file_system_client(file_system=container_name)
|
|
121
|
+
|
|
122
|
+
# Ensure prefix ends with / to indicate a directory
|
|
123
|
+
if prefix and not prefix.endswith('/'):
|
|
124
|
+
prefix = prefix + '/'
|
|
125
|
+
|
|
126
|
+
# List paths with the prefix
|
|
127
|
+
file_list = []
|
|
128
|
+
paths = file_system_client.get_paths(path=prefix.rstrip('/'))
|
|
129
|
+
for path in paths:
|
|
130
|
+
if not path.is_directory:
|
|
131
|
+
file_list.append((path.name, path.content_length))
|
|
132
|
+
|
|
133
|
+
return file_list
|
|
@@ -9,7 +9,7 @@ def parse_cloud_path(path: str) -> Tuple[str, str, str]:
|
|
|
9
9
|
"""Parse a cloud storage path into service, bucket/container, and object components.
|
|
10
10
|
|
|
11
11
|
Args:
|
|
12
|
-
path: Cloud storage URL (gcs://, s3://,
|
|
12
|
+
path: Cloud storage URL (gcs://, s3://, or abfss://).
|
|
13
13
|
|
|
14
14
|
Returns:
|
|
15
15
|
Tuple of (service, bucket, object_path).
|
|
@@ -29,7 +29,8 @@ def get_pyarrow_filesystem(
|
|
|
29
29
|
aws_profile: Optional[str] = None,
|
|
30
30
|
gcp_project: Optional[str] = None,
|
|
31
31
|
gcp_credentials: Optional[str] = None,
|
|
32
|
-
azure_account: Optional[str] = None
|
|
32
|
+
azure_account: Optional[str] = None,
|
|
33
|
+
azure_access_key: Optional[str] = None
|
|
33
34
|
) -> Tuple[Any, str]:
|
|
34
35
|
"""Create a PyArrow filesystem for native cloud access.
|
|
35
36
|
|
|
@@ -42,6 +43,7 @@ def get_pyarrow_filesystem(
|
|
|
42
43
|
gcp_project: GCP project ID for GCS.
|
|
43
44
|
gcp_credentials: Path to GCP service account JSON for GCS.
|
|
44
45
|
azure_account: Azure storage account name.
|
|
46
|
+
azure_access_key: Azure storage account access key.
|
|
45
47
|
|
|
46
48
|
Returns:
|
|
47
49
|
Tuple of (filesystem, path_prefix) where path_prefix is used
|
|
@@ -62,7 +64,7 @@ def get_pyarrow_filesystem(
|
|
|
62
64
|
elif service == 'gcs':
|
|
63
65
|
return _get_gcs_filesystem(gcp_project, gcp_credentials)
|
|
64
66
|
elif service == 'azure':
|
|
65
|
-
return _get_azure_filesystem(azure_account)
|
|
67
|
+
return _get_azure_filesystem(azure_account, azure_access_key)
|
|
66
68
|
else:
|
|
67
69
|
raise ValueError(f"Unsupported service for PyArrow filesystem: {service}")
|
|
68
70
|
|
|
@@ -140,12 +142,14 @@ def _get_gcs_filesystem(
|
|
|
140
142
|
|
|
141
143
|
|
|
142
144
|
def _get_azure_filesystem(
|
|
143
|
-
azure_account: Optional[str] = None
|
|
145
|
+
azure_account: Optional[str] = None,
|
|
146
|
+
azure_access_key: Optional[str] = None
|
|
144
147
|
) -> Tuple[Any, str]:
|
|
145
|
-
"""Create PyArrow AzureFileSystem.
|
|
148
|
+
"""Create PyArrow AzureFileSystem for ADLS Gen2.
|
|
146
149
|
|
|
147
150
|
Args:
|
|
148
151
|
azure_account: Azure storage account name.
|
|
152
|
+
azure_access_key: Azure storage account access key.
|
|
149
153
|
|
|
150
154
|
Returns:
|
|
151
155
|
Tuple of (AzureFileSystem, path_prefix).
|
|
@@ -156,16 +160,11 @@ def _get_azure_filesystem(
|
|
|
156
160
|
|
|
157
161
|
if azure_account:
|
|
158
162
|
kwargs['account_name'] = azure_account
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
# Format: https://<account>.blob.core.windows.net
|
|
165
|
-
import re
|
|
166
|
-
match = re.match(r'https://([^.]+)\.blob\.core\.windows\.net', account_url)
|
|
167
|
-
if match:
|
|
168
|
-
kwargs['account_name'] = match.group(1)
|
|
163
|
+
|
|
164
|
+
# Check for access key (parameter or environment variable)
|
|
165
|
+
access_key = azure_access_key or os.environ.get('AZURE_STORAGE_ACCESS_KEY')
|
|
166
|
+
if access_key:
|
|
167
|
+
kwargs['account_key'] = access_key
|
|
169
168
|
|
|
170
169
|
filesystem = pa_fs.AzureFileSystem(**kwargs)
|
|
171
170
|
return filesystem, ''
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cloudcat
|
|
3
|
-
Version: 0.3.
|
|
4
|
-
Summary: Preview and analyze data files in Google Cloud Storage, AWS S3, and Azure
|
|
3
|
+
Version: 0.3.3
|
|
4
|
+
Summary: Preview and analyze data files in Google Cloud Storage, AWS S3, and Azure Data Lake Storage Gen2 from your terminal
|
|
5
5
|
Home-page: https://github.com/jonathansudhakar1/cloudcat
|
|
6
6
|
Author: Jonathan Sudhakar
|
|
7
7
|
Author-email: jonathan@example.com
|
|
@@ -9,7 +9,7 @@ Project-URL: Homepage, https://github.com/jonathansudhakar1/cloudcat
|
|
|
9
9
|
Project-URL: Documentation, https://cloudcatcli.com
|
|
10
10
|
Project-URL: Bug Reports, https://github.com/jonathansudhakar1/cloudcat/issues
|
|
11
11
|
Project-URL: Source, https://github.com/jonathansudhakar1/cloudcat
|
|
12
|
-
Keywords: cloud,gcs,s3,azure,cli,storage,data,parquet,csv,json,avro,orc,google-cloud,aws,
|
|
12
|
+
Keywords: cloud,gcs,s3,azure,cli,storage,data,parquet,csv,json,avro,orc,google-cloud,aws,adls,datalake,data-engineering,etl,spark,bigquery,databricks,kafka,hive
|
|
13
13
|
Classifier: Development Status :: 4 - Beta
|
|
14
14
|
Classifier: Intended Audience :: Developers
|
|
15
15
|
Classifier: Intended Audience :: System Administrators
|
|
@@ -38,7 +38,7 @@ Requires-Dist: tabulate>=0.8.9
|
|
|
38
38
|
Requires-Dist: colorama>=0.4.4
|
|
39
39
|
Requires-Dist: google-cloud-storage>=2.0.0
|
|
40
40
|
Requires-Dist: boto3>=1.18.0
|
|
41
|
-
Requires-Dist: azure-storage-
|
|
41
|
+
Requires-Dist: azure-storage-file-datalake>=12.0.0
|
|
42
42
|
Requires-Dist: azure-identity>=1.0.0
|
|
43
43
|
Provides-Extra: parquet
|
|
44
44
|
Requires-Dist: pyarrow>=5.0.0; extra == "parquet"
|
|
@@ -30,7 +30,7 @@ setup(
|
|
|
30
30
|
# Cloud providers (included by default)
|
|
31
31
|
"google-cloud-storage>=2.0.0",
|
|
32
32
|
"boto3>=1.18.0",
|
|
33
|
-
"azure-storage-
|
|
33
|
+
"azure-storage-file-datalake>=12.0.0",
|
|
34
34
|
"azure-identity>=1.0.0",
|
|
35
35
|
],
|
|
36
36
|
tests_require=[
|
|
@@ -60,10 +60,10 @@ setup(
|
|
|
60
60
|
},
|
|
61
61
|
author="Jonathan Sudhakar",
|
|
62
62
|
author_email="jonathan@example.com",
|
|
63
|
-
description="Preview and analyze data files in Google Cloud Storage, AWS S3, and Azure
|
|
63
|
+
description="Preview and analyze data files in Google Cloud Storage, AWS S3, and Azure Data Lake Storage Gen2 from your terminal",
|
|
64
64
|
long_description=long_description,
|
|
65
65
|
long_description_content_type="text/markdown",
|
|
66
|
-
keywords="cloud, gcs, s3, azure, cli, storage, data, parquet, csv, json, avro, orc, google-cloud, aws,
|
|
66
|
+
keywords="cloud, gcs, s3, azure, cli, storage, data, parquet, csv, json, avro, orc, google-cloud, aws, adls, datalake, data-engineering, etl, spark, bigquery, databricks, kafka, hive",
|
|
67
67
|
project_urls={
|
|
68
68
|
"Homepage": "https://github.com/jonathansudhakar1/cloudcat",
|
|
69
69
|
"Documentation": "https://cloudcatcli.com",
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
"""Azure Blob Storage client and operations."""
|
|
2
|
-
|
|
3
|
-
import io
|
|
4
|
-
import os
|
|
5
|
-
import sys
|
|
6
|
-
from typing import List, Tuple
|
|
7
|
-
|
|
8
|
-
from colorama import Fore, Style
|
|
9
|
-
|
|
10
|
-
from ..config import cloud_config
|
|
11
|
-
|
|
12
|
-
# Try to import Azure client
|
|
13
|
-
try:
|
|
14
|
-
from azure.storage.blob import BlobServiceClient, ContainerClient
|
|
15
|
-
HAS_AZURE = True
|
|
16
|
-
except ImportError:
|
|
17
|
-
BlobServiceClient = None
|
|
18
|
-
ContainerClient = None
|
|
19
|
-
HAS_AZURE = False
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def get_azure_blob_service_client():
|
|
23
|
-
"""Get an Azure BlobServiceClient with optional account configuration.
|
|
24
|
-
|
|
25
|
-
Returns:
|
|
26
|
-
azure.storage.blob.BlobServiceClient instance.
|
|
27
|
-
|
|
28
|
-
Raises:
|
|
29
|
-
SystemExit: If azure-storage-blob is not installed.
|
|
30
|
-
ValueError: If Azure credentials are not configured.
|
|
31
|
-
"""
|
|
32
|
-
if not HAS_AZURE:
|
|
33
|
-
sys.stderr.write(
|
|
34
|
-
Fore.RED + "Error: azure-storage-blob package is required for Azure access.\n" +
|
|
35
|
-
"Install it with: pip install azure-storage-blob\n" + Style.RESET_ALL
|
|
36
|
-
)
|
|
37
|
-
sys.exit(1)
|
|
38
|
-
|
|
39
|
-
# Check for explicit account override
|
|
40
|
-
if cloud_config.azure_account:
|
|
41
|
-
account_url = f"https://{cloud_config.azure_account}.blob.core.windows.net"
|
|
42
|
-
from azure.identity import DefaultAzureCredential
|
|
43
|
-
credential = DefaultAzureCredential()
|
|
44
|
-
return BlobServiceClient(account_url=account_url, credential=credential)
|
|
45
|
-
|
|
46
|
-
# Fall back to environment variables
|
|
47
|
-
connection_string = os.environ.get('AZURE_STORAGE_CONNECTION_STRING')
|
|
48
|
-
account_url = os.environ.get('AZURE_STORAGE_ACCOUNT_URL')
|
|
49
|
-
|
|
50
|
-
if connection_string:
|
|
51
|
-
return BlobServiceClient.from_connection_string(connection_string)
|
|
52
|
-
elif account_url:
|
|
53
|
-
from azure.identity import DefaultAzureCredential
|
|
54
|
-
credential = DefaultAzureCredential()
|
|
55
|
-
return BlobServiceClient(account_url=account_url, credential=credential)
|
|
56
|
-
else:
|
|
57
|
-
raise ValueError(
|
|
58
|
-
"Azure credentials not found. Use --account, or set "
|
|
59
|
-
"AZURE_STORAGE_CONNECTION_STRING or AZURE_STORAGE_ACCOUNT_URL environment variable."
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def get_azure_stream(container_name: str, blob_name: str) -> io.BytesIO:
|
|
64
|
-
"""Get a file stream from Azure Blob Storage.
|
|
65
|
-
|
|
66
|
-
Args:
|
|
67
|
-
container_name: Azure container name.
|
|
68
|
-
blob_name: Blob path within the container.
|
|
69
|
-
|
|
70
|
-
Returns:
|
|
71
|
-
BytesIO buffer containing the file content.
|
|
72
|
-
"""
|
|
73
|
-
blob_service_client = get_azure_blob_service_client()
|
|
74
|
-
blob_client = blob_service_client.get_blob_client(container=container_name, blob=blob_name)
|
|
75
|
-
|
|
76
|
-
# Download blob to a BytesIO buffer
|
|
77
|
-
buffer = io.BytesIO()
|
|
78
|
-
download_stream = blob_client.download_blob()
|
|
79
|
-
buffer.write(download_stream.readall())
|
|
80
|
-
buffer.seek(0)
|
|
81
|
-
|
|
82
|
-
return buffer
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def get_azure_file_size(container_name: str, blob_name: str) -> int:
|
|
86
|
-
"""Get the size of an Azure blob without downloading it.
|
|
87
|
-
|
|
88
|
-
Args:
|
|
89
|
-
container_name: Azure container name.
|
|
90
|
-
blob_name: Blob path within the container.
|
|
91
|
-
|
|
92
|
-
Returns:
|
|
93
|
-
File size in bytes.
|
|
94
|
-
"""
|
|
95
|
-
blob_service_client = get_azure_blob_service_client()
|
|
96
|
-
blob_client = blob_service_client.get_blob_client(container=container_name, blob=blob_name)
|
|
97
|
-
properties = blob_client.get_blob_properties()
|
|
98
|
-
return properties.size
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def list_azure_directory(container_name: str, prefix: str) -> List[Tuple[str, int]]:
|
|
102
|
-
"""List files in an Azure Blob Storage container directory.
|
|
103
|
-
|
|
104
|
-
Args:
|
|
105
|
-
container_name: Azure container name.
|
|
106
|
-
prefix: Directory prefix.
|
|
107
|
-
|
|
108
|
-
Returns:
|
|
109
|
-
List of (filename, size) tuples.
|
|
110
|
-
"""
|
|
111
|
-
blob_service_client = get_azure_blob_service_client()
|
|
112
|
-
container_client = blob_service_client.get_container_client(container_name)
|
|
113
|
-
|
|
114
|
-
# Ensure prefix ends with / to indicate a directory
|
|
115
|
-
if prefix and not prefix.endswith('/'):
|
|
116
|
-
prefix = prefix + '/'
|
|
117
|
-
|
|
118
|
-
# List blobs with the prefix
|
|
119
|
-
file_list = []
|
|
120
|
-
blobs = container_client.list_blobs(name_starts_with=prefix)
|
|
121
|
-
for blob in blobs:
|
|
122
|
-
if not blob.name.endswith('/'):
|
|
123
|
-
file_list.append((blob.name, blob.size))
|
|
124
|
-
|
|
125
|
-
return file_list
|
|
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
|