Update copyright years for 2013
[gpodder.git] / src / gpodder / util.py
blob49fa676558f5603612cfe8d7e2167a85bddd2138
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2013 Thomas Perl and the gPodder Team
5 # Copyright (c) 2011 Neal H. Walfield
7 # gPodder is free software; you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 3 of the License, or
10 # (at your option) any later version.
12 # gPodder is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
22 # util.py -- Misc utility functions
23 # Thomas Perl <thp@perli.net> 2007-08-04
26 """Miscellaneous helper functions for gPodder
28 This module provides helper and utility functions for gPodder that
29 are not tied to any specific part of gPodder.
31 """
33 import gpodder
35 import logging
36 logger = logging.getLogger(__name__)
38 import os
39 import os.path
40 import platform
41 import glob
42 import stat
43 import shlex
44 import shutil
45 import socket
46 import sys
47 import string
49 import re
50 import subprocess
51 from htmlentitydefs import entitydefs
52 import time
53 import gzip
54 import datetime
55 import threading
57 import urlparse
58 import urllib
59 import urllib2
60 import httplib
61 import webbrowser
62 import mimetypes
63 import itertools
65 import feedparser
67 import StringIO
68 import xml.dom.minidom
70 if gpodder.ui.win32:
71 try:
72 import win32file
73 except ImportError:
74 logger.warn('Running on Win32 but win32api/win32file not installed.')
75 win32file = None
77 _ = gpodder.gettext
78 N_ = gpodder.ngettext
81 import locale
82 try:
83 locale.setlocale(locale.LC_ALL, '')
84 except Exception, e:
85 logger.warn('Cannot set locale (%s)', e, exc_info=True)
87 # Native filesystem encoding detection
88 encoding = sys.getfilesystemencoding()
90 if encoding is None:
91 if 'LANG' in os.environ and '.' in os.environ['LANG']:
92 lang = os.environ['LANG']
93 (language, encoding) = lang.rsplit('.', 1)
94 logger.info('Detected encoding: %s', encoding)
95 elif gpodder.ui.harmattan or gpodder.ui.sailfish:
96 encoding = 'utf-8'
97 elif gpodder.ui.win32:
98 # To quote http://docs.python.org/howto/unicode.html:
99 # ,,on Windows, Python uses the name "mbcs" to refer
100 # to whatever the currently configured encoding is``
101 encoding = 'mbcs'
102 else:
103 encoding = 'iso-8859-15'
104 logger.info('Assuming encoding: ISO-8859-15 ($LANG not set).')
107 # Filename / folder name sanitization
108 def _sanitize_char(c):
109 if c in string.whitespace:
110 return ' '
111 elif c in ',-.()':
112 return c
113 elif c in string.punctuation or ord(c) <= 31:
114 return '_'
116 return c
118 SANITIZATION_TABLE = ''.join(map(_sanitize_char, map(chr, range(256))))
119 del _sanitize_char
121 _MIME_TYPE_LIST = [
122 ('.aac', 'audio/aac'),
123 ('.axa', 'audio/annodex'),
124 ('.flac', 'audio/flac'),
125 ('.m4b', 'audio/m4b'),
126 ('.m4a', 'audio/mp4'),
127 ('.mp3', 'audio/mpeg'),
128 ('.spx', 'audio/ogg'),
129 ('.oga', 'audio/ogg'),
130 ('.ogg', 'audio/ogg'),
131 ('.wma', 'audio/x-ms-wma'),
132 ('.3gp', 'video/3gpp'),
133 ('.axv', 'video/annodex'),
134 ('.divx', 'video/divx'),
135 ('.m4v', 'video/m4v'),
136 ('.mp4', 'video/mp4'),
137 ('.ogv', 'video/ogg'),
138 ('.mov', 'video/quicktime'),
139 ('.flv', 'video/x-flv'),
140 ('.mkv', 'video/x-matroska'),
141 ('.wmv', 'video/x-ms-wmv'),
142 ('.opus', 'audio/opus'),
145 _MIME_TYPES = dict((k, v) for v, k in _MIME_TYPE_LIST)
146 _MIME_TYPES_EXT = dict(_MIME_TYPE_LIST)
149 def make_directory( path):
151 Tries to create a directory if it does not exist already.
152 Returns True if the directory exists after the function
153 call, False otherwise.
155 if os.path.isdir( path):
156 return True
158 try:
159 os.makedirs( path)
160 except:
161 logger.warn('Could not create directory: %s', path)
162 return False
164 return True
167 def normalize_feed_url(url):
169 Converts any URL to http:// or ftp:// so that it can be
170 used with "wget". If the URL cannot be converted (invalid
171 or unknown scheme), "None" is returned.
173 This will also normalize feed:// and itpc:// to http://.
175 >>> normalize_feed_url('itpc://example.org/podcast.rss')
176 'http://example.org/podcast.rss'
178 If no URL scheme is defined (e.g. "curry.com"), we will
179 simply assume the user intends to add a http:// feed.
181 >>> normalize_feed_url('curry.com')
182 'http://curry.com/'
184 There are even some more shortcuts for advanced users
185 and lazy typists (see the source for details).
187 >>> normalize_feed_url('fb:43FPodcast')
188 'http://feeds.feedburner.com/43FPodcast'
190 It will also take care of converting the domain name to
191 all-lowercase (because domains are not case sensitive):
193 >>> normalize_feed_url('http://Example.COM/')
194 'http://example.com/'
196 Some other minimalistic changes are also taken care of,
197 e.g. a ? with an empty query is removed:
199 >>> normalize_feed_url('http://example.org/test?')
200 'http://example.org/test'
202 if not url or len(url) < 8:
203 return None
205 # This is a list of prefixes that you can use to minimize the amount of
206 # keystrokes that you have to use.
207 # Feel free to suggest other useful prefixes, and I'll add them here.
208 PREFIXES = {
209 'fb:': 'http://feeds.feedburner.com/%s',
210 'yt:': 'http://www.youtube.com/rss/user/%s/videos.rss',
211 'sc:': 'http://soundcloud.com/%s',
212 'fm4od:': 'http://onapp1.orf.at/webcam/fm4/fod/%s.xspf',
213 # YouTube playlists. To get a list of playlists per-user, use:
214 # https://gdata.youtube.com/feeds/api/users/<username>/playlists
215 'ytpl:': 'http://gdata.youtube.com/feeds/api/playlists/%s',
218 for prefix, expansion in PREFIXES.iteritems():
219 if url.startswith(prefix):
220 url = expansion % (url[len(prefix):],)
221 break
223 # Assume HTTP for URLs without scheme
224 if not '://' in url:
225 url = 'http://' + url
227 scheme, netloc, path, query, fragment = urlparse.urlsplit(url)
229 # Schemes and domain names are case insensitive
230 scheme, netloc = scheme.lower(), netloc.lower()
232 # Normalize empty paths to "/"
233 if path == '':
234 path = '/'
236 # feed://, itpc:// and itms:// are really http://
237 if scheme in ('feed', 'itpc', 'itms'):
238 scheme = 'http'
240 if scheme not in ('http', 'https', 'ftp', 'file'):
241 return None
243 # urlunsplit might return "a slighty different, but equivalent URL"
244 return urlparse.urlunsplit((scheme, netloc, path, query, fragment))
247 def username_password_from_url(url):
248 r"""
249 Returns a tuple (username,password) containing authentication
250 data from the specified URL or (None,None) if no authentication
251 data can be found in the URL.
253 See Section 3.1 of RFC 1738 (http://www.ietf.org/rfc/rfc1738.txt)
255 >>> username_password_from_url('https://@host.com/')
256 ('', None)
257 >>> username_password_from_url('telnet://host.com/')
258 (None, None)
259 >>> username_password_from_url('ftp://foo:@host.com/')
260 ('foo', '')
261 >>> username_password_from_url('http://a:b@host.com/')
262 ('a', 'b')
263 >>> username_password_from_url(1)
264 Traceback (most recent call last):
266 ValueError: URL has to be a string or unicode object.
267 >>> username_password_from_url(None)
268 Traceback (most recent call last):
270 ValueError: URL has to be a string or unicode object.
271 >>> username_password_from_url('http://a@b:c@host.com/')
272 ('a@b', 'c')
273 >>> username_password_from_url('ftp://a:b:c@host.com/')
274 ('a', 'b:c')
275 >>> username_password_from_url('http://i%2Fo:P%40ss%3A@host.com/')
276 ('i/o', 'P@ss:')
277 >>> username_password_from_url('ftp://%C3%B6sterreich@host.com/')
278 ('\xc3\xb6sterreich', None)
279 >>> username_password_from_url('http://w%20x:y%20z@example.org/')
280 ('w x', 'y z')
281 >>> username_password_from_url('http://example.com/x@y:z@test.com/')
282 (None, None)
284 if type(url) not in (str, unicode):
285 raise ValueError('URL has to be a string or unicode object.')
287 (username, password) = (None, None)
289 (scheme, netloc, path, params, query, fragment) = urlparse.urlparse(url)
291 if '@' in netloc:
292 (authentication, netloc) = netloc.rsplit('@', 1)
293 if ':' in authentication:
294 (username, password) = authentication.split(':', 1)
296 # RFC1738 dictates that we should not allow ['/', '@', ':']
297 # characters in the username and password field (Section 3.1):
299 # 1. The "/" can't be in there at this point because of the way
300 # urlparse (which we use above) works.
301 # 2. Due to gPodder bug 1521, we allow "@" in the username and
302 # password field. We use netloc.rsplit('@', 1), which will
303 # make sure that we split it at the last '@' in netloc.
304 # 3. The colon must be excluded (RFC2617, Section 2) in the
305 # username, but is apparently allowed in the password. This
306 # is handled by the authentication.split(':', 1) above, and
307 # will cause any extraneous ':'s to be part of the password.
309 username = urllib.unquote(username)
310 password = urllib.unquote(password)
311 else:
312 username = urllib.unquote(authentication)
314 return (username, password)
316 def directory_is_writable(path):
318 Returns True if the specified directory exists and is writable
319 by the current user.
321 return os.path.isdir(path) and os.access(path, os.W_OK)
324 def calculate_size( path):
326 Tries to calculate the size of a directory, including any
327 subdirectories found. The returned value might not be
328 correct if the user doesn't have appropriate permissions
329 to list all subdirectories of the given path.
331 if path is None:
332 return 0L
334 if os.path.dirname( path) == '/':
335 return 0L
337 if os.path.isfile( path):
338 return os.path.getsize( path)
340 if os.path.isdir( path) and not os.path.islink( path):
341 sum = os.path.getsize( path)
343 try:
344 for item in os.listdir(path):
345 try:
346 sum += calculate_size(os.path.join(path, item))
347 except:
348 logger.warn('Cannot get size for %s', path, exc_info=True)
349 except:
350 logger.warn('Cannot access %s', path, exc_info=True)
352 return sum
354 return 0L
357 def file_modification_datetime(filename):
359 Returns the modification date of the specified file
360 as a datetime.datetime object or None if the modification
361 date cannot be determined.
363 if filename is None:
364 return None
366 if not os.access(filename, os.R_OK):
367 return None
369 try:
370 s = os.stat(filename)
371 timestamp = s[stat.ST_MTIME]
372 return datetime.datetime.fromtimestamp(timestamp)
373 except:
374 logger.warn('Cannot get mtime for %s', filename, exc_info=True)
375 return None
378 def file_age_in_days(filename):
380 Returns the age of the specified filename in days or
381 zero if the modification date cannot be determined.
383 dt = file_modification_datetime(filename)
384 if dt is None:
385 return 0
386 else:
387 return (datetime.datetime.now()-dt).days
389 def file_modification_timestamp(filename):
391 Returns the modification date of the specified file as a number
392 or -1 if the modification date cannot be determined.
394 if filename is None:
395 return -1
396 try:
397 s = os.stat(filename)
398 return s[stat.ST_MTIME]
399 except:
400 logger.warn('Cannot get modification timestamp for %s', filename)
401 return -1
404 def file_age_to_string(days):
406 Converts a "number of days" value to a string that
407 can be used in the UI to display the file age.
409 >>> file_age_to_string(0)
411 >>> file_age_to_string(1)
412 u'1 day ago'
413 >>> file_age_to_string(2)
414 u'2 days ago'
416 if days < 1:
417 return ''
418 else:
419 return N_('%(count)d day ago', '%(count)d days ago', days) % {'count':days}
422 def is_system_file(filename):
424 Checks to see if the given file is a system file.
426 if gpodder.ui.win32 and win32file is not None:
427 result = win32file.GetFileAttributes(filename)
428 #-1 is returned by GetFileAttributes when an error occurs
429 #0x4 is the FILE_ATTRIBUTE_SYSTEM constant
430 return result != -1 and result & 0x4 != 0
431 else:
432 return False
435 def get_free_disk_space_win32(path):
437 Win32-specific code to determine the free disk space remaining
438 for a given path. Uses code from:
440 http://mail.python.org/pipermail/python-list/2003-May/203223.html
442 if win32file is None:
443 # Cannot determine free disk space
444 return 0
446 drive, tail = os.path.splitdrive(path)
447 userFree, userTotal, freeOnDisk = win32file.GetDiskFreeSpaceEx(drive)
448 return userFree
451 def get_free_disk_space(path):
453 Calculates the free disk space available to the current user
454 on the file system that contains the given path.
456 If the path (or its parent folder) does not yet exist, this
457 function returns zero.
460 if not os.path.exists(path):
461 return 0
463 if gpodder.ui.win32:
464 return get_free_disk_space_win32(path)
466 s = os.statvfs(path)
468 return s.f_bavail * s.f_bsize
471 def format_date(timestamp):
473 Converts a UNIX timestamp to a date representation. This
474 function returns "Today", "Yesterday", a weekday name or
475 the date in %x format, which (according to the Python docs)
476 is the "Locale's appropriate date representation".
478 Returns None if there has been an error converting the
479 timestamp to a string representation.
481 if timestamp is None:
482 return None
484 seconds_in_a_day = 60*60*24
486 today = time.localtime()[:3]
487 yesterday = time.localtime(time.time() - seconds_in_a_day)[:3]
488 try:
489 timestamp_date = time.localtime(timestamp)[:3]
490 except ValueError, ve:
491 logger.warn('Cannot convert timestamp', exc_info=True)
492 return None
494 if timestamp_date == today:
495 return _('Today')
496 elif timestamp_date == yesterday:
497 return _('Yesterday')
499 try:
500 diff = int( (time.time() - timestamp)/seconds_in_a_day )
501 except:
502 logger.warn('Cannot convert "%s" to date.', timestamp, exc_info=True)
503 return None
505 try:
506 timestamp = datetime.datetime.fromtimestamp(timestamp)
507 except:
508 return None
510 if diff < 7:
511 # Weekday name
512 return str(timestamp.strftime('%A').decode(encoding))
513 else:
514 # Locale's appropriate date representation
515 return str(timestamp.strftime('%x'))
518 def format_filesize(bytesize, use_si_units=False, digits=2):
520 Formats the given size in bytes to be human-readable,
522 Returns a localized "(unknown)" string when the bytesize
523 has a negative value.
525 si_units = (
526 ( 'kB', 10**3 ),
527 ( 'MB', 10**6 ),
528 ( 'GB', 10**9 ),
531 binary_units = (
532 ( 'KiB', 2**10 ),
533 ( 'MiB', 2**20 ),
534 ( 'GiB', 2**30 ),
537 try:
538 bytesize = float( bytesize)
539 except:
540 return _('(unknown)')
542 if bytesize < 0:
543 return _('(unknown)')
545 if use_si_units:
546 units = si_units
547 else:
548 units = binary_units
550 ( used_unit, used_value ) = ( 'B', bytesize )
552 for ( unit, value ) in units:
553 if bytesize >= value:
554 used_value = bytesize / float(value)
555 used_unit = unit
557 return ('%.'+str(digits)+'f %s') % (used_value, used_unit)
560 def delete_file(filename):
561 """Delete a file from the filesystem
563 Errors (permissions errors or file not found)
564 are silently ignored.
566 try:
567 os.remove(filename)
568 except:
569 pass
572 def remove_html_tags(html):
574 Remove HTML tags from a string and replace numeric and
575 named entities with the corresponding character, so the
576 HTML text can be displayed in a simple text view.
578 if html is None:
579 return None
581 # If we would want more speed, we could make these global
582 re_strip_tags = re.compile('<[^>]*>')
583 re_unicode_entities = re.compile('&#(\d{2,4});')
584 re_html_entities = re.compile('&(.{2,8});')
585 re_newline_tags = re.compile('(<br[^>]*>|<[/]?ul[^>]*>|</li>)', re.I)
586 re_listing_tags = re.compile('<li[^>]*>', re.I)
588 result = html
590 # Convert common HTML elements to their text equivalent
591 result = re_newline_tags.sub('\n', result)
592 result = re_listing_tags.sub('\n * ', result)
593 result = re.sub('<[Pp]>', '\n\n', result)
595 # Remove all HTML/XML tags from the string
596 result = re_strip_tags.sub('', result)
598 # Convert numeric XML entities to their unicode character
599 result = re_unicode_entities.sub(lambda x: unichr(int(x.group(1))), result)
601 # Convert named HTML entities to their unicode character
602 result = re_html_entities.sub(lambda x: unicode(entitydefs.get(x.group(1),''), 'iso-8859-1'), result)
604 # Convert more than two newlines to two newlines
605 result = re.sub('([\r\n]{2})([\r\n])+', '\\1', result)
607 return result.strip()
610 def wrong_extension(extension):
612 Determine if a given extension looks like it's
613 wrong (e.g. empty, extremely long or spaces)
615 Returns True if the extension most likely is a
616 wrong one and should be replaced.
618 >>> wrong_extension('.mp3')
619 False
620 >>> wrong_extension('.divx')
621 False
622 >>> wrong_extension('mp3')
623 True
624 >>> wrong_extension('')
625 True
626 >>> wrong_extension('.12 - Everybody')
627 True
628 >>> wrong_extension('.mp3 ')
629 True
630 >>> wrong_extension('.')
631 True
632 >>> wrong_extension('.42')
633 True
635 if not extension:
636 return True
637 elif len(extension) > 5:
638 return True
639 elif ' ' in extension:
640 return True
641 elif extension == '.':
642 return True
643 elif not extension.startswith('.'):
644 return True
645 else:
646 try:
647 # ".<number>" is an invalid extension
648 float(extension)
649 return True
650 except:
651 pass
653 return False
656 def extension_from_mimetype(mimetype):
658 Simply guesses what the file extension should be from the mimetype
660 >>> extension_from_mimetype('audio/mp4')
661 '.m4a'
662 >>> extension_from_mimetype('audio/ogg')
663 '.ogg'
664 >>> extension_from_mimetype('audio/mpeg')
665 '.mp3'
666 >>> extension_from_mimetype('video/x-matroska')
667 '.mkv'
668 >>> extension_from_mimetype('wrong-mimetype')
671 if mimetype in _MIME_TYPES:
672 return _MIME_TYPES[mimetype]
673 return mimetypes.guess_extension(mimetype) or ''
676 def mimetype_from_extension(extension):
678 Simply guesses what the mimetype should be from the file extension
680 >>> mimetype_from_extension('.m4a')
681 'audio/mp4'
682 >>> mimetype_from_extension('.ogg')
683 'audio/ogg'
684 >>> mimetype_from_extension('.mp3')
685 'audio/mpeg'
686 >>> mimetype_from_extension('.mkv')
687 'video/x-matroska'
688 >>> mimetype_from_extension('._invalid_file_extension_')
691 if extension in _MIME_TYPES_EXT:
692 return _MIME_TYPES_EXT[extension]
694 # Need to prepend something to the extension, so guess_type works
695 type, encoding = mimetypes.guess_type('file'+extension)
697 return type or ''
700 def extension_correct_for_mimetype(extension, mimetype):
702 Check if the given filename extension (e.g. ".ogg") is a possible
703 extension for a given mimetype (e.g. "application/ogg") and return
704 a boolean value (True if it's possible, False if not). Also do
706 >>> extension_correct_for_mimetype('.ogg', 'application/ogg')
707 True
708 >>> extension_correct_for_mimetype('.ogv', 'video/ogg')
709 True
710 >>> extension_correct_for_mimetype('.ogg', 'audio/mpeg')
711 False
712 >>> extension_correct_for_mimetype('.m4a', 'audio/mp4')
713 True
714 >>> extension_correct_for_mimetype('mp3', 'audio/mpeg')
715 Traceback (most recent call last):
717 ValueError: "mp3" is not an extension (missing .)
718 >>> extension_correct_for_mimetype('.mp3', 'audio mpeg')
719 Traceback (most recent call last):
721 ValueError: "audio mpeg" is not a mimetype (missing /)
723 if not '/' in mimetype:
724 raise ValueError('"%s" is not a mimetype (missing /)' % mimetype)
725 if not extension.startswith('.'):
726 raise ValueError('"%s" is not an extension (missing .)' % extension)
728 if (extension, mimetype) in _MIME_TYPE_LIST:
729 return True
731 # Create a "default" extension from the mimetype, e.g. "application/ogg"
732 # becomes ".ogg", "audio/mpeg" becomes ".mpeg", etc...
733 default = ['.'+mimetype.split('/')[-1]]
735 return extension in default+mimetypes.guess_all_extensions(mimetype)
738 def filename_from_url(url):
740 Extracts the filename and (lowercase) extension (with dot)
741 from a URL, e.g. http://server.com/file.MP3?download=yes
742 will result in the string ("file", ".mp3") being returned.
744 This function will also try to best-guess the "real"
745 extension for a media file (audio, video) by
746 trying to match an extension to these types and recurse
747 into the query string to find better matches, if the
748 original extension does not resolve to a known type.
750 http://my.net/redirect.php?my.net/file.ogg => ("file", ".ogg")
751 http://server/get.jsp?file=/episode0815.MOV => ("episode0815", ".mov")
752 http://s/redirect.mp4?http://serv2/test.mp4 => ("test", ".mp4")
754 (scheme, netloc, path, para, query, fragid) = urlparse.urlparse(url)
755 (filename, extension) = os.path.splitext(os.path.basename( urllib.unquote(path)))
757 if file_type_by_extension(extension) is not None and not \
758 query.startswith(scheme+'://'):
759 # We have found a valid extension (audio, video)
760 # and the query string doesn't look like a URL
761 return ( filename, extension.lower() )
763 # If the query string looks like a possible URL, try that first
764 if len(query.strip()) > 0 and query.find('/') != -1:
765 query_url = '://'.join((scheme, urllib.unquote(query)))
766 (query_filename, query_extension) = filename_from_url(query_url)
768 if file_type_by_extension(query_extension) is not None:
769 return os.path.splitext(os.path.basename(query_url))
771 # No exact match found, simply return the original filename & extension
772 return ( filename, extension.lower() )
775 def file_type_by_extension(extension):
777 Tries to guess the file type by looking up the filename
778 extension from a table of known file types. Will return
779 "audio", "video" or None.
781 >>> file_type_by_extension('.aif')
782 'audio'
783 >>> file_type_by_extension('.3GP')
784 'video'
785 >>> file_type_by_extension('.m4a')
786 'audio'
787 >>> file_type_by_extension('.txt') is None
788 True
789 >>> file_type_by_extension(None) is None
790 True
791 >>> file_type_by_extension('ogg')
792 Traceback (most recent call last):
794 ValueError: Extension does not start with a dot: ogg
796 if not extension:
797 return None
799 if not extension.startswith('.'):
800 raise ValueError('Extension does not start with a dot: %s' % extension)
802 extension = extension.lower()
804 if extension in _MIME_TYPES_EXT:
805 return _MIME_TYPES_EXT[extension].split('/')[0]
807 # Need to prepend something to the extension, so guess_type works
808 type, encoding = mimetypes.guess_type('file'+extension)
810 if type is not None and '/' in type:
811 filetype, rest = type.split('/', 1)
812 if filetype in ('audio', 'video', 'image'):
813 return filetype
815 return None
818 def get_first_line( s):
820 Returns only the first line of a string, stripped so
821 that it doesn't have whitespace before or after.
823 return s.strip().split('\n')[0].strip()
826 def object_string_formatter(s, **kwargs):
828 Makes attributes of object passed in as keyword
829 arguments available as {OBJECTNAME.ATTRNAME} in
830 the passed-in string and returns a string with
831 the above arguments replaced with the attribute
832 values of the corresponding object.
834 >>> class x: pass
835 >>> a = x()
836 >>> a.title = 'Hello world'
837 >>> object_string_formatter('{episode.title}', episode=a)
838 'Hello world'
840 >>> class x: pass
841 >>> a = x()
842 >>> a.published = 123
843 >>> object_string_formatter('Hi {episode.published} 456', episode=a)
844 'Hi 123 456'
846 result = s
847 for key, o in kwargs.iteritems():
848 matches = re.findall(r'\{%s\.([^\}]+)\}' % key, s)
849 for attr in matches:
850 if hasattr(o, attr):
851 try:
852 from_s = '{%s.%s}' % (key, attr)
853 to_s = str(getattr(o, attr))
854 result = result.replace(from_s, to_s)
855 except:
856 logger.warn('Replace of "%s" failed for "%s".', attr, s)
858 return result
861 def format_desktop_command(command, filenames, start_position=None):
863 Formats a command template from the "Exec=" line of a .desktop
864 file to a string that can be invoked in a shell.
866 Handled format strings: %U, %u, %F, %f and a fallback that
867 appends the filename as first parameter of the command.
869 Also handles non-standard %p which is replaced with the start_position
870 (probably only makes sense if starting a single file). (see bug 1140)
872 See http://standards.freedesktop.org/desktop-entry-spec/1.0/ar01s06.html
874 Returns a list of commands to execute, either one for
875 each filename if the application does not support multiple
876 file names or one for all filenames (%U, %F or unknown).
878 # Replace backslashes with slashes to fix win32 issues
879 # (even on win32, "/" works, but "\" does not)
880 command = command.replace('\\', '/')
882 if start_position is not None:
883 command = command.replace('%p', str(start_position))
885 command = shlex.split(command)
887 command_before = command
888 command_after = []
889 multiple_arguments = True
890 for fieldcode in ('%U', '%F', '%u', '%f'):
891 if fieldcode in command:
892 command_before = command[:command.index(fieldcode)]
893 command_after = command[command.index(fieldcode)+1:]
894 multiple_arguments = fieldcode in ('%U', '%F')
895 break
897 if multiple_arguments:
898 return [command_before + filenames + command_after]
900 commands = []
901 for filename in filenames:
902 commands.append(command_before+[filename]+command_after)
904 return commands
906 def url_strip_authentication(url):
908 Strips authentication data from an URL. Returns the URL with
909 the authentication data removed from it.
911 >>> url_strip_authentication('https://host.com/')
912 'https://host.com/'
913 >>> url_strip_authentication('telnet://foo:bar@host.com/')
914 'telnet://host.com/'
915 >>> url_strip_authentication('ftp://billy@example.org')
916 'ftp://example.org'
917 >>> url_strip_authentication('ftp://billy:@example.org')
918 'ftp://example.org'
919 >>> url_strip_authentication('http://aa:bc@localhost/x')
920 'http://localhost/x'
921 >>> url_strip_authentication('http://i%2Fo:P%40ss%3A@blubb.lan/u.html')
922 'http://blubb.lan/u.html'
923 >>> url_strip_authentication('http://c:d@x.org/')
924 'http://x.org/'
925 >>> url_strip_authentication('http://P%40%3A:i%2F@cx.lan')
926 'http://cx.lan'
927 >>> url_strip_authentication('http://x@x.com:s3cret@example.com/')
928 'http://example.com/'
930 url_parts = list(urlparse.urlsplit(url))
931 # url_parts[1] is the HOST part of the URL
933 # Remove existing authentication data
934 if '@' in url_parts[1]:
935 url_parts[1] = url_parts[1].rsplit('@', 1)[1]
937 return urlparse.urlunsplit(url_parts)
940 def url_add_authentication(url, username, password):
942 Adds authentication data (username, password) to a given
943 URL in order to construct an authenticated URL.
945 >>> url_add_authentication('https://host.com/', '', None)
946 'https://host.com/'
947 >>> url_add_authentication('http://example.org/', None, None)
948 'http://example.org/'
949 >>> url_add_authentication('telnet://host.com/', 'foo', 'bar')
950 'telnet://foo:bar@host.com/'
951 >>> url_add_authentication('ftp://example.org', 'billy', None)
952 'ftp://billy@example.org'
953 >>> url_add_authentication('ftp://example.org', 'billy', '')
954 'ftp://billy:@example.org'
955 >>> url_add_authentication('http://localhost/x', 'aa', 'bc')
956 'http://aa:bc@localhost/x'
957 >>> url_add_authentication('http://blubb.lan/u.html', 'i/o', 'P@ss:')
958 'http://i%2Fo:P@ss:@blubb.lan/u.html'
959 >>> url_add_authentication('http://a:b@x.org/', 'c', 'd')
960 'http://c:d@x.org/'
961 >>> url_add_authentication('http://i%2F:P%40%3A@cx.lan', 'P@x', 'i/')
962 'http://P@x:i%2F@cx.lan'
963 >>> url_add_authentication('http://x.org/', 'a b', 'c d')
964 'http://a%20b:c%20d@x.org/'
966 if username is None or username == '':
967 return url
969 # Relaxations of the strict quoting rules (bug 1521):
970 # 1. Accept '@' in username and password
971 # 2. Acecpt ':' in password only
972 username = urllib.quote(username, safe='@')
974 if password is not None:
975 password = urllib.quote(password, safe='@:')
976 auth_string = ':'.join((username, password))
977 else:
978 auth_string = username
980 url = url_strip_authentication(url)
982 url_parts = list(urlparse.urlsplit(url))
983 # url_parts[1] is the HOST part of the URL
984 url_parts[1] = '@'.join((auth_string, url_parts[1]))
986 return urlparse.urlunsplit(url_parts)
989 def urlopen(url, headers=None, data=None, timeout=None):
991 An URL opener with the User-agent set to gPodder (with version)
993 username, password = username_password_from_url(url)
994 if username is not None or password is not None:
995 url = url_strip_authentication(url)
996 password_mgr = urllib2.HTTPPasswordMgrWithDefaultRealm()
997 password_mgr.add_password(None, url, username, password)
998 handler = urllib2.HTTPBasicAuthHandler(password_mgr)
999 opener = urllib2.build_opener(handler)
1000 else:
1001 opener = urllib2.build_opener()
1003 if headers is None:
1004 headers = {}
1005 else:
1006 headers = dict(headers)
1008 headers.update({'User-agent': gpodder.user_agent})
1009 request = urllib2.Request(url, data=data, headers=headers)
1010 if timeout is None:
1011 return opener.open(request)
1012 else:
1013 return opener.open(request, timeout=timeout)
1015 def get_real_url(url):
1017 Gets the real URL of a file and resolves all redirects.
1019 try:
1020 return urlopen(url).geturl()
1021 except:
1022 logger.error('Getting real url for %s', url, exc_info=True)
1023 return url
1026 def find_command(command):
1028 Searches the system's PATH for a specific command that is
1029 executable by the user. Returns the first occurence of an
1030 executable binary in the PATH, or None if the command is
1031 not available.
1033 On Windows, this also looks for "<command>.bat" and
1034 "<command>.exe" files if "<command>" itself doesn't exist.
1037 if 'PATH' not in os.environ:
1038 return None
1040 for path in os.environ['PATH'].split(os.pathsep):
1041 command_file = os.path.join(path, command)
1042 if gpodder.ui.win32 and not os.path.exists(command_file):
1043 for extension in ('.bat', '.exe'):
1044 cmd = command_file + extension
1045 if os.path.isfile(cmd):
1046 command_file = cmd
1047 break
1048 if os.path.isfile(command_file) and os.access(command_file, os.X_OK):
1049 return command_file
1051 return None
1053 idle_add_handler = None
1055 def idle_add(func, *args):
1056 """Run a function in the main GUI thread
1058 This is a wrapper function that does the Right Thing depending on if we are
1059 running on Gtk+, Qt or CLI.
1061 You should use this function if you are calling from a Python thread and
1062 modify UI data, so that you make sure that the function is called as soon
1063 as possible from the main UI thread.
1065 if gpodder.ui.gtk:
1066 import gobject
1067 gobject.idle_add(func, *args)
1068 elif gpodder.ui.qml:
1069 from PySide.QtCore import Signal, QTimer, QThread, Qt, QObject
1071 class IdleAddHandler(QObject):
1072 signal = Signal(object)
1073 def __init__(self):
1074 QObject.__init__(self)
1076 self.main_thread_id = QThread.currentThreadId()
1078 self.signal.connect(self.run_func)
1080 def run_func(self, func):
1081 assert QThread.currentThreadId() == self.main_thread_id, \
1082 ("Running in %s, not %s"
1083 % (str(QThread.currentThreadId()),
1084 str(self.main_thread_id)))
1085 func()
1087 def idle_add(self, func, *args):
1088 def doit():
1089 try:
1090 func(*args)
1091 except Exception, e:
1092 logger.exception("Running %s%s: %s",
1093 func, str(tuple(args)), str(e))
1095 if QThread.currentThreadId() == self.main_thread_id:
1096 # If we emit the signal in the main thread,
1097 # then the function will be run immediately.
1098 # Instead, use a single shot timer with a 0
1099 # timeout: this will run the function when the
1100 # event loop next iterates.
1101 QTimer.singleShot(0, doit)
1102 else:
1103 self.signal.emit(doit)
1105 global idle_add_handler
1106 if idle_add_handler is None:
1107 idle_add_handler = IdleAddHandler()
1109 idle_add_handler.idle_add(func, *args)
1110 else:
1111 func(*args)
1114 def bluetooth_available():
1116 Returns True or False depending on the availability
1117 of bluetooth functionality on the system.
1119 if find_command('bluetooth-sendto') or \
1120 find_command('gnome-obex-send'):
1121 return True
1122 else:
1123 return False
1126 def bluetooth_send_file(filename):
1128 Sends a file via bluetooth.
1130 This function tries to use "bluetooth-sendto", and if
1131 it is not available, it also tries "gnome-obex-send".
1133 command_line = None
1135 if find_command('bluetooth-sendto'):
1136 command_line = ['bluetooth-sendto']
1137 elif find_command('gnome-obex-send'):
1138 command_line = ['gnome-obex-send']
1140 if command_line is not None:
1141 command_line.append(filename)
1142 return (subprocess.Popen(command_line).wait() == 0)
1143 else:
1144 logger.error('Cannot send file. Please install "bluetooth-sendto" or "gnome-obex-send".')
1145 return False
1148 def format_time(value):
1149 """Format a seconds value to a string
1151 >>> format_time(0)
1152 '00:00'
1153 >>> format_time(20)
1154 '00:20'
1155 >>> format_time(3600)
1156 '01:00:00'
1157 >>> format_time(10921)
1158 '03:02:01'
1160 dt = datetime.datetime.utcfromtimestamp(value)
1161 if dt.hour == 0:
1162 return dt.strftime('%M:%S')
1163 else:
1164 return dt.strftime('%H:%M:%S')
1166 def parse_time(value):
1167 """Parse a time string into seconds
1169 >>> parse_time('00:00')
1171 >>> parse_time('00:00:00')
1173 >>> parse_time('00:20')
1175 >>> parse_time('00:00:20')
1177 >>> parse_time('01:00:00')
1178 3600
1179 >>> parse_time('03:02:01')
1180 10921
1181 >>> parse_time('61:08')
1182 3668
1183 >>> parse_time('25:03:30')
1184 90210
1185 >>> parse_time('25:3:30')
1186 90210
1187 >>> parse_time('61.08')
1188 3668
1190 if value == '':
1191 return 0
1193 if not value:
1194 raise ValueError('Invalid value: %s' % (str(value),))
1196 m = re.match(r'(\d+)[:.](\d\d?)[:.](\d\d?)', value)
1197 if m:
1198 hours, minutes, seconds = m.groups()
1199 return (int(hours) * 60 + int(minutes)) * 60 + int(seconds)
1201 m = re.match(r'(\d+)[:.](\d\d?)', value)
1202 if m:
1203 minutes, seconds = m.groups()
1204 return int(minutes) * 60 + int(seconds)
1206 return int(value)
1209 def format_seconds_to_hour_min_sec(seconds):
1211 Take the number of seconds and format it into a
1212 human-readable string (duration).
1214 >>> format_seconds_to_hour_min_sec(3834)
1215 u'1 hour, 3 minutes and 54 seconds'
1216 >>> format_seconds_to_hour_min_sec(3600)
1217 u'1 hour'
1218 >>> format_seconds_to_hour_min_sec(62)
1219 u'1 minute and 2 seconds'
1222 if seconds < 1:
1223 return N_('%(count)d second', '%(count)d seconds', seconds) % {'count':seconds}
1225 result = []
1227 seconds = int(seconds)
1229 hours = seconds/3600
1230 seconds = seconds%3600
1232 minutes = seconds/60
1233 seconds = seconds%60
1235 if hours:
1236 result.append(N_('%(count)d hour', '%(count)d hours', hours) % {'count':hours})
1238 if minutes:
1239 result.append(N_('%(count)d minute', '%(count)d minutes', minutes) % {'count':minutes})
1241 if seconds:
1242 result.append(N_('%(count)d second', '%(count)d seconds', seconds) % {'count':seconds})
1244 if len(result) > 1:
1245 return (' '+_('and')+' ').join((', '.join(result[:-1]), result[-1]))
1246 else:
1247 return result[0]
1249 def http_request(url, method='HEAD'):
1250 (scheme, netloc, path, parms, qry, fragid) = urlparse.urlparse(url)
1251 conn = httplib.HTTPConnection(netloc)
1252 start = len(scheme) + len('://') + len(netloc)
1253 conn.request(method, url[start:])
1254 return conn.getresponse()
1257 def gui_open(filename):
1259 Open a file or folder with the default application set
1260 by the Desktop environment. This uses "xdg-open" on all
1261 systems with a few exceptions:
1263 on Win32, os.startfile() is used
1265 try:
1266 if gpodder.ui.win32:
1267 os.startfile(filename)
1268 elif gpodder.ui.osx:
1269 subprocess.Popen(['open', filename])
1270 else:
1271 subprocess.Popen(['xdg-open', filename])
1272 return True
1273 except:
1274 logger.error('Cannot open file/folder: "%s"', filename, exc_info=True)
1275 return False
1278 def open_website(url):
1280 Opens the specified URL using the default system web
1281 browser. This uses Python's "webbrowser" module, so
1282 make sure your system is set up correctly.
1284 run_in_background(lambda: webbrowser.open(url))
1286 def convert_bytes(d):
1288 Convert byte strings to unicode strings
1290 This function will decode byte strings into unicode
1291 strings. Any other data types will be left alone.
1293 >>> convert_bytes(None)
1294 >>> convert_bytes(1)
1296 >>> convert_bytes(4711L)
1297 4711L
1298 >>> convert_bytes(True)
1299 True
1300 >>> convert_bytes(3.1415)
1301 3.1415
1302 >>> convert_bytes('Hello')
1303 u'Hello'
1304 >>> convert_bytes(u'Hey')
1305 u'Hey'
1307 if d is None:
1308 return d
1309 if any(isinstance(d, t) for t in (int, long, bool, float)):
1310 return d
1311 elif not isinstance(d, unicode):
1312 return d.decode('utf-8', 'ignore')
1313 return d
1315 def sanitize_encoding(filename):
1316 r"""
1317 Generate a sanitized version of a string (i.e.
1318 remove invalid characters and encode in the
1319 detected native language encoding).
1321 >>> sanitize_encoding('\x80')
1323 >>> sanitize_encoding(u'unicode')
1324 'unicode'
1326 # The encoding problem goes away in Python 3.. hopefully!
1327 if sys.version_info >= (3, 0):
1328 return filename
1330 global encoding
1331 if not isinstance(filename, unicode):
1332 filename = filename.decode(encoding, 'ignore')
1333 return filename.encode(encoding, 'ignore')
1336 def sanitize_filename(filename, max_length=0, use_ascii=False):
1338 Generate a sanitized version of a filename that can
1339 be written on disk (i.e. remove/replace invalid
1340 characters and encode in the native language) and
1341 trim filename if greater than max_length (0 = no limit).
1343 If use_ascii is True, don't encode in the native language,
1344 but use only characters from the ASCII character set.
1346 if not isinstance(filename, unicode):
1347 filename = filename.decode(encoding, 'ignore')
1349 if max_length > 0 and len(filename) > max_length:
1350 logger.info('Limiting file/folder name "%s" to %d characters.',
1351 filename, max_length)
1352 filename = filename[:max_length]
1354 filename = filename.encode('ascii' if use_ascii else encoding, 'ignore')
1355 filename = filename.translate(SANITIZATION_TABLE)
1356 filename = filename.strip('.' + string.whitespace)
1358 return filename
1361 def find_mount_point(directory):
1363 Try to find the mount point for a given directory.
1364 If the directory is itself a mount point, return
1365 it. If not, remove the last part of the path and
1366 re-check if it's a mount point. If the directory
1367 resides on your root filesystem, "/" is returned.
1369 >>> find_mount_point('/')
1372 >>> find_mount_point(u'/something')
1373 Traceback (most recent call last):
1375 ValueError: Convert unicode objects to str first.
1377 >>> find_mount_point(None)
1378 Traceback (most recent call last):
1380 ValueError: Directory names should be of type str.
1382 >>> find_mount_point(42)
1383 Traceback (most recent call last):
1385 ValueError: Directory names should be of type str.
1387 >>> from minimock import mock, restore
1388 >>> mocked_mntpoints = ('/', '/home', '/media/usbdisk', '/media/cdrom')
1389 >>> mock('os.path.ismount', returns_func=lambda x: x in mocked_mntpoints)
1391 >>> # For mocking os.getcwd(), we simply use a lambda to avoid the
1392 >>> # massive output of "Called os.getcwd()" lines in this doctest
1393 >>> os.getcwd = lambda: '/home/thp'
1395 >>> find_mount_point('.')
1396 Called os.path.ismount('/home/thp')
1397 Called os.path.ismount('/home')
1398 '/home'
1399 >>> find_mount_point('relativity')
1400 Called os.path.ismount('/home/thp/relativity')
1401 Called os.path.ismount('/home/thp')
1402 Called os.path.ismount('/home')
1403 '/home'
1404 >>> find_mount_point('/media/usbdisk/')
1405 Called os.path.ismount('/media/usbdisk')
1406 '/media/usbdisk'
1407 >>> find_mount_point('/home/thp/Desktop')
1408 Called os.path.ismount('/home/thp/Desktop')
1409 Called os.path.ismount('/home/thp')
1410 Called os.path.ismount('/home')
1411 '/home'
1412 >>> find_mount_point('/media/usbdisk/Podcasts/With Spaces')
1413 Called os.path.ismount('/media/usbdisk/Podcasts/With Spaces')
1414 Called os.path.ismount('/media/usbdisk/Podcasts')
1415 Called os.path.ismount('/media/usbdisk')
1416 '/media/usbdisk'
1417 >>> find_mount_point('/home/')
1418 Called os.path.ismount('/home')
1419 '/home'
1420 >>> find_mount_point('/media/cdrom/../usbdisk/blubb//')
1421 Called os.path.ismount('/media/usbdisk/blubb')
1422 Called os.path.ismount('/media/usbdisk')
1423 '/media/usbdisk'
1424 >>> restore()
1426 if isinstance(directory, unicode):
1427 # XXX: This is only valid for Python 2 - misleading error in Python 3?
1428 # We do not accept unicode strings, because they could fail when
1429 # trying to be converted to some native encoding, so fail loudly
1430 # and leave it up to the callee to encode into the proper encoding.
1431 raise ValueError('Convert unicode objects to str first.')
1433 if not isinstance(directory, str):
1434 # In Python 2, we assume it's a byte str; in Python 3, we assume
1435 # that it's a unicode str. The abspath/ismount/split functions of
1436 # os.path work with unicode str in Python 3, but not in Python 2.
1437 raise ValueError('Directory names should be of type str.')
1439 directory = os.path.abspath(directory)
1441 while directory != '/':
1442 if os.path.ismount(directory):
1443 return directory
1444 else:
1445 (directory, tail_data) = os.path.split(directory)
1447 return '/'
1450 # matches http:// and ftp:// and mailto://
1451 protocolPattern = re.compile(r'^\w+://')
1453 def isabs(string):
1455 @return true if string is an absolute path or protocoladdress
1456 for addresses beginning in http:// or ftp:// or ldap:// -
1457 they are considered "absolute" paths.
1458 Source: http://code.activestate.com/recipes/208993/
1460 if protocolPattern.match(string): return 1
1461 return os.path.isabs(string)
1464 def commonpath(l1, l2, common=[]):
1466 helper functions for relpath
1467 Source: http://code.activestate.com/recipes/208993/
1469 if len(l1) < 1: return (common, l1, l2)
1470 if len(l2) < 1: return (common, l1, l2)
1471 if l1[0] != l2[0]: return (common, l1, l2)
1472 return commonpath(l1[1:], l2[1:], common+[l1[0]])
1474 def relpath(p1, p2):
1476 Finds relative path from p1 to p2
1477 Source: http://code.activestate.com/recipes/208993/
1479 pathsplit = lambda s: s.split(os.path.sep)
1481 (common,l1,l2) = commonpath(pathsplit(p1), pathsplit(p2))
1482 p = []
1483 if len(l1) > 0:
1484 p = [ ('..'+os.sep) * len(l1) ]
1485 p = p + l2
1486 if len(p) is 0:
1487 return "."
1489 return os.path.join(*p)
1492 def get_hostname():
1493 """Return the hostname of this computer
1495 This can be implemented in a different way on each
1496 platform and should yield a unique-per-user device ID.
1498 nodename = platform.node()
1500 if nodename:
1501 return nodename
1503 # Fallback - but can this give us "localhost"?
1504 return socket.gethostname()
1506 def detect_device_type():
1507 """Device type detection for gpodder.net
1509 This function tries to detect on which
1510 kind of device gPodder is running on.
1512 Possible return values:
1513 desktop, laptop, mobile, server, other
1515 if gpodder.ui.harmattan or gpodder.ui.sailfish:
1516 return 'mobile'
1517 elif glob.glob('/proc/acpi/battery/*'):
1518 # Linux: If we have a battery, assume Laptop
1519 return 'laptop'
1521 return 'desktop'
1524 def write_m3u_playlist(m3u_filename, episodes, extm3u=True):
1525 """Create an M3U playlist from a episode list
1527 If the parameter "extm3u" is False, the list of
1528 episodes should be a list of filenames, and no
1529 extended information will be written into the
1530 M3U files (#EXTM3U / #EXTINF).
1532 If the parameter "extm3u" is True (default), then the
1533 list of episodes should be PodcastEpisode objects,
1534 as the extended metadata will be taken from them.
1536 f = open(m3u_filename, 'w')
1538 if extm3u:
1539 # Mandatory header for extended playlists
1540 f.write('#EXTM3U\n')
1542 for episode in episodes:
1543 if not extm3u:
1544 # Episode objects are strings that contain file names
1545 f.write(episode+'\n')
1546 continue
1548 if episode.was_downloaded(and_exists=True):
1549 filename = episode.local_filename(create=False)
1550 assert filename is not None
1552 if os.path.dirname(filename).startswith(os.path.dirname(m3u_filename)):
1553 filename = filename[len(os.path.dirname(m3u_filename)+os.sep):]
1554 f.write('#EXTINF:0,'+episode.playlist_title()+'\n')
1555 f.write(filename+'\n')
1557 f.close()
1560 def generate_names(filename):
1561 basename, ext = os.path.splitext(filename)
1562 for i in itertools.count():
1563 if i:
1564 yield '%s (%d)%s' % (basename, i+1, ext)
1565 else:
1566 yield filename
1569 def is_known_redirecter(url):
1570 """Check if a URL redirect is expected, and no filenames should be updated
1572 We usually honor URL redirects, and update filenames accordingly.
1573 In some cases (e.g. Soundcloud) this results in a worse filename,
1574 so we hardcode and detect these cases here to avoid renaming files
1575 for which we know that a "known good default" exists.
1577 The problem here is that by comparing the currently-assigned filename
1578 with the new filename determined by the URL, we cannot really determine
1579 which one is the "better" URL (e.g. "n5rMSpXrqmR9.128.mp3" for Soundcloud).
1582 # Soundcloud-hosted media downloads (we take the track name as filename)
1583 if url.startswith('http://ak-media.soundcloud.com/'):
1584 return True
1586 return False
1589 def atomic_rename(old_name, new_name):
1590 """Atomically rename/move a (temporary) file
1592 This is usually used when updating a file safely by writing
1593 the new contents into a temporary file and then moving the
1594 temporary file over the original file to replace it.
1596 if gpodder.ui.win32:
1597 # Win32 does not support atomic rename with os.rename
1598 shutil.move(old_name, new_name)
1599 else:
1600 os.rename(old_name, new_name)
1603 def check_command(self, cmd):
1604 """Check if a command line command/program exists"""
1605 # Prior to Python 2.7.3, this module (shlex) did not support Unicode input.
1606 cmd = sanitize_encoding(cmd)
1607 program = shlex.split(cmd)[0]
1608 return (find_command(program) is not None)
1611 def rename_episode_file(episode, filename):
1612 """Helper method to update a PodcastEpisode object
1614 Useful after renaming/converting its download file.
1616 if not os.path.exists(filename):
1617 raise ValueError('Target filename does not exist.')
1619 basename, extension = os.path.splitext(filename)
1621 episode.download_filename = os.path.basename(filename)
1622 episode.file_size = os.path.getsize(filename)
1623 episode.mime_type = mimetype_from_extension(extension)
1624 episode.save()
1625 episode.db.commit()
1628 def get_update_info(url='http://gpodder.org/downloads'):
1630 Get up to date release information from gpodder.org.
1632 Returns a tuple: (up_to_date, latest_version, release_date, days_since)
1634 Example result (up to date version, 20 days after release):
1635 (True, '3.0.4', '2012-01-24', 20)
1637 Example result (outdated version, 10 days after release):
1638 (False, '3.0.5', '2012-02-29', 10)
1640 data = urlopen(url).read()
1641 id_field_re = re.compile(r'<([a-z]*)[^>]*id="([^"]*)"[^>]*>([^<]*)</\1>')
1642 info = dict((m.group(2), m.group(3)) for m in id_field_re.finditer(data))
1644 latest_version = info['latest-version']
1645 release_date = info['release-date']
1647 release_parsed = datetime.datetime.strptime(release_date, '%Y-%m-%d')
1648 days_since_release = (datetime.datetime.today() - release_parsed).days
1650 convert = lambda s: tuple(int(x) for x in s.split('.'))
1651 up_to_date = (convert(gpodder.__version__) >= convert(latest_version))
1653 return up_to_date, latest_version, release_date, days_since_release
1656 def run_in_background(function, daemon=False):
1657 logger.debug('run_in_background: %s (%s)', function, str(daemon))
1658 thread = threading.Thread(target=function)
1659 thread.setDaemon(daemon)
1660 thread.start()
1661 return thread
1664 def linux_get_active_interfaces():
1665 """Get active network interfaces using 'ip link'
1667 Returns a list of active network interfaces or an
1668 empty list if the device is offline. The loopback
1669 interface is not included.
1671 process = subprocess.Popen(['ip', 'link'], stdout=subprocess.PIPE)
1672 data, _ = process.communicate()
1673 for interface, _ in re.findall(r'\d+: ([^:]+):.*state (UP|UNKNOWN)', data):
1674 if interface != 'lo':
1675 yield interface
1678 def osx_get_active_interfaces():
1679 """Get active network interfaces using 'ifconfig'
1681 Returns a list of active network interfaces or an
1682 empty list if the device is offline. The loopback
1683 interface is not included.
1685 process = subprocess.Popen(['ifconfig'], stdout=subprocess.PIPE)
1686 stdout, _ = process.communicate()
1687 for i in re.split('\n(?!\t)', stdout, re.MULTILINE):
1688 b = re.match('(\\w+):.*status: active$', i, re.MULTILINE | re.DOTALL)
1689 if b:
1690 yield b.group(1)
1692 def unix_get_active_interfaces():
1693 """Get active network interfaces using 'ifconfig'
1695 Returns a list of active network interfaces or an
1696 empty list if the device is offline. The loopback
1697 interface is not included.
1699 process = subprocess.Popen(['ifconfig'], stdout=subprocess.PIPE)
1700 stdout, _ = process.communicate()
1701 for i in re.split('\n(?!\t)', stdout, re.MULTILINE):
1702 b = re.match('(\\w+):.*status: active$', i, re.MULTILINE | re.DOTALL)
1703 if b:
1704 yield b.group(1)
1707 def connection_available():
1708 """Check if an Internet connection is available
1710 Returns True if a connection is available (or if there
1711 is no way to determine the connection). Returns False
1712 if no network interfaces are up (i.e. no connectivity).
1714 try:
1715 if gpodder.ui.win32:
1716 # FIXME: Implement for Windows
1717 return True
1718 elif gpodder.ui.osx:
1719 return len(list(osx_get_active_interfaces())) > 0
1720 else:
1721 # By default, we assume we're not offline (bug 1730)
1722 offline = False
1724 if find_command('ifconfig') is not None:
1725 # If ifconfig is available, and it says we don't have
1726 # any active interfaces, assume we're offline
1727 if len(list(unix_get_active_interfaces())) == 0:
1728 offline = True
1730 # If we assume we're offline, try the "ip" command as fallback
1731 if offline and find_command('ip') is not None:
1732 if len(list(linux_get_active_interfaces())) == 0:
1733 offline = True
1734 else:
1735 offline = False
1737 return not offline
1739 return False
1740 except Exception, e:
1741 logger.warn('Cannot get connection status: %s', e, exc_info=True)
1742 # When we can't determine the connection status, act as if we're online (bug 1730)
1743 return True
1746 def website_reachable(url):
1748 Check if a specific website is available.
1750 if not connection_available():
1751 # No network interfaces up - assume website not reachable
1752 return (False, None)
1754 try:
1755 response = urllib2.urlopen(url, timeout=1)
1756 return (True, response)
1757 except urllib2.URLError as err:
1758 pass
1760 return (False, None)