cloudcat 0.3.5__tar.gz → 0.3.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. {cloudcat-0.3.5 → cloudcat-0.3.7}/PKG-INFO +17 -17
  2. {cloudcat-0.3.5 → cloudcat-0.3.7}/README.md +16 -16
  3. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/__init__.py +1 -1
  4. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/cli.py +67 -38
  5. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/progress.py +8 -6
  6. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/storage/azure.py +109 -14
  7. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/storage/base.py +1 -1
  8. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat.egg-info/PKG-INFO +17 -17
  9. {cloudcat-0.3.5 → cloudcat-0.3.7}/tests/test_cli.py +2 -2
  10. {cloudcat-0.3.5 → cloudcat-0.3.7}/tests/test_file_filtering.py +9 -6
  11. {cloudcat-0.3.5 → cloudcat-0.3.7}/LICENSE +0 -0
  12. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/compression.py +0 -0
  13. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/config.py +0 -0
  14. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/filtering.py +0 -0
  15. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/formatters.py +0 -0
  16. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/readers/__init__.py +0 -0
  17. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/readers/avro.py +0 -0
  18. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/readers/csv.py +0 -0
  19. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/readers/json.py +0 -0
  20. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/readers/orc.py +0 -0
  21. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/readers/parquet.py +0 -0
  22. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/readers/text.py +0 -0
  23. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/storage/__init__.py +0 -0
  24. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/storage/gcs.py +0 -0
  25. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/storage/s3.py +0 -0
  26. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/streaming/__init__.py +0 -0
  27. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/streaming/filesystems.py +0 -0
  28. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/streaming/stats.py +0 -0
  29. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/streaming/tracking.py +0 -0
  30. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat.egg-info/SOURCES.txt +0 -0
  31. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat.egg-info/dependency_links.txt +0 -0
  32. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat.egg-info/entry_points.txt +0 -0
  33. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat.egg-info/requires.txt +0 -0
  34. {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat.egg-info/top_level.txt +0 -0
  35. {cloudcat-0.3.5 → cloudcat-0.3.7}/pyproject.toml +0 -0
  36. {cloudcat-0.3.5 → cloudcat-0.3.7}/setup.cfg +0 -0
  37. {cloudcat-0.3.5 → cloudcat-0.3.7}/setup.py +0 -0
  38. {cloudcat-0.3.5 → cloudcat-0.3.7}/tests/__init__.py +0 -0
  39. {cloudcat-0.3.5 → cloudcat-0.3.7}/tests/conftest.py +0 -0
  40. {cloudcat-0.3.5 → cloudcat-0.3.7}/tests/test_compression.py +0 -0
  41. {cloudcat-0.3.5 → cloudcat-0.3.7}/tests/test_config.py +0 -0
  42. {cloudcat-0.3.5 → cloudcat-0.3.7}/tests/test_data_reading.py +0 -0
  43. {cloudcat-0.3.5 → cloudcat-0.3.7}/tests/test_filtering.py +0 -0
  44. {cloudcat-0.3.5 → cloudcat-0.3.7}/tests/test_format_detection.py +0 -0
  45. {cloudcat-0.3.5 → cloudcat-0.3.7}/tests/test_integration.py +0 -0
  46. {cloudcat-0.3.5 → cloudcat-0.3.7}/tests/test_output_formatting.py +0 -0
  47. {cloudcat-0.3.5 → cloudcat-0.3.7}/tests/test_path_parsing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudcat
3
- Version: 0.3.5
3
+ Version: 0.3.7
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
  Home-page: https://github.com/jonathansudhakar1/cloudcat
6
6
  Author: Jonathan Sudhakar
@@ -187,7 +187,7 @@ cloudcat -p gcs://my-bucket/data.csv
187
187
  cloudcat -p s3://my-bucket/analytics/events.parquet
188
188
 
189
189
  # Preview JSON data from Azure with pretty formatting
190
- cloudcat -p az://my-container/logs.json -o jsonp
190
+ cloudcat -p abfss://my-container@account.dfs.core.windows.net/logs.json -o jsonp
191
191
 
192
192
  # Read Avro files from Kafka
193
193
  cloudcat -p s3://my-bucket/kafka-export.avro
@@ -196,7 +196,7 @@ cloudcat -p s3://my-bucket/kafka-export.avro
196
196
  cloudcat -p gcs://my-bucket/hive-table.orc
197
197
 
198
198
  # Read log files as plain text
199
- cloudcat -p az://logs/app.log -i text
199
+ cloudcat -p abfss://logs@account.dfs.core.windows.net/app.log -i text
200
200
 
201
201
  # Read from a Spark output directory
202
202
  cloudcat -p s3://my-bucket/spark-output/ -i parquet
@@ -219,7 +219,6 @@ cloudcat -p gcs://bucket/data.csv --offset 100 -n 10
219
219
  |----------|------------|--------|
220
220
  | Google Cloud Storage | `gcs://` or `gs://` | ✅ Supported |
221
221
  | Amazon S3 | `s3://` | ✅ Supported |
222
- | Azure Blob Storage | `az://` or `azure://` | ✅ Supported |
223
222
  | Azure Data Lake Gen2 | `abfss://` | ✅ Supported |
224
223
 
225
224
  ### File Format Support
@@ -338,7 +337,7 @@ cloudcat -p s3://bucket/logs.csv --where "level=ERROR"
338
337
  # String matching filters
339
338
  cloudcat -p gcs://bucket/data.csv --where "name contains john"
340
339
  cloudcat -p s3://bucket/emails.json --where "email endswith @gmail.com"
341
- cloudcat -p az://logs/app.log --where "message startswith ERROR"
340
+ cloudcat -p abfss://logs@account.dfs.core.windows.net/app.log --where "message startswith ERROR"
342
341
 
343
342
  # Skip first N rows (pagination)
344
343
  cloudcat -p gcs://bucket/data.csv --offset 100 -n 10
@@ -361,7 +360,7 @@ cloudcat -p gcs://bucket/events.parquet.zst
361
360
  cloudcat -p s3://bucket/data.csv.lz4
362
361
 
363
362
  # Bzip2 compressed (built-in)
364
- cloudcat -p az://container/archive.json.bz2
363
+ cloudcat -p abfss://container@account.dfs.core.windows.net/archive.json.bz2
365
364
  ```
366
365
 
367
366
  ### Directory Operations
@@ -482,7 +481,7 @@ Usage: cloudcat [OPTIONS]
482
481
  Options:
483
482
  -p, --path TEXT Cloud storage path (required)
484
483
  Format: gcs://bucket/path, s3://bucket/path,
485
- or az://container/path
484
+ or abfss://container@account.dfs.core.windows.net/path
486
485
 
487
486
  -o, --output-format TEXT Output format: table, json, jsonp, csv
488
487
  [default: table]
@@ -582,23 +581,24 @@ cloudcat -p s3://bucket/data.csv --profile production
582
581
  # Automatically detected
583
582
  ```
584
583
 
585
- ### Azure Blob Storage
584
+ ### Azure Data Lake Storage Gen2
586
585
 
587
- CloudCat supports multiple authentication methods for Azure:
586
+ CloudCat supports multiple authentication methods for Azure ADLS Gen2:
588
587
 
589
588
  ```bash
590
- # Option 1: Connection string (simplest)
591
- export AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net"
589
+ # Option 1: Access key (simplest)
590
+ cloudcat -p abfss://container@account.dfs.core.windows.net/data.csv --az-access-key "YOUR_KEY"
592
591
 
593
- # Option 2: Account URL with DefaultAzureCredential (for Azure AD auth)
594
- export AZURE_STORAGE_ACCOUNT_URL="https://youraccount.blob.core.windows.net"
595
- az login
592
+ # Option 2: Access key via environment variable
593
+ export AZURE_STORAGE_ACCESS_KEY="YOUR_KEY"
594
+ cloudcat -p abfss://container@account.dfs.core.windows.net/data.csv
596
595
 
597
- # Option 3: Specify storage account via CLI option
598
- cloudcat -p az://container/data.csv --account mystorageaccount
596
+ # Option 3: Azure CLI with DefaultAzureCredential
597
+ az login
598
+ cloudcat -p abfss://container@account.dfs.core.windows.net/data.csv
599
599
  ```
600
600
 
601
- **Path format:** `az://container-name/path/to/blob`
601
+ **Path format:** `abfss://container@account.dfs.core.windows.net/path/to/file`
602
602
 
603
603
  ## Performance Tips
604
604
 
@@ -109,7 +109,7 @@ cloudcat -p gcs://my-bucket/data.csv
109
109
  cloudcat -p s3://my-bucket/analytics/events.parquet
110
110
 
111
111
  # Preview JSON data from Azure with pretty formatting
112
- cloudcat -p az://my-container/logs.json -o jsonp
112
+ cloudcat -p abfss://my-container@account.dfs.core.windows.net/logs.json -o jsonp
113
113
 
114
114
  # Read Avro files from Kafka
115
115
  cloudcat -p s3://my-bucket/kafka-export.avro
@@ -118,7 +118,7 @@ cloudcat -p s3://my-bucket/kafka-export.avro
118
118
  cloudcat -p gcs://my-bucket/hive-table.orc
119
119
 
120
120
  # Read log files as plain text
121
- cloudcat -p az://logs/app.log -i text
121
+ cloudcat -p abfss://logs@account.dfs.core.windows.net/app.log -i text
122
122
 
123
123
  # Read from a Spark output directory
124
124
  cloudcat -p s3://my-bucket/spark-output/ -i parquet
@@ -141,7 +141,6 @@ cloudcat -p gcs://bucket/data.csv --offset 100 -n 10
141
141
  |----------|------------|--------|
142
142
  | Google Cloud Storage | `gcs://` or `gs://` | ✅ Supported |
143
143
  | Amazon S3 | `s3://` | ✅ Supported |
144
- | Azure Blob Storage | `az://` or `azure://` | ✅ Supported |
145
144
  | Azure Data Lake Gen2 | `abfss://` | ✅ Supported |
146
145
 
147
146
  ### File Format Support
@@ -260,7 +259,7 @@ cloudcat -p s3://bucket/logs.csv --where "level=ERROR"
260
259
  # String matching filters
261
260
  cloudcat -p gcs://bucket/data.csv --where "name contains john"
262
261
  cloudcat -p s3://bucket/emails.json --where "email endswith @gmail.com"
263
- cloudcat -p az://logs/app.log --where "message startswith ERROR"
262
+ cloudcat -p abfss://logs@account.dfs.core.windows.net/app.log --where "message startswith ERROR"
264
263
 
265
264
  # Skip first N rows (pagination)
266
265
  cloudcat -p gcs://bucket/data.csv --offset 100 -n 10
@@ -283,7 +282,7 @@ cloudcat -p gcs://bucket/events.parquet.zst
283
282
  cloudcat -p s3://bucket/data.csv.lz4
284
283
 
285
284
  # Bzip2 compressed (built-in)
286
- cloudcat -p az://container/archive.json.bz2
285
+ cloudcat -p abfss://container@account.dfs.core.windows.net/archive.json.bz2
287
286
  ```
288
287
 
289
288
  ### Directory Operations
@@ -404,7 +403,7 @@ Usage: cloudcat [OPTIONS]
404
403
  Options:
405
404
  -p, --path TEXT Cloud storage path (required)
406
405
  Format: gcs://bucket/path, s3://bucket/path,
407
- or az://container/path
406
+ or abfss://container@account.dfs.core.windows.net/path
408
407
 
409
408
  -o, --output-format TEXT Output format: table, json, jsonp, csv
410
409
  [default: table]
@@ -504,23 +503,24 @@ cloudcat -p s3://bucket/data.csv --profile production
504
503
  # Automatically detected
505
504
  ```
506
505
 
507
- ### Azure Blob Storage
506
+ ### Azure Data Lake Storage Gen2
508
507
 
509
- CloudCat supports multiple authentication methods for Azure:
508
+ CloudCat supports multiple authentication methods for Azure ADLS Gen2:
510
509
 
511
510
  ```bash
512
- # Option 1: Connection string (simplest)
513
- export AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net"
511
+ # Option 1: Access key (simplest)
512
+ cloudcat -p abfss://container@account.dfs.core.windows.net/data.csv --az-access-key "YOUR_KEY"
514
513
 
515
- # Option 2: Account URL with DefaultAzureCredential (for Azure AD auth)
516
- export AZURE_STORAGE_ACCOUNT_URL="https://youraccount.blob.core.windows.net"
517
- az login
514
+ # Option 2: Access key via environment variable
515
+ export AZURE_STORAGE_ACCESS_KEY="YOUR_KEY"
516
+ cloudcat -p abfss://container@account.dfs.core.windows.net/data.csv
518
517
 
519
- # Option 3: Specify storage account via CLI option
520
- cloudcat -p az://container/data.csv --account mystorageaccount
518
+ # Option 3: Azure CLI with DefaultAzureCredential
519
+ az login
520
+ cloudcat -p abfss://container@account.dfs.core.windows.net/data.csv
521
521
  ```
522
522
 
523
- **Path format:** `az://container-name/path/to/blob`
523
+ **Path format:** `abfss://container@account.dfs.core.windows.net/path/to/file`
524
524
 
525
525
  ## Performance Tips
526
526
 
@@ -2,4 +2,4 @@
2
2
  #!/usr/bin/env python
3
3
  """cloudcat - A CLI utility to read and display files from cloud storage."""
4
4
 
5
- __version__ = "0.3.5"
5
+ __version__ = "0.3.7"
@@ -163,8 +163,9 @@ def find_first_non_empty_file(
163
163
  service: str,
164
164
  bucket: str,
165
165
  prefix: str,
166
- input_format: Optional[str] = None
167
- ) -> str:
166
+ input_format: Optional[str] = None,
167
+ quiet: bool = False
168
+ ) -> Tuple[str, int]:
168
169
  """Find the first non-empty file in a directory that matches the input format.
169
170
 
170
171
  Args:
@@ -172,9 +173,10 @@ def find_first_non_empty_file(
172
173
  bucket: Bucket or container name.
173
174
  prefix: Directory prefix.
174
175
  input_format: Optional format filter.
176
+ quiet: If True, suppress output messages.
175
177
 
176
178
  Returns:
177
- File path of the first suitable file.
179
+ Tuple of (file_path, file_size) for the first suitable file.
178
180
 
179
181
  Raises:
180
182
  ValueError: If no suitable files are found.
@@ -201,21 +203,24 @@ def find_first_non_empty_file(
201
203
  if matching_files:
202
204
  # Use the first matching file
203
205
  selected_file = matching_files[0]
204
- click.echo(Fore.BLUE + f"Selected file: {selected_file[0]} ({selected_file[1]} bytes)" + Style.RESET_ALL)
205
- return selected_file[0]
206
+ if not quiet:
207
+ click.echo(Fore.BLUE + f"Selected file: {selected_file[0]} ({selected_file[1]} bytes)" + Style.RESET_ALL)
208
+ return selected_file[0], selected_file[1]
206
209
 
207
210
  # If no input_format specified or no matching files found, use the first non-empty file
208
211
  # Skip common metadata files
209
212
  for file_name, file_size in non_empty_files:
210
213
  # Skip if the file matches any of the patterns to ignore
211
214
  if not any(re.search(pattern, file_name) for pattern in SKIP_PATTERNS):
212
- click.echo(Fore.BLUE + f"Selected file: {file_name} ({file_size} bytes)" + Style.RESET_ALL)
213
- return file_name
215
+ if not quiet:
216
+ click.echo(Fore.BLUE + f"Selected file: {file_name} ({file_size} bytes)" + Style.RESET_ALL)
217
+ return file_name, file_size
214
218
 
215
219
  # If all files are skipped, use the first non-empty file anyway
216
220
  selected_file = non_empty_files[0]
217
- click.echo(Fore.YELLOW + f"Only found metadata files, using: {selected_file[0]} ({selected_file[1]} bytes)" + Style.RESET_ALL)
218
- return selected_file[0]
221
+ if not quiet:
222
+ click.echo(Fore.YELLOW + f"Only found metadata files, using: {selected_file[0]} ({selected_file[1]} bytes)" + Style.RESET_ALL)
223
+ return selected_file[0], selected_file[1]
219
224
 
220
225
 
221
226
  def detect_format_from_path(path: str) -> str:
@@ -838,8 +843,8 @@ def main(path, output_format, input_format, columns, num_rows, offset, where, sc
838
843
  # Parse the path
839
844
  service, bucket, object_path = parse_cloud_path(path)
840
845
 
841
- # Check if path is a directory (ends with '/')
842
- is_directory = object_path.endswith('/')
846
+ # Check if path is a directory (ends with '/' or is empty = bucket root)
847
+ is_directory = object_path.endswith('/') or object_path == ''
843
848
 
844
849
  # Initialize streaming stats
845
850
  streaming_stats = None
@@ -850,51 +855,83 @@ def main(path, output_format, input_format, columns, num_rows, offset, where, sc
850
855
  if multi_file_mode == 'first' or (multi_file_mode == 'auto' and max_size_mb <= 0):
851
856
  # Use a single file
852
857
  start_progress("Listing files...")
853
- object_path = find_first_non_empty_file(service, bucket, object_path, input_format)
858
+
859
+ # Find first non-empty file (quiet during progress)
860
+ object_path, file_size = find_first_non_empty_file(service, bucket, object_path, input_format, quiet=True)
861
+ stop_progress()
862
+
863
+ # Show file selection info
864
+ click.echo(Fore.BLUE + f"Selected file: {object_path} ({file_size} bytes)" + Style.RESET_ALL)
854
865
 
855
866
  # Determine input format if not specified
856
867
  if not input_format:
857
868
  input_format = detect_format_from_path(object_path)
869
+ click.echo(Fore.BLUE + f"Inferred input format: {input_format}" + Style.RESET_ALL)
858
870
 
859
871
  # Get file name for display
860
872
  file_name = object_path.split('/')[-1]
861
- update_progress(f"Reading {file_name}...")
873
+ start_progress(f"Reading {file_name}...")
862
874
 
863
875
  # Read the data from the single file with streaming
864
876
  df, full_schema, streaming_stats = read_data_streaming(service, bucket, object_path, input_format, num_rows, columns, delimiter, offset)
865
877
  total_record_count = None # Will be computed later if needed
866
878
 
867
- # Stop progress and show format info
879
+ # Stop progress
868
880
  stop_progress()
869
- click.echo(Fore.BLUE + f"Inferred input format: {input_format}" + Style.RESET_ALL)
870
881
  else:
871
882
  # Read from multiple files
872
883
  start_progress("Listing files...")
873
884
 
874
- # Determine input format if not specified (use the first file to infer)
885
+ # Get files to read for preview (limited by max_size_mb)
886
+ # First, determine input format if not specified (use the first file to infer)
875
887
  if not input_format:
876
- first_file = find_first_non_empty_file(service, bucket, object_path)
888
+ stop_progress()
889
+ first_file, _ = find_first_non_empty_file(service, bucket, object_path, quiet=True)
877
890
  input_format = detect_format_from_path(first_file)
891
+ start_progress(f"Selecting {input_format} files...")
892
+ else:
893
+ update_progress(f"Selecting {input_format} files...")
878
894
 
879
- update_progress(f"Selecting {input_format} files...")
880
-
881
- # Get files to read for preview (limited by max_size_mb)
882
895
  file_list = get_files_for_multiread(service, bucket, object_path, input_format, max_size_mb, quiet=True)
883
896
 
884
- # Read data from multiple files with progress updates
885
- update_progress(f"Reading {len(file_list)} files...")
886
- df, full_schema, rows_in_files = read_data_from_multiple_files(
887
- service, bucket, file_list, input_format, num_rows, columns, delimiter, offset, quiet=True
888
- )
897
+ # For a single file, use streaming read for efficiency
898
+ if len(file_list) == 1:
899
+ single_file_path = file_list[0][0]
900
+ file_name = single_file_path.split('/')[-1]
901
+ update_progress(f"Reading {file_name}...")
889
902
 
890
- # Calculate total size for stats
891
- total_size = sum(f[1] for f in file_list)
892
- streaming_stats = StreamingStats(file_size=total_size, bytes_read=total_size, format_type=input_format)
903
+ # Use streaming read for all formats
904
+ df, full_schema, streaming_stats = read_data_streaming(
905
+ service, bucket, single_file_path, input_format, num_rows, columns, delimiter, offset
906
+ )
907
+ stop_progress()
908
+
909
+ click.echo(Fore.BLUE + f"Inferred input format: {input_format}" + Style.RESET_ALL)
910
+ multi_file_list = file_list
911
+ total_record_count = None
912
+ elif len(file_list) > 1:
913
+ # Read data from multiple files with progress updates
914
+ update_progress(f"Reading {len(file_list)} files...")
915
+ df, full_schema, rows_in_files = read_data_from_multiple_files(
916
+ service, bucket, file_list, input_format, num_rows, columns, delimiter, offset, quiet=True
917
+ )
918
+
919
+ # Stop progress before any output
920
+ stop_progress()
921
+
922
+ # Calculate total size for stats
923
+ total_size = sum(f[1] for f in file_list)
924
+ streaming_stats = StreamingStats(file_size=total_size, bytes_read=total_size, format_type=input_format)
925
+
926
+ click.echo(Fore.BLUE + f"Inferred input format: {input_format}" + Style.RESET_ALL)
927
+
928
+ # total_record_count will be computed later if --count is specified
929
+ total_record_count = None
930
+ multi_file_list = file_list
893
931
 
894
932
  # For --count, get ALL files (not limited by max_size_mb)
895
933
  # so we can count records across the entire directory
896
934
  if count:
897
- stop_progress()
898
935
  all_files = get_files_for_multiread(service, bucket, object_path, input_format, max_size_mb=999999, quiet=True)
899
936
  all_files_size = sum(f[1] for f in all_files)
900
937
  all_files_size_mb = all_files_size / (1024 * 1024)
@@ -909,15 +946,6 @@ def main(path, output_format, input_format, columns, num_rows, offset, where, sc
909
946
  multi_file_list = all_files
910
947
  # Update stats to reflect all files
911
948
  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
-
919
- # total_record_count will be computed later if --count is specified
920
- total_record_count = None
921
949
 
922
950
  # Update object_path for display/logging purposes
923
951
  num_files_display = len(multi_file_list) if count else len(file_list)
@@ -1008,6 +1036,7 @@ def main(path, output_format, input_format, columns, num_rows, offset, where, sc
1008
1036
  click.echo(Fore.BLUE + f"\n{streaming_stats.format_report()}" + Style.RESET_ALL)
1009
1037
 
1010
1038
  except Exception as e:
1039
+ stop_progress() # Make sure progress is stopped on error
1011
1040
  click.echo(Fore.RED + f"Error: {str(e)}" + Style.RESET_ALL, err=True)
1012
1041
  sys.exit(1)
1013
1042
 
@@ -46,9 +46,10 @@ class ProgressIndicator:
46
46
  self._thread.join(timeout=0.5)
47
47
  self._thread = None
48
48
 
49
- # Clear the current line
50
- sys.stderr.write('\r' + ' ' * 80 + '\r')
51
- sys.stderr.flush()
49
+ # Clear the current line - use stdout since that's where output goes
50
+ # Use a wider clear to handle wide terminals
51
+ sys.stdout.write('\r' + ' ' * 120 + '\r')
52
+ sys.stdout.flush()
52
53
 
53
54
  # Show final message if provided
54
55
  if final_message:
@@ -66,11 +67,12 @@ class ProgressIndicator:
66
67
  self._frame_index = (self._frame_index + 1) % len(self.SPINNER_FRAMES)
67
68
 
68
69
  # Write spinner and message, overwriting the line
70
+ # Use stdout to match the rest of the CLI output
69
71
  output = f'\r{Fore.CYAN}{frame}{Style.RESET_ALL} {message}'
70
72
  # Pad to clear any leftover characters from longer previous messages
71
- output = output.ljust(80)
72
- sys.stderr.write(output)
73
- sys.stderr.flush()
73
+ output = output.ljust(120)
74
+ sys.stdout.write(output)
75
+ sys.stdout.flush()
74
76
 
75
77
  time.sleep(0.08)
76
78
 
@@ -12,10 +12,21 @@ from ..config import cloud_config
12
12
  # Try to import Azure Data Lake client
13
13
  try:
14
14
  from azure.storage.filedatalake import DataLakeServiceClient
15
- HAS_AZURE = True
15
+ HAS_AZURE_DATALAKE = True
16
16
  except ImportError:
17
17
  DataLakeServiceClient = None
18
- HAS_AZURE = False
18
+ HAS_AZURE_DATALAKE = False
19
+
20
+ # Try to import Azure Blob client (fallback for non-HNS accounts)
21
+ try:
22
+ from azure.storage.blob import BlobServiceClient
23
+ HAS_AZURE_BLOB = True
24
+ except ImportError:
25
+ BlobServiceClient = None
26
+ HAS_AZURE_BLOB = False
27
+
28
+ # Combined availability check
29
+ HAS_AZURE = HAS_AZURE_DATALAKE or HAS_AZURE_BLOB
19
30
 
20
31
 
21
32
  def get_azure_datalake_service_client():
@@ -32,7 +43,7 @@ def get_azure_datalake_service_client():
32
43
  SystemExit: If azure-storage-file-datalake is not installed.
33
44
  ValueError: If Azure credentials are not configured.
34
45
  """
35
- if not HAS_AZURE:
46
+ if not HAS_AZURE_DATALAKE:
36
47
  sys.stderr.write(
37
48
  Fore.RED + "Error: azure-storage-file-datalake package is required for Azure access.\n" +
38
49
  "Install it with: pip install azure-storage-file-datalake\n" + Style.RESET_ALL
@@ -62,6 +73,39 @@ def get_azure_datalake_service_client():
62
73
  return DataLakeServiceClient(account_url=account_url, credential=credential)
63
74
 
64
75
 
76
+ def _get_blob_service_client():
77
+ """Get an Azure BlobServiceClient for fallback operations.
78
+
79
+ Used when DataLake API is not available or fails (e.g., non-HNS accounts).
80
+
81
+ Returns:
82
+ azure.storage.blob.BlobServiceClient instance.
83
+ """
84
+ if not HAS_AZURE_BLOB:
85
+ sys.stderr.write(
86
+ Fore.RED + "Error: azure-storage-blob package is required for Azure access.\n" +
87
+ "Install it with: pip install azure-storage-blob\n" + Style.RESET_ALL
88
+ )
89
+ sys.exit(1)
90
+
91
+ account_name = cloud_config.azure_account
92
+ if not account_name:
93
+ raise ValueError(
94
+ "Azure storage account not found. Use abfss:// URL format: "
95
+ "abfss://container@account.dfs.core.windows.net/path"
96
+ )
97
+
98
+ account_url = f"https://{account_name}.blob.core.windows.net"
99
+ access_key = cloud_config.azure_access_key or os.environ.get('AZURE_STORAGE_ACCESS_KEY')
100
+
101
+ if access_key:
102
+ return BlobServiceClient(account_url=account_url, credential=access_key)
103
+ else:
104
+ from azure.identity import DefaultAzureCredential
105
+ credential = DefaultAzureCredential()
106
+ return BlobServiceClient(account_url=account_url, credential=credential)
107
+
108
+
65
109
  # Keep old function name as alias for backwards compatibility
66
110
  get_azure_blob_service_client = get_azure_datalake_service_client
67
111
 
@@ -106,16 +150,8 @@ def get_azure_file_size(container_name: str, file_path: str) -> int:
106
150
  return properties.size
107
151
 
108
152
 
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
- """
153
+ def _list_azure_directory_datalake(container_name: str, prefix: str) -> List[Tuple[str, int]]:
154
+ """List files using Azure Data Lake Storage API (requires HNS enabled)."""
119
155
  datalake_service_client = get_azure_datalake_service_client()
120
156
  file_system_client = datalake_service_client.get_file_system_client(file_system=container_name)
121
157
 
@@ -125,9 +161,68 @@ def list_azure_directory(container_name: str, prefix: str) -> List[Tuple[str, in
125
161
 
126
162
  # List paths with the prefix
127
163
  file_list = []
128
- paths = file_system_client.get_paths(path=prefix.rstrip('/'))
164
+ paths = file_system_client.get_paths(path=prefix.rstrip('/') if prefix else None)
129
165
  for path in paths:
130
166
  if not path.is_directory:
131
167
  file_list.append((path.name, path.content_length))
132
168
 
133
169
  return file_list
170
+
171
+
172
+ def _list_azure_directory_blob(container_name: str, prefix: str) -> List[Tuple[str, int]]:
173
+ """List files using Azure Blob Storage API (works with any storage account)."""
174
+ blob_service_client = _get_blob_service_client()
175
+ container_client = blob_service_client.get_container_client(container_name)
176
+
177
+ # Ensure prefix ends with / to indicate a directory (if not empty)
178
+ if prefix and not prefix.endswith('/'):
179
+ prefix = prefix + '/'
180
+
181
+ # List blobs with the prefix
182
+ file_list = []
183
+ blobs = container_client.list_blobs(name_starts_with=prefix if prefix else None)
184
+ for blob in blobs:
185
+ # Skip "directory" blobs (size 0, name ends with /)
186
+ if blob.size > 0 and not blob.name.endswith('/'):
187
+ file_list.append((blob.name, blob.size))
188
+
189
+ return file_list
190
+
191
+
192
+ def list_azure_directory(container_name: str, prefix: str) -> List[Tuple[str, int]]:
193
+ """List files in an Azure storage directory.
194
+
195
+ Tries Data Lake API first (for HNS-enabled accounts), then falls back
196
+ to Blob Storage API (works with any storage account).
197
+
198
+ Args:
199
+ container_name: Azure filesystem (container) name.
200
+ prefix: Directory prefix.
201
+
202
+ Returns:
203
+ List of (filename, size) tuples.
204
+ """
205
+ # Try Data Lake API first (better performance for HNS accounts)
206
+ if HAS_AZURE_DATALAKE:
207
+ try:
208
+ return _list_azure_directory_datalake(container_name, prefix)
209
+ except Exception as e:
210
+ # Check if it's an endpoint/feature incompatibility error
211
+ error_str = str(e)
212
+ if 'EndpointUnsupportedAccountFeatures' in error_str or 'BlobStorageEvents' in error_str:
213
+ # Fall back to Blob API
214
+ if HAS_AZURE_BLOB:
215
+ return _list_azure_directory_blob(container_name, prefix)
216
+ # Re-raise other errors
217
+ raise
218
+
219
+ # Fall back to Blob API if Data Lake not available
220
+ if HAS_AZURE_BLOB:
221
+ return _list_azure_directory_blob(container_name, prefix)
222
+
223
+ # Neither API available
224
+ sys.stderr.write(
225
+ Fore.RED + "Error: azure-storage-file-datalake or azure-storage-blob package is required.\n" +
226
+ "Install with: pip install azure-storage-file-datalake\n" + Style.RESET_ALL
227
+ )
228
+ sys.exit(1)
@@ -48,7 +48,7 @@ def parse_cloud_path(path: str) -> Tuple[str, str, str]:
48
48
  bucket = netloc
49
49
  object_path = parsed.path.lstrip('/')
50
50
  else:
51
- raise ValueError(f"Unsupported scheme: {parsed.scheme}. Use gcs://, s3://, az://, or abfss://")
51
+ raise ValueError(f"Unsupported scheme: {parsed.scheme}. Use gcs://, s3://, or abfss://")
52
52
 
53
53
  return service, bucket, object_path
54
54
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudcat
3
- Version: 0.3.5
3
+ Version: 0.3.7
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
  Home-page: https://github.com/jonathansudhakar1/cloudcat
6
6
  Author: Jonathan Sudhakar
@@ -187,7 +187,7 @@ cloudcat -p gcs://my-bucket/data.csv
187
187
  cloudcat -p s3://my-bucket/analytics/events.parquet
188
188
 
189
189
  # Preview JSON data from Azure with pretty formatting
190
- cloudcat -p az://my-container/logs.json -o jsonp
190
+ cloudcat -p abfss://my-container@account.dfs.core.windows.net/logs.json -o jsonp
191
191
 
192
192
  # Read Avro files from Kafka
193
193
  cloudcat -p s3://my-bucket/kafka-export.avro
@@ -196,7 +196,7 @@ cloudcat -p s3://my-bucket/kafka-export.avro
196
196
  cloudcat -p gcs://my-bucket/hive-table.orc
197
197
 
198
198
  # Read log files as plain text
199
- cloudcat -p az://logs/app.log -i text
199
+ cloudcat -p abfss://logs@account.dfs.core.windows.net/app.log -i text
200
200
 
201
201
  # Read from a Spark output directory
202
202
  cloudcat -p s3://my-bucket/spark-output/ -i parquet
@@ -219,7 +219,6 @@ cloudcat -p gcs://bucket/data.csv --offset 100 -n 10
219
219
  |----------|------------|--------|
220
220
  | Google Cloud Storage | `gcs://` or `gs://` | ✅ Supported |
221
221
  | Amazon S3 | `s3://` | ✅ Supported |
222
- | Azure Blob Storage | `az://` or `azure://` | ✅ Supported |
223
222
  | Azure Data Lake Gen2 | `abfss://` | ✅ Supported |
224
223
 
225
224
  ### File Format Support
@@ -338,7 +337,7 @@ cloudcat -p s3://bucket/logs.csv --where "level=ERROR"
338
337
  # String matching filters
339
338
  cloudcat -p gcs://bucket/data.csv --where "name contains john"
340
339
  cloudcat -p s3://bucket/emails.json --where "email endswith @gmail.com"
341
- cloudcat -p az://logs/app.log --where "message startswith ERROR"
340
+ cloudcat -p abfss://logs@account.dfs.core.windows.net/app.log --where "message startswith ERROR"
342
341
 
343
342
  # Skip first N rows (pagination)
344
343
  cloudcat -p gcs://bucket/data.csv --offset 100 -n 10
@@ -361,7 +360,7 @@ cloudcat -p gcs://bucket/events.parquet.zst
361
360
  cloudcat -p s3://bucket/data.csv.lz4
362
361
 
363
362
  # Bzip2 compressed (built-in)
364
- cloudcat -p az://container/archive.json.bz2
363
+ cloudcat -p abfss://container@account.dfs.core.windows.net/archive.json.bz2
365
364
  ```
366
365
 
367
366
  ### Directory Operations
@@ -482,7 +481,7 @@ Usage: cloudcat [OPTIONS]
482
481
  Options:
483
482
  -p, --path TEXT Cloud storage path (required)
484
483
  Format: gcs://bucket/path, s3://bucket/path,
485
- or az://container/path
484
+ or abfss://container@account.dfs.core.windows.net/path
486
485
 
487
486
  -o, --output-format TEXT Output format: table, json, jsonp, csv
488
487
  [default: table]
@@ -582,23 +581,24 @@ cloudcat -p s3://bucket/data.csv --profile production
582
581
  # Automatically detected
583
582
  ```
584
583
 
585
- ### Azure Blob Storage
584
+ ### Azure Data Lake Storage Gen2
586
585
 
587
- CloudCat supports multiple authentication methods for Azure:
586
+ CloudCat supports multiple authentication methods for Azure ADLS Gen2:
588
587
 
589
588
  ```bash
590
- # Option 1: Connection string (simplest)
591
- export AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net"
589
+ # Option 1: Access key (simplest)
590
+ cloudcat -p abfss://container@account.dfs.core.windows.net/data.csv --az-access-key "YOUR_KEY"
592
591
 
593
- # Option 2: Account URL with DefaultAzureCredential (for Azure AD auth)
594
- export AZURE_STORAGE_ACCOUNT_URL="https://youraccount.blob.core.windows.net"
595
- az login
592
+ # Option 2: Access key via environment variable
593
+ export AZURE_STORAGE_ACCESS_KEY="YOUR_KEY"
594
+ cloudcat -p abfss://container@account.dfs.core.windows.net/data.csv
596
595
 
597
- # Option 3: Specify storage account via CLI option
598
- cloudcat -p az://container/data.csv --account mystorageaccount
596
+ # Option 3: Azure CLI with DefaultAzureCredential
597
+ az login
598
+ cloudcat -p abfss://container@account.dfs.core.windows.net/data.csv
599
599
  ```
600
600
 
601
- **Path format:** `az://container-name/path/to/blob`
601
+ **Path format:** `abfss://container@account.dfs.core.windows.net/path/to/file`
602
602
 
603
603
  ## Performance Tips
604
604
 
@@ -164,7 +164,7 @@ class TestCLI:
164
164
  @patch('cloudcat.cli.parse_cloud_path')
165
165
  def test_directory_path_first_mode(self, mock_parse, mock_read, mock_find):
166
166
  mock_parse.return_value = ("gcs", "bucket", "folder/")
167
- mock_find.return_value = "folder/data.csv"
167
+ mock_find.return_value = ("folder/data.csv", 1024) # Now returns tuple (path, size)
168
168
  mock_df = pd.DataFrame({"name": ["John"], "age": [25]})
169
169
  mock_read.return_value = (mock_df, mock_df.dtypes, StreamingStats())
170
170
 
@@ -182,7 +182,7 @@ class TestCLI:
182
182
  @patch('cloudcat.cli.find_first_non_empty_file')
183
183
  def test_directory_path_all_mode(self, mock_find, mock_parse, mock_read_multi, mock_get_files):
184
184
  mock_parse.return_value = ("s3", "bucket", "folder/")
185
- mock_find.return_value = "folder/data.csv"
185
+ mock_find.return_value = ("folder/data.csv", 1024) # Now returns tuple (path, size)
186
186
  mock_get_files.return_value = [("file1.csv", 1024), ("file2.csv", 2048)]
187
187
  mock_df = pd.DataFrame({"name": ["John"], "age": [25]})
188
188
  mock_read_multi.return_value = (mock_df, mock_df.dtypes, 100)
@@ -96,9 +96,10 @@ class TestFileFiltering:
96
96
  ("data2.csv", 2048)
97
97
  ]
98
98
 
99
- first_file = find_first_non_empty_file("gcs", "bucket", "prefix")
99
+ file_path, file_size = find_first_non_empty_file("gcs", "bucket", "prefix", quiet=True)
100
100
 
101
- assert first_file == "data1.csv"
101
+ assert file_path == "data1.csv"
102
+ assert file_size == 1024
102
103
 
103
104
  @patch('cloudcat.cli.list_directory')
104
105
  def test_find_first_non_empty_file_with_format_filter(self, mock_list_dir):
@@ -108,9 +109,10 @@ class TestFileFiltering:
108
109
  ("data3.parquet", 4096)
109
110
  ]
110
111
 
111
- first_file = find_first_non_empty_file("gcs", "bucket", "prefix", input_format="json")
112
+ file_path, file_size = find_first_non_empty_file("gcs", "bucket", "prefix", input_format="json", quiet=True)
112
113
 
113
- assert first_file == "data2.json"
114
+ assert file_path == "data2.json"
115
+ assert file_size == 2048
114
116
 
115
117
  @patch('cloudcat.cli.list_directory')
116
118
  def test_find_first_non_empty_file_azure(self, mock_list_dir):
@@ -119,9 +121,10 @@ class TestFileFiltering:
119
121
  ("data.csv", 1024)
120
122
  ]
121
123
 
122
- first_file = find_first_non_empty_file("azure", "container", "prefix")
124
+ file_path, file_size = find_first_non_empty_file("azure", "container", "prefix", quiet=True)
123
125
 
124
- assert first_file == "data.csv"
126
+ assert file_path == "data.csv"
127
+ assert file_size == 1024
125
128
 
126
129
  @patch('cloudcat.cli.list_directory')
127
130
  def test_get_files_for_multiread_no_files_raises_error(self, mock_list_dir):
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