cloud-files 5.7.0__py3-none-any.whl → 6.0.0__py3-none-any.whl

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cloud-files
3
- Version: 5.7.0
3
+ Version: 6.0.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
@@ -1,13 +1,13 @@
1
1
  cloudfiles/__init__.py,sha256=pLB4CcV2l3Jgv_ni1520Np1pfzFj8Cpr87vNxFT3rNI,493
2
- cloudfiles/cloudfiles.py,sha256=tPG1PBLEjABPu-KLe93yf6xW_zbafPsQ6z5NuofyUoU,56743
2
+ cloudfiles/cloudfiles.py,sha256=SlSkGKCBdnne7vE7Y5_tvsMcFNx_coUWMtDajEnbHfY,58093
3
3
  cloudfiles/compression.py,sha256=WXJHnoNLJ_NWyoY9ygZmFA2qMou35_9xS5dzF7-2H-M,6262
4
4
  cloudfiles/connectionpools.py,sha256=aL8RiSjRepECfgAFmJcz80aJFKbou7hsbuEgugDKwB8,4814
5
5
  cloudfiles/exceptions.py,sha256=N0oGQNG-St6RvnT8e5p_yC_E61q2kgAe2scwAL0F49c,843
6
6
  cloudfiles/gcs.py,sha256=unqu5KxGKaPq6N4QeHSpCDdtnK1BzPOAerTZ8FLt2_4,3820
7
- cloudfiles/interfaces.py,sha256=5rUh2DWOVlg13fAxyZ0wAaQyfW04xc2zlUfTItFV-zQ,45325
7
+ cloudfiles/interfaces.py,sha256=Kg5t2-tWD0EoJ0qK-wid7zdxLgs7q0mDduPxAzyUUL0,47499
8
8
  cloudfiles/lib.py,sha256=HHjCvjmOjA0nZWSvHGoSeYpxqd6FAG8xk8LM212LAUA,5382
9
9
  cloudfiles/monitoring.py,sha256=N5Xq0PYZK1OxoYtwBFsnnfaq7dElTgY8Rn2Ez_I3aoo,20897
10
- cloudfiles/paths.py,sha256=HOvtdLSIYGwlwvnZt9d_Ez3TXOe7WWd18bZNDpExUDQ,12231
10
+ cloudfiles/paths.py,sha256=FLdShqkOg1XlkHurU9eiKzLadx2JFYG1EmleCpOFsYQ,12229
11
11
  cloudfiles/resumable_tools.py,sha256=NyuSoGh1SaP5akrHCpd9kgy2-JruEWrHW9lvJxV7jpE,6711
12
12
  cloudfiles/scheduler.py,sha256=ioqBT5bMPCVHEHlnL-SW_wHulxGgjeThiKHlnaDOydo,3831
13
13
  cloudfiles/secrets.py,sha256=IuYKHmmvFmQTyG2Zcmbx7e8U2LIv-woG5d8qyOlyCD8,5431
@@ -16,12 +16,12 @@ cloudfiles/threaded_queue.py,sha256=Nl4vfXhQ6nDLF8PZpSSBpww0M2zWtcd4DLs3W3BArBw,
16
16
  cloudfiles/typing.py,sha256=f3ZYkNfN9poxhGu5j-P0KCxjCCqSn9HAg5KiIPkjnCg,416
17
17
  cloudfiles_cli/LICENSE,sha256=Jna4xYE8CCQmaxjr5Fs-wmUBnIQJ1DGcNn9MMjbkprk,1538
18
18
  cloudfiles_cli/__init__.py,sha256=Wftt3R3F21QsHtWqx49ODuqT9zcSr0em7wk48kcH0WM,29
19
- cloudfiles_cli/cloudfiles_cli.py,sha256=k5_bMUcjDM2o-HjgwSaK6rT51t91nYjSAy3xZHf-qSs,38128
20
- cloud_files-5.7.0.dist-info/AUTHORS,sha256=BFVmobgAhaVFI5fqbuqAY5XmBQxe09ZZAsAOTy87hKQ,318
21
- cloud_files-5.7.0.dist-info/LICENSE,sha256=Jna4xYE8CCQmaxjr5Fs-wmUBnIQJ1DGcNn9MMjbkprk,1538
22
- cloud_files-5.7.0.dist-info/METADATA,sha256=oiedYRc-OIb1u8yRqMKOOKtvEJHj5phtQd-0V-cEqfI,30530
23
- cloud_files-5.7.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
24
- cloud_files-5.7.0.dist-info/entry_points.txt,sha256=xlirb1FVhn1mbcv4IoyMEGumDqKOA4VMVd3drsRQxIg,51
25
- cloud_files-5.7.0.dist-info/pbr.json,sha256=btfjSn_FM-LMcr5pxgz5jRJ_ImTfyOwfBZgFRavgNP8,46
26
- cloud_files-5.7.0.dist-info/top_level.txt,sha256=xPyrST3okJbsmdCF5IC2gYAVxg_aD5AYVTnNo8UuoZU,26
27
- cloud_files-5.7.0.dist-info/RECORD,,
19
+ cloudfiles_cli/cloudfiles_cli.py,sha256=jHbQasZb5DB_g8nGxS3Y0ekAdIPcSVrHN5mvEedUl0k,38908
20
+ cloud_files-6.0.0.dist-info/AUTHORS,sha256=BFVmobgAhaVFI5fqbuqAY5XmBQxe09ZZAsAOTy87hKQ,318
21
+ cloud_files-6.0.0.dist-info/LICENSE,sha256=Jna4xYE8CCQmaxjr5Fs-wmUBnIQJ1DGcNn9MMjbkprk,1538
22
+ cloud_files-6.0.0.dist-info/METADATA,sha256=SJw22OqzxSN3BvyacUjQgJ1trdAWs4mJv9hC0LYKQZk,30530
23
+ cloud_files-6.0.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
24
+ cloud_files-6.0.0.dist-info/entry_points.txt,sha256=xlirb1FVhn1mbcv4IoyMEGumDqKOA4VMVd3drsRQxIg,51
25
+ cloud_files-6.0.0.dist-info/pbr.json,sha256=P1Yg68JWbSeMCxsbPR-QhAUj2p8rzNNuqgMHtcFAveo,46
26
+ cloud_files-6.0.0.dist-info/top_level.txt,sha256=xPyrST3okJbsmdCF5IC2gYAVxg_aD5AYVTnNo8UuoZU,26
27
+ cloud_files-6.0.0.dist-info/RECORD,,
@@ -0,0 +1 @@
1
+ {"git_version": "38a2b59", "is_release": true}
cloudfiles/cloudfiles.py CHANGED
@@ -18,6 +18,7 @@ import platform
18
18
  import posixpath
19
19
  import re
20
20
  import shutil
21
+ import threading
21
22
  import types
22
23
  import time
23
24
 
@@ -1007,6 +1008,40 @@ class CloudFiles:
1007
1008
  return results
1008
1009
  return first(results.values())
1009
1010
 
1011
+ def subtree_size(self, prefix:GetPathType = "") -> dict[str,int]:
1012
+ """High performance size calculation for directory trees."""
1013
+ prefix, return_multiple = toiter(prefix, is_iter=True)
1014
+ total_bytes = 0
1015
+ total_files = 0
1016
+
1017
+ total = totalfn(prefix, None)
1018
+
1019
+ lock = threading.Lock()
1020
+
1021
+ def size_thunk(prefix):
1022
+ nonlocal total_bytes
1023
+ nonlocal total_files
1024
+ nonlocal lock
1025
+
1026
+ with self._get_connection() as conn:
1027
+ subtree_files, subtree_bytes = conn.subtree_size(prefix)
1028
+ with lock:
1029
+ total_files += subtree_files
1030
+ total_bytes += subtree_bytes
1031
+
1032
+ schedule_jobs(
1033
+ fns=( partial(size_thunk, path) for path in prefix ),
1034
+ concurrency=self.num_threads,
1035
+ progress=self.progress,
1036
+ green=self.green,
1037
+ total=total,
1038
+ )
1039
+
1040
+ return {
1041
+ "N": total_files,
1042
+ "num_bytes": total_bytes,
1043
+ }
1044
+
1010
1045
  @parallelize(desc="Delete")
1011
1046
  def delete(
1012
1047
  self, paths:GetPathType, total:Optional[int] = None,
@@ -1666,6 +1701,12 @@ class CloudFiles:
1666
1701
  return os.path.join(*paths)
1667
1702
  return posixpath.join(*paths)
1668
1703
 
1704
+ @property
1705
+ def sep(self) -> str:
1706
+ if self._path.protocol == "file":
1707
+ return os.sep
1708
+ return posixpath.sep
1709
+
1669
1710
  def dirname(self, path:str) -> str:
1670
1711
  if self._path.protocol == "file":
1671
1712
  return os.path.dirname(path)
@@ -1706,11 +1747,17 @@ class CloudFiles:
1706
1747
 
1707
1748
  class CloudFile:
1708
1749
  def __init__(
1709
- self, path:str, cache_meta:bool = False,
1750
+ self,
1751
+ path:str,
1752
+ cache_meta:bool = False,
1710
1753
  secrets:SecretsType = None,
1711
1754
  composite_upload_threshold:int = int(1e8),
1712
1755
  locking:bool = True,
1713
1756
  lock_dir:Optional[str] = None,
1757
+ endpoint:Optional[str] = None,
1758
+ no_sign_request:bool = False,
1759
+ request_payer:Optional[str] = None,
1760
+ use_https:bool = False,
1714
1761
  ):
1715
1762
  path = paths.normalize(path)
1716
1763
  self.cf = CloudFiles(
@@ -1719,6 +1766,10 @@ class CloudFile:
1719
1766
  composite_upload_threshold=composite_upload_threshold,
1720
1767
  locking=locking,
1721
1768
  lock_dir=lock_dir,
1769
+ use_https=use_https,
1770
+ endpoint=endpoint,
1771
+ request_payer=request_payer,
1772
+ no_sign_request=no_sign_request,
1722
1773
  )
1723
1774
  self.filename = paths.basename(path)
1724
1775
 
@@ -1726,6 +1777,10 @@ class CloudFile:
1726
1777
  self._size:Optional[int] = None
1727
1778
  self._head = None
1728
1779
 
1780
+ @property
1781
+ def sep(self) -> str:
1782
+ return self.cf.sep
1783
+
1729
1784
  @property
1730
1785
  def protocol(self):
1731
1786
  return self.cf.protocol
cloudfiles/interfaces.py CHANGED
@@ -48,6 +48,7 @@ MEM_POOL = None
48
48
 
49
49
  S3_ACLS = {
50
50
  "tigerdata": "private",
51
+ "nokura": "public-read",
51
52
  }
52
53
 
53
54
  S3ConnectionPoolParams = namedtuple('S3ConnectionPoolParams', 'service bucket_name request_payer')
@@ -303,6 +304,22 @@ class FileInterface(StorageInterface):
303
304
 
304
305
  return self.io_with_lock(do_size, path, exclusive=False)
305
306
 
307
+ def subtree_size(self, prefix:str = "") -> tuple[int,int]:
308
+ total_bytes = 0
309
+ total_files = 0
310
+
311
+ subdir = self.get_path_to_file("")
312
+ if prefix:
313
+ subdir = os.path.join(subdir, os.path.dirname(prefix))
314
+
315
+ for root, dirs, files in os.walk(subdir):
316
+ for f in files:
317
+ path = os.path.join(root, f)
318
+ total_files += 1
319
+ total_bytes += os.path.getsize(path)
320
+
321
+ return (total_files, total_bytes)
322
+
306
323
  def exists(self, file_path):
307
324
  path = self.get_path_to_file(file_path)
308
325
  def do_exists():
@@ -580,8 +597,7 @@ class MemoryInterface(StorageInterface):
580
597
 
581
598
  Returns: iterator
582
599
  """
583
- layer_path = self.get_path_to_file("")
584
- path = os.path.join(layer_path, prefix) + '*'
600
+ layer_path = self.get_path_to_file("")
585
601
 
586
602
  remove = layer_path
587
603
  if len(remove) and remove[-1] != '/':
@@ -615,6 +631,23 @@ class MemoryInterface(StorageInterface):
615
631
  filenames.sort()
616
632
  return iter(filenames)
617
633
 
634
+ def subtree_size(self, prefix:str = "") -> tuple[int,int]:
635
+ layer_path = self.get_path_to_file("")
636
+
637
+ remove = layer_path
638
+ if len(remove) and remove[-1] != '/':
639
+ remove += '/'
640
+
641
+ total_bytes = 0
642
+ total_files = 0
643
+ for filename, binary in self._data.items():
644
+ f_prefix = f.removeprefix(remove)[:len(prefix)]
645
+ if f_prefix == prefix:
646
+ total_bytes += len(binary)
647
+ total_files += 1
648
+
649
+ return (total_files, total_bytes)
650
+
618
651
  class GoogleCloudStorageInterface(StorageInterface):
619
652
  exists_batch_size = Batch._MAX_BATCH_SIZE
620
653
  delete_batch_size = Batch._MAX_BATCH_SIZE
@@ -798,6 +831,7 @@ class GoogleCloudStorageInterface(StorageInterface):
798
831
  except google.cloud.exceptions.NotFound:
799
832
  pass
800
833
 
834
+
801
835
  @retry
802
836
  def list_files(self, prefix, flat=False):
803
837
  """
@@ -815,35 +849,46 @@ class GoogleCloudStorageInterface(StorageInterface):
815
849
  blobs = self._bucket.list_blobs(
816
850
  prefix=path,
817
851
  delimiter=delimiter,
852
+ page_size=2500,
853
+ fields="items(name),nextPageToken",
818
854
  )
819
855
 
820
- first = True
821
- for blob in blobs:
822
- # This awkward construction is necessary
823
- # because the request that populates prefixes
824
- # isn't made until the iterator is activated.
825
- if first and blobs.prefixes:
856
+ for page in blobs.pages:
857
+ if page.prefixes:
826
858
  yield from (
827
859
  item.removeprefix(path)
828
- for item in blobs.prefixes
860
+ for item in page.prefixes
829
861
  )
830
- first = False
831
862
 
832
- filename = blob.name.removeprefix(layer_path)
833
- if not filename:
834
- continue
835
- elif not flat and filename[-1] != '/':
836
- yield filename
837
- elif flat and '/' not in blob.name.removeprefix(path):
838
- yield filename
863
+ for blob in page:
864
+ filename = blob.name.removeprefix(layer_path)
865
+ if not filename:
866
+ continue
867
+ elif not flat and filename[-1] != '/':
868
+ yield filename
869
+ elif flat and '/' not in blob.name.removeprefix(path):
870
+ yield filename
839
871
 
840
- # When there are no regular items at this level
841
- # we need to still print the directories.
842
- if first and blobs.prefixes:
843
- yield from (
844
- item.removeprefix(path)
845
- for item in blobs.prefixes
846
- )
872
+
873
+ @retry
874
+ def subtree_size(self, prefix:str = "") -> tuple[int,int]:
875
+ layer_path = self.get_path_to_file("")
876
+ path = posixpath.join(layer_path, prefix)
877
+
878
+ blobs = self._bucket.list_blobs(
879
+ prefix=path,
880
+ page_size=5000,
881
+ fields="items(name,size),nextPageToken",
882
+ )
883
+
884
+ total_bytes = 0
885
+ total_files = 0
886
+ for page in blobs.pages:
887
+ for blob in page:
888
+ total_bytes += blob.size
889
+ total_files += 1
890
+
891
+ return (total_files, total_bytes)
847
892
 
848
893
  def release_connection(self):
849
894
  global GC_POOL
@@ -892,6 +937,8 @@ class HttpInterface(StorageInterface):
892
937
  key = self.get_path_to_file(file_path)
893
938
  headers = self.default_headers()
894
939
  with self.session.head(key, headers=headers) as resp:
940
+ if resp.status_code in (404, 403):
941
+ return None
895
942
  resp.raise_for_status()
896
943
  return resp.headers
897
944
 
@@ -899,6 +946,9 @@ class HttpInterface(StorageInterface):
899
946
  headers = self.head(file_path)
900
947
  return int(headers["Content-Length"])
901
948
 
949
+ def subtree_size(self, prefix:str = "") -> tuple[int,int]:
950
+ raise NotImplementedError()
951
+
902
952
  @retry
903
953
  def get_file(self, file_path, start=None, end=None, part_size=None):
904
954
  key = self.get_path_to_file(file_path)
@@ -909,24 +959,20 @@ class HttpInterface(StorageInterface):
909
959
  end = int(end - 1) if end is not None else ''
910
960
  headers["Range"] = f"bytes={start}-{end}"
911
961
 
912
- resp = self.session.get(key, headers=headers)
913
-
914
- if resp.status_code in (404, 403):
915
- return (None, None, None, None)
916
- resp.close()
917
- resp.raise_for_status()
962
+ with self.session.get(key, headers=headers, stream=True) as resp:
963
+ if resp.status_code in (404, 403):
964
+ return (None, None, None, None)
965
+ resp.raise_for_status()
966
+ resp.raw.decode_content = False
967
+ content = resp.raw.read()
968
+ content_encoding = resp.headers.get('Content-Encoding', None)
918
969
 
919
970
  # Don't check MD5 for http because the etag can come in many
920
971
  # forms from either GCS, S3 or another service entirely. We
921
972
  # probably won't figure out how to decode it right.
922
973
  # etag = resp.headers.get('etag', None)
923
- content_encoding = resp.headers.get('Content-Encoding', None)
924
-
925
- # requests automatically decodes these
926
- if content_encoding in (None, '', 'gzip', 'deflate', 'br'):
927
- content_encoding = None
928
974
 
929
- return (resp.content, content_encoding, None, None)
975
+ return (content, content_encoding, None, None)
930
976
 
931
977
  @retry
932
978
  def save_file(self, src, dest, resumable) -> tuple[bool, int]:
@@ -1027,7 +1073,6 @@ class HttpInterface(StorageInterface):
1027
1073
  )
1028
1074
 
1029
1075
  for res in results.get("items", []):
1030
- print(res["name"])
1031
1076
  yield res["name"].removeprefix(strip)
1032
1077
 
1033
1078
  token = results.get("nextPageToken", None)
@@ -1500,6 +1545,47 @@ class S3Interface(StorageInterface):
1500
1545
  for filename in iterate(resp):
1501
1546
  yield filename
1502
1547
 
1548
+ def subtree_size(self, prefix:str = "") -> tuple[int,int]:
1549
+ layer_path = self.get_path_to_file("")
1550
+ path = posixpath.join(layer_path, prefix)
1551
+
1552
+ @retry
1553
+ def s3lst(path, continuation_token=None):
1554
+ kwargs = {
1555
+ 'Bucket': self._path.bucket,
1556
+ 'Prefix': path,
1557
+ **self._additional_attrs
1558
+ }
1559
+
1560
+ if continuation_token:
1561
+ kwargs['ContinuationToken'] = continuation_token
1562
+
1563
+ return self._conn.list_objects_v2(**kwargs)
1564
+
1565
+ resp = s3lst(path)
1566
+
1567
+ def iterate(resp):
1568
+ if 'Contents' not in resp.keys():
1569
+ resp['Contents'] = []
1570
+
1571
+ for item in resp['Contents']:
1572
+ yield item.get('Size', 0)
1573
+
1574
+ total_bytes = 0
1575
+ total_files = 0
1576
+ for num_bytes in iterate(resp):
1577
+ total_files += 1
1578
+ total_bytes += num_bytes
1579
+
1580
+ while resp['IsTruncated'] and resp['NextContinuationToken']:
1581
+ resp = s3lst(path, resp['NextContinuationToken'])
1582
+
1583
+ for num_bytes in iterate(resp):
1584
+ total_files += 1
1585
+ total_bytes += num_bytes
1586
+
1587
+ return (total_files, total_bytes)
1588
+
1503
1589
  def release_connection(self):
1504
1590
  global S3_POOL
1505
1591
  service = self._path.alias or 's3'
cloudfiles/paths.py CHANGED
@@ -22,7 +22,7 @@ PRECOMPUTED_SUFFIX = '|neuroglancer-precomputed:'
22
22
 
23
23
  ALIAS_FILE = os.path.join(CLOUD_FILES_DIR, "aliases.json")
24
24
  OFFICIAL_ALIASES = {
25
- "nokura": "s3://https://nokura.pni.princeton.edu/",
25
+ "nokura": "s3://https://c10s.pni.princeton.edu/",
26
26
  "matrix": "s3://https://s3-hpcrc.rc.princeton.edu/",
27
27
  "tigerdata": "s3://https://td.princeton.edu/",
28
28
  }
@@ -182,6 +182,19 @@ def get_mfp(path, recursive):
182
182
 
183
183
  return (many, flat, prefix, suffix)
184
184
 
185
+ @main.command("mkdir")
186
+ @click.argument("paths", nargs=-1)
187
+ def _mkdir(paths):
188
+ """
189
+ Create paths on the local file system.
190
+ """
191
+ for path in paths:
192
+ path = normalize_path(path)
193
+ protocol = get_protocol(path)
194
+
195
+ if protocol == "file":
196
+ mkdir(path.replace("file://", "", 1))
197
+
185
198
  @main.command()
186
199
  @click.argument("source", nargs=-1)
187
200
  @click.argument("destination", nargs=1)
@@ -588,6 +601,7 @@ def touch(
588
601
  ctx, sources,
589
602
  progress, no_sign_request,
590
603
  ):
604
+ """Create file if it doesn't exist."""
591
605
  sources = list(map(normalize_path, sources))
592
606
  sources = [ src.replace("precomputed://", "") for src in sources ]
593
607
  pbar = tqdm(total=len(sources), desc="Touch", disable=(not progress))
@@ -788,14 +802,22 @@ def __rm(cloudpath, progress, paths):
788
802
  @click.option('-c', '--grand-total', is_flag=True, default=False, help="Sum a grand total of all inputs.")
789
803
  @click.option('-s', '--summarize', is_flag=True, default=False, help="Sum a total for each input argument.")
790
804
  @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.')
791
- def du(paths, grand_total, summarize, human_readable):
805
+ @click.option('-N', '--count-files', is_flag=True, default=False, help='Also report the number of files.')
806
+ def du(paths, grand_total, summarize, human_readable, count_files):
792
807
  """Display disk usage statistics."""
793
808
  results = []
809
+
810
+ list_data = False
811
+
794
812
  for path in paths:
795
813
  npath = normalize_path(path)
796
814
  if ispathdir(path):
797
815
  cf = CloudFiles(npath)
798
- results.append(cf.size(cf.list()))
816
+ if summarize:
817
+ results.append(cf.subtree_size())
818
+ else:
819
+ list_data = True
820
+ results.append(cf.size(cf.list()))
799
821
  else:
800
822
  cf = CloudFiles(os.path.dirname(npath))
801
823
  sz = cf.size(os.path.basename(npath))
@@ -824,8 +846,15 @@ def du(paths, grand_total, summarize, human_readable):
824
846
  return f"{(val / 2**60):.2f} EiB"
825
847
 
826
848
  summary = {}
849
+ num_files = 0
827
850
  for path, res in zip(paths, results):
828
- summary[path] = sum(res.values())
851
+ if list_data:
852
+ summary[path] = sum(res.values())
853
+ num_files += len(res)
854
+ else:
855
+ summary[path] = res["num_bytes"]
856
+ num_files += res["N"]
857
+
829
858
  if summarize:
830
859
  print(f"{SI(summary[path])}\t{path}")
831
860
 
@@ -835,7 +864,10 @@ def du(paths, grand_total, summarize, human_readable):
835
864
  print(f"{SI(size)}\t{pth}")
836
865
 
837
866
  if grand_total:
838
- print(f"{SI(sum(summary.values()))}\ttotal")
867
+ print(f"{SI(sum(summary.values()))}\tbytes total")
868
+
869
+ if count_files:
870
+ print(f"{num_files}\tfiles total")
839
871
 
840
872
  @main.command()
841
873
  @click.argument('paths', nargs=-1)
@@ -1 +0,0 @@
1
- {"git_version": "cab2668", "is_release": true}