cloudcat 0.3.2__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.2 → cloudcat-0.3.3}/PKG-INFO +4 -4
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat/__init__.py +1 -1
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat/cli.py +61 -30
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat/config.py +3 -1
- cloudcat-0.3.3/cloudcat/progress.py +102 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat/storage/__init__.py +3 -2
- cloudcat-0.3.3/cloudcat/storage/azure.py +133 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat/storage/base.py +1 -1
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat/streaming/filesystems.py +13 -14
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat.egg-info/PKG-INFO +4 -4
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat.egg-info/SOURCES.txt +1 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat.egg-info/requires.txt +1 -1
- {cloudcat-0.3.2 → cloudcat-0.3.3}/setup.py +3 -3
- cloudcat-0.3.2/cloudcat/storage/azure.py +0 -125
- {cloudcat-0.3.2 → cloudcat-0.3.3}/LICENSE +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/README.md +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat/compression.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat/filtering.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat/formatters.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat/readers/__init__.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat/readers/avro.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat/readers/csv.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat/readers/json.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat/readers/orc.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat/readers/parquet.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat/readers/text.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat/storage/gcs.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat/storage/s3.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat/streaming/__init__.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat/streaming/stats.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat/streaming/tracking.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat.egg-info/dependency_links.txt +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat.egg-info/entry_points.txt +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/cloudcat.egg-info/top_level.txt +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/pyproject.toml +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/setup.cfg +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/tests/__init__.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/tests/conftest.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/tests/test_cli.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/tests/test_compression.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/tests/test_config.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/tests/test_data_reading.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/tests/test_file_filtering.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/tests/test_filtering.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/tests/test_format_detection.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/tests/test_integration.py +0 -0
- {cloudcat-0.3.2 → cloudcat-0.3.3}/tests/test_output_formatting.py +0 -0
- {cloudcat-0.3.2 → 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,
|
|
@@ -139,12 +140,14 @@ def get_files_for_multiread(
|
|
|
139
140
|
total_size = 0
|
|
140
141
|
|
|
141
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
|
+
|
|
142
148
|
selected_files.append((file_name, file_size))
|
|
143
149
|
total_size += file_size
|
|
144
150
|
|
|
145
|
-
if total_size >= max_size_bytes:
|
|
146
|
-
break
|
|
147
|
-
|
|
148
151
|
if not selected_files:
|
|
149
152
|
raise ValueError(f"No suitable files found in {service}://{bucket}/{prefix}")
|
|
150
153
|
|
|
@@ -255,7 +258,8 @@ def read_data_from_multiple_files(
|
|
|
255
258
|
num_rows: int,
|
|
256
259
|
columns: Optional[str] = None,
|
|
257
260
|
delimiter: Optional[str] = None,
|
|
258
|
-
offset: int = 0
|
|
261
|
+
offset: int = 0,
|
|
262
|
+
quiet: bool = False
|
|
259
263
|
) -> Tuple[pd.DataFrame, pd.Series, int]:
|
|
260
264
|
"""Read data from multiple files and concatenate the results.
|
|
261
265
|
|
|
@@ -268,6 +272,7 @@ def read_data_from_multiple_files(
|
|
|
268
272
|
columns: Columns to select.
|
|
269
273
|
delimiter: CSV delimiter.
|
|
270
274
|
offset: Rows to skip.
|
|
275
|
+
quiet: Suppress progress messages.
|
|
271
276
|
|
|
272
277
|
Returns:
|
|
273
278
|
Tuple of (DataFrame, schema, total_rows).
|
|
@@ -278,16 +283,22 @@ def read_data_from_multiple_files(
|
|
|
278
283
|
rows_skipped = 0
|
|
279
284
|
total_rows = 0
|
|
280
285
|
|
|
281
|
-
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):
|
|
282
287
|
file_name, file_size = file_info
|
|
283
|
-
|
|
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}")
|
|
284
294
|
|
|
285
295
|
stream = get_stream(service, bucket, file_name)
|
|
286
296
|
|
|
287
297
|
# Check for compression and decompress if needed
|
|
288
298
|
compression = detect_compression(file_name)
|
|
289
299
|
if compression:
|
|
290
|
-
|
|
300
|
+
if not quiet:
|
|
301
|
+
click.echo(Fore.BLUE + f"Detected {compression} compression, decompressing..." + Style.RESET_ALL)
|
|
291
302
|
stream = decompress_stream(stream, compression)
|
|
292
303
|
|
|
293
304
|
# Calculate how many rows to read from this file
|
|
@@ -314,10 +325,11 @@ def read_data_from_multiple_files(
|
|
|
314
325
|
# Process files in order until we have enough rows
|
|
315
326
|
remaining_offset = offset
|
|
316
327
|
remaining_rows = num_rows if num_rows > 0 else float('inf')
|
|
328
|
+
total_files = len(file_list)
|
|
317
329
|
|
|
318
|
-
for file_info in file_list:
|
|
330
|
+
for file_index, file_info in enumerate(file_list):
|
|
319
331
|
try:
|
|
320
|
-
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)
|
|
321
333
|
|
|
322
334
|
if not df.empty:
|
|
323
335
|
total_rows += len(df)
|
|
@@ -460,7 +472,8 @@ def read_data_streaming(
|
|
|
460
472
|
aws_profile=cloud_config.aws_profile,
|
|
461
473
|
gcp_project=cloud_config.gcp_project,
|
|
462
474
|
gcp_credentials=cloud_config.gcp_credentials,
|
|
463
|
-
azure_account=cloud_config.azure_account
|
|
475
|
+
azure_account=cloud_config.azure_account,
|
|
476
|
+
azure_access_key=cloud_config.azure_access_key
|
|
464
477
|
)
|
|
465
478
|
pyarrow_path = f"{bucket}/{object_path}"
|
|
466
479
|
|
|
@@ -709,7 +722,7 @@ def get_record_count_multiple_files(
|
|
|
709
722
|
|
|
710
723
|
@click.command()
|
|
711
724
|
@click.version_option(version=__version__, prog_name='cloudcat')
|
|
712
|
-
@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://)')
|
|
713
726
|
@click.option('--output-format', '-o', type=click.Choice(['json', 'jsonp', 'csv', 'table']), default='table',
|
|
714
727
|
help='Output format (default: table)')
|
|
715
728
|
@click.option('--input-format', '-i', type=click.Choice(['json', 'csv', 'parquet', 'avro', 'orc', 'text']),
|
|
@@ -729,11 +742,11 @@ def get_record_count_multiple_files(
|
|
|
729
742
|
@click.option('--profile', help='AWS profile name (for S3 access)')
|
|
730
743
|
@click.option('--project', help='GCP project ID (for GCS access)')
|
|
731
744
|
@click.option('--credentials', help='Path to GCP service account JSON file')
|
|
732
|
-
@click.option('--
|
|
745
|
+
@click.option('--az-access-key', help='Azure storage account access key')
|
|
733
746
|
@click.option('--yes', '-y', is_flag=True, help='Skip confirmation prompts (for scripting)')
|
|
734
747
|
def main(path, output_format, input_format, columns, num_rows, offset, where, schema, count,
|
|
735
|
-
multi_file_mode, max_size_mb, delimiter, profile, project, credentials,
|
|
736
|
-
"""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.
|
|
737
750
|
|
|
738
751
|
Supported formats: CSV, JSON, Parquet, Avro, ORC, and plain text.
|
|
739
752
|
Supports compressed files: .gz, .zst, .lz4, .snappy, .bz2
|
|
@@ -749,8 +762,8 @@ def main(path, output_format, input_format, columns, num_rows, offset, where, sc
|
|
|
749
762
|
cloudcat --path s3://my-bucket/data.parquet --columns id,name,value
|
|
750
763
|
|
|
751
764
|
\b
|
|
752
|
-
# Read from Azure
|
|
753
|
-
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
|
|
754
767
|
|
|
755
768
|
\b
|
|
756
769
|
# Read Avro files from Kafka exports
|
|
@@ -762,7 +775,7 @@ def main(path, output_format, input_format, columns, num_rows, offset, where, sc
|
|
|
762
775
|
|
|
763
776
|
\b
|
|
764
777
|
# Read log files as plain text
|
|
765
|
-
cloudcat --path
|
|
778
|
+
cloudcat --path abfss://logs@account.dfs.core.windows.net/app.log --input-format text
|
|
766
779
|
|
|
767
780
|
\b
|
|
768
781
|
# Read from a directory (reads first non-empty data file)
|
|
@@ -804,8 +817,8 @@ def main(path, output_format, input_format, columns, num_rows, offset, where, sc
|
|
|
804
817
|
cloudcat --path gcs://bucket/data.csv --credentials /path/to/service-account.json
|
|
805
818
|
|
|
806
819
|
\b
|
|
807
|
-
# Use
|
|
808
|
-
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
|
|
809
822
|
"""
|
|
810
823
|
try:
|
|
811
824
|
# Configure cloud credentials from CLI options
|
|
@@ -815,8 +828,8 @@ def main(path, output_format, input_format, columns, num_rows, offset, where, sc
|
|
|
815
828
|
cloud_config.gcp_project = project
|
|
816
829
|
if credentials:
|
|
817
830
|
cloud_config.gcp_credentials = credentials
|
|
818
|
-
if
|
|
819
|
-
cloud_config.
|
|
831
|
+
if az_access_key:
|
|
832
|
+
cloud_config.azure_access_key = az_access_key
|
|
820
833
|
|
|
821
834
|
# Handle special characters in delimiter
|
|
822
835
|
if delimiter == "\\t":
|
|
@@ -834,37 +847,44 @@ def main(path, output_format, input_format, columns, num_rows, offset, where, sc
|
|
|
834
847
|
|
|
835
848
|
# Handle directory paths based on multi-file-mode
|
|
836
849
|
if is_directory:
|
|
837
|
-
click.echo(Fore.BLUE + f"Path is a directory" + Style.RESET_ALL)
|
|
838
|
-
|
|
839
850
|
if multi_file_mode == 'first' or (multi_file_mode == 'auto' and max_size_mb <= 0):
|
|
840
851
|
# Use a single file
|
|
841
|
-
|
|
852
|
+
start_progress("Listing files...")
|
|
842
853
|
object_path = find_first_non_empty_file(service, bucket, object_path, input_format)
|
|
843
854
|
|
|
844
855
|
# Determine input format if not specified
|
|
845
856
|
if not input_format:
|
|
846
857
|
input_format = detect_format_from_path(object_path)
|
|
847
|
-
|
|
858
|
+
|
|
859
|
+
# Get file name for display
|
|
860
|
+
file_name = object_path.split('/')[-1]
|
|
861
|
+
update_progress(f"Reading {file_name}...")
|
|
848
862
|
|
|
849
863
|
# Read the data from the single file with streaming
|
|
850
864
|
df, full_schema, streaming_stats = read_data_streaming(service, bucket, object_path, input_format, num_rows, columns, delimiter, offset)
|
|
851
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)
|
|
852
870
|
else:
|
|
853
871
|
# Read from multiple files
|
|
854
|
-
|
|
872
|
+
start_progress("Listing files...")
|
|
855
873
|
|
|
856
874
|
# Determine input format if not specified (use the first file to infer)
|
|
857
875
|
if not input_format:
|
|
858
876
|
first_file = find_first_non_empty_file(service, bucket, object_path)
|
|
859
877
|
input_format = detect_format_from_path(first_file)
|
|
860
|
-
|
|
878
|
+
|
|
879
|
+
update_progress(f"Selecting {input_format} files...")
|
|
861
880
|
|
|
862
881
|
# Get files to read for preview (limited by max_size_mb)
|
|
863
|
-
file_list = get_files_for_multiread(service, bucket, object_path, input_format, max_size_mb)
|
|
882
|
+
file_list = get_files_for_multiread(service, bucket, object_path, input_format, max_size_mb, quiet=True)
|
|
864
883
|
|
|
865
|
-
# Read data from multiple files
|
|
884
|
+
# Read data from multiple files with progress updates
|
|
885
|
+
update_progress(f"Reading {len(file_list)} files...")
|
|
866
886
|
df, full_schema, rows_in_files = read_data_from_multiple_files(
|
|
867
|
-
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
|
|
868
888
|
)
|
|
869
889
|
|
|
870
890
|
# Calculate total size for stats
|
|
@@ -874,6 +894,7 @@ def main(path, output_format, input_format, columns, num_rows, offset, where, sc
|
|
|
874
894
|
# For --count, get ALL files (not limited by max_size_mb)
|
|
875
895
|
# so we can count records across the entire directory
|
|
876
896
|
if count:
|
|
897
|
+
stop_progress()
|
|
877
898
|
all_files = get_files_for_multiread(service, bucket, object_path, input_format, max_size_mb=999999, quiet=True)
|
|
878
899
|
all_files_size = sum(f[1] for f in all_files)
|
|
879
900
|
all_files_size_mb = all_files_size / (1024 * 1024)
|
|
@@ -889,8 +910,12 @@ def main(path, output_format, input_format, columns, num_rows, offset, where, sc
|
|
|
889
910
|
# Update stats to reflect all files
|
|
890
911
|
streaming_stats.file_size = all_files_size
|
|
891
912
|
else:
|
|
913
|
+
stop_progress()
|
|
892
914
|
multi_file_list = file_list
|
|
893
915
|
|
|
916
|
+
# Show format info
|
|
917
|
+
click.echo(Fore.BLUE + f"Inferred input format: {input_format}" + Style.RESET_ALL)
|
|
918
|
+
|
|
894
919
|
# total_record_count will be computed later if --count is specified
|
|
895
920
|
total_record_count = None
|
|
896
921
|
|
|
@@ -904,10 +929,16 @@ def main(path, output_format, input_format, columns, num_rows, offset, where, sc
|
|
|
904
929
|
input_format = detect_format_from_path(object_path)
|
|
905
930
|
click.echo(Fore.BLUE + f"Inferred input format: {input_format}" + Style.RESET_ALL)
|
|
906
931
|
|
|
932
|
+
# Get file name for display
|
|
933
|
+
file_name = object_path.split('/')[-1]
|
|
934
|
+
start_progress(f"Reading {file_name}...")
|
|
935
|
+
|
|
907
936
|
# Read the data with streaming
|
|
908
937
|
df, full_schema, streaming_stats = read_data_streaming(service, bucket, object_path, input_format, num_rows, columns, delimiter, offset)
|
|
909
938
|
total_record_count = None # Will be computed later if needed
|
|
910
939
|
|
|
940
|
+
stop_progress()
|
|
941
|
+
|
|
911
942
|
# Apply WHERE filter if specified
|
|
912
943
|
if where and not df.empty:
|
|
913
944
|
original_count = len(df)
|
|
@@ -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
|