python3-cyberfusion-common 2.10.11.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Cyberfusion
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.1
2
+ Name: python3-cyberfusion-common
3
+ Version: 2.10.11.1
4
+ Summary: Common utilities.
5
+ Home-page: https://vcs.cyberfusion.nl/shared/python3-cyberfusion-common
6
+ Author: William Edwards
7
+ Author-email: wedwards@cyberfusion.nl
8
+ License: MIT
9
+ Keywords: cyberfusion,common
10
+ Platform: linux
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+
18
+ # python3-cyberfusion-common
19
+
20
+ Common utilities.
21
+
22
+ # Install
23
+
24
+ ## PyPI
25
+
26
+ Run the following command to install the package from PyPI:
27
+
28
+ pip3 install python3-cyberfusion-common
29
+
30
+ ## Generic
31
+
32
+ Run the following command to create a source distribution:
33
+
34
+ python3 setup.py sdist
35
+
36
+ ## Debian
37
+
38
+ Run the following commands to build a Debian package:
39
+
40
+ mk-build-deps -i -t 'apt -o Debug::pkgProblemResolver=yes --no-install-recommends -y'
41
+ dpkg-buildpackage -us -uc
42
+
43
+ # Configure
44
+
45
+ No configuration is supported.
46
+
47
+ # Usage
48
+
49
+ See code.
50
+
51
+ # Tests
52
+
53
+ Run tests with pytest:
54
+
55
+ pytest tests/
56
+
57
+ The tests must be run from the project root.
58
+
59
+
@@ -0,0 +1,40 @@
1
+ # python3-cyberfusion-common
2
+
3
+ Common utilities.
4
+
5
+ # Install
6
+
7
+ ## PyPI
8
+
9
+ Run the following command to install the package from PyPI:
10
+
11
+ pip3 install python3-cyberfusion-common
12
+
13
+ ## Generic
14
+
15
+ Run the following command to create a source distribution:
16
+
17
+ python3 setup.py sdist
18
+
19
+ ## Debian
20
+
21
+ Run the following commands to build a Debian package:
22
+
23
+ mk-build-deps -i -t 'apt -o Debug::pkgProblemResolver=yes --no-install-recommends -y'
24
+ dpkg-buildpackage -us -uc
25
+
26
+ # Configure
27
+
28
+ No configuration is supported.
29
+
30
+ # Usage
31
+
32
+ See code.
33
+
34
+ # Tests
35
+
36
+ Run tests with pytest:
37
+
38
+ pytest tests/
39
+
40
+ The tests must be run from the project root.
@@ -0,0 +1,22 @@
1
+ [tool.isort]
2
+ profile = "black"
3
+ line_length = 79
4
+ known_first_party = ["cyberfusion"]
5
+ default_section = "THIRDPARTY"
6
+
7
+ [tool.black]
8
+ line-length = 79
9
+ target-version = ["py37"]
10
+ exclude = '''
11
+ (
12
+ /(
13
+ \.eggs # exclude a few common directories in the
14
+ | \.git # root of the project
15
+ | \.hg
16
+ | \.mypy_cache
17
+ | \.tox
18
+ | \.venv
19
+ | venv
20
+ )/
21
+ )
22
+ '''
@@ -0,0 +1,12 @@
1
+ [aliases]
2
+ test = pytest
3
+
4
+ [tool:pytest]
5
+ norecursedirs = .git build dist *.egg __pycache__ .cache
6
+ testpaths = tests
7
+ junit_suite_name = python3-cyberfusion-common
8
+
9
+ [egg_info]
10
+ tag_build =
11
+ tag_date = 0
12
+
@@ -0,0 +1,34 @@
1
+ """A setuptools based setup module."""
2
+
3
+ from setuptools import setup
4
+
5
+ with open("README.md", "r", encoding="utf-8") as fh:
6
+ long_description = fh.read()
7
+
8
+ setup(
9
+ name="python3-cyberfusion-common",
10
+ version="2.10.11.1",
11
+ description="Common utilities.",
12
+ long_description=long_description,
13
+ long_description_content_type="text/markdown",
14
+ python_requires=">=3.9",
15
+ author="William Edwards",
16
+ author_email="wedwards@cyberfusion.nl",
17
+ url="https://vcs.cyberfusion.nl/shared/python3-cyberfusion-common",
18
+ platforms=["linux"],
19
+ packages=["cyberfusion.Common", "cyberfusion.Common.exceptions"],
20
+ package_dir={"": "src"},
21
+ data_files=[],
22
+ install_requires=[
23
+ "cached_property==1.5.2",
24
+ "psutil==5.8.0",
25
+ "requests==2.25.1",
26
+ ],
27
+ classifiers=[
28
+ "Programming Language :: Python :: 3",
29
+ "License :: OSI Approved :: MIT License",
30
+ "Operating System :: OS Independent",
31
+ ],
32
+ keywords=["cyberfusion", "common"],
33
+ license="MIT",
34
+ )
@@ -0,0 +1,41 @@
1
+ """Helper for handling config files."""
2
+
3
+ import configparser
4
+ import os
5
+ from typing import Optional
6
+
7
+ from cached_property import cached_property
8
+
9
+
10
+ class CyberfusionConfig:
11
+ """Abstract ConfigParser implementation for use in scripts."""
12
+
13
+ def __init__(
14
+ self,
15
+ path: Optional[str] = None,
16
+ ) -> None:
17
+ """Set attributes."""
18
+ self._path = path
19
+
20
+ @property
21
+ def path(self) -> str:
22
+ """Set config file path."""
23
+ if not self._path:
24
+ self._path = os.path.join(
25
+ os.path.sep, "etc", "cyberfusion", "cyberfusion.cfg"
26
+ )
27
+
28
+ return self._path
29
+
30
+ @cached_property
31
+ def config(self) -> configparser.ConfigParser:
32
+ """Read config."""
33
+ config = configparser.ConfigParser()
34
+
35
+ config.read(self.path)
36
+
37
+ return config
38
+
39
+ def get(self, section: str, key: str) -> str:
40
+ """Retrieve config option."""
41
+ return self.config.get(section, key)
@@ -0,0 +1,81 @@
1
+ """Helper for handling filesystem."""
2
+
3
+ import os
4
+ from enum import Enum
5
+ from pathlib import Path
6
+
7
+ import psutil
8
+
9
+ CEPH_NAME_ATTRIBUTE_RBYTES = "ceph.dir.rbytes"
10
+
11
+
12
+ class FilesystemType(Enum):
13
+ """Filesystem types.
14
+
15
+ We don't expect any other filesystems than the ones in this Enum under normal
16
+ circumstances. This Enum prevents you from having to write filesystem types
17
+ by name manually; it's not a complete list of all possibilities.
18
+ """
19
+
20
+ EXT4 = "ext4"
21
+ EXT2 = "ext2" # Usually for /boot
22
+ XFS = "xfs"
23
+ CEPH = "ceph"
24
+ OVERLAY = "overlay" # Usually for Docker
25
+ APFS = "apfs" # Development on macOS
26
+
27
+
28
+ def get_filesystem(path: str) -> str:
29
+ """Get filesystem that absolute path is on.
30
+
31
+ Returns the earliest filesystem. E.g. if '/a/b/c' is passed, and both '/a' and
32
+ '/b' are mountpoints, '/b' is returned.
33
+
34
+ Does not resolve symlinks, so the filesystem of the symlink itself is looked
35
+ up.
36
+
37
+ Inspired by https://stackoverflow.com/a/4453715/3837431
38
+ """
39
+
40
+ # Ensure path is an absolute path. For relative paths, dirname() would return
41
+ # the wrong result
42
+
43
+ path = os.path.abspath(path)
44
+
45
+ # Work our way down the path by getting the directory that the current
46
+ # filesystem object is in
47
+
48
+ while not os.path.ismount(path):
49
+ path = os.path.dirname(path)
50
+
51
+ return path
52
+
53
+
54
+ def get_filesystem_type(path: str) -> FilesystemType:
55
+ """Get type of filesystem."""
56
+
57
+ # If this yields no results (i.e. the path is missing), next() raises StopIteration
58
+
59
+ partition = next(
60
+ filter(
61
+ lambda x: x.mountpoint == path, psutil.disk_partitions(all=True)
62
+ )
63
+ )
64
+
65
+ return FilesystemType(partition.fstype)
66
+
67
+
68
+ def get_directory_size(path: str) -> int:
69
+ """Get size of directory."""
70
+
71
+ # CephFS is a special case, as CephFS has extended attributes to determine
72
+ # size
73
+
74
+ is_ceph = get_filesystem_type(get_filesystem(path)) == FilesystemType.CEPH
75
+
76
+ if is_ceph:
77
+ return int(os.getxattr(path, CEPH_NAME_ATTRIBUTE_RBYTES).decode("utf-8")) # type: ignore[attr-defined]
78
+
79
+ # Loop through directory for filesystems without special implementation
80
+
81
+ return sum(f.stat().st_size for f in Path(path).rglob("*") if f.is_file())
@@ -0,0 +1,185 @@
1
+ """Helper for comparing filesystem objects."""
2
+
3
+ import filecmp
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Dict, List, Optional, Tuple
7
+
8
+
9
+ def _get_recursive_dircmps(dircmp: filecmp.dircmp) -> List[filecmp.dircmp]:
10
+ """Get dircmp object for every subdirectory.
11
+
12
+ Subdirectories are only returned for directories that are in the left and
13
+ right directories.
14
+ """
15
+ dircmps = []
16
+
17
+ dircmps.append(dircmp)
18
+
19
+ for sub_dircmp in dircmp.subdirs.values():
20
+ dircmps.extend(_get_recursive_dircmps(sub_dircmp))
21
+
22
+ return dircmps
23
+
24
+
25
+ def get_different_files_in_directories(
26
+ left_directory_path: str, right_directory_path: str
27
+ ) -> List[Tuple[str, str]]:
28
+ """Get files with same name in both directories but with different contents or attributes.
29
+
30
+ Files which are present in subdirectories that only exist in either the left
31
+ or right directories are not returned. Use the 'get_directories_only_in_*_directory'
32
+ functions to get subdirectories that are in either the left or right directories.
33
+ This ensures that this function does not return individual files in whole new
34
+ directories.
35
+ """
36
+ dircmps = _get_recursive_dircmps(
37
+ filecmp.dircmp(left_directory_path, right_directory_path)
38
+ )
39
+
40
+ files = []
41
+
42
+ for dircmp in dircmps:
43
+ for diff_file in dircmp.diff_files:
44
+ absolute_path_left = os.path.join(dircmp.left, diff_file)
45
+ absolute_path_right = os.path.join(dircmp.right, diff_file)
46
+
47
+ files.append((absolute_path_left, absolute_path_right))
48
+
49
+ return files
50
+
51
+
52
+ def get_files_only_in_left_directory(
53
+ left_directory_path: str, right_directory_path: str
54
+ ) -> List[str]:
55
+ """Get files which are only present in left directory.
56
+
57
+ Files which are present in subdirectories that only exist in either the left
58
+ or right directories are not returned. Use the 'get_directories_only_in_*_directory'
59
+ functions to get subdirectories that are in either the left or right directories.
60
+ This ensures that this function does not return individual files in whole new
61
+ directories.
62
+ """
63
+ dircmps = _get_recursive_dircmps(
64
+ filecmp.dircmp(left_directory_path, right_directory_path)
65
+ )
66
+
67
+ files = []
68
+
69
+ for dircmp in dircmps:
70
+ for left_only_file in dircmp.left_only:
71
+ absolute_path = os.path.join(dircmp.left, left_only_file)
72
+
73
+ if os.path.isdir(absolute_path):
74
+ continue
75
+
76
+ files.append(absolute_path)
77
+
78
+ return files
79
+
80
+
81
+ def get_files_only_in_right_directory(
82
+ left_directory_path: str, right_directory_path: str
83
+ ) -> List[str]:
84
+ """Get files which are only present in right directory.
85
+
86
+ Files which are present in subdirectories that only exist in either the left
87
+ or right directories are not returned. Use the 'get_directories_only_in_*_directory'
88
+ functions to get subdirectories that are in either the left or right directories.
89
+ This ensures that this function does not return individual files in whole new
90
+ directories.
91
+ """
92
+ dircmps = _get_recursive_dircmps(
93
+ filecmp.dircmp(left_directory_path, right_directory_path)
94
+ )
95
+
96
+ files = []
97
+
98
+ for dircmp in dircmps:
99
+ for right_only_file in dircmp.right_only:
100
+ absolute_path = os.path.join(dircmp.right, right_only_file)
101
+
102
+ if os.path.isdir(absolute_path):
103
+ continue
104
+
105
+ files.append(absolute_path)
106
+
107
+ return files
108
+
109
+
110
+ def get_directories_only_in_left_directory(
111
+ left_directory_path: str, right_directory_path: str
112
+ ) -> List[str]:
113
+ """Get directories which are only present in left directory."""
114
+ dircmps = _get_recursive_dircmps(
115
+ filecmp.dircmp(left_directory_path, right_directory_path)
116
+ )
117
+
118
+ directories = []
119
+
120
+ for dircmp in dircmps:
121
+ for left_only_file in dircmp.left_only:
122
+ absolute_path = os.path.join(dircmp.left, left_only_file)
123
+
124
+ if os.path.isfile(absolute_path):
125
+ continue
126
+
127
+ directories.append(absolute_path)
128
+
129
+ return directories
130
+
131
+
132
+ def get_directories_only_in_right_directory(
133
+ left_directory_path: str, right_directory_path: str
134
+ ) -> List[str]:
135
+ """Get directories which are only present in right directory."""
136
+ dircmps = _get_recursive_dircmps(
137
+ filecmp.dircmp(left_directory_path, right_directory_path)
138
+ )
139
+
140
+ directories = []
141
+
142
+ for dircmp in dircmps:
143
+ for right_only_file in dircmp.right_only:
144
+ absolute_path = os.path.join(dircmp.right, right_only_file)
145
+
146
+ if os.path.isfile(absolute_path):
147
+ continue
148
+
149
+ directories.append(absolute_path)
150
+
151
+ return directories
152
+
153
+
154
+ def get_nested_directory_structure(
155
+ paths_list: List[str],
156
+ ) -> Dict[str, Optional[Dict[str, Optional[dict]]]]:
157
+ """Convert list of paths to nested dict."""
158
+ tree: Dict[str, Optional[Dict[str, Optional[dict]]]] = {}
159
+
160
+ # Example of logic:
161
+ #
162
+ # >>> tree = {}
163
+ # >>> node = tree
164
+ # >>> node = node.setdefault('a', {})
165
+ # >>> node = node.setdefault('b', {})
166
+ # >>> node = tree
167
+ # >>> tree
168
+ # {'a': {'b': {}}}
169
+
170
+ for path in paths_list:
171
+ node = tree # Reset to root level for new path, see docstring
172
+
173
+ parts = Path(path).parts
174
+
175
+ for i, element in enumerate(parts):
176
+ last = i == (len(parts) - 1)
177
+
178
+ if last:
179
+ node.setdefault(element, None)
180
+ else:
181
+ node = node.setdefault( # type: ignore[assignment] # mypy does not support nested types
182
+ element, {}
183
+ ) # Next recursion will be at this level, see docstring
184
+
185
+ return tree
@@ -0,0 +1,157 @@
1
+ """Helper classes for Cyberfusion scripts."""
2
+
3
+ import base64
4
+ import os
5
+ import secrets
6
+ import shutil
7
+ import string
8
+ import uuid
9
+ from datetime import datetime, timezone
10
+ from hashlib import md5, sha1
11
+ from socket import gethostname
12
+ from typing import Optional
13
+
14
+ import requests
15
+
16
+ from cyberfusion.Common.exceptions import ExecutableNotFound
17
+
18
+ CHAR_PREFIX_WILDCARD = "*"
19
+ CHAR_LABEL = "."
20
+
21
+
22
+ class EmailAddresses:
23
+ """Cyberfusion email addresses."""
24
+
25
+ ENGINEERING = "engineering@cyberfusion.nl"
26
+ SUPPORT = "support@cyberfusion.nl"
27
+
28
+
29
+ def find_executable(name: str) -> str:
30
+ """Find absolute path of executable.
31
+
32
+ Use this function when the executable must exist. This function raises an
33
+ exception if it does not.
34
+ """
35
+ path = shutil.which(name)
36
+
37
+ if path:
38
+ return path
39
+
40
+ raise ExecutableNotFound(name)
41
+
42
+
43
+ def try_find_executable(name: str) -> Optional[str]:
44
+ """Find absolute path of executable.
45
+
46
+ Use this function when the executable may be absent. This function returns
47
+ None if it is.
48
+ """
49
+ return shutil.which(name)
50
+
51
+
52
+ def download_from_url(
53
+ url: str, *, root_directory: Optional[str] = None
54
+ ) -> str:
55
+ """Download from URL.
56
+
57
+ Large files are supported due to use of chunking and streaming.
58
+
59
+ Inspired by https://stackoverflow.com/a/16696317/19535769
60
+ """
61
+ if root_directory is None:
62
+ root_directory = os.path.join(os.path.sep, "tmp")
63
+
64
+ path = os.path.join(root_directory, generate_random_string())
65
+
66
+ # Create and set permissions
67
+
68
+ with open(path, "w"):
69
+ pass
70
+
71
+ os.chmod(path, 0o600)
72
+
73
+ # Download
74
+
75
+ with requests.get(url, stream=True) as r:
76
+ r.raise_for_status()
77
+
78
+ with open(path, "wb") as f:
79
+ for chunk in r.iter_content(chunk_size=8192):
80
+ f.write(chunk)
81
+
82
+ return path
83
+
84
+
85
+ def get_tmp_file() -> str:
86
+ """Create tmp file and return path."""
87
+ path = os.path.join(os.path.sep, "tmp", str(uuid.uuid4()))
88
+
89
+ with open(path, "w"):
90
+ pass
91
+
92
+ os.chmod(path, 0o600) # Do not allow regular users to view file contents
93
+
94
+ return path
95
+
96
+
97
+ def get_hostname() -> str:
98
+ """Get hostname."""
99
+ return gethostname()
100
+
101
+
102
+ def get_domain_is_wildcard(domain: str) -> bool:
103
+ """Determine if domain is wildcard."""
104
+ return domain.split(CHAR_LABEL)[0] == CHAR_PREFIX_WILDCARD
105
+
106
+
107
+ def get_today_timestamp() -> float:
108
+ """Get UNIX timestamp from the first second of today."""
109
+ current_datetime = datetime.utcnow()
110
+
111
+ return (
112
+ datetime(
113
+ year=current_datetime.year,
114
+ month=current_datetime.month,
115
+ day=current_datetime.day,
116
+ )
117
+ .replace(tzinfo=timezone.utc)
118
+ .timestamp()
119
+ )
120
+
121
+
122
+ def generate_random_string(length: int = 24) -> str:
123
+ """Generate random string."""
124
+
125
+ # Set allowed characters (ASCII + digits)
126
+
127
+ alphabet = string.ascii_letters + string.digits
128
+
129
+ # Return string using allowed characters
130
+
131
+ return "".join(secrets.choice(alphabet) for i in range(length))
132
+
133
+
134
+ def hash_string_mariadb(string: str) -> str:
135
+ """Hash string with SHA-1 by MariaDB standards."""
136
+ return (
137
+ "*"
138
+ + sha1(sha1(string.encode("utf-8")).digest()) # noqa: S303
139
+ .hexdigest()
140
+ .upper()
141
+ )
142
+
143
+
144
+ def convert_bytes_gib(size: int) -> float:
145
+ """Convert bytes to GiB."""
146
+ return size / (1024**3)
147
+
148
+
149
+ def get_md5_hash(path: str) -> str:
150
+ """Get Base64 encoded 128-bit MD5 digest of file."""
151
+ hash_ = md5()
152
+
153
+ with open(path, "rb") as f:
154
+ for chunk in iter(lambda: f.read(128 * hash_.block_size), b""):
155
+ hash_.update(chunk)
156
+
157
+ return base64.b64encode(hash_.digest()).decode()
@@ -0,0 +1,10 @@
1
+ """Exceptions."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class ExecutableNotFound(Exception):
8
+ """Raise exception when executable not found."""
9
+
10
+ name: str
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.1
2
+ Name: python3-cyberfusion-common
3
+ Version: 2.10.11.1
4
+ Summary: Common utilities.
5
+ Home-page: https://vcs.cyberfusion.nl/shared/python3-cyberfusion-common
6
+ Author: William Edwards
7
+ Author-email: wedwards@cyberfusion.nl
8
+ License: MIT
9
+ Keywords: cyberfusion,common
10
+ Platform: linux
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+
18
+ # python3-cyberfusion-common
19
+
20
+ Common utilities.
21
+
22
+ # Install
23
+
24
+ ## PyPI
25
+
26
+ Run the following command to install the package from PyPI:
27
+
28
+ pip3 install python3-cyberfusion-common
29
+
30
+ ## Generic
31
+
32
+ Run the following command to create a source distribution:
33
+
34
+ python3 setup.py sdist
35
+
36
+ ## Debian
37
+
38
+ Run the following commands to build a Debian package:
39
+
40
+ mk-build-deps -i -t 'apt -o Debug::pkgProblemResolver=yes --no-install-recommends -y'
41
+ dpkg-buildpackage -us -uc
42
+
43
+ # Configure
44
+
45
+ No configuration is supported.
46
+
47
+ # Usage
48
+
49
+ See code.
50
+
51
+ # Tests
52
+
53
+ Run tests with pytest:
54
+
55
+ pytest tests/
56
+
57
+ The tests must be run from the project root.
58
+
59
+
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ setup.cfg
5
+ setup.py
6
+ src/cyberfusion/Common/Config.py
7
+ src/cyberfusion/Common/Filesystem.py
8
+ src/cyberfusion/Common/FilesystemComparison.py
9
+ src/cyberfusion/Common/__init__.py
10
+ src/cyberfusion/Common/exceptions/__init__.py
11
+ src/python3_cyberfusion_common.egg-info/PKG-INFO
12
+ src/python3_cyberfusion_common.egg-info/SOURCES.txt
13
+ src/python3_cyberfusion_common.egg-info/dependency_links.txt
14
+ src/python3_cyberfusion_common.egg-info/requires.txt
15
+ src/python3_cyberfusion_common.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+ cached_property==1.5.2
2
+ psutil==5.8.0
3
+ requests==2.25.1