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.
Files changed (39) hide show
  1. {cloud_files-6.2.2 → cloud_files-6.3.1}/ChangeLog +13 -0
  2. {cloud_files-6.2.2 → cloud_files-6.3.1}/PKG-INFO +30 -1
  3. {cloud_files-6.2.2 → cloud_files-6.3.1}/README.md +29 -0
  4. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloud_files.egg-info/PKG-INFO +30 -1
  5. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloud_files.egg-info/SOURCES.txt +2 -1
  6. cloud_files-6.3.1/cloud_files.egg-info/pbr.json +1 -0
  7. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/cloudfiles.py +13 -2
  8. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/interfaces.py +163 -35
  9. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles_cli/cloudfiles_cli.py +69 -33
  10. cloud_files-6.3.1/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.1}/.github/workflows/test-suite.yml +0 -0
  13. {cloud_files-6.2.2 → cloud_files-6.3.1}/AUTHORS +0 -0
  14. {cloud_files-6.2.2 → cloud_files-6.3.1}/LICENSE +0 -0
  15. {cloud_files-6.2.2 → cloud_files-6.3.1}/MANIFEST.in +0 -0
  16. {cloud_files-6.2.2 → cloud_files-6.3.1}/automated_test.py +0 -0
  17. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloud_files.egg-info/dependency_links.txt +0 -0
  18. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloud_files.egg-info/entry_points.txt +0 -0
  19. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloud_files.egg-info/not-zip-safe +0 -0
  20. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloud_files.egg-info/requires.txt +0 -0
  21. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloud_files.egg-info/top_level.txt +0 -0
  22. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/__init__.py +0 -0
  23. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/compression.py +0 -0
  24. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/connectionpools.py +0 -0
  25. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/exceptions.py +0 -0
  26. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/gcs.py +0 -0
  27. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/lib.py +0 -0
  28. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/monitoring.py +0 -0
  29. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/paths.py +0 -0
  30. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/resumable_tools.py +0 -0
  31. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/scheduler.py +0 -0
  32. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/secrets.py +0 -0
  33. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/threaded_queue.py +0 -0
  34. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles/typing.py +0 -0
  35. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles_cli/LICENSE +0 -0
  36. {cloud_files-6.2.2 → cloud_files-6.3.1}/cloudfiles_cli/__init__.py +0 -0
  37. {cloud_files-6.2.2 → cloud_files-6.3.1}/requirements.txt +0 -0
  38. {cloud_files-6.2.2 → cloud_files-6.3.1}/setup.cfg +0 -0
  39. {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.2.2
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.2.2
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.
@@ -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": "369aaaf", "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
@@ -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(self, prefix, flat):
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
- return iter(filenames)
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(self, prefix, flat):
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
- return iter(filenames)
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(self, prefix, flat=False):
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(name),nextPageToken,prefixes",
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
- yield from (
861
- item.removeprefix(path)
862
- for item in page.prefixes
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(self, prefix, flat):
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
- yield from (
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
- yield res["name"].removeprefix(strip)
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(self, prefix, flat=False):
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, "bucket-owner-full-control"),
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(self, prefix, flat=False):
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 not flat and filename[-1] != '/':
1549
- yield filename
1550
- elif flat and '/' not in key.removeprefix(path):
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 filename in iterate(resp):
1554
- yield filename
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 filename in iterate(resp):
1560
- yield filename
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
- xferpaths = os.path.basename(nsrc)
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
- xferpaths = os.path.basename(nsrc)
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
- def xferinit(source, destination, compression, db):
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, source, compression)
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
- def du(paths, grand_total, summarize, human_readable, count_files):
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 = True
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 = False
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
- 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"
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"{SI(summary[path])}\t{path}")
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"{SI(size)}\t{pth}")
896
+ print(f"{disp(size)}\t{pth}")
866
897
 
867
898
  if grand_total:
868
- print(f"{SI(sum(summary.values()))}\tbytes total")
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