-
Notifications
You must be signed in to change notification settings - Fork 0
/
wget.py
628 lines (521 loc) · 21.8 KB
/
wget.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
#!/usr/bin/env python
"""
Download utility as an easy way to get file from the net
python -m wget <URL>
python wget.py <URL>
Downloads: http://pypi.python.org/pypi/wget/
Development: http://bitbucket.org/techtonik/python-wget/
wget.py is not option compatible with Unix wget utility,
to make command line interface intuitive for new people.
Public domain by anatoly techtonik <techtonik@gmail.com>
Also available under the terms of MIT license
Copyright (c) 2010-2015 anatoly techtonik
"""
__version__ = "3.2"
import sys, shutil, os
import tempfile
import math
PY3K = sys.version_info >= (3, 0)
if PY3K:
import urllib.request as ulib
import urllib.parse as urlparse
else:
import urllib as ulib
import urlparse
# --- workarounds for Python misbehavior ---
# enable passing unicode arguments from command line in Python 2.x
# https://stackoverflow.com/questions/846850/read-unicode-characters
def win32_utf8_argv():
"""Uses shell32.GetCommandLineArgvW to get sys.argv as a list of Unicode
strings.
Versions 2.x of Python don't support Unicode in sys.argv on
Windows, with the underlying Windows API instead replacing multi-byte
characters with '?'.
"""
from ctypes import POINTER, byref, cdll, c_int, windll
from ctypes.wintypes import LPCWSTR, LPWSTR
GetCommandLineW = cdll.kernel32.GetCommandLineW
GetCommandLineW.argtypes = []
GetCommandLineW.restype = LPCWSTR
CommandLineToArgvW = windll.shell32.CommandLineToArgvW
CommandLineToArgvW.argtypes = [LPCWSTR, POINTER(c_int)]
CommandLineToArgvW.restype = POINTER(LPWSTR)
cmd = GetCommandLineW()
argc = c_int(0)
argv = CommandLineToArgvW(cmd, byref(argc))
argnum = argc.value
sysnum = len(sys.argv)
result = []
if argnum > 0:
# Remove Python executable and commands if present
start = argnum - sysnum
for i in range(start, argnum):
result.append(argv[i].encode('utf-8'))
return result
# enable unicode output to windows console
# https://stackoverflow.com/questions/878972/windows-cmd-encoding-change-causes-python-crash
def win32_unicode_console():
import codecs
from ctypes import WINFUNCTYPE, windll, POINTER, byref, c_int
from ctypes.wintypes import BOOL, HANDLE, DWORD, LPWSTR, LPCWSTR, LPVOID
original_stderr = sys.stderr
# Output exceptions in this code to original_stderr, so that we can at least see them
def _complain(message):
original_stderr.write(message if isinstance(message, str) else repr(message))
original_stderr.write('\n')
codecs.register(lambda name: codecs.lookup('utf-8') if name == 'cp65001' else None)
try:
GetStdHandle = WINFUNCTYPE(HANDLE, DWORD)(("GetStdHandle", windll.kernel32))
STD_OUTPUT_HANDLE = DWORD(-11)
STD_ERROR_HANDLE = DWORD(-12)
GetFileType = WINFUNCTYPE(DWORD, DWORD)(("GetFileType", windll.kernel32))
FILE_TYPE_CHAR = 0x0002
FILE_TYPE_REMOTE = 0x8000
GetConsoleMode = WINFUNCTYPE(BOOL, HANDLE, POINTER(DWORD))(("GetConsoleMode", windll.kernel32))
INVALID_HANDLE_VALUE = DWORD(-1).value
def not_a_console(handle):
if handle == INVALID_HANDLE_VALUE or handle is None:
return True
return ((GetFileType(handle) & ~FILE_TYPE_REMOTE) != FILE_TYPE_CHAR
or GetConsoleMode(handle, byref(DWORD())) == 0)
old_stdout_fileno = None
old_stderr_fileno = None
if hasattr(sys.stdout, 'fileno'):
old_stdout_fileno = sys.stdout.fileno()
if hasattr(sys.stderr, 'fileno'):
old_stderr_fileno = sys.stderr.fileno()
STDOUT_FILENO = 1
STDERR_FILENO = 2
real_stdout = (old_stdout_fileno == STDOUT_FILENO)
real_stderr = (old_stderr_fileno == STDERR_FILENO)
if real_stdout:
hStdout = GetStdHandle(STD_OUTPUT_HANDLE)
if not_a_console(hStdout):
real_stdout = False
if real_stderr:
hStderr = GetStdHandle(STD_ERROR_HANDLE)
if not_a_console(hStderr):
real_stderr = False
if real_stdout or real_stderr:
WriteConsoleW = WINFUNCTYPE(BOOL, HANDLE, LPWSTR, DWORD, POINTER(DWORD), LPVOID)(("WriteConsoleW", windll.kernel32))
class UnicodeOutput:
def __init__(self, hConsole, stream, fileno, name):
self._hConsole = hConsole
self._stream = stream
self._fileno = fileno
self.closed = False
self.softspace = False
self.mode = 'w'
self.encoding = 'utf-8'
self.name = name
self.flush()
def isatty(self):
return False
def close(self):
# don't really close the handle, that would only cause problems
self.closed = True
def fileno(self):
return self._fileno
def flush(self):
if self._hConsole is None:
try:
self._stream.flush()
except Exception as e:
_complain("%s.flush: %r from %r" % (self.name, e, self._stream))
raise
def write(self, text):
try:
if self._hConsole is None:
if not PY3K and isinstance(text, unicode):
text = text.encode('utf-8')
elif PY3K and isinstance(text, str):
text = text.encode('utf-8')
self._stream.write(text)
else:
if not PY3K and not isinstance(text, unicode):
text = str(text).decode('utf-8')
elif PY3K and not isinstance(text, str):
text = text.decode('utf-8')
remaining = len(text)
while remaining:
n = DWORD(0)
# There is a shorter-than-documented limitation on the
# length of the string passed to WriteConsoleW (see
# <http://tahoe-lafs.org/trac/tahoe-lafs/ticket/1232>.
retval = WriteConsoleW(self._hConsole, text, min(remaining, 10000), byref(n), None)
if retval == 0 or n.value == 0:
raise IOError("WriteConsoleW returned %r, n.value = %r" % (retval, n.value))
remaining -= n.value
if not remaining:
break
text = text[n.value:]
except Exception as e:
_complain("%s.write: %r" % (self.name, e))
raise
def writelines(self, lines):
try:
for line in lines:
self.write(line)
except Exception as e:
_complain("%s.writelines: %r" % (self.name, e))
raise
if real_stdout:
sys.stdout = UnicodeOutput(hStdout, None, STDOUT_FILENO, '<Unicode console stdout>')
else:
sys.stdout = UnicodeOutput(None, sys.stdout, old_stdout_fileno, '<Unicode redirected stdout>')
if real_stderr:
sys.stderr = UnicodeOutput(hStderr, None, STDERR_FILENO, '<Unicode console stderr>')
else:
sys.stderr = UnicodeOutput(None, sys.stderr, old_stderr_fileno, '<Unicode redirected stderr>')
except Exception as e:
_complain("exception %r while fixing up sys.stdout and sys.stderr" % (e,))
# --- helpers ---
def to_unicode(filename):
""":return: filename decoded from utf-8 to unicode"""
#
if PY3K:
# [ ] test this on Python 3 + (Windows, Linux)
# [ ] port filename_from_headers once this works
# [ ] add test to repository / Travis
return filename
else:
if isinstance(filename, unicode):
return filename
else:
return unicode(filename, 'utf-8')
def filename_from_url(url):
""":return: detected filename as unicode or None"""
# [ ] test urlparse behavior with unicode url
fname = os.path.basename(urlparse.urlparse(url).path)
if len(fname.strip(" \n\t.")) == 0:
return None
return to_unicode(fname)
def filename_from_headers(headers):
"""Detect filename from Content-Disposition headers if present.
http://greenbytes.de/tech/tc2231/
:param: headers as dict, list or string
:return: filename from content-disposition header or None
"""
if type(headers) == str:
headers = headers.splitlines()
if type(headers) == list:
headers = dict([x.split(':', 1) for x in headers])
cdisp = headers.get("Content-Disposition")
if not cdisp:
return None
cdtype = cdisp.split(';')
if len(cdtype) == 1:
return None
if cdtype[0].strip().lower() not in ('inline', 'attachment'):
return None
# several filename params is illegal, but just in case
fnames = [x for x in cdtype[1:] if x.strip().startswith('filename=')]
if len(fnames) > 1:
return None
name = fnames[0].split('=')[1].strip(' \t"')
name = os.path.basename(name)
if not name:
return None
return name
def filename_fix_existing(filename):
"""Expands name portion of filename with numeric ' (x)' suffix to
return filename that doesn't exist already.
"""
dirname = u'.'
name, ext = filename.rsplit('.', 1)
names = [x for x in os.listdir(dirname) if x.startswith(name)]
names = [x.rsplit('.', 1)[0] for x in names]
suffixes = [x.replace(name, '') for x in names]
# filter suffixes that match ' (x)' pattern
suffixes = [x[2:-1] for x in suffixes
if x.startswith(' (') and x.endswith(')')]
indexes = [int(x) for x in suffixes
if set(x) <= set('0123456789')]
idx = 1
if indexes:
idx += sorted(indexes)[-1]
return '%s (%d).%s' % (name, idx, ext)
# --- terminal/console output helpers ---
def get_console_width():
"""Return width of available window area. Autodetection works for
Windows and POSIX platforms. Returns 80 for others
Code from http://bitbucket.org/techtonik/python-pager
"""
if os.name == 'nt':
STD_INPUT_HANDLE = -10
STD_OUTPUT_HANDLE = -11
STD_ERROR_HANDLE = -12
# get console handle
from ctypes import windll, Structure, byref
try:
from ctypes.wintypes import SHORT, WORD, DWORD
except ImportError:
# workaround for missing types in Python 2.5
from ctypes import (
c_short as SHORT, c_ushort as WORD, c_ulong as DWORD)
console_handle = windll.kernel32.GetStdHandle(STD_OUTPUT_HANDLE)
# CONSOLE_SCREEN_BUFFER_INFO Structure
class COORD(Structure):
_fields_ = [("X", SHORT), ("Y", SHORT)]
class SMALL_RECT(Structure):
_fields_ = [("Left", SHORT), ("Top", SHORT),
("Right", SHORT), ("Bottom", SHORT)]
class CONSOLE_SCREEN_BUFFER_INFO(Structure):
_fields_ = [("dwSize", COORD),
("dwCursorPosition", COORD),
("wAttributes", WORD),
("srWindow", SMALL_RECT),
("dwMaximumWindowSize", DWORD)]
sbi = CONSOLE_SCREEN_BUFFER_INFO()
ret = windll.kernel32.GetConsoleScreenBufferInfo(
console_handle, byref(sbi))
if ret == 0:
return 0
return sbi.srWindow.Right+1
elif os.name == 'posix':
from fcntl import ioctl
from termios import TIOCGWINSZ
from array import array
winsize = array("H", [0] * 4)
try:
ioctl(sys.stdout.fileno(), TIOCGWINSZ, winsize)
except IOError:
pass
return (winsize[1], winsize[0])[0]
return 80
def bar_thermometer(current, total, width=80):
"""Return thermometer style progress bar string. `total` argument
can not be zero. The minimum size of bar returned is 3. Example:
[.......... ]
Control and trailing symbols (\r and spaces) are not included.
See `bar_adaptive` for more information.
"""
# number of dots on thermometer scale
avail_dots = width-2
shaded_dots = int(math.floor(float(current) / total * avail_dots))
return '[' + '.'*shaded_dots + ' '*(avail_dots-shaded_dots) + ']'
def bar_adaptive(current, total, width=80):
"""Return progress bar string for given values in one of three
styles depending on available width:
[.. ] downloaded / total
downloaded / total
[.. ]
if total value is unknown or <= 0, show bytes counter using two
adaptive styles:
%s / unknown
%s
if there is not enough space on the screen, do not display anything
returned string doesn't include control characters like \r used to
place cursor at the beginning of the line to erase previous content.
this function leaves one free character at the end of string to
avoid automatic linefeed on Windows.
"""
# process special case when total size is unknown and return immediately
if not total or total < 0:
msg = "%s / unknown" % current
if len(msg) < width: # leaves one character to avoid linefeed
return msg
if len("%s" % current) < width:
return "%s" % current
# --- adaptive layout algorithm ---
#
# [x] describe the format of the progress bar
# [x] describe min width for each data field
# [x] set priorities for each element
# [x] select elements to be shown
# [x] choose top priority element min_width < avail_width
# [x] lessen avail_width by value if min_width
# [x] exclude element from priority list and repeat
# 10% [.. ] 10/100
# pppp bbbbb sssssss
min_width = {
'percent': 4, # 100%
'bar': 3, # [.]
'size': len("%s" % total)*2 + 3, # 'xxxx / yyyy'
}
priority = ['percent', 'bar', 'size']
# select elements to show
selected = []
avail = width
for field in priority:
if min_width[field] < avail:
selected.append(field)
avail -= min_width[field]+1 # +1 is for separator or for reserved space at
# the end of line to avoid linefeed on Windows
# render
output = ''
for field in selected:
if field == 'percent':
# fixed size width for percentage
output += ('%s%%' % (100 * current // total)).rjust(min_width['percent'])
elif field == 'bar': # [. ]
# bar takes its min width + all available space
output += bar_thermometer(current, total, min_width['bar']+avail)
elif field == 'size':
# size field has a constant width (min == max)
output += ("%s / %s" % (current, total)).rjust(min_width['size'])
selected = selected[1:]
if selected:
output += ' ' # add field separator
return output
# --/ console helpers
__current_size = 0 # global state variable, which exists solely as a
# workaround against Python 3.3.0 regression
# http://bugs.python.org/issue16409
# fixed in Python 3.3.1
def callback_progress(blocks, block_size, total_size, bar_function):
"""callback function for urlretrieve that is called when connection is
created and when once for each block
draws adaptive progress bar in terminal/console
use sys.stdout.write() instead of "print,", because it allows one more
symbol at the line end without linefeed on Windows
:param blocks: number of blocks transferred so far
:param block_size: in bytes
:param total_size: in bytes, can be -1 if server doesn't return it
:param bar_function: another callback function to visualize progress
"""
global __current_size
width = min(100, get_console_width())
if sys.version_info[:3] == (3, 3, 0): # regression workaround
if blocks == 0: # first call
__current_size = 0
else:
__current_size += block_size
current_size = __current_size
else:
current_size = min(blocks*block_size, total_size)
progress = bar_function(current_size, total_size, width)
if progress:
sys.stdout.write("\r" + progress)
def detect_filename(url=None, out=None, headers=None, default="download.wget"):
"""Return filename for saving file. If no filename is detected from output
argument, url or headers, return default (download.wget)
"""
names = dict(out='', url='', headers='')
if out:
names["out"] = out or ''
if url:
names["url"] = filename_from_url(url) or ''
if headers:
names["headers"] = filename_from_headers(headers) or ''
return names["out"] or names["headers"] or names["url"] or default
def download(url, out=None, bar=bar_adaptive):
"""High level function, which downloads URL into tmp file in current
directory and then renames it to filename autodetected from either URL
or HTTP headers.
:param bar: function to track download progress (visualize etc.)
:param out: output filename or directory
:return: filename where URL is downloaded to
"""
# detect of out is a directory
outdir = None
if out and os.path.isdir(out):
outdir = out
out = None
# get filename for temp file in current directory
prefix = detect_filename(url, out)
(fd, tmpfile) = tempfile.mkstemp(".tmp", prefix=prefix, dir=tempfile.gettempdir() )
os.close(fd)
os.unlink(tmpfile)
# set progress monitoring callback
def callback_charged(blocks, block_size, total_size):
# 'closure' to set bar drawing function in callback
callback_progress(blocks, block_size, total_size, bar_function=bar)
if bar:
callback = callback_charged
else:
callback = None
if PY3K:
# Python 3 can not quote URL as needed
binurl = list(urlparse.urlsplit(url))
binurl[2] = urlparse.quote(binurl[2])
binurl = urlparse.urlunsplit(binurl)
else:
binurl = url
(tmpfile, headers) = ulib.urlretrieve(binurl, tmpfile, callback)
filename = detect_filename(url, out, headers)
if outdir:
filename = outdir + "/" + filename
# add numeric ' (x)' suffix if filename already exists
if os.path.exists(filename):
filename = filename_fix_existing(filename)
shutil.move(tmpfile, filename)
#print headers
return filename
usage = """\
usage: wget.py [options] URL
options:
-o --output FILE|DIR output filename or directory
-h --help
--version
"""
if __name__ == "__main__":
if len(sys.argv) < 2 or "-h" in sys.argv or "--help" in sys.argv:
sys.exit(usage)
if "--version" in sys.argv:
sys.exit("wget.py " + __version__)
# patch Python 2.x to read unicode from command line
if not PY3K and sys.platform == "win32":
sys.argv = win32_utf8_argv()
# patch Python to write unicode characters to console
if sys.platform == "win32":
win32_unicode_console()
from optparse import OptionParser
parser = OptionParser()
parser.add_option("-o", "--output", dest="output")
(options, args) = parser.parse_args()
url = sys.argv[1]
filename = download(args[0], out=options.output)
print("")
print("Saved under %s" % filename)
r"""
features that require more tuits for urlretrieve API
http://www.python.org/doc/2.6/library/urllib.html#urllib.urlretrieve
[x] autodetect filename from URL
[x] autodetect filename from headers - Content-Disposition
http://greenbytes.de/tech/tc2231/
[ ] make HEAD request to detect temp filename from Content-Disposition
[ ] process HTTP status codes (i.e. 404 error)
http://ftp.de.debian.org/debian/pool/iso-codes_3.24.2.orig.tar.bz2
[ ] catch KeyboardInterrupt
[ ] optionally preserve incomplete file
[x] create temp file in current directory
[ ] resume download (broken connection)
[ ] resume download (incomplete file)
[x] show progress indicator
http://mail.python.org/pipermail/tutor/2005-May/038797.html
[x] do not overwrite downloaded file
[x] rename file automatically if exists
[x] optionally specify path for downloaded file
[ ] options plan
[x] -h, --help, --version (CHAOS speccy)
[ ] clpbar progress bar style
_ 30.0Mb at 3.0 Mbps eta: 0:00:20 30% [===== ]
[ ] test "bar \r" print with \r at the end of line on Windows
[ ] process Python 2.x urllib.ContentTooShortError exception gracefully
(ideally retry and continue download)
(tmpfile, headers) = urllib.urlretrieve(url, tmpfile, callback_progress)
File "C:\Python27\lib\urllib.py", line 93, in urlretrieve
return _urlopener.retrieve(url, filename, reporthook, data)
File "C:\Python27\lib\urllib.py", line 283, in retrieve
"of %i bytes" % (read, size), result)
urllib.ContentTooShortError: retrieval incomplete: got only 15239952 out of 24807571 bytes
[ ] find out if urlretrieve may return unicode headers
[ ] write files with unicode characters
https://bitbucket.org/techtonik/python-wget/issues/7/filename-issue
[x] Python 2, Windows
[x] Python 3, Windows
[ ] Linux
[ ] add automatic tests
[ ] specify unicode URL from command line
[ ] specify unicode output file from command line
[ ] test suite for unsafe filenames from url and from headers
[ ] security checks
[ ] filename_from_url
[ ] filename_from_headers
[ ] MITM redirect from https URL
[ ] https certificate check
[ ] size+hash check helpers
[ ] fail if size is known and mismatch
[ ] fail if hash mismatch
"""