rclone-api 1.0.76__tar.gz → 1.0.78__tar.gz

Sign up to get free protection for your applications and to get access to all the features.
Files changed (70) hide show
  1. {rclone_api-1.0.76 → rclone_api-1.0.78}/PKG-INFO +1 -1
  2. {rclone_api-1.0.76 → rclone_api-1.0.78}/pyproject.toml +1 -1
  3. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api/__init__.py +2 -1
  4. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api/completed_process.py +11 -0
  5. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api/dir_listing.py +10 -0
  6. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api/file.py +18 -4
  7. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api/group_files.py +48 -1
  8. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api/rclone.py +84 -2
  9. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api/remote.py +3 -0
  10. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api/types.py +15 -0
  11. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api/util.py +1 -1
  12. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api.egg-info/PKG-INFO +1 -1
  13. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api.egg-info/SOURCES.txt +1 -0
  14. {rclone_api-1.0.76 → rclone_api-1.0.78}/tests/test_diff.py +23 -0
  15. rclone_api-1.0.78/tests/test_size_files.py +85 -0
  16. {rclone_api-1.0.76 → rclone_api-1.0.78}/.aiderignore +0 -0
  17. {rclone_api-1.0.76 → rclone_api-1.0.78}/.github/workflows/lint.yml +0 -0
  18. {rclone_api-1.0.76 → rclone_api-1.0.78}/.github/workflows/push_macos.yml +0 -0
  19. {rclone_api-1.0.76 → rclone_api-1.0.78}/.github/workflows/push_ubuntu.yml +0 -0
  20. {rclone_api-1.0.76 → rclone_api-1.0.78}/.github/workflows/push_win.yml +0 -0
  21. {rclone_api-1.0.76 → rclone_api-1.0.78}/.gitignore +0 -0
  22. {rclone_api-1.0.76 → rclone_api-1.0.78}/.pylintrc +0 -0
  23. {rclone_api-1.0.76 → rclone_api-1.0.78}/.vscode/launch.json +0 -0
  24. {rclone_api-1.0.76 → rclone_api-1.0.78}/.vscode/settings.json +0 -0
  25. {rclone_api-1.0.76 → rclone_api-1.0.78}/.vscode/tasks.json +0 -0
  26. {rclone_api-1.0.76 → rclone_api-1.0.78}/LICENSE +0 -0
  27. {rclone_api-1.0.76 → rclone_api-1.0.78}/MANIFEST.in +0 -0
  28. {rclone_api-1.0.76 → rclone_api-1.0.78}/README.md +0 -0
  29. {rclone_api-1.0.76 → rclone_api-1.0.78}/clean +0 -0
  30. {rclone_api-1.0.76 → rclone_api-1.0.78}/install +0 -0
  31. {rclone_api-1.0.76 → rclone_api-1.0.78}/lint +0 -0
  32. {rclone_api-1.0.76 → rclone_api-1.0.78}/requirements.testing.txt +0 -0
  33. {rclone_api-1.0.76 → rclone_api-1.0.78}/setup.cfg +0 -0
  34. {rclone_api-1.0.76 → rclone_api-1.0.78}/setup.py +0 -0
  35. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api/assets/example.txt +0 -0
  36. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api/cli.py +0 -0
  37. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api/cmd/list_files.py +0 -0
  38. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api/config.py +0 -0
  39. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api/convert.py +0 -0
  40. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api/deprecated.py +0 -0
  41. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api/diff.py +0 -0
  42. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api/dir.py +0 -0
  43. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api/exec.py +0 -0
  44. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api/filelist.py +0 -0
  45. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api/process.py +0 -0
  46. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api/rpath.py +0 -0
  47. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api/scan_missing_folders.py +0 -0
  48. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api/walk.py +0 -0
  49. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api.egg-info/dependency_links.txt +0 -0
  50. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api.egg-info/entry_points.txt +0 -0
  51. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api.egg-info/requires.txt +0 -0
  52. {rclone_api-1.0.76 → rclone_api-1.0.78}/src/rclone_api.egg-info/top_level.txt +0 -0
  53. {rclone_api-1.0.76 → rclone_api-1.0.78}/test +0 -0
  54. {rclone_api-1.0.76 → rclone_api-1.0.78}/tests/test_cmd_list_files.py +0 -0
  55. {rclone_api-1.0.76 → rclone_api-1.0.78}/tests/test_copy.py +0 -0
  56. {rclone_api-1.0.76 → rclone_api-1.0.78}/tests/test_copy_files.py +0 -0
  57. {rclone_api-1.0.76 → rclone_api-1.0.78}/tests/test_group_files.py +0 -0
  58. {rclone_api-1.0.76 → rclone_api-1.0.78}/tests/test_is_synced.py +0 -0
  59. {rclone_api-1.0.76 → rclone_api-1.0.78}/tests/test_ls.py +0 -0
  60. {rclone_api-1.0.76 → rclone_api-1.0.78}/tests/test_mount.py +0 -0
  61. {rclone_api-1.0.76 → rclone_api-1.0.78}/tests/test_mount_s3.py +0 -0
  62. {rclone_api-1.0.76 → rclone_api-1.0.78}/tests/test_mount_webdav.py +0 -0
  63. {rclone_api-1.0.76 → rclone_api-1.0.78}/tests/test_obscure.py +0 -0
  64. {rclone_api-1.0.76 → rclone_api-1.0.78}/tests/test_remote_control.py +0 -0
  65. {rclone_api-1.0.76 → rclone_api-1.0.78}/tests/test_remotes.py +0 -0
  66. {rclone_api-1.0.76 → rclone_api-1.0.78}/tests/test_scan_missing_folders.py +0 -0
  67. {rclone_api-1.0.76 → rclone_api-1.0.78}/tests/test_serve_webdav.py +0 -0
  68. {rclone_api-1.0.76 → rclone_api-1.0.78}/tests/test_walk.py +0 -0
  69. {rclone_api-1.0.76 → rclone_api-1.0.78}/tox.ini +0 -0
  70. {rclone_api-1.0.76 → rclone_api-1.0.78}/upload_package.sh +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: rclone_api
3
- Version: 1.0.76
3
+ Version: 1.0.78
4
4
  Summary: rclone api in python
5
5
  Home-page: https://github.com/zackees/rclone-api
6
6
  Maintainer: Zachary Vorhies
@@ -15,7 +15,7 @@ dependencies = [
15
15
  "python-dotenv>=1.0.0",
16
16
  ]
17
17
  # Change this with the version number bump.
18
- version = "1.0.76"
18
+ version = "1.0.78"
19
19
 
20
20
  [tool.setuptools]
21
21
  package-dir = {"" = "src"}
@@ -9,7 +9,7 @@ from .process import Process
9
9
  from .rclone import Rclone, rclone_verbose
10
10
  from .remote import Remote
11
11
  from .rpath import RPath
12
- from .types import ListingOption, Order
12
+ from .types import ListingOption, Order, SizeResult
13
13
 
14
14
  __all__ = [
15
15
  "Rclone",
@@ -29,4 +29,5 @@ __all__ = [
29
29
  "ListingOption",
30
30
  "Order",
31
31
  "ListingOption",
32
+ "SizeResult",
32
33
  ]
@@ -47,3 +47,14 @@ class CompletedProcess:
47
47
  if rtn != 0:
48
48
  return rtn
49
49
  return 0
50
+
51
+ def __str__(self) -> str:
52
+
53
+ cmd_strs: list[str] = []
54
+ rtn_cods: list[int] = []
55
+ for cp in self.completed:
56
+ cmd_strs.append(subprocess.list2cmdline(cp.args))
57
+ rtn_cods.append(cp.returncode)
58
+ msg = f"CompletedProcess: {len(cmd_strs)} commands\n"
59
+ msg += "\n".join([f"{cmd} -> {rtn}" for cmd, rtn in zip(cmd_strs, rtn_cods)])
60
+ return msg
@@ -29,6 +29,16 @@ class DirListing:
29
29
  self.dirs: list[Dir] = [Dir(d) for d in dirs_and_files if d.is_dir]
30
30
  self.files: list[File] = [File(f) for f in dirs_and_files if not f.is_dir]
31
31
 
32
+ def files_relative(self, prefix: str) -> list[str]:
33
+ """Return a list of file paths relative to the root directory."""
34
+ from rclone_api.file import File
35
+
36
+ out: list[str] = []
37
+ f: File
38
+ for f in self.files:
39
+ out.append(f.relative_to(prefix))
40
+ return out
41
+
32
42
  def __str__(self) -> str:
33
43
  n_files = len(self.files)
34
44
  n_dirs = len(self.dirs)
@@ -1,4 +1,5 @@
1
1
  import json
2
+ from pathlib import Path
2
3
 
3
4
  from rclone_api.rpath import RPath
4
5
 
@@ -40,10 +41,23 @@ class File:
40
41
 
41
42
  def to_string(self, include_remote: bool = True) -> str:
42
43
  """Convert the File to a string."""
43
- out = str(self.path)
44
- if not include_remote:
45
- _, out = out.split(":", 1)
46
- return out
44
+ # out = str(self.path)
45
+ remote = self.path.remote
46
+ rest = self.path.path
47
+ if include_remote:
48
+ return f"{remote.name}:{rest}"
49
+ return rest
50
+
51
+ def relative_to(self, prefix: str) -> str:
52
+ """Return the relative path to the other directory."""
53
+ self_path = Path(str(self))
54
+ rel_path = self_path.relative_to(prefix)
55
+ return str(rel_path.as_posix())
56
+
57
+ @property
58
+ def size(self) -> int:
59
+ """Get the size of the file."""
60
+ return self.path.size
47
61
 
48
62
  def __str__(self) -> str:
49
63
  return str(self.path)
@@ -9,6 +9,19 @@ class FilePathParts:
9
9
  parents: list[str]
10
10
  name: str
11
11
 
12
+ def to_string(self, include_remote: bool, include_bucket: bool) -> str:
13
+ """Convert to string, may throw for not include_bucket=False."""
14
+ parents = list(self.parents)
15
+ if not include_bucket:
16
+ parents.pop(0)
17
+ path = "/".join(parents)
18
+ if path:
19
+ path += "/"
20
+ path += self.name
21
+ if include_remote:
22
+ return f"{self.remote}{path}"
23
+ return path
24
+
12
25
 
13
26
  def parse_file(file_path: str) -> FilePathParts:
14
27
  """Parse file path into parts."""
@@ -165,4 +178,38 @@ def group_files(files: list[str], fully_qualified: bool = True) -> dict[str, lis
165
178
  return out
166
179
 
167
180
 
168
- __all__ = ["group_files"]
181
+ def group_under_remote(
182
+ files: list[str], fully_qualified: bool = True
183
+ ) -> dict[str, list[str]]:
184
+ """split between filename and remote"""
185
+
186
+ #### DOE STHIS NEED TO BE REMOVEDD????? #####
187
+
188
+ assert fully_qualified is True, "Not implemented for fully_qualified=False"
189
+ out: dict[str, list[str]] = {}
190
+ for file in files:
191
+ parsed = parse_file(file)
192
+ remote = f"{parsed.remote}:"
193
+ file_list = out.setdefault(remote, [])
194
+ file_list.append(parsed.to_string(include_remote=False, include_bucket=True))
195
+ return out
196
+
197
+
198
+ def group_under_remote_bucket(
199
+ files: list[str], fully_qualified: bool = True
200
+ ) -> dict[str, list[str]]:
201
+ """split between filename and bucket"""
202
+ assert fully_qualified is True, "Not implemented for fully_qualified=False"
203
+ out: dict[str, list[str]] = {}
204
+ for file in files:
205
+ parsed = parse_file(file)
206
+ remote = f"{parsed.remote}:"
207
+ parts = parsed.parents
208
+ bucket = parts[0]
209
+ remote_bucket = f"{remote}{bucket}"
210
+ file_list = out.setdefault(remote_bucket, [])
211
+ file_list.append(parsed.to_string(include_remote=False, include_bucket=False))
212
+ return out
213
+
214
+
215
+ __all__ = ["group_files", "group_under_remote", "group_under_remote_bucket"]
@@ -22,11 +22,19 @@ from rclone_api.diff import DiffItem, DiffOption, diff_stream_from_running_proce
22
22
  from rclone_api.dir_listing import DirListing
23
23
  from rclone_api.exec import RcloneExec
24
24
  from rclone_api.file import File
25
- from rclone_api.group_files import group_files
25
+ from rclone_api.group_files import (
26
+ group_files,
27
+ group_under_remote_bucket,
28
+ )
26
29
  from rclone_api.process import Process
27
30
  from rclone_api.remote import Remote
28
31
  from rclone_api.rpath import RPath
29
- from rclone_api.types import ListingOption, ModTimeStrategy, Order
32
+ from rclone_api.types import (
33
+ ListingOption,
34
+ ModTimeStrategy,
35
+ Order,
36
+ SizeResult,
37
+ )
30
38
  from rclone_api.util import (
31
39
  get_check,
32
40
  get_rclone_exe,
@@ -827,3 +835,77 @@ class Rclone:
827
835
  if proc.poll() is not None:
828
836
  raise ValueError("NFS serve process failed to start")
829
837
  return proc
838
+
839
+ def size_files(
840
+ self,
841
+ src: str,
842
+ files: list[str],
843
+ fast_list: bool = True,
844
+ other_args: list[str] | None = None,
845
+ check: bool | None = False,
846
+ verbose: bool | None = None,
847
+ ) -> SizeResult:
848
+ """Get the size of a list of files. Example of files items: "remote:bucket/to/file"."""
849
+ verbose = get_verbose(verbose)
850
+ check = get_check(check)
851
+ files = list(files)
852
+ prefix = src if src.endswith(":") else f"{src}/"
853
+ if src:
854
+ files = [f"{prefix}{f}" for f in files]
855
+ file_list: dict[str, list[str]]
856
+ file_list = group_under_remote_bucket(files)
857
+ all_files: list[File] = []
858
+ for src_path, files in file_list.items():
859
+ cmd = ["lsjson", src_path, "--files-only", "-R"]
860
+ with TemporaryDirectory() as tmpdir:
861
+ # print("files: " + ",".join(files))
862
+ include_files_txt = Path(tmpdir) / "include_files.txt"
863
+ include_files_txt.write_text("\n".join(files), encoding="utf-8")
864
+ cmd += ["--files-from", str(include_files_txt)]
865
+ if fast_list:
866
+ cmd.append("--fast-list")
867
+ if other_args:
868
+ cmd += other_args
869
+ cp = self._run(cmd, check=check)
870
+
871
+ if cp.returncode != 0:
872
+ if check:
873
+ raise ValueError(f"Error getting file sizes: {cp.stderr}")
874
+ else:
875
+ warnings.warn(f"Error getting file sizes: {cp.stderr}")
876
+ stdout = cp.stdout
877
+ pieces = src_path.split(":", 1)
878
+ remote_name = pieces[0]
879
+ parent_path: str | None
880
+ if len(pieces) > 1:
881
+ parent_path = pieces[1]
882
+ else:
883
+ parent_path = None
884
+ remote = Remote(name=remote_name, rclone=self)
885
+ paths: list[RPath] = RPath.from_json_str(
886
+ stdout, remote, parent_path=parent_path
887
+ )
888
+ # print(paths)
889
+ all_files += [File(p) for p in paths]
890
+ file_sizes: dict[str, int] = {}
891
+ f: File
892
+ for f in all_files:
893
+ p = f.to_string(include_remote=True)
894
+ if p in file_sizes:
895
+ warnings.warn(f"Duplicate file found: {p}")
896
+ continue
897
+ size = f.size
898
+ if size == 0:
899
+ warnings.warn(f"File size is 0: {p}")
900
+ file_sizes[p] = f.size
901
+ total_size = sum(file_sizes.values())
902
+ file_sizes_path_corrected: dict[str, int] = {}
903
+ for path, size in file_sizes.items():
904
+ # remove the prefix
905
+ path_path = Path(path)
906
+ path_str = path_path.relative_to(prefix).as_posix()
907
+ file_sizes_path_corrected[path_str] = size
908
+ out: SizeResult = SizeResult(
909
+ prefix=prefix, total_size=total_size, file_sizes=file_sizes_path_corrected
910
+ )
911
+ return out
@@ -7,6 +7,9 @@ class Remote:
7
7
  def __init__(self, name: str, rclone: Any) -> None:
8
8
  from rclone_api.rclone import Rclone
9
9
 
10
+ if ":" in name:
11
+ raise ValueError("Remote name cannot contain ':'")
12
+
10
13
  assert isinstance(rclone, Rclone)
11
14
  self.name = name
12
15
  self.rclone: Rclone = rclone
@@ -1,3 +1,4 @@
1
+ from dataclasses import dataclass
1
2
  from enum import Enum
2
3
 
3
4
 
@@ -16,3 +17,17 @@ class Order(Enum):
16
17
  NORMAL = "normal"
17
18
  REVERSE = "reverse"
18
19
  RANDOM = "random"
20
+
21
+
22
+ # class GroupingOption(Enum):
23
+ # BUCKET = "bucket"
24
+ # REMOTE = "remote"
25
+
26
+
27
+ @dataclass
28
+ class SizeResult:
29
+ """Size result dataclass."""
30
+
31
+ prefix: str
32
+ total_size: int
33
+ file_sizes: dict[str, int]
@@ -104,7 +104,7 @@ def rclone_execute(
104
104
  )
105
105
  if verbose:
106
106
  cmd_str = subprocess.list2cmdline(cmd)
107
- print(f"Running: {cmd_str}")
107
+ print(f"\nRunning: {cmd_str}")
108
108
  cp = subprocess.run(
109
109
  cmd, capture_output=capture, encoding="utf-8", check=False, shell=False
110
110
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: rclone_api
3
- Version: 1.0.76
3
+ Version: 1.0.78
4
4
  Summary: rclone api in python
5
5
  Home-page: https://github.com/zackees/rclone-api
6
6
  Maintainer: Zachary Vorhies
@@ -64,4 +64,5 @@ tests/test_remote_control.py
64
64
  tests/test_remotes.py
65
65
  tests/test_scan_missing_folders.py
66
66
  tests/test_serve_webdav.py
67
+ tests/test_size_files.py
67
68
  tests/test_walk.py
@@ -70,6 +70,29 @@ class RcloneDiffTests(unittest.TestCase):
70
70
  msg = "\n".join([str(item) for item in all])
71
71
  print(msg)
72
72
 
73
+ def test_min_max_size(self) -> None:
74
+ rclone = Rclone(_generate_rclone_config())
75
+ item: DiffItem
76
+ all: list[DiffItem] = list(
77
+ rclone.diff(
78
+ "dst:rclone-api-unit-test", "dst:rclone-api-unit-test", min_size="70M"
79
+ )
80
+ )
81
+ for item in all:
82
+ if "internaly_ai_alignment.mp4" in item.path:
83
+ break
84
+ else:
85
+ self.fail("internaly_ai_alignment.mp4 not found")
86
+ all.clear()
87
+ all = list(
88
+ rclone.diff(
89
+ "dst:rclone-api-unit-test", "dst:rclone-api-unit-test", max_size="70M"
90
+ )
91
+ )
92
+ for item in all:
93
+ if "internaly_ai_alignment.mp4" in item.path:
94
+ self.fail("internaly_ai_alignment.mp4 not filtered")
95
+
73
96
  def test_diff_missing_on_dst(self) -> None:
74
97
  rclone = Rclone(_generate_rclone_config())
75
98
  item: DiffItem
@@ -0,0 +1,85 @@
1
+ """
2
+ Unit test file.
3
+ """
4
+
5
+ import os
6
+ import unittest
7
+
8
+ from dotenv import load_dotenv
9
+
10
+ from rclone_api import Config, DirListing, Rclone, SizeResult
11
+
12
+ load_dotenv()
13
+
14
+ BUCKET_NAME = os.getenv("BUCKET_NAME") # Default if not in .env
15
+
16
+
17
+ def _generate_rclone_config() -> Config:
18
+
19
+ # BUCKET_NAME = os.getenv("BUCKET_NAME", "TorrentBooks") # Default if not in .env
20
+
21
+ # Load additional environment variables
22
+ BUCKET_KEY_SECRET = os.getenv("BUCKET_KEY_SECRET")
23
+ BUCKET_KEY_PUBLIC = os.getenv("BUCKET_KEY_PUBLIC")
24
+ # BUCKET_URL = os.getenv("BUCKET_URL")
25
+ BUCKET_URL = "sfo3.digitaloceanspaces.com"
26
+
27
+ config_text = f"""
28
+ [dst]
29
+ type = s3
30
+ provider = DigitalOcean
31
+ access_key_id = {BUCKET_KEY_PUBLIC}
32
+ secret_access_key = {BUCKET_KEY_SECRET}
33
+ endpoint = {BUCKET_URL}
34
+ """
35
+
36
+ out = Config(config_text)
37
+ return out
38
+
39
+
40
+ class RcloneSizeFilesTester(unittest.TestCase):
41
+ """Test rclone functionality."""
42
+
43
+ def setUp(self) -> None:
44
+ """Check if all required environment variables are set before running tests."""
45
+ required_vars = [
46
+ "BUCKET_NAME",
47
+ "BUCKET_KEY_SECRET",
48
+ "BUCKET_KEY_PUBLIC",
49
+ "BUCKET_URL",
50
+ ]
51
+ missing = [var for var in required_vars if not os.getenv(var)]
52
+ if missing:
53
+ self.skipTest(
54
+ f"Missing required environment variables: {', '.join(missing)}"
55
+ )
56
+ os.environ["RCLONE_API_VERBOSE"] = "1"
57
+
58
+ def test_size(self) -> None:
59
+ rclone = Rclone(_generate_rclone_config())
60
+ # rclone.walk
61
+ dirlisting: DirListing
62
+ is_first = True
63
+ files: list[str] = []
64
+ src = f"dst:{BUCKET_NAME}"
65
+ for dirlisting in rclone.walk(src, max_depth=1):
66
+ if is_first:
67
+ # assert just one file
68
+ # assert len(dirlisting.files) == 1
69
+ self.assertEqual(len(dirlisting.files), 1)
70
+ # assert it's first.txt
71
+ self.assertEqual(dirlisting.files[0].name, "first.txt")
72
+ is_first = False
73
+ # print(dirlisting)
74
+ for file in dirlisting.files_relative(src):
75
+ files.append(file)
76
+
77
+ # print(files)
78
+
79
+ size_map: SizeResult = rclone.size_files(src=src, files=files, check=True)
80
+ print(size_map)
81
+ print("done")
82
+
83
+
84
+ if __name__ == "__main__":
85
+ unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes