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.
- {cloudcat-0.3.5 → cloudcat-0.3.7}/PKG-INFO +17 -17
- {cloudcat-0.3.5 → cloudcat-0.3.7}/README.md +16 -16
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/__init__.py +1 -1
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/cli.py +67 -38
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/progress.py +8 -6
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/storage/azure.py +109 -14
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/storage/base.py +1 -1
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat.egg-info/PKG-INFO +17 -17
- {cloudcat-0.3.5 → cloudcat-0.3.7}/tests/test_cli.py +2 -2
- {cloudcat-0.3.5 → cloudcat-0.3.7}/tests/test_file_filtering.py +9 -6
- {cloudcat-0.3.5 → cloudcat-0.3.7}/LICENSE +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/compression.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/config.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/filtering.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/formatters.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/readers/__init__.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/readers/avro.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/readers/csv.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/readers/json.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/readers/orc.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/readers/parquet.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/readers/text.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/storage/__init__.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/storage/gcs.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/storage/s3.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/streaming/__init__.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/streaming/filesystems.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/streaming/stats.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat/streaming/tracking.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat.egg-info/SOURCES.txt +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat.egg-info/dependency_links.txt +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat.egg-info/entry_points.txt +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat.egg-info/requires.txt +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/cloudcat.egg-info/top_level.txt +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/pyproject.toml +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/setup.cfg +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/setup.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/tests/__init__.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/tests/conftest.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/tests/test_compression.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/tests/test_config.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/tests/test_data_reading.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/tests/test_filtering.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/tests/test_format_detection.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/tests/test_integration.py +0 -0
- {cloudcat-0.3.5 → cloudcat-0.3.7}/tests/test_output_formatting.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
591
|
-
|
|
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:
|
|
594
|
-
export
|
|
595
|
-
|
|
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:
|
|
598
|
-
|
|
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:** `
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
513
|
-
|
|
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:
|
|
516
|
-
export
|
|
517
|
-
|
|
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:
|
|
520
|
-
|
|
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:** `
|
|
523
|
+
**Path format:** `abfss://container@account.dfs.core.windows.net/path/to/file`
|
|
524
524
|
|
|
525
525
|
## Performance Tips
|
|
526
526
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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
|
-
|
|
51
|
-
sys.
|
|
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(
|
|
72
|
-
sys.
|
|
73
|
-
sys.
|
|
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
|
-
|
|
15
|
+
HAS_AZURE_DATALAKE = True
|
|
16
16
|
except ImportError:
|
|
17
17
|
DataLakeServiceClient = None
|
|
18
|
-
|
|
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
|
|
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
|
|
110
|
-
"""List files
|
|
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://,
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
591
|
-
|
|
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:
|
|
594
|
-
export
|
|
595
|
-
|
|
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:
|
|
598
|
-
|
|
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:** `
|
|
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
|
-
|
|
99
|
+
file_path, file_size = find_first_non_empty_file("gcs", "bucket", "prefix", quiet=True)
|
|
100
100
|
|
|
101
|
-
assert
|
|
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
|
-
|
|
112
|
+
file_path, file_size = find_first_non_empty_file("gcs", "bucket", "prefix", input_format="json", quiet=True)
|
|
112
113
|
|
|
113
|
-
assert
|
|
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
|
-
|
|
124
|
+
file_path, file_size = find_first_non_empty_file("azure", "container", "prefix", quiet=True)
|
|
123
125
|
|
|
124
|
-
assert
|
|
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
|
|
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
|