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.
Files changed (48) hide show
  1. {cloudcat-0.3.1 → cloudcat-0.3.3}/PKG-INFO +4 -4
  2. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/__init__.py +1 -1
  3. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/cli.py +94 -37
  4. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/config.py +3 -1
  5. cloudcat-0.3.3/cloudcat/progress.py +102 -0
  6. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/storage/__init__.py +3 -2
  7. cloudcat-0.3.3/cloudcat/storage/azure.py +133 -0
  8. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/storage/base.py +1 -1
  9. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/streaming/filesystems.py +13 -14
  10. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat.egg-info/PKG-INFO +4 -4
  11. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat.egg-info/SOURCES.txt +1 -0
  12. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat.egg-info/requires.txt +1 -1
  13. {cloudcat-0.3.1 → cloudcat-0.3.3}/setup.py +3 -3
  14. cloudcat-0.3.1/cloudcat/storage/azure.py +0 -125
  15. {cloudcat-0.3.1 → cloudcat-0.3.3}/LICENSE +0 -0
  16. {cloudcat-0.3.1 → cloudcat-0.3.3}/README.md +0 -0
  17. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/compression.py +0 -0
  18. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/filtering.py +0 -0
  19. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/formatters.py +0 -0
  20. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/readers/__init__.py +0 -0
  21. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/readers/avro.py +0 -0
  22. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/readers/csv.py +0 -0
  23. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/readers/json.py +0 -0
  24. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/readers/orc.py +0 -0
  25. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/readers/parquet.py +0 -0
  26. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/readers/text.py +0 -0
  27. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/storage/gcs.py +0 -0
  28. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/storage/s3.py +0 -0
  29. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/streaming/__init__.py +0 -0
  30. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/streaming/stats.py +0 -0
  31. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat/streaming/tracking.py +0 -0
  32. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat.egg-info/dependency_links.txt +0 -0
  33. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat.egg-info/entry_points.txt +0 -0
  34. {cloudcat-0.3.1 → cloudcat-0.3.3}/cloudcat.egg-info/top_level.txt +0 -0
  35. {cloudcat-0.3.1 → cloudcat-0.3.3}/pyproject.toml +0 -0
  36. {cloudcat-0.3.1 → cloudcat-0.3.3}/setup.cfg +0 -0
  37. {cloudcat-0.3.1 → cloudcat-0.3.3}/tests/__init__.py +0 -0
  38. {cloudcat-0.3.1 → cloudcat-0.3.3}/tests/conftest.py +0 -0
  39. {cloudcat-0.3.1 → cloudcat-0.3.3}/tests/test_cli.py +0 -0
  40. {cloudcat-0.3.1 → cloudcat-0.3.3}/tests/test_compression.py +0 -0
  41. {cloudcat-0.3.1 → cloudcat-0.3.3}/tests/test_config.py +0 -0
  42. {cloudcat-0.3.1 → cloudcat-0.3.3}/tests/test_data_reading.py +0 -0
  43. {cloudcat-0.3.1 → cloudcat-0.3.3}/tests/test_file_filtering.py +0 -0
  44. {cloudcat-0.3.1 → cloudcat-0.3.3}/tests/test_filtering.py +0 -0
  45. {cloudcat-0.3.1 → cloudcat-0.3.3}/tests/test_format_detection.py +0 -0
  46. {cloudcat-0.3.1 → cloudcat-0.3.3}/tests/test_integration.py +0 -0
  47. {cloudcat-0.3.1 → cloudcat-0.3.3}/tests/test_output_formatting.py +0 -0
  48. {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.1
4
- Summary: Preview and analyze data files in Google Cloud Storage, AWS S3, and Azure Blob Storage from your terminal
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,azure-blob,data-engineering,etl,spark,bigquery,databricks,kafka,hive
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-blob>=12.0.0
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"
@@ -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.1"
5
+ __version__ = "0.3.3"
@@ -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
- total_mb = total_size / (1024 * 1024)
151
- click.echo(Fore.BLUE + f"Reading {len(selected_files)} files totaling {total_mb:.2f} MB" + Style.RESET_ALL)
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
- click.echo(Fore.BLUE + f"Reading file: {file_name} ({file_size/1024:.1f} KB)" + Style.RESET_ALL)
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
- click.echo(Fore.BLUE + f"Detected {compression} compression, decompressing..." + Style.RESET_ALL)
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 az://)')
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('--account', help='Azure storage account name')
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, account):
732
- """Display data from files in Google Cloud Storage, AWS S3, or Azure Blob Storage.
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 Blob Storage
749
- cloudcat --path az://my-container/data.json --output-format jsonp
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 az://logs/app.log --input-format text
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 specific Azure storage account
804
- cloudcat --path az://container/data.csv --account mystorageaccount
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 account:
815
- cloud_config.azure_account = account
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
- click.echo(Fore.BLUE + f"Looking for first suitable file..." + Style.RESET_ALL)
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
- click.echo(Fore.BLUE + f"Inferred input format: {input_format}" + Style.RESET_ALL)
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
- click.echo(Fore.BLUE + f"Reading multiple files (up to {max_size_mb}MB)..." + Style.RESET_ALL)
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
- # Get files to read
859
- file_list = get_files_for_multiread(service, bucket, object_path, input_format, max_size_mb)
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
- # Store file_list for --count to use later
871
- multi_file_list = file_list
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
- object_path = f"{object_path} ({len(file_list)} files)"
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
- 'get_azure_blob_service_client',
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://, az://, or abfss://).
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
- else:
160
- # Try to get from environment
161
- account_url = os.environ.get('AZURE_STORAGE_ACCOUNT_URL')
162
- if account_url:
163
- # Extract account name from URL
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.1
4
- Summary: Preview and analyze data files in Google Cloud Storage, AWS S3, and Azure Blob Storage from your terminal
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,azure-blob,data-engineering,etl,spark,bigquery,databricks,kafka,hive
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-blob>=12.0.0
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"
@@ -8,6 +8,7 @@ cloudcat/compression.py
8
8
  cloudcat/config.py
9
9
  cloudcat/filtering.py
10
10
  cloudcat/formatters.py
11
+ cloudcat/progress.py
11
12
  cloudcat.egg-info/PKG-INFO
12
13
  cloudcat.egg-info/SOURCES.txt
13
14
  cloudcat.egg-info/dependency_links.txt
@@ -4,7 +4,7 @@ tabulate>=0.8.9
4
4
  colorama>=0.4.4
5
5
  google-cloud-storage>=2.0.0
6
6
  boto3>=1.18.0
7
- azure-storage-blob>=12.0.0
7
+ azure-storage-file-datalake>=12.0.0
8
8
  azure-identity>=1.0.0
9
9
 
10
10
  [all]
@@ -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-blob>=12.0.0",
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 Blob Storage from your terminal",
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, azure-blob, data-engineering, etl, spark, bigquery, databricks, kafka, hive",
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