├── .github
├── FUNDING.yml
├── dw.gif
├── dw_last.gif
├── dw_other.gif
├── indefinite_bar.gif
└── workflows
│ ├── release.yml
│ └── test.yml
├── downloader_cli
├── __version__.py
├── __init__.py
├── color.py
├── main.py
└── download.py
├── .gitignore
├── LICENSE
├── setup.py
├── tests
├── test_color.py
└── test_download.py
└── README.md
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: deepjyoti30
2 | custom: ['https://www.paypal.me/deepjyoti30']
--------------------------------------------------------------------------------
/.github/dw.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/deepjyoti30/downloader-cli/HEAD/.github/dw.gif
--------------------------------------------------------------------------------
/.github/dw_last.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/deepjyoti30/downloader-cli/HEAD/.github/dw_last.gif
--------------------------------------------------------------------------------
/.github/dw_other.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/deepjyoti30/downloader-cli/HEAD/.github/dw_other.gif
--------------------------------------------------------------------------------
/downloader_cli/__version__.py:
--------------------------------------------------------------------------------
1 | """Contiain the version of the package"""
2 | __version__ = "0.3.4"
3 |
--------------------------------------------------------------------------------
/.github/indefinite_bar.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/deepjyoti30/downloader-cli/HEAD/.github/indefinite_bar.gif
--------------------------------------------------------------------------------
/downloader_cli/__init__.py:
--------------------------------------------------------------------------------
1 | name = "downloader_cli"
2 |
3 | __all__ = [
4 | "download",
5 | "color",
6 | "__version__"
7 | ]
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | build/*
2 | dist/*
3 | downloader_cli.egg-info/*
4 | downloader_cli/__pycache__/*
5 | tests/__pycache__/*
6 | test.py
7 | __pycache__
8 |
9 | .vscode
10 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: PyPI Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - "*"
7 |
8 | jobs:
9 | release:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - name: Checkout Repo
14 | uses: actions/checkout@v2
15 | - name: Setup Python
16 | uses: actions/setup-python@v2
17 | - name: Install dependencies
18 | run: |
19 | python -m pip install --upgrade pip
20 | pip install setuptools wheel twine
21 | - name: Build the dist files
22 | run: python setup.py sdist bdist_wheel
23 | - name: Publish
24 | env:
25 | TWINE_USERNAME: "__token__"
26 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
27 | run: twine upload dist/*
28 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Run tests
2 |
3 | on:
4 | push:
5 | branches:
6 | - "master"
7 | pull_request:
8 | branches:
9 | - "master"
10 | workflow_dispatch:
11 |
12 | jobs:
13 | test:
14 | runs-on: ubuntu-20.04
15 |
16 | strategy:
17 | matrix:
18 | python-version: ["3.6", "3.7", "3.8", "3.9"]
19 |
20 | steps:
21 | - name: Checkout repo
22 | uses: actions/checkout@v2
23 | - name: Set Up Python ${{ matrix.python-version }}
24 | uses: actions/setup-python@v4
25 | with:
26 | python-version: ${{ matrix.python-version }}
27 | - name: Display Python version
28 | run: python -c "import sys; print(sys.version)"
29 | - name: Install package
30 | run: python setup.py install
31 | - name: Install pytest
32 | run: |
33 | python -m pip install --upgrade pip
34 | pip install pytest
35 | - name: Run tests
36 | run: pytest tests/test*
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019-2020 deepjyoti30
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | """Setup downloader-cli"""
3 |
4 | import io
5 | from setuptools import setup
6 |
7 | with io.open("README.md", encoding='utf-8') as fh:
8 | long_description = fh.read()
9 |
10 | requirements = [
11 | 'urllib3>=1.25.6'
12 | ]
13 |
14 | exec(open("downloader_cli/__version__.py").read())
15 |
16 |
17 | setup(
18 | name="downloader_cli",
19 | version=__version__,
20 | author="Deepjyoti Barman",
21 | author_email="deep.barman30@gmail.com",
22 | description="A simple downloader written in Python with an awesome progressbar.",
23 | long_description=long_description,
24 | long_description_content_type="text/markdown",
25 | url="https://github.com/deepjyoti30/downloader-cli",
26 | packages=["downloader_cli"],
27 | classifiers=(
28 | [
29 | "Programming Language :: Python :: 3",
30 | "License :: OSI Approved :: MIT License",
31 | "Operating System :: OS Independent",
32 | ]
33 | ),
34 | entry_points={
35 | 'console_scripts': [
36 | "dw = downloader_cli.main:main"
37 | ]
38 | },
39 | install_requires=requirements,
40 | )
41 |
--------------------------------------------------------------------------------
/tests/test_color.py:
--------------------------------------------------------------------------------
1 | """
2 | Handle testing the color related functionality from the
3 | ShellColor class
4 | """
5 |
6 | import pytest
7 |
8 | from downloader_cli.color import ShellColor
9 |
10 |
11 | class TestShellColor:
12 | def test_is_valid_color(self):
13 | """
14 | Test the is_valid_color method of the ShellColor class.
15 | """
16 | shell_color = ShellColor()
17 |
18 | assert shell_color.is_valid_color("green") == True
19 | assert shell_color.is_valid_color("test") == False
20 | assert shell_color.is_valid_color("reset") == False
21 | assert shell_color.is_valid_color("\033[0;32m") == True
22 | assert shell_color.is_valid_color("\e[0;32m") == True
23 | assert shell_color.is_valid_color("\e_test") == False
24 |
25 | def test_wrap_in_color(self):
26 | """
27 | Test the wrap_in_color method of ShellColor class
28 | """
29 | shell_color = ShellColor()
30 |
31 | assert shell_color.wrap_in_color(
32 | "test", "green") == "\033[1;32mtest\033[0m"
33 | assert shell_color.wrap_in_color(
34 | "test", "\033[1;31m") == "\033[1;31mtest\033[0m"
35 | assert shell_color.wrap_in_color(
36 | "test", "green", True) == "\033[1;32mtest"
37 |
38 | with pytest.raises(ValueError):
39 | assert shell_color.wrap_in_color(
40 | "test", "test_color") == "\033[1;32mtest"
41 |
--------------------------------------------------------------------------------
/downloader_cli/color.py:
--------------------------------------------------------------------------------
1 | """
2 | Handle color related util functions
3 | """
4 |
5 | from typing import Dict
6 |
7 |
8 | class ShellColor:
9 | def __init__(self):
10 | pass
11 |
12 | def __get_color_map(self) -> Dict:
13 | return {
14 | 'reset': 0,
15 | 'black': 30,
16 | 'red': 31,
17 | 'green': 32,
18 | 'yellow': 33,
19 | 'blue': 34,
20 | 'magenta': 35,
21 | 'cyan': 36,
22 | 'white': 37
23 | }
24 |
25 | def __is_raw_color(self, color: str) -> bool:
26 | return color.startswith("\e[") or color.startswith("\033[")
27 |
28 | def __get_reset_color(self) -> str:
29 | return "\033[0m"
30 |
31 | @property
32 | def reset(self) -> str:
33 | return self.__get_reset_color()
34 |
35 | def __build_color_str(self, color_number: int) -> str:
36 | return f"\033[1;{color_number}m" if color_number != 0 else self.reset
37 |
38 | def is_valid_color(self, color: str) -> bool:
39 | """
40 | Check if the passed color is a valid color.
41 |
42 | This method will always return `true` if a raw color string is passed.
43 |
44 | `reset` will not be considered a valid color
45 | """
46 | return bool(self.__get_color_map().get(color, 0)) if not self.__is_raw_color(color) else True
47 |
48 | def wrap_in_color(self, to_wrap: str, color: str, skip_reset: bool = False) -> str:
49 | """
50 | Wrap the passed string in the provided color and accordingly
51 | set reset if `skip_reset` is not `False`
52 |
53 | If an empty string is passed for the `color` value, then the `to_wrap` string will
54 | be returned as is, without any modifications.
55 | """
56 | if color == "":
57 | return to_wrap
58 |
59 | if not self.__is_raw_color(color):
60 | color_number = self.__get_color_map().get(color, 0)
61 | if not bool(color_number):
62 | raise ValueError(
63 | 'invalid value passed for `color`. Please use `is_valid_color()` to validate the color before using.')
64 |
65 | color = self.__build_color_str(color_number)
66 |
67 | reset_to_add = self.reset
68 | if skip_reset:
69 | reset_to_add = ""
70 |
71 | return f"{color}{to_wrap}{reset_to_add}"
72 |
--------------------------------------------------------------------------------
/tests/test_download.py:
--------------------------------------------------------------------------------
1 | """Tests various methods of the Download
2 | class.
3 |
4 | All the methods that start with test are used
5 | to test a certain function. The test method
6 | will have the name of the method being tested
7 | seperated by an underscore.
8 |
9 | If the method to be tested is extract_content,
10 | the test method name will be test_extract_content
11 | """
12 |
13 | from hashlib import md5
14 | from os import remove
15 |
16 | from downloader_cli.download import Download
17 |
18 |
19 | TEST_URL = "http://212.183.159.230/5MB.zip"
20 |
21 |
22 | def test__extract_border_icon():
23 | """Test the _extract_border_icon method"""
24 | download = Download(TEST_URL)
25 |
26 | icon_one = download._extract_border_icon("#")
27 | icon_two = download._extract_border_icon("[]")
28 | icon_none = download._extract_border_icon("")
29 | icon_more = download._extract_border_icon("sdafasdfasdf")
30 |
31 | assert icon_one == ('#', '#'), "Should be ('#', '#')"
32 | assert icon_two == ('[', ']'), "Should be ('[', '])"
33 | assert icon_none == ('|', '|'), "Should be ('|', '|')"
34 | assert icon_more == ('|', '|'), "Should be ('|', '|')"
35 |
36 |
37 | def test__build_headers():
38 | """Test the _build_headers method"""
39 | download = Download(TEST_URL)
40 |
41 | download._build_headers(1024)
42 | header_built = download.headers
43 |
44 | assert header_built == {"Range": "bytes={}-".format(1024)}, \
45 | "Should be 1024"
46 |
47 |
48 | def test__preprocess_conn():
49 | """Test the _preprocess_conn method"""
50 | download = Download(TEST_URL)
51 | download._preprocess_conn()
52 |
53 | assert download.f_size == 5242880, "Should be 5242880"
54 |
55 |
56 | def test__format_size():
57 | """
58 | Test the function that formats the size
59 | """
60 | download = Download(TEST_URL)
61 |
62 | size, unit = download._format_size(255678999)
63 |
64 | # Size should be 243.83449459075928
65 | # and unit should be `MB`
66 | size = int(size)
67 |
68 | assert size == 243, "Should be 243"
69 | assert unit == "MB", "Should be MB"
70 |
71 |
72 | def test__format_time():
73 | """
74 | Test the format time function that formats the
75 | passed time into a readable value
76 | """
77 | download = Download(TEST_URL)
78 |
79 | time, unit = download._format_time(2134991)
80 |
81 | # Time should be 9 days
82 | assert int(time) == 9, "Should be 9"
83 | assert unit == "d", "Should be d"
84 |
85 | time, unit = download._format_time(245)
86 |
87 | # Time should be 4 minutes
88 | assert int(time) == 4, "Should be 4"
89 | assert unit == "m", "Should be m"
90 |
91 |
92 | def test_file_integrity():
93 | """
94 | Test the integrity of the downloaded file.
95 |
96 | We will test the 5MB.zip file which has a hash
97 | of `eb08885e3082037a12a42308c521fa3c`.
98 | """
99 | HASH = "eb08885e3082037a12a42308c521fa3c"
100 |
101 | download = Download(TEST_URL)
102 | download.download()
103 |
104 | # Once download is done, check the integrity
105 | _hash = md5(open("5MB.zip", "rb").read()).hexdigest()
106 |
107 | assert _hash == HASH, "Integrity check failed for 5MB.zip"
108 |
109 | # Remove the file now
110 | remove(download.basename)
111 |
--------------------------------------------------------------------------------
/downloader_cli/main.py:
--------------------------------------------------------------------------------
1 | import argparse
2 | import sys
3 |
4 | from downloader_cli.__version__ import __version__
5 | from downloader_cli.download import Download
6 |
7 |
8 | def arguments():
9 | """Parse the arguments."""
10 | parser = argparse.ArgumentParser()
11 |
12 | parser.add_argument('URL', help="URL of the file",
13 | type=str, metavar="SOURCE")
14 | parser.add_argument('des', help="target filepath (existing directories \
15 | will be treated as the target location)", default=None, nargs="?",
16 | metavar='TARGET')
17 | force = parser.add_mutually_exclusive_group()
18 | force.add_argument('-f', '-o', '--force', help="overwrite if the file already exists",
19 | action="store_true")
20 | force.add_argument('-c', '--resume', help='resume failed or cancelled \
21 | download (partial sanity check)', action="store_true")
22 | parser.add_argument('-e', '--echo', help="print the filepath to stdout after \
23 | downloading (other output will be redirected \
24 | to stderr)", action="store_true")
25 | parser.add_argument(
26 | '-q', '--quiet', help="suppress filesize and progress info", action="store_true")
27 | parser.add_argument(
28 | '-b', '--batch', help="Download files in batch. If this flag is passed \
29 | the passed source will be considered as a file with download links \
30 | seperated by a newline. This flag will be ignored if source is a valid \
31 | URL.", default=False, action="store_true"
32 | )
33 | parser.add_argument('-v', '--version', action='version',
34 | version=__version__,
35 | help='show the program version number and exit')
36 |
37 | ui_group = parser.add_argument_group("UI Group")
38 | ui_group.add_argument(
39 | "--done", help="Icon indicating the percentage done of the downloader", type=str, default=None
40 | )
41 | ui_group.add_argument(
42 | "--left", help="Icon indicating the percentage remaining to download", type=str, default=None
43 | )
44 | ui_group.add_argument(
45 | "--current", help="Icon indicating the current percentage in the progress bar", type=str, default=None
46 | )
47 | ui_group.add_argument(
48 | "--color-done", help="Color for the done percentage icon", type=str, default=""
49 | )
50 | ui_group.add_argument(
51 | "--color-left", help="Color for the remaining percentage icon", type=str, default=""
52 | )
53 | ui_group.add_argument(
54 | "--color-current", help="Color for the current indicator icon in the progress bar", type=str, default=""
55 | )
56 | ui_group.add_argument(
57 | "--icon-border", help="Icon for the border of the progress bar", type=str, default="|"
58 | )
59 |
60 | args = parser.parse_args()
61 | return args
62 |
63 |
64 | def main():
65 | args = arguments()
66 | _out = Download(URL=args.URL, des=args.des, overwrite=args.force,
67 | continue_download=args.resume, echo=args.echo,
68 | quiet=args.quiet, batch=args.batch, icon_done=args.done,
69 | icon_left=args.left, color_done=args.color_done,
70 | color_left=args.color_left, icon_border=args.icon_border,
71 | icon_current=args.current, color_current=args.color_current)
72 | success = _out.download()
73 | if success and args.echo:
74 | print(_out.des)
75 | sys.stderr.close
76 |
77 |
78 | if __name__ == "__main__":
79 | main()
80 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
downloader-cli
2 | A simple downloader written in Python with an awesome progressbar.
3 |
4 |
5 |

6 |
7 |
8 |
9 |
10 |
11 |
Installation |
Requirements |
Usage |
Use It |
Other examples
12 |
13 |
14 | [](https://www.python.org/)
15 | [](LICENSE) [](https://img.shields.io/badge/dynamic/json?style=for-the-badge&maxAge=86400&label=downloads&query=%24.total_downloads&url=https%3A%2F%2Fapi.pepy.tech%2Fapi%2Fprojects%2Fdownloader-cli)   [](http://makeapullrequest.com)
16 |
17 |
18 |
19 | # Installation
20 |
21 | - [PyPI](#pypi)
22 | - [Arch](#arch)
23 | - [Gentoo](#gentoo)
24 | - [Conda-Forge](#conda-forge)
25 | - [Manual](#manual)
26 |
27 | > NOTE: The following packages (except installing manually) will get you the latest release. If you want to try out the latest development stuff, install manually.
28 |
29 | ### PyPI
30 |
31 | The package is available in PyPI [here](https://pypi.org/project/downloader-cli/)
32 |
33 | Install it using
34 |
35 | ```sh
36 | pip install downloader-cli
37 | ```
38 |
39 | ### Arch
40 |
41 | The package is available in the AUR [here](https://aur.archlinux.org/packages/downloader-cli/)
42 |
43 | Install it using `yay`
44 |
45 | ```console
46 | yay -S downloader-cli
47 | ```
48 |
49 | ### Gentoo
50 |
51 | The package is also available in src_prepare Gentoo overlay [here](https://gitlab.com/src_prepare/src_prepare-overlay/-/tree/master/net-misc/downloader-cli/)
52 |
53 | First set up src_prepare-overlay
54 |
55 | ```sh
56 | sudo emerge -anv --noreplace app-eselect/eselect-repository
57 | sudo eselect repository enable src_prepare-overlay
58 | sudo emaint sync -r src_prepare-overlay
59 | ```
60 |
61 | Install it using
62 |
63 | ```sh
64 | sudo emerge -anv --autounmask net-misc/downloader-cli
65 | ```
66 |
67 | ### Conda-Forge
68 |
69 | Installing `downloader-cli` from the `conda-forge` channel can be achieved by adding `conda-forge` to your channels with:
70 |
71 | ```
72 | conda config --add channels conda-forge
73 | conda config --set channel_priority strict
74 | ```
75 |
76 | Once the `conda-forge` channel has been enabled, `downloader-cli` can be installed with:
77 |
78 | ```
79 | conda install downloader-cli
80 | ```
81 |
82 | It is possible to list all of the versions of `downloader-cli` available on your platform with:
83 |
84 | ```
85 | conda search downloader-cli --channel conda-forge
86 | ```
87 |
88 | ### Manual
89 |
90 | If you want to manuall install, clone the repo and run the following command
91 |
92 | ```sh
93 | sudo python setup.py install
94 | ```
95 |
96 | # Requirements
97 |
98 | **downloader-cli** requires just one external module.
99 |
100 | - [urllib3](https://pypi.org/project/urllib3/)
101 |
102 | # Usage
103 |
104 | The script also allows some other values from the commandline.
105 |
106 | ```console
107 | usage: dw [-h] [-f | -c] [-e] [-q] [-b] [-v] [--done DONE] [--left LEFT]
108 | [--current CURRENT] [--color-done COLOR_DONE]
109 | [--color-left COLOR_LEFT] [--color-current COLOR_CURRENT]
110 | [--icon-border ICON_BORDER]
111 | SOURCE [TARGET]
112 |
113 | positional arguments:
114 | SOURCE URL of the file
115 | TARGET target filepath (existing directories will be treated
116 | as the target location)
117 |
118 | optional arguments:
119 | -h, --help show this help message and exit
120 | -f, -o, --force overwrite if the file already exists
121 | -c, --resume resume failed or cancelled download (partial sanity
122 | check)
123 | -e, --echo print the filepath to stdout after downloading (other
124 | output will be redirected to stderr)
125 | -q, --quiet suppress filesize and progress info
126 | -b, --batch Download files in batch. If this flag is passed the
127 | passed source will be considered as a file with
128 | download links seperated by a newline. This flag will
129 | be ignored if source is a valid URL.
130 | -v, --version show the program version number and exit
131 |
132 | UI Group:
133 | --done DONE Icon indicating the percentage done of the downloader
134 | --left LEFT Icon indicating the percentage remaining to download
135 | --current CURRENT Icon indicating the current percentage in the progress
136 | bar
137 | --color-done COLOR_DONE
138 | Color for the done percentage icon
139 | --color-left COLOR_LEFT
140 | Color for the remaining percentage icon
141 | --color-current COLOR_CURRENT
142 | Color for the current indicator icon in the progress
143 | bar
144 | --icon-border ICON_BORDER
145 | Icon for the border of the progress bar
146 |
147 | ```
148 |
149 | # Use It
150 |
151 | **Want to use it in your project?**
152 |
153 | Import the `Download` class using the following.
154 |
155 | ```python
156 | from downloader_cli.download import Download
157 | Download(url).download()
158 | ```
159 |
160 | Above is the simplest way to use it in your app. The other arguments are optional.
161 |
162 | ## Arguments
163 |
164 | The module takes various arguments. Only **one** is required though.
165 |
166 | | Name | required | default |
167 | |------|----------|---------|
168 | | URL/file | Yes | |
169 | | des | No | None (Current directory is selected and the name is extracted from the URL)|
170 | | overwrite| No | False |
171 | | continue_download| No | False |
172 | | echo | No | False |
173 | | quiet | No | False |
174 | | batch | No | False |
175 | | icon_done| No | ▓ |
176 | | icon_left| No | ░ |
177 | | icon_current | No | ▓ |
178 | | icon_border| No | \| (If a single char is passed, it will be used for both the right and left border. If a string of 2 chars are passed, 1st char will be used as left border and the 2nd as the right border) |
179 | | color_done | No | blue |
180 | | color_left | No | blue |
181 | | color_current | No | blue |
182 |
183 | > **NOTE** For details regarding the arguments, check [Usage](#usage)
184 |
185 | > **NOTE** In case the file size is not available, the bar is shown as indefinite, in which case the icon_left
186 | > by default space(`" "`).
187 |
188 | # Other examples
189 |
190 | ### In case you want to experiment with the progress bar's icons, here's some examples.
191 |
192 | - This is when I passed `icon_done` as `#` and `icon_left` as space.
193 |
194 |
195 |

196 |
197 |
198 | - In case a file's size is not available from the server, the progressbar is indefinite.
199 |
200 |
201 |

202 |
203 |
--------------------------------------------------------------------------------
/downloader_cli/download.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import urllib.request
4 | import sys
5 | import time
6 | from os import path, get_terminal_size, name
7 | import itertools
8 | from re import match
9 |
10 | from downloader_cli.color import ShellColor
11 |
12 | # import traceback ## Required to debug at times.
13 |
14 |
15 | class Download:
16 |
17 | def __init__(
18 | self,
19 | URL,
20 | des=None,
21 | overwrite=False,
22 | continue_download=False,
23 | echo=False,
24 | quiet=False,
25 | batch=False,
26 | icon_done="▓",
27 | icon_left="░",
28 | icon_current="",
29 | icon_border="|",
30 | color_done="",
31 | color_left="",
32 | color_current=""
33 | ):
34 | # Initialize necessary engines
35 | self.__init_color_engine()
36 |
37 | self.URL = URL
38 | self.des = des
39 | self.passed_dir = None
40 | self.headers = {}
41 | self.f_size = 0
42 | self.__done_icon = icon_done if icon_done is not None and len(
43 | icon_done) < 2 else "▓"
44 | self.__left_icon = icon_left if icon_left is not None and len(
45 | icon_left) < 2 else "░"
46 | self.__done_color = color_done
47 | self.__left_color = color_left
48 | self.__current_color = color_current
49 | self.__current_icon = icon_current if icon_current is not None and len(
50 | icon_current) < 2 else "▓"
51 | self.border_left, self.border_right = self._extract_border_icon(
52 | icon_border)
53 | self._cycle_bar = None
54 | self.echo = echo
55 | self.quiet = quiet
56 | self.batch = batch
57 | self.overwrite = overwrite
58 | self.continue_download = continue_download
59 | self.file_exists = False
60 | self.ostream = sys.stderr if self.echo else sys.stdout
61 |
62 | # Validate the colors
63 | self.__validate_passed_color(self.__done_color, 'color_done')
64 | self.__validate_passed_color(self.__left_color, 'color_left')
65 | self.__validate_passed_color(self.__current_color, 'color_current')
66 |
67 | def __validate_passed_color(self, color, name):
68 | if color != "" and not self.color_engine.is_valid_color(color):
69 | raise ValueError(f'invalid value passed for `{name}`')
70 |
71 | def __init_color_engine(self):
72 | """
73 | Initialize the color engine class
74 | """
75 | self.__color_engine = ShellColor()
76 |
77 | @property
78 | def color_engine(self) -> ShellColor:
79 | return self.__color_engine
80 |
81 | def _extract_border_icon(self, passed_icon):
82 | """"
83 | Extract the passed border icon according to
84 | what is passed.
85 |
86 | If the string has length equal to 2, then use the
87 | first char as left border icon and the second as
88 | right.
89 |
90 | If the string has length equal to 1, use the same icon for both.
91 | """
92 | if len(passed_icon) == 1:
93 | return passed_icon, passed_icon
94 |
95 | if len(passed_icon) == 2:
96 | return passed_icon[0], passed_icon[1]
97 |
98 | return "|", "|"
99 |
100 | def _build_headers(self, rem):
101 | """Build headers according to requirement."""
102 | self.headers = {"Range": "bytes={}-".format(rem)}
103 | print("Trying to resume download at: {} bytes".format(
104 | rem), file=self.ostream)
105 |
106 | def _parse_exists(self):
107 | """This function should be called if the file already exists.
108 |
109 | In that case there are two possibilities, it's partially downloaded
110 | or it's a proper file.
111 | """
112 | if self.overwrite:
113 | return
114 | elif self.continue_download:
115 | cur_size = path.getsize(self.des)
116 | original_size = urllib.request.urlopen(self.URL).info()[
117 | 'Content-Length']
118 |
119 | if original_size is None:
120 | print("WARNING: Could not perform sanity check on partial download.",
121 | file=self.ostream)
122 | self._build_headers(cur_size)
123 | elif cur_size < int(original_size):
124 | self._build_headers(cur_size)
125 | else:
126 | print("ERROR: File exists. See 'dw --help' for solutions.",
127 | file=self.ostream)
128 | exit(-1)
129 |
130 | def _preprocess_conn(self):
131 | """Make necessary things for the connection."""
132 | self.req = urllib.request.Request(url=self.URL, headers=self.headers)
133 |
134 | try:
135 | self.conn = urllib.request.urlopen(self.req)
136 | except Exception as e:
137 | print("ERROR: {}".format(e))
138 | exit()
139 |
140 | self.f_size = self.conn.info()['Content-Length']
141 |
142 | if self.f_size is not None:
143 | self.f_size = int(self.f_size)
144 |
145 | def _get_terminal_length(self):
146 | """Return the length of the terminal."""
147 | # If quiet is passed, skip this calculation and return a default length
148 | if self.quiet:
149 | return 50
150 |
151 | cols = get_terminal_size().columns
152 | return cols if name != "nt" else cols - 1
153 |
154 | def _parse_destination(self):
155 | # Check if the des is passed
156 | if self.des is not None:
157 | if path.isdir(self.des):
158 | self.passed_dir = self.des
159 | self.des = path.join(self.des, self._get_name())
160 | else:
161 | self.des = self._get_name()
162 |
163 | # Put a check to see if file already exists.
164 | # Try to resume it if that's true
165 | if path.exists(self.des):
166 | self._parse_exists()
167 | self.file_exists = True
168 |
169 | def _is_valid_src_path(self, file_path):
170 | """Check to see if the path passed is
171 | a valid source path.
172 |
173 | A valid source path would be a file that
174 | is not a directory and actually a file
175 | present in the disk.
176 | """
177 | return not path.exists(file_path) or not path.isfile(file_path)
178 |
179 | def _parse_URL(self):
180 | """
181 | The URL can be a file as well so in that case we
182 | will download each URL from that file.
183 |
184 | In case the URL is not a file and just a simple URL,
185 | download just that one.
186 |
187 | returns: A list of urls
188 | """
189 | if match(r"^https?://*|^file://*", self.URL):
190 | return [self.URL]
191 |
192 | # Below code will only be executed if the -b
193 | # flag is passed
194 | if not self.batch:
195 | print("{}: not a valid URL. Pass -b if it is a file "
196 | "containing various URL's and you want bulk download."
197 | .format(self.URL))
198 | exit(0)
199 |
200 | rel_path = path.expanduser(self.URL)
201 |
202 | # Put a check to see if the file is present
203 | if self._is_valid_src_path(rel_path):
204 | print("{}: not a valid name or is a directory".format(rel_path))
205 | exit(-1)
206 |
207 | # If it's not an URL, read the contents.
208 | # Since the URL is not an actual URL, we're assuming
209 | # it is a file that contains URL's seperated by new
210 | # lines.
211 | with open(rel_path, "r") as RSTREAM:
212 | return RSTREAM.read().split("\n")
213 |
214 | def _get_name(self):
215 | """Try to get the name of the file from the URL."""
216 |
217 | name = 'temp'
218 | temp_url = self.URL
219 |
220 | split_url = temp_url.split('/')[-1]
221 |
222 | if split_url:
223 | # Remove query params if any
224 | name = split_url.split("?")[0]
225 |
226 | return name
227 |
228 | def _format_size(self, size):
229 | """Format the passed size.
230 |
231 | If its more than an 1 Mb then return the size in Mb's
232 | else return it in Kb's along with the unit.
233 | """
234 | map_unit = {0: 'bytes', 1: "KB", 2: "MB", 3: "GB"}
235 | formatted_size = size
236 |
237 | no_iters = 0
238 | while formatted_size > 1024:
239 | no_iters += 1
240 | formatted_size /= 1024
241 |
242 | return (formatted_size, map_unit[no_iters])
243 |
244 | def _format_time(self, time_left):
245 | """Format the passed time depending."""
246 | unit_map = {0: 's', 1: 'm', 2: 'h', 3: 'd'}
247 |
248 | no_iter = 0
249 | while time_left > 60:
250 | no_iter += 1
251 | time_left /= 60
252 |
253 | return time_left, unit_map[no_iter]
254 |
255 | def _format_speed(self, speed):
256 | """Format the speed."""
257 | unit = {0: 'Kb/s', 1: 'Mb/s', 2: 'Gb/s'}
258 |
259 | inc_with_iter = 0
260 | while speed > 1000:
261 | speed = speed / 1000
262 | inc_with_iter += 1
263 |
264 | return speed, unit[inc_with_iter]
265 |
266 | def _get_speed_n_time(self, file_size_dl, beg_time, cur_time):
267 | """Return the speed and time depending on the passed arguments."""
268 |
269 | # Sometimes the beg_time and the cur_time are same, so we need
270 | # to make sure that doesn't raise a ZeroDivisionError in the
271 | # following line.
272 | if cur_time == beg_time:
273 | return "Inf", "", 0, ""
274 |
275 | # Calculate speed
276 | speed = (file_size_dl / 1024) / (cur_time - beg_time)
277 |
278 | # Calculate time left
279 | if self.f_size is not None:
280 | time_left = ((self.f_size - file_size_dl) / 1024) / speed
281 | time_left, time_unit = self._format_time(time_left)
282 | else:
283 | time_left, time_unit = 0, ""
284 |
285 | # Format the speed
286 | speed, s_unit = self._format_speed(speed)
287 |
288 | return round(speed), s_unit, round(time_left), time_unit
289 |
290 | def _get_pos(self, reduce_with_each_iter):
291 | if self._cycle_bar is None:
292 | self._cycle_bar = itertools.cycle(
293 | range(0, int(reduce_with_each_iter)))
294 |
295 | return (next(self._cycle_bar) + 1)
296 |
297 | @property
298 | def done_icon(self) -> str:
299 | """
300 | Return the done icon.
301 |
302 | This will wrap the icon in any colors if they are provided
303 | """
304 | return self.color_engine.wrap_in_color(self.__done_icon, self.__done_color)
305 |
306 | @property
307 | def current_icon(self) -> str:
308 | """
309 | Return the current icon.
310 |
311 | This will wrap the icon in any colors if they are provided.
312 | """
313 | return self.color_engine.wrap_in_color(self.__current_icon, self.__current_color)
314 |
315 | @property
316 | def left_icon(self) -> str:
317 | """
318 | Return the left icon.
319 |
320 | This will wrap the icon in any colors if they are provided
321 | """
322 | return self.color_engine.wrap_in_color(self.__left_icon, self.__left_color)
323 |
324 | def get_done_with_current(self, done_percent: int, remaining_percent: int = None) -> str:
325 | if done_percent == 0:
326 | return ""
327 |
328 | if remaining_percent == 0:
329 | # Download is completed so no need to show the current icon
330 | # anymore
331 | return self.done_icon * done_percent
332 |
333 | return self.done_icon * (done_percent - len(self.__current_icon)) + self.current_icon
334 |
335 | def _get_bar(self, status, length, percent=None):
336 | """Calculate the progressbar depending on the length of terminal."""
337 |
338 | map_bar = {
339 | 40: r"|%-40s|",
340 | 20: r"|%-20s|",
341 | 10: r"|%-10s|",
342 | 5: r"|%-5s|",
343 | 2: r"|%-2s|"
344 | }
345 | # Till now characters present is the length of status.
346 | # length is the length of terminal.
347 | # We need to decide how long our bar will be.
348 | cur_len = len(status) + 2 # 2 for bar
349 |
350 | if percent is not None:
351 | cur_len += 5 # 5 for percent
352 |
353 | reduce_with_each_iter = 40
354 | while reduce_with_each_iter > 0:
355 | if cur_len + reduce_with_each_iter > length:
356 | reduce_with_each_iter = int(reduce_with_each_iter / 2)
357 | else:
358 | break
359 |
360 | # Add space.
361 | space = length - (len(status) + 2 + reduce_with_each_iter + 5)
362 | status += r"%s" % (" " * space)
363 |
364 | if reduce_with_each_iter > 0:
365 | # Make BOLD
366 | status += "\033[1m"
367 | # Add color.
368 | status += "\033[1;34m"
369 | if percent is not None:
370 | done = int(percent / (100 / reduce_with_each_iter))
371 | status += r"%s%s%s%s" % (
372 | self.border_left,
373 | self.get_done_with_current(
374 | done, reduce_with_each_iter - done),
375 | self.left_icon * (reduce_with_each_iter - done),
376 | self.border_right)
377 | else:
378 | current_pos = self._get_pos(reduce_with_each_iter)
379 | bar = " " * (current_pos - 1) if current_pos > 1 else ""
380 | bar += self.current_icon * 1
381 | bar += " " * int((reduce_with_each_iter) - current_pos)
382 | status += r"%s%s%s" % (self.border_left,
383 | bar, self.border_right)
384 |
385 | status += "\033[0m"
386 | return status
387 |
388 | def _download(self):
389 | try:
390 | self._parse_destination()
391 |
392 | # Download files with a progressbar showing the percentage
393 | self._preprocess_conn()
394 | WSTREAM = open(self.des, 'ab')
395 |
396 | if self.f_size is not None and self.quiet is False:
397 | formatted_file_size, dw_unit = self._format_size(self.f_size)
398 | print("Size: {} {}".format(
399 | round(formatted_file_size), dw_unit), file=self.ostream)
400 |
401 | _owrite = ("Overwriting: {}" if (self.file_exists and
402 | self.overwrite) else "Saving as: {}").format(self.des)
403 | if self.quiet:
404 | self.ostream.write(_owrite)
405 | self.ostream.write("...")
406 | else:
407 | print(_owrite, file=self.ostream)
408 | self.ostream.flush()
409 |
410 | file_size_dl = 0
411 | block_sz = 8192
412 |
413 | beg_time = time.time()
414 | while True:
415 | buffer = self.conn.read(block_sz)
416 | if not buffer:
417 | break
418 |
419 | file_size_dl += len(buffer)
420 | WSTREAM.write(buffer)
421 |
422 | # Initialize all the variables that cannot be calculated
423 | # to ''
424 | speed = ''
425 | time_left = ''
426 | time_unit = ''
427 | percent = ''
428 |
429 | speed, s_unit, time_left, time_unit = self._get_speed_n_time(
430 | file_size_dl,
431 | beg_time,
432 | cur_time=time.time()
433 | )
434 |
435 | if self.f_size is not None:
436 | percent = file_size_dl * 100 / self.f_size
437 |
438 | # Get basename
439 | self.basename = path.basename(self.des)
440 |
441 | # Calculate amount of space req in between
442 | length = self._get_terminal_length()
443 |
444 | f_size_disp, dw_unit = self._format_size(file_size_dl)
445 |
446 | status = r"%-7s" % ("%s %s" % (round(f_size_disp), dw_unit))
447 | status += r"| %-3s %s " % ("%s" % (speed), s_unit)
448 |
449 | if self.f_size is not None:
450 | status += r"|| ETA: %-4s " % ("%s %s" %
451 | (time_left, time_unit))
452 | status = self._get_bar(status, length, percent)
453 | status += r" %-4s" % ("{}%".format(round(percent)))
454 | else:
455 | status = self._get_bar(status, length)
456 |
457 | if not self.quiet:
458 | self.ostream.write('\r')
459 | self.ostream.write(status)
460 | self.ostream.flush()
461 |
462 | WSTREAM.close()
463 | if self.quiet:
464 | self.ostream.write("...success\n")
465 | self.ostream.flush()
466 | return True
467 | except KeyboardInterrupt:
468 | self.ostream.flush()
469 | print("Keyboard Interrupt passed. Exiting peacefully.")
470 | exit(0)
471 | except Exception as e:
472 | print("ERROR: {}".format(e))
473 | return False
474 |
475 | def download(self):
476 | """
477 | download will iterate through a list of possible url's
478 | and destinations and keep passing to the actual download
479 | method _download().
480 | """
481 | urls = self._parse_URL()
482 | for url in urls:
483 | self.URL = url
484 | self._download()
485 | self.des = self.passed_dir
486 |
--------------------------------------------------------------------------------