├── .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 | [![forthebadge made-with-python](http://ForTheBadge.com/images/badges/made-with-python.svg)](https://www.python.org/)

15 | [![License](https://img.shields.io/badge/License-MIT-pink.svg?style=for-the-badge)](LICENSE) [![Downloads](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)](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) ![PyPI](https://img.shields.io/pypi/v/downloader-cli?style=for-the-badge) ![AUR](https://img.shields.io/aur/version/downloader-cli?style=for-the-badge) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-purple.svg?style=for-the-badge)](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 | --------------------------------------------------------------------------------