713 lines
20 KiB
Python
713 lines
20 KiB
Python
# Copyright 2014 Baidu, Inc.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
|
|
# except in compliance with the License. You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software distributed under the
|
|
# License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
|
|
# either express or implied. See the License for the specific language governing permissions
|
|
# and limitations under the License.
|
|
|
|
"""
|
|
This module provide some tools for bce client.
|
|
"""
|
|
# str() generator unicode,bytes() for ASCII
|
|
from __future__ import print_function
|
|
from __future__ import absolute_import
|
|
from builtins import str, bytes
|
|
from future.utils import iteritems, iterkeys, itervalues
|
|
from baidubce import compat
|
|
|
|
import os
|
|
import re
|
|
import datetime
|
|
import hashlib
|
|
import base64
|
|
import string
|
|
import sys
|
|
try:
|
|
from urllib.parse import urlparse
|
|
except ImportError:
|
|
from urlparse import urlparse
|
|
from Crypto.Cipher import AES
|
|
import baidubce
|
|
from baidubce.http import http_headers
|
|
|
|
import codecs
|
|
|
|
DEFAULT_CNAME_LIKE_LIST = [b".cdn.bcebos.com"]
|
|
DEFAULT_BOS_DOMAIN_SUFFIX = b'bcebos.com'
|
|
HTTP_PROTOCOL_HEAD = b'http'
|
|
|
|
def get_md5_from_fp(fp, offset=0, length=-1, buf_size=8192):
|
|
"""
|
|
Get MD5 from file by fp.
|
|
|
|
:type fp: FileIO
|
|
:param fp: None
|
|
|
|
:type offset: long
|
|
:param offset: None
|
|
|
|
:type length: long
|
|
:param length: None
|
|
=======================
|
|
:return:
|
|
**file_size, MD(encode by base64)**
|
|
"""
|
|
|
|
origin_offset = fp.tell()
|
|
if offset:
|
|
fp.seek(offset)
|
|
md5 = hashlib.md5()
|
|
while True:
|
|
bytes_to_read = buf_size
|
|
if bytes_to_read > length > 0:
|
|
bytes_to_read = length
|
|
buf = fp.read(bytes_to_read)
|
|
if not buf:
|
|
break
|
|
md5.update(buf)
|
|
if length > 0:
|
|
length -= len(buf)
|
|
if length == 0:
|
|
break
|
|
fp.seek(origin_offset)
|
|
return base64.standard_b64encode(md5.digest())
|
|
|
|
|
|
def get_canonical_time(timestamp=0):
|
|
"""
|
|
Get cannonical time.
|
|
|
|
:type timestamp: int
|
|
:param timestamp: None
|
|
=======================
|
|
:return:
|
|
**string of canonical_time**
|
|
"""
|
|
if timestamp == 0:
|
|
utctime = datetime.datetime.utcnow()
|
|
else:
|
|
utctime = datetime.datetime.utcfromtimestamp(timestamp)
|
|
return b"%04d-%02d-%02dT%02d:%02d:%02dZ" % (
|
|
utctime.year, utctime.month, utctime.day,
|
|
utctime.hour, utctime.minute, utctime.second)
|
|
|
|
|
|
def is_ip(s):
|
|
"""
|
|
Check a string whether is a legal ip address.
|
|
|
|
:type s: string
|
|
:param s: None
|
|
=======================
|
|
:return:
|
|
**Boolean**
|
|
"""
|
|
try:
|
|
tmp_list = s.split(b':')
|
|
s = tmp_list[0]
|
|
if s == b'localhost':
|
|
return True
|
|
tmp_list = s.split(b'.')
|
|
if len(tmp_list) != 4:
|
|
return False
|
|
else:
|
|
for i in tmp_list:
|
|
if int(i) < 0 or int(i) > 255:
|
|
return False
|
|
except:
|
|
return False
|
|
return True
|
|
|
|
|
|
def convert_to_standard_string(input_string):
|
|
"""
|
|
Encode a string to utf-8.
|
|
|
|
:type input_string: string
|
|
:param input_string: None
|
|
=======================
|
|
:return:
|
|
**string**
|
|
"""
|
|
#if isinstance(input_string, str):
|
|
# return input_string.encode(baidubce.DEFAULT_ENCODING)
|
|
#elif isinstance(input_string, bytes):
|
|
# return input_string
|
|
#else:
|
|
# return str(input_string).encode("utf-8")
|
|
return compat.convert_to_bytes(input_string)
|
|
|
|
def convert_header2map(header_list):
|
|
"""
|
|
Transfer a header list to dict
|
|
|
|
:type s: list
|
|
:param s: None
|
|
=======================
|
|
:return:
|
|
**dict**
|
|
"""
|
|
header_map = {}
|
|
for a, b in header_list:
|
|
if isinstance(a, bytes):
|
|
a = a.strip(b'\"')
|
|
if isinstance(b, bytes):
|
|
b = b.strip(b'\"')
|
|
header_map[a] = b
|
|
return header_map
|
|
|
|
|
|
def safe_get_element(name, container):
|
|
"""
|
|
Get element from dict which the lower of key and name are equal.
|
|
|
|
:type name: string
|
|
:param name: None
|
|
|
|
:type container: dict
|
|
:param container: None
|
|
=======================
|
|
:return:
|
|
**Value**
|
|
"""
|
|
for k, v in iteritems(container):
|
|
if k.strip().lower() == name.strip().lower():
|
|
return v
|
|
return ""
|
|
|
|
|
|
def check_redirect(res):
|
|
"""
|
|
Check whether the response is redirect.
|
|
|
|
:type res: HttpResponse
|
|
:param res: None
|
|
|
|
:return:
|
|
**Boolean**
|
|
"""
|
|
is_redirect = False
|
|
try:
|
|
if res.status == 301 or res.status == 302:
|
|
is_redirect = True
|
|
except:
|
|
pass
|
|
return is_redirect
|
|
|
|
|
|
def _get_normalized_char_list():
|
|
""""
|
|
:return:
|
|
**ASCII string**
|
|
"""
|
|
ret = ['%%%02X' % i for i in range(256)]
|
|
for ch in string.ascii_letters + string.digits + '.~-_':
|
|
ret[ord(ch)] = ch
|
|
if isinstance(ret[0], str):
|
|
ret = [s.encode("utf-8") for s in ret]
|
|
return ret
|
|
_NORMALIZED_CHAR_LIST = _get_normalized_char_list()
|
|
|
|
|
|
def normalize_string(in_str, encoding_slash=True):
|
|
"""
|
|
Encode in_str.
|
|
When encoding_slash is True, don't encode skip_chars, vice versa.
|
|
|
|
:type in_str: string
|
|
:param in_str: None
|
|
|
|
:type encoding_slash: Bool
|
|
:param encoding_slash: None
|
|
===============================
|
|
:return:
|
|
**ASCII string**
|
|
"""
|
|
tmp = []
|
|
for ch in convert_to_standard_string(in_str):
|
|
# on python3, ch is int type
|
|
sep = ''
|
|
index = -1
|
|
if isinstance(ch, int):
|
|
# on py3
|
|
sep = chr(ch).encode("utf-8")
|
|
index = ch
|
|
else:
|
|
sep = ch
|
|
index = ord(ch)
|
|
if sep == b'/' and not encoding_slash:
|
|
tmp.append(b'/')
|
|
else:
|
|
tmp.append(_NORMALIZED_CHAR_LIST[index])
|
|
return (b'').join(tmp)
|
|
|
|
|
|
def append_uri(base_uri, *path_components):
|
|
"""
|
|
Append path_components to the end of base_uri in order, and ignore all empty strings and None
|
|
|
|
:param base_uri: None
|
|
:type base_uri: string
|
|
|
|
:param path_components: None
|
|
|
|
:return: the final url
|
|
:rtype: str
|
|
"""
|
|
tmp = [base_uri]
|
|
for path in path_components:
|
|
if path:
|
|
tmp.append(normalize_string(path, False))
|
|
if len(tmp) > 1:
|
|
tmp[0] = tmp[0].rstrip(b'/')
|
|
tmp[-1] = tmp[-1].lstrip(b'/')
|
|
for i in range(1, len(tmp) - 1):
|
|
tmp[i] = tmp[i].strip(b'/')
|
|
return (b'/').join(tmp)
|
|
|
|
|
|
def check_bucket_valid(bucket):
|
|
"""
|
|
Check bucket name whether is legal.
|
|
|
|
:type bucket: string
|
|
:param bucket: None
|
|
=======================
|
|
:return:
|
|
**Boolean**
|
|
"""
|
|
alphabet = "abcdefghijklmnopqrstuvwxyz0123456789-"
|
|
if len(bucket) < 3 or len(bucket) > 63:
|
|
return False
|
|
if bucket[-1] == "-" or bucket[-1] == "_":
|
|
return False
|
|
if not (('a' <= bucket[0] <= 'z') or ('0' <= bucket[0] <= '9')):
|
|
return False
|
|
for i in bucket:
|
|
if not i in alphabet:
|
|
return False
|
|
return True
|
|
|
|
|
|
def guess_content_type_by_file_name(file_name):
|
|
"""
|
|
Get file type by filename.
|
|
|
|
:type file_name: string
|
|
:param file_name: None
|
|
=======================
|
|
:return:
|
|
**Type Value**
|
|
"""
|
|
mime_map = dict()
|
|
mime_map["js"] = "application/javascript"
|
|
mime_map["xlsx"] = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
mime_map["xltx"] = "application/vnd.openxmlformats-officedocument.spreadsheetml.template"
|
|
mime_map["potx"] = "application/vnd.openxmlformats-officedocument.presentationml.template"
|
|
mime_map["ppsx"] = "application/vnd.openxmlformats-officedocument.presentationml.slideshow"
|
|
mime_map["pptx"] = "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
|
mime_map["sldx"] = "application/vnd.openxmlformats-officedocument.presentationml.slide"
|
|
mime_map["docx"] = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
mime_map["dotx"] = "application/vnd.openxmlformats-officedocument.wordprocessingml.template"
|
|
mime_map["xlam"] = "application/vnd.ms-excel.addin.macroEnabled.12"
|
|
mime_map["xlsb"] = "application/vnd.ms-excel.sheet.binary.macroEnabled.12"
|
|
try:
|
|
file_name = compat.convert_to_string(file_name)
|
|
name = os.path.basename(file_name.lower())
|
|
suffix = name.split('.')[-1]
|
|
if suffix in iterkeys(mime_map):
|
|
mime_type = mime_map[suffix]
|
|
else:
|
|
import mimetypes
|
|
|
|
mimetypes.init()
|
|
mime_type = mimetypes.types_map.get("." + suffix, 'application/octet-stream')
|
|
except:
|
|
mime_type = 'application/octet-stream'
|
|
if not mime_type:
|
|
mime_type = 'application/octet-stream'
|
|
|
|
return compat.convert_to_bytes(mime_type)
|
|
|
|
|
|
_first_cap_regex = re.compile('(.)([A-Z][a-z]+)')
|
|
_number_cap_regex = re.compile('([a-z])([0-9]{2,})')
|
|
_end_cap_regex = re.compile('([a-z0-9])([A-Z])')
|
|
|
|
|
|
def pythonize_name(name):
|
|
"""Convert camel case to a "pythonic" name.
|
|
Examples::
|
|
pythonize_name('CamelCase') -> 'camel_case'
|
|
pythonize_name('already_pythonized') -> 'already_pythonized'
|
|
pythonize_name('HTTPRequest') -> 'http_request'
|
|
pythonize_name('HTTPStatus200Ok') -> 'http_status_200_ok'
|
|
pythonize_name('UPPER') -> 'upper'
|
|
pythonize_name('ContentMd5')->'content_md5'
|
|
pythonize_name('') -> ''
|
|
"""
|
|
if name == "eTag":
|
|
return "etag"
|
|
s1 = _first_cap_regex.sub(r'\1_\2', name)
|
|
s2 = _number_cap_regex.sub(r'\1_\2', s1)
|
|
return _end_cap_regex.sub(r'\1_\2', s2).lower()
|
|
|
|
|
|
def get_canonical_querystring(params, for_signature):
|
|
"""
|
|
|
|
:param params:
|
|
:param for_signature:
|
|
:return:
|
|
"""
|
|
if params is None:
|
|
return ''
|
|
result = []
|
|
for k, v in iteritems(params):
|
|
if not for_signature or k.lower != http_headers.AUTHORIZATION.lower():
|
|
if v is None:
|
|
v = ''
|
|
result.append(b'%s=%s' % (normalize_string(k), normalize_string(v)))
|
|
result.sort()
|
|
return (b'&').join(result)
|
|
|
|
|
|
def print_object(obj):
|
|
"""
|
|
|
|
:param obj:
|
|
:return:
|
|
"""
|
|
tmp = []
|
|
for k, v in iteritems(obj.__dict__):
|
|
if not k.startswith('__') and k != "raw_data":
|
|
if isinstance(v, bytes):
|
|
tmp.append("%s:'%s'" % (k, v))
|
|
# str is unicode
|
|
elif isinstance(v, str):
|
|
tmp.append("%s:u'%s'" % (k, v))
|
|
else:
|
|
tmp.append('%s:%s' % (k, v))
|
|
return '{%s}' % ','.join(tmp)
|
|
|
|
class Expando(object):
|
|
"""
|
|
Expandable class
|
|
"""
|
|
def __init__(self, attr_dict=None):
|
|
if attr_dict:
|
|
self.__dict__.update(attr_dict)
|
|
|
|
def __getattr__(self, item):
|
|
if item.startswith('__'):
|
|
raise AttributeError
|
|
return None
|
|
|
|
def __repr__(self):
|
|
return print_object(self)
|
|
|
|
|
|
def dict_to_python_object(d):
|
|
"""
|
|
|
|
:param d:
|
|
:return:
|
|
"""
|
|
attr = {}
|
|
for k, v in iteritems(d):
|
|
if not isinstance(k, compat.string_types):
|
|
k = compat.convert_to_string(k)
|
|
k = pythonize_name(k)
|
|
attr[k] = v
|
|
return Expando(attr)
|
|
|
|
|
|
def required(**types):
|
|
"""
|
|
decorator of input param check
|
|
:param types:
|
|
:return:
|
|
"""
|
|
def _required(f):
|
|
def _decorated(*args, **kwds):
|
|
for i, v in enumerate(args):
|
|
if f.__code__.co_varnames[i] in types:
|
|
if v is None:
|
|
raise ValueError('arg "%s" should not be None' %
|
|
(f.__code__.co_varnames[i]))
|
|
if not isinstance(v, types[f.__code__.co_varnames[i]]):
|
|
raise TypeError('arg "%s"= %r does not match %s' %
|
|
(f.__code__.co_varnames[i],
|
|
v,
|
|
types[f.__code__.co_varnames[i]]))
|
|
for k, v in iteritems(kwds):
|
|
if k in types:
|
|
if v is None:
|
|
raise ValueError('arg "%s" should not be None' % k)
|
|
if not isinstance(v, types[k]):
|
|
raise TypeError('arg "%s"= %r does not match %s' % (k, v, types[k]))
|
|
return f(*args, **kwds)
|
|
_decorated.__name__ = f.__name__
|
|
return _decorated
|
|
return _required
|
|
|
|
|
|
def parse_host_port(endpoint, default_protocol):
|
|
"""
|
|
parse protocol, host, port from endpoint in config
|
|
|
|
:type: string
|
|
:param endpoint: endpoint in config
|
|
|
|
:type: baidubce.protocol.HTTP or baidubce.protocol.HTTPS
|
|
:param default_protocol: if there is no scheme in endpoint,
|
|
we will use this protocol as default
|
|
:return: tuple of protocol, host, port
|
|
"""
|
|
# netloc should begin with // according to RFC1808
|
|
if b"//" not in endpoint:
|
|
endpoint = b"//" + endpoint
|
|
|
|
try:
|
|
# scheme in endpoint dominates input default_protocol
|
|
parse_result = urlparse(
|
|
endpoint,
|
|
compat.convert_to_bytes(default_protocol.name))
|
|
except Exception as e:
|
|
raise ValueError('Invalid endpoint:%s, error:%s' % (endpoint,
|
|
compat.convert_to_string(e)))
|
|
|
|
if parse_result.scheme == compat.convert_to_bytes(baidubce.protocol.HTTP.name):
|
|
protocol = baidubce.protocol.HTTP
|
|
port = baidubce.protocol.HTTP.default_port
|
|
elif parse_result.scheme == compat.convert_to_bytes(baidubce.protocol.HTTPS.name):
|
|
protocol = baidubce.protocol.HTTPS
|
|
port = baidubce.protocol.HTTPS.default_port
|
|
else:
|
|
raise ValueError('Unsupported protocol %s' % parse_result.scheme)
|
|
host = parse_result.hostname
|
|
if parse_result.port is not None:
|
|
port = parse_result.port
|
|
|
|
return protocol, host, port
|
|
|
|
"""
|
|
def aes128_encrypt_16char_key(adminpass, secretkey):
|
|
|
|
#Python2:encrypt admin password by AES128
|
|
|
|
pad_it = lambda s: s + (16 - len(s) % 16) * chr(16 - len(s) % 16)
|
|
key = secretkey[0:16]
|
|
mode = AES.MODE_ECB
|
|
cryptor = AES.new(key, mode, key)
|
|
cipheradminpass = cryptor.encrypt(pad_it(adminpass)).encode('hex')
|
|
return cipheradminpass
|
|
"""
|
|
|
|
|
|
def aes128_encrypt_16char_key(adminpass, secretkey):
|
|
"""
|
|
|
|
:param adminpass: adminpass
|
|
:param secretkey: secretkey
|
|
:return: cipheradminpass
|
|
"""
|
|
|
|
# Python3: encrypt admin password by AES128
|
|
|
|
pad_it = lambda s: s + (16 - len(s) % 16) * chr(16 - len(s) % 16)
|
|
key = secretkey[0:16]
|
|
mode = AES.MODE_ECB
|
|
cryptor = AES.new(key, mode)
|
|
pad_admin = pad_it(adminpass)
|
|
byte_pad_admin = pad_admin.encode(encoding='utf-8')
|
|
|
|
cryptoradminpass = cryptor.encrypt(byte_pad_admin)
|
|
#print(cryptoradminpass)
|
|
|
|
#cipheradminpass = cryptor.encrypt(byte_pad_admin).encode('hex')
|
|
byte_cipheradminpass = codecs.encode(cryptoradminpass, 'hex_codec')
|
|
#print(byte_cipheradminpass)
|
|
|
|
cipheradminpass = byte_cipheradminpass.decode(encoding='utf-8')
|
|
#print(cipheradminpass)
|
|
|
|
return cipheradminpass
|
|
|
|
def is_cname_like_host(host):
|
|
"""
|
|
:param host: custom domain
|
|
:return: domain end with cdn endpoint or not
|
|
"""
|
|
if host is None:
|
|
return False
|
|
for suffix in DEFAULT_CNAME_LIKE_LIST:
|
|
if host.lower().endswith(suffix):
|
|
return True
|
|
return False
|
|
|
|
|
|
def is_custom_host(host, bucket_name):
|
|
"""
|
|
custom host : xxx.region.bcebos.com
|
|
: return: custom, domain or not
|
|
"""
|
|
if host is None or bucket_name is None:
|
|
return False
|
|
|
|
host_split = host.split(b'.')
|
|
# split http head
|
|
return host.lower().startswith(compat.convert_to_bytes(bucket_name.lower())) \
|
|
and len(host_split) == 4 and is_bos_suffixed_host(host)
|
|
|
|
|
|
def is_bos_suffixed_host(host):
|
|
"""
|
|
:param host: bos endpoint
|
|
:return: bos endpoint or not
|
|
"""
|
|
if host is None:
|
|
return False
|
|
if host.endswith(b'/'):
|
|
check_host = host[:-1]
|
|
else:
|
|
check_host = host
|
|
|
|
return check_host.lower().endswith(DEFAULT_BOS_DOMAIN_SUFFIX)
|
|
|
|
|
|
def check_ipv4(ipAddr):
|
|
"""
|
|
:param ipAddr: ip address
|
|
:return: true or false
|
|
"""
|
|
compile_ip=re.compile(b'((2(5[0-5]|[0-4]\d))|[0-1]?\d{1,2})(.((2(5[0-5]|[0-4]\d))|[0-1]?\d{1,2})){3}')
|
|
return compile_ip.match(ipAddr)
|
|
|
|
def _get_data_size(data):
|
|
if hasattr(data, '__len__'):
|
|
return len(data)
|
|
|
|
if hasattr(data, 'len'):
|
|
return data.len
|
|
|
|
if hasattr(data, 'seek') and hasattr(data, 'tell'):
|
|
return file_object_remaining_bytes(data)
|
|
|
|
return None
|
|
|
|
def file_object_remaining_bytes(fileobj):
|
|
current = fileobj.tell()
|
|
|
|
fileobj.seek(0, os.SEEK_END)
|
|
end = fileobj.tell()
|
|
fileobj.seek(current, os.SEEK_SET)
|
|
|
|
return end - current
|
|
|
|
def _invoke_progress_callback(progress_callback, consumed_bytes, total_bytes):
|
|
if progress_callback:
|
|
progress_callback(consumed_bytes, total_bytes)
|
|
|
|
def make_progress_adapter(data, progress_callback, size=None):
|
|
"""return a adapter,when reading 'data', that is, calling read or iterating
|
|
over it Call the progress callback function
|
|
|
|
:param data: bytes,file object or iterable
|
|
:param progress_callback: callback function, ref:`_default_progress_callback`
|
|
:param size: size of `data`
|
|
|
|
:return: callback function adapter
|
|
"""
|
|
|
|
if size is None:
|
|
size = _get_data_size(data)
|
|
|
|
if size is None:
|
|
raise ValueError('{0} is not a file object'.format(data.__class__.__name__))
|
|
|
|
return _BytesAndFileAdapter(data, progress_callback, size)
|
|
|
|
_CHUNK_SIZE = 8 * 1024
|
|
|
|
class _BytesAndFileAdapter(object):
|
|
"""With this adapter, you can add progress monitoring to 'data'.
|
|
|
|
:param data: bytes or file object
|
|
:param progress_callback: user-provided callback function. like callback(bytes_read, total_bytes)
|
|
bytes_read is readed bytes;total_bytes is total bytes
|
|
:param int size : data size
|
|
"""
|
|
def __init__(self, data, progress_callback=None, size=None):
|
|
self.data = data
|
|
self.progress_callback = progress_callback
|
|
self.size = size
|
|
self.offset = 0
|
|
|
|
@property
|
|
def len(self):
|
|
return self.size
|
|
|
|
# for python 2.x
|
|
def __bool__(self):
|
|
return True
|
|
# for python 3.x
|
|
__nonzero__=__bool__
|
|
|
|
# support iterable type
|
|
# def __iter__(self):
|
|
# return self
|
|
|
|
# def __next__(self):
|
|
# return self.next()
|
|
|
|
# def next(self):
|
|
# content = self.read(_CHUNK_SIZE)
|
|
|
|
# if content:
|
|
# return content
|
|
# else:
|
|
# raise StopIteration
|
|
|
|
def read(self, amt=None):
|
|
if self.offset >= self.size:
|
|
return compat.convert_to_bytes('')
|
|
|
|
if amt is None or amt < 0:
|
|
bytes_to_read = self.size - self.offset
|
|
else:
|
|
bytes_to_read = min(amt, self.size - self.offset)
|
|
|
|
if isinstance(self.data, bytes):
|
|
content = self.data[self.offset:self.offset+bytes_to_read]
|
|
else:
|
|
content = self.data.read(bytes_to_read)
|
|
|
|
self.offset += bytes_to_read
|
|
|
|
_invoke_progress_callback(self.progress_callback, min(self.offset, self.size), self.size)
|
|
|
|
return content
|
|
|
|
def default_progress_callback(consumed_bytes, total_bytes):
|
|
"""Progress bar callback function that calculates the percentage of current completion
|
|
|
|
:param consumed_bytes: Amount of data that has been uploaded/downloaded
|
|
:param total_bytes: According to the total amount
|
|
"""
|
|
if total_bytes:
|
|
rate = int(100 * (float(consumed_bytes) / float(total_bytes)))
|
|
start_progress = '*' * rate
|
|
end_progress = '.' * (100 - rate)
|
|
if rate == 100:
|
|
print("\r{}%[{}->{}]\n".format(rate, start_progress, end_progress), end="")
|
|
else:
|
|
print("\r{}%[{}->{}]".format(rate, start_progress, end_progress), end="")
|
|
|
|
sys.stdout.flush()
|