cloud-files 6.2.2__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.2 → cloud_files-6.3.0}/ChangeLog +6 -0
  2. {cloud_files-6.2.2 → cloud_files-6.3.0}/PKG-INFO +43 -2
  3. {cloud_files-6.2.2 → cloud_files-6.3.0}/README.md +29 -0
  4. {cloud_files-6.2.2 → cloud_files-6.3.0}/cloud_files.egg-info/PKG-INFO +43 -2
  5. {cloud_files-6.2.2 → 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.2 → cloud_files-6.3.0}/cloudfiles/cloudfiles.py +13 -2
  8. {cloud_files-6.2.2 → cloud_files-6.3.0}/cloudfiles/interfaces.py +158 -33
  9. {cloud_files-6.2.2 → 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.2/cloud_files.egg-info/pbr.json +0 -1
  12. {cloud_files-6.2.2 → cloud_files-6.3.0}/.github/workflows/test-suite.yml +0 -0
  13. {cloud_files-6.2.2 → cloud_files-6.3.0}/AUTHORS +0 -0
  14. {cloud_files-6.2.2 → cloud_files-6.3.0}/LICENSE +0 -0
  15. {cloud_files-6.2.2 → cloud_files-6.3.0}/MANIFEST.in +0 -0
  16. {cloud_files-6.2.2 → cloud_files-6.3.0}/automated_test.py +0 -0
  17. {cloud_files-6.2.2 → cloud_files-6.3.0}/cloud_files.egg-info/dependency_links.txt +0 -0
  18. {cloud_files-6.2.2 → cloud_files-6.3.0}/cloud_files.egg-info/entry_points.txt +0 -0
  19. {cloud_files-6.2.2 → cloud_files-6.3.0}/cloud_files.egg-info/not-zip-safe +0 -0
  20. {cloud_files-6.2.2 → cloud_files-6.3.0}/cloud_files.egg-info/requires.txt +0 -0
  21. {cloud_files-6.2.2 → cloud_files-6.3.0}/cloud_files.egg-info/top_level.txt +0 -0
  22. {cloud_files-6.2.2 → cloud_files-6.3.0}/cloudfiles/__init__.py +0 -0
  23. {cloud_files-6.2.2 → cloud_files-6.3.0}/cloudfiles/compression.py +0 -0
  24. {cloud_files-6.2.2 → cloud_files-6.3.0}/cloudfiles/connectionpools.py +0 -0
  25. {cloud_files-6.2.2 → cloud_files-6.3.0}/cloudfiles/exceptions.py +0 -0
  26. {cloud_files-6.2.2 → cloud_files-6.3.0}/cloudfiles/gcs.py +0 -0
  27. {cloud_files-6.2.2 → cloud_files-6.3.0}/cloudfiles/lib.py +0 -0
  28. {cloud_files-6.2.2 → cloud_files-6.3.0}/cloudfiles/monitoring.py +0 -0
  29. {cloud_files-6.2.2 → cloud_files-6.3.0}/cloudfiles/paths.py +0 -0
  30. {cloud_files-6.2.2 → cloud_files-6.3.0}/cloudfiles/resumable_tools.py +0 -0
  31. {cloud_files-6.2.2 → cloud_files-6.3.0}/cloudfiles/scheduler.py +0 -0
  32. {cloud_files-6.2.2 → cloud_files-6.3.0}/cloudfiles/secrets.py +0 -0
  33. {cloud_files-6.2.2 → cloud_files-6.3.0}/cloudfiles/threaded_queue.py +0 -0
  34. {cloud_files-6.2.2 → cloud_files-6.3.0}/cloudfiles/typing.py +0 -0
  35. {cloud_files-6.2.2 → cloud_files-6.3.0}/cloudfiles_cli/LICENSE +0 -0
  36. {cloud_files-6.2.2 → cloud_files-6.3.0}/cloudfiles_cli/__init__.py +0 -0
  37. {cloud_files-6.2.2 → cloud_files-6.3.0}/requirements.txt +0 -0
  38. {cloud_files-6.2.2 → cloud_files-6.3.0}/setup.cfg +0 -0
  39. {cloud_files-6.2.2 → cloud_files-6.3.0}/setup.py +0 -0
@@ -1,6 +1,12 @@
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
+
4
10
  6.2.2
5
11
  -----
6
12
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: cloud-files
3
- Version: 6.2.2
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.2
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
@@ -346,7 +348,14 @@ class FileInterface(StorageInterface):
346
348
  for path in file_paths:
347
349
  self.delete_file(path)
348
350
 
349
- 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
+ ):
350
359
  """
351
360
  List the files in the layer with the given prefix.
352
361
 
@@ -354,7 +363,6 @@ class FileInterface(StorageInterface):
354
363
  while non-flat means generate all file paths with that
355
364
  prefix.
356
365
  """
357
-
358
366
  layer_path = self.get_path_to_file("")
359
367
  path = os.path.join(layer_path, prefix)
360
368
 
@@ -405,8 +413,16 @@ class FileInterface(StorageInterface):
405
413
 
406
414
  filenames = list(map(stripext, filenames))
407
415
  filenames.sort()
408
- return iter(filenames)
409
-
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
+
410
426
  class MemoryInterface(StorageInterface):
411
427
  def __init__(self, path, secrets=None, request_payer=None, **kwargs):
412
428
  global MEM_BUCKET_POOL_LOCK
@@ -589,7 +605,14 @@ class MemoryInterface(StorageInterface):
589
605
  for path in file_paths:
590
606
  self.delete_file(path)
591
607
 
592
- 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
+ ):
593
616
  """
594
617
  List the files in the layer with the given prefix.
595
618
 
@@ -631,7 +654,17 @@ class MemoryInterface(StorageInterface):
631
654
 
632
655
  filenames = list(map(stripext, filenames))
633
656
  filenames.sort()
634
- 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 )
635
668
 
636
669
  def subtree_size(self, prefix:str = "") -> tuple[int,int]:
637
670
  layer_path = self.get_path_to_file("")
@@ -833,9 +866,15 @@ class GoogleCloudStorageInterface(StorageInterface):
833
866
  except google.cloud.exceptions.NotFound:
834
867
  pass
835
868
 
836
-
837
869
  @retry
838
- 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
+ ):
839
878
  """
840
879
  List the files in the layer with the given prefix.
841
880
 
@@ -848,29 +887,53 @@ class GoogleCloudStorageInterface(StorageInterface):
848
887
 
849
888
  delimiter = '/' if flat else None
850
889
 
890
+ items = "name"
891
+ if size:
892
+ items += ",size"
893
+
851
894
  blobs = self._bucket.list_blobs(
852
895
  prefix=path,
853
896
  delimiter=delimiter,
854
897
  page_size=2500,
855
- fields="items(name),nextPageToken,prefixes",
898
+ fields=f"items({items}),nextPageToken,prefixes",
899
+ page_token=resume_token,
856
900
  )
857
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
+
858
915
  for page in blobs.pages:
859
916
  if page.prefixes:
860
- yield from (
861
- item.removeprefix(path)
862
- for item in page.prefixes
863
- )
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)
864
928
 
865
929
  for blob in page:
866
930
  filename = blob.name.removeprefix(layer_path)
867
931
  if not filename:
868
932
  continue
869
933
  elif not flat and filename[-1] != '/':
870
- yield filename
934
+ yield return_args(filename, blob, page)
871
935
  elif flat and '/' not in blob.name.removeprefix(path):
872
- yield filename
873
-
936
+ yield return_args(filename, blob, page)
874
937
 
875
938
  @retry
876
939
  def subtree_size(self, prefix:str = "") -> tuple[int,int]:
@@ -1026,7 +1089,14 @@ class HttpInterface(StorageInterface):
1026
1089
  def files_exist(self, file_paths):
1027
1090
  return {path: self.exists(path) for path in file_paths}
1028
1091
 
1029
- 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
+ ):
1030
1100
  bucket = self._path.path.split('/')[0]
1031
1101
  prefix = posixpath.join(
1032
1102
  self._path.path.replace(bucket, '', 1),
@@ -1037,10 +1107,18 @@ class HttpInterface(StorageInterface):
1037
1107
 
1038
1108
  headers = self.default_headers()
1039
1109
 
1110
+ items = "name"
1111
+ if size:
1112
+ items += ",size"
1113
+
1114
+ fields = f"items({items}),nextPageToken,prefixes"
1115
+
1040
1116
  @retry_if_not(AuthorizationError)
1041
1117
  def request(token):
1042
1118
  nonlocal headers
1043
- params = {}
1119
+ params = {
1120
+ "fields": fields,
1121
+ }
1044
1122
  if prefix:
1045
1123
  params["prefix"] = prefix
1046
1124
  if token is not None:
@@ -1048,6 +1126,7 @@ class HttpInterface(StorageInterface):
1048
1126
  if flat:
1049
1127
  params["delimiter"] = '/'
1050
1128
 
1129
+
1051
1130
  results = self.session.get(
1052
1131
  f"https://storage.googleapis.com/storage/v1/b/{bucket}/o",
1053
1132
  params=params,
@@ -1067,17 +1146,33 @@ class HttpInterface(StorageInterface):
1067
1146
  token = None
1068
1147
  while True:
1069
1148
  results = request(token)
1149
+ token = results.get("nextPageToken", None)
1070
1150
 
1071
1151
  if 'prefixes' in results:
1072
- yield from (
1152
+ itr = (
1073
1153
  item.removeprefix(strip)
1074
1154
  for item in results["prefixes"]
1075
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 )
1076
1164
 
1077
1165
  for res in results.get("items", []):
1078
- 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)
1079
1175
 
1080
- token = results.get("nextPageToken", None)
1081
1176
  if token is None:
1082
1177
  break
1083
1178
 
@@ -1124,11 +1219,21 @@ class HttpInterface(StorageInterface):
1124
1219
  if flat:
1125
1220
  break
1126
1221
 
1127
- 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
+ ):
1128
1230
  if self._path.host == "https://storage.googleapis.com":
1129
- yield from self._list_files_google(prefix, flat)
1231
+ yield from self._list_files_google(prefix, flat, size, return_resume_token, resume_token)
1130
1232
  return
1131
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
+
1132
1237
  url = posixpath.join(self._path.host, self._path.path, prefix)
1133
1238
  resp = requests.head(url)
1134
1239
 
@@ -1490,7 +1595,14 @@ class S3Interface(StorageInterface):
1490
1595
  **self._additional_attrs,
1491
1596
  )
1492
1597
 
1493
- 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
+ ):
1494
1606
  """
1495
1607
  List the files in the layer with the given prefix.
1496
1608
 
@@ -1517,7 +1629,7 @@ class S3Interface(StorageInterface):
1517
1629
 
1518
1630
  return self._conn.list_objects_v2(**kwargs)
1519
1631
 
1520
- resp = s3lst(path)
1632
+ resp = s3lst(path, continuation_token=resume_token)
1521
1633
  # the case where the prefix is something like "build", but "build" is a subdirectory
1522
1634
  # so requery with "build/" to get the proper behavior
1523
1635
  if (
@@ -1528,7 +1640,7 @@ class S3Interface(StorageInterface):
1528
1640
  and len(resp.get("CommonPrefixes", [])) == 1
1529
1641
  ):
1530
1642
  path += '/'
1531
- resp = s3lst(path)
1643
+ resp = s3lst(path, continuation_token=resume_token)
1532
1644
 
1533
1645
  def iterate(resp):
1534
1646
  if 'CommonPrefixes' in resp.keys():
@@ -1540,24 +1652,37 @@ class S3Interface(StorageInterface):
1540
1652
  if 'Contents' not in resp.keys():
1541
1653
  resp['Contents'] = []
1542
1654
 
1655
+ token = None
1656
+ if resp["IsTruncated"]:
1657
+ token = resp["NextContinuationToken"]
1658
+
1543
1659
  for item in resp['Contents']:
1544
1660
  key = item['Key']
1545
1661
  filename = key.removeprefix(layer_path)
1546
1662
  if filename == '':
1547
1663
  continue
1548
- elif not flat and filename[-1] != '/':
1549
- yield filename
1550
- 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:
1551
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)
1552
1677
 
1553
- for filename in iterate(resp):
1554
- yield filename
1678
+ for result in iterate(resp):
1679
+ yield result
1555
1680
 
1556
1681
  while resp['IsTruncated'] and resp['NextContinuationToken']:
1557
1682
  resp = s3lst(path, resp['NextContinuationToken'])
1558
1683
 
1559
- for filename in iterate(resp):
1560
- yield filename
1684
+ for result in iterate(resp):
1685
+ yield result
1561
1686
 
1562
1687
  def subtree_size(self, prefix:str = "") -> tuple[int,int]:
1563
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": "c6239ff", "is_release": true}
File without changes
File without changes
File without changes
File without changes
File without changes