Updated Norwegian Bokmål translation
[gpodder.git] / src / gpodder / util.py
blob5f39b6f86ab3771d52333ecf33a487f5ca4ecb9e
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2010 Thomas Perl and the gPodder Team
6 # gPodder is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 3 of the License, or
9 # (at your option) any later version.
11 # gPodder is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
21 # util.py -- Misc utility functions
22 # Thomas Perl <thp@perli.net> 2007-08-04
25 """Miscellaneous helper functions for gPodder
27 This module provides helper and utility functions for gPodder that
28 are not tied to any specific part of gPodder.
30 """
32 import gpodder
33 from gpodder.liblogger import log
35 import os
36 import os.path
37 import platform
38 import glob
39 import stat
40 import shlex
41 import socket
43 import re
44 import subprocess
45 from htmlentitydefs import entitydefs
46 import time
47 import gzip
48 import datetime
49 import threading
51 import urlparse
52 import urllib
53 import urllib2
54 import httplib
55 import webbrowser
56 import mimetypes
58 import feedparser
60 import StringIO
61 import xml.dom.minidom
63 _ = gpodder.gettext
64 N_ = gpodder.ngettext
67 # Try to detect OS encoding (by Leonid Ponomarev)
68 if gpodder.ui.maemo:
69 encoding = 'utf8'
70 else:
71 encoding = 'iso-8859-15'
73 if 'LANG' in os.environ and '.' in os.environ['LANG']:
74 lang = os.environ['LANG']
75 (language, encoding) = lang.rsplit('.', 1)
76 log('Detected encoding: %s', encoding)
77 enc = encoding
78 else:
79 # Using iso-8859-15 here as (hopefully) sane default
80 # see http://en.wikipedia.org/wiki/ISO/IEC_8859-1
81 log('Using ISO-8859-15 as encoding. If this')
82 log('is incorrect, please set your $LANG variable.')
85 # Used by file_type_by_extension()
86 _BUILTIN_FILE_TYPES = None
89 def make_directory( path):
90 """
91 Tries to create a directory if it does not exist already.
92 Returns True if the directory exists after the function
93 call, False otherwise.
94 """
95 if os.path.isdir( path):
96 return True
98 try:
99 os.makedirs( path)
100 except:
101 log( 'Could not create directory: %s', path)
102 return False
104 return True
107 def normalize_feed_url(url):
109 Converts any URL to http:// or ftp:// so that it can be
110 used with "wget". If the URL cannot be converted (invalid
111 or unknown scheme), "None" is returned.
113 This will also normalize feed:// and itpc:// to http://
114 Also supported are phobos.apple.com links (iTunes podcast)
115 and itms:// links (iTunes podcast direct link).
117 >>> normalize_feed_url('itpc://example.org/podcast.rss')
118 'http://example.org/podcast.rss'
120 If no URL scheme is defined (e.g. "curry.com"), we will
121 simply assume the user intends to add a http:// feed.
123 >>> normalize_feed_url('curry.com')
124 'http://curry.com'
126 There are even some more shortcuts for advanced users
127 and lazy typists (see the source for details).
129 >>> normalize_feed_url('fb:43FPodcast')
130 'http://feeds.feedburner.com/43FPodcast'
132 if not url or len(url) < 8:
133 return None
135 # This is a list of prefixes that you can use to minimize the amount of
136 # keystrokes that you have to use.
137 # Feel free to suggest other useful prefixes, and I'll add them here.
138 PREFIXES = {
139 'fb:': 'http://feeds.feedburner.com/%s',
140 'yt:': 'http://www.youtube.com/rss/user/%s/videos.rss',
141 'sc:': 'http://soundcloud.com/%s',
144 for prefix, expansion in PREFIXES.iteritems():
145 if url.startswith(prefix):
146 url = expansion % (url[len(prefix):],)
147 break
149 # Assume HTTP for URLs without scheme
150 if not '://' in url:
151 url = 'http://' + url
153 # The scheme of the URL should be all-lowercase
154 (scheme, rest) = url.split('://', 1)
155 scheme = scheme.lower()
157 # Remember to parse iTunes XML for itms:// URLs
158 do_parse_itunes_xml = (scheme == 'itms')
160 # feed://, itpc:// and itms:// are really http://
161 if scheme in ('feed', 'itpc', 'itms'):
162 scheme = 'http'
164 # Re-assemble our URL
165 url = scheme + '://' + rest
167 # If we had an itms:// URL, parse XML
168 if do_parse_itunes_xml:
169 url = parse_itunes_xml(url)
171 # Links to "phobos.apple.com"
172 url = itunes_discover_rss(url)
174 if scheme in ('http', 'https', 'ftp'):
175 return url
177 return None
180 def username_password_from_url(url):
181 r"""
182 Returns a tuple (username,password) containing authentication
183 data from the specified URL or (None,None) if no authentication
184 data can be found in the URL.
186 See Section 3.1 of RFC 1738 (http://www.ietf.org/rfc/rfc1738.txt)
188 >>> username_password_from_url('https://@host.com/')
189 ('', None)
190 >>> username_password_from_url('telnet://host.com/')
191 (None, None)
192 >>> username_password_from_url('ftp://foo:@host.com/')
193 ('foo', '')
194 >>> username_password_from_url('http://a:b@host.com/')
195 ('a', 'b')
196 >>> username_password_from_url(1)
197 Traceback (most recent call last):
199 ValueError: URL has to be a string or unicode object.
200 >>> username_password_from_url(None)
201 Traceback (most recent call last):
203 ValueError: URL has to be a string or unicode object.
204 >>> username_password_from_url('http://a@b:c@host.com/')
205 Traceback (most recent call last):
207 ValueError: "@" must be encoded for username/password (RFC1738).
208 >>> username_password_from_url('ftp://a:b:c@host.com/')
209 Traceback (most recent call last):
211 ValueError: ":" must be encoded for username/password (RFC1738).
212 >>> username_password_from_url('http://i%2Fo:P%40ss%3A@host.com/')
213 ('i/o', 'P@ss:')
214 >>> username_password_from_url('ftp://%C3%B6sterreich@host.com/')
215 ('\xc3\xb6sterreich', None)
216 >>> username_password_from_url('http://w%20x:y%20z@example.org/')
217 ('w x', 'y z')
219 if type(url) not in (str, unicode):
220 raise ValueError('URL has to be a string or unicode object.')
222 (username, password) = (None, None)
224 (scheme, netloc, path, params, query, fragment) = urlparse.urlparse(url)
226 if '@' in netloc:
227 (authentication, netloc) = netloc.rsplit('@', 1)
228 if ':' in authentication:
229 (username, password) = authentication.split(':', 1)
230 # RFC1738 dictates that we should not allow these unquoted
231 # characters in the username and password field (Section 3.1).
232 for c in (':', '@', '/'):
233 if c in username or c in password:
234 raise ValueError('"%c" must be encoded for username/password (RFC1738).' % c)
235 username = urllib.unquote(username)
236 password = urllib.unquote(password)
237 else:
238 username = urllib.unquote(authentication)
240 return (username, password)
243 def directory_is_writable( path):
245 Returns True if the specified directory exists and is writable
246 by the current user.
248 return os.path.isdir( path) and os.access( path, os.W_OK)
251 def calculate_size( path):
253 Tries to calculate the size of a directory, including any
254 subdirectories found. The returned value might not be
255 correct if the user doesn't have appropriate permissions
256 to list all subdirectories of the given path.
258 if path is None:
259 return 0L
261 if os.path.dirname( path) == '/':
262 return 0L
264 if os.path.isfile( path):
265 return os.path.getsize( path)
267 if os.path.isdir( path) and not os.path.islink( path):
268 sum = os.path.getsize( path)
270 try:
271 for item in os.listdir(path):
272 try:
273 sum += calculate_size(os.path.join(path, item))
274 except:
275 log('Cannot get size for %s', path)
276 except:
277 log('Cannot access: %s', path)
279 return sum
281 return 0L
284 def file_modification_datetime(filename):
286 Returns the modification date of the specified file
287 as a datetime.datetime object or None if the modification
288 date cannot be determined.
290 if filename is None:
291 return None
293 if not os.access(filename, os.R_OK):
294 return None
296 try:
297 s = os.stat(filename)
298 timestamp = s[stat.ST_MTIME]
299 return datetime.datetime.fromtimestamp(timestamp)
300 except:
301 log('Cannot get modification timestamp for %s', filename)
302 return None
305 def file_modification_timestamp(filename):
307 Returns the modification date of the specified file as a number
308 or -1 if the modification date cannot be determined.
310 if filename is None:
311 return -1
312 try:
313 s = os.stat(filename)
314 return s[stat.ST_MTIME]
315 except:
316 log('Cannot get modification timestamp for %s', filename)
317 return -1
320 def file_age_in_days(filename):
322 Returns the age of the specified filename in days or
323 zero if the modification date cannot be determined.
325 dt = file_modification_datetime(filename)
326 if dt is None:
327 return 0
328 else:
329 return (datetime.datetime.now()-dt).days
332 def file_age_to_string(days):
334 Converts a "number of days" value to a string that
335 can be used in the UI to display the file age.
337 >>> file_age_to_string(0)
339 >>> file_age_to_string(1)
340 u'1 day ago'
341 >>> file_age_to_string(2)
342 u'2 days ago'
344 if days < 1:
345 return ''
346 else:
347 return N_('%d day ago', '%d days ago', days) % days
350 def get_free_disk_space_win32(path):
352 Win32-specific code to determine the free disk space remaining
353 for a given path. Uses code from:
355 http://mail.python.org/pipermail/python-list/2003-May/203223.html
358 drive, tail = os.path.splitdrive(path)
360 try:
361 import win32file
362 userFree, userTotal, freeOnDisk = win32file.GetDiskFreeSpaceEx(drive)
363 return userFree
364 except ImportError:
365 log('Warning: Running on Win32 but win32api/win32file not installed.')
367 # Cannot determine free disk space
368 return 0
371 def get_free_disk_space(path):
373 Calculates the free disk space available to the current user
374 on the file system that contains the given path.
376 If the path (or its parent folder) does not yet exist, this
377 function returns zero.
380 if not os.path.exists(path):
381 return 0
383 if gpodder.win32:
384 return get_free_disk_space_win32(path)
386 s = os.statvfs(path)
388 return s.f_bavail * s.f_bsize
391 def format_date(timestamp):
393 Converts a UNIX timestamp to a date representation. This
394 function returns "Today", "Yesterday", a weekday name or
395 the date in %x format, which (according to the Python docs)
396 is the "Locale's appropriate date representation".
398 Returns None if there has been an error converting the
399 timestamp to a string representation.
401 if timestamp is None:
402 return None
404 seconds_in_a_day = 60*60*24
406 today = time.localtime()[:3]
407 yesterday = time.localtime(time.time() - seconds_in_a_day)[:3]
408 try:
409 timestamp_date = time.localtime(timestamp)[:3]
410 except ValueError, ve:
411 log('Warning: Cannot convert timestamp', traceback=True)
412 return None
414 if timestamp_date == today:
415 return _('Today')
416 elif timestamp_date == yesterday:
417 return _('Yesterday')
419 try:
420 diff = int( (time.time() - timestamp)/seconds_in_a_day )
421 except:
422 log('Warning: Cannot convert "%s" to date.', timestamp, traceback=True)
423 return None
425 try:
426 timestamp = datetime.datetime.fromtimestamp(timestamp)
427 except:
428 return None
430 if diff < 7:
431 # Weekday name
432 return str(timestamp.strftime('%A'))
433 else:
434 # Locale's appropriate date representation
435 return str(timestamp.strftime('%x'))
438 def format_filesize(bytesize, use_si_units=False, digits=2):
440 Formats the given size in bytes to be human-readable,
442 Returns a localized "(unknown)" string when the bytesize
443 has a negative value.
445 si_units = (
446 ( 'kB', 10**3 ),
447 ( 'MB', 10**6 ),
448 ( 'GB', 10**9 ),
451 binary_units = (
452 ( 'KiB', 2**10 ),
453 ( 'MiB', 2**20 ),
454 ( 'GiB', 2**30 ),
457 try:
458 bytesize = float( bytesize)
459 except:
460 return _('(unknown)')
462 if bytesize < 0:
463 return _('(unknown)')
465 if use_si_units:
466 units = si_units
467 else:
468 units = binary_units
470 ( used_unit, used_value ) = ( 'B', bytesize )
472 for ( unit, value ) in units:
473 if bytesize >= value:
474 used_value = bytesize / float(value)
475 used_unit = unit
477 return ('%.'+str(digits)+'f %s') % (used_value, used_unit)
480 def delete_file(filename):
481 """Delete a file from the filesystem
483 Errors (permissions errors or file not found)
484 are silently ignored.
486 try:
487 os.remove(filename)
488 except:
489 pass
492 def remove_html_tags(html):
494 Remove HTML tags from a string and replace numeric and
495 named entities with the corresponding character, so the
496 HTML text can be displayed in a simple text view.
498 # If we would want more speed, we could make these global
499 re_strip_tags = re.compile('<[^>]*>')
500 re_unicode_entities = re.compile('&#(\d{2,4});')
501 re_html_entities = re.compile('&(.{2,8});')
502 re_newline_tags = re.compile('(<br[^>]*>|<[/]?ul[^>]*>|</li>)', re.I)
503 re_listing_tags = re.compile('<li[^>]*>', re.I)
505 result = html
507 # Convert common HTML elements to their text equivalent
508 result = re_newline_tags.sub('\n', result)
509 result = re_listing_tags.sub('\n * ', result)
510 result = re.sub('<[Pp]>', '\n\n', result)
512 # Remove all HTML/XML tags from the string
513 result = re_strip_tags.sub('', result)
515 # Convert numeric XML entities to their unicode character
516 result = re_unicode_entities.sub(lambda x: unichr(int(x.group(1))), result)
518 # Convert named HTML entities to their unicode character
519 result = re_html_entities.sub(lambda x: unicode(entitydefs.get(x.group(1),''), 'iso-8859-1'), result)
521 # Convert more than two newlines to two newlines
522 result = re.sub('([\r\n]{2})([\r\n])+', '\\1', result)
524 return result.strip()
527 def wrong_extension(extension):
529 Determine if a given extension looks like it's
530 wrong (e.g. empty, extremely long or spaces)
532 Returns True if the extension most likely is a
533 wrong one and should be replaced.
535 >>> wrong_extension('.mp3')
536 False
537 >>> wrong_extension('.divx')
538 False
539 >>> wrong_extension('mp3')
540 True
541 >>> wrong_extension('')
542 True
543 >>> wrong_extension('.12 - Everybody')
544 True
545 >>> wrong_extension('.mp3 ')
546 True
547 >>> wrong_extension('.')
548 True
549 >>> wrong_extension('.42')
550 True
552 if not extension:
553 return True
554 elif len(extension) > 5:
555 return True
556 elif ' ' in extension:
557 return True
558 elif extension == '.':
559 return True
560 elif not extension.startswith('.'):
561 return True
562 else:
563 try:
564 # ".<number>" is an invalid extension
565 float(extension)
566 return True
567 except:
568 pass
570 return False
573 def extension_from_mimetype(mimetype):
575 Simply guesses what the file extension should be from the mimetype
577 MIMETYPE_EXTENSIONS = {
578 # This is required for YouTube downloads on Maemo 5
579 'video/x-flv': '.flv',
580 'video/mp4': '.mp4',
582 if mimetype in MIMETYPE_EXTENSIONS:
583 return MIMETYPE_EXTENSIONS[mimetype]
584 return mimetypes.guess_extension(mimetype) or ''
587 def extension_correct_for_mimetype(extension, mimetype):
589 Check if the given filename extension (e.g. ".ogg") is a possible
590 extension for a given mimetype (e.g. "application/ogg") and return
591 a boolean value (True if it's possible, False if not). Also do
593 >>> extension_correct_for_mimetype('.ogg', 'application/ogg')
594 True
595 >>> extension_correct_for_mimetype('.ogv', 'video/ogg')
596 True
597 >>> extension_correct_for_mimetype('.ogg', 'audio/mpeg')
598 False
599 >>> extension_correct_for_mimetype('mp3', 'audio/mpeg')
600 Traceback (most recent call last):
602 ValueError: "mp3" is not an extension (missing .)
603 >>> extension_correct_for_mimetype('.mp3', 'audio mpeg')
604 Traceback (most recent call last):
606 ValueError: "audio mpeg" is not a mimetype (missing /)
608 if not '/' in mimetype:
609 raise ValueError('"%s" is not a mimetype (missing /)' % mimetype)
610 if not extension.startswith('.'):
611 raise ValueError('"%s" is not an extension (missing .)' % extension)
613 # Create a "default" extension from the mimetype, e.g. "application/ogg"
614 # becomes ".ogg", "audio/mpeg" becomes ".mpeg", etc...
615 default = ['.'+mimetype.split('/')[-1]]
617 return extension in default+mimetypes.guess_all_extensions(mimetype)
620 def filename_from_url(url):
622 Extracts the filename and (lowercase) extension (with dot)
623 from a URL, e.g. http://server.com/file.MP3?download=yes
624 will result in the string ("file", ".mp3") being returned.
626 This function will also try to best-guess the "real"
627 extension for a media file (audio, video) by
628 trying to match an extension to these types and recurse
629 into the query string to find better matches, if the
630 original extension does not resolve to a known type.
632 http://my.net/redirect.php?my.net/file.ogg => ("file", ".ogg")
633 http://server/get.jsp?file=/episode0815.MOV => ("episode0815", ".mov")
634 http://s/redirect.mp4?http://serv2/test.mp4 => ("test", ".mp4")
636 (scheme, netloc, path, para, query, fragid) = urlparse.urlparse(url)
637 (filename, extension) = os.path.splitext(os.path.basename( urllib.unquote(path)))
639 if file_type_by_extension(extension) is not None and not \
640 query.startswith(scheme+'://'):
641 # We have found a valid extension (audio, video)
642 # and the query string doesn't look like a URL
643 return ( filename, extension.lower() )
645 # If the query string looks like a possible URL, try that first
646 if len(query.strip()) > 0 and query.find('/') != -1:
647 query_url = '://'.join((scheme, urllib.unquote(query)))
648 (query_filename, query_extension) = filename_from_url(query_url)
650 if file_type_by_extension(query_extension) is not None:
651 return os.path.splitext(os.path.basename(query_url))
653 # No exact match found, simply return the original filename & extension
654 return ( filename, extension.lower() )
657 def file_type_by_extension(extension):
659 Tries to guess the file type by looking up the filename
660 extension from a table of known file types. Will return
661 "audio", "video" or None.
663 >>> file_type_by_extension('.aif')
664 'audio'
665 >>> file_type_by_extension('.3GP')
666 'video'
667 >>> file_type_by_extension('.txt') is None
668 True
669 >>> file_type_by_extension(None) is None
670 True
671 >>> file_type_by_extension('ogg')
672 Traceback (most recent call last):
674 ValueError: Extension does not start with a dot: ogg
676 if not extension:
677 return None
679 if not extension.startswith('.'):
680 raise ValueError('Extension does not start with a dot: %s' % extension)
682 global _BUILTIN_FILE_TYPES
683 if _BUILTIN_FILE_TYPES is None:
684 # List all types that are not in the default mimetypes.types_map
685 # (even if they might be detected by mimetypes.guess_type)
686 # For OGG, see http://wiki.xiph.org/MIME_Types_and_File_Extensions
687 audio_types = ('.ogg', '.oga', '.spx', '.flac', '.axa', \
688 '.aac', '.m4a', '.m4b', '.wma')
689 video_types = ('.ogv', '.axv', '.mp4', \
690 '.mkv', '.m4v', '.divx', '.flv', '.wmv', '.3gp')
691 _BUILTIN_FILE_TYPES = {}
692 _BUILTIN_FILE_TYPES.update((ext, 'audio') for ext in audio_types)
693 _BUILTIN_FILE_TYPES.update((ext, 'video') for ext in video_types)
695 extension = extension.lower()
697 if extension in _BUILTIN_FILE_TYPES:
698 return _BUILTIN_FILE_TYPES[extension]
700 # Need to prepend something to the extension, so guess_type works
701 type, encoding = mimetypes.guess_type('file'+extension)
703 if type is not None and '/' in type:
704 filetype, rest = type.split('/', 1)
705 if filetype in ('audio', 'video', 'image'):
706 return filetype
708 return None
711 def get_first_line( s):
713 Returns only the first line of a string, stripped so
714 that it doesn't have whitespace before or after.
716 return s.strip().split('\n')[0].strip()
719 def object_string_formatter( s, **kwargs):
721 Makes attributes of object passed in as keyword
722 arguments available as {OBJECTNAME.ATTRNAME} in
723 the passed-in string and returns a string with
724 the above arguments replaced with the attribute
725 values of the corresponding object.
727 Example:
729 e = Episode()
730 e.title = 'Hello'
731 s = '{episode.title} World'
733 print object_string_formatter( s, episode = e)
734 => 'Hello World'
736 result = s
737 for ( key, o ) in kwargs.items():
738 matches = re.findall( r'\{%s\.([^\}]+)\}' % key, s)
739 for attr in matches:
740 if hasattr( o, attr):
741 try:
742 from_s = '{%s.%s}' % ( key, attr )
743 to_s = getattr( o, attr)
744 result = result.replace( from_s, to_s)
745 except:
746 log( 'Could not replace attribute "%s" in string "%s".', attr, s)
748 return result
751 def format_desktop_command(command, filenames):
753 Formats a command template from the "Exec=" line of a .desktop
754 file to a string that can be invoked in a shell.
756 Handled format strings: %U, %u, %F, %f and a fallback that
757 appends the filename as first parameter of the command.
759 See http://standards.freedesktop.org/desktop-entry-spec/1.0/ar01s06.html
761 Returns a list of commands to execute, either one for
762 each filename if the application does not support multiple
763 file names or one for all filenames (%U, %F or unknown).
765 command = shlex.split(command)
767 command_before = command
768 command_after = []
769 multiple_arguments = True
770 for fieldcode in ('%U', '%F', '%u', '%f'):
771 if fieldcode in command:
772 command_before = command[:command.index(fieldcode)]
773 command_after = command[command.index(fieldcode)+1:]
774 multiple_arguments = fieldcode in ('%U', '%F')
775 break
777 if multiple_arguments:
778 return [command_before + filenames + command_after]
780 commands = []
781 for filename in filenames:
782 commands.append(command_before+[filename]+command_after)
784 return commands
786 def url_strip_authentication(url):
788 Strips authentication data from an URL. Returns the URL with
789 the authentication data removed from it.
791 >>> url_strip_authentication('https://host.com/')
792 'https://host.com/'
793 >>> url_strip_authentication('telnet://foo:bar@host.com/')
794 'telnet://host.com/'
795 >>> url_strip_authentication('ftp://billy@example.org')
796 'ftp://example.org'
797 >>> url_strip_authentication('ftp://billy:@example.org')
798 'ftp://example.org'
799 >>> url_strip_authentication('http://aa:bc@localhost/x')
800 'http://localhost/x'
801 >>> url_strip_authentication('http://i%2Fo:P%40ss%3A@blubb.lan/u.html')
802 'http://blubb.lan/u.html'
803 >>> url_strip_authentication('http://c:d@x.org/')
804 'http://x.org/'
805 >>> url_strip_authentication('http://P%40%3A:i%2F@cx.lan')
806 'http://cx.lan'
808 url_parts = list(urlparse.urlsplit(url))
809 # url_parts[1] is the HOST part of the URL
811 # Remove existing authentication data
812 if '@' in url_parts[1]:
813 url_parts[1] = url_parts[1].split('@', 2)[1]
815 return urlparse.urlunsplit(url_parts)
818 def url_add_authentication(url, username, password):
820 Adds authentication data (username, password) to a given
821 URL in order to construct an authenticated URL.
823 >>> url_add_authentication('https://host.com/', '', None)
824 'https://host.com/'
825 >>> url_add_authentication('http://example.org/', None, None)
826 'http://example.org/'
827 >>> url_add_authentication('telnet://host.com/', 'foo', 'bar')
828 'telnet://foo:bar@host.com/'
829 >>> url_add_authentication('ftp://example.org', 'billy', None)
830 'ftp://billy@example.org'
831 >>> url_add_authentication('ftp://example.org', 'billy', '')
832 'ftp://billy:@example.org'
833 >>> url_add_authentication('http://localhost/x', 'aa', 'bc')
834 'http://aa:bc@localhost/x'
835 >>> url_add_authentication('http://blubb.lan/u.html', 'i/o', 'P@ss:')
836 'http://i%2Fo:P%40ss%3A@blubb.lan/u.html'
837 >>> url_add_authentication('http://a:b@x.org/', 'c', 'd')
838 'http://c:d@x.org/'
839 >>> url_add_authentication('http://i%2F:P%40%3A@cx.lan', 'P@:', 'i/')
840 'http://P%40%3A:i%2F@cx.lan'
841 >>> url_add_authentication('http://x.org/', 'a b', 'c d')
842 'http://a%20b:c%20d@x.org/'
844 if username is None or username == '':
845 return url
847 username = urllib.quote(username, safe='')
849 if password is not None:
850 password = urllib.quote(password, safe='')
851 auth_string = ':'.join((username, password))
852 else:
853 auth_string = username
855 url = url_strip_authentication(url)
857 url_parts = list(urlparse.urlsplit(url))
858 # url_parts[1] is the HOST part of the URL
859 url_parts[1] = '@'.join((auth_string, url_parts[1]))
861 return urlparse.urlunsplit(url_parts)
864 def urlopen(url):
866 An URL opener with the User-agent set to gPodder (with version)
868 username, password = username_password_from_url(url)
869 if username is not None or password is not None:
870 url = url_strip_authentication(url)
871 password_mgr = urllib2.HTTPPasswordMgrWithDefaultRealm()
872 password_mgr.add_password(None, url, username, password)
873 handler = urllib2.HTTPBasicAuthHandler(password_mgr)
874 opener = urllib2.build_opener(handler)
875 else:
876 opener = urllib2.build_opener()
878 headers = {'User-agent': gpodder.user_agent}
879 request = urllib2.Request(url, headers=headers)
880 return opener.open(request)
882 def get_real_url(url):
884 Gets the real URL of a file and resolves all redirects.
886 try:
887 return urlopen(url).geturl()
888 except:
889 log('Error getting real url for %s', url, traceback=True)
890 return url
893 def find_command( command):
895 Searches the system's PATH for a specific command that is
896 executable by the user. Returns the first occurence of an
897 executable binary in the PATH, or None if the command is
898 not available.
901 if 'PATH' not in os.environ:
902 return None
904 for path in os.environ['PATH'].split( os.pathsep):
905 command_file = os.path.join( path, command)
906 if os.path.isfile( command_file) and os.access( command_file, os.X_OK):
907 return command_file
909 return None
912 def parse_itunes_xml(url):
914 Parses an XML document in the "url" parameter (this has to be
915 a itms:// or http:// URL to a XML doc) and searches all "<dict>"
916 elements for the first occurence of a "<key>feedURL</key>"
917 element and then continues the search for the string value of
918 this key.
920 This returns the RSS feed URL for Apple iTunes Podcast XML
921 documents that are retrieved by itunes_discover_rss().
923 url = url.replace('itms://', 'http://')
924 doc = http_get_and_gunzip(url)
925 try:
926 d = xml.dom.minidom.parseString(doc)
927 except Exception, e:
928 log('Error parsing document from itms:// URL: %s', e)
929 return None
930 last_key = None
931 for pairs in d.getElementsByTagName('dict'):
932 for node in pairs.childNodes:
933 if node.nodeType != node.ELEMENT_NODE:
934 continue
936 if node.tagName == 'key' and node.childNodes.length > 0:
937 if node.firstChild.nodeType == node.TEXT_NODE:
938 last_key = node.firstChild.data
940 if last_key != 'feedURL':
941 continue
943 if node.tagName == 'string' and node.childNodes.length > 0:
944 if node.firstChild.nodeType == node.TEXT_NODE:
945 return node.firstChild.data
947 return None
950 def http_get_and_gunzip(uri):
952 Does a HTTP GET request and tells the server that we accept
953 gzip-encoded data. This is necessary, because the Apple iTunes
954 server will always return gzip-encoded data, regardless of what
955 we really request.
957 Returns the uncompressed document at the given URI.
959 request = urllib2.Request(uri)
960 request.add_header("Accept-encoding", "gzip")
961 usock = urllib2.urlopen(request)
962 data = usock.read()
963 if usock.headers.get('content-encoding', None) == 'gzip':
964 data = gzip.GzipFile(fileobj=StringIO.StringIO(data)).read()
965 return data
968 def itunes_discover_rss(url):
970 Takes an iTunes-specific podcast URL and turns it
971 into a "normal" RSS feed URL. If the given URL is
972 not a phobos.apple.com URL, we will simply return
973 the URL and assume it's already an RSS feed URL.
975 Idea from Andrew Clarke's itunes-url-decoder.py
978 if url is None:
979 return url
981 if not 'phobos.apple.com' in url.lower():
982 # This doesn't look like an iTunes URL
983 return url
985 try:
986 data = http_get_and_gunzip(url)
987 (url,) = re.findall("itmsOpen\('([^']*)", data)
988 return parse_itunes_xml(url)
989 except:
990 return None
993 def idle_add(func, *args):
995 This is a wrapper function that does the Right
996 Thing depending on if we are running a GTK+ GUI or
997 not. If not, we're simply calling the function.
999 If we are a GUI app, we use gobject.idle_add() to
1000 call the function later - this is needed for
1001 threads to be able to modify GTK+ widget data.
1003 if gpodder.ui.desktop or gpodder.ui.maemo:
1004 import gobject
1005 def x(f, *a):
1006 f(*a)
1007 return False
1009 gobject.idle_add(func, *args)
1010 else:
1011 func(*args)
1014 def bluetooth_available():
1016 Returns True or False depending on the availability
1017 of bluetooth functionality on the system.
1019 if find_command('bluetooth-sendto') or \
1020 find_command('gnome-obex-send'):
1021 return True
1022 else:
1023 return False
1026 def bluetooth_send_file(filename):
1028 Sends a file via bluetooth.
1030 This function tries to use "bluetooth-sendto", and if
1031 it is not available, it also tries "gnome-obex-send".
1033 command_line = None
1035 if find_command('bluetooth-sendto'):
1036 command_line = ['bluetooth-sendto']
1037 elif find_command('gnome-obex-send'):
1038 command_line = ['gnome-obex-send']
1040 if command_line is not None:
1041 command_line.append(filename)
1042 return (subprocess.Popen(command_line).wait() == 0)
1043 else:
1044 log('Cannot send file. Please install "bluetooth-sendto" or "gnome-obex-send".')
1045 return False
1048 def format_time(value):
1049 """Format a seconds value to a string
1051 >>> format_time(0)
1052 '00:00'
1053 >>> format_time(20)
1054 '00:20'
1055 >>> format_time(3600)
1056 '01:00:00'
1057 >>> format_time(10921)
1058 '03:02:01'
1060 dt = datetime.datetime.utcfromtimestamp(value)
1061 if dt.hour == 0:
1062 return dt.strftime('%M:%S')
1063 else:
1064 return dt.strftime('%H:%M:%S')
1067 def parse_time(value):
1068 """Parse a time string into seconds
1069 >>> parse_time('00:00')
1071 >>> parse_time('00:00:00')
1073 >>> parse_time('00:20')
1075 >>> parse_time('00:00:20')
1077 >>> parse_time('01:00:00')
1078 3600
1079 >>> parse_time('03:02:01')
1080 10921
1082 if not value:
1083 raise ValueError('Invalid value: %s' % (str(value),))
1085 for format in ('%H:%M:%S', '%M:%S'):
1086 try:
1087 t = time.strptime(value, format)
1088 return (t.tm_hour * 60 + t.tm_min) * 60 + t.tm_sec
1089 except ValueError, ve:
1090 continue
1092 return int(value)
1095 def format_seconds_to_hour_min_sec(seconds):
1097 Take the number of seconds and format it into a
1098 human-readable string (duration).
1100 >>> format_seconds_to_hour_min_sec(3834)
1101 u'1 hour, 3 minutes and 54 seconds'
1102 >>> format_seconds_to_hour_min_sec(3600)
1103 u'1 hour'
1104 >>> format_seconds_to_hour_min_sec(62)
1105 u'1 minute and 2 seconds'
1108 if seconds < 1:
1109 return N_('%d second', '%d seconds', seconds) % seconds
1111 result = []
1113 seconds = int(seconds)
1115 hours = seconds/3600
1116 seconds = seconds%3600
1118 minutes = seconds/60
1119 seconds = seconds%60
1121 if hours:
1122 result.append(N_('%d hour', '%d hours', hours) % hours)
1124 if minutes:
1125 result.append(N_('%d minute', '%d minutes', minutes) % minutes)
1127 if seconds:
1128 result.append(N_('%d second', '%d seconds', seconds) % seconds)
1130 if len(result) > 1:
1131 return (' '+_('and')+' ').join((', '.join(result[:-1]), result[-1]))
1132 else:
1133 return result[0]
1135 def http_request(url, method='HEAD'):
1136 (scheme, netloc, path, parms, qry, fragid) = urlparse.urlparse(url)
1137 conn = httplib.HTTPConnection(netloc)
1138 start = len(scheme) + len('://') + len(netloc)
1139 conn.request(method, url[start:])
1140 return conn.getresponse()
1142 def get_episode_info_from_url(url):
1144 Try to get information about a podcast episode by sending
1145 a HEAD request to the HTTP server and parsing the result.
1147 The return value is a dict containing all fields that
1148 could be parsed from the URL. This currently contains:
1150 "length": The size of the file in bytes
1151 "pubdate": The unix timestamp for the pubdate
1153 If there is an error, this function returns {}. This will
1154 only function with http:// and https:// URLs.
1156 if not (url.startswith('http://') or url.startswith('https://')):
1157 return {}
1159 r = http_request(url)
1160 result = {}
1162 log('Trying to get metainfo for %s', url)
1164 if 'content-length' in r.msg:
1165 try:
1166 length = int(r.msg['content-length'])
1167 result['length'] = length
1168 except ValueError, e:
1169 log('Error converting content-length header.')
1171 if 'last-modified' in r.msg:
1172 try:
1173 parsed_date = feedparser._parse_date(r.msg['last-modified'])
1174 pubdate = time.mktime(parsed_date)
1175 result['pubdate'] = pubdate
1176 except:
1177 log('Error converting last-modified header.')
1179 return result
1182 def gui_open(filename):
1184 Open a file or folder with the default application set
1185 by the Desktop environment. This uses "xdg-open" on all
1186 systems with a few exceptions:
1188 on Win32, os.startfile() is used
1189 on Maemo, osso is used to communicate with Nokia Media Player
1191 try:
1192 if gpodder.ui.maemo:
1193 try:
1194 import osso
1195 except ImportError, ie:
1196 log('Cannot import osso module on maemo.')
1197 return False
1199 log('Using Nokia Media Player to open %s', filename)
1200 context = osso.Context('gPodder', gpodder.__version__, False)
1201 filename = filename.encode('utf-8')
1203 # Fix for Maemo bug 7162 (for local files with "#" in filename)
1204 if filename.startswith('/'):
1205 filename = 'file://' + urllib.quote(filename)
1207 rpc = osso.Rpc(context)
1208 app = 'mediaplayer'
1210 _unneeded, extension = os.path.splitext(filename.lower())
1212 # Fix for Maemo bug 5588 (use PDF viewer and images app)
1213 if extension == '.pdf':
1214 app = 'osso_pdfviewer'
1215 elif extension in ('.jpg', '.jpeg', '.png'):
1216 app = 'image_viewer'
1218 svc, path = (x % app for x in ('com.nokia.%s', '/com/nokia/%s'))
1219 rpc.rpc_run(svc, path, svc, 'mime_open', (filename,))
1220 elif gpodder.win32:
1221 os.startfile(filename)
1222 else:
1223 subprocess.Popen(['xdg-open', filename])
1224 return True
1225 except:
1226 log('Cannot open file/folder: "%s"', filename, traceback=True)
1227 return False
1230 def open_website(url):
1232 Opens the specified URL using the default system web
1233 browser. This uses Python's "webbrowser" module, so
1234 make sure your system is set up correctly.
1236 if gpodder.ui.maemo:
1237 import osso
1238 context = osso.Context('gPodder', gpodder.__version__, False)
1239 rpc = osso.Rpc(context)
1240 rpc.rpc_run_with_defaults('osso_browser', \
1241 'open_new_window', \
1242 (url,))
1243 else:
1244 threading.Thread(target=webbrowser.open, args=(url,)).start()
1246 def sanitize_encoding(filename):
1247 r"""
1248 Generate a sanitized version of a string (i.e.
1249 remove invalid characters and encode in the
1250 detected native language encoding).
1252 >>> sanitize_encoding('\x80')
1254 >>> sanitize_encoding(u'unicode')
1255 'unicode'
1257 global encoding
1258 if not isinstance(filename, unicode):
1259 filename = filename.decode(encoding, 'ignore')
1260 return filename.encode(encoding, 'ignore')
1263 def sanitize_filename(filename, max_length=0, use_ascii=False):
1265 Generate a sanitized version of a filename that can
1266 be written on disk (i.e. remove/replace invalid
1267 characters and encode in the native language) and
1268 trim filename if greater than max_length (0 = no limit).
1270 If use_ascii is True, don't encode in the native language,
1271 but use only characters from the ASCII character set.
1273 global encoding
1274 if use_ascii:
1275 e = 'ascii'
1276 else:
1277 e = encoding
1279 if not isinstance(filename, unicode):
1280 filename = filename.decode(encoding, 'ignore')
1282 if max_length > 0 and len(filename) > max_length:
1283 log('Limiting file/folder name "%s" to %d characters.', filename, max_length)
1284 filename = filename[:max_length]
1286 return re.sub('[/|?*<>:+\[\]\"\\\]', '_', filename.strip().encode(e, 'ignore'))
1289 def find_mount_point(directory):
1291 Try to find the mount point for a given directory.
1292 If the directory is itself a mount point, return
1293 it. If not, remove the last part of the path and
1294 re-check if it's a mount point. If the directory
1295 resides on your root filesystem, "/" is returned.
1297 >>> find_mount_point('/')
1300 >>> find_mount_point(u'/something')
1301 Traceback (most recent call last):
1303 ValueError: Convert unicode objects to str first.
1305 >>> find_mount_point(None)
1306 Traceback (most recent call last):
1308 ValueError: Directory names should be of type str.
1310 >>> find_mount_point(42)
1311 Traceback (most recent call last):
1313 ValueError: Directory names should be of type str.
1315 >>> from minimock import mock, restore
1316 >>> mocked_mntpoints = ('/', '/home', '/media/usbdisk', '/media/cdrom')
1317 >>> mock('os.path.ismount', returns_func=lambda x: x in mocked_mntpoints)
1319 >>> # For mocking os.getcwd(), we simply use a lambda to avoid the
1320 >>> # massive output of "Called os.getcwd()" lines in this doctest
1321 >>> os.getcwd = lambda: '/home/thp'
1323 >>> find_mount_point('.')
1324 Called os.path.ismount('/home/thp')
1325 Called os.path.ismount('/home')
1326 '/home'
1327 >>> find_mount_point('relativity')
1328 Called os.path.ismount('/home/thp/relativity')
1329 Called os.path.ismount('/home/thp')
1330 Called os.path.ismount('/home')
1331 '/home'
1332 >>> find_mount_point('/media/usbdisk/')
1333 Called os.path.ismount('/media/usbdisk')
1334 '/media/usbdisk'
1335 >>> find_mount_point('/home/thp/Desktop')
1336 Called os.path.ismount('/home/thp/Desktop')
1337 Called os.path.ismount('/home/thp')
1338 Called os.path.ismount('/home')
1339 '/home'
1340 >>> find_mount_point('/media/usbdisk/Podcasts/With Spaces')
1341 Called os.path.ismount('/media/usbdisk/Podcasts/With Spaces')
1342 Called os.path.ismount('/media/usbdisk/Podcasts')
1343 Called os.path.ismount('/media/usbdisk')
1344 '/media/usbdisk'
1345 >>> find_mount_point('/home/')
1346 Called os.path.ismount('/home')
1347 '/home'
1348 >>> find_mount_point('/media/cdrom/../usbdisk/blubb//')
1349 Called os.path.ismount('/media/usbdisk/blubb')
1350 Called os.path.ismount('/media/usbdisk')
1351 '/media/usbdisk'
1352 >>> restore()
1354 if isinstance(directory, unicode):
1355 # We do not accept unicode strings, because they could fail when
1356 # trying to be converted to some native encoding, so fail loudly
1357 # and leave it up to the callee to encode into the proper encoding.
1358 raise ValueError('Convert unicode objects to str first.')
1360 if not isinstance(directory, str):
1361 raise ValueError('Directory names should be of type str.')
1363 directory = os.path.abspath(directory)
1365 while directory != '/':
1366 if os.path.ismount(directory):
1367 return directory
1368 else:
1369 (directory, tail_data) = os.path.split(directory)
1371 return '/'
1374 # matches http:// and ftp:// and mailto://
1375 protocolPattern = re.compile(r'^\w+://')
1377 def isabs(string):
1379 @return true if string is an absolute path or protocoladdress
1380 for addresses beginning in http:// or ftp:// or ldap:// -
1381 they are considered "absolute" paths.
1382 Source: http://code.activestate.com/recipes/208993/
1384 if protocolPattern.match(string): return 1
1385 return os.path.isabs(string)
1387 def rel2abs(path, base = os.curdir):
1388 """ converts a relative path to an absolute path.
1390 @param path the path to convert - if already absolute, is returned
1391 without conversion.
1392 @param base - optional. Defaults to the current directory.
1393 The base is intelligently concatenated to the given relative path.
1394 @return the relative path of path from base
1395 Source: http://code.activestate.com/recipes/208993/
1397 if isabs(path): return path
1398 retval = os.path.join(base,path)
1399 return os.path.abspath(retval)
1401 def commonpath(l1, l2, common=[]):
1403 helper functions for relpath
1404 Source: http://code.activestate.com/recipes/208993/
1406 if len(l1) < 1: return (common, l1, l2)
1407 if len(l2) < 1: return (common, l1, l2)
1408 if l1[0] != l2[0]: return (common, l1, l2)
1409 return commonpath(l1[1:], l2[1:], common+[l1[0]])
1411 def relpath(p1, p2):
1413 Finds relative path from p1 to p2
1414 Source: http://code.activestate.com/recipes/208993/
1416 pathsplit = lambda s: s.split(os.path.sep)
1418 (common,l1,l2) = commonpath(pathsplit(p1), pathsplit(p2))
1419 p = []
1420 if len(l1) > 0:
1421 p = [ ('..'+os.sep) * len(l1) ]
1422 p = p + l2
1423 if len(p) is 0:
1424 return "."
1426 return os.path.join(*p)
1429 def run_external_command(command_line):
1431 This is the function that will be called in a separate
1432 thread that will call an external command (specified by
1433 command_line). In case of problem (i.e. the command has
1434 not been found or there has been another error), we will
1435 call the notification function with two arguments - the
1436 first being the error message and the second being the
1437 title to be used for the error message.
1439 >>> from minimock import mock, Mock, restore
1440 >>> mock('subprocess.Popen', returns=Mock('subprocess.Popen'))
1441 >>> run_external_command('testprogramm')
1442 Called subprocess.Popen('testprogramm', shell=True)
1443 Called subprocess.Popen.wait()
1444 >>> restore()
1447 def open_process(command_line):
1448 log('Running external command: %s', command_line)
1449 p = subprocess.Popen(command_line, shell=True)
1450 result = p.wait()
1451 if result == 127:
1452 log('Command not found: %s', command_line)
1453 elif result == 126:
1454 log('Command permission denied: %s', command_line)
1455 elif result > 0:
1456 log('Command returned an error (%d): %s', result, command_line)
1457 else:
1458 log('Command finished successfully: %s', command_line)
1460 threading.Thread(target=open_process, args=(command_line,)).start()
1462 def get_hostname():
1463 """Return the hostname of this computer
1465 This can be implemented in a different way on each
1466 platform and should yield a unique-per-user device ID.
1468 nodename = platform.node()
1470 if nodename:
1471 return nodename
1473 # Fallback - but can this give us "localhost"?
1474 return socket.gethostname()
1476 def detect_device_type():
1477 """Device type detection for gpodder.net
1479 This function tries to detect on which
1480 kind of device gPodder is running on.
1482 Possible return values:
1483 desktop, laptop, mobile, server, other
1485 if gpodder.ui.maemo:
1486 return 'mobile'
1487 elif glob.glob('/proc/acpi/battery/*'):
1488 # Linux: If we have a battery, assume Laptop
1489 return 'laptop'
1491 return 'desktop'