├── pycp ├── __init__.py ├── main.py ├── transfer.py └── progress.py ├── test ├── test_dir │ ├── file.exe │ ├── a_dir │ │ ├── empty │ │ ├── c_file │ │ ├── d_file │ │ └── .hidden │ ├── a_file │ └── b_file ├── conftest.py ├── test_transfer_text.py ├── test_pymv.py ├── test_progress.py └── test_pycp.py ├── bench ├── .gitignore ├── run.sh ├── run.py └── setup.py ├── scrot └── pycp.png ├── .coveragerc ├── AUTHORS ├── .flake8 ├── MANIFEST.in ├── lint.sh ├── .bumpversion.cfg ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── linters.yml │ └── tests.yml ├── mypy.ini ├── pyproject.toml ├── COPYING.txt ├── README.rst ├── Changelog.rst ├── .pylintrc └── poetry.lock /pycp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/test_dir/file.exe: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/test_dir/a_dir/empty: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/test_dir/a_file: -------------------------------------------------------------------------------- 1 | a 2 | -------------------------------------------------------------------------------- /test/test_dir/b_file: -------------------------------------------------------------------------------- 1 | b 2 | -------------------------------------------------------------------------------- /test/test_dir/a_dir/c_file: -------------------------------------------------------------------------------- 1 | c 2 | -------------------------------------------------------------------------------- /test/test_dir/a_dir/d_file: -------------------------------------------------------------------------------- 1 | d 2 | -------------------------------------------------------------------------------- /bench/.gitignore: -------------------------------------------------------------------------------- 1 | *.dat 2 | pycp.cprof 3 | -------------------------------------------------------------------------------- /test/test_dir/a_dir/.hidden: -------------------------------------------------------------------------------- 1 | I'm hidden 2 | -------------------------------------------------------------------------------- /scrot/pycp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/your-tools/pycp/HEAD/scrot/pycp.png -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = pycp 3 | 4 | omit = 5 | test/* 6 | 7 | branch = True 8 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Dimitri Merejkowsky 2 | robmaloy 3 | -------------------------------------------------------------------------------- /bench/run.sh: -------------------------------------------------------------------------------- 1 | python -m cProfile -o pycp.cprof -s cumulative run.py 2 | pyprof2calltree -k -i pycp.cprof 3 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = build/ 3 | max-line-length=100 4 | ignore = E203, W503 5 | max-complexity = 12 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt 2 | include *.rst 3 | recursive-include test/test_dir * 4 | recursive-include test/ *.py 5 | -------------------------------------------------------------------------------- /lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | set -x 4 | poetry run black --check . 5 | poetry run isort --check . 6 | poetry run flake8 . 7 | poetry run mypy 8 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 8.0.8 3 | tag = true 4 | commit = true 5 | message = Bump to {new_version} 6 | 7 | [bumpversion:file:setup.py] 8 | 9 | [bumpversion:file:pycp/main.py] 10 | 11 | -------------------------------------------------------------------------------- /bench/run.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from pycp.main import main as pycp_main 4 | 5 | 6 | def main() -> None: 7 | sys.argv = ["pycp", "src.dat", "dest.dat"] 8 | pycp_main() 9 | 10 | 11 | if __name__ == "__main__": 12 | main() 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #Python: 2 | *.pyc 3 | 4 | #Created by setup.py: 5 | dist/ 6 | build/ 7 | doc/ 8 | MANIFEST 9 | 10 | # created by PKGBUILD: 11 | src/ 12 | pkg/ 13 | *.tar.xz 14 | *.tar.gz 15 | 16 | 17 | # Created by pip 18 | *.egg-info 19 | 20 | # Created by pytest 21 | .coverage 22 | -------------------------------------------------------------------------------- /bench/setup.py: -------------------------------------------------------------------------------- 1 | import pycp.transfer 2 | 3 | 4 | def main() -> None: 5 | with open("src.dat", "wb") as fp: 6 | for i in range(0, 5): 7 | data = [i] * pycp.transfer.BUFFER_SIZE 8 | fp.write(bytes(data)) 9 | 10 | 11 | if __name__ == "__main__": 12 | main() 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a new bug report 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | ### Environment 10 | 11 | * Output of `pycp --version`: ... 12 | * Operating system: ... 13 | 14 | ### Command you ran 15 | 16 | ```console 17 | $ ... 18 | ``` 19 | 20 | ### Actual output 21 | 22 | ```text 23 | ... 24 | ``` 25 | 26 | ### Expected result 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | This may include new command-line options 14 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | files = **/*.py 3 | allow_untyped_decorators = true 4 | warn_unused_configs = true 5 | disallow_subclassing_any = true 6 | disallow_untyped_calls = true 7 | disallow_untyped_defs = true 8 | disallow_incomplete_defs = true 9 | check_untyped_defs = true 10 | no_implicit_optional = true 11 | warn_redundant_casts = true 12 | warn_unused_ignores = true 13 | warn_return_any = true 14 | ignore_missing_imports = false 15 | pretty = true 16 | 17 | 18 | [mypy-pytest] 19 | ignore_missing_imports = true 20 | -------------------------------------------------------------------------------- /.github/workflows/linters.yml: -------------------------------------------------------------------------------- 1 | name: linters 2 | 3 | on: [push] 4 | 5 | jobs: 6 | run_linters: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | 12 | - uses: actions/checkout@v1 13 | 14 | - name: Set up Python 15 | uses: actions/setup-python@v1 16 | with: 17 | python-version: "3.11" 18 | 19 | - name: Prepare project for development 20 | run: | 21 | python -m pip install poetry 22 | python -m poetry config virtualenvs.create false 23 | python -m poetry install 24 | 25 | - name: Run linters 26 | run: ./lint.sh 27 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | run_tests: 7 | 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 13 | os: [ubuntu-latest, macos-latest] 14 | 15 | steps: 16 | 17 | - uses: actions/checkout@v1 18 | 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | 24 | - name: Prepare project for development 25 | run: | 26 | python -m pip install poetry 27 | python -m poetry config virtualenvs.create false 28 | python -m poetry install 29 | 30 | - name: Run tests 31 | run: | 32 | poetry run pytest --cov . --cov-report term 33 | 34 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.isort] 2 | profile = "black" 3 | 4 | [tool.poetry] 5 | name = "pycp" 6 | version = "8.0.8" 7 | description = "cp, mv and rm with a progress bar" 8 | authors = ["Dimitri Merejkowsky "] 9 | license = "MIT" 10 | readme = "README.rst" 11 | repository = "http://github.com/your-tools/pycp" 12 | keywords = ["command line"] 13 | classifiers=[ 14 | "Environment :: Console", 15 | "Operating System :: Unix", 16 | "Topic :: System :: Shells", 17 | ] 18 | 19 | [tool.poetry.dependencies] 20 | python = "^3.8.1" 21 | 22 | [tool.poetry.group.dev.dependencies] 23 | black = "^23" 24 | isort = "^5.13.2" 25 | flake8 = "^6.1" 26 | pytest = "^7.4" 27 | pytest-mock = "^3.12" 28 | pytest-cov = "^4.1" 29 | mypy = "^1.7" 30 | 31 | [tool.poetry.scripts] 32 | pycp = "pycp.main:main" 33 | pymv = "pycp.main:main" 34 | 35 | [build-system] 36 | requires = ["poetry-core>=1.0.0"] 37 | build-backend = "poetry.core.masonry.api" 38 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import shutil 4 | import tempfile 5 | import typing 6 | 7 | import pytest 8 | 9 | 10 | @pytest.fixture() 11 | def test_dir() -> typing.Iterator[str]: 12 | cur_dir = os.path.abspath(os.path.dirname(__file__)) 13 | cur_test = os.path.join(cur_dir, "test_dir") 14 | temp_dir = tempfile.mkdtemp("pycp-test") 15 | test_dir = os.path.join(temp_dir, "test_dir") 16 | shutil.copytree(cur_test, test_dir) 17 | yield test_dir 18 | if os.environ.get("DEBUG"): 19 | print("not removing", test_dir) 20 | else: 21 | shutil.rmtree(test_dir) 22 | 23 | 24 | class TerminalSize: 25 | def __init__(self) -> None: 26 | self.lines = 25 27 | self.columns = 80 28 | 29 | 30 | def mock_term_size(mocker: typing.Any, width: int) -> None: 31 | size = TerminalSize() 32 | size.columns = width 33 | patcher = mocker.patch("shutil.get_terminal_size") 34 | patcher.return_value = size 35 | 36 | 37 | def strip_ansi_colors(string: str) -> str: 38 | ansi_escape = re.compile(r"\x1b[^m]*m") 39 | return re.sub(ansi_escape, "", string) 40 | -------------------------------------------------------------------------------- /COPYING.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Dimitri Merejkowsky 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | 21 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | pycp: cp and mv with a progressbar 2 | ================================== 3 | 4 | .. image:: http://img.shields.io/pypi/v/pycp.png 5 | :target: https://pypi.python.org/pypi/pycp 6 | 7 | 8 | What it looks like: 9 | 10 | .. image:: https://raw.githubusercontent.com/your-tools/pycp/master/scrot/pycp.png 11 | :target: https://github.com/your-tools/pycp 12 | 13 | 14 | See ``pycp --help`` for detailed usage. 15 | 16 | Development happens on `github `_ 17 | 18 | 19 | Installation 20 | ------------ 21 | 22 | ``pycp`` works both for any version greater than Python 3.4, and is installable with 23 | ``pip``. 24 | 25 | 26 | For ``Archlinux,`` a ``PKGBUILD`` is also available on ``AUR`` 27 | 28 | 29 | Notes 30 | ----- 31 | 32 | * Implementation heavily inspired by the wonderful library ``progressbar`` by Nilton Volpato. 33 | 34 | * I also maintain a similar tool written in rust called `rusync `_. It has a different set of features (so you may want to stick with pycpy), but is much faster. 35 | 36 | * If you are looking for a ncurses-based solution, vcp maybe the right choice 37 | for you http://www.freebsdsoftware.org/sysutils/vcp.html 38 | 39 | * If you are looking for a more general solution to display progress bars when 40 | performing command-line operations, see clpbar: http://clpbar.sourceforge.net/ 41 | -------------------------------------------------------------------------------- /test/test_transfer_text.py: -------------------------------------------------------------------------------- 1 | """ Tests for the TransferText component, used to print each file transfer 2 | one by one. 3 | Like: 4 | 5 | ``` 6 | pycp /path/to/foo /path/to/bar 7 | /path/to/{foo => bar} 8 | ``` 9 | """ 10 | 11 | from conftest import strip_ansi_colors 12 | 13 | from pycp.progress import TransferText 14 | 15 | 16 | def assert_pprint(src: str, dest: str, actual: str) -> None: 17 | transfer_text = TransferText() 18 | _, out = transfer_text.render({"src": src, "dest": dest, "width": 40}) 19 | assert strip_ansi_colors(out) == actual 20 | 21 | 22 | def test_01() -> None: 23 | src = "/path/to/foo" 24 | dest = "/path/to/bar" 25 | assert_pprint(src, dest, "/path/to/{foo => bar}") 26 | 27 | 28 | def test_02() -> None: 29 | src = "/path/to/foo/a/b" 30 | dest = "/path/to/spam/a/b" 31 | assert_pprint(src, dest, "/path/to/{foo => spam}/a/b") 32 | 33 | 34 | def test_03() -> None: 35 | src = "/path/to/foo/a/b" 36 | dest = "/path/to/foo/bar/a/b" 37 | assert_pprint(src, dest, "/path/to/foo/{ => bar}/a/b") 38 | 39 | 40 | def test_no_pfx() -> None: 41 | src = "/path/to/foo/a/b" 42 | dest = "/other/a/b" 43 | assert_pprint(src, dest, "{/path/to/foo => /other}/a/b") 44 | 45 | 46 | def test_no_sfx() -> None: 47 | src = "/path/to/foo/a" 48 | dest = "/path/to/foo/b" 49 | assert_pprint(src, dest, "/path/to/foo/{a => b}") 50 | 51 | 52 | def test_no_dir() -> None: 53 | src = "a" 54 | dest = "b" 55 | assert_pprint(src, dest, "a => b") 56 | -------------------------------------------------------------------------------- /test/test_pymv.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from pycp.main import main as pycp_main 5 | 6 | 7 | def test_mv_file_file(test_dir: str) -> None: 8 | """mv a_file -> a_file.back should work""" 9 | a_file = os.path.join(test_dir, "a_file") 10 | a_file_back = os.path.join(test_dir, "a_file.back") 11 | 12 | sys.argv = ["pymv", a_file, a_file_back] 13 | pycp_main() 14 | assert os.path.exists(a_file_back) 15 | assert not os.path.exists(a_file) 16 | 17 | 18 | def test_mv_dir_dir_1(test_dir: str) -> None: 19 | """ "mv a_dir -> b_dir should work when b_dir does not exist""" 20 | a_dir = os.path.join(test_dir, "a_dir") 21 | b_dir = os.path.join(test_dir, "b_dir") 22 | sys.argv = ["pymv", a_dir, b_dir] 23 | pycp_main() 24 | c_file = os.path.join(b_dir, "c_file") 25 | d_file = os.path.join(b_dir, "c_file") 26 | assert os.path.exists(c_file) 27 | assert os.path.exists(d_file) 28 | assert not os.path.exists(a_dir) 29 | 30 | 31 | def test_mv_dir_dir_2(test_dir: str) -> None: 32 | """mv a_dir -> b_dir should work when b_dir exists""" 33 | a_dir = os.path.join(test_dir, "a_dir") 34 | b_dir = os.path.join(test_dir, "b_dir") 35 | os.mkdir(b_dir) 36 | sys.argv = ["pymv", a_dir, b_dir] 37 | pycp_main() 38 | c_file = os.path.join(b_dir, "a_dir", "c_file") 39 | d_file = os.path.join(b_dir, "a_dir", "c_file") 40 | assert os.path.exists(c_file) 41 | assert os.path.exists(d_file) 42 | assert not os.path.exists(a_dir) 43 | 44 | 45 | def test_hidden(test_dir: str) -> None: 46 | """Check that hidden files are be moved too""" 47 | a_dir = os.path.join(test_dir, "a_dir") 48 | dest = os.path.join(test_dir, "dest") 49 | os.mkdir(dest) 50 | sys.argv = ["pymv", a_dir, dest] 51 | pycp_main() 52 | hidden_copy = os.path.join(dest, "a_dir", ".hidden") 53 | assert os.path.exists(hidden_copy) 54 | -------------------------------------------------------------------------------- /test/test_progress.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from conftest import mock_term_size 4 | 5 | import pycp.progress 6 | from pycp.progress import ( 7 | GlobalIndicator, 8 | OneFileIndicator, 9 | shorten_path, 10 | shorten_string, 11 | ) 12 | 13 | 14 | def test_shorten_path() -> None: 15 | assert shorten_path("bazinga", 6) == "baz..." 16 | assert shorten_path("foo/bar/baz", 12) == "foo/bar/baz" 17 | assert shorten_path("foo/bar/baz", 10) == "f/b/baz" 18 | assert shorten_path("/foo/bar/baz", 11) == "/f/b/baz" 19 | assert shorten_path("foo/bar/bazinga", 10) == "f/b/baz..." 20 | assert shorten_path("foo/bar/baz/spam/eggs", 6) == "eggs" 21 | assert shorten_path("foo/bar/baz/spam/elephant", 4) == "e..." 22 | assert ( 23 | shorten_path("Songs/17 Hippies/Sirba/02 Mad Bad Cat.mp3", 40) 24 | == "S/1/S/02 Mad Bad Cat.mp3" 25 | ) 26 | 27 | 28 | def test_shorten_string() -> None: 29 | assert shorten_string("foobar", 5) == "fo..." 30 | assert shorten_string("foobar", 3) == "f.." 31 | assert shorten_string("foobar", 2) == "f." 32 | assert shorten_string("foobar", 1) == "f" 33 | 34 | 35 | # Note: there are no 'assert' here, so these tests only check 36 | # that rendering does not crash 37 | # I keep them because it helps when tweaking pycp's output 38 | 39 | 40 | def test_global_indicator(mocker: typing.Any) -> None: 41 | expected_width = 90 42 | mock_term_size(mocker, expected_width) 43 | global_indicator = GlobalIndicator() 44 | 45 | progress = pycp.progress.Progress() 46 | progress.index = 2 47 | progress.count = 3 48 | progress.src = "src/foo" 49 | progress.dest = "dest/foo" 50 | progress.file_size = 100 51 | 52 | global_indicator.on_new_file(progress) 53 | global_indicator.on_progress(progress) 54 | global_indicator.on_file_done() 55 | 56 | progress.src = "src/bar" 57 | progress.dest = "dest/bar" 58 | global_indicator.on_new_file(progress) 59 | global_indicator.on_progress(progress) 60 | global_indicator.on_file_done() 61 | 62 | global_indicator.on_finish() 63 | 64 | 65 | def test_indicates_progress_file_by_file() -> None: 66 | one_file_indicator = OneFileIndicator() 67 | one_file_indicator.on_start() 68 | 69 | progress = pycp.progress.Progress() 70 | progress.index = 2 71 | progress.count = 3 72 | progress.src = "src/foo" 73 | progress.dest = "dest/foo" 74 | progress.file_size = 100 75 | 76 | one_file_indicator.on_new_file(progress) 77 | progress.file_done = 25 78 | one_file_indicator.on_progress(progress) 79 | progress.file_done = 75 80 | one_file_indicator.on_progress(progress) 81 | progress.file_done = 100 82 | one_file_indicator.on_progress(progress) 83 | one_file_indicator.on_file_done() 84 | 85 | one_file_indicator.on_finish() 86 | -------------------------------------------------------------------------------- /pycp/main.py: -------------------------------------------------------------------------------- 1 | """This module contains the main() function, 2 | to parse command line 3 | 4 | """ 5 | 6 | import argparse 7 | import os 8 | import sys 9 | import typing 10 | 11 | from pycp.transfer import TransferError, TransferManager, TransferOptions 12 | 13 | 14 | def is_pymv() -> bool: 15 | return sys.argv[0].endswith("pymv") 16 | 17 | 18 | def parse_commandline() -> argparse.Namespace: 19 | """Parses command line arguments""" 20 | if is_pymv(): 21 | prog_name = "pymv" 22 | action = "move" 23 | else: 24 | prog_name = "pycp" 25 | action = "copy" 26 | 27 | usage = """ 28 | %s [options] SOURCE DESTINATION 29 | %s [options] SOURCE... DIRECTORY 30 | 31 | %s SOURCE to DESTINATION or multiple SOURCE(s) to DIRECTORY 32 | """ % ( 33 | prog_name, 34 | prog_name, 35 | action, 36 | ) 37 | 38 | parser = argparse.ArgumentParser(usage=usage, prog=prog_name) 39 | 40 | parser.add_argument("-v", "--version", action="version", version="8.0.8") 41 | parser.add_argument( 42 | "-i", 43 | "--interactive", 44 | action="store_true", 45 | dest="interactive", 46 | help="ask before overwriting existing files", 47 | ) 48 | 49 | parser.add_argument( 50 | "-s", 51 | "--safe", 52 | action="store_true", 53 | dest="safe", 54 | help="never overwrite existing files", 55 | ) 56 | 57 | parser.add_argument( 58 | "-f", 59 | "--force", 60 | action="store_false", 61 | dest="safe", 62 | help="silently overwrite existing files (this is the default)", 63 | ) 64 | 65 | parser.add_argument( 66 | "-p", 67 | "--preserve", 68 | action="store_true", 69 | dest="preserve", 70 | help="preserve time stamps and ownership", 71 | ) 72 | 73 | parser.add_argument( 74 | "--ignore-errors", 75 | action="store_true", 76 | dest="ignore_errors", 77 | help="do not abort immediately if one file transfer fails", 78 | ) 79 | 80 | parser.add_argument( 81 | "-g", 82 | "--global-pbar", 83 | action="store_true", 84 | dest="global_progress", 85 | help="display only one progress bar during transfer", 86 | ) 87 | 88 | parser.add_argument( 89 | "--i-love-candy", action="store_true", dest="pacman", help=argparse.SUPPRESS 90 | ) 91 | 92 | parser.set_defaults( 93 | safe=False, 94 | interactive=False, 95 | ignore_errors=False, 96 | preserve=False, 97 | global_progress=False, 98 | ) 99 | parser.add_argument("files", nargs="+") 100 | 101 | return parser.parse_args() 102 | 103 | 104 | SourcesAndDest = typing.Tuple[typing.List[str], str] 105 | 106 | 107 | def parse_filelist(filelist: typing.List[str]) -> SourcesAndDest: 108 | if len(filelist) < 2: 109 | sys.exit("Incorrect number of arguments") 110 | 111 | sources = filelist[:-1] 112 | destination = filelist[-1] 113 | 114 | if len(sources) > 1: 115 | if not os.path.isdir(destination): 116 | sys.exit("%s is not an existing directory" % destination) 117 | 118 | for source in sources: 119 | if not os.path.exists(source): 120 | sys.exit("%s does not exist" % source) 121 | 122 | return sources, destination 123 | 124 | 125 | def main() -> None: 126 | args = parse_commandline() 127 | args.move = is_pymv() 128 | 129 | files = args.files 130 | sources, destination = parse_filelist(files) 131 | 132 | transfer_options = TransferOptions() 133 | transfer_options.update(args) # type: ignore 134 | 135 | transfer_manager = TransferManager(sources, destination, transfer_options) 136 | try: 137 | errors = transfer_manager.do_transfer() 138 | except TransferError as err: 139 | sys.exit(str(err)) 140 | except KeyboardInterrupt: 141 | sys.exit("Interrputed by user") 142 | 143 | if errors: 144 | print("Error occurred when transferring the following files:") 145 | for file_name, error in errors.items(): 146 | print(file_name, error) 147 | 148 | 149 | if __name__ == "__main__": 150 | main() 151 | -------------------------------------------------------------------------------- /Changelog.rst: -------------------------------------------------------------------------------- 1 | 8.0.8 2 | ----- 3 | 4 | * Fix long_description metadata 5 | 6 | 8.0.7 7 | ----- 8 | 9 | * Remove dependency on `python-cli-ui` 10 | 11 | 8.0.6 12 | ----- 13 | 14 | * Fix packaging issue: ``pycp`` wheels are no longer universal. 15 | 16 | 8.0.5 17 | ----- 18 | 19 | * Fix crash in ``pycp -g``. Reported by @z1lt0id 20 | 21 | 8.0.4 22 | ----- 23 | 24 | * Partial revert of 8.0.3: ``pycp`` is now still fast even with just one CPU 25 | 26 | 27 | 8.0.3 28 | ----- 29 | 30 | * Performance improvements (see #20). We are now faster than v7 :) 31 | Note that ``pycp`` will be still be slow if only one CPU is available. 32 | 33 | 8.0.2 34 | ----- 35 | 36 | * Packaging fixes 37 | 38 | 8.0.1 39 | ---- 40 | 41 | * Fix calling ``--version`` is some corner cases. 42 | 43 | 8.0 44 | --- 45 | 46 | * New feature: colors by default. 47 | 48 | I'd like to thank @schvabodka-man for giving me the opportunity to 49 | refactor code that was more than 7 years old :) 50 | 51 | * Breaking change: remove ``--all`` see `#19 `_ 52 | for details. 53 | * Drop Windows support 54 | * Drop Python2 support 55 | * Massive refactoring 56 | * Stricter CI 57 | 58 | 7.3 59 | --- 60 | * Try to preserve user and group when used with ``-p,--preserve`` 61 | * Optimization : read source file size only once 62 | * Fix crash when file size increases while it's being copied 63 | 64 | 7.2.2 65 | ----- 66 | * Include test/test_dir/ in source package. This 67 | makes it possible for pycp packages to run the tests 68 | 69 | 7.2.1 70 | ----- 71 | * Fix README. (version bump required for updating 72 | pypi page) 73 | 74 | 7.2 75 | --- 76 | * Bring back Python2.7 compatibily. Why not ? 77 | * Display a file count even when not using ``-g`` 78 | 79 | 7.1 80 | --- 81 | * Fix classifiers 82 | 83 | 7.0 84 | --- 85 | * port to Python3 86 | * switch to setuptools for the packaging 87 | 88 | 6.1 89 | --- 90 | * improve symlink support 91 | 92 | 6.0 93 | --- 94 | * massive refactoring 95 | * pycp no longer depends on progressbar 96 | * add pycp -g option to display a global progress bar on 97 | several lines 98 | 99 | 5.0 100 | --- 101 | * massive refactoring 102 | * pycp no longer uses threading code. 103 | copying small files should now be painless 104 | (no more time.sleep) 105 | * pycp learned --all and --preserve options 106 | * change license from GPL to BSD 107 | 108 | 4.3.3 109 | ----- 110 | * pycp no longer hangs when copy fails. 111 | * error code is non zero when serious problems occurs. 112 | 113 | 4.3.2 114 | ----- 115 | 116 | Bug fixes concerning small and empty files 117 | 118 | 4.3.1 119 | ----- 120 | Bug fix: ``pymv a_dir b_dir`` left an empty ``a_dir`` behind 121 | 122 | 4.3 123 | ---- 124 | Nicer print of what is being transfered:: 125 | 126 | /path/to/{foo => bar}/a/b 127 | 128 | instead of:: 129 | 130 | /path/to/foo/a/b -> /path/to/bar/a/b 131 | 132 | 4.2 133 | --- 134 | Pycp now is available on Pypi: 135 | http://pypi.python.org/pypi/pycp/ 136 | 137 | 4.1 138 | --- 139 | You can now use --safe to never overwrite files. 140 | 141 | 4.0.2 142 | ----- 143 | Lots of bug fixes, introducing automatic tests 144 | 145 | 4.0.1 146 | ------ 147 | Fix bug for Python2.5: threading module still has 148 | only camelCase functions. 149 | 150 | 4.0 151 | ---- 152 | Now using ``shutil`` and ``thread`` modules instead of ``subprocess``. 153 | (Replacing ``supbrocess.popen("/bin/cp")`` by calling a thread 154 | running ``shutil.copy``) 155 | Bonus: pycp might become cross-platform 156 | 157 | 3.2 158 | ---- 159 | Switch from ``getopt`` to ``OptionParser`` (much better) 160 | 161 | 3.1 162 | --- 163 | * Now using ``/bin/cp`` instead of ``cp`` (thanks, Chris Gilles) 164 | 165 | * No more ``-o`` option. Files are now overwritten by default. 166 | Pass a ``-i,--interactive`` option if you want to be asked 167 | for confirmation before overwritting files 168 | 169 | * Mimic ``cp`` behaviour. (thanks, ctaf) 170 | 171 | 3.0 172 | --- 173 | Little trick to have a ``pymv`` 174 | 175 | 2.2 176 | --- 177 | * Skips existing files instead of canceling whole operation 178 | * Implementing ``-o,--overwrite`` option. 179 | 180 | 2.1 181 | --- 182 | Able to copy multiple files:: 183 | 184 | pycp bar foo /path/to/baz 185 | 186 | 2.0 187 | ---- 188 | Now able to copy recursively files! 189 | 190 | 1.3 191 | ---- 192 | Add an ETA and file speed estimation 193 | 194 | 1.2 195 | --- 196 | * Fix possible division by zero 197 | * Fix possible race condition 198 | 199 | 1.1 200 | --- 201 | Add a proper license 202 | 203 | 1.0 204 | --- 205 | Initial commit 206 | -------------------------------------------------------------------------------- /test/test_pycp.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import shutil 4 | import stat 5 | import sys 6 | import tempfile 7 | import time 8 | import typing 9 | 10 | import pytest 11 | from conftest import mock_term_size, strip_ansi_colors 12 | 13 | from pycp.main import main as pycp_main 14 | 15 | 16 | def test_zero() -> None: 17 | sys.argv = ["pycp"] 18 | with pytest.raises(SystemExit): 19 | pycp_main() 20 | 21 | 22 | def test_cp_self_1(test_dir: str) -> None: 23 | """cp a_file -> a_file should fail (same file)""" 24 | a_file = os.path.join(test_dir, "a_file") 25 | sys.argv = ["pycp", a_file, a_file] 26 | with pytest.raises(SystemExit): 27 | pycp_main() 28 | 29 | 30 | def test_cp_self_2(test_dir: str) -> None: 31 | """cp a_file -> . should fail (same file)""" 32 | a_file = os.path.join(test_dir, "a_file") 33 | sys.argv = ["pycp", a_file, test_dir] 34 | with pytest.raises(SystemExit): 35 | pycp_main() 36 | 37 | 38 | def test_cp_file_file(test_dir: str) -> None: 39 | """cp a_file -> a_file.back should work""" 40 | # cp a_file a_file.back 41 | a_file = os.path.join(test_dir, "a_file") 42 | a_file_back = os.path.join(test_dir, "a_file.back") 43 | 44 | sys.argv = ["pycp", a_file, a_file_back] 45 | pycp_main() 46 | assert os.path.exists(a_file_back) 47 | 48 | 49 | def test_cp_asbsolute_symlink(test_dir: str) -> None: 50 | """ 51 | Scenario: 52 | * a_link is a link to /path/to/abs/target 53 | * we run `pycp a_link b_link` 54 | * b_link should point to /path/to/abs/target 55 | """ 56 | # note: since shutil.copytree does not handle 57 | # symlinks the way we would like to, create 58 | # link now 59 | a_link = os.path.join(test_dir, "a_link") 60 | a_target = os.path.join(test_dir, "a_target") 61 | with open(a_target, "w") as fp: 62 | fp.write("a_target\n") 63 | os.symlink(a_target, a_link) 64 | b_link = os.path.join(test_dir, "b_link") 65 | sys.argv = ["pycp", a_link, b_link] 66 | pycp_main() 67 | assert os.path.islink(b_link) 68 | b_target = os.readlink(b_link) 69 | assert b_target == a_target 70 | 71 | 72 | def test_cp_relative_symlink(test_dir: str) -> None: 73 | """ 74 | Scenario: 75 | * a_link is a link to a_target 76 | * we run `pycp a_link b_link` 77 | * b_link should point to a_target 78 | """ 79 | a_link = os.path.join(test_dir, "a_link") 80 | a_target = os.path.join(test_dir, "a_target") 81 | with open(a_target, "w") as fp: 82 | fp.write("a_target\n") 83 | os.symlink("a_target", a_link) 84 | b_dir = os.path.join(test_dir, "b_dir") 85 | os.mkdir(b_dir) 86 | b_link = os.path.join(b_dir, "b_link") 87 | sys.argv = ["pycp", a_link, b_link] 88 | pycp_main() 89 | assert os.path.islink(b_link) 90 | b_target = os.readlink(b_link) 91 | assert b_target == "a_target" 92 | 93 | 94 | def test_cp_exe_file(test_dir: str) -> None: 95 | """Copied exe file should still be executable""" 96 | exe_file = os.path.join(test_dir, "file.exe") 97 | exe_file_2 = os.path.join(test_dir, "file2.exe") 98 | sys.argv = ["pycp", exe_file, exe_file_2] 99 | pycp_main() 100 | assert os.access(exe_file_2, os.X_OK) 101 | 102 | 103 | def test_cp_file_dir(test_dir: str) -> None: 104 | """cp a_file -> b_dir should work""" 105 | a_file = os.path.join(test_dir, "a_file") 106 | b_dir = os.path.join(test_dir, "b_dir") 107 | os.mkdir(b_dir) 108 | sys.argv = ["pycp", a_file, b_dir] 109 | pycp_main() 110 | dest = os.path.join(b_dir, "a_file") 111 | assert os.path.exists(dest) 112 | 113 | 114 | def test_cp_dir_dir_1(test_dir: str) -> None: 115 | """cp a_dir -> b_dir should work when b_dir does not exist""" 116 | a_dir = os.path.join(test_dir, "a_dir") 117 | b_dir = os.path.join(test_dir, "b_dir") 118 | sys.argv = ["pycp", a_dir, b_dir] 119 | pycp_main() 120 | c_file = os.path.join(b_dir, "c_file") 121 | d_file = os.path.join(b_dir, "c_file") 122 | assert os.path.exists(c_file) 123 | assert os.path.exists(d_file) 124 | 125 | 126 | def test_cp_dir_dir_2(test_dir: str) -> None: 127 | """cp a_dir -> b_dir should work when b_dir exists""" 128 | a_dir = os.path.join(test_dir, "a_dir") 129 | b_dir = os.path.join(test_dir, "b_dir") 130 | os.mkdir(b_dir) 131 | sys.argv = ["pycp", a_dir, b_dir] 132 | pycp_main() 133 | c_file = os.path.join(b_dir, "a_dir", "c_file") 134 | d_file = os.path.join(b_dir, "a_dir", "c_file") 135 | assert os.path.exists(c_file) 136 | assert os.path.exists(d_file) 137 | 138 | 139 | def test_cp_dir_dir2_global(test_dir: str) -> None: 140 | """cp a_dir -> b_dir should work when using `--global""" 141 | a_dir = os.path.join(test_dir, "a_dir") 142 | b_dir = os.path.join(test_dir, "b_dir") 143 | os.mkdir(b_dir) 144 | sys.argv = ["pycp", "-g", a_dir, b_dir] 145 | pycp_main() 146 | c_file = os.path.join(b_dir, "a_dir", "c_file") 147 | d_file = os.path.join(b_dir, "a_dir", "c_file") 148 | assert os.path.exists(c_file) 149 | assert os.path.exists(d_file) 150 | 151 | 152 | def test_no_source(test_dir: str) -> None: 153 | """cp d_file -> d_file.back should fail if d_file does not exist""" 154 | d_file = os.path.join(test_dir, "d_file") 155 | sys.argv = ["pycp", d_file, "d_file.back"] 156 | with pytest.raises(SystemExit): 157 | pycp_main() 158 | 159 | 160 | def test_no_dest(test_dir: str) -> None: 161 | """cp a_file -> d_dir should fail if d_dir does not exist""" 162 | a_file = os.path.join(test_dir, "a_file") 163 | d_dir = os.path.join(test_dir, "d_dir" + os.path.sep) 164 | sys.argv = ["pycp", a_file, d_dir] 165 | with pytest.raises(SystemExit): 166 | pycp_main() 167 | 168 | 169 | def test_several_sources_1(test_dir: str) -> None: 170 | """cp a_file b_file -> c_dir should work""" 171 | a_file = os.path.join(test_dir, "a_file") 172 | b_file = os.path.join(test_dir, "b_file") 173 | c_dir = os.path.join(test_dir, "c_dir") 174 | os.mkdir(c_dir) 175 | sys.argv = ["pycp", a_file, b_file, c_dir] 176 | pycp_main() 177 | 178 | 179 | def test_several_sources_2(test_dir: str) -> None: 180 | """cp a_file b_file -> c_file should fail""" 181 | a_file = os.path.join(test_dir, "a_file") 182 | b_file = os.path.join(test_dir, "b_file") 183 | c_file = os.path.join(test_dir, "c_file") 184 | sys.argv = ["pycp", a_file, b_file, c_file] 185 | with pytest.raises(SystemExit): 186 | pycp_main() 187 | 188 | 189 | def test_several_sources_3(test_dir: str) -> None: 190 | """cp a_file b_file -> c_dir should fail if c_dir does not exist""" 191 | a_file = os.path.join(test_dir, "a_file") 192 | b_file = os.path.join(test_dir, "b_file") 193 | c_dir = os.path.join(test_dir, "c_dir") 194 | sys.argv = ["pycp", a_file, b_file, c_dir] 195 | with pytest.raises(SystemExit): 196 | pycp_main() 197 | 198 | 199 | def test_overwrite_1(test_dir: str) -> None: 200 | """cp a_file -> b_file should overwrite b_file when not using --safe""" 201 | a_file = os.path.join(test_dir, "a_file") 202 | b_file = os.path.join(test_dir, "b_file") 203 | sys.argv = ["pycp", a_file, b_file] 204 | pycp_main() 205 | b_file_desc = open(b_file, "r") 206 | b_contents = b_file_desc.read() 207 | b_file_desc.close() 208 | assert b_contents == "a\n" 209 | 210 | 211 | def test_overwrite_2(test_dir: str) -> None: 212 | """cp a_file -> b_file should not overwrite b_file when using --safe""" 213 | a_file = os.path.join(test_dir, "a_file") 214 | b_file = os.path.join(test_dir, "b_file") 215 | sys.argv = ["pycp", "--safe", a_file, b_file] 216 | pycp_main() 217 | b_file_desc = open(b_file, "r") 218 | b_contents = b_file_desc.read() 219 | b_file_desc.close() 220 | assert b_contents == "b\n" 221 | 222 | 223 | def test_copy_readonly(test_dir: str) -> None: 224 | """cp a_file -> ro_dir should fail if ro_dir is read only""" 225 | a_file = os.path.join(test_dir, "a_file") 226 | ro_dir = tempfile.mkdtemp("pycp-test-ro") 227 | os.chmod(ro_dir, stat.S_IRUSR | stat.S_IXUSR) 228 | sys.argv = ["pycp", a_file, ro_dir] 229 | with pytest.raises(SystemExit): 230 | pycp_main() 231 | shutil.rmtree(ro_dir) 232 | 233 | 234 | def test_preserve(test_dir: str) -> None: 235 | """Check that mtimes are preserved""" 236 | a_file = os.path.join(test_dir, "a_file") 237 | long_ago = time.time() - 10000 238 | os.utime(a_file, (long_ago, long_ago)) 239 | a_copy = os.path.join(test_dir, "a_copy") 240 | sys.argv = ["pycp", "--preserve", a_file, a_copy] 241 | pycp_main() 242 | copy_stat = os.stat(a_copy) 243 | assert copy_stat.st_mtime == pytest.approx(long_ago, abs=1) 244 | 245 | 246 | @pytest.mark.xfail() 247 | def test_output_does_not_wrap_1( 248 | test_dir: str, capsys: typing.Any, mocker: typing.Any 249 | ) -> None: 250 | """ 251 | When not using --global, each printed line length 252 | should be less that the terminal size 253 | """ 254 | # and we'll trigger this bug: 255 | # https://github.com/dmerejkowsky/pycp/issues/29 256 | a_file = os.path.join(test_dir, "a_file") 257 | a_file_back = os.path.join(test_dir, "a_file.back") 258 | 259 | expected_width = 90 260 | mock_term_size(mocker, expected_width) 261 | sys.argv = ["pycp", a_file, a_file_back] 262 | pycp_main() 263 | out, err = capsys.readouterr() 264 | lines = re.split(r"\r|\n", out) 265 | for line in lines: 266 | assert len(strip_ansi_colors(line)) <= expected_width 267 | 268 | 269 | def test_output_does_not_wrap_2( 270 | test_dir: str, capsys: typing.Any, mocker: typing.Any 271 | ) -> None: 272 | """ 273 | When using --global, each printed line length 274 | should be less that the terminal size 275 | """ 276 | expected_width = 90 277 | mock_term_size(mocker, expected_width) 278 | a_dir = os.path.join(test_dir, "a_dir") 279 | b_dir = os.path.join(test_dir, "b_dir") 280 | 281 | sys.argv = ["pycp", "--global", a_dir, b_dir] 282 | pycp_main() 283 | 284 | out, err = capsys.readouterr() 285 | lines = re.split(r"\r|\n", out) 286 | for line in lines: 287 | assert len(strip_ansi_colors(line)) <= expected_width 288 | -------------------------------------------------------------------------------- /pycp/transfer.py: -------------------------------------------------------------------------------- 1 | """This module contains the TransferManager class. 2 | 3 | This will do the work of transferring the files, 4 | while using the FilePbar or the GlobalPbar from 5 | pycp.progress 6 | 7 | """ 8 | 9 | import os 10 | import stat 11 | import time 12 | import typing 13 | 14 | from pycp.progress import GlobalIndicator, OneFileIndicator, Progress, ProgressIndicator 15 | 16 | BUFFER_SIZE = 100 * 1024 17 | 18 | 19 | class TransferError(Exception): 20 | """Custom exception: wraps IOError""" 21 | 22 | def __init__(self, message: str) -> None: 23 | Exception.__init__(self) 24 | self.message = message 25 | 26 | def __str__(self) -> str: 27 | return self.message 28 | 29 | 30 | class TransferOptions: 31 | def __init__(self) -> None: 32 | self.ignore_errors = False 33 | self.global_progress = False 34 | self.interactive = False 35 | self.preserve = False 36 | self.safe = False 37 | self.move = False 38 | 39 | def update(self, args: typing.Dict[str, typing.Any]) -> None: 40 | for name, value in vars(args).items(): 41 | if hasattr(self, name): 42 | setattr(self, name, value) 43 | 44 | 45 | def samefile(src: str, dest: str) -> bool: 46 | """Check if two files are the same in a 47 | crossplatform way 48 | 49 | """ 50 | try: 51 | return os.path.samefile(src, dest) 52 | except OSError: 53 | return False 54 | 55 | 56 | def check_same_file(src: str, dest: str) -> None: 57 | if samefile(src, dest): 58 | raise TransferError("%s and %s are the same file!" % (src, dest)) 59 | 60 | 61 | def handle_symlink(src: str, dest: str) -> None: 62 | target = os.readlink(src) 63 | # remove existing stuff 64 | if os.path.lexists(dest): 65 | os.remove(dest) 66 | os.symlink(target, dest) 67 | 68 | 69 | def open_files(src: str, dest: str) -> typing.Tuple[typing.BinaryIO, typing.BinaryIO]: 70 | try: 71 | src_file = open(src, "rb") 72 | except IOError: 73 | raise TransferError("Could not open %s for reading" % src) 74 | try: 75 | dest_file = open(dest, "wb") 76 | except IOError: 77 | raise TransferError("Could not open %s for writing" % dest) 78 | return src_file, dest_file 79 | 80 | 81 | class TransferInfo: 82 | """This class contains: 83 | * a list of tuples: to_transfer (src, dest) where: 84 | - src and dest are both files 85 | - basename(dest) is guaranteed to exist) 86 | * an add(src, dest) method 87 | * a get_size() which is the total size of the files to be 88 | transferred 89 | 90 | """ 91 | 92 | def __init__(self, sources: typing.List[str], destination: str) -> None: 93 | self.size = 0 94 | # List of tuples (src, dest, size) of files to transfer 95 | self.to_transfer: typing.List[typing.Tuple[str, str, int]] = list() 96 | # List of directories to remove 97 | self.to_remove: typing.List[str] = list() 98 | self.parse(sources, destination) 99 | 100 | def parse(self, sources: typing.List[str], destination: str) -> None: 101 | """Recursively go through the sources, creating missing 102 | directories, computing total size to be transferred, and 103 | so on. 104 | 105 | """ 106 | filenames = [x for x in sources if os.path.isfile(x)] 107 | directories = [x for x in sources if os.path.isdir(x)] 108 | 109 | for filename in filenames: 110 | self._parse_file(filename, destination) 111 | 112 | for directory in directories: 113 | self._parse_dir(directory, destination) 114 | 115 | def _parse_file(self, source: str, destination: str) -> None: 116 | """Parse a new source file""" 117 | if os.path.isdir(destination): 118 | basename = os.path.basename(os.path.normpath(source)) 119 | destination = os.path.join(destination, basename) 120 | self.add(source, destination) 121 | 122 | def _parse_dir(self, source: str, destination: str) -> None: 123 | """Parse a new source directory""" 124 | if os.path.isdir(destination): 125 | basename = os.path.basename(os.path.normpath(source)) 126 | destination = os.path.join(destination, basename) 127 | if not os.path.exists(destination): 128 | os.mkdir(destination) 129 | file_names = sorted(os.listdir(source)) 130 | file_names = [os.path.join(source, f) for f in file_names] 131 | self.parse(file_names, destination) 132 | self.to_remove.append(source) 133 | 134 | def add(self, src: str, dest: str) -> None: 135 | """Add a new tuple to the transfer list.""" 136 | file_size = os.path.getsize(src) 137 | if not os.path.islink(src): 138 | self.size += file_size 139 | self.to_transfer.append((src, dest, file_size)) 140 | 141 | 142 | Callback = typing.Callable[[int], None] 143 | 144 | 145 | class FileTransferManager: 146 | """This class handles transferring one file to an other""" 147 | 148 | def __init__(self, src: str, dest: str, options: TransferOptions) -> None: 149 | self.src = src 150 | self.dest = dest 151 | self.options = options 152 | self.callback: Callback = lambda _: None 153 | 154 | def set_callback(self, callback: Callback) -> None: 155 | self.callback = callback 156 | 157 | def do_transfer(self) -> typing.Optional[Exception]: 158 | """Called transfer_file, catch TransferError depending 159 | on the options. 160 | 161 | Returns an error message if something went wrong, 162 | and we did not raise 163 | 164 | """ 165 | error = None 166 | # Handle overwriting of files: 167 | if os.path.exists(self.dest): 168 | should_skip = self.handle_overwrite() 169 | if should_skip: 170 | return None 171 | try: 172 | self.transfer_file() 173 | except TransferError as exception: 174 | if self.options.ignore_errors: 175 | error = exception 176 | # remove dest file 177 | if not self.options.move: 178 | try: 179 | os.remove(self.dest) 180 | except OSError: 181 | # We don't want to raise here 182 | pass 183 | 184 | else: 185 | # Re-raise 186 | raise 187 | return error 188 | 189 | def transfer_file(self) -> None: 190 | """Transfer src to dest, calling 191 | callback(transferred) while doing so, 192 | where transferred is the size of the buffer successfully transferred 193 | 194 | src and dest must be two valid file paths. 195 | 196 | If move is True, remove src when done. 197 | """ 198 | check_same_file(self.src, self.dest) 199 | if os.path.islink(self.src): 200 | handle_symlink(self.src, self.dest) 201 | self.callback(0) 202 | return 203 | 204 | src_file, dest_file = open_files(self.src, self.dest) 205 | transferred = 0 206 | try: 207 | while True: 208 | data = src_file.read(BUFFER_SIZE) 209 | if not data: 210 | self.callback(0) 211 | break 212 | transferred = len(data) 213 | self.callback(transferred) 214 | dest_file.write(data) 215 | except IOError as err: 216 | mess = "Problem when transferring %s to %s\n" % (self.src, self.dest) 217 | mess += "Error was: %s" % err 218 | raise TransferError(mess) 219 | finally: 220 | src_file.close() 221 | dest_file.close() 222 | 223 | try: 224 | self.post_transfer() 225 | except OSError as err: 226 | print("Warning: failed to finalize transfer of %s: %s" % (self.dest, err)) 227 | 228 | if self.options.move: 229 | try: 230 | os.remove(self.src) 231 | except OSError: 232 | print("Warting: could not remove %s" % self.src) 233 | 234 | def post_transfer(self) -> None: 235 | """Handle state of transferred file 236 | 237 | By default, preserve only permissions. 238 | If "preserve" option was given, preserve also 239 | utime and flags. 240 | 241 | """ 242 | src_st = os.stat(self.src) 243 | if hasattr(os, "chmod"): 244 | mode = stat.S_IMODE(src_st.st_mode) 245 | os.chmod(self.dest, mode) 246 | if not self.options.preserve: 247 | return 248 | if hasattr(os, "utime"): 249 | os.utime(self.dest, (src_st.st_atime, src_st.st_mtime)) 250 | uid = src_st.st_uid 251 | gid = src_st.st_gid 252 | try: 253 | os.chown(self.dest, uid, gid) 254 | except OSError: 255 | # we likely don't have enough permissions to do this 256 | # just ignore 257 | pass 258 | 259 | def handle_overwrite(self) -> bool: 260 | """Return True if we should skip the file. 261 | Ask user for confirmation if we were called 262 | with an 'interactive' option. 263 | 264 | """ 265 | # Safe: always skip 266 | if self.options.safe: 267 | print("Waning: skipping", self.dest) 268 | return True 269 | 270 | # Not safe and not interactive => overwrite 271 | if not self.options.interactive: 272 | return False 273 | 274 | # Interactive 275 | print("File: '%s' already exists" % self.dest) 276 | print("Overwrite?") 277 | user_input = input() 278 | if user_input == "y": 279 | return False 280 | else: 281 | return True 282 | 283 | 284 | class TransferManager: 285 | """Handles transfer of a one or several sources to a destination 286 | 287 | One FileTransferManager object will be created for each file 288 | to transfer. 289 | 290 | """ 291 | 292 | def __init__( 293 | self, sources: typing.List[str], destination: str, options: TransferOptions 294 | ) -> None: 295 | self.sources = sources 296 | self.destination = destination 297 | self.options = options 298 | self.transfer_info = TransferInfo(sources, destination) 299 | 300 | self.progress_indicator: ProgressIndicator = ( 301 | GlobalIndicator() if self.options.global_progress else OneFileIndicator() 302 | ) 303 | self.last_progress_update = 0.0 304 | self.last_progress: typing.Optional[Progress] = None 305 | 306 | def do_transfer(self) -> typing.Dict[str, Exception]: 307 | """Performs the real transfer""" 308 | errors: typing.Dict[str, Exception] = dict() 309 | progress = Progress() 310 | total_start = time.time() 311 | progress.total_done = 0 312 | progress.total_size = self.transfer_info.size 313 | progress.index = 0 314 | progress.count = len(self.transfer_info.to_transfer) 315 | self.progress_indicator.on_start() 316 | 317 | def on_file_transfer(transferred: int) -> None: 318 | progress.file_done += transferred 319 | progress.total_done += transferred 320 | now = time.time() 321 | progress.total_elapsed = now - total_start 322 | progress.file_elapsed = now - file_start 323 | self.last_progress = progress 324 | if now - self.last_progress_update > 0.1: 325 | self.progress_indicator.on_progress(progress) 326 | self.last_progress_update = now 327 | 328 | for src, dest, file_size in self.transfer_info.to_transfer: 329 | file_start = time.time() 330 | progress.index += 1 331 | progress.src = src 332 | progress.dest = dest 333 | progress.file_size = file_size 334 | progress.file_start = time.time() 335 | progress.file_done = 0 336 | 337 | ftm = FileTransferManager(src, dest, self.options) 338 | ftm.set_callback(on_file_transfer) 339 | self.progress_indicator.on_new_file(progress) 340 | error = ftm.do_transfer() 341 | if self.last_progress: 342 | self.progress_indicator.on_progress(self.last_progress) 343 | self.progress_indicator.on_file_done() 344 | if error: 345 | errors[src] = error 346 | 347 | self.progress_indicator.on_finish() 348 | if self.options.move and not self.options.ignore_errors: 349 | for to_remove in self.transfer_info.to_remove: 350 | try: 351 | os.rmdir(to_remove) 352 | except OSError as error: 353 | print( 354 | "Warning: Failed to remove ", 355 | to_remove, 356 | ":\n", 357 | error, 358 | end="\n", 359 | sep="", 360 | ) 361 | 362 | return errors 363 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code 6 | extension-pkg-whitelist= 7 | 8 | # Add files or directories to the blacklist. They should be base names, not 9 | # paths. 10 | ignore=CVS 11 | 12 | # Add files or directories matching the regex patterns to the blacklist. The 13 | # regex matches against base names, not paths. 14 | ignore-patterns= 15 | 16 | # Python code to execute, usually for sys.path manipulation such as 17 | # pygtk.require(). 18 | #init-hook= 19 | 20 | # Use multiple processes to speed up Pylint. 21 | jobs=1 22 | 23 | # List of plugins (as comma separated values of python modules names) to load, 24 | # usually to register additional checkers. 25 | load-plugins= 26 | 27 | # Pickle collected data for later comparisons. 28 | persistent=yes 29 | 30 | # Specify a configuration file. 31 | #rcfile= 32 | 33 | # Allow loading of arbitrary C extensions. Extensions are imported into the 34 | # active Python interpreter and may run arbitrary code. 35 | unsafe-load-any-extension=no 36 | 37 | 38 | [MESSAGES CONTROL] 39 | 40 | # Only show warnings with the listed confidence levels. Leave empty to show 41 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 42 | confidence= 43 | 44 | # Disable the message, report, category or checker with the given id(s). You 45 | # can either give multiple identifiers separated by comma (,) or put this 46 | # option multiple times (only on the command line, not in the configuration 47 | # file where it should appear only once).You can also use "--disable=all" to 48 | # disable everything first and then reenable specific checks. For example, if 49 | # you want to run only the similarities checker, you can use "--disable=all 50 | # --enable=similarities". If you want to run only the classes checker, but have 51 | # no Warning level messages displayed, use"--disable=all --enable=classes 52 | # --disable=W" 53 | disable=print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,long-suffix,old-ne-operator,old-octal-literal,import-star-module-level,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call,no-else-return,fixme,missing-docstring,too-few-public-methods 54 | 55 | 56 | # Enable the message, report, category or checker with the given id(s). You can 57 | # either give multiple identifier separated by comma (,) or put this option 58 | # multiple time (only on the command line, not in the configuration file where 59 | # it should appear only once). See also the "--disable" option for examples. 60 | enable= 61 | 62 | 63 | [REPORTS] 64 | 65 | # Python expression which should return a note less than 10 (10 is the highest 66 | # note). You have access to the variables errors warning, statement which 67 | # respectively contain the number of errors / warnings messages and the total 68 | # number of statements analyzed. This is used by the global evaluation report 69 | # (RP0004). 70 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 71 | 72 | # Template used to display messages. This is a python new-style format string 73 | # used to format the message information. See doc for all details 74 | #msg-template= 75 | 76 | # Set the output format. Available formats are text, parseable, colorized, json 77 | # and msvs (visual studio).You can also give a reporter class, eg 78 | # mypackage.mymodule.MyReporterClass. 79 | output-format=parseable 80 | 81 | # Tells whether to display a full report or only the messages 82 | reports=no 83 | 84 | # Activate the evaluation score. 85 | score=no 86 | 87 | 88 | [REFACTORING] 89 | 90 | # Maximum number of nested blocks for function / method body 91 | max-nested-blocks=5 92 | 93 | 94 | [TYPECHECK] 95 | 96 | # List of decorators that produce context managers, such as 97 | # contextlib.contextmanager. Add to this list to register other decorators that 98 | # produce valid context managers. 99 | contextmanager-decorators=contextlib.contextmanager 100 | 101 | # List of members which are set dynamically and missed by pylint inference 102 | # system, and so shouldn't trigger E1101 when accessed. Python regular 103 | # expressions are accepted. 104 | generated-members= 105 | 106 | # Tells whether missing members accessed in mixin class should be ignored. A 107 | # mixin class is detected if its name ends with "mixin" (case insensitive). 108 | ignore-mixin-members=yes 109 | 110 | # This flag controls whether pylint should warn about no-member and similar 111 | # checks whenever an opaque object is returned when inferring. The inference 112 | # can return multiple potential results while evaluating a Python object, but 113 | # some branches might not be evaluated, which results in partial inference. In 114 | # that case, it might be useful to still emit no-member and other checks for 115 | # the rest of the inferred objects. 116 | ignore-on-opaque-inference=yes 117 | 118 | # List of class names for which member attributes should not be checked (useful 119 | # for classes with dynamically set attributes). This supports the use of 120 | # qualified names. 121 | ignored-classes=optparse.Values,thread._local,_thread._local 122 | 123 | # List of module names for which member attributes should not be checked 124 | # (useful for modules/projects where namespaces are manipulated during runtime 125 | # and thus existing member attributes cannot be deduced by static analysis. It 126 | # supports qualified module names, as well as Unix pattern matching. 127 | ignored-modules= 128 | 129 | # Show a hint with possible names when a member name was not found. The aspect 130 | # of finding the hint is based on edit distance. 131 | missing-member-hint=yes 132 | 133 | # The minimum edit distance a name should have in order to be considered a 134 | # similar match for a missing member name. 135 | missing-member-hint-distance=1 136 | 137 | # The total number of similar names that should be taken in consideration when 138 | # showing a hint for a missing member. 139 | missing-member-max-choices=1 140 | 141 | 142 | [BASIC] 143 | 144 | # Naming hint for argument names 145 | argument-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 146 | 147 | # Regular expression matching correct argument names 148 | argument-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 149 | 150 | # Naming hint for attribute names 151 | attr-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 152 | 153 | # Regular expression matching correct attribute names 154 | attr-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 155 | 156 | # Bad variable names which should always be refused, separated by a comma 157 | bad-names=foo,bar,baz,toto,tutu,tata 158 | 159 | # Naming hint for class attribute names 160 | class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 161 | 162 | # Regular expression matching correct class attribute names 163 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 164 | 165 | # Naming hint for class names 166 | class-name-hint=[A-Z_][a-zA-Z0-9]+$ 167 | 168 | # Regular expression matching correct class names 169 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 170 | 171 | # Naming hint for constant names 172 | const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 173 | 174 | # Regular expression matching correct constant names 175 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 176 | 177 | # Minimum line length for functions/classes that require docstrings, shorter 178 | # ones are exempt. 179 | docstring-min-length=-1 180 | 181 | # Naming hint for function names 182 | function-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 183 | 184 | # Regular expression matching correct function names 185 | function-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 186 | 187 | # Good variable names which should always be accepted, separated by a comma 188 | good-names=i,j,k,ex,Run,_,fd,bar 189 | 190 | # Include a hint for the correct naming format with invalid-name 191 | include-naming-hint=no 192 | 193 | # Naming hint for inline iteration names 194 | inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ 195 | 196 | # Regular expression matching correct inline iteration names 197 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 198 | 199 | # Naming hint for method names 200 | method-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 201 | 202 | # Regular expression matching correct method names 203 | method-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 204 | 205 | # Naming hint for module names 206 | module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 207 | 208 | # Regular expression matching correct module names 209 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 210 | 211 | # Colon-delimited sets of names that determine each other's naming style when 212 | # the name regexes allow several styles. 213 | name-group= 214 | 215 | # Regular expression which should only match function or class names that do 216 | # not require a docstring. 217 | no-docstring-rgx=.* 218 | 219 | # List of decorators that produce properties, such as abc.abstractproperty. Add 220 | # to this list to register other decorators that produce valid properties. 221 | property-classes=abc.abstractproperty 222 | 223 | # Naming hint for variable names 224 | variable-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 225 | 226 | # Regular expression matching correct variable names 227 | variable-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 228 | 229 | 230 | [VARIABLES] 231 | 232 | # List of additional names supposed to be defined in builtins. Remember that 233 | # you should avoid to define new builtins when possible. 234 | additional-builtins= 235 | 236 | # Tells whether unused global variables should be treated as a violation. 237 | allow-global-unused-variables=yes 238 | 239 | # List of strings which can identify a callback function by name. A callback 240 | # name must start or end with one of those strings. 241 | callbacks=cb_,_cb 242 | 243 | # A regular expression matching the name of dummy variables (i.e. expectedly 244 | # not used). 245 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 246 | 247 | # Argument names that match this expression will be ignored. Default to name 248 | # with leading underscore 249 | ignored-argument-names=_.*|^ignored_|^unused_ 250 | 251 | # Tells whether we should check for unused import in __init__ files. 252 | init-import=no 253 | 254 | # List of qualified module names which can have objects that can redefine 255 | # builtins. 256 | redefining-builtins-modules=six.moves,future.builtins 257 | 258 | 259 | [SIMILARITIES] 260 | 261 | # Ignore comments when computing similarities. 262 | ignore-comments=yes 263 | 264 | # Ignore docstrings when computing similarities. 265 | ignore-docstrings=yes 266 | 267 | # Ignore imports when computing similarities. 268 | ignore-imports=no 269 | 270 | # Minimum lines number of a similarity. 271 | min-similarity-lines=4 272 | 273 | 274 | [LOGGING] 275 | 276 | # Logging modules to check that the string format arguments are in logging 277 | # function parameter format 278 | logging-modules=logging 279 | 280 | 281 | [SPELLING] 282 | 283 | # Spelling dictionary name. Available dictionaries: none. To make it working 284 | # install python-enchant package. 285 | spelling-dict= 286 | 287 | # List of comma separated words that should not be checked. 288 | spelling-ignore-words= 289 | 290 | # A path to a file that contains private dictionary; one word per line. 291 | spelling-private-dict-file= 292 | 293 | # Tells whether to store unknown words to indicated private dictionary in 294 | # --spelling-private-dict-file option instead of raising a message. 295 | spelling-store-unknown-words=no 296 | 297 | 298 | [MISCELLANEOUS] 299 | 300 | # List of note tags to take in consideration, separated by a comma. 301 | notes=FIXME,XXX,TODO 302 | 303 | 304 | [FORMAT] 305 | 306 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 307 | expected-line-ending-format= 308 | 309 | # Regexp for a line that is allowed to be longer than the limit. 310 | ignore-long-lines=^\s*(# )??$ 311 | 312 | # Number of spaces of indent required inside a hanging or continued line. 313 | indent-after-paren=4 314 | 315 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 316 | # tab). 317 | indent-string=' ' 318 | 319 | # Maximum number of characters on a single line. 320 | max-line-length=100 321 | 322 | # Maximum number of lines in a module 323 | max-module-lines=1000 324 | 325 | # List of optional constructs for which whitespace checking is disabled. `dict- 326 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 327 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 328 | # `empty-line` allows space-only lines. 329 | no-space-check=trailing-comma,dict-separator 330 | 331 | # Allow the body of a class to be on the same line as the declaration if body 332 | # contains single statement. 333 | single-line-class-stmt=no 334 | 335 | # Allow the body of an if to be on the same line as the test if there is no 336 | # else. 337 | single-line-if-stmt=no 338 | 339 | 340 | [IMPORTS] 341 | 342 | # Allow wildcard imports from modules that define __all__. 343 | allow-wildcard-with-all=no 344 | 345 | # Analyse import fallback blocks. This can be used to support both Python 2 and 346 | # 3 compatible code, which means that the block might have code that exists 347 | # only in one or another interpreter, leading to false positives when analysed. 348 | analyse-fallback-blocks=no 349 | 350 | # Deprecated modules which should not be used, separated by a comma 351 | deprecated-modules=optparse,tkinter.tix 352 | 353 | # Create a graph of external dependencies in the given file (report RP0402 must 354 | # not be disabled) 355 | ext-import-graph= 356 | 357 | # Create a graph of every (i.e. internal and external) dependencies in the 358 | # given file (report RP0402 must not be disabled) 359 | import-graph= 360 | 361 | # Create a graph of internal dependencies in the given file (report RP0402 must 362 | # not be disabled) 363 | int-import-graph= 364 | 365 | # Force import order to recognize a module as part of the standard 366 | # compatibility libraries. 367 | known-standard-library= 368 | 369 | # Force import order to recognize a module as part of a third party library. 370 | known-third-party=enchant 371 | 372 | 373 | [CLASSES] 374 | 375 | # List of method names used to declare (i.e. assign) instance attributes. 376 | defining-attr-methods=__init__,__new__,setUp 377 | 378 | # List of member names, which should be excluded from the protected access 379 | # warning. 380 | exclude-protected=_asdict,_fields,_replace,_source,_make 381 | 382 | # List of valid names for the first argument in a class method. 383 | valid-classmethod-first-arg=cls 384 | 385 | # List of valid names for the first argument in a metaclass class method. 386 | valid-metaclass-classmethod-first-arg=mcs 387 | 388 | 389 | [DESIGN] 390 | 391 | # Maximum number of arguments for function / method 392 | max-args=5 393 | 394 | # Maximum number of attributes for a class (see R0902). 395 | max-attributes=7 396 | 397 | # Maximum number of boolean expressions in a if statement 398 | max-bool-expr=5 399 | 400 | # Maximum number of branch for function / method body 401 | max-branches=12 402 | 403 | # Maximum number of locals for function / method body 404 | max-locals=15 405 | 406 | # Maximum number of parents for a class (see R0901). 407 | max-parents=7 408 | 409 | # Maximum number of public methods for a class (see R0904). 410 | max-public-methods=20 411 | 412 | # Maximum number of return / yield for function / method body 413 | max-returns=6 414 | 415 | # Maximum number of statements in function / method body 416 | max-statements=50 417 | 418 | # Minimum number of public methods for a class (see R0903). 419 | min-public-methods=2 420 | 421 | 422 | [EXCEPTIONS] 423 | 424 | # Exceptions that will emit a warning when being caught. Defaults to 425 | # "Exception" 426 | overgeneral-exceptions=Exception 427 | -------------------------------------------------------------------------------- /pycp/progress.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import os 3 | import shutil 4 | import sys 5 | import time 6 | import typing 7 | from dataclasses import dataclass 8 | 9 | 10 | class Progress: 11 | def __init__(self) -> None: 12 | self.total_done = 0 13 | self.total_size = 0 14 | self.total_elapsed = 0.0 15 | 16 | self.index = 0 17 | self.count = 0 18 | self.src = "" 19 | self.dest = "" 20 | self.file_done = 0 21 | self.file_size = 0 22 | self.file_start = 0.0 23 | self.file_elapsed = 0.0 24 | 25 | 26 | def cursor_up(nb_lines: int) -> None: 27 | """Move the cursor up by nb_lines""" 28 | sys.stdout.write("\033[%dA" % nb_lines) 29 | sys.stdout.flush() 30 | 31 | 32 | def get_fraction(current_value: int, max_value: int) -> float: 33 | if max_value == 0 and current_value == 0: 34 | return 1 35 | if max_value == 0: 36 | return 0 37 | if current_value == 0: 38 | return 0 39 | if current_value == max_value: 40 | return 1 41 | return float(current_value) / max_value 42 | 43 | 44 | def human_readable(size: int) -> str: 45 | """Build a nice human readable string from a size given in 46 | bytes 47 | 48 | """ 49 | if size < 1024**2: 50 | hreadable = float(size) / 1024.0 51 | return "%.0fK" % hreadable 52 | elif size < (1024**3): 53 | hreadable = float(size) / (1024**2) 54 | return "%.1fM" % round(hreadable, 1) 55 | else: 56 | hreadable = float(size) / (1024.0**3) 57 | return "%.2fG" % round(hreadable, 2) 58 | 59 | 60 | def shorten_path(path: str, length: int) -> str: 61 | """Shorten a path so that it is never longer 62 | that the given length 63 | 64 | """ 65 | if len(path) < length: 66 | return path 67 | if os.path.sep not in path: 68 | return shorten_string(path, length) 69 | 70 | short_base = "" 71 | if path.startswith(os.path.sep): 72 | short_base = os.path.sep 73 | path = path[1:] 74 | parts = path.split(os.path.sep) 75 | short_base += os.path.sep.join([p[0] for p in parts[:-1]]) 76 | if len(short_base) > length: 77 | short_base = "" 78 | 79 | # Shorten the last part: 80 | short_name = parts[-1] 81 | last_length = length - len(short_base) 82 | if short_base: 83 | last_length = last_length - 1 84 | short_name = shorten_string(short_name, last_length) 85 | return os.path.join(short_base, short_name) 86 | 87 | 88 | def shorten_string(input_string: str, length: int) -> str: 89 | """Shorten a string in a nice way: 90 | 91 | >>> shorten_string("foobar", 5) 92 | 'fo...' 93 | """ 94 | if len(input_string) < length: 95 | return input_string 96 | if length > 3: 97 | return input_string[: length - 3] + "..." 98 | if length == 3: 99 | return input_string[0] + ".." 100 | if length == 2: 101 | return input_string[0] + "." 102 | if length == 1: 103 | return input_string[0] 104 | return "" 105 | 106 | 107 | def describe_transfer(src: str, dest: str) -> typing.Tuple[str, str, str, str]: 108 | """Returns pfx, src_mid, dest_mid, sfx, the 4 components 109 | required to build the "foo/{bar => baz}/qux" string 110 | 111 | """ 112 | # Note: directly borrowed from git's diff.c file. 113 | len_src = len(src) 114 | len_dest = len(dest) 115 | 116 | # Find common prefix 117 | pfx_length = 0 118 | i = 0 119 | j = 0 120 | while i < len_src and j < len_dest and src[i] == dest[j]: 121 | if src[i] == os.path.sep: 122 | pfx_length = i + 1 123 | i += 1 124 | j += 1 125 | 126 | # Find common suffix 127 | sfx_length = 0 128 | i = len_src - 1 129 | j = len_dest - 1 130 | while i > 0 and j > 0 and src[i] == dest[j]: 131 | if src[i] == os.path.sep: 132 | sfx_length = len_src - i 133 | i -= 1 134 | j -= 1 135 | 136 | src_midlen = len_src - pfx_length - sfx_length 137 | dest_midlen = len_dest - pfx_length - sfx_length 138 | 139 | pfx = src[:pfx_length] 140 | sfx = dest[len_dest - sfx_length :] 141 | src_mid = src[pfx_length : pfx_length + src_midlen] 142 | dest_mid = dest[pfx_length : pfx_length + dest_midlen] 143 | 144 | if pfx == os.path.sep: 145 | # The common prefix is / , 146 | # avoid print /{etc => tmp}/foo, and 147 | # print {/etc => /tmp}/foo 148 | pfx = "" 149 | src_mid = os.path.sep + src_mid 150 | dest_mid = os.path.sep + dest_mid 151 | 152 | return pfx, src_mid, dest_mid, sfx 153 | 154 | 155 | Props = typing.Dict[str, typing.Any] 156 | SizedString = typing.Tuple[int, str] 157 | 158 | 159 | class Component(metaclass=abc.ABCMeta): 160 | @abc.abstractmethod 161 | def render(self, props: Props) -> SizedString: 162 | """Should return a tuple with a length and a string""" 163 | 164 | 165 | class AnsiEscapeSequence(Component): 166 | seq = "\0" 167 | 168 | def render(self, props: Props) -> SizedString: 169 | return 0, self.seq 170 | 171 | 172 | class Blue(AnsiEscapeSequence): 173 | seq = "\x1b[34;1m" 174 | 175 | 176 | class Bold(AnsiEscapeSequence): 177 | seq = "\x1b[1m" 178 | 179 | 180 | class Brown(AnsiEscapeSequence): 181 | seq = "\x1b[33m" 182 | 183 | 184 | class Green(AnsiEscapeSequence): 185 | seq = "\x1b[32;1m" 186 | 187 | 188 | class LightGray(AnsiEscapeSequence): 189 | seq = "\x1b[37m" 190 | 191 | 192 | class Reset(AnsiEscapeSequence): 193 | seq = "\x1b[0m" 194 | 195 | 196 | class Standout(AnsiEscapeSequence): 197 | seq = "\x1b[3m" 198 | 199 | 200 | class Yellow(AnsiEscapeSequence): 201 | seq = "\x1b[33;1m" 202 | 203 | 204 | class Text(Component): 205 | def __init__(self, text: str) -> None: 206 | self.text = text 207 | 208 | def render(self, props: Props) -> SizedString: 209 | return len(self.text), self.text 210 | 211 | 212 | class Space(Text): 213 | def __init__(self) -> None: 214 | super().__init__(" ") 215 | 216 | 217 | class Dash(Text): 218 | def __init__(self) -> None: 219 | super().__init__(" - ") 220 | 221 | 222 | class Pipe(Text): 223 | def __init__(self) -> None: 224 | super().__init__(" | ") 225 | 226 | 227 | class DynamicText(Component, metaclass=abc.ABCMeta): 228 | @abc.abstractmethod 229 | def get_text(self, props: Props) -> str: 230 | pass 231 | 232 | def render(self, props: Props) -> SizedString: 233 | text = self.get_text(props) 234 | return len(text), text 235 | 236 | 237 | # TODO: fix name 238 | class FixedWidthComponent(Component, metaclass=abc.ABCMeta): 239 | def render(self, props: Props) -> SizedString: 240 | width = props["width"] 241 | text = self.render_props_for_width(props, width) 242 | return len(text), text 243 | 244 | @abc.abstractmethod 245 | def render_props_for_width(self, props: Props, width: int) -> str: 246 | pass 247 | 248 | 249 | class TransferText(FixedWidthComponent): 250 | def render_props_for_width(self, props: Props, width: int) -> str: 251 | src = props["src"] 252 | dest = props["dest"] 253 | pfx, src_mid, dest_mid, sfx = describe_transfer(src, dest) 254 | if not pfx and not sfx: 255 | components = [ 256 | Bold(), 257 | Text(src), 258 | Reset(), 259 | Blue(), 260 | Text(" => "), 261 | Reset(), 262 | Bold(), 263 | Text(dest), 264 | ] 265 | else: 266 | components = [ 267 | Bold(), 268 | Text(pfx), 269 | Reset(), 270 | LightGray(), 271 | Text("{%s => %s}" % (src_mid, dest_mid)), 272 | Reset(), 273 | Bold(), 274 | Text(sfx), 275 | ] 276 | return "".join(x.render({})[1] for x in components) 277 | 278 | 279 | class Counter(DynamicText): 280 | def get_text(self, props: Props) -> str: 281 | index = props["index"] 282 | count = props["count"] 283 | num_digits = len(str(count)) 284 | counter_format = "[%{}d/%d]".format(num_digits) 285 | return counter_format % (index, count) 286 | 287 | 288 | class Percent(DynamicText): 289 | def get_text(self, props: Props) -> str: 290 | current_value = props["current_value"] 291 | max_value = props["max_value"] 292 | fraction = get_fraction(current_value, max_value) 293 | return "%3d%%" % int(fraction * 100) 294 | 295 | 296 | class Bar(FixedWidthComponent): 297 | def render_props_for_width(self, props: Props, width: int) -> str: 298 | current_value = props["current_value"] 299 | max_value = props["max_value"] 300 | 301 | marker = "#" 302 | fraction = get_fraction(current_value, max_value) 303 | cwidth = width - 2 304 | marked_width = int(fraction * cwidth) 305 | res = (marker * marked_width).ljust(cwidth) 306 | return "[%s]" % res 307 | 308 | 309 | class Speed(DynamicText): 310 | def get_text(self, props: Props) -> str: 311 | elapsed = props["elapsed"] 312 | current_value = props["current_value"] 313 | if elapsed < 2e-6: 314 | bits_per_second = 0.0 315 | else: 316 | bits_per_second = float(current_value) / elapsed 317 | speed = bits_per_second 318 | 319 | units = ["B", "K", "M", "G", "T", "P"] 320 | unit = None 321 | for unit in units: 322 | if speed < 1000: 323 | break 324 | speed /= 1000 325 | speed_format = "%.2f %s" 326 | return speed_format % (speed, unit + "/s") 327 | 328 | 329 | class ETA(DynamicText): 330 | @classmethod 331 | def get_eta(cls, fraction: float, elapsed: int) -> str: 332 | if fraction == 0: 333 | return "ETA : --:--:--" 334 | if fraction == 1: 335 | return "Time : " + cls.format_time(elapsed) 336 | eta = elapsed / fraction - elapsed 337 | return cls.format_time(eta) 338 | 339 | @staticmethod 340 | def format_time(seconds: float) -> str: 341 | return time.strftime("%H:%M:%S", time.gmtime(seconds)) 342 | 343 | def get_text(self, props: Props) -> str: 344 | elapsed = props["elapsed"] 345 | current_value = props["current_value"] 346 | max_value = props["max_value"] 347 | fraction = get_fraction(current_value, max_value) 348 | eta = self.get_eta(fraction, elapsed) 349 | return eta 350 | 351 | 352 | class Filename(DynamicText): 353 | def get_text(self, props: Props) -> str: 354 | filename = props["filename"] 355 | return shorten_path(filename, 40) 356 | 357 | 358 | @dataclass 359 | class FixedTuple: 360 | index: int 361 | component: Component 362 | 363 | 364 | class Line: 365 | def __init__(self, components: typing.List[Component]) -> None: 366 | self.components = components 367 | fixed = list() 368 | for i, component in enumerate(components): 369 | if isinstance(component, FixedWidthComponent): 370 | fixed.append(FixedTuple(i, component)) 371 | assert len(fixed) == 1, "Expecting exactly one fixed width component" 372 | self.fixed = fixed[0] 373 | 374 | def render(self, **kwargs: typing.Any) -> str: 375 | accumulator = [""] * len(self.components) 376 | term_width = shutil.get_terminal_size().columns 377 | current_width = 0 378 | for i, component in enumerate(self.components): 379 | if i == self.fixed.index: 380 | continue 381 | length, string = component.render(kwargs) 382 | accumulator[i] = string 383 | current_width += length 384 | 385 | fixed_width = term_width - current_width 386 | kwargs["width"] = fixed_width 387 | accumulator[self.fixed.index] = self.fixed.component.render(kwargs)[1] 388 | 389 | return "".join(accumulator) 390 | 391 | 392 | class ProgressIndicator: 393 | def __init__(self) -> None: 394 | pass 395 | 396 | def on_new_file(self, progress: Progress) -> None: 397 | pass 398 | 399 | def on_file_done(self) -> None: 400 | pass 401 | 402 | def on_progress(self, progress: Progress) -> None: 403 | pass 404 | 405 | def on_start(self) -> None: 406 | pass 407 | 408 | def on_finish(self) -> None: 409 | pass 410 | 411 | 412 | class OneFileIndicator(ProgressIndicator): 413 | def __init__(self) -> None: 414 | super().__init__() 415 | self.first_line = Line([Blue(), Counter(), TransferText(), Reset()]) 416 | self.second_line = Line( 417 | [ 418 | Blue(), 419 | Percent(), 420 | Reset(), 421 | Space(), 422 | LightGray(), 423 | Bar(), 424 | Reset(), 425 | Dash(), 426 | Standout(), 427 | Speed(), 428 | Reset(), 429 | Pipe(), 430 | Yellow(), 431 | ETA(), 432 | Reset(), 433 | ] 434 | ) 435 | 436 | def on_new_file(self, progress: Progress) -> None: 437 | out1 = self.first_line.render( 438 | index=progress.index, 439 | count=progress.count, 440 | src=progress.src, 441 | dest=progress.dest, 442 | ) 443 | print(out1) 444 | out2 = self.second_line.render( 445 | current_value=0, elapsed=0, max_value=progress.file_size 446 | ) 447 | print(out2, end="\r") 448 | 449 | def on_progress(self, progress: Progress) -> None: 450 | out = self.second_line.render( 451 | index=progress.index, 452 | count=progress.count, 453 | current_value=progress.file_done, 454 | elapsed=progress.file_elapsed, 455 | max_value=progress.file_size, 456 | ) 457 | print(out, end="\r") 458 | 459 | def on_file_done(self) -> None: 460 | print() 461 | 462 | 463 | class GlobalIndicator(ProgressIndicator): 464 | def __init__(self) -> None: 465 | super().__init__() 466 | self.first_line = self.build_first_line() 467 | self.second_line = self.build_second_line() 468 | 469 | def on_start(self) -> None: 470 | print() 471 | 472 | @staticmethod 473 | def build_first_line() -> Line: 474 | return Line( 475 | [ 476 | Green(), 477 | Counter(), 478 | Reset(), 479 | Space(), 480 | Blue(), 481 | Percent(), 482 | Reset(), 483 | Dash(), 484 | LightGray(), 485 | Bar(), 486 | Reset(), 487 | Dash(), 488 | Yellow(), 489 | ETA(), 490 | Reset(), 491 | ] 492 | ) 493 | 494 | @staticmethod 495 | def build_second_line() -> Line: 496 | return Line( 497 | [ 498 | Blue(), 499 | Percent(), 500 | Reset(), 501 | Space(), 502 | Bold(), 503 | Filename(), 504 | Reset(), 505 | Space(), 506 | LightGray(), 507 | Bar(), 508 | Reset(), 509 | Dash(), 510 | Standout(), 511 | Speed(), 512 | Reset(), 513 | Pipe(), 514 | Yellow(), 515 | ETA(), 516 | Reset(), 517 | ] 518 | ) 519 | 520 | def _render_first_line(self, progress: Progress) -> None: 521 | out = self.first_line.render( 522 | index=progress.index, 523 | count=progress.count, 524 | current_value=progress.total_done, 525 | elapsed=progress.total_elapsed, 526 | max_value=progress.total_size, 527 | ) 528 | cursor_up(2) 529 | print("\r", out, sep="") 530 | 531 | def _render_second_line(self, progress: Progress) -> None: 532 | out = self.second_line.render( 533 | current_value=progress.file_done, 534 | max_value=progress.file_size, 535 | elapsed=progress.file_elapsed, 536 | filename=progress.src, 537 | ) 538 | print("\r", out, sep="") 539 | 540 | def on_progress(self, progress: Progress) -> None: 541 | self._render_first_line(progress) 542 | self._render_second_line(progress) 543 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "black" 5 | version = "23.12.0" 6 | description = "The uncompromising code formatter." 7 | optional = false 8 | python-versions = ">=3.8" 9 | files = [ 10 | {file = "black-23.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:67f19562d367468ab59bd6c36a72b2c84bc2f16b59788690e02bbcb140a77175"}, 11 | {file = "black-23.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bbd75d9f28a7283b7426160ca21c5bd640ca7cd8ef6630b4754b6df9e2da8462"}, 12 | {file = "black-23.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:593596f699ca2dcbbbdfa59fcda7d8ad6604370c10228223cd6cf6ce1ce7ed7e"}, 13 | {file = "black-23.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:12d5f10cce8dc27202e9a252acd1c9a426c83f95496c959406c96b785a92bb7d"}, 14 | {file = "black-23.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e73c5e3d37e5a3513d16b33305713237a234396ae56769b839d7c40759b8a41c"}, 15 | {file = "black-23.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ba09cae1657c4f8a8c9ff6cfd4a6baaf915bb4ef7d03acffe6a2f6585fa1bd01"}, 16 | {file = "black-23.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ace64c1a349c162d6da3cef91e3b0e78c4fc596ffde9413efa0525456148873d"}, 17 | {file = "black-23.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:72db37a2266b16d256b3ea88b9affcdd5c41a74db551ec3dd4609a59c17d25bf"}, 18 | {file = "black-23.12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fdf6f23c83078a6c8da2442f4d4eeb19c28ac2a6416da7671b72f0295c4a697b"}, 19 | {file = "black-23.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39dda060b9b395a6b7bf9c5db28ac87b3c3f48d4fdff470fa8a94ab8271da47e"}, 20 | {file = "black-23.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7231670266ca5191a76cb838185d9be59cfa4f5dd401b7c1c70b993c58f6b1b5"}, 21 | {file = "black-23.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:193946e634e80bfb3aec41830f5d7431f8dd5b20d11d89be14b84a97c6b8bc75"}, 22 | {file = "black-23.12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bcf91b01ddd91a2fed9a8006d7baa94ccefe7e518556470cf40213bd3d44bbbc"}, 23 | {file = "black-23.12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:996650a89fe5892714ea4ea87bc45e41a59a1e01675c42c433a35b490e5aa3f0"}, 24 | {file = "black-23.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdbff34c487239a63d86db0c9385b27cdd68b1bfa4e706aa74bb94a435403672"}, 25 | {file = "black-23.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:97af22278043a6a1272daca10a6f4d36c04dfa77e61cbaaf4482e08f3640e9f0"}, 26 | {file = "black-23.12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ead25c273adfad1095a8ad32afdb8304933efba56e3c1d31b0fee4143a1e424a"}, 27 | {file = "black-23.12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c71048345bdbced456cddf1622832276d98a710196b842407840ae8055ade6ee"}, 28 | {file = "black-23.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a832b6e00eef2c13b3239d514ea3b7d5cc3eaa03d0474eedcbbda59441ba5d"}, 29 | {file = "black-23.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:6a82a711d13e61840fb11a6dfecc7287f2424f1ca34765e70c909a35ffa7fb95"}, 30 | {file = "black-23.12.0-py3-none-any.whl", hash = "sha256:a7c07db8200b5315dc07e331dda4d889a56f6bf4db6a9c2a526fa3166a81614f"}, 31 | {file = "black-23.12.0.tar.gz", hash = "sha256:330a327b422aca0634ecd115985c1c7fd7bdb5b5a2ef8aa9888a82e2ebe9437a"}, 32 | ] 33 | 34 | [package.dependencies] 35 | click = ">=8.0.0" 36 | mypy-extensions = ">=0.4.3" 37 | packaging = ">=22.0" 38 | pathspec = ">=0.9.0" 39 | platformdirs = ">=2" 40 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 41 | typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} 42 | 43 | [package.extras] 44 | colorama = ["colorama (>=0.4.3)"] 45 | d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] 46 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 47 | uvloop = ["uvloop (>=0.15.2)"] 48 | 49 | [[package]] 50 | name = "click" 51 | version = "8.1.7" 52 | description = "Composable command line interface toolkit" 53 | optional = false 54 | python-versions = ">=3.7" 55 | files = [ 56 | {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, 57 | {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, 58 | ] 59 | 60 | [package.dependencies] 61 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 62 | 63 | [[package]] 64 | name = "colorama" 65 | version = "0.4.6" 66 | description = "Cross-platform colored terminal text." 67 | optional = false 68 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 69 | files = [ 70 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 71 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 72 | ] 73 | 74 | [[package]] 75 | name = "coverage" 76 | version = "7.3.4" 77 | description = "Code coverage measurement for Python" 78 | optional = false 79 | python-versions = ">=3.8" 80 | files = [ 81 | {file = "coverage-7.3.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:aff2bd3d585969cc4486bfc69655e862028b689404563e6b549e6a8244f226df"}, 82 | {file = "coverage-7.3.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4353923f38d752ecfbd3f1f20bf7a3546993ae5ecd7c07fd2f25d40b4e54571"}, 83 | {file = "coverage-7.3.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea473c37872f0159294f7073f3fa72f68b03a129799f3533b2bb44d5e9fa4f82"}, 84 | {file = "coverage-7.3.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5214362abf26e254d749fc0c18af4c57b532a4bfde1a057565616dd3b8d7cc94"}, 85 | {file = "coverage-7.3.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f99b7d3f7a7adfa3d11e3a48d1a91bb65739555dd6a0d3fa68aa5852d962e5b1"}, 86 | {file = "coverage-7.3.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:74397a1263275bea9d736572d4cf338efaade2de9ff759f9c26bcdceb383bb49"}, 87 | {file = "coverage-7.3.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:f154bd866318185ef5865ace5be3ac047b6d1cc0aeecf53bf83fe846f4384d5d"}, 88 | {file = "coverage-7.3.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e0d84099ea7cba9ff467f9c6f747e3fc3906e2aadac1ce7b41add72e8d0a3712"}, 89 | {file = "coverage-7.3.4-cp310-cp310-win32.whl", hash = "sha256:3f477fb8a56e0c603587b8278d9dbd32e54bcc2922d62405f65574bd76eba78a"}, 90 | {file = "coverage-7.3.4-cp310-cp310-win_amd64.whl", hash = "sha256:c75738ce13d257efbb6633a049fb2ed8e87e2e6c2e906c52d1093a4d08d67c6b"}, 91 | {file = "coverage-7.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:997aa14b3e014339d8101b9886063c5d06238848905d9ad6c6eabe533440a9a7"}, 92 | {file = "coverage-7.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8a9c5bc5db3eb4cd55ecb8397d8e9b70247904f8eca718cc53c12dcc98e59fc8"}, 93 | {file = "coverage-7.3.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27ee94f088397d1feea3cb524e4313ff0410ead7d968029ecc4bc5a7e1d34fbf"}, 94 | {file = "coverage-7.3.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ce03e25e18dd9bf44723e83bc202114817f3367789052dc9e5b5c79f40cf59d"}, 95 | {file = "coverage-7.3.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85072e99474d894e5df582faec04abe137b28972d5e466999bc64fc37f564a03"}, 96 | {file = "coverage-7.3.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a877810ef918d0d345b783fc569608804f3ed2507bf32f14f652e4eaf5d8f8d0"}, 97 | {file = "coverage-7.3.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9ac17b94ab4ca66cf803f2b22d47e392f0977f9da838bf71d1f0db6c32893cb9"}, 98 | {file = "coverage-7.3.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:36d75ef2acab74dc948d0b537ef021306796da551e8ac8b467810911000af66a"}, 99 | {file = "coverage-7.3.4-cp311-cp311-win32.whl", hash = "sha256:47ee56c2cd445ea35a8cc3ad5c8134cb9bece3a5cb50bb8265514208d0a65928"}, 100 | {file = "coverage-7.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:11ab62d0ce5d9324915726f611f511a761efcca970bd49d876cf831b4de65be5"}, 101 | {file = "coverage-7.3.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:33e63c578f4acce1b6cd292a66bc30164495010f1091d4b7529d014845cd9bee"}, 102 | {file = "coverage-7.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:782693b817218169bfeb9b9ba7f4a9f242764e180ac9589b45112571f32a0ba6"}, 103 | {file = "coverage-7.3.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c4277ddaad9293454da19121c59f2d850f16bcb27f71f89a5c4836906eb35ef"}, 104 | {file = "coverage-7.3.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3d892a19ae24b9801771a5a989fb3e850bd1ad2e2b6e83e949c65e8f37bc67a1"}, 105 | {file = "coverage-7.3.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3024ec1b3a221bd10b5d87337d0373c2bcaf7afd86d42081afe39b3e1820323b"}, 106 | {file = "coverage-7.3.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a1c3e9d2bbd6f3f79cfecd6f20854f4dc0c6e0ec317df2b265266d0dc06535f1"}, 107 | {file = "coverage-7.3.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:e91029d7f151d8bf5ab7d8bfe2c3dbefd239759d642b211a677bc0709c9fdb96"}, 108 | {file = "coverage-7.3.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:6879fe41c60080aa4bb59703a526c54e0412b77e649a0d06a61782ecf0853ee1"}, 109 | {file = "coverage-7.3.4-cp312-cp312-win32.whl", hash = "sha256:fd2f8a641f8f193968afdc8fd1697e602e199931012b574194052d132a79be13"}, 110 | {file = "coverage-7.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:d1d0ce6c6947a3a4aa5479bebceff2c807b9f3b529b637e2b33dea4468d75fc7"}, 111 | {file = "coverage-7.3.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:36797b3625d1da885b369bdaaa3b0d9fb8865caed3c2b8230afaa6005434aa2f"}, 112 | {file = "coverage-7.3.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bfed0ec4b419fbc807dec417c401499ea869436910e1ca524cfb4f81cf3f60e7"}, 113 | {file = "coverage-7.3.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f97ff5a9fc2ca47f3383482858dd2cb8ddbf7514427eecf5aa5f7992d0571429"}, 114 | {file = "coverage-7.3.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:607b6c6b35aa49defaebf4526729bd5238bc36fe3ef1a417d9839e1d96ee1e4c"}, 115 | {file = "coverage-7.3.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8e258dcc335055ab59fe79f1dec217d9fb0cdace103d6b5c6df6b75915e7959"}, 116 | {file = "coverage-7.3.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a02ac7c51819702b384fea5ee033a7c202f732a2a2f1fe6c41e3d4019828c8d3"}, 117 | {file = "coverage-7.3.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:b710869a15b8caf02e31d16487a931dbe78335462a122c8603bb9bd401ff6fb2"}, 118 | {file = "coverage-7.3.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c6a23ae9348a7a92e7f750f9b7e828448e428e99c24616dec93a0720342f241d"}, 119 | {file = "coverage-7.3.4-cp38-cp38-win32.whl", hash = "sha256:758ebaf74578b73f727acc4e8ab4b16ab6f22a5ffd7dd254e5946aba42a4ce76"}, 120 | {file = "coverage-7.3.4-cp38-cp38-win_amd64.whl", hash = "sha256:309ed6a559bc942b7cc721f2976326efbfe81fc2b8f601c722bff927328507dc"}, 121 | {file = "coverage-7.3.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:aefbb29dc56317a4fcb2f3857d5bce9b881038ed7e5aa5d3bcab25bd23f57328"}, 122 | {file = "coverage-7.3.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:183c16173a70caf92e2dfcfe7c7a576de6fa9edc4119b8e13f91db7ca33a7923"}, 123 | {file = "coverage-7.3.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a4184dcbe4f98d86470273e758f1d24191ca095412e4335ff27b417291f5964"}, 124 | {file = "coverage-7.3.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93698ac0995516ccdca55342599a1463ed2e2d8942316da31686d4d614597ef9"}, 125 | {file = "coverage-7.3.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb220b3596358a86361139edce40d97da7458412d412e1e10c8e1970ee8c09ab"}, 126 | {file = "coverage-7.3.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d5b14abde6f8d969e6b9dd8c7a013d9a2b52af1235fe7bebef25ad5c8f47fa18"}, 127 | {file = "coverage-7.3.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:610afaf929dc0e09a5eef6981edb6a57a46b7eceff151947b836d869d6d567c1"}, 128 | {file = "coverage-7.3.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d6ed790728fb71e6b8247bd28e77e99d0c276dff952389b5388169b8ca7b1c28"}, 129 | {file = "coverage-7.3.4-cp39-cp39-win32.whl", hash = "sha256:c15fdfb141fcf6a900e68bfa35689e1256a670db32b96e7a931cab4a0e1600e5"}, 130 | {file = "coverage-7.3.4-cp39-cp39-win_amd64.whl", hash = "sha256:38d0b307c4d99a7aca4e00cad4311b7c51b7ac38fb7dea2abe0d182dd4008e05"}, 131 | {file = "coverage-7.3.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:b1e0f25ae99cf247abfb3f0fac7ae25739e4cd96bf1afa3537827c576b4847e5"}, 132 | {file = "coverage-7.3.4.tar.gz", hash = "sha256:020d56d2da5bc22a0e00a5b0d54597ee91ad72446fa4cf1b97c35022f6b6dbf0"}, 133 | ] 134 | 135 | [package.dependencies] 136 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 137 | 138 | [package.extras] 139 | toml = ["tomli"] 140 | 141 | [[package]] 142 | name = "exceptiongroup" 143 | version = "1.2.0" 144 | description = "Backport of PEP 654 (exception groups)" 145 | optional = false 146 | python-versions = ">=3.7" 147 | files = [ 148 | {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, 149 | {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, 150 | ] 151 | 152 | [package.extras] 153 | test = ["pytest (>=6)"] 154 | 155 | [[package]] 156 | name = "flake8" 157 | version = "6.1.0" 158 | description = "the modular source code checker: pep8 pyflakes and co" 159 | optional = false 160 | python-versions = ">=3.8.1" 161 | files = [ 162 | {file = "flake8-6.1.0-py2.py3-none-any.whl", hash = "sha256:ffdfce58ea94c6580c77888a86506937f9a1a227dfcd15f245d694ae20a6b6e5"}, 163 | {file = "flake8-6.1.0.tar.gz", hash = "sha256:d5b3857f07c030bdb5bf41c7f53799571d75c4491748a3adcd47de929e34cd23"}, 164 | ] 165 | 166 | [package.dependencies] 167 | mccabe = ">=0.7.0,<0.8.0" 168 | pycodestyle = ">=2.11.0,<2.12.0" 169 | pyflakes = ">=3.1.0,<3.2.0" 170 | 171 | [[package]] 172 | name = "iniconfig" 173 | version = "2.0.0" 174 | description = "brain-dead simple config-ini parsing" 175 | optional = false 176 | python-versions = ">=3.7" 177 | files = [ 178 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 179 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 180 | ] 181 | 182 | [[package]] 183 | name = "isort" 184 | version = "5.13.2" 185 | description = "A Python utility / library to sort Python imports." 186 | optional = false 187 | python-versions = ">=3.8.0" 188 | files = [ 189 | {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, 190 | {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, 191 | ] 192 | 193 | [package.extras] 194 | colors = ["colorama (>=0.4.6)"] 195 | 196 | [[package]] 197 | name = "mccabe" 198 | version = "0.7.0" 199 | description = "McCabe checker, plugin for flake8" 200 | optional = false 201 | python-versions = ">=3.6" 202 | files = [ 203 | {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, 204 | {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, 205 | ] 206 | 207 | [[package]] 208 | name = "mypy" 209 | version = "1.7.1" 210 | description = "Optional static typing for Python" 211 | optional = false 212 | python-versions = ">=3.8" 213 | files = [ 214 | {file = "mypy-1.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:12cce78e329838d70a204293e7b29af9faa3ab14899aec397798a4b41be7f340"}, 215 | {file = "mypy-1.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1484b8fa2c10adf4474f016e09d7a159602f3239075c7bf9f1627f5acf40ad49"}, 216 | {file = "mypy-1.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31902408f4bf54108bbfb2e35369877c01c95adc6192958684473658c322c8a5"}, 217 | {file = "mypy-1.7.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f2c2521a8e4d6d769e3234350ba7b65ff5d527137cdcde13ff4d99114b0c8e7d"}, 218 | {file = "mypy-1.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:fcd2572dd4519e8a6642b733cd3a8cfc1ef94bafd0c1ceed9c94fe736cb65b6a"}, 219 | {file = "mypy-1.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b901927f16224d0d143b925ce9a4e6b3a758010673eeded9b748f250cf4e8f7"}, 220 | {file = "mypy-1.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2f7f6985d05a4e3ce8255396df363046c28bea790e40617654e91ed580ca7c51"}, 221 | {file = "mypy-1.7.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:944bdc21ebd620eafefc090cdf83158393ec2b1391578359776c00de00e8907a"}, 222 | {file = "mypy-1.7.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9c7ac372232c928fff0645d85f273a726970c014749b924ce5710d7d89763a28"}, 223 | {file = "mypy-1.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:f6efc9bd72258f89a3816e3a98c09d36f079c223aa345c659622f056b760ab42"}, 224 | {file = "mypy-1.7.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6dbdec441c60699288adf051f51a5d512b0d818526d1dcfff5a41f8cd8b4aaf1"}, 225 | {file = "mypy-1.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4fc3d14ee80cd22367caaaf6e014494415bf440980a3045bf5045b525680ac33"}, 226 | {file = "mypy-1.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c6e4464ed5f01dc44dc9821caf67b60a4e5c3b04278286a85c067010653a0eb"}, 227 | {file = "mypy-1.7.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:d9b338c19fa2412f76e17525c1b4f2c687a55b156320acb588df79f2e6fa9fea"}, 228 | {file = "mypy-1.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:204e0d6de5fd2317394a4eff62065614c4892d5a4d1a7ee55b765d7a3d9e3f82"}, 229 | {file = "mypy-1.7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:84860e06ba363d9c0eeabd45ac0fde4b903ad7aa4f93cd8b648385a888e23200"}, 230 | {file = "mypy-1.7.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8c5091ebd294f7628eb25ea554852a52058ac81472c921150e3a61cdd68f75a7"}, 231 | {file = "mypy-1.7.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40716d1f821b89838589e5b3106ebbc23636ffdef5abc31f7cd0266db936067e"}, 232 | {file = "mypy-1.7.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5cf3f0c5ac72139797953bd50bc6c95ac13075e62dbfcc923571180bebb662e9"}, 233 | {file = "mypy-1.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:78e25b2fd6cbb55ddfb8058417df193f0129cad5f4ee75d1502248e588d9e0d7"}, 234 | {file = "mypy-1.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:75c4d2a6effd015786c87774e04331b6da863fc3fc4e8adfc3b40aa55ab516fe"}, 235 | {file = "mypy-1.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2643d145af5292ee956aa0a83c2ce1038a3bdb26e033dadeb2f7066fb0c9abce"}, 236 | {file = "mypy-1.7.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75aa828610b67462ffe3057d4d8a4112105ed211596b750b53cbfe182f44777a"}, 237 | {file = "mypy-1.7.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ee5d62d28b854eb61889cde4e1dbc10fbaa5560cb39780c3995f6737f7e82120"}, 238 | {file = "mypy-1.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:72cf32ce7dd3562373f78bd751f73c96cfb441de147cc2448a92c1a308bd0ca6"}, 239 | {file = "mypy-1.7.1-py3-none-any.whl", hash = "sha256:f7c5d642db47376a0cc130f0de6d055056e010debdaf0707cd2b0fc7e7ef30ea"}, 240 | {file = "mypy-1.7.1.tar.gz", hash = "sha256:fcb6d9afb1b6208b4c712af0dafdc650f518836065df0d4fb1d800f5d6773db2"}, 241 | ] 242 | 243 | [package.dependencies] 244 | mypy-extensions = ">=1.0.0" 245 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 246 | typing-extensions = ">=4.1.0" 247 | 248 | [package.extras] 249 | dmypy = ["psutil (>=4.0)"] 250 | install-types = ["pip"] 251 | mypyc = ["setuptools (>=50)"] 252 | reports = ["lxml"] 253 | 254 | [[package]] 255 | name = "mypy-extensions" 256 | version = "1.0.0" 257 | description = "Type system extensions for programs checked with the mypy type checker." 258 | optional = false 259 | python-versions = ">=3.5" 260 | files = [ 261 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 262 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 263 | ] 264 | 265 | [[package]] 266 | name = "packaging" 267 | version = "23.2" 268 | description = "Core utilities for Python packages" 269 | optional = false 270 | python-versions = ">=3.7" 271 | files = [ 272 | {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, 273 | {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, 274 | ] 275 | 276 | [[package]] 277 | name = "pathspec" 278 | version = "0.12.1" 279 | description = "Utility library for gitignore style pattern matching of file paths." 280 | optional = false 281 | python-versions = ">=3.8" 282 | files = [ 283 | {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, 284 | {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, 285 | ] 286 | 287 | [[package]] 288 | name = "platformdirs" 289 | version = "4.1.0" 290 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 291 | optional = false 292 | python-versions = ">=3.8" 293 | files = [ 294 | {file = "platformdirs-4.1.0-py3-none-any.whl", hash = "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380"}, 295 | {file = "platformdirs-4.1.0.tar.gz", hash = "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420"}, 296 | ] 297 | 298 | [package.extras] 299 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] 300 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] 301 | 302 | [[package]] 303 | name = "pluggy" 304 | version = "1.3.0" 305 | description = "plugin and hook calling mechanisms for python" 306 | optional = false 307 | python-versions = ">=3.8" 308 | files = [ 309 | {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, 310 | {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, 311 | ] 312 | 313 | [package.extras] 314 | dev = ["pre-commit", "tox"] 315 | testing = ["pytest", "pytest-benchmark"] 316 | 317 | [[package]] 318 | name = "pycodestyle" 319 | version = "2.11.1" 320 | description = "Python style guide checker" 321 | optional = false 322 | python-versions = ">=3.8" 323 | files = [ 324 | {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"}, 325 | {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"}, 326 | ] 327 | 328 | [[package]] 329 | name = "pyflakes" 330 | version = "3.1.0" 331 | description = "passive checker of Python programs" 332 | optional = false 333 | python-versions = ">=3.8" 334 | files = [ 335 | {file = "pyflakes-3.1.0-py2.py3-none-any.whl", hash = "sha256:4132f6d49cb4dae6819e5379898f2b8cce3c5f23994194c24b77d5da2e36f774"}, 336 | {file = "pyflakes-3.1.0.tar.gz", hash = "sha256:a0aae034c444db0071aa077972ba4768d40c830d9539fd45bf4cd3f8f6992efc"}, 337 | ] 338 | 339 | [[package]] 340 | name = "pytest" 341 | version = "7.4.3" 342 | description = "pytest: simple powerful testing with Python" 343 | optional = false 344 | python-versions = ">=3.7" 345 | files = [ 346 | {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, 347 | {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, 348 | ] 349 | 350 | [package.dependencies] 351 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 352 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 353 | iniconfig = "*" 354 | packaging = "*" 355 | pluggy = ">=0.12,<2.0" 356 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 357 | 358 | [package.extras] 359 | testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 360 | 361 | [[package]] 362 | name = "pytest-cov" 363 | version = "4.1.0" 364 | description = "Pytest plugin for measuring coverage." 365 | optional = false 366 | python-versions = ">=3.7" 367 | files = [ 368 | {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, 369 | {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, 370 | ] 371 | 372 | [package.dependencies] 373 | coverage = {version = ">=5.2.1", extras = ["toml"]} 374 | pytest = ">=4.6" 375 | 376 | [package.extras] 377 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] 378 | 379 | [[package]] 380 | name = "pytest-mock" 381 | version = "3.12.0" 382 | description = "Thin-wrapper around the mock package for easier use with pytest" 383 | optional = false 384 | python-versions = ">=3.8" 385 | files = [ 386 | {file = "pytest-mock-3.12.0.tar.gz", hash = "sha256:31a40f038c22cad32287bb43932054451ff5583ff094bca6f675df2f8bc1a6e9"}, 387 | {file = "pytest_mock-3.12.0-py3-none-any.whl", hash = "sha256:0972719a7263072da3a21c7f4773069bcc7486027d7e8e1f81d98a47e701bc4f"}, 388 | ] 389 | 390 | [package.dependencies] 391 | pytest = ">=5.0" 392 | 393 | [package.extras] 394 | dev = ["pre-commit", "pytest-asyncio", "tox"] 395 | 396 | [[package]] 397 | name = "tomli" 398 | version = "2.0.1" 399 | description = "A lil' TOML parser" 400 | optional = false 401 | python-versions = ">=3.7" 402 | files = [ 403 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 404 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 405 | ] 406 | 407 | [[package]] 408 | name = "typing-extensions" 409 | version = "4.9.0" 410 | description = "Backported and Experimental Type Hints for Python 3.8+" 411 | optional = false 412 | python-versions = ">=3.8" 413 | files = [ 414 | {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, 415 | {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, 416 | ] 417 | 418 | [metadata] 419 | lock-version = "2.0" 420 | python-versions = "^3.8.1" 421 | content-hash = "f3c24af7e2b89be4d8853b51417b380ab9c381dcd3bd845073eca0574dc9cfe0" 422 | --------------------------------------------------------------------------------