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.
Files changed (39) hide show
  1. {cloud_files-6.2.1 → cloud_files-6.3.0}/ChangeLog +11 -0
  2. {cloud_files-6.2.1 → cloud_files-6.3.0}/PKG-INFO +43 -2
  3. {cloud_files-6.2.1 → cloud_files-6.3.0}/README.md +29 -0
  4. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloud_files.egg-info/PKG-INFO +43 -2
  5. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloud_files.egg-info/SOURCES.txt +2 -1
  6. cloud_files-6.3.0/cloud_files.egg-info/pbr.json +1 -0
  7. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/cloudfiles.py +13 -2
  8. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/interfaces.py +162 -36
  9. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles_cli/cloudfiles_cli.py +50 -29
  10. cloud_files-6.3.0/cloudfiles_cli/listing_db.py +174 -0
  11. cloud_files-6.2.1/cloud_files.egg-info/pbr.json +0 -1
  12. {cloud_files-6.2.1 → cloud_files-6.3.0}/.github/workflows/test-suite.yml +0 -0
  13. {cloud_files-6.2.1 → cloud_files-6.3.0}/AUTHORS +0 -0
  14. {cloud_files-6.2.1 → cloud_files-6.3.0}/LICENSE +0 -0
  15. {cloud_files-6.2.1 → cloud_files-6.3.0}/MANIFEST.in +0 -0
  16. {cloud_files-6.2.1 → cloud_files-6.3.0}/automated_test.py +0 -0
  17. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloud_files.egg-info/dependency_links.txt +0 -0
  18. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloud_files.egg-info/entry_points.txt +0 -0
  19. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloud_files.egg-info/not-zip-safe +0 -0
  20. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloud_files.egg-info/requires.txt +0 -0
  21. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloud_files.egg-info/top_level.txt +0 -0
  22. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/__init__.py +0 -0
  23. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/compression.py +0 -0
  24. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/connectionpools.py +0 -0
  25. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/exceptions.py +0 -0
  26. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/gcs.py +0 -0
  27. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/lib.py +0 -0
  28. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/monitoring.py +0 -0
  29. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/paths.py +0 -0
  30. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/resumable_tools.py +0 -0
  31. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/scheduler.py +0 -0
  32. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/secrets.py +0 -0
  33. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/threaded_queue.py +0 -0
  34. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles/typing.py +0 -0
  35. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles_cli/LICENSE +0 -0
  36. {cloud_files-6.2.1 → cloud_files-6.3.0}/cloudfiles_cli/__init__.py +0 -0
  37. {cloud_files-6.2.1 → cloud_files-6.3.0}/requirements.txt +0 -0
  38. {cloud_files-6.2.1 → cloud_files-6.3.0}/setup.cfg +0 -0
  39. {cloud_files-6.2.1 → cloud_files-6.3.0}/setup.py +0 -0
@@ -1,6 +1,17 @@
1
1
  CHANGES
2
2
  =======
3
3
 
4
+ 6.3.0
5
+ -----
6
+
7
+ * feat(ls): \`cloudfiles ls --sqlite bucket.db ...\` (#125)
8
+ * feat(cli): add group by file extension to du
9
+
10
+ 6.2.2
11
+ -----
12
+
13
+ * fix: handle more synthetic etags
14
+
4
15
  6.2.1
5
16
  -----
6
17
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: cloud-files
3
- Version: 6.2.1
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
  [![PyPI version](https://badge.fury.io/py/cloud-files.svg)](https://badge.fury.io/py/cloud-files) [![Test Suite](https://github.com/seung-lab/cloud-files/workflows/Test%20Suite/badge.svg)](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
1
+ Metadata-Version: 2.4
2
2
  Name: cloud-files
3
- Version: 6.2.1
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
  [![PyPI version](https://badge.fury.io/py/cloud-files.svg)](https://badge.fury.io/py/cloud-files) [![Test Suite](https://github.com/seung-lab/cloud-files/workflows/Test%20Suite/badge.svg)](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.
@@ -33,4 +33,5 @@ cloudfiles/threaded_queue.py
33
33
  cloudfiles/typing.py
34
34
  cloudfiles_cli/LICENSE
35
35
  cloudfiles_cli/__init__.py
36
- cloudfiles_cli/cloudfiles_cli.py
36
+ cloudfiles_cli/cloudfiles_cli.py
37
+ cloudfiles_cli/listing_db.py
@@ -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, prefix:str = "", flat:bool = False
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(prefix, flat):
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(self, prefix, flat):
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
- return iter(filenames)
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(self, prefix, flat):
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
- return iter(filenames)
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(self, prefix, flat=False):
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(name),nextPageToken,prefixes",
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
- yield from (
860
- item.removeprefix(path)
861
- for item in page.prefixes
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(self, prefix, flat):
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
- yield from (
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
- yield res["name"].removeprefix(strip)
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(self, prefix, flat=False):
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 ingested via NFS
1332
- # Not good behavior, but nothing much we can do other than ignore it.
1333
- if etag != "1-" and not validate_s3_multipart_etag(content, etag, part_size):
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(self, prefix, flat=False):
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 not flat and filename[-1] != '/':
1548
- yield filename
1549
- elif flat and '/' not in key.removeprefix(path):
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 filename in iterate(resp):
1553
- yield filename
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 filename in iterate(resp):
1559
- yield filename
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
- def du(paths, grand_total, summarize, human_readable, count_files):
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 = True
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 = False
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
- def SI(val):
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"{SI(summary[path])}\t{path}")
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"{SI(size)}\t{pth}")
881
+ print(f"{disp(size)}\t{pth}")
866
882
 
867
883
  if grand_total:
868
- print(f"{SI(sum(summary.values()))}\tbytes total")
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