[SCM] Packaging for googlecl branch, upstream, updated. upstream/0.9.3-6-ge2cd940

Tom Miller tom.h.miller at gmail.com
Wed Jul 28 21:14:11 UTC 2010


The following commit has been merged in the upstream branch:
commit e2cd9400c8acbb34f1530e4bb9e8e645c7293472
Author: Tom Miller <tom.h.miller at gmail.com>
Date:   Fri Jul 23 22:43:01 2010 -0400

    Imported Upstream version 0.9.9

diff --git a/PKG-INFO b/PKG-INFO
index 4921961..c97f383 100644
--- a/PKG-INFO
+++ b/PKG-INFO
@@ -1,6 +1,6 @@
 Metadata-Version: 1.0
 Name: googlecl
-Version: 0.9.8
+Version: 0.9.9
 Summary: Use (some) Google services from the command line
 Home-page: http://code.google.com/p/googlecl
 Author: Tom H. Miller
diff --git a/README.config b/README.config
index bbd8e66..2b98064 100644
--- a/README.config
+++ b/README.config
@@ -29,6 +29,7 @@ xxx_format: [<extension>], The extension to use for a type of document. The type
 xxx_editor: [<editor>], The editor to use for a type of document. The types of document are the same as the xxx_format option, plus pdf_editor in case you have a pdf editor.
 format: [<extension>], The extension to use by default if the document type is not defined by an xxx_format option.
 editor: [<editor>], The editor to use by default if the document type is not defined by an xxx_editor option. If this is not defined, will use the EDITOR environment variable instead.
+decode_utf_8: [True,False], When you retrieve docs from the server, you can have GoogleCL try to decode them from UTF-8 immediately. Most users will not need to worry about this, but it's handy if you have an application sensitive to unicode characters such as `less` or `tex`.
 
 2.4 GENERAL
 url_style: [site, direct], Which sub-style to use for listing urls. Site will typically put you at the website, while direct is usually a link directly to the resource.
diff --git a/README.txt b/README.txt
index afadffd..b57957c 100644
--- a/README.txt
+++ b/README.txt
@@ -38,7 +38,7 @@ For installation instructions, see INSTALL.txt
 For help with the configuration file, see README.config
 
 1.1 README style
-Wiki markup, is used occasionally in this document:
+Wiki markup is used occasionally in this document:
   * The '`' character is marks example commands.
   * '*' denotes an entry in a list (such as this one).
 
@@ -68,6 +68,7 @@ Tasks:
 Common options:
   * cal: Specify the name of the calendar. This can be a regular expression. If this option is not given, the primary calendar is used.
   * date: Specify a date, or date range. Dates are inclusive, so `--date 2010-06-23,2010-06-25` will include the 23rd, 24th, and 25th of June.
+  * reminder: (for add task only) Add a reminder to the events being added, one per default reminder type in your calendar settings. Default is in minutes, though you can say something like "2h" for one hour, "1d" for one day, etc.
 
 Tasks:
   * add: Add event to calendar. `add "Dinner party with George tomorrow at 6pm"`
@@ -99,6 +100,9 @@ Tasks:
   * upload: Upload documents. `upload the_bobs.csv ~/work/docs_to_share`
 
 2.1.5 Picasa
+Common options:
+  * owner: Owner of the albums you want to deal with. For example, to download bob's album, add --owner bob to the "get" task. To post to your friend's album that she shared with you, add --owner your_friend to the "post" task.
+
 Tasks:
   * create: Create an album. `create --title "Summer Vacation 2009" --tags Vermont ~/photos/vacation2009/*`
   * delete: Delete photos or albums. `delete --title "Stupid album"`
@@ -120,8 +124,13 @@ The list task can be given additional arguments to specify what exactly is being
 `$ google <service> list style1,style2,style3 --delimiter ": "`
 
 will output those styles, in that order, with ": " as a delimiter. Valid values for `<`style1`>` etc. are (with common services in parentheses):
+  * 'address' - postal addresses. (Contacts)
   * 'author' - author(s). (Blogger)
+	* 'company' - company name. (Contacts)
   * 'email' - email address(es). (Contacts)
+	* 'im' - instant messanger handles. (Contacts)
+	* 'notes' - notes on a contact. (Contacts)
+	* 'phone' - phone numbers. (Contacts)
   * 'summary' - summary text.
   * 'title' or 'name' - displayed title or name.
   * 'url' - treated as 'url-direct' or 'url-site' depending on setting in preferences file.
diff --git a/changelog b/changelog
index be38be0..fe2ce7f 100644
--- a/changelog
+++ b/changelog
@@ -1,3 +1,18 @@
+version 0.9.9
+ Enhancements
+ * Enabled video upload and download for Picasa.
+ * Added reminders for Calendar via --reminder option.
+ * Added --owner option to specify other accounts to interact with (e.g. Picasa collaborative albums, listing videos from other YouTube accounts).
+ * Added list styles for contacts.
+ * Allow docs edit task to create folders.
+ * Included workaround for downloading new-version documents.
+ * Added debug, verbose, and quiet flag options.
+ * Added configuration file option decode_utf_8 to automatically decode downloaded Google Docs.
+
+ Bugfixes
+ * YouTube tasks can retrieve more than 50 results from queries.
+ * Googlemail domains can authenticate properly.
+
 version 0.9.8
  Enhancements
  * Authorization for Apps users occurs properly.
diff --git a/man/google.1 b/man/google.1
index ca51b97..d692183 100644
--- a/man/google.1
+++ b/man/google.1
@@ -1,5 +1,5 @@
 .\" DO NOT MODIFY THIS FILE!  It was generated by help2man 1.36.
-.TH GOOGLE "1" "June 2010" "google 0.9.8" "User Commands"
+.TH GOOGLE "1" "July 2010" "google 0.9.9" "User Commands"
 .SH NAME
 google \- command-line access to (some) Google services
 .SH SYNOPSIS
@@ -11,9 +11,9 @@ Called without a service name, it starts an interactive session.
 .PP
 Available tasks for service picasa: 'get', 'create', 'list', 'list\-albums', 'tag', 'post', 'delete'
 .IP
-get: Download photos
+get: Download albums
 .IP
-Requires: none Optional: title, query Arguments: LOCATION
+Requires: none Optional: title, owner, format Arguments: LOCATION
 .IP
 create: Create an album
 .IP
@@ -21,19 +21,19 @@ Requires: title Optional: date, summary, tags Arguments: PATH_TO_PHOTOS
 .IP
 list: List photos
 .IP
-Requires: delimiter Optional: title, query
+Requires: delimiter Optional: title, query, owner
 .IP
 list\-albums: List albums
 .IP
-Requires: delimiter Optional: title
+Requires: delimiter Optional: title, owner
 .IP
 tag: Tag photos
 .IP
-Requires: tags AND (title OR query)
+Requires: tags AND (title OR query) Optional: owner
 .IP
 post: Post photos to an album
 .IP
-Requires: title Optional: tags Arguments: PATH_TO_PHOTOS
+Requires: title Optional: tags, owner Arguments: PATH_TO_PHOTOS
 .IP
 delete: Delete photos or albums
 .IP
@@ -49,9 +49,9 @@ tag: Label posts
 .IP
 Requires: tags AND title Optional: blog
 .IP
-list: List posts in your blog
+list: List posts in a blog
 .IP
-Requires: delimiter Optional: blog, title
+Requires: delimiter Optional: blog, title, owner
 .IP
 delete: Delete a post.
 .IP
@@ -69,7 +69,7 @@ Requires: devkey AND title AND (category OR tags)
 .IP
 list: List videos by user.
 .IP
-Requires: delimiter Optional: title
+Requires: delimiter Optional: title, owner
 .IP
 delete: Delete videos.
 .IP
@@ -173,6 +173,9 @@ of the album  Calendar only \- date of the event to add
 / look for.  Can also specify a range with a comma:
 "YYYY\-MM\-DD", events between date and future. "YYYYMM\-DD,YYYY\-MM\-DD" events between two dates.
 .TP
+\fB\-\-debug\fR
+Enable all debugging output, including HTTP data
+.TP
 \fB\-\-delimiter\fR=\fIDELIMITER\fR
 Specify a delimiter for the output of the list task.
 .TP
@@ -189,6 +192,9 @@ in.
 \fB\-\-format\fR=\fIFORMAT\fR
 Docs only \- format to download documents as.
 .TP
+\fB\-\-hostid\fR=\fIHOSTID\fR
+Label the machine being used.
+.TP
 \fB\-n\fR TITLE, \fB\-\-title\fR=\fITITLE\fR
 Title of the item
 .TP
@@ -196,10 +202,22 @@ Title of the item
 Google Apps Premier only \- do not convert the file on
 upload. (Else converts to native Google Docs format)
 .TP
+\fB\-o\fR OWNER, \fB\-\-owner\fR=\fIOWNER\fR
+Username or ID of the owner of the resource. For
+example, 'picasa list\-albums \fB\-o\fR bob' to list bob's
+albums
+.TP
 \fB\-q\fR QUERY, \fB\-\-query\fR=\fIQUERY\fR
 Full text query string for specifying items. Searches
 on titles, captions, and tags.
 .TP
+\fB\-\-quiet\fR
+Print only prompts and error messages
+.TP
+\fB\-\-reminder\fR=\fIREMINDER\fR
+Calendar only \- specify time for added event's
+reminder, e.g. "10m", "3h", "1d"
+.TP
 \fB\-s\fR SUMMARY, \fB\-\-summary\fR=\fISUMMARY\fR
 Description of the upload, or file containing the
 description.
@@ -208,10 +226,10 @@ description.
 Tags for item, e.g. "Sunsets, Earth Day"
 .TP
 \fB\-u\fR USER, \fB\-\-user\fR=\fIUSER\fR
-Username to use for the task. Exact application is
-task\-dependent. If authentication is necessary, this
-will force the user to specify a password through a
-command line prompt or option.
+Username to log in with for the service.
+.TP
+\fB\-v\fR, \fB\-\-verbose\fR
+Print all messages.
 .SH EXAMPLES
 .nf
 google blogger post \-\-title 'foo' 'command line posting'
diff --git a/setup.py b/setup.py
index 705c61f..417c512 100644
--- a/setup.py
+++ b/setup.py
@@ -31,7 +31,7 @@ blog, uploading files to Picasa, or editing a Google Docs file."""
 
 
 setup(name="googlecl",
-      version="0.9.8",
+      version="0.9.9",
       description="Use (some) Google services from the command line",
       author="Tom H. Miller",
       author_email="tom.h.miller at gmail.com",
diff --git a/src/google b/src/google
index cf5875c..8c4f6cb 100755
--- a/src/google
+++ b/src/google
@@ -44,16 +44,18 @@ Some terminology in use:
 from __future__ import with_statement
 
 __author__ = 'tom.h.miller at gmail.com (Tom Miller)'
+import logging
 import optparse
 import os
 import urllib
 import sys
 import googlecl
 
-VERSION = '0.9.8'
+VERSION = '0.9.9'
 
 AVAILABLE_SERVICES = ['picasa', 'blogger', 'youtube', 'docs', 'contacts',
-                       'calendar']
+                      'calendar']
+LOG = logging.getLogger(googlecl.LOGGER_NAME)
 
 
 def expand_as_command_line(command_string):
@@ -164,8 +166,8 @@ def fill_out_options(service_header, task, options):
                         if attr.strip('_') == attr and
                         not getattr(options, attr)]
   for attr in missing_attributes:
-    # "user" is always a required option.
-    if task.requires(attr, options) or attr == 'user':
+    # "user" and "hostid" are always required options.
+    if task.requires(attr, options) or attr == 'user' or attr == 'hostid':
       value = googlecl.get_config_option(service_header, attr)
       if value:
         setattr(options, attr, value)
@@ -203,8 +205,8 @@ def get_hd_domain(username, default_domain='default'):
   """
   name, at_sign, domain = username.partition('@')
   # If user specifies gmail.com, it confuses the hd parameter (thanks, bartosh!)
-  if domain == 'gmail.com':
-    return default_domain
+  if domain == 'gmail.com' or domain == 'googlemail.com':
+    return 'default'
   return domain or default_domain
 
 
@@ -266,6 +268,12 @@ def run_interactive(parser):
   while True:
     try:
       command_string = raw_input('> ')
+      if command_string.startswith('python '):
+        LOG.info('HINT: No need to include "python" in interactive mode')
+        command_string = command_string.replace('python ', '', 1)
+      if command_string.startswith('google '):
+        LOG.info('HINT: No need to include "google" in interactive mode')
+        command_string = command_string.replace('google ', '', 1)
       if not command_string:
         continue
       elif command_string == '?':
@@ -310,7 +318,7 @@ def run_once(options, args):
     if service == 'help':
       print_help()
     else:
-      print 'Must specify at least a service and a task!'
+      LOG.error('Must specify at least a service and a task!')
     return
 
   if service == 'help':
@@ -318,9 +326,9 @@ def run_once(options, args):
       service_module = __import__('googlecl.' + task_name + '.service',
                                   globals(), locals(), -1)
     except ImportError, err:
-      print err.args[0]
-      print 'Did you specify the service correctly? Must be one of ' +\
-            str(AVAILABLE_SERVICES[1:-1])
+      LOG.error(err.args[0])
+      LOG.error('Did you specify the service correctly? Must be one of ' +
+                str(AVAILABLE_SERVICES)[1:-1])
       return
     else:
       print_help(task_name, service_module.TASKS)
@@ -330,19 +338,23 @@ def run_once(options, args):
       service_module = __import__('googlecl.' + service + '.service',
                                   globals(), locals(), -1)
     except ImportError, err:
-      print err.args[0]
-      print 'Did you specify the service correctly? Must be one of ' +\
-            str(AVAILABLE_SERVICES)[1:-1]
+      LOG.error(err.args[0])
+      LOG.error('Did you specify the service correctly? Must be one of ' +
+                str(AVAILABLE_SERVICES)[1:-1])
       return
 
+  # Not sure why the fromlist keyword argument became necessary...
+  package = __import__('googlecl.' + service, fromlist=['SECTION_HEADER'])
   client = service_module.SERVICE_CLASS()
-  
+  client.debug = googlecl.get_config_option(package.SECTION_HEADER,
+                                            'debug',
+                                            default=options.debug)
   try:
     task = service_module.TASKS[task_name]
     task.name = task_name
   except KeyError:
-    print 'Did not recognize task, please use one of ' + \
-          str(service_module.TASKS.keys())
+    LOG.error('Did not recognize task, please use one of ' + \
+              str(service_module.TASKS.keys()))
     return
   
   if task.requires('devkey'):
@@ -352,8 +364,6 @@ def run_once(options, args):
     # You can get your own key at http://code.google.com/apis/youtube/dashboard 
     if not options.devkey:
       options.devkey = googlecl.read_devkey() or 'AI39si4d9dBo0dX7TnGyfQ68bNiKfEeO7wORCfY3HAgSStFboTgTgAi9nQwJMfMSizdGIs35W9wVGkygEw8ei3_fWGIiGSiqnQ'
-  # Not sure why the fromlist keyword argument became necessary...
-  package = __import__('googlecl.' + service, fromlist=['SECTION_HEADER'])
   # fill_out_options will read the key from file if necessary, but will not set
   # it since it will always get a non-empty value beforehand.
   fill_out_options(package.SECTION_HEADER, task, options)
@@ -363,7 +373,7 @@ def run_once(options, args):
   try:
     token = googlecl.read_access_token(service, client.email)
   except (KeyError, IndexError):
-    print 'WARNING: Token file appears to be corrupted. Not using.'
+    LOG.warning('Token file appears to be corrupted. Not using.')
     token = None
   if token:
     client.SetOAuthToken(token)
@@ -379,19 +389,19 @@ def run_once(options, args):
       googlecl.remove_access_token(service, client.email)
   if not authenticated:
     domain = get_hd_domain(client.email)
-    if client.RequestAccess(domain):
+    if client.RequestAccess(domain, options.hostid):
       authorized_account = client.get_email()
       if not verify_email(client.email, authorized_account):
-        print 'You specified account ' + client.email +\
-              ' but granted access for ' + authorized_account
-        print 'Please log out of ' + authorized_account +\
-              ' and grant access with ' + client.email
+        LOG.error('You specified account ' + client.email +
+                  ' but granted access for ' + authorized_account + '.' +
+                  ' Please log out of ' + authorized_account +
+                  ' and grant access with ' + client.email + '.')
         return
       else:
         # Only write the token if it's for the right user
         googlecl.write_access_token(service, client.email, client.current_token)
     else:
-      print 'Failed to get valid access token!'
+      LOG.error('Failed to get valid access token!')
       return
 
   googlecl.set_missing_default(package.SECTION_HEADER, 'user',
@@ -408,6 +418,21 @@ def run_once(options, args):
   task.run(client, options, args)
 
 
+def setup_logger(options):
+  """Setup the global (root, basic) configuration for logging."""
+  format = '%(message)s'
+  if options.debug:
+    level = logging.DEBUG
+    format = '%(levelname)s:%(name)s:%(message)s'
+  elif options.verbose:
+    level = logging.DEBUG
+  elif options.quiet:
+    level = logging.ERROR
+  else:
+    level = logging.INFO
+  logging.basicConfig(level=level, format=format)
+
+
 def setup_parser():
   """Set up the parser.
   
@@ -460,6 +485,9 @@ def setup_parser():
                     ' Can also specify a range with a comma:' +
                     ' "YYYY-MM-DD", events between date and future.' +
                     ' "YYYY-MM-DD,YYYY-MM-DD" events between two dates.')
+  parser.add_option('--debug', dest='debug',
+                    action='store_true', default=False,
+                    help=('Enable all debugging output, including HTTP data'))
   parser.add_option('--delimiter', dest='delimiter', default=',',
                     help='Specify a delimiter for the output of the list task.')
   parser.add_option('--draft', dest='draft', default=False,
@@ -472,25 +500,37 @@ def setup_parser():
                     '/ search in.')
   parser.add_option('--format', dest='format',
                     help='Docs only - format to download documents as.')
+  parser.add_option('--hostid', dest='hostid', 
+                    help='Label the machine being used.')
   parser.add_option('-n', '--title', dest='title',
                     help='Title of the item')
   parser.add_option('--no-convert', dest='convert',
                     action='store_false', default=True,
                     help='Google Apps Premier only - do not convert the file' +
                     ' on upload. (Else converts to native Google Docs format)')
+  parser.add_option('-o', '--owner', dest='owner',
+                    help=('Username or ID of the owner of the resource. ' +
+                          'For example,' +
+                          " 'picasa list-albums -o bob' to list bob's albums"))
   parser.add_option('-q', '--query', dest='query',
                     help=('Full text query string for specifying items.'
                           + ' Searches on titles, captions, and tags.'))
+  parser.add_option('--quiet', dest='quiet', 
+                    action='store_true', default=False,
+                    help='Print only prompts and error messages')
+  parser.add_option('--reminder', dest='reminder',
+                    help=("Calendar only - specify time for added event's " +
+                          'reminder, e.g. "10m", "3h", "1d"'))
   parser.add_option('-s', '--summary', dest='summary', 
                     help=('Description of the upload, ' +
                           'or file containing the description.'))
   parser.add_option('-t',  '--tags', dest='tags',
                     help='Tags for item, e.g. "Sunsets, Earth Day"')
   parser.add_option('-u', '--user', dest='user',
-                    help=('Username to use for the task. Exact application ' +
-                          'is task-dependent. If authentication is ' +
-                          'necessary, this will force the user to specify a ' +
-                          'password through a command line prompt or option.'))
+                    help='Username to log in with for the service.')
+  parser.add_option('-v', '--verbose', dest='verbose',
+                    default=False, action='store_true',
+                    help='Print all messages.')
   return parser
 
 
@@ -519,9 +559,10 @@ def main():
   """Entry point for GoogleCL script."""
   parser = setup_parser()
   (options, args) = parser.parse_args()
+  setup_logger(options)
   if not googlecl.load_preferences(options.config):
     if options.config:
-      print 'Could not read config file at ' + options.config
+      LOG.warning('Could not read config file at ' + options.config)
     return
   if not args:
     run_interactive(parser)
diff --git a/src/googlecl/__init__.py b/src/googlecl/__init__.py
index 214ca2b..af75a77 100644
--- a/src/googlecl/__init__.py
+++ b/src/googlecl/__init__.py
@@ -19,7 +19,7 @@ from __future__ import with_statement
 __author__ = 'tom.h.miller at gmail.com (Tom Miller)'
 import ConfigParser
 import os
-
+import re
 
 CONFIG = ConfigParser.ConfigParser()
 GOOGLE_CL_DIR = os.path.expanduser(os.path.join('~', '.googlecl'))
@@ -28,6 +28,9 @@ HISTORY_FILENAME = 'history'
 TOKENS_FILENAME_FORMAT = 'access_tok_%s'
 DEVKEY_FILENAME = 'yt_devkey'
 
+FILE_EXT_PATTERN = re.compile('.*\.([a-zA-Z0-9]{3,}$)')
+LOGGER_NAME = 'googlecl'
+
 
 def get_config_option(section, option, default=None, type=None):
   """Return option from config file.
@@ -69,6 +72,15 @@ def get_config_option(section, option, default=None, type=None):
     return default
 
 
+def get_extension_from_path(path):
+  """Return the extension of a file."""
+  match = FILE_EXT_PATTERN.match(path)
+  if match:
+    return match.group(1)
+  else:
+    return None
+
+
 def load_preferences(path=None):
   """Load preferences / configuration file.
   
@@ -78,12 +90,14 @@ def load_preferences(path=None):
   """
   def set_options():
     """Set the most basic options in the config file."""
-    import googlecl.picasa
-    import googlecl.docs
-    import googlecl.contacts
+    import googlecl
+    import getpass
+    import socket
     # These may be useful to define at the module level, but for now,
     # keep them here.
     # REMEMBER: updating these means you need to update the CONFIG readme.
+    default_hostid = getpass.getuser() + '@' +  socket.gethostname()
+    _youtube = {'max_results': '50'}
     _contacts = {'list_style': 'title,email'}
     _calendar = {'list_style': 'title,when'}
     _picasa = {'access': 'public'}
@@ -95,7 +109,8 @@ def load_preferences(path=None):
                'list_style': 'title,url-site',
                'missing_field_value': 'N/A',
                'date_print_format': '%b %d %H:%M',
-               'cap_results': 'False'}
+               'cap_results': 'False',
+               'hostid': default_hostid}
     _docs = {'document_format': 'txt',
              'spreadsheet_format': 'xls',
              'presentation_format': 'ppt',
@@ -106,6 +121,7 @@ def load_preferences(path=None):
                        googlecl.picasa.SECTION_HEADER: _picasa,
                        googlecl.contacts.SECTION_HEADER: _contacts,
                        googlecl.calendar.SECTION_HEADER: _calendar,
+                       googlecl.youtube.SECTION_HEADER: _youtube,
                        'GENERAL': _general}
     made_changes = False
     for section_name in config_defaults.keys():
diff --git a/src/googlecl/blogger/__init__.py b/src/googlecl/blogger/__init__.py
index 22d9fc6..00c0362 100644
--- a/src/googlecl/blogger/__init__.py
+++ b/src/googlecl/blogger/__init__.py
@@ -11,7 +11,8 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
+import googlecl
 
-
-SECTION_HEADER = 'BLOGGER' 
-
+service_name = __name__.split('.')[-1]
+LOGGER_NAME = googlecl.LOGGER_NAME + '.' + service_name
+SECTION_HEADER = service_name.upper()
diff --git a/src/googlecl/blogger/service.py b/src/googlecl/blogger/service.py
index b119dfb..c5e83ee 100644
--- a/src/googlecl/blogger/service.py
+++ b/src/googlecl/blogger/service.py
@@ -21,12 +21,16 @@ from __future__ import with_statement
 __author__ = 'tom.h.miller at gmail.com (Tom Miller)'
 import atom
 import gdata
+import logging
 import os
 import googlecl
 import googlecl.service
 from googlecl.blogger import SECTION_HEADER
 
 
+LOG = logging.getLogger(googlecl.blogger.LOGGER_NAME)
+
+
 class BlogNotFound(googlecl.service.Error):
   """Specified blog is not found."""
   def __str__(self):
@@ -80,23 +84,24 @@ class BloggerServiceCL(googlecl.service.BaseServiceCL):
 
   AddPost = add_post
 
-  def _get_blog_id(self, blog_title=None, user='default'):
+  def _get_blog_id(self, blog_title=None, user_id='default'):
     """Return the blog ID of the blog that matches blog_title.
     
     Keyword arguments:
       blog_title: Name or title of the blog.
-      user: Owner of the blog. Default 'default' for the authenticated user.
+      user_id: Profile ID of blog's owner as seen in the profile view URL.
+              Default 'default' for the authenticated user.
     
     Returns:
       Blog ID (blog_entry.GetSelfLink().href.split('/')[-1]) if a blog is
       found matching the user and blog_title. None otherwise.
     
     """
-    blog_entry = self.GetSingleEntry('/feeds/' + user + '/blogs', blog_title)
+    blog_entry = self.GetSingleEntry('/feeds/' + user_id + '/blogs', blog_title)
     if blog_entry:
       return blog_entry.GetSelfLink().href.split('/')[-1]
     else:
-      raise BlogNotFound('No blog matching', blog_title)
+      raise BlogNotFound('No blogs returned matching', blog_title)
     
   def is_token_valid(self, test_uri='/feeds/default/blogs'):
     """Check that the token being used is valid."""
@@ -104,22 +109,20 @@ class BloggerServiceCL(googlecl.service.BaseServiceCL):
 
   IsTokenValid = is_token_valid
     
-  def get_posts(self, blog_title=None, post_title=None):
+  def get_posts(self, blog_title=None, post_title=None, user_id='default'):
     """Get entries for posts that match a title.
     
-    This will only get posts for the user that has logged in. It's apparently
-    very difficult to obtain the profile ID that Blogger uses unless you have
-    logged in.
-    
     Keyword arguments:
       blog_title: Name or title of the blog the post is in. (Default None)
       post_title: Title that the post should have. (Default None, for all posts)
+      user_id: Profile ID of blog's owner as seen in the profile view URL.
+              (Default 'default' for authenticated user)
          
     Returns:
       List of posts that match parameters, or [] if none do.
       
     """
-    blog_id = self._get_blog_id(blog_title)
+    blog_id = self._get_blog_id(blog_title, user_id)
     if blog_id:
       uri = '/feeds/' + blog_id + '/posts/default'
       return self.GetEntries(uri, post_title)
@@ -166,6 +169,15 @@ class BloggerServiceCL(googlecl.service.BaseServiceCL):
 SERVICE_CLASS = BloggerServiceCL
 
 
+class BloggerEntryToStringWrapper(googlecl.service.BaseEntryToStringWrapper):
+  @property
+  def author(self):
+    """Author."""
+    # Name of author 'x' name is in entry.author[x].name.text
+    text_extractor = lambda entry: getattr(getattr(entry, 'name'), 'text')
+    return self._join(self.entry.author, text_extractor=text_extractor)
+
+
 #===============================================================================
 # Each of the following _run_* functions execute a particular task.
 #  
@@ -178,7 +190,7 @@ SERVICE_CLASS = BloggerServiceCL
 def _run_post(client, options, args):
   max_size = 500000
   if not args:
-    print 'Must provide paths to files and/or string content to post'
+    LOG.error('Must provide paths to files and/or string content to post')
     return
   if not options.blog:
     options.blog = googlecl.get_config_option(SECTION_HEADER, 'blog')
@@ -187,8 +199,8 @@ def _run_post(client, options, args):
       with open(content_string, 'r') as content_file:
         content = content_file.read(max_size)
         if content_file.read(1):
-          print 'Only read first ' + str(max_size) + ' bytes of file ' +\
-                content_string
+          LOG.warning('Only read first ' + str(max_size) + ' bytes of file ' +
+                      content_string)
       title = os.path.basename(content_string).split('.')[0]
     else:
       if not options.title:
@@ -199,7 +211,7 @@ def _run_post(client, options, args):
                              blog_title=options.blog,
                              is_draft=options.draft)
     except gdata.service.RequestError, err:
-      print 'Failed to post: ' + str(err)
+      LOG.error('Failed to post: ' + str(err))
     else:
       if entry and options.tags:
         client.LabelPosts([entry], options.tags)
@@ -212,7 +224,7 @@ def _run_delete(client, options, args):
     post_entries = client.GetPosts(blog_title=options.blog,
                                    post_title=options.title)
   except BlogNotFound, err:
-    print err
+    LOG.error(err)
     return
   client.Delete(post_entries, entry_type = 'post',
                 delete_default=googlecl.CONFIG.getboolean('GENERAL',
@@ -223,9 +235,10 @@ def _run_list(client, options, args):
   if not options.blog:
     options.blog = googlecl.get_config_option(SECTION_HEADER, 'blog')
   try:
-    entries = client.GetPosts(options.blog, options.title)
+    entries = client.GetPosts(options.blog, options.title,
+                              user_id=options.owner or 'default')
   except BlogNotFound, err:
-    print err
+    LOG.error(err)
     return
   if args:
     style_list = args[0].split(',')
@@ -233,8 +246,10 @@ def _run_list(client, options, args):
     style_list = googlecl.get_config_option(SECTION_HEADER,
                                             'list_style').split(',')
   for entry in entries:
-    print googlecl.service.entry_to_string(entry, style_list,
-                                         delimiter=options.delimiter)
+    print googlecl.service.compile_entry_string(
+                                             BloggerEntryToStringWrapper(entry),
+                                             style_list,
+                                             delimiter=options.delimiter)
 
 
 def _run_tag(client, options, args):
@@ -243,7 +258,7 @@ def _run_tag(client, options, args):
   try:
     entries = client.GetPosts(options.blog, options.title)
   except BlogNotFound, err:
-    print err
+    LOG.error(err)
     return
   client.LabelPosts(entries, options.tags)
 
@@ -254,10 +269,10 @@ TASKS = {'delete': googlecl.service.Task('Delete a post.', callback=_run_delete,
          'post': googlecl.service.Task('Post content.', callback=_run_post,
                                        optional=['blog', 'title', 'tags'],
                                        args_desc='PATH_TO_CONTENT or CONTENT'),
-         'list': googlecl.service.Task('List posts in your blog',
+         'list': googlecl.service.Task('List posts in a blog',
                                        callback=_run_list,
                                        required=['delimiter'],
-                                       optional=['blog', 'title']),
+                                       optional=['blog', 'title', 'owner']),
          'tag': googlecl.service.Task('Label posts', callback=_run_tag,
                                       required=['tags', 'title'],
                                       optional=['blog'])}
diff --git a/src/googlecl/calendar/__init__.py b/src/googlecl/calendar/__init__.py
index b86fa6a..7e9fe62 100644
--- a/src/googlecl/calendar/__init__.py
+++ b/src/googlecl/calendar/__init__.py
@@ -14,8 +14,11 @@
 """Data for GoogleCL's calendar service."""
 import datetime
 import googlecl.service
+import googlecl
 
-SECTION_HEADER = 'CALENDAR'
+service_name = __name__.split('.')[-1]
+LOGGER_NAME = googlecl.LOGGER_NAME + '.' + service_name
+SECTION_HEADER = service_name.upper()
 QUERY_DATE_FORMAT = '%Y-%m-%dT%H:%M:%S'
 
 
@@ -75,9 +78,9 @@ class Date(object):
 
 
 def get_utc_timedelta():
-  """Return the UTC offset as a timedelta."""
+  """Return the UTC offset of local zone at present time as a timedelta."""
   import time
-  if time.daylight != 0:
+  if time.localtime().tm_isdst and time.daylight:
     return datetime.timedelta(hours=time.altzone/3600)
   else:
     return datetime.timedelta(hours=time.timezone/3600)
diff --git a/src/googlecl/calendar/service.py b/src/googlecl/calendar/service.py
index 7fb5423..830d71f 100644
--- a/src/googlecl/calendar/service.py
+++ b/src/googlecl/calendar/service.py
@@ -28,10 +28,13 @@ import datetime
 import gdata.calendar.service
 import googlecl
 import googlecl.service
+import logging
+import time
 import urllib
 from googlecl.calendar import SECTION_HEADER
 
 
+LOG = logging.getLogger(googlecl.calendar.LOGGER_NAME)
 USER_BATCH_URL_FORMAT = \
                gdata.calendar.service.DEFAULT_BATCH_URL.replace('default', '%s')
 
@@ -95,6 +98,34 @@ class CalendarServiceCL(gdata.calendar.service.CalendarService,
     map(request_feed.AddDelete, [None], delete_events, [None])
     self.ExecuteBatch(request_feed, USER_BATCH_URL_FORMAT % cal_user)
 
+  def add_reminders(self, calendar_user, events, minutes):
+    """Add default reminders to events.
+
+    Keyword arguments:
+      calendar_user: "User" of the calendar.
+      events: List of events to add reminder to.
+      minutes: Number of minutes before each event to send reminder.
+
+    Returns:
+      List of events with batch results.
+
+    """
+    request_feed = gdata.calendar.CalendarEventFeed()
+    for event in events:
+      if event.when:
+        for a_when in event.when:
+          a_when.reminder.append(gdata.calendar.Reminder(minutes=minutes))
+      else:
+        LOG.debug('No "when" data for event!')
+        event.when.append(gdata.calendar.When())
+        event.when[0].reminder.append(gdata.calendar.Reminder(minutes=minutes))
+      request_feed.AddUpdate(entry=event)
+    response_feed = self.ExecuteBatch(request_feed,
+                                      USER_BATCH_URL_FORMAT % calendar_user)
+    return response_feed.entry
+
+  AddReminders = add_reminders
+
   def delete_events(self, events, date, calendar_user):
     """Delete events from a calendar.
     
@@ -187,7 +218,7 @@ class CalendarServiceCL(gdata.calendar.service.CalendarService,
       event = gdata.calendar.CalendarEventEntry()
       event.content = atom.Content(text=event_str)
       event.quick_add = gdata.calendar.QuickAdd(value='true')
-      request_feed.AddInsert(event, 'insert-request' + str(i))
+      request_feed.AddInsert(event, 'insert-' + event_str[0:5] + str(i))
     response_feed = self.ExecuteBatch(request_feed,
                                       USER_BATCH_URL_FORMAT % calendar_user)
     return response_feed.entry
@@ -271,6 +302,62 @@ class CalendarServiceCL(gdata.calendar.service.CalendarService,
 SERVICE_CLASS = CalendarServiceCL
 
 
+class CalendarEntryToStringWrapper(googlecl.service.BaseEntryToStringWrapper):
+  @property
+  def when(self):
+    """When event takes place."""
+    start_time_data, end_time_data, freq = get_datetimes(self.entry)
+    print_format = googlecl.get_config_option(SECTION_HEADER,
+                                              'date_print_format')
+    start_time = time.strftime(print_format, start_time_data)
+    end_time = time.strftime(print_format, end_time_data)
+    value = start_time + ' - ' + end_time
+    if freq:
+      if freq.has_key('BYDAY'):
+        value += ' (' + freq['BYDAY'].lower() + ')'
+      else:
+        value += ' (' + freq['FREQ'].lower() + ')'
+    return value
+
+  @property
+  def where(self):
+    """Where event takes place"""
+    return self._join(self.entry.where, text_attribute='value_string')
+
+
+def convert_reminder_string(reminder):
+  """Convert reminder string to minutes integer.
+
+  Keyword arguments:
+    reminder: String representation of time,
+              e.g. '10' for 10 minutes,
+                   '1d' for one day,
+                   '3h' for three hours, etc.
+  Returns:
+    Integer of reminder converted to minutes.
+
+  Raises:
+    ValueError if conversion failed.
+
+  """
+  if not reminder:
+    return None
+  unit = reminder.lower()[-1]
+  value = reminder[:-1]
+  if unit == 's':
+    return int(value) / 60
+  elif unit == 'm':
+    return int(value)
+  elif unit == 'h':
+    return int(value) * 60
+  elif unit == 'd':
+    return int(value) * 60 * 24
+  elif unit == 'w':
+    return int(value) * 60 * 24 * 7
+  else:
+    return int(reminder)
+
+
 def get_date_today(include_hour=False, as_range=False):
   """Get today's date, as if entered by the user.
 
@@ -306,7 +393,6 @@ def get_datetimes(cal_entry):
            event does not repeat (does not have a gd:recurrence element)).
   
   """
-  import time
   if cal_entry.recurrence:
     return parse_recurrence(cal_entry.recurrence.text)
   else:
@@ -338,7 +424,6 @@ def parse_recurrence(time_string):
     values. (http://www.ietf.org/rfc/rfc2445.txt, section 4.3.10)
   
   """
-  import time
   # Google calendars uses a pretty limited section of RFC 2445, and I'm
   # abusing that here. This will probably break if Google ever changes how
   # they handle recurrence, or how the recurrence string is built.
@@ -358,27 +443,11 @@ def parse_recurrence(time_string):
   return (start_time, end_time, freq)
 
 
-#===============================================================================
-# Each of the following _run_* functions execute a particular task.
-#  
-# Keyword arguments:
-#  client: Client to the service being used.
-#  options: Contains all attributes required to perform the task
-#  args: Additional arguments passed in on the command line, may or may not be
-#        required
-#===============================================================================
-def _run_list(client, options, args):
+def _list(client, options, args, date):
   cal_user_list = client.get_calendar_user_list(options.cal)
   if not cal_user_list:
-    print 'No calendar matches "' + options.cal + '"'
+    LOG.error('No calendar matches "' + options.cal + '"')
     return
-  # If no other search parameters are mentioned, set date to be
-  # today. (Prevent user from retrieving all events ever)
-  if not (options.title or options.query or options.date):
-    date = googlecl.calendar.Date(get_date_today(include_hour=True,
-                                                 as_range=True))
-  else:
-    date = googlecl.calendar.Date(options.date)
   for cal in cal_user_list:
     print ''
     print '[' + str(cal) + ']'
@@ -394,52 +463,70 @@ def _run_list(client, options, args):
       style_list = googlecl.get_config_option(SECTION_HEADER,
                                               'list_style').split(',')
     for entry in entries:
-      print googlecl.service.entry_to_string(entry, style_list,
-                                             delimiter=options.delimiter)
+      print googlecl.service.compile_entry_string(
+                                            CalendarEntryToStringWrapper(entry),
+                                            style_list,
+                                            delimiter=options.delimiter)
+
+
+#===============================================================================
+# Each of the following _run_* functions execute a particular task.
+#  
+# Keyword arguments:
+#  client: Client to the service being used.
+#  options: Contains all attributes required to perform the task
+#  args: Additional arguments passed in on the command line, may or may not be
+#        required
+#===============================================================================
+def _run_list(client, options, args):
+  # If no other search parameters are mentioned, set date to be
+  # today. (Prevent user from retrieving all events ever)
+  if not (options.title or options.query or options.date):
+    date = googlecl.calendar.Date(get_date_today(include_hour=True,
+                                                 as_range=True))
+  else:
+    date = googlecl.calendar.Date(options.date)
+  _list(client, options, args, date)
 
 
 def _run_list_today(client, options, args):
-  cal_user_list = client.get_calendar_user_list(options.cal)
-  if not cal_user_list:
-    print 'No calendar matches "' + options.cal + '"'
-    return
   date = googlecl.calendar.Date(get_date_today(include_hour=False))
-  for cal in cal_user_list:
-    print ''
-    print '[' + str(cal) + ']'
-    entries = client.get_events(cal.user,
-                                start_date=date.utc_start,
-                                end_date=date.utc_end,
-                                title=options.title,
-                                query=options.query)
-
-    if args:
-      style_list = args[0].split(',')
-    else:
-      style_list = googlecl.get_config_option(SECTION_HEADER,
-                                              'list_style').split(',')
-    for entry in entries:
-      print googlecl.service.entry_to_string(entry, style_list,
-                                             delimiter=options.delimiter)
+  _list(client, options, args, date)
 
 
 def _run_add(client, options, args):
   cal_user_list = client.get_calendar_user_list(options.cal)
   if not cal_user_list:
-    print 'No calendar matches "' + options.cal + '"'
+    LOG.error('No calendar matches "' + options.cal + '"')
     return
+  minutes = convert_reminder_string(options.reminder)
   for cal in cal_user_list:
-    client.quick_add_event(args, cal.user)
+    results = client.quick_add_event(args, cal.user)
+    if LOG.isEnabledFor(logging.DEBUG):
+      for entry in results:
+        LOG.debug('ID: %s, status: %s, reason: %s',
+                  entry.batch_id.text,
+                  entry.batch_status.code,
+                  entry.batch_status.reason)
+      
+    if minutes:
+      new_results = client.add_reminders(cal.user, results, minutes)
+      if LOG.isEnabledFor(logging.DEBUG):
+        for entry in new_results:
+          LOG.debug('ID: %s, status: %s, reason: %s',
+                    entry.batch_id.text,
+                    entry.batch_status.code,
+                    entry.batch_status.reason)
 
 
 def _run_delete(client, options, args):
   cal_user_list = client.get_calendar_user_list(options.cal)
   if not cal_user_list:
-    print 'No calendar matches "' + options.cal + '"'
+    LOG.error('No calendar matches "' + options.cal + '"')
     return
   date = googlecl.calendar.Date(options.date)
   for cal in cal_user_list:
-    print 'For calendar ' + str(cal)
+    LOG.info('For calendar ' + str(cal))
     events = client.get_events(cal.user,
                                start_date=date.utc_start,
                                end_date=date.utc_end,
@@ -449,7 +536,7 @@ def _run_delete(client, options, args):
     try:
       client.delete_events(events, options.date, cal.user)
     except EventsNotFound:
-      print 'No events found that match your options!'
+      LOG.warning('No events found that match your options!')
 
 
 TASKS = {'list': googlecl.service.Task('List events on a calendar',
diff --git a/src/googlecl/contacts/__init__.py b/src/googlecl/contacts/__init__.py
index 9f6f568..00c0362 100644
--- a/src/googlecl/contacts/__init__.py
+++ b/src/googlecl/contacts/__init__.py
@@ -11,7 +11,8 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
+import googlecl
 
-
-SECTION_HEADER = 'CONTACTS'
-
+service_name = __name__.split('.')[-1]
+LOGGER_NAME = googlecl.LOGGER_NAME + '.' + service_name
+SECTION_HEADER = service_name.upper()
diff --git a/src/googlecl/contacts/service.py b/src/googlecl/contacts/service.py
index 8fddbb9..6d3f278 100644
--- a/src/googlecl/contacts/service.py
+++ b/src/googlecl/contacts/service.py
@@ -26,12 +26,16 @@ List contacts:
 from __future__ import with_statement
 
 __author__ = 'tom.h.miller at gmail.com (Tom Miller)'
+import logging
 import gdata.contacts.service
 import googlecl
 import googlecl.service
 from googlecl.contacts import SECTION_HEADER
 
 
+LOG = logging.getLogger(googlecl.contacts.LOGGER_NAME)
+
+
 class ContactsServiceCL(gdata.contacts.service.ContactsService,
                         googlecl.service.BaseServiceCL):
   
@@ -68,7 +72,8 @@ class ContactsServiceCL(gdata.contacts.service.ContactsService,
       try:
         name, email = string_or_csv_file.split(',')
       except ValueError:
-        print string_or_csv_file + ' is neither a name,email pair nor a file.'
+        LOG.error(string_or_csv_file +
+                  ' is neither a name,email pair nor a file.')
         return
       new_contact = gdata.contacts.ContactEntry(title=atom.Title(
                                                              text=name.strip()))
@@ -77,7 +82,8 @@ class ContactsServiceCL(gdata.contacts.service.ContactsService,
         self.CreateContact(new_contact)
       except gdata.service.RequestError, err:
         if err.args[0]['reason'] == 'Conflict':
-          print 'Already have a contact for e-mail address ' + email.strip()
+          LOG.error('Already have a contact for e-mail address ' +
+                    email.strip())
         else:
           raise 
 
@@ -119,6 +125,48 @@ class ContactsServiceCL(gdata.contacts.service.ContactsService,
 SERVICE_CLASS = ContactsServiceCL
 
 
+class ContactsEntryToStringWrapper(googlecl.service.BaseEntryToStringWrapper):
+  @property
+  def address(self):
+    """Postal addresses."""
+    return self._join(self.entry.postal_address, text_attribute='text')
+
+  @property
+  def company(self):
+    """Name of company."""
+    return self.entry.organization.org_name.text
+  org_name = company
+
+  @property
+  def email(self):
+    """Email addresses."""
+    return self._join(self.entry.email, text_attribute='address')
+
+  @property
+  def im(self):
+    """Instant messanger handles."""
+    return self._join(self.entry.im, text_attribute='address',
+                      label_attribute='protocol')
+
+  @property
+  def notes(self):
+    """Additional notes."""
+    return self.entry.content.text
+
+  @property
+  def phone_number(self):
+    """Phone numbers."""
+    return self._join(self.entry.phone_number, text_attribute='text')
+  phone = phone_number
+
+  @property
+  # Overrides Base's title. "name" will still give name of contact.
+  def title(self):
+    """Title of contact in organization."""
+    return self.entry.organization.org_title.text
+  org_title = title
+
+
 #===============================================================================
 # Each of the following _run_* functions execute a particular task.
 #  
@@ -138,9 +186,10 @@ def _run_list(client, options, args):
     style_list = googlecl.get_config_option(SECTION_HEADER,
                                             'list_style').split(',')
   for entry in entries:
-    print googlecl.service.entry_to_string(entry, style_list,
-                                           delimiter=options.delimiter)
-
+    print googlecl.service.compile_entry_string(
+                                            ContactsEntryToStringWrapper(entry),
+                                            style_list,
+                                            delimiter=options.delimiter)
 
 def _run_add(client, options, args):
   for contact in args:
@@ -152,7 +201,7 @@ def _run_delete(client, options, args):
   if options.title is not None:
     args.append(options.title)
   if len(args) == 0:
-    print "No contacts specified. Try: google contacts delete 'John Doe'"
+    LOG.error('No contacts specified. Try: google contacts delete "John Doe"')
     return
   for name in args:
     entries = client.GetContacts(name)
@@ -167,7 +216,8 @@ def _run_add_groups(client, options, args):
 
 def _run_delete_groups(client, options, args):
   if len(args) == 0:
-    print "No groups specified. Try: google contacts delete-groups 'In-laws'"
+    LOG.error('No groups specified. Try: ' +
+              'google contacts delete-groups "In-laws"')
     return
   for group in args:
     entries = client.GetGroups(group)
@@ -182,7 +232,9 @@ def _run_list_groups(client, options, args):
   for group in args:
     entries = client.GetGroups(group)
     for entry in entries:
-      print googlecl.service.entry_to_string(entry, ['title'],
+      print googlecl.service.compile_entry_string(
+                                           ContactsEntryToStringWrapper(entry),
+                                           ['title'],
                                            delimiter=options.delimiter)
 
 
diff --git a/src/googlecl/docs/__init__.py b/src/googlecl/docs/__init__.py
index 281222f..00c0362 100644
--- a/src/googlecl/docs/__init__.py
+++ b/src/googlecl/docs/__init__.py
@@ -11,7 +11,8 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
+import googlecl
 
-
-SECTION_HEADER = 'DOCS'
-
+service_name = __name__.split('.')[-1]
+LOGGER_NAME = googlecl.LOGGER_NAME + '.' + service_name
+SECTION_HEADER = service_name.upper()
diff --git a/src/googlecl/docs/service.py b/src/googlecl/docs/service.py
index e17f6c4..5401f2f 100644
--- a/src/googlecl/docs/service.py
+++ b/src/googlecl/docs/service.py
@@ -26,16 +26,22 @@ Download docs:
   docs get --folder "Some folder"
 
 """
+from __future__ import with_statement
+
 __author__ = 'tom.h.miller at gmail.com (Tom Miller)'
 import ConfigParser
 import gdata.docs.service
+import logging
 import os
+import shutil
 import googlecl
 import googlecl.service
-import warnings
 from googlecl.docs import SECTION_HEADER
 
 
+LOG = logging.getLogger(googlecl.docs.LOGGER_NAME)
+
+
 class DocsError(googlecl.service.Error):
   """Base error for Docs errors."""
   pass
@@ -44,7 +50,15 @@ class UnexpectedExtension(DocsError):
   """Found an unexpected filename extension."""
   def __str__(self):
     if len(self.args) == 1:
-      return 'Unexpected extension: ' + self.args[0]
+      return 'Unexpected extension: ' + str(self.args[0])
+    else:
+      return str(self.args)
+
+class UnknownDoctype(DocsError):
+  """Document type / label is unknown."""
+  def __str__(self):
+    if len(self.args) == 1:
+      return 'Unknown document type: ' + str(self.args[0])
     else:
       return str(self.args)
 
@@ -57,6 +71,7 @@ FOLDER_LABEL = 'folder'
 PDF_LABEL = 'pdf'
 DOCUMENTS_NAMESPACE = 'http://schemas.google.com/docs/2007'
 
+
 class DocsServiceCL(gdata.docs.service.DocsService,
                     googlecl.service.BaseServiceCL):
   
@@ -66,11 +81,45 @@ class DocsServiceCL(gdata.docs.service.DocsService,
   app with a command line interface.
   
   """
-  
+
   def __init__(self):
     """Constructor.""" 
     gdata.docs.service.DocsService.__init__(self, source='GoogleCL')
     self._set_params(SECTION_HEADER)
+    # 302 Moved Temporarily errors began cropping up for new style docs
+    # during export. Using https solves the problem, so set ssl True here.
+    self.ssl = True
+
+  def _DownloadFile(self, uri, file_path):
+    """Downloads a file.
+
+    Overloaded from docs.service.DocsService to optionally decode from UTF.
+
+    Args:
+      uri: string The full Export URL to download the file from.
+      file_path: string The full path to save the file to.
+
+    Raises:
+      RequestError: on error response from server.
+    """
+    server_response = self.request('GET', uri)
+    response_body = server_response.read()
+    if server_response.status != 200:
+      raise gdata.service.RequestError, {'status': server_response.status,
+                                         'reason': server_response.reason,
+                                         'body': response_body}
+    if googlecl.get_config_option(SECTION_HEADER, 'decode_utf_8',
+                                  False, bool):
+      try:
+        file_string = response_body.decode('utf-8-sig')
+      except UnicodeError, err:
+        LOG.error('Could not decode: ' + str(err))
+        file_string = response_body
+    else:
+      file_string = response_body
+    with open(file_path, 'wb') as download_file:
+      download_file.write(file_string)
+      download_file.flush()
 
   def create_folder(self, title, folder_or_uri=None):
     """Stolen from gdata-2.0.10 to make recursive directory upload work."""
@@ -96,30 +145,74 @@ class DocsServiceCL(gdata.docs.service.DocsService,
 
   CreateFolder = create_folder
 
-  def edit_doc(self, doc_entry, editor, file_format):
+  def edit_doc(self, doc_entry_or_title, editor, file_format,
+               folder_entry_or_path=None):
     """Edit a document.
     
     Keyword arguments:
-      doc_entry: DocEntry of the document to edit.
+      doc_entry_or_title: DocEntry of the existing document to edit,
+                          or title of the document to create.
       editor: Name of the editor to use. Should be executable from the user's
               working directory.
       file_format: Suffix of the file to download.
                    For example, "txt", "csv", "xcl".
+      folder_entry_or_path: Entry or string representing folder to upload into.
+                   If a string, a new set of folders will ALWAYS be created.
+                   For example, 'my_folder' to upload to my_folder,
+                   'foo/bar' to upload into subfolder bar under folder foo.
+                   Default None for root folder.
     
     """ 
     import subprocess
     import tempfile
-    import shutil
     from gdata.docs.service import SUPPORTED_FILETYPES
     
+    try:
+      doc_title = doc_entry_or_title.title.text
+      new_doc = False
+    except AttributeError:
+      doc_title = doc_entry_or_title
+      new_doc = True
+
     temp_dir = tempfile.mkdtemp()
-    path = os.path.join(temp_dir, doc_entry.title.text + '.' + file_format)
-    self.Export(doc_entry.content.src, path)
-    create_time = os.stat(path).st_mtime
+    # If we're creating a new document and not given a folder entry
+    if new_doc and isinstance(folder_entry_or_path, basestring):
+      folder_path = os.path.normpath(folder_entry_or_path)
+      # Some systems allow more than one path separator
+      if os.altsep:
+        folder_path.replace(os.altsep, os.sep)
+      base_folder = folder_path.split(os.sep)[0]
+      # Define the base path such that upload_docs will create a folder
+      # named base_folder
+      base_path = os.path.join(temp_dir, base_folder)
+      total_basename = os.path.join(temp_dir, folder_path)
+      os.makedirs(total_basename)
+      path = os.path.join(total_basename, doc_title + '.' + file_format)
+    else:
+      path = os.path.join(temp_dir, doc_title + '.' + file_format)
+      base_path = path
+
+    if not new_doc:
+      self.Export(doc_entry_or_title.content.src, path)
+      file_hash = _md5_hash_file(path)
+    else:
+      file_hash = None
+
     subprocess.call([editor, path])
-    if create_time == os.stat(path).st_mtime:
-      print 'No modifications to file, not uploading.'
+    if file_hash and file_hash == _md5_hash_file(path):
+      LOG.info('No modifications to file, not uploading.')
+      return
+    elif not os.path.exists(path):
+      LOG.info('No file written, not uploading.')
       return
+    
+    if new_doc:
+      if isinstance(folder_entry_or_path, basestring):
+        # Let code in upload_docs handle the creation of new folder(s)
+        self.upload_docs([base_path])
+      else:
+        # folder_entry_or_path is None or a GDataEntry.
+        self.upload_single_doc(path, folder_entry=folder_entry_or_path)
     else:
       try:
         content_type = SUPPORTED_FILETYPES[file_format.upper()]
@@ -131,7 +224,13 @@ class DocsServiceCL(gdata.docs.service.DocsService,
                                   ' for a content type to upload as.')
         content_type = SUPPORTED_FILETYPES[file_format]
       mediasource = gdata.MediaSource(file_path=path, content_type=content_type)
-      self.Put(mediasource, doc_entry.GetEditMediaLink().href)
+      try:
+        self.Put(mediasource, doc_entry_or_title.GetEditMediaLink().href)
+      except gdata.service.RequestError, err:
+        LOG.error(err)
+        new_path = safe_move(path, '.')
+        LOG.info('Moved edited document to ' + new_path)
+
     try:
       # Good faith effort to keep the temp directory clean.
       shutil.rmtree(temp_dir)
@@ -141,6 +240,24 @@ class DocsServiceCL(gdata.docs.service.DocsService,
 
   EditDoc = edit_doc
 
+  def export(self, entry_or_id_or_url, file_path, gid=None, extra_params=None):
+    """Export old and new version docs.
+    
+    Ripped from gdata.docs.DocsService, adds 'format' parameter to make
+    new version documents happy.
+    
+    """
+    ext = googlecl.get_extension_from_path(file_path)
+    if ext:
+      if extra_params is None:
+        extra_params = {}
+      # Fix issue with new-style docs always downloading to PDF
+      # (gdata-issues Issue 2157)
+      extra_params['format'] = ext
+    self.Download(entry_or_id_or_url, file_path, ext, gid, extra_params)
+
+  Export = export
+
   def get_docs(self, base_path, entries, file_format=None):
     """Download documents.
     
@@ -150,20 +267,37 @@ class DocsServiceCL(gdata.docs.service.DocsService,
       entries: List of DocEntry items representing the files to download.
       file_format: Suffix to give the file when downloading.
                    For example, "txt", "csv", "xcl". Default None to let
-                   get_extension decide the extension.
+                   get_extension_from_doctype decide the extension.
 
     """
+    if not os.path.isdir(base_path):
+      if len(entries) > 1:
+        raise DocsError('Target "' + base_path + '" is not a directory')
+      format_from_filename = googlecl.get_extension_from_path(base_path)
+      if format_from_filename:
+        # Strip the extension off here if it exists. Don't want to double up
+        # on extension in for loop. (+1 for '.')
+        base_path = base_path[:-(len(format_from_filename)+1)]
+    else:
+      format_from_filename = None
     default_format = 'txt'
     for entry in entries:
       if not file_format:
-        file_format = get_extension(get_document_type(entry)) or default_format
-      path = os.path.join(base_path, entry.title.text + '.' + file_format)
-      print 'Downloading ' + entry.title.text + ' to ' + path
+        file_format = format_from_filename or\
+                      get_extension_from_doctype(get_document_type(entry)) or\
+                      default_format
+      if os.path.isdir(base_path):
+        path = os.path.join(base_path, entry.title.text + '.' + file_format)
+      else:
+        path = base_path + '.' + file_format
+      LOG.info('Downloading ' + entry.title.text + ' to ' + path)
       try:
         self.Export(entry, path)
       except gdata.service.RequestError, err:
-        print err
-        print 'Download of ' + entry.title.text + ' failed'
+        LOG.error('Download of ' + entry.title.text + ' failed: ' + str(err))
+      except IOError, err:
+        LOG.error(err)
+        LOG.info('Does your destination filename contain invalid characters?')
 
   GetDocs = get_docs
 
@@ -240,7 +374,7 @@ class DocsServiceCL(gdata.docs.service.DocsService,
                                                params={'showfolders': 'true'})
       folder_entries = self.GetEntries(query.ToUri(), title=title)
       if not folder_entries:
-        warnings.warn('No folder found that matches ' + title, stacklevel=2)
+        LOG.warning('No folder found that matches ' + title)
       return folder_entries
     else:
       return None
@@ -259,14 +393,14 @@ class DocsServiceCL(gdata.docs.service.DocsService,
 
   IsTokenValid = is_token_valid
 
-  def request_access(self, domain, scopes=None):
+  def request_access(self, domain, node, scopes=None):
     """Request access as in BaseServiceCL, but specify scopes."""
     # When people use docs (writely), they expect access to
     # spreadsheets as well (wise).
     if not scopes:
       scopes = gdata.service.CLIENT_LOGIN_SCOPES['writely'] +\
                gdata.service.CLIENT_LOGIN_SCOPES['wise']
-    return googlecl.service.BaseServiceCL.request_access(self, domain,
+    return googlecl.service.BaseServiceCL.request_access(self, domain, node,
                                                          scopes=scopes)
 
   RequestAccess = request_access
@@ -312,7 +446,7 @@ class DocsServiceCL(gdata.docs.service.DocsService,
           else:
             fentry = self.CreateFolder(folder_name, folder_root)
           folder_entries[dirpath] = fentry
-          print 'Created folder ' + dirpath + ' ' + folder_name
+          LOG.debug('Created folder ' + dirpath + ' ' + folder_name)
           for fname in filenames:
             loc = self.upload_single_doc(os.path.join(dirpath, fname),
                                          folder_entry=fentry)
@@ -345,48 +479,53 @@ class DocsServiceCL(gdata.docs.service.DocsService,
         extension = filename.split('.')[1]
       except IndexError:
         default_ext = 'txt'
-        print 'No extension on filename! Treating as ' + default_ext
+        LOG.info('No extension on filename! Treating as ' + default_ext)
         extension = default_ext
     try:
       content_type = SUPPORTED_FILETYPES[extension.upper()]
     except KeyError:
-      print 'No supported filetype found for extension ' + extension
+      LOG.info('No supported filetype found for extension ' + extension)
       content_type = 'text/plain'
-      print 'Uploading as ' + content_type
-    print 'Loading ' + path
+      LOG.info('Uploading as ' + content_type)
+    LOG.info('Loading ' + path)
     try:
       media = gdata.MediaSource(file_path=path, content_type=content_type)
     except IOError, err:
-      print err
+      LOG.error(err)
       return None
     entry_title = title or filename.split('.')[0]
-    # Upload() wasn't added until later versions of DocsService, so
-    # we may not have it. To support uploading to folders for earlier
-    # versions of the API, expose the lower-level Post
-    entry = gdata.docs.DocumentListEntry()
-    entry.title = atom.Title(text=entry_title)
-    if extension.lower() in ['csv', 'tsv', 'tab', 'ods', 'xls']:
-      category = _make_kind_category(SPREADSHEET_LABEL)
-    elif extension.lower() in ['ppt', 'pps']:
-      category = _make_kind_category(PRESENTATION_LABEL)
-    elif extension.lower() in ['pdf']:
-      category = _make_kind_category(PDF_LABEL)
-    # Treat everything else as a document
-    else:
-      category = _make_kind_category(DOCUMENT_LABEL)
-    entry.category.append(category)
     try:
-      new_entry = self.Post(entry, post_uri, media_source=media,
-                            extra_headers={'Slug': media.file_name},
-                            converter=gdata.docs.DocumentListEntryFromString)
-    except (gdata.service.RequestError, UnexpectedExtension), err:
-      print 'Failed to upload ' + path
-      print err
+      try:
+        # Upload() wasn't added until later versions of DocsService, so
+        # we may not have it. 
+        new_entry = self.Upload(media, entry_title, post_uri)
+      except AttributeError:
+        entry = gdata.docs.DocumentListEntry()
+        entry.title = atom.Title(text=entry_title)
+        # Cover the supported filetypes in gdata-2.0.10 even though
+        # they aren't listed in gdata 1.2.4... see what happens.
+        if extension.lower() in ['csv', 'tsv', 'tab', 'ods', 'xls', 'xlsx']:
+          category = _make_kind_category(SPREADSHEET_LABEL)
+        elif extension.lower() in ['ppt', 'pps']:
+          category = _make_kind_category(PRESENTATION_LABEL)
+        elif extension.lower() in ['pdf']:
+          category = _make_kind_category(PDF_LABEL)
+        # Treat everything else as a document
+        else:
+          category = _make_kind_category(DOCUMENT_LABEL)
+        entry.category.append(category)
+        # To support uploading to folders for earlier
+        # versions of the API, expose the lower-level Post
+        new_entry = self.Post(entry, post_uri, media_source=media,
+                              extra_headers={'Slug': media.file_name},
+                              converter=gdata.docs.DocumentListEntryFromString)
+    except gdata.service.RequestError, err:
+      LOG.error('Failed to upload ' + path + ': ' + str(err))
       return None
     else:
-      print 'Upload success! Direct link: ' +\
-            new_entry.GetAlternateLink().href
-    return  new_entry.GetAlternateLink().href
+      LOG.info('Upload success! Direct link: ' +
+               new_entry.GetAlternateLink().href)
+    return new_entry.GetAlternateLink().href
 
   UploadSingleDoc = upload_single_doc
 
@@ -394,6 +533,20 @@ class DocsServiceCL(gdata.docs.service.DocsService,
 SERVICE_CLASS = DocsServiceCL
 
 
+# Read size is 128*20 for no good reason.
+# Just want to avoid reading in the whole file, and read in a multiple of 128.
+def _md5_hash_file(path, read_size=2560):
+  """Return a binary md5 checksum of file at path."""
+  import hashlib
+  hash_function = hashlib.md5()
+  with open(path, 'r') as my_file:
+    data = my_file.read(read_size)
+    while data:
+      hash_function.update(data)
+      data = my_file.read(read_size)
+  return hash_function.digest()
+
+
 def _make_kind_category(label):
   """Stolen from gdata-2.0.10 docs.service."""
   import atom
@@ -424,7 +577,7 @@ def get_document_type(entry):
     return None
 
 
-def get_extension(doctype_label):
+def get_extension_from_doctype(doctype_label):
   """Return file extension based on document type and preferences file."""
   try:
     if doctype_label == SPREADSHEET_LABEL:
@@ -435,13 +588,19 @@ def get_extension(doctype_label):
       return 'pdf'
     elif doctype_label == PRESENTATION_LABEL:
       return googlecl.CONFIG.get(SECTION_HEADER, 'presentation_format')
-  except ConfigParser.ParsingError, err:
-    print err
-    try:
-      return googlecl.CONFIG.get(SECTION_HEADER, 'format')
-    except ConfigParser.ParsingError, err2:
-      print err2
-      return None
+    else:
+      raise UnknownDoctype(doctype_label)
+  except ConfigParser.NoOptionError, err:
+    LOG.error(err)
+  except UnknownDoctype, err:
+    if doctype_label is not None:
+      LOG.error(err)
+
+  try:
+    return googlecl.CONFIG.get(SECTION_HEADER, 'format')
+  except ConfigParser.NoOptionError, err:
+    LOG.error(err)
+  return None
 
 
 def get_editor(doctype_label):
@@ -469,11 +628,45 @@ def get_editor(doctype_label):
       return googlecl.CONFIG.get(SECTION_HEADER, 'pdf_editor')
     elif doctype_label == PRESENTATION_LABEL:
       return googlecl.CONFIG.get(SECTION_HEADER, 'presentation_editor')
-  except ConfigParser.NoOptionError:
-    try:
-      return googlecl.CONFIG.get(SECTION_HEADER, 'editor')
-    except ConfigParser.NoOptionError:
-      return os.getenv('EDITOR')
+    else:
+      raise UnknownDoctype(doctype_label)
+  except ConfigParser.NoOptionError, err:
+    LOG.error(err)
+  except UnknownDoctype, err:
+    if doctype_label is not None:
+      LOG.error(err)
+
+  try:
+    return googlecl.CONFIG.get(SECTION_HEADER, 'editor')
+  except ConfigParser.NoOptionError, err:
+    LOG.error(err)
+  return os.getenv('EDITOR')
+
+
+def safe_move(src, dst):
+  """Move file from src to dst.
+
+  If file with same name already exists at dst, rename the new file
+  while preserving the extension.
+
+  Returns:
+    path to new file.
+
+  """
+  new_dir = os.path.abspath(dst)
+  ext = googlecl.get_extension_from_path(src)
+  if not ext:
+    dotted_ext = ''
+  else:
+    dotted_ext = '.' + ext
+  filename = os.path.basename(src).rstrip(dotted_ext)
+  rename_num = 1
+  new_path = os.path.join(new_dir, filename + dotted_ext)
+  while os.path.exists(new_path):
+    new_filename = filename + '-' + str(rename_num) + dotted_ext
+    new_path = os.path.join(new_dir, new_filename) 
+  shutil.move(src, new_path)
+  return new_path
 
 
 #===============================================================================
@@ -487,18 +680,19 @@ def get_editor(doctype_label):
 #===============================================================================
 def _run_get(client, options, args):
   if not hasattr(client, 'Export'):
-    print 'Downloading documents is not supported for gdata-python-client < 2.0'
+    LOG.error('Downloading documents is not supported for' +
+              ' gdata-python-client < 2.0')
     return
   if not args:
     path = os.getcwd()
   else:
     path = args[0]
-    if not os.path.exists(path):
-      print 'Path ' + path + ' does not exist!'
-      return
   folder_entries = client.get_folder(options.folder)
   entries = client.get_doclist(options.title, folder_entries)
-  client.get_docs(path, entries, file_format=options.format)
+  try:
+    client.get_docs(path, entries, file_format=options.format)
+  except DocsError, err:
+    LOG.error(err)
 
 
 def _run_list(client, options, args):
@@ -510,13 +704,15 @@ def _run_list(client, options, args):
     style_list = googlecl.get_config_option(SECTION_HEADER,
                                             'list_style').split(',')
   for entry in entries:
-    print googlecl.service.entry_to_string(entry, style_list,
-                                           delimiter=options.delimiter)
+    print googlecl.service.compile_entry_string(
+                               googlecl.service.BaseEntryToStringWrapper(entry),
+                               style_list,
+                               delimiter=options.delimiter)
 
 
 def _run_upload(client, options, args):
   if not args:
-    print 'Need to tell me what to upload!'
+    LOG.error('Need to tell me what to upload!')
     return
   folder_entries = client.get_folder(options.folder)
   folder_entry = client.get_single_entry(folder_entries)
@@ -526,33 +722,37 @@ def _run_upload(client, options, args):
 
 def _run_edit(client, options, args):
   if not hasattr(client, 'Export'):
-    print 'Editing documents is not supported' +\
-          ' for gdata-python-client < 2.0'
+    LOG.error('Editing documents is not supported' +
+              ' for gdata-python-client < 2.0')
     return
   folder_entry_list = client.get_folder(options.folder)
   doc_entry = client.get_single_doc(options.title, folder_entry_list)
-  if not doc_entry:
-    print 'No matching documents found! Creating it.'
-    new_entry = gdata.docs.DocumentListEntry()
-    new_entry.title = gdata.atom.Title(text=options.title or 'GoogleCL doc')
-    category = _make_kind_category(DOCUMENT_LABEL)
-    new_entry.category.append(category)
-    folder_entries = client.get_folder(options.folder)
-    folder_entry = client.get_single_entry(folder_entries)
-    if folder_entry:
-      post_uri = folder_entry.content.src
-    else:
-      post_uri = '/feeds/documents/private/full'
-    doc_entry = client.Post(new_entry, post_uri)
-  doc_type = get_document_type(doc_entry)
-  format_ext = options.format or get_extension(doc_type)
+  if doc_entry:
+    doc_entry_or_title = doc_entry
+    doc_type = get_document_type(doc_entry)
+  else:
+    doc_entry_or_title = options.title
+    doc_type = None
+    LOG.debug('No matching documents found! Will create one.')
+  folder_entry = client.get_single_entry(folder_entry_list)
+  if not folder_entry and options.folder:
+    # Don't tell the user no matching folders were found if they didn't
+    # specify one.
+    LOG.debug('No matching folders found! Will create them.')
+  format_ext = options.format or get_extension_from_doctype(doc_type)
   editor = options.editor or get_editor(doc_type)
   if not editor:
-    print 'No editor defined!'
-    print 'Define an "editor" option in your config file, set the ' +\
-          'EDITOR environment variable, or pass an editor in with --editor.'
+    LOG.error('No editor defined!')
+    LOG.info('Define an "editor" option in your config file, set the ' +
+             'EDITOR environment variable, or pass an editor in with --editor.')
+    return
+  if not format_ext:
+    LOG.error('No format defined!')
+    LOG.info('Define a "format" option in your config file,' +
+             ' or pass in a format with --format')
     return
-  client.edit_doc(doc_entry, editor, format_ext)
+  client.edit_doc(doc_entry_or_title, editor, format_ext,
+                  folder_entry_or_path=folder_entry or options.folder)
 
 
 def _run_delete(client, options, args):
diff --git a/src/googlecl/picasa/__init__.py b/src/googlecl/picasa/__init__.py
index aa4985d..00c0362 100644
--- a/src/googlecl/picasa/__init__.py
+++ b/src/googlecl/picasa/__init__.py
@@ -11,7 +11,8 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
+import googlecl
 
-
-SECTION_HEADER = 'PICASA'
-
+service_name = __name__.split('.')[-1]
+LOGGER_NAME = googlecl.LOGGER_NAME + '.' + service_name
+SECTION_HEADER = service_name.upper()
diff --git a/src/googlecl/picasa/service.py b/src/googlecl/picasa/service.py
index 3ab9f68..a12e152 100644
--- a/src/googlecl/picasa/service.py
+++ b/src/googlecl/picasa/service.py
@@ -19,13 +19,37 @@
 from __future__ import with_statement
 
 __author__ = 'tom.h.miller at gmail.com (Tom Miller)'
+import logging
 import os
 import urllib
 import googlecl
 import googlecl.service
 from googlecl.picasa import SECTION_HEADER
 from gdata.photos.service import PhotosService, GooglePhotosException
-
+import gdata.photos
+
+LOG = logging.getLogger(googlecl.picasa.LOGGER_NAME)
+SUPPORTED_VIDEO_TYPES = {'wmv': 'video/x-ms-wmv',
+                         'avi': 'video/avi',
+                         '3gp': 'video/3gpp',
+                         'mov': 'video/quicktime',
+                         'qt': 'video/quicktime',
+                         'mp4': 'video/mp4',
+                         'mpa': 'video/mpeg',
+                         'mpe': 'video/mpeg',
+                         'mpeg': 'video/mpeg',
+                         'mpg': 'video/mpeg',
+                         'mpv2': 'video/mpeg',
+                         'mpeg4': 'video/mpeg4',}
+# XXX gdata.photos.service contains a very strange check against (outdated)
+# allowed MIME types. This is a hack to allow videos to be uploaded.
+# We're creating a list of the allowed video types stripped of the initial
+# 'video/', eliminating duplicates via set(), then converting to tuple()
+# since that's what gdata.photos.service uses.
+gdata.photos.service.SUPPORTED_UPLOAD_TYPES += \
+     tuple(set([type.split('/')[1] for type in SUPPORTED_VIDEO_TYPES.values()]))
+DOWNLOAD_VIDEO_TYPES = {'swf': 'application/x-shockwave-flash',
+                        'mp4': 'video/mpeg4',}
 
 class PhotosServiceCL(PhotosService, googlecl.service.BaseServiceCL):
   
@@ -101,13 +125,13 @@ class PhotosServiceCL(PhotosService, googlecl.service.BaseServiceCL):
       entry_type = 'album'
       search_string = title
     if not entries:
-      print 'No %ss matching %s' % (entry_type, search_string)
+      LOG.info('No %ss matching %s' % (entry_type, search_string))
     googlecl.service.BaseServiceCL.Delete(self, entries,
                                           entry_type, delete_default)
 
   Delete = delete
 
-  def download_album(self, base_path, user, title=None):
+  def download_album(self, base_path, user, video_format='mp4', title=None):
     """Download an album to the local host.
     
     Keyword arguments:
@@ -119,10 +143,45 @@ class PhotosServiceCL(PhotosService, googlecl.service.BaseServiceCL):
       title: Title that the album should have. (Default None, for all albums)
        
     """
+    def _get_download_info(photo_or_video, video_format):
+      """Get download link and extension for photo or video.
+      
+      video_format must be in DOWNLOAD_VIDEO_TYPES.
+      
+      Returns:
+        (url, extension)
+      """
+      wanted_content = None
+      for content in photo_or_video.media.content:
+        if content.medium == 'image' and not wanted_content:
+          wanted_content = content
+        elif content.type == DOWNLOAD_VIDEO_TYPES[video_format]:
+          wanted_content = content
+      if not wanted_content:
+        LOG.error('Did not find desired medium!')
+        LOG.debug('photo_or_video.media:\n' + photo_or_video.media)
+        return None
+      elif wanted_content.medium == 'image':
+        url = photo_or_video.content.src
+        url = url[:url.rfind('/')+1]+'d'+url[url.rfind('/'):]
+        mimetype = photo_or_video.content.type
+        extension = mimetype.split('/')[1]
+      else:
+        url = wanted_content.url
+        extension = video_format
+      return (url, extension)
+    # End _get_download_info
+
     if not user:
       user = 'default'
     entries = self.GetAlbum(user=user, title=title)
-    
+    if video_format not in DOWNLOAD_VIDEO_TYPES.keys():
+      LOG.error('Unsupported video format: ' + video_format)
+      LOG.info('Try one of the following video formats: ' +
+               str(DOWNLOAD_VIDEO_TYPES.keys())[1:-1])
+      video_format = 'mp4'
+      LOG.info('Downloading videos as ' + video_format)
+
     for album in entries:
       album_path = os.path.join(base_path, album.title.text)
       album_concat = 1
@@ -136,24 +195,21 @@ class PhotosServiceCL(PhotosService, googlecl.service.BaseServiceCL):
       photo_feed = self.GetFeed('/data/feed/api/user/%s/albumid/%s?kind=photo' %
                                 (user, album.gphoto_id.text))
       
-      for photo in photo_feed.entry:
+      for photo_or_video in photo_feed.entry:
         #TODO: Test on Windows (upload from one OS, download from another)
-        photo_name = os.path.split(photo.title.text)[1]
-        photo_path = os.path.join(album_path, photo_name)
+        photo_or_video_name = photo_or_video.title.text.split(os.extsep)[0]
+        url, extension = _get_download_info(photo_or_video, video_format)
+        path = os.path.join(album_path,
+                            photo_or_video_name + os.extsep + extension)
         # Check for a file extension, add it if it does not exist.
-        if not '.' in photo_path:
-          photo_ext = photo.content.type
-          photo_path += '.' + photo_ext[photo_ext.find('/')+1:]
-        if os.path.exists(photo_path):
-          base_photo_path = photo_path
+        if os.path.exists(path):
+          base_path = path
           photo_concat = 1
-          while os.path.exists(photo_path):
-            photo_path = base_photo_path + '-%i' % photo_concat
+          while os.path.exists(path):
+            path = base_path + '-%i' % photo_concat
             photo_concat += 1
-        print 'Downloading %s to %s' % (photo.title.text, photo_path)
-        url = photo.content.src
-        high_res_url = url[:url.rfind('/')+1]+'d'+url[url.rfind('/'):]
-        urllib.urlretrieve(high_res_url, photo_path)
+        LOG.info('Downloading %s to %s' % (photo_or_video.title.text, path))
+        urllib.urlretrieve(url, path)
 
   DownloadAlbum = download_album
 
@@ -180,38 +236,49 @@ class PhotosServiceCL(PhotosService, googlecl.service.BaseServiceCL):
 
   GetSingleAlbum = get_single_album
 
-  def insert_photo_list(self, album, photo_list, tags=''):
+  def insert_media_list(self, album, photo_list, tags='', user='default'):
     """Insert photos into an album.
     
     Keyword arguments:
-      album: The album entry of the album getting the photos.
-      photo_list: A list of paths, each path a picture on the local host.
-      tags: Text of the tags to be added to each photo, e.g. 'Islands, Vacation'
+      album: The album entry of the album getting the media.
+      photo_list: A list of paths, each path a picture or video on
+                  the local host.
+      tags: Text of the tags to be added to each item, e.g. 'Islands, Vacation'
             (Default '').
     
     """
     album_url = ('/data/feed/api/user/%s/albumid/%s' %
-                 ('default', album.gphoto_id.text))
+                 (user, album.gphoto_id.text))
     keywords = tags
     failures = []
     for path in photo_list:
       if not tags and self.prompt_for_tags:
         keywords = raw_input('Enter tags for photo %s: ' % path)
-      print 'Loading file %s to album %s' % (path, album.title.text)
+      LOG.info('Loading file ' + path + ' to album ' + album.title.text)
+      ext = googlecl.get_extension_from_path(path)
+      if not ext:
+        LOG.debug('No extension match on path ' + path)
+        content_type = 'image/jpeg'
+      else:
+        try:
+          content_type = SUPPORTED_VIDEO_TYPES[ext]
+        except KeyError:
+          content_type = 'image/' + ext
       try:
         self.InsertPhotoSimple(album_url, 
                                title=os.path.split(path)[1], 
                                summary='',
                                filename_or_handle=path, 
-                               keywords=keywords)
+                               keywords=keywords,
+                               content_type=content_type)
       except GooglePhotosException, err:
-        print 'Failed to upload %s. (%s: %s)' % (path,
-                                                 err.args[0],
-                                                 err.args[1]) 
+        LOG.error('Failed to upload %s. (%s: %s)', path,
+                                                   err.args[0],
+                                                   err.args[1]) 
         failures.append(file)   
     return failures
 
-  InsertPhotoList = insert_photo_list
+  InsertMediaList = insert_media_list
 
   def is_token_valid(self, test_uri='/data/feed/api/user/default'):
     """Check that the token being used is valid."""
@@ -273,8 +340,8 @@ def _run_create(client, options, args):
       timestamp = time.mktime(time.strptime(options.date,
                                             googlecl.service.DATE_FORMAT))
     except ValueError, err:
-      print err
-      print 'Ignoring date option, using today'
+      LOG.error(err)
+      LOG.info('Ignoring date option, using today')
       options.date = ''
     else:
       # Timestamp needs to be in milliseconds after the epoch
@@ -285,7 +352,7 @@ def _run_create(client, options, args):
                                                         'access'),
                              timestamp=options.date)
   if args:
-    client.InsertPhotoList(album, photo_list=args, tags=options.tags)
+    client.InsertMediaList(album, photo_list=args, tags=options.tags)
 
 
 def _run_delete(client, options, args):
@@ -296,7 +363,7 @@ def _run_delete(client, options, args):
 
 
 def _run_list(client, options, args):
-  entries = client.build_entry_list(user=options.user,
+  entries = client.build_entry_list(user=options.owner or options.user,
                                     title=options.title,
                                     query=options.encoded_query,
                                     force_photos=True)
@@ -305,13 +372,15 @@ def _run_list(client, options, args):
   else:
     style_list = googlecl.get_config_option(SECTION_HEADER,
                                             'list_style').split(',')
-  for item in entries:
-    print googlecl.service.entry_to_string(item, style_list,
-                                           delimiter=options.delimiter)
+  for entry in entries:
+    print googlecl.service.compile_entry_string(
+                               googlecl.service.BaseEntryToStringWrapper(entry),
+                               style_list,
+                               delimiter=options.delimiter)
 
 
 def _run_list_albums(client, options, args):
-  entries = client.build_entry_list(user=options.user,
+  entries = client.build_entry_list(user=options.owner or options.user,
                                     title=options.title,
                                     force_photos=False)
   if args:
@@ -319,38 +388,46 @@ def _run_list_albums(client, options, args):
   else:
     style_list = googlecl.get_config_option(SECTION_HEADER,
                                             'list_style').split(',')
-  for item in entries:
-    print googlecl.service.entry_to_string(item, style_list,
-                                           delimiter=options.delimiter)
+  for entry in entries:
+    print googlecl.service.compile_entry_string(
+                               googlecl.service.BaseEntryToStringWrapper(entry),
+                               style_list,
+                               delimiter=options.delimiter)
 
 
 def _run_post(client, options, args):
   if not args:
-    print 'Must provide photos to post!'
+    LOG.error('Must provide photos to post!')
     return
-  album = client.GetSingleAlbum(title=options.title)
+  album = client.GetSingleAlbum(user=options.owner or options.user,
+                                title=options.title)
   if album:
-    client.InsertPhotoList(album, args, tags=options.tags)
+    client.InsertMediaList(album, args, tags=options.tags,
+                           user=options.owner or options.user)
   else:
-    print 'No albums found that match %s' % options.title
+    LOG.error('No albums found that match ' + options.title)
 
 
 def _run_get(client, options, args):
   if not args:
-    print 'Must provide destination of album(s)!'
+    LOG.error('Must provide destination of album(s)!')
     return
   base_path = args[0]
-  client.DownloadAlbum(base_path, user=options.user, title=options.title)
+  client.DownloadAlbum(base_path,
+                       user=options.owner or options.user,
+                       video_format=options.format or 'mp4',
+                       title=options.title)
 
 
 def _run_tag(client, options, args):
-  entries = client.build_entry_list(query=options.query,
+  entries = client.build_entry_list(user=options.owner or options.user,
+                                    query=options.query,
                                     title=options.title,
                                     force_photos=True)
   if entries:
     client.TagPhotos(entries, options.tags)
   else:
-    print 'No matches for the title and/or query you gave.'
+    LOG.error('No matches for the title and/or query you gave.')
 
 
 TASKS = {'create': googlecl.service.Task('Create an album',
@@ -360,20 +437,22 @@ TASKS = {'create': googlecl.service.Task('Create an album',
                                          args_desc='PATH_TO_PHOTOS'), 
          'post': googlecl.service.Task('Post photos to an album',
                                        callback=_run_post,
-                                       required='title', optional='tags',
+                                       required='title',
+                                       optional=['tags', 'owner'],
                                        args_desc='PATH_TO_PHOTOS'), 
          'delete': googlecl.service.Task('Delete photos or albums',
                                          callback=_run_delete,
                                          required=[['title', 'query']]),
          'list': googlecl.service.Task('List photos', callback=_run_list,
                                        required=['delimiter'],
-                                       optional=['title', 'query']),
+                                       optional=['title', 'query', 'owner']),
          'list-albums': googlecl.service.Task('List albums',
                                               callback=_run_list_albums,
                                               required=['delimiter'],
-                                              optional=['title']),
-         'get': googlecl.service.Task('Download photos', callback=_run_get,
-                                      optional=['title', 'query'], 
+                                              optional=['title', 'owner']),
+         'get': googlecl.service.Task('Download albums', callback=_run_get,
+                                      optional=['title', 'owner', 'format'], 
                                       args_desc='LOCATION'),
          'tag': googlecl.service.Task('Tag photos', callback=_run_tag,
-                                      required=['tags', ['title', 'query']])}
+                                      required=['tags', ['title', 'query']],
+                                      optional='owner')}
diff --git a/src/googlecl/service.py b/src/googlecl/service.py
index c46785c..98ebdf6 100644
--- a/src/googlecl/service.py
+++ b/src/googlecl/service.py
@@ -19,20 +19,17 @@
 
 import gdata.service
 import googlecl
+import logging
 import re
 
-
 DATE_FORMAT = '%Y-%m-%d'
+LOG = logging.getLogger(googlecl.LOGGER_NAME)
 
 
 class Error(Exception):
   """Base error for GoogleCL exceptions."""
   pass
 
-class LeftoverData(Warning):
-  """Data left on server because of max_results and cap_results settings."""
-  pass
-
 
 class BaseServiceCL(gdata.service.GDataService):
 
@@ -40,7 +37,7 @@ class BaseServiceCL(gdata.service.GDataService):
 
   def _set_params(self, section):
     """Set some basic attributes common to all instances."""
-    LARGE_MAX_RESULTS = 10000
+    large_max_results = 10000
     # Because each new xxxServiceCL class should use the more specific
     # superclass's __init__ function, don't define one here.
     self.source = 'GoogleCL'
@@ -64,11 +61,12 @@ class BaseServiceCL(gdata.service.GDataService):
                                                   type=bool)
     self.max_results = googlecl.get_config_option(section,
                                                   'max_results',
-                                                  default=LARGE_MAX_RESULTS,
+                                                  default=large_max_results,
                                                   type=int)
-    # Prevent user from shooting self in foot...
-    if not self.cap_results and self.max_results < LARGE_MAX_RESULTS:
-      self.max_results = LARGE_MAX_RESULTS
+    if (self.service != 'youtube' and
+        (not self.cap_results and self.max_results < large_max_results)):
+      LOG.warning('You are requesting only ' + str(self.max_results) +
+                  ' results per query -- this may be slow')
 
   def delete(self, entries, entry_type, delete_default):
     """Extends Delete to handle a list of entries.
@@ -97,11 +95,11 @@ class BaseServiceCL(gdata.service.GDataService):
         try:
           gdata.service.GDataService.Delete(self, item.GetEditLink().href)
         except gdata.service.RequestError, err:
-          print 'Could not delete ' + entry_type + ': ' + str(err)
+          LOG.warning('Could not delete ' + entry_type + ': ' + str(err))
 
   Delete = delete
 
-  def get_email(self, _uri=None):
+  def get_email(self, _uri=None, redirects_remaining=4):
     """Get the email address that has the OAuth access token.
 
     Uses the "Email address" scope to return the email address the user
@@ -136,7 +134,8 @@ class BaseServiceCL(gdata.service.GDataService):
         location = (server_response.getheader('Location') or
                     server_response.getheader('location'))
         if location is not None:
-          return BaseServiceCL.get_email(location)
+          return BaseServiceCL.get_email(location,
+                                      redirects_remaining=redirects_remaining-1)
         else:
           raise gdata.service.RequestError, {'status': server_response.status,
                 'reason': '302 received without Location header',
@@ -165,7 +164,6 @@ class BaseServiceCL(gdata.service.GDataService):
       List of entries.
     
     """
-    import warnings
     uri = set_max_results(uri, self.max_results)
     try:
       if converter:
@@ -173,14 +171,13 @@ class BaseServiceCL(gdata.service.GDataService):
       else:
         feed = self.GetFeed(uri)
     except gdata.service.RequestError, err:
-      print 'Failed to get entries: ' + str(err)
+      LOG.error('Failed to get entries: ' + str(err))
       return []
     all_entries = feed.entry
     if feed.GetNextLink():
       if self.cap_results:
-        warnings.warn('Leaving data that matches query on server.' +
-                      ' Increase max_results or set cap_results to False.',
-                      LeftoverData, stacklevel=2)
+        LOG.warning('Leaving data that matches query on server.' +
+                    ' Increase max_results or set cap_results to False.')
       else:
         while feed and feed.GetNextLink():
           feed = self.GetNext(feed)
@@ -189,8 +186,13 @@ class BaseServiceCL(gdata.service.GDataService):
     if not title:
       return all_entries
     if self.use_regex:
-      return [entry for entry in all_entries 
-              if entry.title.text and re.match(title,entry.title.text)]
+      try:
+        return [entry for entry in all_entries 
+                if entry.title.text and re.match(title,entry.title.text)]
+      except re.error, err:
+        LOG.error('Regular expression error: ' + str(err) + '!')
+        LOG.debug('regex provided: ' + title)
+        return []
     else:
       return [entry for entry in all_entries if title == entry.title.text]
 
@@ -258,14 +260,14 @@ class BaseServiceCL(gdata.service.GDataService):
     except gdata.service.RequestError, err:
       # If the complaint is NOT about the token, print the error message.
       if err.args[0]['body'].lower().find('token invalid') == -1:
-        print 'Token invalid! ' + str(err)
+        LOG.info('Token invalid! ' + str(err))
       return False
     else:
       return True
 
   IsTokenValid = is_token_valid
 
-  def request_access(self, domain, scopes=None):
+  def request_access(self, domain, hostid, scopes=None):
     """Do all the steps involved with getting an OAuth access token.
     
     Keyword arguments:
@@ -284,7 +286,7 @@ class BaseServiceCL(gdata.service.GDataService):
     self.SetOAuthInputParameters(gdata.auth.OAuthSignatureMethod.HMAC_SHA1,
                                  consumer_key='anonymous',
                                  consumer_secret='anonymous')
-    display_name = 'GoogleCL'
+    display_name = 'GoogleCL %s' % hostid
     fetch_params = {'xoauth_displayname':display_name}
     # First and third if statements taken from
     # gdata.service.GDataService.FetchOAuthRequestToken.
@@ -300,7 +302,7 @@ class BaseServiceCL(gdata.service.GDataService):
       request_token = self.FetchOAuthRequestToken(scopes=scopes,
                                                   extra_parameters=fetch_params)
     except gdata.service.FetchingOAuthRequestTokenFailed, err:
-      print err[0]['body'].strip() + '; Request token retrieval failed!'
+      LOG.error(err[0]['body'].strip() + '; Request token retrieval failed!')
       return False
     auth_params = {'hd': domain}
     auth_url = self.GenerateOAuthAuthorizationURL(request_token=request_token,
@@ -314,7 +316,7 @@ class BaseServiceCL(gdata.service.GDataService):
         browser = webbrowser.get(browser_str)
       browser.open(auth_url)
     except webbrowser.Error, err:
-      print 'Failed to launch web browser: ' + str(err)
+      LOG.info('Failed to launch web browser: ' + str(err))
     message = 'Please log in and/or grant access via your browser at ' +\
               auth_url + ' then hit enter.'
     raw_input(message)
@@ -322,7 +324,7 @@ class BaseServiceCL(gdata.service.GDataService):
     try:
       self.UpgradeToOAuthAccessToken(request_token)
     except gdata.service.TokenUpgradeFailed:
-      print 'Token upgrade failed! Could not get OAuth access token.'
+      LOG.error('Token upgrade failed! Could not get OAuth access token.')
       return False
     else:
       return True
@@ -442,111 +444,168 @@ class Task(object):
 
   def _not_impl(self, *args):
     """Just use this as a place-holder for Task callbacks."""
-    print 'Sorry, this task is not yet implemented!'
+    LOG.error('Sorry, this task is not yet implemented!')
+
+
+class BaseEntryToStringWrapper(object):
+  """Wraps GDataEntries to easily get human-readable data."""
+  def __init__(self, gdata_entry,
+               intra_property_delimiter='',
+               label_delimiter=' '):
+    """Constructor.
+
+    Keyword arguments:
+      gdata_entry: The GDataEntry to extract data from.
+      intra_property_delimiter: Delimiter to distinguish between multiple
+                   values in a single property (e.g. multiple email addresses).
+                   Default '' (there will always be at least one space).
+      label_delimiter: String to place in front of a label for intra-property
+                       values. For example, for a contact with multiple phone
+                       numbers, ':' would yield "Work:<number> Home:<number>"
+                       Default ' ' (there is no whitespace between label and
+                       value if it is not specified).
+                       Set as NoneType to omit labels entirely.
+
+    """
+    self.entry = gdata_entry
+    self.intra_property_delimiter = intra_property_delimiter
+    self.label_delimiter = label_delimiter
+
+  @property
+  def title(self):
+    """Title or name."""
+    return self.entry.title.text
+  name = title
+
+  @property
+  def url(self):
+    """url_direct or url_site, depending on url_style defined in config."""
+    return self._url(googlecl.get_config_option('GENERAL', 'url_style'))
+
+  @property
+  def url_direct(self):
+    """Url that leads directly to content."""
+    return self._url('direct')
+
+  @property
+  def url_site(self):
+    """Url that leads to site hosting content."""
+    return self._url('site')
+
+  def _url(self, substyle):
+    if not self.entry.GetHtmlLink():
+      href = ''
+    else:
+      href = self.entry.GetHtmlLink().href
+
+    if substyle == 'direct':
+      return self.entry.content.src or href
+    return href or self.entry.content.src
+
+  @property
+  def summary(self):
+    """Summary or description."""
+    try:
+      # Try to access the "default" description
+      value = self.entry.media.description.text
+    except AttributeError:
+      # If it's not there, try the summary attribute
+      value = self.entry.summary.text
+    else:
+      if not value:
+        # If the "default" description was there, but it was empty,
+        # try the summary attribute.
+        value = self.entry.summary.text
+    return value
+  description = summary
 
+  @property
+  def tags(self):
+    """Tags / keywords or labels."""
+    try:
+      return self.entry.media.description.keywords.text
+    except AttributeError:
+      # Blogger uses categories.
+      return self.intra_property_delimiter.join(
+                                [c.term for c in self.entry.category if c.term])
+  labels = tags
+  keywords = tags
+
+  @property
+  def xml(self):
+    """Raw XML."""
+    return str(self.entry)
+
+  def _extract_label(self, entry_list_item, label_attr=None):
+    """Determine the human-readable label of the item."""
+    if label_attr and hasattr(entry_list_item, label_attr):
+      scheme_or_label = getattr(entry_list_item, label_attr)
+    elif hasattr(entry_list_item, 'rel'):
+      scheme_or_label = entry_list_item.rel
+    elif hasattr(entry_list_item, 'label'):
+      scheme_or_label = entry_list_item.label
+    else:
+      return None
 
-def entry_to_string(entry, style_list, delimiter, missing_field_value=None):
+    if scheme_or_label:
+      return scheme_or_label[scheme_or_label.find('#')+1:]
+    else:
+      return None
+
+  def _join(self, entry_list, text_attribute='text',
+            text_extractor=None, label_attribute=None):
+    """Join a list of entries into a string.
+
+    Keyword arguments:
+      entry_list: List of entries to be joined.
+      text_attribute: String of the attribute that will give human readable
+                      text for each entry in entry_list. Default 'text'.
+      text_extractor: Function that can be used to get desired text.
+                      Default None. Use this if the readable data is buried
+                      deeper than a single attribute.
+      label_attribute: If the attribute for the label is not 'rel' or 'label'
+                       it can be specified here.
+
+    Returns:
+      String from joining the items in entry_list.
+
+    """
+    if not text_extractor:
+      if not text_attribute:
+        raise Error('One of "text_extractor" or ' +
+                    '"text_attribute" must be defined!')
+      text_extractor = lambda entry: getattr(entry, text_attribute)
+
+    if self.label_delimiter is None:
+      return self.intra_property_delimiter.join([text_extractor(e)
+                                                 for e in entry_list
+                                                 if text_extractor(e)])
+    else:
+      separating_string = self.intra_property_delimiter + ' '
+      joined_string = ''
+      for entry in entry_list:
+        if self.label_delimiter is not None:
+          label = self._extract_label(entry, label_attr=label_attribute)
+          if label:
+            joined_string += label + self.label_delimiter
+        joined_string += text_extractor(entry) + separating_string
+      return joined_string.rstrip(separating_string)
+
+
+def compile_entry_string(entry, attribute_list, delimiter,
+                         missing_field_value=None):
   """Return a useful string describing a gdata.data.GDEntry.
   
   Keyword arguments:
-    entry: Entry to convert to string.
-    style_list: List of strings that describe what the return string should be
-           composed of. Valid style strings are:
-           'title', 'name' - title of the entry (entry.title.text).
-           'url' - treated as 'url-direct' or 'url-site' depending on
-                   setting in preferences file.
-           'url-site' - url of the site associated with the entry
-                        (entry.GetHtmlLink().href).
-           'url-direct' - url directly to the resource 
-                          (entry.content.src).
-           'author' - author of the entry (entry.author[:].name.text).
-           'email' - email addresses of entry (entry.email[:].address),
-           'where' - location associated with the entry
-                     (entry.where[:].value_string).
-           'when' - time of the entry
-                    (entry.when[:].start_time - entry.when[:].end_time)
-           'summary', 'description', 'desc' - Summary / caption / description
-                    of the entry (entry.media.description.text or
-                    entry.summary.text).
-           'tags', 'labels' - Keywords / tags / labels of the entry
-                              (entry.media.description.keywords.text or
-                              entry.categories[:].term).
-           'xml' - Dump the xml of the entry.
-                               
-           The difference between url-site and url-direct is best exemplified
-           by a picasa PhotoEntry: 'url-site' gives a link to the photo in the
-           user's album, 'url-direct' gives a link to the image url.
-           If 'url-direct' is specified but is not applicable, 'url-site' is
-           placed in its stead, and vice-versa.
-    delimiter: String to use as the delimiter between fields.
+    wrapped_entry: BaseEntryToStringWrapper to display.
+    attribute_list: List of attributes to access
+    delimiter: String to use as the delimiter between attributes.
     missing_field_value: If any of the styles for any of the entries are
                          invalid or undefined, put this in its place
                          (Default None to use "missing_field_value" config
                          option).
-    
   
   """
-  def _string_for_style(style, entry, join_string):
-    """Figure out the string to return that matches the requested style."""
-    from googlecl.calendar.service import get_datetimes
-    import time
-
-    # We can access attributes willy-nilly, since this function is wrapped in
-    # a try block.
-    value = ''
-    if style == 'title' or style == 'name':
-      value = entry.title.text
-    elif style[:3] == 'url':
-      substyle = style[4:] or googlecl.CONFIG.get('GENERAL', 'url_style')
-      if not entry.GetHtmlLink():
-        href = ''
-      else:
-        href = entry.GetHtmlLink().href
-      if substyle == 'direct':
-        value = entry.content.src or href
-      else:
-        value = href or entry.content.src
-    elif style == 'author' and entry.author:
-      value = join_string.join([a.name.text for a in entry.author])
-    elif style == 'email':
-      value = join_string.join([e.address for e in entry.email])
-    elif style == 'when':
-      start_time_data, end_time_data, freq = get_datetimes(entry)
-      print_format = googlecl.CONFIG.get('GENERAL', 'date_print_format')
-      start_time = time.strftime(print_format, start_time_data)
-      end_time = time.strftime(print_format, end_time_data)
-      value = start_time + ' - ' + end_time
-      if freq:
-        if freq.has_key('BYDAY'):
-          value += ' (' + freq['BYDAY'].lower() + ')'
-        else:
-          value += ' (' + freq['FREQ'].lower() + ')'
-    elif style == 'where':
-      value = join_string.join([w.value_string for w in entry.where
-                                if w.value_string])
-    elif style == 'summary' or style[:4] == 'desc':
-      try:
-        # Try to access the "default" description
-        value = entry.media.description.text
-      except AttributeError:
-        # If it's not there, try the summary attribute
-        value = entry.summary.text
-      else:
-        if not value:
-          # If the "default" description was there, but it was empty,
-          # try the summary attribute.
-          value = entry.summary.text
-    elif style == 'tags' or style == 'labels':
-      try:
-        value = entry.media.description.keywords.text
-      except AttributeError:
-        # Blogger uses categories.
-        value = join_string.join([c.term for c in entry.category if c.term])
-    elif style == 'xml':
-      value = str(entry)
-    else:
-      raise ValueError("'Unknown listing style: '" + style + "'")
-    return value
 
   return_string = ''
   missing_field_value = missing_field_value or googlecl.CONFIG.get('GENERAL',
@@ -554,22 +613,21 @@ def entry_to_string(entry, style_list, delimiter, missing_field_value=None):
   if not delimiter:
     delimiter = ','
   if delimiter.strip() == ',':
-    join_string = ';'
+    entry.intra_property_delimiter = ';'
   else:
-    join_string = ','
-  for style in style_list:
-    val = ''
+    entry.intra_property_delimiter = ','
+  for attr in attribute_list:
     try:
       # Get the value, replacing NoneTypes and empty strings
       # with the missing field value.
-      val = _string_for_style(style, entry, join_string) or missing_field_value
+      val = getattr(entry, attr) or missing_field_value
     except ValueError, err:
-      print err.args[0] + ' (Did not add value for style ' + style + ')'
+      LOG.debug(err.args[0] + ' (Did not add value for style ' + attr + ')')
     except AttributeError, err:
-      return_string += missing_field_value
+      val = missing_field_value
     # Ensure the delimiter won't appear in a non-delineation role,
     # but let it slide if the raw xml is being dumped
-    if style != 'xml':
+    if attr != 'xml':
       return_string += val.replace(delimiter, ' ') + delimiter
     else:
       return_string = val
diff --git a/src/googlecl/youtube/__init__.py b/src/googlecl/youtube/__init__.py
index fafc967..00c0362 100644
--- a/src/googlecl/youtube/__init__.py
+++ b/src/googlecl/youtube/__init__.py
@@ -11,7 +11,8 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
+import googlecl
 
-
-SECTION_HEADER = 'YOUTUBE'
-
+service_name = __name__.split('.')[-1]
+LOGGER_NAME = googlecl.LOGGER_NAME + '.' + service_name
+SECTION_HEADER = service_name.upper()
diff --git a/src/googlecl/youtube/service.py b/src/googlecl/youtube/service.py
index bd48cfe..7fb677a 100644
--- a/src/googlecl/youtube/service.py
+++ b/src/googlecl/youtube/service.py
@@ -18,6 +18,7 @@
 
 __author__ = 'tom.h.miller at gmail.com (Tom Miller)'
 import gdata.youtube
+import logging
 import os
 import googlecl
 import googlecl.service
@@ -25,6 +26,9 @@ from googlecl.youtube import SECTION_HEADER
 from gdata.youtube.service import YouTubeService
 
 
+LOG = logging.getLogger(googlecl.youtube.LOGGER_NAME)
+
+
 class YouTubeServiceCL(YouTubeService, googlecl.service.BaseServiceCL):
   
   """Extends gdata.youtube.service.YouTubeService for the command line.
@@ -57,7 +61,8 @@ class YouTubeServiceCL(YouTubeService, googlecl.service.BaseServiceCL):
         self.UpdateVideoEntry(video)
       except gdata.service.RequestError, err:
         if err.args[0]['body'].find('invalid_value') != -1:
-          print 'Category update failed, ' + category + ' is not a category.'
+          LOG.error('Category update failed, ' + category +
+                    ' is not a category.')
         else:
           raise
 
@@ -113,8 +118,15 @@ class YouTubeServiceCL(YouTubeService, googlecl.service.BaseServiceCL):
         taglist = devtags.replace(', ', ',')
         taglist = taglist.split(',')
         video_entry.AddDeveloperTags(taglist)
-      print 'Loading ' + path
-      self.InsertVideoEntry(video_entry, path)
+      LOG.info('Loading ' + path)
+      try:
+        self.InsertVideoEntry(video_entry, path)
+      except gdata.service.RequestError, err:
+        if err.args[0]['body'].find('invalid_value') != -1:
+          err_str = 'Invalid category name'
+        else:
+          err_str = str(err)
+        LOG.error('Failed to upload video: ' + err_str)
 
   PostVideos = post_videos
 
@@ -186,20 +198,23 @@ def build_category(category):
 #        required
 #===============================================================================
 def _run_list(client, options, args):
-  entries = client.GetVideos(title=options.title)
+  entries = client.GetVideos(user=options.owner or options.user,
+                             title=options.title)
   if args:
     style_list = args[0].split(',')
   else:
     style_list = googlecl.get_config_option(SECTION_HEADER,
                                             'list_style').split(',')
   for vid in entries:
-    print googlecl.service.entry_to_string(vid, style_list,
-                                           delimiter=options.delimiter)
+    print googlecl.service.compile_entry_string(
+                                 googlecl.service.BaseEntryToStringWrapper(vid),
+                                 style_list,
+                                 delimiter=options.delimiter)
 
 
 def _run_post(client, options, args):
   if not args:
-    print 'Must provide path to video to post!'
+    LOG.error('Must provide path to video to post!')
     return
   client.PostVideos(args, title=options.title, desc=options.summary,
                     tags=options.tags, category=options.category)
@@ -225,7 +240,8 @@ TASKS = {'post': googlecl.service.Task('Post a video.', callback=_run_post,
                                        args_desc='PATH_TO_VIDEO'),
          'list': googlecl.service.Task('List videos by user.',
                                        callback=_run_list,
-                                       required='delimiter', optional='title'),
+                                       required='delimiter',
+                                       optional=['title', 'owner']),
          'tag': googlecl.service.Task('Add tags to a video and/or ' +\
                                       'change its category.',
                                       callback=_run_tag,

-- 
Packaging for googlecl



More information about the Pkg-google-commits mailing list