cloud-files 6.2.1__tar.gz → 6.3.0__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.
- {cloud_files-6.2.1 → cloud_files-6.3.0}/ChangeLog +11 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/PKG-INFO +43 -2
- {cloud_files-6.2.1 → cloud_files-6.3.0}/README.md +29 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloud_files.egg-info/PKG-INFO +43 -2
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloud_files.egg-info/SOURCES.txt +2 -1
- cloud_files-6.3.0/cloud_files.egg-info/pbr.json +1 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/cloudfiles.py +13 -2
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/interfaces.py +162 -36
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles_cli/cloudfiles_cli.py +50 -29
- cloud_files-6.3.0/cloudfiles_cli/listing_db.py +174 -0
- cloud_files-6.2.1/cloud_files.egg-info/pbr.json +0 -1
- {cloud_files-6.2.1 → cloud_files-6.3.0}/.github/workflows/test-suite.yml +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/AUTHORS +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/LICENSE +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/MANIFEST.in +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/automated_test.py +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloud_files.egg-info/dependency_links.txt +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloud_files.egg-info/entry_points.txt +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloud_files.egg-info/not-zip-safe +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloud_files.egg-info/requires.txt +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloud_files.egg-info/top_level.txt +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/__init__.py +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/compression.py +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/connectionpools.py +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/exceptions.py +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/gcs.py +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/lib.py +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/monitoring.py +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/paths.py +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/resumable_tools.py +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/scheduler.py +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/secrets.py +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/threaded_queue.py +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/typing.py +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles_cli/LICENSE +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles_cli/__init__.py +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/requirements.txt +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/setup.cfg +0 -0
- {cloud_files-6.2.1 → cloud_files-6.3.0}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: cloud-files
|
|
3
|
-
Version: 6.
|
|
3
|
+
Version: 6.3.0
|
|
4
4
|
Summary: Fast access to cloud storage and local FS.
|
|
5
5
|
Home-page: https://github.com/seung-lab/cloud-files/
|
|
6
6
|
Author: William Silversmith
|
|
@@ -54,6 +54,18 @@ Requires-Dist: intervaltree; extra == "monitoring"
|
|
|
54
54
|
Requires-Dist: matplotlib; extra == "monitoring"
|
|
55
55
|
Provides-Extra: apache
|
|
56
56
|
Requires-Dist: lxml; extra == "apache"
|
|
57
|
+
Dynamic: author
|
|
58
|
+
Dynamic: author-email
|
|
59
|
+
Dynamic: classifier
|
|
60
|
+
Dynamic: description
|
|
61
|
+
Dynamic: description-content-type
|
|
62
|
+
Dynamic: home-page
|
|
63
|
+
Dynamic: license
|
|
64
|
+
Dynamic: license-file
|
|
65
|
+
Dynamic: provides-extra
|
|
66
|
+
Dynamic: requires-dist
|
|
67
|
+
Dynamic: requires-python
|
|
68
|
+
Dynamic: summary
|
|
57
69
|
|
|
58
70
|
[](https://badge.fury.io/py/cloud-files) [](https://github.com/seung-lab/cloud-files/actions?query=workflow%3A%22Test+Suite%22)
|
|
59
71
|
|
|
@@ -497,6 +509,8 @@ cf.clear_locks()
|
|
|
497
509
|
```bash
|
|
498
510
|
# list cloud and local directories
|
|
499
511
|
cloudfiles ls gs://bucket-folder/
|
|
512
|
+
# resumable list cloud and local directories to sqlite db
|
|
513
|
+
cloudfiles ls --sqlite example.db gs://bucket-folder/
|
|
500
514
|
# parallel file transfer, no decompression
|
|
501
515
|
cloudfiles -p 2 cp --progress -r s3://bkt/ gs://bkt2/
|
|
502
516
|
# change compression type to brotli
|
|
@@ -554,6 +568,33 @@ cloudfiles ls -e "gs://bucket/prefix[ab]"
|
|
|
554
568
|
# cloudfiles ls gs://bucket/prefixb
|
|
555
569
|
```
|
|
556
570
|
|
|
571
|
+
### `ls` sqlite
|
|
572
|
+
|
|
573
|
+
When there is a very large directory in the cloud, sometimes we want to download the file listing and file sizes locally for ease of searching and comparison. This feature allows you to initiate resumable downloads of a given bucket or directory on Google Cloud Storage or Amazone S3 endpoints. The `file`, `mem`, and `https` protocols do not support resumption (except for the Google Storage REST API which does have support).
|
|
574
|
+
|
|
575
|
+
```bash
|
|
576
|
+
cloudfiles ls --sqlite example.db gs://bucket/prefix --progress
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
This will start downloading the data to `example.db` in the local directory. You can then search for files in the `files` table.
|
|
580
|
+
|
|
581
|
+
```sql
|
|
582
|
+
SELECT sum(size) FROM files WHERE path LIKE '%example.jpg';
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
For example, one plausible use for this technique is to check whether a copy of a large dataset has missing files. Download `original.db` and `copy.db` and then you can do a set difference.
|
|
586
|
+
|
|
587
|
+
```sql
|
|
588
|
+
$ sqlite3
|
|
589
|
+
|
|
590
|
+
> ATTACH DATABASE 'original.db' AS original_data;
|
|
591
|
+
> ATTACH DATABASE 'copy.db' AS copied_data;
|
|
592
|
+
|
|
593
|
+
> SELECT path from original_data.files
|
|
594
|
+
EXCEPT
|
|
595
|
+
SELECT path from copied_data.files;
|
|
596
|
+
```
|
|
597
|
+
|
|
557
598
|
### `alias` for Alternative S3 Endpoints
|
|
558
599
|
|
|
559
600
|
You can set your own protocols for S3 compatible endpoints by creating dynamic or persistent aliases. CloudFiles comes with two official s3 endpoints that are important for the Seung Lab, `matrix://` and `tigerdata://` which point to Princeton S3 endpoints. Official aliases can't be overridden.
|
|
@@ -440,6 +440,8 @@ cf.clear_locks()
|
|
|
440
440
|
```bash
|
|
441
441
|
# list cloud and local directories
|
|
442
442
|
cloudfiles ls gs://bucket-folder/
|
|
443
|
+
# resumable list cloud and local directories to sqlite db
|
|
444
|
+
cloudfiles ls --sqlite example.db gs://bucket-folder/
|
|
443
445
|
# parallel file transfer, no decompression
|
|
444
446
|
cloudfiles -p 2 cp --progress -r s3://bkt/ gs://bkt2/
|
|
445
447
|
# change compression type to brotli
|
|
@@ -497,6 +499,33 @@ cloudfiles ls -e "gs://bucket/prefix[ab]"
|
|
|
497
499
|
# cloudfiles ls gs://bucket/prefixb
|
|
498
500
|
```
|
|
499
501
|
|
|
502
|
+
### `ls` sqlite
|
|
503
|
+
|
|
504
|
+
When there is a very large directory in the cloud, sometimes we want to download the file listing and file sizes locally for ease of searching and comparison. This feature allows you to initiate resumable downloads of a given bucket or directory on Google Cloud Storage or Amazone S3 endpoints. The `file`, `mem`, and `https` protocols do not support resumption (except for the Google Storage REST API which does have support).
|
|
505
|
+
|
|
506
|
+
```bash
|
|
507
|
+
cloudfiles ls --sqlite example.db gs://bucket/prefix --progress
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
This will start downloading the data to `example.db` in the local directory. You can then search for files in the `files` table.
|
|
511
|
+
|
|
512
|
+
```sql
|
|
513
|
+
SELECT sum(size) FROM files WHERE path LIKE '%example.jpg';
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
For example, one plausible use for this technique is to check whether a copy of a large dataset has missing files. Download `original.db` and `copy.db` and then you can do a set difference.
|
|
517
|
+
|
|
518
|
+
```sql
|
|
519
|
+
$ sqlite3
|
|
520
|
+
|
|
521
|
+
> ATTACH DATABASE 'original.db' AS original_data;
|
|
522
|
+
> ATTACH DATABASE 'copy.db' AS copied_data;
|
|
523
|
+
|
|
524
|
+
> SELECT path from original_data.files
|
|
525
|
+
EXCEPT
|
|
526
|
+
SELECT path from copied_data.files;
|
|
527
|
+
```
|
|
528
|
+
|
|
500
529
|
### `alias` for Alternative S3 Endpoints
|
|
501
530
|
|
|
502
531
|
You can set your own protocols for S3 compatible endpoints by creating dynamic or persistent aliases. CloudFiles comes with two official s3 endpoints that are important for the Seung Lab, `matrix://` and `tigerdata://` which point to Princeton S3 endpoints. Official aliases can't be overridden.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: cloud-files
|
|
3
|
-
Version: 6.
|
|
3
|
+
Version: 6.3.0
|
|
4
4
|
Summary: Fast access to cloud storage and local FS.
|
|
5
5
|
Home-page: https://github.com/seung-lab/cloud-files/
|
|
6
6
|
Author: William Silversmith
|
|
@@ -54,6 +54,18 @@ Requires-Dist: intervaltree; extra == "monitoring"
|
|
|
54
54
|
Requires-Dist: matplotlib; extra == "monitoring"
|
|
55
55
|
Provides-Extra: apache
|
|
56
56
|
Requires-Dist: lxml; extra == "apache"
|
|
57
|
+
Dynamic: author
|
|
58
|
+
Dynamic: author-email
|
|
59
|
+
Dynamic: classifier
|
|
60
|
+
Dynamic: description
|
|
61
|
+
Dynamic: description-content-type
|
|
62
|
+
Dynamic: home-page
|
|
63
|
+
Dynamic: license
|
|
64
|
+
Dynamic: license-file
|
|
65
|
+
Dynamic: provides-extra
|
|
66
|
+
Dynamic: requires-dist
|
|
67
|
+
Dynamic: requires-python
|
|
68
|
+
Dynamic: summary
|
|
57
69
|
|
|
58
70
|
[](https://badge.fury.io/py/cloud-files) [](https://github.com/seung-lab/cloud-files/actions?query=workflow%3A%22Test+Suite%22)
|
|
59
71
|
|
|
@@ -497,6 +509,8 @@ cf.clear_locks()
|
|
|
497
509
|
```bash
|
|
498
510
|
# list cloud and local directories
|
|
499
511
|
cloudfiles ls gs://bucket-folder/
|
|
512
|
+
# resumable list cloud and local directories to sqlite db
|
|
513
|
+
cloudfiles ls --sqlite example.db gs://bucket-folder/
|
|
500
514
|
# parallel file transfer, no decompression
|
|
501
515
|
cloudfiles -p 2 cp --progress -r s3://bkt/ gs://bkt2/
|
|
502
516
|
# change compression type to brotli
|
|
@@ -554,6 +568,33 @@ cloudfiles ls -e "gs://bucket/prefix[ab]"
|
|
|
554
568
|
# cloudfiles ls gs://bucket/prefixb
|
|
555
569
|
```
|
|
556
570
|
|
|
571
|
+
### `ls` sqlite
|
|
572
|
+
|
|
573
|
+
When there is a very large directory in the cloud, sometimes we want to download the file listing and file sizes locally for ease of searching and comparison. This feature allows you to initiate resumable downloads of a given bucket or directory on Google Cloud Storage or Amazone S3 endpoints. The `file`, `mem`, and `https` protocols do not support resumption (except for the Google Storage REST API which does have support).
|
|
574
|
+
|
|
575
|
+
```bash
|
|
576
|
+
cloudfiles ls --sqlite example.db gs://bucket/prefix --progress
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
This will start downloading the data to `example.db` in the local directory. You can then search for files in the `files` table.
|
|
580
|
+
|
|
581
|
+
```sql
|
|
582
|
+
SELECT sum(size) FROM files WHERE path LIKE '%example.jpg';
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
For example, one plausible use for this technique is to check whether a copy of a large dataset has missing files. Download `original.db` and `copy.db` and then you can do a set difference.
|
|
586
|
+
|
|
587
|
+
```sql
|
|
588
|
+
$ sqlite3
|
|
589
|
+
|
|
590
|
+
> ATTACH DATABASE 'original.db' AS original_data;
|
|
591
|
+
> ATTACH DATABASE 'copy.db' AS copied_data;
|
|
592
|
+
|
|
593
|
+
> SELECT path from original_data.files
|
|
594
|
+
EXCEPT
|
|
595
|
+
SELECT path from copied_data.files;
|
|
596
|
+
```
|
|
597
|
+
|
|
557
598
|
### `alias` for Alternative S3 Endpoints
|
|
558
599
|
|
|
559
600
|
You can set your own protocols for S3 compatible endpoints by creating dynamic or persistent aliases. CloudFiles comes with two official s3 endpoints that are important for the Seung Lab, `matrix://` and `tigerdata://` which point to Princeton S3 endpoints. Official aliases can't be overridden.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"git_version": "182afad", "is_release": true}
|
|
@@ -1138,7 +1138,12 @@ class CloudFiles:
|
|
|
1138
1138
|
# )
|
|
1139
1139
|
|
|
1140
1140
|
def list(
|
|
1141
|
-
self,
|
|
1141
|
+
self,
|
|
1142
|
+
prefix:str = "",
|
|
1143
|
+
flat:bool = False,
|
|
1144
|
+
size:bool = False,
|
|
1145
|
+
return_resume_token:bool = False,
|
|
1146
|
+
resume_token:Optional[str] = None,
|
|
1142
1147
|
) -> Generator[str,None,None]:
|
|
1143
1148
|
"""
|
|
1144
1149
|
List files with the given prefix.
|
|
@@ -1160,7 +1165,13 @@ class CloudFiles:
|
|
|
1160
1165
|
Return: generated sequence of file paths relative to cloudpath
|
|
1161
1166
|
"""
|
|
1162
1167
|
with self._get_connection() as conn:
|
|
1163
|
-
for f in conn.list_files(
|
|
1168
|
+
for f in conn.list_files(
|
|
1169
|
+
prefix=prefix,
|
|
1170
|
+
flat=flat,
|
|
1171
|
+
size=size,
|
|
1172
|
+
resume_token=resume_token,
|
|
1173
|
+
return_resume_token=return_resume_token,
|
|
1174
|
+
):
|
|
1164
1175
|
yield f
|
|
1165
1176
|
|
|
1166
1177
|
def transfer_to(
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
1
3
|
import base64
|
|
2
4
|
import binascii
|
|
3
5
|
from collections import defaultdict, namedtuple
|
|
@@ -30,6 +32,7 @@ from .secrets import (
|
|
|
30
32
|
CLOUD_FILES_LOCK_DIR,
|
|
31
33
|
)
|
|
32
34
|
|
|
35
|
+
NFS_ETAG_REGEXP = re.compile(r'\d+\-$')
|
|
33
36
|
COMPRESSION_EXTENSIONS = ('.gz', '.br', '.zstd','.bz2','.xz')
|
|
34
37
|
GZIP_TYPES = (True, 'gzip', 1)
|
|
35
38
|
|
|
@@ -345,7 +348,14 @@ class FileInterface(StorageInterface):
|
|
|
345
348
|
for path in file_paths:
|
|
346
349
|
self.delete_file(path)
|
|
347
350
|
|
|
348
|
-
def list_files(
|
|
351
|
+
def list_files(
|
|
352
|
+
self,
|
|
353
|
+
prefix:str,
|
|
354
|
+
flat:bool = False,
|
|
355
|
+
size:bool = False,
|
|
356
|
+
return_resume_token:bool = False,
|
|
357
|
+
resume_token:Optional[str] = None,
|
|
358
|
+
):
|
|
349
359
|
"""
|
|
350
360
|
List the files in the layer with the given prefix.
|
|
351
361
|
|
|
@@ -353,7 +363,6 @@ class FileInterface(StorageInterface):
|
|
|
353
363
|
while non-flat means generate all file paths with that
|
|
354
364
|
prefix.
|
|
355
365
|
"""
|
|
356
|
-
|
|
357
366
|
layer_path = self.get_path_to_file("")
|
|
358
367
|
path = os.path.join(layer_path, prefix)
|
|
359
368
|
|
|
@@ -404,8 +413,16 @@ class FileInterface(StorageInterface):
|
|
|
404
413
|
|
|
405
414
|
filenames = list(map(stripext, filenames))
|
|
406
415
|
filenames.sort()
|
|
407
|
-
|
|
408
|
-
|
|
416
|
+
|
|
417
|
+
if not size and not return_resume_token:
|
|
418
|
+
return iter(filenames)
|
|
419
|
+
elif size and return_resume_token:
|
|
420
|
+
return ( (filename, os.path.getsize(filename), None) for filename in filenames )
|
|
421
|
+
elif size and not return_resume_token:
|
|
422
|
+
return ( (filename, os.path.getsize(filename)) for filename in filenames )
|
|
423
|
+
elif not size and return_resume_token:
|
|
424
|
+
return ( (filename, None) for filename in filenames )
|
|
425
|
+
|
|
409
426
|
class MemoryInterface(StorageInterface):
|
|
410
427
|
def __init__(self, path, secrets=None, request_payer=None, **kwargs):
|
|
411
428
|
global MEM_BUCKET_POOL_LOCK
|
|
@@ -588,7 +605,14 @@ class MemoryInterface(StorageInterface):
|
|
|
588
605
|
for path in file_paths:
|
|
589
606
|
self.delete_file(path)
|
|
590
607
|
|
|
591
|
-
def list_files(
|
|
608
|
+
def list_files(
|
|
609
|
+
self,
|
|
610
|
+
prefix:str,
|
|
611
|
+
flat:bool = False,
|
|
612
|
+
size:bool = False,
|
|
613
|
+
return_resume_token:bool = False,
|
|
614
|
+
resume_token:Optional[str] = None,
|
|
615
|
+
):
|
|
592
616
|
"""
|
|
593
617
|
List the files in the layer with the given prefix.
|
|
594
618
|
|
|
@@ -630,7 +654,17 @@ class MemoryInterface(StorageInterface):
|
|
|
630
654
|
|
|
631
655
|
filenames = list(map(stripext, filenames))
|
|
632
656
|
filenames.sort()
|
|
633
|
-
|
|
657
|
+
|
|
658
|
+
# The size operation could be made much faster
|
|
659
|
+
# but will require some surgery
|
|
660
|
+
if not size and not return_resume_token:
|
|
661
|
+
return iter(filenames)
|
|
662
|
+
elif size and return_resume_token:
|
|
663
|
+
return ( (filename, self.size(filename), None) for filename in filenames )
|
|
664
|
+
elif size and not return_resume_token:
|
|
665
|
+
return ( (filename, self.size(filename)) for filename in filenames )
|
|
666
|
+
elif not size and return_resume_token:
|
|
667
|
+
return ( (filename, None) for filename in filenames )
|
|
634
668
|
|
|
635
669
|
def subtree_size(self, prefix:str = "") -> tuple[int,int]:
|
|
636
670
|
layer_path = self.get_path_to_file("")
|
|
@@ -832,9 +866,15 @@ class GoogleCloudStorageInterface(StorageInterface):
|
|
|
832
866
|
except google.cloud.exceptions.NotFound:
|
|
833
867
|
pass
|
|
834
868
|
|
|
835
|
-
|
|
836
869
|
@retry
|
|
837
|
-
def list_files(
|
|
870
|
+
def list_files(
|
|
871
|
+
self,
|
|
872
|
+
prefix:str,
|
|
873
|
+
flat:bool = False,
|
|
874
|
+
size:bool = False,
|
|
875
|
+
return_resume_token:bool = False,
|
|
876
|
+
resume_token:Optional[str] = None,
|
|
877
|
+
):
|
|
838
878
|
"""
|
|
839
879
|
List the files in the layer with the given prefix.
|
|
840
880
|
|
|
@@ -847,29 +887,53 @@ class GoogleCloudStorageInterface(StorageInterface):
|
|
|
847
887
|
|
|
848
888
|
delimiter = '/' if flat else None
|
|
849
889
|
|
|
890
|
+
items = "name"
|
|
891
|
+
if size:
|
|
892
|
+
items += ",size"
|
|
893
|
+
|
|
850
894
|
blobs = self._bucket.list_blobs(
|
|
851
895
|
prefix=path,
|
|
852
896
|
delimiter=delimiter,
|
|
853
897
|
page_size=2500,
|
|
854
|
-
fields="items(
|
|
898
|
+
fields=f"items({items}),nextPageToken,prefixes",
|
|
899
|
+
page_token=resume_token,
|
|
855
900
|
)
|
|
856
901
|
|
|
902
|
+
def return_args(filename, blob, page):
|
|
903
|
+
nonlocal blobs
|
|
904
|
+
args = [ filename ]
|
|
905
|
+
if size:
|
|
906
|
+
args.append(blob.size)
|
|
907
|
+
if return_resume_token:
|
|
908
|
+
args.append(blobs.next_page_token)
|
|
909
|
+
|
|
910
|
+
if len(args) == 1:
|
|
911
|
+
return args[0]
|
|
912
|
+
else:
|
|
913
|
+
return tuple(args)
|
|
914
|
+
|
|
857
915
|
for page in blobs.pages:
|
|
858
916
|
if page.prefixes:
|
|
859
|
-
|
|
860
|
-
item.removeprefix(path)
|
|
861
|
-
|
|
862
|
-
|
|
917
|
+
for item in page.prefixes:
|
|
918
|
+
ret = [ item.removeprefix(path) ]
|
|
919
|
+
if size:
|
|
920
|
+
ret.append(0)
|
|
921
|
+
if return_resume_token:
|
|
922
|
+
ret.append(page.next_page_token)
|
|
923
|
+
|
|
924
|
+
if len(ret) == 1:
|
|
925
|
+
yield ret[0]
|
|
926
|
+
else:
|
|
927
|
+
yield tuple(ret)
|
|
863
928
|
|
|
864
929
|
for blob in page:
|
|
865
930
|
filename = blob.name.removeprefix(layer_path)
|
|
866
931
|
if not filename:
|
|
867
932
|
continue
|
|
868
933
|
elif not flat and filename[-1] != '/':
|
|
869
|
-
yield filename
|
|
934
|
+
yield return_args(filename, blob, page)
|
|
870
935
|
elif flat and '/' not in blob.name.removeprefix(path):
|
|
871
|
-
yield filename
|
|
872
|
-
|
|
936
|
+
yield return_args(filename, blob, page)
|
|
873
937
|
|
|
874
938
|
@retry
|
|
875
939
|
def subtree_size(self, prefix:str = "") -> tuple[int,int]:
|
|
@@ -1025,7 +1089,14 @@ class HttpInterface(StorageInterface):
|
|
|
1025
1089
|
def files_exist(self, file_paths):
|
|
1026
1090
|
return {path: self.exists(path) for path in file_paths}
|
|
1027
1091
|
|
|
1028
|
-
def _list_files_google(
|
|
1092
|
+
def _list_files_google(
|
|
1093
|
+
self,
|
|
1094
|
+
prefix:str,
|
|
1095
|
+
flat:bool = False,
|
|
1096
|
+
size:bool = False,
|
|
1097
|
+
return_resume_token:bool = False,
|
|
1098
|
+
resume_token:Optional[str] = None,
|
|
1099
|
+
):
|
|
1029
1100
|
bucket = self._path.path.split('/')[0]
|
|
1030
1101
|
prefix = posixpath.join(
|
|
1031
1102
|
self._path.path.replace(bucket, '', 1),
|
|
@@ -1036,10 +1107,18 @@ class HttpInterface(StorageInterface):
|
|
|
1036
1107
|
|
|
1037
1108
|
headers = self.default_headers()
|
|
1038
1109
|
|
|
1110
|
+
items = "name"
|
|
1111
|
+
if size:
|
|
1112
|
+
items += ",size"
|
|
1113
|
+
|
|
1114
|
+
fields = f"items({items}),nextPageToken,prefixes"
|
|
1115
|
+
|
|
1039
1116
|
@retry_if_not(AuthorizationError)
|
|
1040
1117
|
def request(token):
|
|
1041
1118
|
nonlocal headers
|
|
1042
|
-
params = {
|
|
1119
|
+
params = {
|
|
1120
|
+
"fields": fields,
|
|
1121
|
+
}
|
|
1043
1122
|
if prefix:
|
|
1044
1123
|
params["prefix"] = prefix
|
|
1045
1124
|
if token is not None:
|
|
@@ -1047,6 +1126,7 @@ class HttpInterface(StorageInterface):
|
|
|
1047
1126
|
if flat:
|
|
1048
1127
|
params["delimiter"] = '/'
|
|
1049
1128
|
|
|
1129
|
+
|
|
1050
1130
|
results = self.session.get(
|
|
1051
1131
|
f"https://storage.googleapis.com/storage/v1/b/{bucket}/o",
|
|
1052
1132
|
params=params,
|
|
@@ -1066,17 +1146,33 @@ class HttpInterface(StorageInterface):
|
|
|
1066
1146
|
token = None
|
|
1067
1147
|
while True:
|
|
1068
1148
|
results = request(token)
|
|
1149
|
+
token = results.get("nextPageToken", None)
|
|
1069
1150
|
|
|
1070
1151
|
if 'prefixes' in results:
|
|
1071
|
-
|
|
1152
|
+
itr = (
|
|
1072
1153
|
item.removeprefix(strip)
|
|
1073
1154
|
for item in results["prefixes"]
|
|
1074
1155
|
)
|
|
1156
|
+
if not size and not return_resume_token:
|
|
1157
|
+
yield from itr
|
|
1158
|
+
elif not size and return_resume_token:
|
|
1159
|
+
yield from ( (pre, token) for pre in itr )
|
|
1160
|
+
elif size and not return_resume_token:
|
|
1161
|
+
yield from ( (pre, 0) for pre in itr )
|
|
1162
|
+
else:
|
|
1163
|
+
yield from ( (pre, 0, token) for pre in itr )
|
|
1075
1164
|
|
|
1076
1165
|
for res in results.get("items", []):
|
|
1077
|
-
|
|
1166
|
+
name = res["name"].removeprefix(strip)
|
|
1167
|
+
if not size and not return_resume_token:
|
|
1168
|
+
yield name
|
|
1169
|
+
elif size and not return_resume_token:
|
|
1170
|
+
yield (name, int(res["size"]))
|
|
1171
|
+
elif not size and return_resume_token:
|
|
1172
|
+
yield (name, token)
|
|
1173
|
+
else:
|
|
1174
|
+
yield (name, int(res["size"]), token)
|
|
1078
1175
|
|
|
1079
|
-
token = results.get("nextPageToken", None)
|
|
1080
1176
|
if token is None:
|
|
1081
1177
|
break
|
|
1082
1178
|
|
|
@@ -1123,11 +1219,21 @@ class HttpInterface(StorageInterface):
|
|
|
1123
1219
|
if flat:
|
|
1124
1220
|
break
|
|
1125
1221
|
|
|
1126
|
-
def list_files(
|
|
1222
|
+
def list_files(
|
|
1223
|
+
self,
|
|
1224
|
+
prefix:str,
|
|
1225
|
+
flat:bool = False,
|
|
1226
|
+
size:bool = False,
|
|
1227
|
+
return_resume_token:bool = False,
|
|
1228
|
+
resume_token:Optional[str] = None,
|
|
1229
|
+
):
|
|
1127
1230
|
if self._path.host == "https://storage.googleapis.com":
|
|
1128
|
-
yield from self._list_files_google(prefix, flat)
|
|
1231
|
+
yield from self._list_files_google(prefix, flat, size, return_resume_token, resume_token)
|
|
1129
1232
|
return
|
|
1130
1233
|
|
|
1234
|
+
if size or resume_token or return_resume_token:
|
|
1235
|
+
raise NotImplementedError("size, resume_token, and return_resume_token are not yet implemented.")
|
|
1236
|
+
|
|
1131
1237
|
url = posixpath.join(self._path.host, self._path.path, prefix)
|
|
1132
1238
|
resp = requests.head(url)
|
|
1133
1239
|
|
|
@@ -1328,9 +1434,9 @@ class S3Interface(StorageInterface):
|
|
|
1328
1434
|
# AWS has special multipart validation
|
|
1329
1435
|
# so we handle it here... leaky abstraction.
|
|
1330
1436
|
if '-' in etag:
|
|
1331
|
-
# Dell ECS S3 uses a synthetic Etag of "1-" for objects
|
|
1332
|
-
# Not good behavior, but nothing much we can do other than ignore it.
|
|
1333
|
-
if etag
|
|
1437
|
+
# Dell ECS S3 uses a synthetic Etag of "1-" (and similar) for objects
|
|
1438
|
+
# ingested via NFS. Not good behavior, but nothing much we can do other than ignore it.
|
|
1439
|
+
if not NFS_ETAG_REGEXP.match(etag) and not validate_s3_multipart_etag(content, etag, part_size):
|
|
1334
1440
|
raise MD5IntegrityError(f"{file_path} failed its multipart md5 check. server md5: {etag}")
|
|
1335
1441
|
etag = None
|
|
1336
1442
|
else:
|
|
@@ -1489,7 +1595,14 @@ class S3Interface(StorageInterface):
|
|
|
1489
1595
|
**self._additional_attrs,
|
|
1490
1596
|
)
|
|
1491
1597
|
|
|
1492
|
-
def list_files(
|
|
1598
|
+
def list_files(
|
|
1599
|
+
self,
|
|
1600
|
+
prefix:str,
|
|
1601
|
+
flat:bool = False,
|
|
1602
|
+
size:bool = False,
|
|
1603
|
+
return_resume_token:bool = False,
|
|
1604
|
+
resume_token:Optional[str] = None,
|
|
1605
|
+
):
|
|
1493
1606
|
"""
|
|
1494
1607
|
List the files in the layer with the given prefix.
|
|
1495
1608
|
|
|
@@ -1516,7 +1629,7 @@ class S3Interface(StorageInterface):
|
|
|
1516
1629
|
|
|
1517
1630
|
return self._conn.list_objects_v2(**kwargs)
|
|
1518
1631
|
|
|
1519
|
-
resp = s3lst(path)
|
|
1632
|
+
resp = s3lst(path, continuation_token=resume_token)
|
|
1520
1633
|
# the case where the prefix is something like "build", but "build" is a subdirectory
|
|
1521
1634
|
# so requery with "build/" to get the proper behavior
|
|
1522
1635
|
if (
|
|
@@ -1527,7 +1640,7 @@ class S3Interface(StorageInterface):
|
|
|
1527
1640
|
and len(resp.get("CommonPrefixes", [])) == 1
|
|
1528
1641
|
):
|
|
1529
1642
|
path += '/'
|
|
1530
|
-
resp = s3lst(path)
|
|
1643
|
+
resp = s3lst(path, continuation_token=resume_token)
|
|
1531
1644
|
|
|
1532
1645
|
def iterate(resp):
|
|
1533
1646
|
if 'CommonPrefixes' in resp.keys():
|
|
@@ -1539,24 +1652,37 @@ class S3Interface(StorageInterface):
|
|
|
1539
1652
|
if 'Contents' not in resp.keys():
|
|
1540
1653
|
resp['Contents'] = []
|
|
1541
1654
|
|
|
1655
|
+
token = None
|
|
1656
|
+
if resp["IsTruncated"]:
|
|
1657
|
+
token = resp["NextContinuationToken"]
|
|
1658
|
+
|
|
1542
1659
|
for item in resp['Contents']:
|
|
1543
1660
|
key = item['Key']
|
|
1544
1661
|
filename = key.removeprefix(layer_path)
|
|
1545
1662
|
if filename == '':
|
|
1546
1663
|
continue
|
|
1547
|
-
elif
|
|
1548
|
-
|
|
1549
|
-
elif flat and
|
|
1664
|
+
elif flat and '/' in key.removeprefix(path):
|
|
1665
|
+
continue
|
|
1666
|
+
elif not flat and filename[-1] == "/":
|
|
1667
|
+
continue
|
|
1668
|
+
|
|
1669
|
+
if not size and not return_resume_token:
|
|
1550
1670
|
yield filename
|
|
1671
|
+
elif size and not return_resume_token:
|
|
1672
|
+
yield (filename, int(item["Size"]))
|
|
1673
|
+
elif not size and return_resume_token:
|
|
1674
|
+
yield (filename, token)
|
|
1675
|
+
else:
|
|
1676
|
+
yield (filename, int(item["Size"]), token)
|
|
1551
1677
|
|
|
1552
|
-
for
|
|
1553
|
-
yield
|
|
1678
|
+
for result in iterate(resp):
|
|
1679
|
+
yield result
|
|
1554
1680
|
|
|
1555
1681
|
while resp['IsTruncated'] and resp['NextContinuationToken']:
|
|
1556
1682
|
resp = s3lst(path, resp['NextContinuationToken'])
|
|
1557
1683
|
|
|
1558
|
-
for
|
|
1559
|
-
yield
|
|
1684
|
+
for result in iterate(resp):
|
|
1685
|
+
yield result
|
|
1560
1686
|
|
|
1561
1687
|
def subtree_size(self, prefix:str = "") -> tuple[int,int]:
|
|
1562
1688
|
layer_path = self.get_path_to_file("")
|
|
@@ -37,6 +37,24 @@ from cloudfiles.lib import (
|
|
|
37
37
|
)
|
|
38
38
|
import cloudfiles.lib
|
|
39
39
|
|
|
40
|
+
from . import listing_db
|
|
41
|
+
|
|
42
|
+
def SI(val:int) -> str:
|
|
43
|
+
if val < 1024:
|
|
44
|
+
return f"{val} bytes"
|
|
45
|
+
elif val < 2**20:
|
|
46
|
+
return f"{(val / 2**10):.2f} KiB"
|
|
47
|
+
elif val < 2**30:
|
|
48
|
+
return f"{(val / 2**20):.2f} MiB"
|
|
49
|
+
elif val < 2**40:
|
|
50
|
+
return f"{(val / 2**30):.2f} GiB"
|
|
51
|
+
elif val < 2**50:
|
|
52
|
+
return f"{(val / 2**40):.2f} TiB"
|
|
53
|
+
elif val < 2**60:
|
|
54
|
+
return f"{(val / 2**50):.2f} PiB"
|
|
55
|
+
else:
|
|
56
|
+
return f"{(val / 2**60):.2f} EiB"
|
|
57
|
+
|
|
40
58
|
def cloudpathjoin(cloudpath, *args):
|
|
41
59
|
cloudpath = normalize_path(cloudpath)
|
|
42
60
|
proto = get_protocol(cloudpath)
|
|
@@ -89,8 +107,10 @@ def license():
|
|
|
89
107
|
@click.option('--flat', is_flag=True, default=False, help='Only produce a single level of directory hierarchy.',show_default=True)
|
|
90
108
|
@click.option('-e','--expr',is_flag=True, default=False, help=r'Use a limited regexp language (e.g. [abc123]{3}) to generate prefixes.', show_default=True)
|
|
91
109
|
@click.option('--no-auth',is_flag=True, default=False, help='Uses the http API for read-only operations.', show_default=True)
|
|
110
|
+
@click.option('--sqlite', 'sqlite_filename', default="", help='Write results to this sqlite database.', show_default=True)
|
|
111
|
+
@click.option('--progress', is_flag=True, default=False, help="Show progress bar for sqlite.", show_default=True)
|
|
92
112
|
@click.argument("cloudpath")
|
|
93
|
-
def ls(shortpath, flat, expr, cloudpath, no_auth):
|
|
113
|
+
def ls(shortpath, flat, expr, cloudpath, no_auth, sqlite_filename, progress):
|
|
94
114
|
"""Recursively lists the contents of a directory."""
|
|
95
115
|
cloudpath = normalize_path(cloudpath)
|
|
96
116
|
|
|
@@ -107,6 +127,12 @@ def ls(shortpath, flat, expr, cloudpath, no_auth):
|
|
|
107
127
|
|
|
108
128
|
flat = flat or flt
|
|
109
129
|
|
|
130
|
+
if sqlite_filename != "":
|
|
131
|
+
return listing_db.list(
|
|
132
|
+
cloudpath, sqlite_filename,
|
|
133
|
+
prefix, flat, progress
|
|
134
|
+
)
|
|
135
|
+
|
|
110
136
|
cf = CloudFiles(cloudpath, no_sign_request=no_sign_request)
|
|
111
137
|
iterables = []
|
|
112
138
|
if expr:
|
|
@@ -804,18 +830,19 @@ def __rm(cloudpath, progress, paths):
|
|
|
804
830
|
@click.option('-s', '--summarize', is_flag=True, default=False, help="Sum a total for each input argument.")
|
|
805
831
|
@click.option('-h', '--human-readable', is_flag=True, default=False, help='"Human-readable" output. Use unit suffixes: Bytes, KiB, MiB, GiB, TiB, PiB, and EiB.')
|
|
806
832
|
@click.option('-N', '--count-files', is_flag=True, default=False, help='Also report the number of files.')
|
|
807
|
-
|
|
833
|
+
@click.option('-e', '--group-by-ext', is_flag=True, default=False, help='Group and summarize disk usage by file extension.')
|
|
834
|
+
def du(paths, grand_total, summarize, human_readable, count_files, group_by_ext):
|
|
808
835
|
"""Display disk usage statistics."""
|
|
809
836
|
results = []
|
|
810
837
|
|
|
811
|
-
simplified_data =
|
|
838
|
+
simplified_data = False
|
|
812
839
|
|
|
813
840
|
for path in paths:
|
|
814
841
|
npath = normalize_path(path)
|
|
815
842
|
if ispathdir(path):
|
|
816
843
|
cf = CloudFiles(npath)
|
|
817
|
-
if summarize:
|
|
818
|
-
simplified_data =
|
|
844
|
+
if summarize and not group_by_ext:
|
|
845
|
+
simplified_data = True
|
|
819
846
|
results.append(cf.subtree_size())
|
|
820
847
|
else:
|
|
821
848
|
results.append(cf.size(cf.list()))
|
|
@@ -827,49 +854,43 @@ def du(paths, grand_total, summarize, human_readable, count_files):
|
|
|
827
854
|
return
|
|
828
855
|
results.append({ path: sz })
|
|
829
856
|
|
|
830
|
-
|
|
831
|
-
if not human_readable:
|
|
832
|
-
return val
|
|
833
|
-
|
|
834
|
-
if val < 1024:
|
|
835
|
-
return f"{val} Bytes"
|
|
836
|
-
elif val < 2**20:
|
|
837
|
-
return f"{(val / 2**10):.2f} KiB"
|
|
838
|
-
elif val < 2**30:
|
|
839
|
-
return f"{(val / 2**20):.2f} MiB"
|
|
840
|
-
elif val < 2**40:
|
|
841
|
-
return f"{(val / 2**30):.2f} GiB"
|
|
842
|
-
elif val < 2**50:
|
|
843
|
-
return f"{(val / 2**40):.2f} TiB"
|
|
844
|
-
elif val < 2**60:
|
|
845
|
-
return f"{(val / 2**50):.2f} PiB"
|
|
846
|
-
else:
|
|
847
|
-
return f"{(val / 2**60):.2f} EiB"
|
|
857
|
+
disp = SI if human_readable else (lambda x: x)
|
|
848
858
|
|
|
849
859
|
summary = {}
|
|
850
860
|
num_files = 0
|
|
861
|
+
ext_totals = defaultdict(int)
|
|
862
|
+
|
|
851
863
|
for path, res in zip(paths, results):
|
|
852
864
|
if simplified_data:
|
|
853
|
-
summary[path] = sum(res.values())
|
|
854
|
-
num_files += len(res)
|
|
855
|
-
else:
|
|
856
865
|
summary[path] = res["num_bytes"]
|
|
857
866
|
num_files += res["N"]
|
|
867
|
+
else:
|
|
868
|
+
summary[path] = sum(res.values())
|
|
869
|
+
num_files += len(res)
|
|
870
|
+
if group_by_ext:
|
|
871
|
+
for filepath, size in res.items():
|
|
872
|
+
ext = os.path.splitext(filepath)[1].lower() or "(no extension)"
|
|
873
|
+
ext_totals[ext] += size
|
|
858
874
|
|
|
859
875
|
if summarize:
|
|
860
|
-
print(f"{
|
|
876
|
+
print(f"{disp(summary[path])}\t{path}")
|
|
861
877
|
|
|
862
878
|
if not summarize:
|
|
863
879
|
for res in results:
|
|
864
880
|
for pth, size in res.items():
|
|
865
|
-
print(f"{
|
|
881
|
+
print(f"{disp(size)}\t{pth}")
|
|
866
882
|
|
|
867
883
|
if grand_total:
|
|
868
|
-
print(f"{
|
|
884
|
+
print(f"{disp(sum(summary.values()))}\tbytes total")
|
|
869
885
|
|
|
870
886
|
if count_files:
|
|
871
887
|
print(f"{num_files}\tfiles total")
|
|
872
888
|
|
|
889
|
+
if group_by_ext:
|
|
890
|
+
print("\nUsage by file extension:")
|
|
891
|
+
for ext, total in sorted(ext_totals.items(), key=lambda x: x[1], reverse=True):
|
|
892
|
+
print(f"{disp(total)}\t{ext}")
|
|
893
|
+
|
|
873
894
|
@main.command()
|
|
874
895
|
@click.argument('paths', nargs=-1)
|
|
875
896
|
def head(paths):
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from tqdm import tqdm
|
|
5
|
+
|
|
6
|
+
from cloudfiles.lib import sip
|
|
7
|
+
from cloudfiles import CloudFiles
|
|
8
|
+
|
|
9
|
+
# the maximum value of a host parameter number is
|
|
10
|
+
# SQLITE_MAX_VARIABLE_NUMBER, which defaults to 999
|
|
11
|
+
# for SQLite versions prior to 3.32.0 (2020-05-22) or
|
|
12
|
+
# 32766 for SQLite versions after 3.32.0.
|
|
13
|
+
# https://www.sqlite.org/limits.html
|
|
14
|
+
SQLITE_MAX_PARAMS = 999 if sqlite3.sqlite_version_info <= (3,32,0) else 32766
|
|
15
|
+
BIND = '?'
|
|
16
|
+
|
|
17
|
+
def init_db(filename:str) -> sqlite3.Connection:
|
|
18
|
+
"""Create tables, indexes, and tune SQLite for bulk-insert performance."""
|
|
19
|
+
conn = sqlite3.connect(filename)
|
|
20
|
+
|
|
21
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
22
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
23
|
+
conn.execute("PRAGMA cache_size=-131072") # 128 MB (negative = kilobytes)
|
|
24
|
+
|
|
25
|
+
conn.execute("""
|
|
26
|
+
CREATE TABLE IF NOT EXISTS files (
|
|
27
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
28
|
+
path TEXT NOT NULL UNIQUE,
|
|
29
|
+
size INTEGER
|
|
30
|
+
)
|
|
31
|
+
""")
|
|
32
|
+
|
|
33
|
+
conn.execute("""
|
|
34
|
+
CREATE TABLE IF NOT EXISTS checkpoint (
|
|
35
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
36
|
+
page_token TEXT,
|
|
37
|
+
rows_done INTEGER DEFAULT 0,
|
|
38
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
39
|
+
)
|
|
40
|
+
""")
|
|
41
|
+
|
|
42
|
+
conn.execute(
|
|
43
|
+
"""
|
|
44
|
+
INSERT OR IGNORE
|
|
45
|
+
INTO checkpoint (id, page_token, rows_done)
|
|
46
|
+
VALUES (1, "", 0)
|
|
47
|
+
"""
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
conn.execute("""
|
|
51
|
+
CREATE TABLE IF NOT EXISTS metadata (
|
|
52
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
53
|
+
name TEXT,
|
|
54
|
+
value TEXT
|
|
55
|
+
)
|
|
56
|
+
""")
|
|
57
|
+
|
|
58
|
+
conn.execute(
|
|
59
|
+
"""
|
|
60
|
+
INSERT OR IGNORE
|
|
61
|
+
INTO metadata (id, name, value)
|
|
62
|
+
VALUES (1, "cloudpath", "")
|
|
63
|
+
"""
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
conn.commit()
|
|
67
|
+
|
|
68
|
+
return conn
|
|
69
|
+
|
|
70
|
+
def get_cloudpath(conn:sqlite3.Connection) -> str:
|
|
71
|
+
return conn.execute(
|
|
72
|
+
f"SELECT value FROM metadata where name = 'cloudpath'"
|
|
73
|
+
).fetchone()[0]
|
|
74
|
+
|
|
75
|
+
def save_cloudpath(conn:sqlite3.Connection, cloudpath:str):
|
|
76
|
+
conn.execute(
|
|
77
|
+
f"UPDATE metadata SET value = ? where name = 'cloudpath'", [ cloudpath ]
|
|
78
|
+
)
|
|
79
|
+
conn.commit()
|
|
80
|
+
|
|
81
|
+
def save_checkpoint(
|
|
82
|
+
conn:sqlite3.Connection,
|
|
83
|
+
page_token:str|None,
|
|
84
|
+
rows_done:int,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Persist the current GCS page token and running row count."""
|
|
87
|
+
conn.execute("""
|
|
88
|
+
UPDATE checkpoint
|
|
89
|
+
SET
|
|
90
|
+
page_token = ?,
|
|
91
|
+
rows_done = rows_done + ?
|
|
92
|
+
""", (page_token, rows_done))
|
|
93
|
+
|
|
94
|
+
def load_checkpoint(conn:sqlite3.Connection) -> tuple[str|None,int]:
|
|
95
|
+
"""Return (page_token, rows_done) for a bucket, or (None, 0) if none."""
|
|
96
|
+
row = conn.execute(
|
|
97
|
+
"SELECT page_token, rows_done FROM checkpoint WHERE id = 1"
|
|
98
|
+
).fetchone()
|
|
99
|
+
return (row[0], row[1]) if row else (None, 0)
|
|
100
|
+
|
|
101
|
+
def write_batch(
|
|
102
|
+
conn:sqlite3.Connection,
|
|
103
|
+
batch:list[tuple[str,int]],
|
|
104
|
+
page_token:str|None,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Write a batch of (path, size) rows and save the checkpoint atomically."""
|
|
107
|
+
for rows in sip(batch, SQLITE_MAX_PARAMS):
|
|
108
|
+
bindslist = ",".join([f"({BIND}, {BIND})"] * len(rows))
|
|
109
|
+
flat_rows = [ x for row in rows for x in row ]
|
|
110
|
+
conn.execute(
|
|
111
|
+
f"INSERT INTO files (path,size) VALUES {bindslist}",
|
|
112
|
+
flat_rows
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def list(
|
|
116
|
+
cloudpath:str,
|
|
117
|
+
db_name:str,
|
|
118
|
+
prefix:str = "",
|
|
119
|
+
flat:bool = False,
|
|
120
|
+
progress:bool = False,
|
|
121
|
+
):
|
|
122
|
+
with init_db(db_name) as conn:
|
|
123
|
+
|
|
124
|
+
saved_cloudpath = get_cloudpath(conn)
|
|
125
|
+
if saved_cloudpath not in ("", cloudpath):
|
|
126
|
+
print(
|
|
127
|
+
f"Cloudpath does not match database. Aborting.\n"
|
|
128
|
+
f"Saved: {saved_cloudpath}\n"
|
|
129
|
+
f"Argument: {cloudpath}"
|
|
130
|
+
)
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
cf = CloudFiles(cloudpath)
|
|
134
|
+
|
|
135
|
+
save_cloudpath(conn, cloudpath)
|
|
136
|
+
page_token, rows_done = load_checkpoint(conn)
|
|
137
|
+
|
|
138
|
+
if progress and page_token is not None:
|
|
139
|
+
print(f"Resuming from {rows_done} rows.")
|
|
140
|
+
|
|
141
|
+
iterator = cf.list(
|
|
142
|
+
prefix=prefix,
|
|
143
|
+
flat=flat,
|
|
144
|
+
size=True,
|
|
145
|
+
resume_token=page_token,
|
|
146
|
+
return_resume_token=True,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
pbar = tqdm(
|
|
150
|
+
disable=(not progress),
|
|
151
|
+
desc="Files",
|
|
152
|
+
initial=rows_done,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
with pbar:
|
|
156
|
+
for batch in sip(iterator, 5000):
|
|
157
|
+
latest_token = batch[-1][2]
|
|
158
|
+
batch = [ (row[0], row[1]) for row in batch ]
|
|
159
|
+
conn.execute("BEGIN TRANSACTION")
|
|
160
|
+
write_batch(conn, batch, latest_token)
|
|
161
|
+
save_checkpoint(conn, latest_token, len(batch))
|
|
162
|
+
conn.commit()
|
|
163
|
+
|
|
164
|
+
pbar.update(len(batch))
|
|
165
|
+
|
|
166
|
+
if progress:
|
|
167
|
+
print("creating index.")
|
|
168
|
+
|
|
169
|
+
s = time.monotonic()
|
|
170
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)")
|
|
171
|
+
elapsed = time.monotonic() - s
|
|
172
|
+
if progress:
|
|
173
|
+
print(f"created index in {elapsed:.3f} sec.")
|
|
174
|
+
print("done.")
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"git_version": "4dd0458", "is_release": true}
|
|
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
|