[utils,compat] Move struct_pack and struct_unpack to compat.py
[youtube-dl] / youtube_dl / compat.py
1 from __future__ import unicode_literals
2
3 import binascii
4 import collections
5 import email
6 import getpass
7 import io
8 import optparse
9 import os
10 import re
11 import shlex
12 import shutil
13 import socket
14 import struct
15 import subprocess
16 import sys
17 import itertools
18 import xml.etree.ElementTree
19
20
21 try:
22     import urllib.request as compat_urllib_request
23 except ImportError:  # Python 2
24     import urllib2 as compat_urllib_request
25
26 try:
27     import urllib.error as compat_urllib_error
28 except ImportError:  # Python 2
29     import urllib2 as compat_urllib_error
30
31 try:
32     import urllib.parse as compat_urllib_parse
33 except ImportError:  # Python 2
34     import urllib as compat_urllib_parse
35
36 try:
37     from urllib.parse import urlparse as compat_urllib_parse_urlparse
38 except ImportError:  # Python 2
39     from urlparse import urlparse as compat_urllib_parse_urlparse
40
41 try:
42     import urllib.parse as compat_urlparse
43 except ImportError:  # Python 2
44     import urlparse as compat_urlparse
45
46 try:
47     import urllib.response as compat_urllib_response
48 except ImportError:  # Python 2
49     import urllib as compat_urllib_response
50
51 try:
52     import http.cookiejar as compat_cookiejar
53 except ImportError:  # Python 2
54     import cookielib as compat_cookiejar
55
56 try:
57     import http.cookies as compat_cookies
58 except ImportError:  # Python 2
59     import Cookie as compat_cookies
60
61 try:
62     import html.entities as compat_html_entities
63 except ImportError:  # Python 2
64     import htmlentitydefs as compat_html_entities
65
66 try:
67     import http.client as compat_http_client
68 except ImportError:  # Python 2
69     import httplib as compat_http_client
70
71 try:
72     from urllib.error import HTTPError as compat_HTTPError
73 except ImportError:  # Python 2
74     from urllib2 import HTTPError as compat_HTTPError
75
76 try:
77     from urllib.request import urlretrieve as compat_urlretrieve
78 except ImportError:  # Python 2
79     from urllib import urlretrieve as compat_urlretrieve
80
81 try:
82     from html.parser import HTMLParser as compat_HTMLParser
83 except ImportError:  # Python 2
84     from HTMLParser import HTMLParser as compat_HTMLParser
85
86
87 try:
88     from subprocess import DEVNULL
89     compat_subprocess_get_DEVNULL = lambda: DEVNULL
90 except ImportError:
91     compat_subprocess_get_DEVNULL = lambda: open(os.path.devnull, 'w')
92
93 try:
94     import http.server as compat_http_server
95 except ImportError:
96     import BaseHTTPServer as compat_http_server
97
98 try:
99     compat_str = unicode  # Python 2
100 except NameError:
101     compat_str = str
102
103 try:
104     from urllib.parse import unquote_to_bytes as compat_urllib_parse_unquote_to_bytes
105     from urllib.parse import unquote as compat_urllib_parse_unquote
106     from urllib.parse import unquote_plus as compat_urllib_parse_unquote_plus
107 except ImportError:  # Python 2
108     _asciire = (compat_urllib_parse._asciire if hasattr(compat_urllib_parse, '_asciire')
109                 else re.compile('([\x00-\x7f]+)'))
110
111     # HACK: The following are the correct unquote_to_bytes, unquote and unquote_plus
112     # implementations from cpython 3.4.3's stdlib. Python 2's version
113     # is apparently broken (see https://github.com/rg3/youtube-dl/pull/6244)
114
115     def compat_urllib_parse_unquote_to_bytes(string):
116         """unquote_to_bytes('abc%20def') -> b'abc def'."""
117         # Note: strings are encoded as UTF-8. This is only an issue if it contains
118         # unescaped non-ASCII characters, which URIs should not.
119         if not string:
120             # Is it a string-like object?
121             string.split
122             return b''
123         if isinstance(string, compat_str):
124             string = string.encode('utf-8')
125         bits = string.split(b'%')
126         if len(bits) == 1:
127             return string
128         res = [bits[0]]
129         append = res.append
130         for item in bits[1:]:
131             try:
132                 append(compat_urllib_parse._hextochr[item[:2]])
133                 append(item[2:])
134             except KeyError:
135                 append(b'%')
136                 append(item)
137         return b''.join(res)
138
139     def compat_urllib_parse_unquote(string, encoding='utf-8', errors='replace'):
140         """Replace %xx escapes by their single-character equivalent. The optional
141         encoding and errors parameters specify how to decode percent-encoded
142         sequences into Unicode characters, as accepted by the bytes.decode()
143         method.
144         By default, percent-encoded sequences are decoded with UTF-8, and invalid
145         sequences are replaced by a placeholder character.
146
147         unquote('abc%20def') -> 'abc def'.
148         """
149         if '%' not in string:
150             string.split
151             return string
152         if encoding is None:
153             encoding = 'utf-8'
154         if errors is None:
155             errors = 'replace'
156         bits = _asciire.split(string)
157         res = [bits[0]]
158         append = res.append
159         for i in range(1, len(bits), 2):
160             append(compat_urllib_parse_unquote_to_bytes(bits[i]).decode(encoding, errors))
161             append(bits[i + 1])
162         return ''.join(res)
163
164     def compat_urllib_parse_unquote_plus(string, encoding='utf-8', errors='replace'):
165         """Like unquote(), but also replace plus signs by spaces, as required for
166         unquoting HTML form values.
167
168         unquote_plus('%7e/abc+def') -> '~/abc def'
169         """
170         string = string.replace('+', ' ')
171         return compat_urllib_parse_unquote(string, encoding, errors)
172
173 try:
174     from urllib.parse import urlencode as compat_urllib_parse_urlencode
175 except ImportError:  # Python 2
176     # Python 2 will choke in urlencode on mixture of byte and unicode strings.
177     # Possible solutions are to either port it from python 3 with all
178     # the friends or manually ensure input query contains only byte strings.
179     # We will stick with latter thus recursively encoding the whole query.
180     def compat_urllib_parse_urlencode(query, doseq=0, encoding='utf-8'):
181         def encode_elem(e):
182             if isinstance(e, dict):
183                 e = encode_dict(e)
184             elif isinstance(e, (list, tuple,)):
185                 list_e = encode_list(e)
186                 e = tuple(list_e) if isinstance(e, tuple) else list_e
187             elif isinstance(e, compat_str):
188                 e = e.encode(encoding)
189             return e
190
191         def encode_dict(d):
192             return dict((encode_elem(k), encode_elem(v)) for k, v in d.items())
193
194         def encode_list(l):
195             return [encode_elem(e) for e in l]
196
197         return compat_urllib_parse.urlencode(encode_elem(query), doseq=doseq)
198
199 try:
200     from urllib.request import DataHandler as compat_urllib_request_DataHandler
201 except ImportError:  # Python < 3.4
202     # Ported from CPython 98774:1733b3bd46db, Lib/urllib/request.py
203     class compat_urllib_request_DataHandler(compat_urllib_request.BaseHandler):
204         def data_open(self, req):
205             # data URLs as specified in RFC 2397.
206             #
207             # ignores POSTed data
208             #
209             # syntax:
210             # dataurl   := "data:" [ mediatype ] [ ";base64" ] "," data
211             # mediatype := [ type "/" subtype ] *( ";" parameter )
212             # data      := *urlchar
213             # parameter := attribute "=" value
214             url = req.get_full_url()
215
216             scheme, data = url.split(':', 1)
217             mediatype, data = data.split(',', 1)
218
219             # even base64 encoded data URLs might be quoted so unquote in any case:
220             data = compat_urllib_parse_unquote_to_bytes(data)
221             if mediatype.endswith(';base64'):
222                 data = binascii.a2b_base64(data)
223                 mediatype = mediatype[:-7]
224
225             if not mediatype:
226                 mediatype = 'text/plain;charset=US-ASCII'
227
228             headers = email.message_from_string(
229                 'Content-type: %s\nContent-length: %d\n' % (mediatype, len(data)))
230
231             return compat_urllib_response.addinfourl(io.BytesIO(data), headers, url)
232
233 try:
234     compat_basestring = basestring  # Python 2
235 except NameError:
236     compat_basestring = str
237
238 try:
239     compat_chr = unichr  # Python 2
240 except NameError:
241     compat_chr = chr
242
243 try:
244     from xml.etree.ElementTree import ParseError as compat_xml_parse_error
245 except ImportError:  # Python 2.6
246     from xml.parsers.expat import ExpatError as compat_xml_parse_error
247
248 if sys.version_info[0] >= 3:
249     compat_etree_fromstring = xml.etree.ElementTree.fromstring
250 else:
251     # python 2.x tries to encode unicode strings with ascii (see the
252     # XMLParser._fixtext method)
253     etree = xml.etree.ElementTree
254
255     try:
256         _etree_iter = etree.Element.iter
257     except AttributeError:  # Python <=2.6
258         def _etree_iter(root):
259             for el in root.findall('*'):
260                 yield el
261                 for sub in _etree_iter(el):
262                     yield sub
263
264     # on 2.6 XML doesn't have a parser argument, function copied from CPython
265     # 2.7 source
266     def _XML(text, parser=None):
267         if not parser:
268             parser = etree.XMLParser(target=etree.TreeBuilder())
269         parser.feed(text)
270         return parser.close()
271
272     def _element_factory(*args, **kwargs):
273         el = etree.Element(*args, **kwargs)
274         for k, v in el.items():
275             if isinstance(v, bytes):
276                 el.set(k, v.decode('utf-8'))
277         return el
278
279     def compat_etree_fromstring(text):
280         doc = _XML(text, parser=etree.XMLParser(target=etree.TreeBuilder(element_factory=_element_factory)))
281         for el in _etree_iter(doc):
282             if el.text is not None and isinstance(el.text, bytes):
283                 el.text = el.text.decode('utf-8')
284         return doc
285
286 if sys.version_info < (2, 7):
287     # Here comes the crazy part: In 2.6, if the xpath is a unicode,
288     # .//node does not match if a node is a direct child of . !
289     def compat_xpath(xpath):
290         if isinstance(xpath, compat_str):
291             xpath = xpath.encode('ascii')
292         return xpath
293 else:
294     compat_xpath = lambda xpath: xpath
295
296 try:
297     from urllib.parse import parse_qs as compat_parse_qs
298 except ImportError:  # Python 2
299     # HACK: The following is the correct parse_qs implementation from cpython 3's stdlib.
300     # Python 2's version is apparently totally broken
301
302     def _parse_qsl(qs, keep_blank_values=False, strict_parsing=False,
303                    encoding='utf-8', errors='replace'):
304         qs, _coerce_result = qs, compat_str
305         pairs = [s2 for s1 in qs.split('&') for s2 in s1.split(';')]
306         r = []
307         for name_value in pairs:
308             if not name_value and not strict_parsing:
309                 continue
310             nv = name_value.split('=', 1)
311             if len(nv) != 2:
312                 if strict_parsing:
313                     raise ValueError('bad query field: %r' % (name_value,))
314                 # Handle case of a control-name with no equal sign
315                 if keep_blank_values:
316                     nv.append('')
317                 else:
318                     continue
319             if len(nv[1]) or keep_blank_values:
320                 name = nv[0].replace('+', ' ')
321                 name = compat_urllib_parse_unquote(
322                     name, encoding=encoding, errors=errors)
323                 name = _coerce_result(name)
324                 value = nv[1].replace('+', ' ')
325                 value = compat_urllib_parse_unquote(
326                     value, encoding=encoding, errors=errors)
327                 value = _coerce_result(value)
328                 r.append((name, value))
329         return r
330
331     def compat_parse_qs(qs, keep_blank_values=False, strict_parsing=False,
332                         encoding='utf-8', errors='replace'):
333         parsed_result = {}
334         pairs = _parse_qsl(qs, keep_blank_values, strict_parsing,
335                            encoding=encoding, errors=errors)
336         for name, value in pairs:
337             if name in parsed_result:
338                 parsed_result[name].append(value)
339             else:
340                 parsed_result[name] = [value]
341         return parsed_result
342
343 try:
344     from shlex import quote as shlex_quote
345 except ImportError:  # Python < 3.3
346     def shlex_quote(s):
347         if re.match(r'^[-_\w./]+$', s):
348             return s
349         else:
350             return "'" + s.replace("'", "'\"'\"'") + "'"
351
352
353 if sys.version_info >= (2, 7, 3):
354     compat_shlex_split = shlex.split
355 else:
356     # Working around shlex issue with unicode strings on some python 2
357     # versions (see http://bugs.python.org/issue1548891)
358     def compat_shlex_split(s, comments=False, posix=True):
359         if isinstance(s, compat_str):
360             s = s.encode('utf-8')
361         return shlex.split(s, comments, posix)
362
363
364 def compat_ord(c):
365     if type(c) is int:
366         return c
367     else:
368         return ord(c)
369
370
371 compat_os_name = os._name if os.name == 'java' else os.name
372
373
374 if sys.version_info >= (3, 0):
375     compat_getenv = os.getenv
376     compat_expanduser = os.path.expanduser
377
378     def compat_setenv(key, value, env=os.environ):
379         env[key] = value
380 else:
381     # Environment variables should be decoded with filesystem encoding.
382     # Otherwise it will fail if any non-ASCII characters present (see #3854 #3217 #2918)
383
384     def compat_getenv(key, default=None):
385         from .utils import get_filesystem_encoding
386         env = os.getenv(key, default)
387         if env:
388             env = env.decode(get_filesystem_encoding())
389         return env
390
391     def compat_setenv(key, value, env=os.environ):
392         def encode(v):
393             from .utils import get_filesystem_encoding
394             return v.encode(get_filesystem_encoding()) if isinstance(v, compat_str) else v
395         env[encode(key)] = encode(value)
396
397     # HACK: The default implementations of os.path.expanduser from cpython do not decode
398     # environment variables with filesystem encoding. We will work around this by
399     # providing adjusted implementations.
400     # The following are os.path.expanduser implementations from cpython 2.7.8 stdlib
401     # for different platforms with correct environment variables decoding.
402
403     if compat_os_name == 'posix':
404         def compat_expanduser(path):
405             """Expand ~ and ~user constructions.  If user or $HOME is unknown,
406             do nothing."""
407             if not path.startswith('~'):
408                 return path
409             i = path.find('/', 1)
410             if i < 0:
411                 i = len(path)
412             if i == 1:
413                 if 'HOME' not in os.environ:
414                     import pwd
415                     userhome = pwd.getpwuid(os.getuid()).pw_dir
416                 else:
417                     userhome = compat_getenv('HOME')
418             else:
419                 import pwd
420                 try:
421                     pwent = pwd.getpwnam(path[1:i])
422                 except KeyError:
423                     return path
424                 userhome = pwent.pw_dir
425             userhome = userhome.rstrip('/')
426             return (userhome + path[i:]) or '/'
427     elif compat_os_name == 'nt' or compat_os_name == 'ce':
428         def compat_expanduser(path):
429             """Expand ~ and ~user constructs.
430
431             If user or $HOME is unknown, do nothing."""
432             if path[:1] != '~':
433                 return path
434             i, n = 1, len(path)
435             while i < n and path[i] not in '/\\':
436                 i = i + 1
437
438             if 'HOME' in os.environ:
439                 userhome = compat_getenv('HOME')
440             elif 'USERPROFILE' in os.environ:
441                 userhome = compat_getenv('USERPROFILE')
442             elif 'HOMEPATH' not in os.environ:
443                 return path
444             else:
445                 try:
446                     drive = compat_getenv('HOMEDRIVE')
447                 except KeyError:
448                     drive = ''
449                 userhome = os.path.join(drive, compat_getenv('HOMEPATH'))
450
451             if i != 1:  # ~user
452                 userhome = os.path.join(os.path.dirname(userhome), path[1:i])
453
454             return userhome + path[i:]
455     else:
456         compat_expanduser = os.path.expanduser
457
458
459 if sys.version_info < (3, 0):
460     def compat_print(s):
461         from .utils import preferredencoding
462         print(s.encode(preferredencoding(), 'xmlcharrefreplace'))
463 else:
464     def compat_print(s):
465         assert isinstance(s, compat_str)
466         print(s)
467
468
469 try:
470     subprocess_check_output = subprocess.check_output
471 except AttributeError:
472     def subprocess_check_output(*args, **kwargs):
473         assert 'input' not in kwargs
474         p = subprocess.Popen(*args, stdout=subprocess.PIPE, **kwargs)
475         output, _ = p.communicate()
476         ret = p.poll()
477         if ret:
478             raise subprocess.CalledProcessError(ret, p.args, output=output)
479         return output
480
481 if sys.version_info < (3, 0) and sys.platform == 'win32':
482     def compat_getpass(prompt, *args, **kwargs):
483         if isinstance(prompt, compat_str):
484             from .utils import preferredencoding
485             prompt = prompt.encode(preferredencoding())
486         return getpass.getpass(prompt, *args, **kwargs)
487 else:
488     compat_getpass = getpass.getpass
489
490 # Python < 2.6.5 require kwargs to be bytes
491 try:
492     def _testfunc(x):
493         pass
494     _testfunc(**{'x': 0})
495 except TypeError:
496     def compat_kwargs(kwargs):
497         return dict((bytes(k), v) for k, v in kwargs.items())
498 else:
499     compat_kwargs = lambda kwargs: kwargs
500
501
502 if sys.version_info < (2, 7):
503     def compat_socket_create_connection(address, timeout, source_address=None):
504         host, port = address
505         err = None
506         for res in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM):
507             af, socktype, proto, canonname, sa = res
508             sock = None
509             try:
510                 sock = socket.socket(af, socktype, proto)
511                 sock.settimeout(timeout)
512                 if source_address:
513                     sock.bind(source_address)
514                 sock.connect(sa)
515                 return sock
516             except socket.error as _:
517                 err = _
518                 if sock is not None:
519                     sock.close()
520         if err is not None:
521             raise err
522         else:
523             raise socket.error('getaddrinfo returns an empty list')
524 else:
525     compat_socket_create_connection = socket.create_connection
526
527
528 # Fix https://github.com/rg3/youtube-dl/issues/4223
529 # See http://bugs.python.org/issue9161 for what is broken
530 def workaround_optparse_bug9161():
531     op = optparse.OptionParser()
532     og = optparse.OptionGroup(op, 'foo')
533     try:
534         og.add_option('-t')
535     except TypeError:
536         real_add_option = optparse.OptionGroup.add_option
537
538         def _compat_add_option(self, *args, **kwargs):
539             enc = lambda v: (
540                 v.encode('ascii', 'replace') if isinstance(v, compat_str)
541                 else v)
542             bargs = [enc(a) for a in args]
543             bkwargs = dict(
544                 (k, enc(v)) for k, v in kwargs.items())
545             return real_add_option(self, *bargs, **bkwargs)
546         optparse.OptionGroup.add_option = _compat_add_option
547
548 if hasattr(shutil, 'get_terminal_size'):  # Python >= 3.3
549     compat_get_terminal_size = shutil.get_terminal_size
550 else:
551     _terminal_size = collections.namedtuple('terminal_size', ['columns', 'lines'])
552
553     def compat_get_terminal_size(fallback=(80, 24)):
554         columns = compat_getenv('COLUMNS')
555         if columns:
556             columns = int(columns)
557         else:
558             columns = None
559         lines = compat_getenv('LINES')
560         if lines:
561             lines = int(lines)
562         else:
563             lines = None
564
565         if columns is None or lines is None or columns <= 0 or lines <= 0:
566             try:
567                 sp = subprocess.Popen(
568                     ['stty', 'size'],
569                     stdout=subprocess.PIPE, stderr=subprocess.PIPE)
570                 out, err = sp.communicate()
571                 _lines, _columns = map(int, out.split())
572             except Exception:
573                 _columns, _lines = _terminal_size(*fallback)
574
575             if columns is None or columns <= 0:
576                 columns = _columns
577             if lines is None or lines <= 0:
578                 lines = _lines
579         return _terminal_size(columns, lines)
580
581 try:
582     itertools.count(start=0, step=1)
583     compat_itertools_count = itertools.count
584 except TypeError:  # Python 2.6
585     def compat_itertools_count(start=0, step=1):
586         n = start
587         while True:
588             yield n
589             n += step
590
591 if sys.version_info >= (3, 0):
592     from tokenize import tokenize as compat_tokenize_tokenize
593 else:
594     from tokenize import generate_tokens as compat_tokenize_tokenize
595
596
597 try:
598     struct.pack('!I', 0)
599 except TypeError:
600     # In Python 2.6 and 2.7.x < 2.7.7, struct requires a bytes argument
601     # See https://bugs.python.org/issue19099
602     def struct_pack(spec, *args):
603         if isinstance(spec, compat_str):
604             spec = spec.encode('ascii')
605         return struct.pack(spec, *args)
606
607     def struct_unpack(spec, *args):
608         if isinstance(spec, compat_str):
609             spec = spec.encode('ascii')
610         return struct.unpack(spec, *args)
611 else:
612     struct_pack = struct.pack
613     struct_unpack = struct.unpack
614
615
616 __all__ = [
617     'compat_HTMLParser',
618     'compat_HTTPError',
619     'compat_basestring',
620     'compat_chr',
621     'compat_cookiejar',
622     'compat_cookies',
623     'compat_etree_fromstring',
624     'compat_expanduser',
625     'compat_get_terminal_size',
626     'compat_getenv',
627     'compat_getpass',
628     'compat_html_entities',
629     'compat_http_client',
630     'compat_http_server',
631     'compat_itertools_count',
632     'compat_kwargs',
633     'compat_ord',
634     'compat_os_name',
635     'compat_parse_qs',
636     'compat_print',
637     'compat_setenv',
638     'compat_shlex_split',
639     'compat_socket_create_connection',
640     'compat_str',
641     'compat_subprocess_get_DEVNULL',
642     'compat_tokenize_tokenize',
643     'compat_urllib_error',
644     'compat_urllib_parse',
645     'compat_urllib_parse_unquote',
646     'compat_urllib_parse_unquote_plus',
647     'compat_urllib_parse_unquote_to_bytes',
648     'compat_urllib_parse_urlencode',
649     'compat_urllib_parse_urlparse',
650     'compat_urllib_request',
651     'compat_urllib_request_DataHandler',
652     'compat_urllib_response',
653     'compat_urlparse',
654     'compat_urlretrieve',
655     'compat_xml_parse_error',
656     'compat_xpath',
657     'shlex_quote',
658     'struct_pack',
659     'struct_unpack',
660     'subprocess_check_output',
661     'workaround_optparse_bug9161',
662 ]