cloud-files 6.2.2__tar.gz → 6.3.1__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.2 → cloud_files-6.3.1}/ChangeLog +13 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/PKG-INFO +30 -1
- {cloud_files-6.2.2 → cloud_files-6.3.1}/README.md +29 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloud_files.egg-info/PKG-INFO +30 -1
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloud_files.egg-info/SOURCES.txt +2 -1
- cloud_files-6.3.1/cloud_files.egg-info/pbr.json +1 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/cloudfiles.py +13 -2
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/interfaces.py +163 -35
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles_cli/cloudfiles_cli.py +69 -33
- cloud_files-6.3.1/cloudfiles_cli/listing_db.py +174 -0
- cloud_files-6.2.2/cloud_files.egg-info/pbr.json +0 -1
- {cloud_files-6.2.2 → cloud_files-6.3.1}/.github/workflows/test-suite.yml +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/AUTHORS +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/LICENSE +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/MANIFEST.in +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/automated_test.py +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloud_files.egg-info/dependency_links.txt +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloud_files.egg-info/entry_points.txt +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloud_files.egg-info/not-zip-safe +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloud_files.egg-info/requires.txt +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloud_files.egg-info/top_level.txt +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/__init__.py +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/compression.py +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/connectionpools.py +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/exceptions.py +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/gcs.py +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/lib.py +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/monitoring.py +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/paths.py +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/resumable_tools.py +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/scheduler.py +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/secrets.py +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/threaded_queue.py +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/typing.py +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles_cli/LICENSE +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles_cli/__init__.py +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/requirements.txt +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/setup.cfg +0 -0
- {cloud_files-6.2.2 → cloud_files-6.3.1}/setup.py +0 -0
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
CHANGES
|
|
2
2
|
=======
|
|
3
3
|
|
|
4
|
+
6.3.1
|
|
5
|
+
-----
|
|
6
|
+
|
|
7
|
+
* fix: ensure ACL is assigned properly during transfer copies
|
|
8
|
+
* fix(cli): incorrect parsing of common prefix from stdin
|
|
9
|
+
* feat: add reading source paths from a text file
|
|
10
|
+
|
|
11
|
+
6.3.0
|
|
12
|
+
-----
|
|
13
|
+
|
|
14
|
+
* feat(ls): \`cloudfiles ls --sqlite bucket.db ...\` (#125)
|
|
15
|
+
* feat(cli): add group by file extension to du
|
|
16
|
+
|
|
4
17
|
6.2.2
|
|
5
18
|
-----
|
|
6
19
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: cloud-files
|
|
3
|
-
Version: 6.
|
|
3
|
+
Version: 6.3.1
|
|
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
|
|
@@ -497,6 +497,8 @@ cf.clear_locks()
|
|
|
497
497
|
```bash
|
|
498
498
|
# list cloud and local directories
|
|
499
499
|
cloudfiles ls gs://bucket-folder/
|
|
500
|
+
# resumable list cloud and local directories to sqlite db
|
|
501
|
+
cloudfiles ls --sqlite example.db gs://bucket-folder/
|
|
500
502
|
# parallel file transfer, no decompression
|
|
501
503
|
cloudfiles -p 2 cp --progress -r s3://bkt/ gs://bkt2/
|
|
502
504
|
# change compression type to brotli
|
|
@@ -554,6 +556,33 @@ cloudfiles ls -e "gs://bucket/prefix[ab]"
|
|
|
554
556
|
# cloudfiles ls gs://bucket/prefixb
|
|
555
557
|
```
|
|
556
558
|
|
|
559
|
+
### `ls` sqlite
|
|
560
|
+
|
|
561
|
+
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).
|
|
562
|
+
|
|
563
|
+
```bash
|
|
564
|
+
cloudfiles ls --sqlite example.db gs://bucket/prefix --progress
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
This will start downloading the data to `example.db` in the local directory. You can then search for files in the `files` table.
|
|
568
|
+
|
|
569
|
+
```sql
|
|
570
|
+
SELECT sum(size) FROM files WHERE path LIKE '%example.jpg';
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
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.
|
|
574
|
+
|
|
575
|
+
```sql
|
|
576
|
+
$ sqlite3
|
|
577
|
+
|
|
578
|
+
> ATTACH DATABASE 'original.db' AS original_data;
|
|
579
|
+
> ATTACH DATABASE 'copy.db' AS copied_data;
|
|
580
|
+
|
|
581
|
+
> SELECT path from original_data.files
|
|
582
|
+
EXCEPT
|
|
583
|
+
SELECT path from copied_data.files;
|
|
584
|
+
```
|
|
585
|
+
|
|
557
586
|
### `alias` for Alternative S3 Endpoints
|
|
558
587
|
|
|
559
588
|
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
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: cloud-files
|
|
3
|
-
Version: 6.
|
|
3
|
+
Version: 6.3.1
|
|
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
|
|
@@ -497,6 +497,8 @@ cf.clear_locks()
|
|
|
497
497
|
```bash
|
|
498
498
|
# list cloud and local directories
|
|
499
499
|
cloudfiles ls gs://bucket-folder/
|
|
500
|
+
# resumable list cloud and local directories to sqlite db
|
|
501
|
+
cloudfiles ls --sqlite example.db gs://bucket-folder/
|
|
500
502
|
# parallel file transfer, no decompression
|
|
501
503
|
cloudfiles -p 2 cp --progress -r s3://bkt/ gs://bkt2/
|
|
502
504
|
# change compression type to brotli
|
|
@@ -554,6 +556,33 @@ cloudfiles ls -e "gs://bucket/prefix[ab]"
|
|
|
554
556
|
# cloudfiles ls gs://bucket/prefixb
|
|
555
557
|
```
|
|
556
558
|
|
|
559
|
+
### `ls` sqlite
|
|
560
|
+
|
|
561
|
+
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).
|
|
562
|
+
|
|
563
|
+
```bash
|
|
564
|
+
cloudfiles ls --sqlite example.db gs://bucket/prefix --progress
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
This will start downloading the data to `example.db` in the local directory. You can then search for files in the `files` table.
|
|
568
|
+
|
|
569
|
+
```sql
|
|
570
|
+
SELECT sum(size) FROM files WHERE path LIKE '%example.jpg';
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
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.
|
|
574
|
+
|
|
575
|
+
```sql
|
|
576
|
+
$ sqlite3
|
|
577
|
+
|
|
578
|
+
> ATTACH DATABASE 'original.db' AS original_data;
|
|
579
|
+
> ATTACH DATABASE 'copy.db' AS copied_data;
|
|
580
|
+
|
|
581
|
+
> SELECT path from original_data.files
|
|
582
|
+
EXCEPT
|
|
583
|
+
SELECT path from copied_data.files;
|
|
584
|
+
```
|
|
585
|
+
|
|
557
586
|
### `alias` for Alternative S3 Endpoints
|
|
558
587
|
|
|
559
588
|
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": "369aaaf", "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
|
|
@@ -52,6 +54,8 @@ S3_ACLS = {
|
|
|
52
54
|
"nokura": "public-read",
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
DEFAULT_S3_ACL = "bucket-owner-full-control"
|
|
58
|
+
|
|
55
59
|
S3ConnectionPoolParams = namedtuple('S3ConnectionPoolParams', 'service bucket_name request_payer')
|
|
56
60
|
GCloudBucketPoolParams = namedtuple('GCloudBucketPoolParams', 'bucket_name request_payer')
|
|
57
61
|
MemoryPoolParams = namedtuple('MemoryPoolParams', 'bucket_name')
|
|
@@ -346,7 +350,14 @@ class FileInterface(StorageInterface):
|
|
|
346
350
|
for path in file_paths:
|
|
347
351
|
self.delete_file(path)
|
|
348
352
|
|
|
349
|
-
def list_files(
|
|
353
|
+
def list_files(
|
|
354
|
+
self,
|
|
355
|
+
prefix:str,
|
|
356
|
+
flat:bool = False,
|
|
357
|
+
size:bool = False,
|
|
358
|
+
return_resume_token:bool = False,
|
|
359
|
+
resume_token:Optional[str] = None,
|
|
360
|
+
):
|
|
350
361
|
"""
|
|
351
362
|
List the files in the layer with the given prefix.
|
|
352
363
|
|
|
@@ -354,7 +365,6 @@ class FileInterface(StorageInterface):
|
|
|
354
365
|
while non-flat means generate all file paths with that
|
|
355
366
|
prefix.
|
|
356
367
|
"""
|
|
357
|
-
|
|
358
368
|
layer_path = self.get_path_to_file("")
|
|
359
369
|
path = os.path.join(layer_path, prefix)
|
|
360
370
|
|
|
@@ -405,8 +415,16 @@ class FileInterface(StorageInterface):
|
|
|
405
415
|
|
|
406
416
|
filenames = list(map(stripext, filenames))
|
|
407
417
|
filenames.sort()
|
|
408
|
-
|
|
409
|
-
|
|
418
|
+
|
|
419
|
+
if not size and not return_resume_token:
|
|
420
|
+
return iter(filenames)
|
|
421
|
+
elif size and return_resume_token:
|
|
422
|
+
return ( (filename, os.path.getsize(filename), None) for filename in filenames )
|
|
423
|
+
elif size and not return_resume_token:
|
|
424
|
+
return ( (filename, os.path.getsize(filename)) for filename in filenames )
|
|
425
|
+
elif not size and return_resume_token:
|
|
426
|
+
return ( (filename, None) for filename in filenames )
|
|
427
|
+
|
|
410
428
|
class MemoryInterface(StorageInterface):
|
|
411
429
|
def __init__(self, path, secrets=None, request_payer=None, **kwargs):
|
|
412
430
|
global MEM_BUCKET_POOL_LOCK
|
|
@@ -589,7 +607,14 @@ class MemoryInterface(StorageInterface):
|
|
|
589
607
|
for path in file_paths:
|
|
590
608
|
self.delete_file(path)
|
|
591
609
|
|
|
592
|
-
def list_files(
|
|
610
|
+
def list_files(
|
|
611
|
+
self,
|
|
612
|
+
prefix:str,
|
|
613
|
+
flat:bool = False,
|
|
614
|
+
size:bool = False,
|
|
615
|
+
return_resume_token:bool = False,
|
|
616
|
+
resume_token:Optional[str] = None,
|
|
617
|
+
):
|
|
593
618
|
"""
|
|
594
619
|
List the files in the layer with the given prefix.
|
|
595
620
|
|
|
@@ -631,7 +656,17 @@ class MemoryInterface(StorageInterface):
|
|
|
631
656
|
|
|
632
657
|
filenames = list(map(stripext, filenames))
|
|
633
658
|
filenames.sort()
|
|
634
|
-
|
|
659
|
+
|
|
660
|
+
# The size operation could be made much faster
|
|
661
|
+
# but will require some surgery
|
|
662
|
+
if not size and not return_resume_token:
|
|
663
|
+
return iter(filenames)
|
|
664
|
+
elif size and return_resume_token:
|
|
665
|
+
return ( (filename, self.size(filename), None) for filename in filenames )
|
|
666
|
+
elif size and not return_resume_token:
|
|
667
|
+
return ( (filename, self.size(filename)) for filename in filenames )
|
|
668
|
+
elif not size and return_resume_token:
|
|
669
|
+
return ( (filename, None) for filename in filenames )
|
|
635
670
|
|
|
636
671
|
def subtree_size(self, prefix:str = "") -> tuple[int,int]:
|
|
637
672
|
layer_path = self.get_path_to_file("")
|
|
@@ -833,9 +868,15 @@ class GoogleCloudStorageInterface(StorageInterface):
|
|
|
833
868
|
except google.cloud.exceptions.NotFound:
|
|
834
869
|
pass
|
|
835
870
|
|
|
836
|
-
|
|
837
871
|
@retry
|
|
838
|
-
def list_files(
|
|
872
|
+
def list_files(
|
|
873
|
+
self,
|
|
874
|
+
prefix:str,
|
|
875
|
+
flat:bool = False,
|
|
876
|
+
size:bool = False,
|
|
877
|
+
return_resume_token:bool = False,
|
|
878
|
+
resume_token:Optional[str] = None,
|
|
879
|
+
):
|
|
839
880
|
"""
|
|
840
881
|
List the files in the layer with the given prefix.
|
|
841
882
|
|
|
@@ -848,29 +889,53 @@ class GoogleCloudStorageInterface(StorageInterface):
|
|
|
848
889
|
|
|
849
890
|
delimiter = '/' if flat else None
|
|
850
891
|
|
|
892
|
+
items = "name"
|
|
893
|
+
if size:
|
|
894
|
+
items += ",size"
|
|
895
|
+
|
|
851
896
|
blobs = self._bucket.list_blobs(
|
|
852
897
|
prefix=path,
|
|
853
898
|
delimiter=delimiter,
|
|
854
899
|
page_size=2500,
|
|
855
|
-
fields="items(
|
|
900
|
+
fields=f"items({items}),nextPageToken,prefixes",
|
|
901
|
+
page_token=resume_token,
|
|
856
902
|
)
|
|
857
903
|
|
|
904
|
+
def return_args(filename, blob, page):
|
|
905
|
+
nonlocal blobs
|
|
906
|
+
args = [ filename ]
|
|
907
|
+
if size:
|
|
908
|
+
args.append(blob.size)
|
|
909
|
+
if return_resume_token:
|
|
910
|
+
args.append(blobs.next_page_token)
|
|
911
|
+
|
|
912
|
+
if len(args) == 1:
|
|
913
|
+
return args[0]
|
|
914
|
+
else:
|
|
915
|
+
return tuple(args)
|
|
916
|
+
|
|
858
917
|
for page in blobs.pages:
|
|
859
918
|
if page.prefixes:
|
|
860
|
-
|
|
861
|
-
item.removeprefix(path)
|
|
862
|
-
|
|
863
|
-
|
|
919
|
+
for item in page.prefixes:
|
|
920
|
+
ret = [ item.removeprefix(path) ]
|
|
921
|
+
if size:
|
|
922
|
+
ret.append(0)
|
|
923
|
+
if return_resume_token:
|
|
924
|
+
ret.append(page.next_page_token)
|
|
925
|
+
|
|
926
|
+
if len(ret) == 1:
|
|
927
|
+
yield ret[0]
|
|
928
|
+
else:
|
|
929
|
+
yield tuple(ret)
|
|
864
930
|
|
|
865
931
|
for blob in page:
|
|
866
932
|
filename = blob.name.removeprefix(layer_path)
|
|
867
933
|
if not filename:
|
|
868
934
|
continue
|
|
869
935
|
elif not flat and filename[-1] != '/':
|
|
870
|
-
yield filename
|
|
936
|
+
yield return_args(filename, blob, page)
|
|
871
937
|
elif flat and '/' not in blob.name.removeprefix(path):
|
|
872
|
-
yield filename
|
|
873
|
-
|
|
938
|
+
yield return_args(filename, blob, page)
|
|
874
939
|
|
|
875
940
|
@retry
|
|
876
941
|
def subtree_size(self, prefix:str = "") -> tuple[int,int]:
|
|
@@ -1026,7 +1091,14 @@ class HttpInterface(StorageInterface):
|
|
|
1026
1091
|
def files_exist(self, file_paths):
|
|
1027
1092
|
return {path: self.exists(path) for path in file_paths}
|
|
1028
1093
|
|
|
1029
|
-
def _list_files_google(
|
|
1094
|
+
def _list_files_google(
|
|
1095
|
+
self,
|
|
1096
|
+
prefix:str,
|
|
1097
|
+
flat:bool = False,
|
|
1098
|
+
size:bool = False,
|
|
1099
|
+
return_resume_token:bool = False,
|
|
1100
|
+
resume_token:Optional[str] = None,
|
|
1101
|
+
):
|
|
1030
1102
|
bucket = self._path.path.split('/')[0]
|
|
1031
1103
|
prefix = posixpath.join(
|
|
1032
1104
|
self._path.path.replace(bucket, '', 1),
|
|
@@ -1037,10 +1109,18 @@ class HttpInterface(StorageInterface):
|
|
|
1037
1109
|
|
|
1038
1110
|
headers = self.default_headers()
|
|
1039
1111
|
|
|
1112
|
+
items = "name"
|
|
1113
|
+
if size:
|
|
1114
|
+
items += ",size"
|
|
1115
|
+
|
|
1116
|
+
fields = f"items({items}),nextPageToken,prefixes"
|
|
1117
|
+
|
|
1040
1118
|
@retry_if_not(AuthorizationError)
|
|
1041
1119
|
def request(token):
|
|
1042
1120
|
nonlocal headers
|
|
1043
|
-
params = {
|
|
1121
|
+
params = {
|
|
1122
|
+
"fields": fields,
|
|
1123
|
+
}
|
|
1044
1124
|
if prefix:
|
|
1045
1125
|
params["prefix"] = prefix
|
|
1046
1126
|
if token is not None:
|
|
@@ -1048,6 +1128,7 @@ class HttpInterface(StorageInterface):
|
|
|
1048
1128
|
if flat:
|
|
1049
1129
|
params["delimiter"] = '/'
|
|
1050
1130
|
|
|
1131
|
+
|
|
1051
1132
|
results = self.session.get(
|
|
1052
1133
|
f"https://storage.googleapis.com/storage/v1/b/{bucket}/o",
|
|
1053
1134
|
params=params,
|
|
@@ -1067,17 +1148,33 @@ class HttpInterface(StorageInterface):
|
|
|
1067
1148
|
token = None
|
|
1068
1149
|
while True:
|
|
1069
1150
|
results = request(token)
|
|
1151
|
+
token = results.get("nextPageToken", None)
|
|
1070
1152
|
|
|
1071
1153
|
if 'prefixes' in results:
|
|
1072
|
-
|
|
1154
|
+
itr = (
|
|
1073
1155
|
item.removeprefix(strip)
|
|
1074
1156
|
for item in results["prefixes"]
|
|
1075
1157
|
)
|
|
1158
|
+
if not size and not return_resume_token:
|
|
1159
|
+
yield from itr
|
|
1160
|
+
elif not size and return_resume_token:
|
|
1161
|
+
yield from ( (pre, token) for pre in itr )
|
|
1162
|
+
elif size and not return_resume_token:
|
|
1163
|
+
yield from ( (pre, 0) for pre in itr )
|
|
1164
|
+
else:
|
|
1165
|
+
yield from ( (pre, 0, token) for pre in itr )
|
|
1076
1166
|
|
|
1077
1167
|
for res in results.get("items", []):
|
|
1078
|
-
|
|
1168
|
+
name = res["name"].removeprefix(strip)
|
|
1169
|
+
if not size and not return_resume_token:
|
|
1170
|
+
yield name
|
|
1171
|
+
elif size and not return_resume_token:
|
|
1172
|
+
yield (name, int(res["size"]))
|
|
1173
|
+
elif not size and return_resume_token:
|
|
1174
|
+
yield (name, token)
|
|
1175
|
+
else:
|
|
1176
|
+
yield (name, int(res["size"]), token)
|
|
1079
1177
|
|
|
1080
|
-
token = results.get("nextPageToken", None)
|
|
1081
1178
|
if token is None:
|
|
1082
1179
|
break
|
|
1083
1180
|
|
|
@@ -1124,11 +1221,21 @@ class HttpInterface(StorageInterface):
|
|
|
1124
1221
|
if flat:
|
|
1125
1222
|
break
|
|
1126
1223
|
|
|
1127
|
-
def list_files(
|
|
1224
|
+
def list_files(
|
|
1225
|
+
self,
|
|
1226
|
+
prefix:str,
|
|
1227
|
+
flat:bool = False,
|
|
1228
|
+
size:bool = False,
|
|
1229
|
+
return_resume_token:bool = False,
|
|
1230
|
+
resume_token:Optional[str] = None,
|
|
1231
|
+
):
|
|
1128
1232
|
if self._path.host == "https://storage.googleapis.com":
|
|
1129
|
-
yield from self._list_files_google(prefix, flat)
|
|
1233
|
+
yield from self._list_files_google(prefix, flat, size, return_resume_token, resume_token)
|
|
1130
1234
|
return
|
|
1131
1235
|
|
|
1236
|
+
if size or resume_token or return_resume_token:
|
|
1237
|
+
raise NotImplementedError("size, resume_token, and return_resume_token are not yet implemented.")
|
|
1238
|
+
|
|
1132
1239
|
url = posixpath.join(self._path.host, self._path.path, prefix)
|
|
1133
1240
|
resp = requests.head(url)
|
|
1134
1241
|
|
|
@@ -1191,7 +1298,7 @@ class S3Interface(StorageInterface):
|
|
|
1191
1298
|
|
|
1192
1299
|
attrs = {
|
|
1193
1300
|
'ContentType': (content_type or 'application/octet-stream'),
|
|
1194
|
-
'ACL': S3_ACLS.get(self._path.alias,
|
|
1301
|
+
'ACL': S3_ACLS.get(self._path.alias, DEFAULT_S3_ACL),
|
|
1195
1302
|
**self._additional_attrs,
|
|
1196
1303
|
}
|
|
1197
1304
|
|
|
@@ -1269,7 +1376,8 @@ class S3Interface(StorageInterface):
|
|
|
1269
1376
|
CopySource=copy_source,
|
|
1270
1377
|
Bucket=dest_bucket_name,
|
|
1271
1378
|
Key=dest_key,
|
|
1272
|
-
MetadataDirective='COPY' # Ensure metadata like Content-Encoding is copied
|
|
1379
|
+
MetadataDirective='COPY', # Ensure metadata like Content-Encoding is copied
|
|
1380
|
+
ACL=S3_ACLS.get(self._path.alias, DEFAULT_S3_ACL),
|
|
1273
1381
|
)
|
|
1274
1382
|
except botocore.exceptions.ClientError as err:
|
|
1275
1383
|
if err.response['Error']['Code'] in ('NoSuchKey', '404'):
|
|
@@ -1490,7 +1598,14 @@ class S3Interface(StorageInterface):
|
|
|
1490
1598
|
**self._additional_attrs,
|
|
1491
1599
|
)
|
|
1492
1600
|
|
|
1493
|
-
def list_files(
|
|
1601
|
+
def list_files(
|
|
1602
|
+
self,
|
|
1603
|
+
prefix:str,
|
|
1604
|
+
flat:bool = False,
|
|
1605
|
+
size:bool = False,
|
|
1606
|
+
return_resume_token:bool = False,
|
|
1607
|
+
resume_token:Optional[str] = None,
|
|
1608
|
+
):
|
|
1494
1609
|
"""
|
|
1495
1610
|
List the files in the layer with the given prefix.
|
|
1496
1611
|
|
|
@@ -1517,7 +1632,7 @@ class S3Interface(StorageInterface):
|
|
|
1517
1632
|
|
|
1518
1633
|
return self._conn.list_objects_v2(**kwargs)
|
|
1519
1634
|
|
|
1520
|
-
resp = s3lst(path)
|
|
1635
|
+
resp = s3lst(path, continuation_token=resume_token)
|
|
1521
1636
|
# the case where the prefix is something like "build", but "build" is a subdirectory
|
|
1522
1637
|
# so requery with "build/" to get the proper behavior
|
|
1523
1638
|
if (
|
|
@@ -1528,7 +1643,7 @@ class S3Interface(StorageInterface):
|
|
|
1528
1643
|
and len(resp.get("CommonPrefixes", [])) == 1
|
|
1529
1644
|
):
|
|
1530
1645
|
path += '/'
|
|
1531
|
-
resp = s3lst(path)
|
|
1646
|
+
resp = s3lst(path, continuation_token=resume_token)
|
|
1532
1647
|
|
|
1533
1648
|
def iterate(resp):
|
|
1534
1649
|
if 'CommonPrefixes' in resp.keys():
|
|
@@ -1540,24 +1655,37 @@ class S3Interface(StorageInterface):
|
|
|
1540
1655
|
if 'Contents' not in resp.keys():
|
|
1541
1656
|
resp['Contents'] = []
|
|
1542
1657
|
|
|
1658
|
+
token = None
|
|
1659
|
+
if resp["IsTruncated"]:
|
|
1660
|
+
token = resp["NextContinuationToken"]
|
|
1661
|
+
|
|
1543
1662
|
for item in resp['Contents']:
|
|
1544
1663
|
key = item['Key']
|
|
1545
1664
|
filename = key.removeprefix(layer_path)
|
|
1546
1665
|
if filename == '':
|
|
1547
1666
|
continue
|
|
1548
|
-
elif
|
|
1549
|
-
|
|
1550
|
-
elif flat and
|
|
1667
|
+
elif flat and '/' in key.removeprefix(path):
|
|
1668
|
+
continue
|
|
1669
|
+
elif not flat and filename[-1] == "/":
|
|
1670
|
+
continue
|
|
1671
|
+
|
|
1672
|
+
if not size and not return_resume_token:
|
|
1551
1673
|
yield filename
|
|
1674
|
+
elif size and not return_resume_token:
|
|
1675
|
+
yield (filename, int(item["Size"]))
|
|
1676
|
+
elif not size and return_resume_token:
|
|
1677
|
+
yield (filename, token)
|
|
1678
|
+
else:
|
|
1679
|
+
yield (filename, int(item["Size"]), token)
|
|
1552
1680
|
|
|
1553
|
-
for
|
|
1554
|
-
yield
|
|
1681
|
+
for result in iterate(resp):
|
|
1682
|
+
yield result
|
|
1555
1683
|
|
|
1556
1684
|
while resp['IsTruncated'] and resp['NextContinuationToken']:
|
|
1557
1685
|
resp = s3lst(path, resp['NextContinuationToken'])
|
|
1558
1686
|
|
|
1559
|
-
for
|
|
1560
|
-
yield
|
|
1687
|
+
for result in iterate(resp):
|
|
1688
|
+
yield result
|
|
1561
1689
|
|
|
1562
1690
|
def subtree_size(self, prefix:str = "") -> tuple[int,int]:
|
|
1563
1691
|
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:
|
|
@@ -299,11 +325,14 @@ def _cp_single(
|
|
|
299
325
|
print(f"cloudfiles: {source} is a directory (not copied).")
|
|
300
326
|
return
|
|
301
327
|
|
|
302
|
-
|
|
328
|
+
nsrc_cf = CloudFiles(nsrc)
|
|
329
|
+
|
|
330
|
+
xferpaths = nsrc_cf.basename(nsrc)
|
|
303
331
|
if use_stdin:
|
|
304
332
|
xferpaths = sys.stdin.readlines()
|
|
305
333
|
xferpaths = [ x.replace("\n", "") for x in xferpaths ]
|
|
306
334
|
prefix = os.path.commonprefix(xferpaths)
|
|
335
|
+
prefix = nsrc_cf.dirname(prefix) + nsrc_cf.sep
|
|
307
336
|
xferpaths = [ x.replace(prefix, "") for x in xferpaths ]
|
|
308
337
|
srcpath = prefix
|
|
309
338
|
if srcpath == "":
|
|
@@ -529,11 +558,14 @@ def _mv_single(
|
|
|
529
558
|
print(f"cloudfiles: {source} is a directory (not copied).")
|
|
530
559
|
return
|
|
531
560
|
|
|
532
|
-
|
|
561
|
+
nsrc_cf = CloudFiles(nsrc)
|
|
562
|
+
|
|
563
|
+
xferpaths = nsrc_cf.basename(nsrc)
|
|
533
564
|
if use_stdin:
|
|
534
565
|
xferpaths = sys.stdin.readlines()
|
|
535
566
|
xferpaths = [ x.replace("\n", "") for x in xferpaths ]
|
|
536
567
|
prefix = os.path.commonprefix(xferpaths)
|
|
568
|
+
prefix = nsrc_cf.dirname(prefix) + nsrc_cf.sep
|
|
537
569
|
xferpaths = [ x.replace(prefix, "") for x in xferpaths ]
|
|
538
570
|
srcpath = prefix
|
|
539
571
|
if srcpath == "":
|
|
@@ -636,12 +668,17 @@ def xfergroup():
|
|
|
636
668
|
"""
|
|
637
669
|
pass
|
|
638
670
|
|
|
671
|
+
def read_txt(text_file:str):
|
|
672
|
+
with open(text_file, "rt") as f:
|
|
673
|
+
yield from ( line.rstrip('\n').rstrip('\r\n') for line in f )
|
|
674
|
+
|
|
639
675
|
@xfergroup.command("init")
|
|
640
676
|
@click.argument("source")
|
|
641
677
|
@click.argument("destination")
|
|
642
678
|
@click.option('-c', '--compression', default='same', help="Destination compression type. Options: same, none, gzip, br, zstd", show_default=True)
|
|
643
679
|
@click.option('--db', default=None, required=True, help="Filepath of the sqlite database used for tracking progress. Different databases should be used for each job.")
|
|
644
|
-
|
|
680
|
+
@click.option('--txt', 'text_file', default=None, type=str, help="Load the source file list from a text file with one file path per line.")
|
|
681
|
+
def xferinit(source, destination, compression, db, text_file):
|
|
645
682
|
"""(1) Create db of files from the source."""
|
|
646
683
|
if compression == "same":
|
|
647
684
|
compression = None
|
|
@@ -650,9 +687,13 @@ def xferinit(source, destination, compression, db):
|
|
|
650
687
|
|
|
651
688
|
source = normalize_path(source)
|
|
652
689
|
destination = normalize_path(destination)
|
|
690
|
+
paths = source
|
|
691
|
+
|
|
692
|
+
if text_file:
|
|
693
|
+
paths = read_txt(text_file)
|
|
653
694
|
|
|
654
695
|
rt = ResumableTransfer(db)
|
|
655
|
-
rt.init(source, destination,
|
|
696
|
+
rt.init(source, destination, paths, compression)
|
|
656
697
|
|
|
657
698
|
@xfergroup.command("execute")
|
|
658
699
|
@click.argument("db")
|
|
@@ -804,18 +845,19 @@ def __rm(cloudpath, progress, paths):
|
|
|
804
845
|
@click.option('-s', '--summarize', is_flag=True, default=False, help="Sum a total for each input argument.")
|
|
805
846
|
@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
847
|
@click.option('-N', '--count-files', is_flag=True, default=False, help='Also report the number of files.')
|
|
807
|
-
|
|
848
|
+
@click.option('-e', '--group-by-ext', is_flag=True, default=False, help='Group and summarize disk usage by file extension.')
|
|
849
|
+
def du(paths, grand_total, summarize, human_readable, count_files, group_by_ext):
|
|
808
850
|
"""Display disk usage statistics."""
|
|
809
851
|
results = []
|
|
810
852
|
|
|
811
|
-
simplified_data =
|
|
853
|
+
simplified_data = False
|
|
812
854
|
|
|
813
855
|
for path in paths:
|
|
814
856
|
npath = normalize_path(path)
|
|
815
857
|
if ispathdir(path):
|
|
816
858
|
cf = CloudFiles(npath)
|
|
817
|
-
if summarize:
|
|
818
|
-
simplified_data =
|
|
859
|
+
if summarize and not group_by_ext:
|
|
860
|
+
simplified_data = True
|
|
819
861
|
results.append(cf.subtree_size())
|
|
820
862
|
else:
|
|
821
863
|
results.append(cf.size(cf.list()))
|
|
@@ -827,49 +869,43 @@ def du(paths, grand_total, summarize, human_readable, count_files):
|
|
|
827
869
|
return
|
|
828
870
|
results.append({ path: sz })
|
|
829
871
|
|
|
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"
|
|
872
|
+
disp = SI if human_readable else (lambda x: x)
|
|
848
873
|
|
|
849
874
|
summary = {}
|
|
850
875
|
num_files = 0
|
|
876
|
+
ext_totals = defaultdict(int)
|
|
877
|
+
|
|
851
878
|
for path, res in zip(paths, results):
|
|
852
879
|
if simplified_data:
|
|
853
|
-
summary[path] = sum(res.values())
|
|
854
|
-
num_files += len(res)
|
|
855
|
-
else:
|
|
856
880
|
summary[path] = res["num_bytes"]
|
|
857
881
|
num_files += res["N"]
|
|
882
|
+
else:
|
|
883
|
+
summary[path] = sum(res.values())
|
|
884
|
+
num_files += len(res)
|
|
885
|
+
if group_by_ext:
|
|
886
|
+
for filepath, size in res.items():
|
|
887
|
+
ext = os.path.splitext(filepath)[1].lower() or "(no extension)"
|
|
888
|
+
ext_totals[ext] += size
|
|
858
889
|
|
|
859
890
|
if summarize:
|
|
860
|
-
print(f"{
|
|
891
|
+
print(f"{disp(summary[path])}\t{path}")
|
|
861
892
|
|
|
862
893
|
if not summarize:
|
|
863
894
|
for res in results:
|
|
864
895
|
for pth, size in res.items():
|
|
865
|
-
print(f"{
|
|
896
|
+
print(f"{disp(size)}\t{pth}")
|
|
866
897
|
|
|
867
898
|
if grand_total:
|
|
868
|
-
print(f"{
|
|
899
|
+
print(f"{disp(sum(summary.values()))}\tbytes total")
|
|
869
900
|
|
|
870
901
|
if count_files:
|
|
871
902
|
print(f"{num_files}\tfiles total")
|
|
872
903
|
|
|
904
|
+
if group_by_ext:
|
|
905
|
+
print("\nUsage by file extension:")
|
|
906
|
+
for ext, total in sorted(ext_totals.items(), key=lambda x: x[1], reverse=True):
|
|
907
|
+
print(f"{disp(total)}\t{ext}")
|
|
908
|
+
|
|
873
909
|
@main.command()
|
|
874
910
|
@click.argument('paths', nargs=-1)
|
|
875
911
|
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": "c6239ff", "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
|