diff options
author | Ajay Garg <ajay@activitycentral.com> | 2012-05-03 19:33:13 (GMT) |
---|---|---|
committer | Anish Mangal <anish@activitycentral.com> | 2012-05-17 18:01:58 (GMT) |
commit | e27a8a7b0bf78bb2f3bc7032d5d663b94facf18a (patch) | |
tree | e7a05ca5158a1ee04d279747e768554f6d00054e | |
parent | 8b040a7dea9e30a6b3f8fe0e062fb99dcfbc0dd1 (diff) |
uy#1769: 1-to-N Feature
37 files changed, 6340 insertions, 19 deletions
diff --git a/configure.ac b/configure.ac index 8e6d871..e04bdec 100644 --- a/configure.ac +++ b/configure.ac @@ -61,8 +61,6 @@ extensions/cpsection/modemconfiguration/config.py extensions/cpsection/Makefile extensions/cpsection/network/Makefile extensions/cpsection/power/Makefile -extensions/cpsection/updater/backends/Makefile -extensions/cpsection/updater/Makefile extensions/deviceicon/Makefile extensions/globalkey/Makefile extensions/Makefile @@ -79,6 +77,8 @@ src/jarabe/model/Makefile src/jarabe/util/Makefile src/jarabe/util/telepathy/Makefile src/jarabe/view/Makefile +src/webdav/acp/Makefile +src/webdav/Makefile src/Makefile ]) diff --git a/src/Makefile.am b/src/Makefile.am index 83571a4..765da8b 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -1 +1,3 @@ -SUBDIRS = jarabe +SUBDIRS = \ + jarabe \ + webdav diff --git a/src/jarabe/journal/Makefile.am b/src/jarabe/journal/Makefile.am index f24dcfe..8efca6d 100644 --- a/src/jarabe/journal/Makefile.am +++ b/src/jarabe/journal/Makefile.am @@ -15,5 +15,6 @@ sugar_PYTHON = \ model.py \ objectchooser.py \ palettes.py \ - volumestoolbar.py \ - processdialog.py + volumestoolbar.py \ + processdialog.py \ + webdavmanager.py diff --git a/src/jarabe/journal/journalactivity.py b/src/jarabe/journal/journalactivity.py index fa308cd..1b841e9 100644 --- a/src/jarabe/journal/journalactivity.py +++ b/src/jarabe/journal/journalactivity.py @@ -485,6 +485,9 @@ class JournalActivity(JournalWindow): def is_editing_mode_present(self): return self._editing_mode + def get_volumes_toolbar(self): + return self._volumes_toolbar + def get_journal(): global _journal diff --git a/src/jarabe/journal/journaltoolbox.py b/src/jarabe/journal/journaltoolbox.py index fd14826..c3614d7 100644 --- a/src/jarabe/journal/journaltoolbox.py +++ b/src/jarabe/journal/journaltoolbox.py @@ -624,6 +624,13 @@ class BatchEraseButton(ToolButton, palettes.ActionItem): show_not_completed_ops_info=True) self.props.tooltip = _('Erase') + # De-sensitize Batch-Erase button, for locally-mounted-remote-shares. + from jarabe.journal.journalactivity import get_mount_point + current_mount_point = get_mount_point() + + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + self.set_sensitive(False) + def _get_actionable_signal(self): return 'clicked' diff --git a/src/jarabe/journal/listview.py b/src/jarabe/journal/listview.py index 8522dca..10f468f 100644 --- a/src/jarabe/journal/listview.py +++ b/src/jarabe/journal/listview.py @@ -634,6 +634,15 @@ class ListView(BaseListView): self.emit('volume-error', message, severity) def __icon_clicked_cb(self, cell, path): + # For locally-mounted remote shares, we do not want to launch + # by clicking on the icons. + # So, check if this is a part of locally-mounted-remote share, + # and if yes, return, without doing anything. + from jarabe.journal.journalactivity import get_mount_point + current_mount_point = get_mount_point() + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + return + row = self.tree_view.get_model()[path] metadata = model.get(row[ListModel.COLUMN_UID]) misc.resume(metadata) diff --git a/src/jarabe/journal/model.py b/src/jarabe/journal/model.py index 83e216f..527f78d 100644 --- a/src/jarabe/journal/model.py +++ b/src/jarabe/journal/model.py @@ -20,6 +20,7 @@ import logging import os +import stat import errno import subprocess from datetime import datetime @@ -36,11 +37,14 @@ import gobject import dbus import gio import gconf +import string from sugar import dispatch from sugar import mime from sugar import util +from jarabe.journal.webdavmanager import get_remote_webdav_share_metadata + DS_DBUS_SERVICE = 'org.laptop.sugar.DataStore' DS_DBUS_INTERFACE = 'org.laptop.sugar.DataStore' DS_DBUS_PATH = '/org/laptop/sugar/DataStore' @@ -425,6 +429,123 @@ class InplaceResultSet(BaseResultSet): return +class RemoteShareResultSet(object): + def __init__(self, ip_address, query): + self._ip_address = ip_address + self._file_list = [] + + self.ready = dispatch.Signal() + self.progress = dispatch.Signal() + + # First time, query is none. + if query is None: + return + + query_text = query.get('query', '') + if query_text.startswith('"') and query_text.endswith('"'): + self._regex = re.compile('*%s*' % query_text.strip(['"'])) + elif query_text: + expression = '' + for word in query_text.split(' '): + expression += '(?=.*%s.*)' % word + self._regex = re.compile(expression, re.IGNORECASE) + else: + self._regex = None + + if query.get('timestamp', ''): + self._date_start = int(query['timestamp']['start']) + self._date_end = int(query['timestamp']['end']) + else: + self._date_start = None + self._date_end = None + + self._mime_types = query.get('mime_type', []) + + self._sort = query.get('order_by', ['+timestamp'])[0] + + def setup(self): + metadata_list_complete = get_remote_webdav_share_metadata(self._ip_address) + for metadata in metadata_list_complete: + + add_to_list = False + if self._regex is not None: + for f in ['fulltext', 'title', + 'description', 'tags']: + if f in metadata and \ + self._regex.match(metadata[f]): + add_to_list = True + break + else: + add_to_list = True + if not add_to_list: + continue + + add_to_list = False + if self._date_start is not None: + if metadata['timestamp'] > self._date_start: + add_to_list = True + else: + add_to_list = True + if not add_to_list: + continue + + add_to_list = False + if self._date_end is not None: + if metadata['timestamp'] < self._date_end: + add_to_list = True + else: + add_to_list = True + if not add_to_list: + continue + + add_to_list = False + if self._mime_types: + mime_type = metadata['mime_type'] + if mime_type in self._mime_types: + add_to_list = True + else: + add_to_list = True + if not add_to_list: + continue + + # If control reaches here, the current metadata has passed + # out all filter-tests. + file_info = (metadata['timestamp'], + metadata['creation_time'], + metadata['filesize'], + metadata) + self._file_list.append(file_info) + + if self._sort[1:] == 'filesize': + keygetter = itemgetter(2) + elif self._sort[1:] == 'creation_time': + keygetter = itemgetter(1) + else: + # timestamp + keygetter = itemgetter(0) + + self._file_list.sort(lambda a, b: cmp(b, a), + key=keygetter, + reverse=(self._sort[0] == '-')) + + self.ready.send(self) + + def get_length(self): + return len(self._file_list) + + length = property(get_length) + + def seek(self, position): + self._position = position + + def read(self): + modified_timestamp, creation_timestamp, filesize, metadata = self._file_list[self._position] + return metadata + + def stop(self): + self._stopped = True + + def _get_file_metadata(path, stat, fetch_preview=True): """Return the metadata from the corresponding file. @@ -436,10 +557,16 @@ def _get_file_metadata(path, stat, fetch_preview=True): dir_path = os.path.dirname(path) metadata = _get_file_metadata_from_json(dir_path, filename, fetch_preview) if metadata: + # For Documents/Shares/Mounted-Drives. + # Special case: for locally-mounted-remote-files, ensure that + # "metadata['filesize' is already present before-hand. This + # will have to be done at the time of fetching + # webdav-properties per resource. if 'filesize' not in metadata: metadata['filesize'] = stat.st_size return metadata + # For Journal. return {'uid': path, 'title': os.path.basename(path), 'timestamp': stat.st_mtime, @@ -529,11 +656,33 @@ def find(query_, page_size): raise ValueError('Exactly one mount point must be specified') if mount_points[0] == '/': + """ + For Journal. + """ return DatastoreResultSet(query, page_size) + elif is_mount_point_for_locally_mounted_remote_share(mount_points[0]): + """ + For Locally-Mounted-Remote-Shares. + Regex Matching is used, to ensure that the mount-point is an + IP-Address. + """ + return RemoteShareResultSet(mount_points[0], query) else: + """ + For Documents/Shares/Mounted-Drives. + """ return InplaceResultSet(query, page_size, mount_points[0]) +def is_mount_point_for_locally_mounted_remote_share(mount_point): + import re + + pattern = '[1-9][0-9]{0,2}\.[0-9]{0,3}\.[0-9]{0,3}\.[0-9]{0,3}' + if re.match(pattern, mount_point) is None: + return False + return True + + def _get_mount_point(path): dir_path = os.path.dirname(path) while dir_path: @@ -544,14 +693,45 @@ def _get_mount_point(path): return None +def is_locally_mounted_remote_share(path): + return string.find(path, '/tmp/') == 0 + + +def extract_ip_address_from_locally_mounted_remote_share_path(path): + """ + Path is of type :: + + /tmp/127.0.0.1/webdav/a.txt + """ + return path.split('/')[2] + + def get(object_id): """Returns the metadata for an object """ - if os.path.exists(object_id): - stat = os.stat(object_id) + if (os.path.exists(object_id) or (is_locally_mounted_remote_share(object_id))): + """ + For Documents/Shares/Mounted-Drives/Locally-Mounted-Remote-Shares, + where ".Sugar-Metadata" folder exists. + + The only thing is that, for locally-mounted-remote-shares, the + "file" is not physically present. + """ + if os.path.exists(object_id): + # if the file is physically present, derive file-metadata + # by physical examination of the file. + stat = os.stat(object_id) + else: + # if the file is remote, derive file-metadata by fetching + # properties remotely (webdav properties). + stat = None + metadata = _get_file_metadata(object_id, stat) metadata['mountpoint'] = _get_mount_point(object_id) else: + """ + For journal, where ".Sugar-Metadata" folder does not exists. + """ metadata = _get_datastore().get_properties(object_id, byte_arrays=True) metadata['mountpoint'] = '/' return metadata @@ -561,9 +741,16 @@ def get_file(object_id): """Returns the file for an object """ if os.path.exists(object_id): + """ + For Documents/Shares/Mounted-Drives/ + Locally-Mounted-Remote-Shares-in-case-when-it-is-present-already. + """ logging.debug('get_file asked for file with path %r', object_id) return object_id else: + """ + For Journal. + """ logging.debug('get_file asked for entry with id %r', object_id) file_path = _get_datastore().get_filename(object_id) if file_path: @@ -754,6 +941,20 @@ def _write_entry_on_external_device(metadata, file_path): _rename_entry_on_external_device(file_path, destination_path, metadata_dir_path) + # For "Shares" folder, we need to set the permissions of the newly + # copied file to 0777, else it will not be accessible by "httpd" + # service. + if metadata['mountpoint'] == '/var/www/web1/web': + fd = os.open(destination_path, os.O_RDONLY) + os.fchmod(fd, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO) + os.close(fd) + + metadata_file_path = os.path.join(metadata_dir_path, file_name + '.metadata') + fd = os.open(metadata_file_path, os.O_RDONLY) + os.fchmod(fd, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO) + os.close(fd) + + object_id = destination_path created.send(None, object_id=object_id) diff --git a/src/jarabe/journal/palettes.py b/src/jarabe/journal/palettes.py index 740b65a..46ade98 100644 --- a/src/jarabe/journal/palettes.py +++ b/src/jarabe/journal/palettes.py @@ -29,6 +29,7 @@ import gconf import gio import glib import time +import socket from sugar import _sugarext @@ -45,6 +46,10 @@ from jarabe.model import mimeregistry from jarabe.journal import misc from jarabe.journal import model +from webdav.Connection import WebdavError +from jarabe.journal.webdavmanager import get_resource_by_ip_address_and_resource_key + + friends_model = friends.get_model() _copy_menu_helper = None @@ -77,6 +82,9 @@ class ObjectPalette(Palette): Palette.__init__(self, primary_text=title, icon=activity_icon) + from jarabe.journal.journalactivity import get_mount_point + current_mount_point = get_mount_point() + if misc.get_activities(metadata) or misc.is_bundle(metadata): if metadata.get('activity_id', ''): resume_label = _('Resume') @@ -86,10 +94,15 @@ class ObjectPalette(Palette): resume_with_label = _('Start with') menu_item = MenuItem(resume_label, 'activity-start') menu_item.connect('activate', self.__start_activate_cb) + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + menu_item.set_sensitive(False) self.menu.append(menu_item) menu_item.show() menu_item = MenuItem(resume_with_label, 'activity-start') + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + menu_item.set_sensitive(False) + self.menu.append(menu_item) menu_item.show() start_with_menu = StartWithMenu(self._metadata) @@ -101,6 +114,7 @@ class ObjectPalette(Palette): self.menu.append(menu_item) menu_item.show() + menu_item = MenuItem(_('Copy to')) icon = Icon(icon_name='edit-copy', xo_color=color, icon_size=gtk.ICON_SIZE_MENU) @@ -120,16 +134,21 @@ class ObjectPalette(Palette): copy_menu.connect('volume-error', self.__volume_error_cb) menu_item.set_submenu(copy_menu) + if self._metadata['mountpoint'] == '/': menu_item = MenuItem(_('Duplicate')) icon = Icon(icon_name='edit-duplicate', xo_color=color, icon_size=gtk.ICON_SIZE_MENU) menu_item.set_image(icon) menu_item.connect('activate', self.__duplicate_activate_cb) + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + menu_item.set_sensitive(False) self.menu.append(menu_item) menu_item.show() menu_item = MenuItem(_('Send to'), 'document-send') + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + menu_item.set_sensitive(False) self.menu.append(menu_item) menu_item.show() @@ -140,14 +159,19 @@ class ObjectPalette(Palette): if detail == True: menu_item = MenuItem(_('View Details'), 'go-right') menu_item.connect('activate', self.__detail_activate_cb) + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + menu_item.set_sensitive(False) self.menu.append(menu_item) menu_item.show() menu_item = MenuItem(_('Erase'), 'list-remove') menu_item.connect('activate', self.__erase_activate_cb) + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + menu_item.set_sensitive(False) self.menu.append(menu_item) menu_item.show() + def __start_activate_cb(self, menu_item): misc.resume(self._metadata) @@ -541,6 +565,31 @@ class ActionItem(gobject.GObject): self._post_operate_per_metadata_per_action(metadata) def _file_path_valid(self, metadata): + from jarabe.journal.journalactivity import get_mount_point + current_mount_point = get_mount_point() + + # Now, for locally mounted remote-shares, download the file. + # Note that, always download the file, to avoid the problems + # of stale-cache. + if model.is_mount_point_for_locally_mounted_remote_share(current_mount_point): + file_path = metadata['uid'] + filename = os.path.basename(file_path) + ip_address = model.extract_ip_address_from_locally_mounted_remote_share_path(file_path) + resource = get_resource_by_ip_address_and_resource_key(ip_address, '/webdav/' + filename) + download_file_path = '/tmp/' + ip_address + '/' + filename + try: + resource.downloadFile(download_file_path) + return True + except (WebdavError, socket.error), e: + error_message = e + logging.warn(error_message) + if self._batch_mode: + self._handle_error_alert(error_message, metadata) + else: + self.emit('volume-error', error_message, + _('Error')) + return False + file_path = model.get_file(metadata['uid']) if not file_path or not os.path.exists(file_path): logging.warn('Entries without a file cannot be copied.') @@ -708,6 +757,25 @@ class DocumentsMenu(BaseCopyMenuItem): self._post_operate_per_metadata_per_action(metadata) +class SharesMenu(BaseCopyMenuItem): + def __init__(self, metadata_list, show_editing_alert, + show_progress_info_alert, batch_mode): + BaseCopyMenuItem.__init__(self, metadata_list, _('Shares'), + show_editing_alert, + show_progress_info_alert, + batch_mode) + + def _operate(self, metadata): + if not self._file_path_valid(metadata): + return False + if not self._metadata_copy_valid(metadata, + '/var/www/web1/web'): + return False + + # This is sync-operation. Call the post-operation now. + self._post_operate_per_metadata_per_action(metadata) + + class FriendsMenu(gtk.Menu): __gtype_name__ = 'JournalFriendsMenu' @@ -835,6 +903,17 @@ class CopyMenuHelper(gtk.Menu): menu.append(documents_menu) documents_menu.show() + if get_mount_point() != '/var/www/web1/web': + documents_menu = SharesMenu(metadata_list, + show_editing_alert, + show_progress_info_alert, + batch_mode) + documents_menu.set_image(Icon(icon_name='emblem-neighborhood-shared', + icon_size=gtk.ICON_SIZE_MENU)) + documents_menu.connect('volume-error', self.__volume_error_cb) + menu.append(documents_menu) + documents_menu.show() + if get_mount_point() != '/': client = gconf.client_get_default() color = XoColor(client.get_string('/desktop/sugar/user/color')) diff --git a/src/jarabe/journal/volumestoolbar.py b/src/jarabe/journal/volumestoolbar.py index 94914e6..1f6e3ec 100644 --- a/src/jarabe/journal/volumestoolbar.py +++ b/src/jarabe/journal/volumestoolbar.py @@ -37,8 +37,10 @@ from sugar.graphics.palette import Palette from sugar.graphics.xocolor import XoColor from sugar import env +from jarabe.frame.notification import NotificationIcon from jarabe.journal import model -from jarabe.view.palettes import JournalVolumePalette, JournalXSPalette +from jarabe.view.palettes import JournalVolumePalette, JournalXSPalette, RemoteSharePalette +import jarabe.frame _JOURNAL_0_METADATA_DIR = '.olpc.store' @@ -209,6 +211,7 @@ class VolumesToolbar(gtk.Toolbar): def _set_up_volumes(self): self._set_up_documents_button() + self._set_up_shares_button() volume_monitor = gio.volume_monitor_get() self._mount_added_hid = volume_monitor.connect('mount-added', @@ -219,12 +222,11 @@ class VolumesToolbar(gtk.Toolbar): for mount in volume_monitor.get_mounts(): self._add_button(mount) - def _set_up_documents_button(self): - documents_path = model.get_documents_path() - if documents_path is not None: - button = DocumentsButton(documents_path) + def _set_up_directory_button(self, dir_path, icon_name, label_text): + if dir_path is not None: + button = DirectoryButton(dir_path, icon_name) button.props.group = self._volume_buttons[0] - label = glib.markup_escape_text(_('Documents')) + label = glib.markup_escape_text(label_text) button.set_palette(Palette(label)) button.connect('toggled', self._button_toggled_cb) button.show() @@ -234,6 +236,39 @@ class VolumesToolbar(gtk.Toolbar): self._volume_buttons.append(button) self.show() + def _set_up_documents_button(self): + documents_path = model.get_documents_path() + self._set_up_directory_button(documents_path, + 'user-documents', + _('Documents')) + + def _set_up_shares_button(self): + shares_dir_path = '/var/www/web1/web' + self._set_up_directory_button(shares_dir_path, + 'emblem-neighborhood-shared', + _('Shares')) + + def _add_remote_share_button(self, buddy): + button = RemoteSharesButton(buddy) + button.props.group = self._volume_buttons[0] + label = glib.markup_escape_text(_('%s\'s share') % \ + buddy.props.nick) + button.set_palette(RemoteSharePalette(buddy, button)) + button.connect('toggled', self._button_toggled_cb) + button.show() + + position = self.get_item_index(self._volume_buttons[-1]) + 1 + self.insert(button, position) + self._volume_buttons.append(button) + self.show() + + frame = jarabe.frame.get_view() + notif_icon = NotificationIcon() + notif_icon.props.icon_name = 'emblem-neighborhood-shared' + notif_icon.props.xo_color = buddy.props.color + frame.add_notification(notif_icon, + gtk.CORNER_BOTTOM_RIGHT) + def __mount_added_cb(self, volume_monitor, mount): self._add_button(mount) @@ -271,8 +306,8 @@ class VolumesToolbar(gtk.Toolbar): def __volume_error_cb(self, button, strerror, severity): self.emit('volume-error', strerror, severity) - def _button_toggled_cb(self, button): - if button.props.active: + def _button_toggled_cb(self, button, force_toggle=False): + if button.props.active or force_toggle: self.emit('volume-changed', button.mount_point) def _unmount_activated_cb(self, menu_item, mount): @@ -300,6 +335,19 @@ class VolumesToolbar(gtk.Toolbar): if len(self.get_children()) < 2: self.hide() + def _remove_remote_share_button(self, mount_point): + # Here, IP_Address is the mount_point. + for button in self.get_children(): + if type(button) == RemoteSharesButton and \ + button.mount_point == mount_point: + self._volume_buttons.remove(button) + self.remove(button) + self.get_children()[0].props.active = True + + if len(sel.get_children()) < 2: + self.hide() + break; + def set_active_volume(self, mount): button = self._get_button_for_mount(mount) button.props.active = True @@ -313,6 +361,12 @@ class VolumesToolbar(gtk.Toolbar): if button.mount_point != mount_point: button.set_sensitive(sensitive) + def get_journal_button(self): + return self._volume_buttons[0] + + def get_button_toggled_cb(self): + return self._button_toggled_cb + class BaseButton(RadioToolButton): __gsignals__ = { @@ -427,18 +481,34 @@ class JournalButtonPalette(Palette): {'free_space': free_space / (1024 * 1024)} -class DocumentsButton(BaseButton): +class DirectoryButton(BaseButton): - def __init__(self, documents_path): - BaseButton.__init__(self, mount_point=documents_path) + def __init__(self, dir_path, icon_name): + BaseButton.__init__(self, mount_point=dir_path) - self.props.named_icon = 'user-documents' + self.props.named_icon = icon_name client = gconf.client_get_default() color = XoColor(client.get_string('/desktop/sugar/user/color')) self.props.xo_color = color +class RemoteSharesButton(BaseButton): + + def __init__(self, buddy): + BaseButton.__init__(self, mount_point=buddy.props.ip_address) + + self._buddy = buddy + self.props.named_icon = 'emblem-neighborhood-shared' + self.props.xo_color = buddy.props.color + self._buddy_ip_address = buddy.props.ip_address + + def create_palette(self): + palette = RemoteSharePalette(self._buddy) + return palette + + + class XSButton(ToolButton): def __init__(self): ToolButton.__init__(self) diff --git a/src/jarabe/journal/webdavmanager.py b/src/jarabe/journal/webdavmanager.py new file mode 100644 index 0000000..6cd0713 --- /dev/null +++ b/src/jarabe/journal/webdavmanager.py @@ -0,0 +1,256 @@ +from gettext import gettext as _ + +import os +import sys + +import gobject +import simplejson +import shutil + +from webdav.Connection import AuthorizationError, WebdavError +from webdav.WebdavClient import CollectionStorer + +def get_key_from_resource(resource): + return resource.path + +def ensure_correct_remote_webdav_hierarchy(remote_webdav_share_resources, + remote_webdav_share_collections): + pass + #assert len(remote_webdav_share_collections.keys()) == 1 + +class WebDavUrlManager(gobject.GObject): + """ + This class holds all data, relevant to a WebDavUrl. + + One thing must be noted, that a valid WebDavUrl is the one which + may contain zero or more resources (files), or zero or more + collections (directories). + + Thus, following are valid WebDavUrls :: + + dav://1.2.3.4/webdav + dav://1.2.3.4/webdav/dir_1 + dav://1.2.3.4/webdav/dir_1/dir_2 + + but following are not :: + + dav://1.2.3.4/webdav/a.txt + dav://1.2.3.4/webdav/dir_1/b.jpg + dav://1.2.3.4/webdab/dir_1/dir_2/c.avi + """ + + def __init__(self, WebDavUrl, username, password): + self._WebDavUrl = WebDavUrl + self._username = username + self._password = password + + def _get_key_from_resource(self, resource): + return resource.path.encode(sys.getfilesystemencoding()) + + def _get_number_of_collections(self): + return len(self._remote_webdav_share_collections) + + def _get_resources_dict(self): + return self._remote_webdav_share_resources + + def _get_collections_dict(self): + return self._remote_webdav_share_collections + + def _get_resource_by_key(self, key): + return self._remote_webdav_share_resources[key]['resource'] + + def _get_metadata_list(self): + metadata_list = [] + for key in self._remote_webdav_share_resources.keys(): + metadata_list.append(self._remote_webdav_share_resources[key]['metadata']) + return metadata_list + + def _get_live_properties(self, resource_key): + resource_container = self._remote_webdav_share_resources[resource_key] + return resource_container['webdav-properties'] + + def _set_metadata_for_resource(self, key, metadata): + self._remote_webdav_share_resources[key]['metadata'] = metadata + + def _fetch_resources_and_collections(self): + webdavConnection = CollectionStorer(self._WebDavUrl, validateResourceNames=False) + + authFailures = 0 + while authFailures < 2: + try: + self._remote_webdav_share_resources = {} + self._remote_webdav_share_collections = {} + + try: + self._collection_contents = webdavConnection.getCollectionContents() + for resource, properties in self._collection_contents: + try: + key = self._get_key_from_resource(resource) + selected_dict = None + + if properties.getResourceType() == 'resource': + selected_dict = self._remote_webdav_share_resources + else: + selected_dict = self._remote_webdav_share_collections + + selected_dict[key] = {} + selected_dict[key]['resource'] = resource + selected_dict[key]['webdav-properties'] = properties + except UnicodeEncodeError: + print("Cannot encode resource path or properties.") + + return True + + except WebdavError, e: + # Note that, we need to deal with all errors, + # except "AuthorizationError", as that is not + # really an error from our perspective. + if not type(e) == AuthorizationError: + from jarabe.journal.journalactivity import get_journal + + error_message = e + get_journal()._volume_error_cb(None, error_message,_('Error')) + + # Simply return, in case of connection-not-available. + return False + + else: + # If this indeed is an Authorization Error, + # re-raise it, so that it is caught by the outer + # "except" block. + raise e + + + except AuthorizationError, e: + if self._username is None or self._password is None: + raise Exception("WebDav username or password is None. Please specify appropriate values.") + + if e.authType == "Basic": + webdavConnection.connection.addBasicAuthorization(self._username, self._password) + elif e.authType == "Digest": + info = parseDigestAuthInfo(e.authInfo) + webdavConnection.connection.addDigestAuthorization(self._username, self._password, realm=info["realm"], qop=info["qop"], nonce=info["nonce"]) + else: + raise + authFailures += 1 + + return False + +webdav_manager = {} + + +def get_resource_by_ip_address_and_resource_key(ip_address, key): + global webdav_manager + + if ip_address in webdav_manager.keys(): + root_webdav = webdav_manager[ip_address] + resources_dict = root_webdav._get_resources_dict() + resource_dict = resources_dict[key] + resource = resource_dict['resource'] + + return resource + + +def get_remote_webdav_share_metadata(ip_address): + protocol = 'dav://' + + root_webdav_url = '/webdav' + + complete_root_url = protocol + ip_address + root_webdav_url + + root_webdav = WebDavUrlManager(complete_root_url, 'test', 'olpc') + if root_webdav._fetch_resources_and_collections() is False: + # Return empty metadata list. + return [] + + # Keep reference to the "WebDavUrlManager", keyed by IP-Address. + global webdav_manager + webdav_manager[ip_address] = root_webdav + + + # Assert that the number of collections is only one at this url + # (i.e. only ".Sugar-Metadata" is present). + assert root_webdav._get_number_of_collections() == 1 + + root_sugar_metadata_url = root_webdav_url + '/.Sugar-Metadata' + + complete_root_sugar_metadata_url = protocol + ip_address + root_sugar_metadata_url + root_webdav_sugar_metadata = WebDavUrlManager(complete_root_sugar_metadata_url, 'test', 'olpc') + if root_webdav_sugar_metadata._fetch_resources_and_collections() is False: + # Return empty metadata list. + return [] + + # assert that the number of collections is zero at this url. + assert root_webdav_sugar_metadata._get_number_of_collections() == 0 + + # Now. associate sugar-metadata with each of the "root-webdav" + # resource. + root_webdav_resources = root_webdav._get_resources_dict() + root_webdav_sugar_metadata_resources = root_webdav_sugar_metadata._get_resources_dict() + + # Prepare the metadata-download folder. + downloaded_data_root_dir = '/tmp/' + ip_address + downloaded_metadata_file_dir = downloaded_data_root_dir + '/.Sugar-Metadata' + if os.path.isdir(downloaded_data_root_dir): + shutil.rmtree(downloaded_data_root_dir) + os.makedirs(downloaded_metadata_file_dir) + + for root_webdav_resource_name in root_webdav_resources.keys(): + """ + root_webdav_resource_name is of the type :: + + /webdav/a.txt + """ + split_tokens_array = root_webdav_resource_name.split('/') + + # This will provide us with "a.txt" + basename = split_tokens_array[len(split_tokens_array) - 1] + + # This will provide us with "a.txt.metadata" + sugar_metadata_basename = basename + '.metadata' + + # Thus will provide us with "/webdav/.Sugar-Metadata/a.txt.metadata" + sugar_metadata_url = root_sugar_metadata_url + '/' + sugar_metadata_basename + + # Ensure that "sugar_metadata_url" is present as one of the + # keys in "root_webdav_sugar_metadata_resources" + assert sugar_metadata_url in root_webdav_sugar_metadata_resources.keys() + + # Now download the metadata file, read its contents, and store + # the metadata in memory. + # It is assumed that the metadata-file is small enough to be + # read in one call to "read". + + downloaded_metadata_file_path = downloaded_metadata_file_dir + '/' + sugar_metadata_basename + metadata_resource = root_webdav_sugar_metadata._get_resource_by_key(sugar_metadata_url) + metadata_resource.downloadFile(downloaded_metadata_file_path) + + file_pointer = open(downloaded_metadata_file_path) + metadata = eval(file_pointer.read()) + file_pointer.close() + + # Very critical. + # 1. CRITICAL ONE: + # Fill in the uid. + # Note that the file is not physically present. + metadata['uid'] = downloaded_data_root_dir + '/' + basename + + # 2. CRITICAL TWO: + # Fill in the properties, that can only be done by reading + # in the webdav-properties. + live_properties = root_webdav._get_live_properties(root_webdav_resource_name) + metadata['filesize'] = live_properties.getContentLength() + metadata['timestamp'] = live_properties.getLastModified() + metadata['creation_time'] = live_properties.getCreationDate() + + # Now, write this to the metadata-file, so that + # webdav-properties get gelled into sugar-metadata. + + file_pointer = open(downloaded_metadata_file_path, 'w') + file_pointer.write(simplejson.dumps(metadata)) + file_pointer.close() + + root_webdav._set_metadata_for_resource(root_webdav_resource_name, + metadata) + + return root_webdav._get_metadata_list() diff --git a/src/jarabe/model/buddy.py b/src/jarabe/model/buddy.py index 8f17d7e..c088aa9 100644 --- a/src/jarabe/model/buddy.py +++ b/src/jarabe/model/buddy.py @@ -43,6 +43,7 @@ class BaseBuddyModel(gobject.GObject): self._color = None self._tags = None self._current_activity = None + self._ip_address = None gobject.GObject.__init__(self, **kwargs) @@ -87,6 +88,16 @@ class BaseBuddyModel(gobject.GObject): getter=get_current_activity, setter=set_current_activity) + def get_ip_address(self): + return self._ip_address + + def set_ip_address(self, ip_address): + self._ip_address = ip_address + + ip_address = gobject.property(type=object, + getter=get_ip_address, + setter=set_ip_address) + def is_owner(self): raise NotImplementedError diff --git a/src/jarabe/model/neighborhood.py b/src/jarabe/model/neighborhood.py index 4528a15..39f648e 100644 --- a/src/jarabe/model/neighborhood.py +++ b/src/jarabe/model/neighborhood.py @@ -930,6 +930,9 @@ class Neighborhood(gobject.GObject): if 'key' in properties: buddy.props.key = properties['key'] + if 'ip4-address' in properties: + buddy.props.ip_address = properties['ip4-address'] + nick_key = CONNECTION_INTERFACE_ALIASING + '/alias' if nick_key in properties: buddy.props.nick = properties[nick_key] diff --git a/src/jarabe/view/buddymenu.py b/src/jarabe/view/buddymenu.py index de5a772..dfbcfa3 100644 --- a/src/jarabe/view/buddymenu.py +++ b/src/jarabe/view/buddymenu.py @@ -73,6 +73,12 @@ class BuddyMenu(Palette): self.menu.append(menu_item) menu_item.show() + access_buddy_remote_share_menu_item = MenuItem(_('Access Share'), 'list-add') + access_buddy_remote_share_menu_item.connect('activate', + self._access_share_cb) + self.menu.append(access_buddy_remote_share_menu_item) + access_buddy_remote_share_menu_item.show() + self._invite_menu = MenuItem('') self._invite_menu.connect('activate', self._invite_friend_cb) self.menu.append(self._invite_menu) @@ -83,6 +89,11 @@ class BuddyMenu(Palette): activity = home_model.get_active_activity() self._update_invite_menu(activity) + def _access_share_cb(self, menuitem): + from jarabe.journal.journalactivity import get_journal + volumes_toolbar = get_journal().get_volumes_toolbar() + volumes_toolbar._add_remote_share_button(self._buddy) + def _add_my_items(self): item = MenuItem(_('Shutdown'), 'system-shutdown') item.connect('activate', self.__shutdown_activate_cb) diff --git a/src/jarabe/view/palettes.py b/src/jarabe/view/palettes.py index 7a17f32..3b26faf 100644 --- a/src/jarabe/view/palettes.py +++ b/src/jarabe/view/palettes.py @@ -336,3 +336,36 @@ class JournalXSPalette(Palette): def __journal_restore_activate_cb(self, menu_item, xs_hostname): dialog = XSRestoreDialog(xs_hostname) dialog.show() + + +class RemoteSharePalette(Palette): + def __init__(self, buddy, button): + Palette.__init__(self, label=('%s\'s share' % buddy.props.nick)) + self._buddy = buddy + self._button = button + + self.props.secondary_text = glib.markup_escape_text(buddy.props.ip_address) + + vbox = gtk.VBox() + self.set_content(vbox) + vbox.show() + + self.connect('popup', self.__popup_cb) + + menu_item = MenuItem(pgettext('Share', 'Unmount')) + + icon = Icon(icon_name='media-eject', icon_size=gtk.ICON_SIZE_MENU) + menu_item.set_image(icon) + icon.show() + + menu_item.connect('activate', self.__unmount_activate_cb) + self.menu.append(menu_item) + menu_item.show() + + def __unmount_activate_cb(self, menu_item): + from jarabe.journal.journalactivity import get_journal + singleton_volumes_toolbar = get_journal().get_volumes_toolbar() + singleton_volumes_toolbar._remove_remote_share_button(self._buddy.props.ip_address) + + def __popup_cb(self, palette): + pass diff --git a/src/webdav/Condition.py b/src/webdav/Condition.py new file mode 100644 index 0000000..76acf94 --- /dev/null +++ b/src/webdav/Condition.py @@ -0,0 +1,475 @@ +# pylint: disable-msg=R0921,W0704,R0901,W0511,R0201 +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + + +""" +This module contains classes for creating a search condition according to the DASL draft. +The classes will output the WHERE part of a search request to a WebDAV server. + +Instances of the classes defined in this module form a tree data structure which represents +a search condition. This tree is made up of AND-nodes, OR-nodes, Operator- and comparison- +nodes and from property (i.e. variable) and constant leaf nodes. +""" + + +import types +from time import strftime +from calendar import timegm +from rfc822 import formatdate + +from webdav.Constants import NS_DAV, PROP_LAST_MODIFIED, DATE_FORMAT_ISO8601 + + +__version__ = "$Revision$"[11:-2] + + +class ConditionTerm(object): + """ + This is the abstact base class for all condition terms. + """ + def __init__(self): + pass + + def toXML(self): + """ + Abstact method which return a XML string which can be passed to a WebDAV server + for a search condition. + """ + raise NotImplementedError + + # start Tamino workaround for missing like-op: + def postFilter(self, resultSet): + """ + Abstact method for temporary workaround for Tamino's absense of the like-operator. + This method shall filter the given result set for those resources which match + all Contains-trems. + """ + return resultSet + # end of workaround + + +class IsCollectionTerm(ConditionTerm): + """ Leaf condition. Checks if the matching resources are collections. """ + + def __init__(self): + """ Constructor. """ + + ConditionTerm.__init__(self) + + def toXML(self): + """ + Returns XML encoding. + """ + + return "<D:is-collection/>" + + +class Literal(ConditionTerm): + """ + A leaf class for condition expressions representing a constant value. + """ + def __init__(self, literal): + ConditionTerm.__init__(self) + self.literal = literal + + def toXML(self): + ''' + Returns XML encoding. + ''' + return "<D:literal>" + self.literal + "</D:literal>" + + +class UnaryTerm(ConditionTerm): + """ + Base class of all nodes with a single child node. + """ + def __init__(self, child): + ConditionTerm.__init__(self) + self.child = child + + def toXML(self): + ''' + Returns XML encoding. + ''' + return self.child.toXML() + + +class BinaryTerm(ConditionTerm): + """ + Base class of all nodes with two child nodes + """ + def __init__(self, left, right): + ConditionTerm.__init__(self) + self.left = left + self.right = right + + def toXML(self): + ''' + Returns XML encoding. + ''' + return self.left.toXML() + self.right.toXML() + +class TupleTerm(ConditionTerm): + """ + Base class of all nodes with multiple single child nodes. + """ + def __init__(self, terms): + ConditionTerm.__init__(self) + self.terms = terms + + def addTerm(self, term): + ''' + Removes a term. + + @param term: term to add + ''' + self.terms.append(term) + + def removeTerm(self, term): + ''' + Adds a term. + + @param term: term to remove + ''' + try: + self.terms.remove(term) + except ValueError: + pass + + def toXML(self): + ''' + Returns XML encoding. + ''' + result = "" + for term in self.terms: + result += term.toXML() + return result + + +class AndTerm(TupleTerm): + """ + This class represents and logical AND-condition with multiple sub terms. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return "<D:and>" + TupleTerm.toXML(self) + "</D:and>" + + # start Tamino workaround for missing like-op: + def postFilter(self, resultSet): + ''' + Filters the given result set. This is a TAMINO WebDav server workaround + for the missing 'like' tag. + + @param resultSet: the result set that needs to be filtered. + ''' + for term in self.terms: + filtered = term.postFilter(resultSet) + resultSet = filtered + return resultSet + # end of workaround + +class OrTerm(TupleTerm): + """ + This class represents and logical OR-condition with multiple sub terms. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return "<D:or>" + TupleTerm.toXML(self) + "</D:or>" + + # start Tamino workaround for missing like-op: + def postFilter(self, resultSet): + ''' + Filters the given result set. This is a TAMINO WebDav server workaround + for the missing 'like' tag. + + @param resultSet: the result set that needs to be filtered. + ''' + raise NotImplementedError + + +class NotTerm(UnaryTerm): + """ + This class represents a negation term for the contained sub term. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + # start Tamino workaround for missing like-op: + if isinstance(self.child, ContainsTerm): + return "" + # end of workaround + return "<D:not>" + UnaryTerm.toXML(self) + "</D:not>" + + # start Tamino workaround for missing like-op: + def postFilter(self, resultSet): + ''' + Filters the given result set. This is a TAMINO WebDav server workaround + for the missing 'like' tag. + + @param resultSet: the result set that needs to be filtered. + ''' + if isinstance(self.child, ContainsTerm): + self.child.negate = 1 + # TODO: pass on filter + return self.child.postFilter(resultSet) + + +class ExistsTerm(UnaryTerm): + """ + Nodes of this class must have a single child with tuple type (of len 2) representing a + WebDAV property. + This leaf term evaluates to true if the (child) property exists. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return '<D:is-defined><D:prop xmlns="%s"><%s' % self.child + ' /></D:prop></D:is-defined>' + +class ContentContainsTerm(UnaryTerm): + """ + This class can be used to search for a given phrase in resources' contents. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return "<D:contains>" + self.child + "</D:contains>" + + + +class BinaryRelationTerm(BinaryTerm): + """ + This is the abstact base class for the following relation operands. + """ + def __init__(self, left, right): + BinaryTerm.__init__(self, left, right) + if isinstance(self.left, types.StringType): # Must be namespace + name pair + self.left = ('DAV:', self.left) + if not isinstance(self.right, Literal): + self.right = Literal(self.right) # Must be Literal instance + + def toXML(self): + ''' + Returns XML encoding. + ''' + ## TODO: extract name space and create shortcut for left element + return '<D:prop xmlns="%s"><%s /></D:prop>' % self.left + self.right.toXML() + + +class StringRelationTerm(BinaryRelationTerm): + """ + This is the abstact base class for the following string relation classes. + """ + def __init__(self, left, right, caseless=None): + """ + @param left: webdav property (namespace, name) + @param right: string/unicode literal + qparam caseless: 1 for case sensitive comparison + """ + BinaryRelationTerm.__init__(self, left, Literal(right)) + self.caseless = caseless + if self.caseless: + self.attrCaseless = "yes" + else: + self.attrCaseless = "no" + +class NumberRelationTerm(BinaryRelationTerm): + """ + This is the abstact base class for the following number comparison classes. + """ + def __init__(self, left, right): + """ + @param left: webdav property (namespace, name) + @param right: constant number + """ + ## TODO: implemet typed literal + BinaryRelationTerm.__init__(self, left, Literal(str(right))) + +class DateRelationTerm(BinaryRelationTerm): + """ + This is the abstact base class for the following date comparison classes. + """ + def __init__(self, left, right): + """ + @param left: webdav property (namespace, name) + @param right: string literal containing a date in ISO8601 format + """ + ## TODO: implemet typed literal + assert len(right) == 9, "No time is specified for literal: " + str(right) + BinaryRelationTerm.__init__(self, left, right) + if self.left == (NS_DAV, PROP_LAST_MODIFIED): + rfc822Time = formatdate(timegm(right)) # must not use locale setting + self.right = Literal(rfc822Time) + else: + self.right = Literal(strftime(DATE_FORMAT_ISO8601, right)) + + +class MatchesTerm(StringRelationTerm): + """ + Nodes of this class evaluate to true if the (child) property's value matches the (child) string. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return '<D:eq caseless="%s">' % self.attrCaseless + StringRelationTerm.toXML(self) + "</D:eq>" + +class ContainsTerm(StringRelationTerm): + """ + Nodes of this class evaluate to true if the (left child) property's value contains the + (right child) string. + """ + def __init__(self, left, right, isTaminoWorkaround=False): + right = unicode(right) + StringRelationTerm.__init__(self, left, "%" + right + "%") + # Tamino workaround: operator like is not yet implemented: + self.negate = 0 + self.isTaminoWorkaround = isTaminoWorkaround + + def toXML(self): + ''' + Returns XML encoding. + ''' + # Tamino workaround: operator like is not yet implemented: + # Produce a is-defined-condition instead + if self.isTaminoWorkaround: + return "<D:isdefined><D:prop xmlns='%s'><%s" % self.left + " /></D:prop></D:isdefined>" + else: + return '<D:like caseless="%s">' % self.attrCaseless + StringRelationTerm.toXML(self) + "</D:like>" + + # start Tamino workaround for missing like-op: + def postFilter(self, resultSet): + ''' + Filters the given result set. This is a TAMINO WebDav server workaround + for the missing 'like' tag. + + @param resultSet: the result set that needs to be filtered. + ''' + newResult = {} + word = self.right.literal[1:-1] # remove leading and trailing '%' characters (see __init__()) + for url, properties in resultSet.items(): + value = properties.get(self.left) + if self.negate: + if not value or value.textof().find(word) < 0: + newResult[url] = properties + else: + if value and value.textof().find(word) >= 0: + newResult[url] = properties + return newResult + # end of workaround + +class IsEqualTerm(NumberRelationTerm): + """ + Nodes of this class evaluate to true if the (left child) numerical property's value is equal + to the (right child) number. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return "<D:eq>" + NumberRelationTerm.toXML(self) + "</D:eq>" + +class IsGreaterTerm(NumberRelationTerm): + """ + Nodes of this class evaluate to true if the (left child) numerical property's value is greater + than the (right child) number. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return "<D:gt>" + NumberRelationTerm.toXML(self) + "</D:gt>" + +class IsGreaterOrEqualTerm(NumberRelationTerm): + """ + Nodes of this class evaluate to true if the (left child) numerical property's value is greater + than or equal to the (right child) number. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return "<D:gte>" + NumberRelationTerm.toXML(self) + "</D:gte>" + +class IsSmallerTerm(NumberRelationTerm): + """ + Nodes of this class evaluate to true if the (left child) numerical property's value is less + than the (right child) number. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return "<D:lt>" + NumberRelationTerm.toXML(self) + "</D:lt>" + +class IsSmallerOrEqualTerm(NumberRelationTerm): + """ + Nodes of this class evaluate to true if the (left child) numerical property's value is less + than or equal to the (right child) number. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return "<D:lte>" + NumberRelationTerm.toXML(self) + "</D:lte>" + + +class OnTerm(DateRelationTerm): + """ + Nodes of this class evaluate to true if the (left child) property's value is a date + equal to the (right child) date. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return "<D:eq>" + DateRelationTerm.toXML(self) + "</D:eq>" + +class AfterTerm(DateRelationTerm): + """ + Nodes of this class evaluate to true if the (left child) property's value is a date + succeeding the (right child) date. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return "<D:gt>" + DateRelationTerm.toXML(self) + "</D:gt>" + +class BeforeTerm(DateRelationTerm): + """ + Nodes of this class evaluate to true if the (left child) property's value is a date + preceeding the (right child) date. + """ + def toXML(self): + ''' + Returns XML encoding. + ''' + return "<D:lt>" + DateRelationTerm.toXML(self) + "</D:lt>" + + + +# Simple module test +if __name__ == '__main__': + # use the example from the webdav specification + condition = AndTerm( (MatchesTerm('getcontenttype', 'image/gif'), \ + IsGreaterTerm('getcontentlength', 4096)) ) + print "Where: " + condition.toXML() diff --git a/src/webdav/Connection.py b/src/webdav/Connection.py new file mode 100644 index 0000000..66f7833 --- /dev/null +++ b/src/webdav/Connection.py @@ -0,0 +1,321 @@ +# pylint: disable-msg=W0142,W0102,R0901,R0904,E0203,E1101,C0103 +# +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + + +""" +The contained class extends the HTTPConnection class for WebDAV support. +""" + + +from httplib import HTTPConnection, CannotSendRequest, BadStatusLine, ResponseNotReady +from copy import copy +import base64 # for basic authentication +try: + import hashlib +except ImportError: # for Python 2.4 compatibility + import md5 + hashlib = md5 +import mimetypes +import os # file handling +import urllib +import types +import socket # to "catch" socket.error +from threading import RLock +try: + from uuid import uuid4 +except ImportError: # for Python 2.4 compatibility + from uuid_ import uuid4 +from xml.parsers.expat import ExpatError + +from davlib import DAV +from qp_xml import Parser + +from webdav.WebdavResponse import MultiStatusResponse, ResponseFormatError +from webdav import Constants +from webdav.logger import getDefaultLogger + + +__version__ = "$LastChangedRevision$" + + +class Connection(DAV): + """ + This class handles a connection to a WebDAV server. + This class is used internally. Client code should prefer classes + L{WebdavClient.ResourceStorer} and L{WebdavClient.CollectionStorer}. + + @author: Roland Betz + """ + + # Constants + # The following switch activates a workaround for the Tamino webdav server: + # Tamino expects URLs which are passed in a HTTP header to be Latin-1 encoded + # instead of Utf-8 encoded. + # Set this switch to zero in order to communicate with conformant servers. + blockSize = 30000 + MaxRetries = 10 + + def __init__(self, *args, **kwArgs): + DAV.__init__(self, *args, **kwArgs) + self.__authorizationInfo = None + self.logger = getDefaultLogger() + self.isConnectedToCatacomb = True + self.serverTypeChecked = False + self._lock = RLock() + + def _request(self, method, url, body=None, extra_hdrs={}): + + self._lock.acquire() + try: + # add the authorization header + extraHeaders = copy(extra_hdrs) + if self.__authorizationInfo: + + # update (digest) authorization data + if hasattr(self.__authorizationInfo, "update"): + self.__authorizationInfo.update(method=method, uri=url) + + extraHeaders["AUTHORIZATION"] = self.__authorizationInfo.authorization + + # encode message parts + body = _toUtf8(body) + url = _urlEncode(url) + for key, value in extraHeaders.items(): + extraHeaders[key] = _toUtf8(value) + if key == "Destination": # copy/move header + if self.isConnectedToCatacomb: + extraHeaders[key] = _toUtf8(value.replace(Constants.SHARP, Constants.QUOTED_SHARP)) + + else: # in case of TAMINO 4.4 + extraHeaders[key] = _urlEncode(value) + # pass message to httplib class + for retry in range(0, Connection.MaxRetries): # retry loop + try: + self.logger.debug("REQUEST Send %s for %s" % (method, url)) + self.logger.debug("REQUEST Body: " + repr(body)) + for hdr in extraHeaders.items(): + self.logger.debug("REQUEST Header: " + repr(hdr)) + self.request(method, url, body, extraHeaders) + response = self.getresponse() + break # no retry needed + except (CannotSendRequest, socket.error, BadStatusLine, ResponseNotReady), exc: + # Workaround, start: reconnect and retry... + self.logger.debug("Exception: " + str(exc) + " Retry ... ") + self.close() + try: + self.connect() + except (CannotSendRequest, socket.error, BadStatusLine, ResponseNotReady), exc: + raise WebdavError("Cannot perform request. Connection failed.") + if retry == Connection.MaxRetries - 1: + raise WebdavError("Cannot perform request.") + return self.__evaluateResponse(method, response) + finally: + self._lock.release() + + def __evaluateResponse(self, method, response): + """ Evaluates the response of the WebDAV server. """ + + status, reason = response.status, response.reason + self.logger.debug("Method: " + method + " Status %d: " % status + reason) + + if status >= Constants.CODE_LOWEST_ERROR: # error has occured ? + self.logger.debug("ERROR Response: " + response.read()) + + # identify authentication CODE_UNAUTHORIZED, throw appropriate exception + if status == Constants.CODE_UNAUTHORIZED: + raise AuthorizationError(reason, status, response.msg["www-authenticate"]) + + response.close() + raise WebdavError(reason, status) + + if status == Constants.CODE_MULTISTATUS: + content = response.read() + ## check for UTF-8 encoding + try: + response.root = Parser().parse(content) + except ExpatError, error: + errorMessage = "Invalid XML document has been returned.\nReason: '%s'" % str(error.args) + raise WebdavError(errorMessage) + try: + response.msr = MultiStatusResponse(response.root) + except ResponseFormatError: + raise WebdavError("Invalid WebDAV response.") + response.close() + self.logger.debug("RESPONSE (Multi-Status): " + unicode(response.msr)) + elif method == 'LOCK' and status == Constants.CODE_SUCCEEDED: + response.parse_lock_response() + response.close() + elif method != 'GET' and method != 'PUT': + self.logger.debug("RESPONSE Body: " + response.read()) + response.close() + return response + + def addBasicAuthorization(self, user, password, realm=None): + if user and len(user) > 0: + self.__authorizationInfo = _BasicAuthenticationInfo(realm=realm, user=user, password=password) + + def addDigestAuthorization(self, user, password, realm, qop, nonce, uri = None, method = None): + if user and len(user) > 0: + # username, realm, password, uri, method, qop are required + self.__authorizationInfo = _DigestAuthenticationInfo(realm=realm, user=user, password=password, uri=uri, method=method, qop=qop, nonce=nonce) + + def putFile(self, path, srcfile, header={}): + self._lock.acquire() + try: + # Assemble header + try: + size = os.path.getsize(srcfile.name) + except os.error, error: + raise WebdavError("Cannot determine file size.\nReason: ''" % str(error.args)) + header["Content-length"] = str(size) + + contentType, contentEnc = mimetypes.guess_type(path) + if contentType: + header['Content-Type'] = contentType + if contentEnc: + header['Content-Encoding'] = contentEnc + if self.__authorizationInfo: + # update (digest) authorization data + if hasattr(self.__authorizationInfo, "update"): + self.__authorizationInfo.update(method="PUT", uri=path) + header["AUTHORIZATION"] = self.__authorizationInfo.authorization + + # send first request + path = _urlEncode(path) + try: + HTTPConnection.request(self, 'PUT', path, "", header) + self._blockCopySocket(srcfile, self, Connection.blockSize) + srcfile.close() + response = self.getresponse() + except (CannotSendRequest, socket.error, BadStatusLine, ResponseNotReady), exc: + self.logger.debug("Exception: " + str(exc) + " Retry ... ") + raise WebdavError("Cannot perform request.") + status, reason = (response.status, response.reason) + self.logger.debug("Status %d: %s" % (status, reason)) + try: + if status >= Constants.CODE_LOWEST_ERROR: # error has occured ? + raise WebdavError(reason, status) + finally: + self.logger.debug("RESPONSE Body: " + response.read()) + response.close() + return response + finally: + self._lock.release() + + def _blockCopySocket(self, source, toSocket, blockSize): + transferedBytes = 0 + block = source.read(blockSize) + #while source.readinto(block, blockSize): + while len(block): + toSocket.send(block) + self.logger.debug("Wrote %d bytes." % len(block)) + transferedBytes += len(block) + block = source.read(blockSize) + self.logger.info("Transfered %d bytes." % transferedBytes) + + def __str__(self): + return self.protocol + "://" + self.host + ':' + str(self.port) + + +class _BasicAuthenticationInfo(object): + def __init__(self, **kwArgs): + self.__dict__.update(kwArgs) + self.cookie = base64.encodestring("%s:%s" % (self.user, self.password) ).strip() + self.authorization = "Basic " + self.cookie + self.password = None # protect password security + +class _DigestAuthenticationInfo(object): + + __nc = "0000000" # in hexadecimal without leading 0x + + def __init__(self, **kwArgs): + + self.__dict__.update(kwArgs) + + if self.qop is None: + raise WebdavError("Digest without qop is not implemented.") + if self.qop == "auth-int": + raise WebdavError("Digest with qop-int is not implemented.") + + def update(self, **kwArgs): + """ Update input data between requests""" + + self.__dict__.update(kwArgs) + + def _makeDigest(self): + """ Creates the digest information. """ + + # increment nonce count + self._incrementNc() + + # username, realm, password, uri, method, qop are required + + a1 = "%s:%s:%s" % (self.user, self.realm, self.password) + ha1 = hashlib.md5(a1).hexdigest() + + #qop == auth + a2 = "%s:%s" % (self.method, self.uri) + ha2 = hashlib.md5(a2).hexdigest() + + cnonce = str(uuid4()) + + responseData = "%s:%s:%s:%s:%s:%s" % (ha1, self.nonce, _DigestAuthenticationInfo.__nc, cnonce, self.qop, ha2) + digestResponse = hashlib.md5(responseData).hexdigest() + + authorization = "Digest username=\"%s\", realm=\"%s\", nonce=\"%s\", uri=\"%s\", algorithm=MD5, response=\"%s\", qop=auth, nc=%s, cnonce=\"%s\"" \ + % (self.user, self.realm, self.nonce, self.uri, digestResponse, _DigestAuthenticationInfo.__nc, cnonce) + return authorization + + authorization = property(_makeDigest) + + def _incrementNc(self): + _DigestAuthenticationInfo.__nc = self._dec2nc(self._nc2dec() + 1) + + def _nc2dec(self): + return int(_DigestAuthenticationInfo.__nc, 16) + + def _dec2nc(self, decimal): + return hex(decimal)[2:].zfill(8) + + +class WebdavError(IOError): + def __init__(self, reason, code=0): + IOError.__init__(self, code) + self.code = code + self.reason = reason + def __str__(self): + return self.reason + + +class AuthorizationError(WebdavError): + def __init__(self, reason, code, authHeader): + WebdavError.__init__(self, reason, code) + + self.authType = authHeader.split(" ")[0] + self.authInfo = authHeader + + +def _toUtf8(body): + if not body is None: + if type(body) == types.UnicodeType: + body = body.encode('utf-8') + return body + + +def _urlEncode(url): + if type(url) == types.UnicodeType: + url = url.encode('utf-8') + return urllib.quote(url) diff --git a/src/webdav/Constants.py b/src/webdav/Constants.py new file mode 100644 index 0000000..56dfd77 --- /dev/null +++ b/src/webdav/Constants.py @@ -0,0 +1,199 @@ +# pylint: disable-msg=C0103 +# +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + + +""" +Contains XML tag names for the WebDAV protocol (RFC 2815) +and further WebDAV related constants. +""" + + +__version__ = "$Revision$"[11:-2] + + +QUOTED_SHARP = "%23" +SHARP = "#" + +# Date formats +DATE_FORMAT_ISO8601 = r"%Y-%m-%dT%H:%M:%SZ" +DATE_FORMAT_HTTP = r"%a, %d %b %Y %H:%M:%S GMT" # not used, substituted by rfc822 function + +NS_DAV = 'DAV:' +NS_TAMINO = 'http://namespaces.softwareag.com/tamino/response2' + +TAG_PROPERTY_FIND = 'propfind' +TAG_PROPERTY_NAME = 'propname' +TAG_PROPERTY_UPDATE = 'propertyupdate' +TAG_PROPERTY_SET = 'set' +TAG_PROPERTY_REMOVE = 'remove' +TAG_ALL_PROPERTY = 'allprop' +TAG_PROP = 'prop' + +TAG_MULTISTATUS = 'multistatus' +TAG_RESPONSE = 'response' +TAG_HREF = 'href' +TAG_PROPERTY_STATUS = 'propstat' +TAG_STATUS = 'status' +TAG_RESPONSEDESCRIPTION = 'responsdescription' + +PROP_CREATION_DATE = 'creationdate' +PROP_DISPLAY_NAME = 'displayname' +PROP_CONTENT_LANGUAGE = 'getcontentlanguage' +PROP_CONTENT_LENGTH = 'getcontentlength' +PROP_CONTENT_TYPE = 'getcontenttype' +PROP_ETAG = 'getetag' +PROP_MODIFICATION_DATE = 'modificationdate' # this property is supported by +# Tamino 4.4 but not by Catacomb; the date format is ISO8601 +PROP_LAST_MODIFIED = 'getlastmodified' +PROP_LOCK_DISCOVERY = 'lockdiscovery' +PROP_RESOURCE_TYPE = 'resourcetype' +PROP_SOURCE = 'source' +PROP_SUPPORTED_LOCK = 'supportedlock' +PROP_OWNER = 'owner' + +PROP_RESOURCE_TYPE_RESOURCE = 'resource' +PROP_RESOURCE_TYPE_COLLECTION = 'collection' + +TAG_LINK = 'link' +TAG_LINK_SOURCE = 'src' +TAG_LINK_DESTINATION = 'dst' + +TAG_LOCK_ENTRY = 'lockentry' +TAG_LOCK_SCOPE = 'lockscope' +TAG_LOCK_TYPE = 'locktype' +TAG_LOCK_INFO = 'lockinfo' +TAG_ACTIVE_LOCK = 'activelock' +TAG_LOCK_DEPTH = 'depth' +TAG_LOCK_TOKEN = 'locktoken' +TAG_LOCK_TIMEOUT = 'timeout' +TAG_LOCK_EXCLUSIVE = 'exclusive' +TAG_LOCK_SHARED = 'shared' +TAG_LOCK_OWNER = 'owner' + +# HTTP error code constants +CODE_MULTISTATUS = 207 +CODE_SUCCEEDED = 200 +CODE_CREATED = 201 +CODE_NOCONTENT = 204 + +CODE_LOWEST_ERROR = 300 + +CODE_UNAUTHORIZED = 401 +CODE_FORBIDDEN = 403 +CODE_NOT_FOUND = 404 +CODE_CONFLICT = 409 +CODE_PRECONDITION_FAILED = 412 +CODE_LOCKED = 423 # no permission +CODE_FAILED_DEPENDENCY = 424 + +CODE_OUTOFMEM = 507 + +# ? +CONFIG_UNICODE_URL = 1 + +# constants for WebDAV DASL according to draft + +TAG_SEARCH_REQUEST = 'searchrequest' +TAG_SEARCH_BASIC = 'basicsearch' +TAG_SEARCH_SELECT = 'select' +TAG_SEARCH_FROM = 'from' +TAG_SEARCH_SCOPE = 'scope' +TAG_SEARCH_WHERE = 'where' + +# constants for WebDAV ACP (according to draft-ietf-webdav-acl-09) below ... + +TAG_ACL = 'acl' +TAG_ACE = 'ace' +TAG_GRANT = 'grant' +TAG_DENY = 'deny' +TAG_PRIVILEGE = 'privilege' +TAG_PRINCIPAL = 'principal' +TAG_ALL = 'all' +TAG_AUTHENTICATED = 'authenticated' +TAG_UNAUTHENTICATED = 'unauthenticated' +TAG_OWNER = 'owner' +TAG_PROPERTY = 'property' +TAG_SELF = 'self' +TAG_INHERITED = 'inherited' +TAG_PROTECTED = 'protected' +TAG_SUPPORTED_PRIVILEGE = 'supported-privilege' +TAG_DESCRIPTION = 'description' + +# privileges for WebDAV ACP: +TAG_READ = 'read' +TAG_WRITE = 'write' +TAG_WRITE_PROPERTIES = 'write-properties' +TAG_WRITE_CONTENT = 'write-content' +TAG_UNLOCK = 'unlock' +TAG_READ_ACL = 'read-acl' +TAG_READ_CURRENT_USER_PRIVILEGE_SET = 'read-current-user-privilege-set' +TAG_WRITE_ACL = 'write-acl' +TAG_ALL = 'all' +TAG_BIND = 'bind' +TAG_UNBIND = 'unbind' +# Tamino-specific privileges +TAG_TAMINO_SECURITY = 'security' +# Limestone-specific privileges +TAG_BIND_COLLECTION = 'bind-collection' +TAG_UNBIND_COLLECTION = 'unbind-collection' +TAG_READ_PRIVATE_PROPERTIES = 'read-private-properties' +TAG_WRITE_PRIVATE_PROPERTIES = 'write-private-properties' + +# properties for WebDAV ACP: +PROP_CURRENT_USER_PRIVILEGE_SET = 'current-user-privilege-set' +PROP_SUPPORTED_PRIVILEGE_SET = 'supported-privilege-set' +PROP_PRINCIPAL_COLLECTION_SET = 'principal-collection-set' + +# reports for WebDAV ACP +REPORT_ACL_PRINCIPAL_PROP_SET = 'acl-principal-prop-set' + + + +# constants for WebDAV Delta-V + +# WebDAV Delta-V method names +METHOD_REPORT = 'REPORT' +METHOD_VERSION_CONTROL = 'VERSION-CONTROL' +METHOD_UNCHECKOUT = 'UNCHECKOUT' +METHOD_CHECKOUT = 'CHECKOUT' +METHOD_CHECKIN = 'CHECKIN' +METHOD_UPDATE = 'UPDATE' + +# Special properties +PROP_SUCCESSOR_SET = (NS_DAV, 'successor-set') +PROP_PREDECESSOR_SET = (NS_DAV, 'predecessor-set') +PROP_VERSION_HISTORY = (NS_DAV, 'version-history') +PROP_CREATOR = (NS_DAV, 'creator-displayname') +PROP_VERSION_NAME = (NS_DAV, 'version-name') +PROP_CHECKEDIN = (NS_DAV, 'checked-in') +PROP_CHECKEDOUT = (NS_DAV, 'checked-out') +PROP_COMMENT = (NS_DAV, 'comment') + +# XML tags for request body +TAG_VERSION_TREE = 'version-tree' +TAG_LOCATE_BY_HISTORY = 'locate-by-history' +TAG_UPDATE = 'update' +TAG_VERSION = 'version' + +# HTTP header constants +HTTP_HEADER_DEPTH_INFINITY = 'infinity' +HTTP_HEADER_IF = 'if' +HTTP_HEADER_DAV = 'dav' +HTTP_HEADER_DASL = 'dasl' +HTTP_HEADER_OPTION_ACL = 'access-control' +HTTP_HEADER_OPTION_DAV_BASIC_SEARCH = 'DAV:basicsearch' +HTTP_HEADER_SERVER = 'server' +HTTP_HEADER_SERVER_TAMINO = 'Apache/2.0.54 (Win32)' diff --git a/src/webdav/Makefile.am b/src/webdav/Makefile.am new file mode 100644 index 0000000..3356daf --- /dev/null +++ b/src/webdav/Makefile.am @@ -0,0 +1,20 @@ +SUBDIRS = acp + +sugardir = $(pythondir)/webdav +sugar_PYTHON = \ + Connection.py \ + davlib.py \ + logger.py \ + NameCheck.py \ + Utils.py \ + VersionHandler.py \ + WebdavRequests.py \ + Condition.py \ + Constants.py \ + __init__.py \ + qp_xml.py \ + uuid_.py \ + WebdavClient.py \ + WebdavResponse.py + + diff --git a/src/webdav/NameCheck.py b/src/webdav/NameCheck.py new file mode 100644 index 0000000..7976973 --- /dev/null +++ b/src/webdav/NameCheck.py @@ -0,0 +1,193 @@ +# pylint: disable-msg=R0904,W0142,W0511,W0104,C0321,E1103,W0212 +# +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + + +""" +Check name of new collections/resources for "illegal" characters. +""" + + +import re +import unicodedata + + +__version__ = "$LastChangedRevision$" + + +_unicodeUmlaut = [unicodedata.lookup("LATIN CAPITAL LETTER A WITH DIAERESIS"), + unicodedata.lookup("LATIN SMALL LETTER A WITH DIAERESIS"), + unicodedata.lookup("LATIN CAPITAL LETTER O WITH DIAERESIS"), + unicodedata.lookup("LATIN SMALL LETTER O WITH DIAERESIS"), + unicodedata.lookup("LATIN CAPITAL LETTER U WITH DIAERESIS"), + unicodedata.lookup("LATIN SMALL LETTER U WITH DIAERESIS"), + unicodedata.lookup("LATIN SMALL LETTER SHARP S")] + +# Define characters and character base sets +_german = u"".join(_unicodeUmlaut) +_alpha = "A-Za-z" +_num = "0-9" +_alphaNum = _alpha + _num +_space = " " +_under = "_" +_dash = "\-" +_dot = "\." +_exclam = "\!" +_tilde = "\~" +_dollar = "\$" +_plus = "+" +_equal = "=" +_sharp = "#" + +# Define character groups +_letterNum = _alphaNum + _german +_letter = _alpha + _german + +# Define character sets for names +firstPropertyChar = _letter + _under +propertyChar = firstPropertyChar + _num + _dash + _dot +firstResourceChar = firstPropertyChar + _num + _tilde + _exclam + _dollar + \ + _dot + _dash + _plus + _equal + _sharp +resourceChar = firstResourceChar + _space + +# Define regular expressions for name validation +_propertyFirstRe = re.compile(u"^["+ firstPropertyChar +"]") + +_propertyRe = re.compile(u"[^"+ propertyChar +"]") +_resourceFirstRe = re.compile(u"^["+ firstResourceChar +"]") +_resourceRe = re.compile(u"[^"+ resourceChar +"]") + + +def isValidPropertyName(name): + """ + Check if the given property name is valid. + + @param name: Property name. + @type name: C{unicode} + + @return: Boolean indicating whether the given property name is valid or not. + @rtype: C{bool} + """ + + illegalChar = _propertyRe.search(name) + return illegalChar == None and _propertyFirstRe.match(name) != None + + +def isValidResourceName(name): + """ + Check if the given resource name is valid. + + @param name: Resource name. + @type name: C{unicode} + + @return: Boolean indicating whether the given resource name is valid or not. + @rtype: C{bool} + """ + + illegalChar = _resourceRe.search(name) + return illegalChar == None and _resourceFirstRe.match(name) != None + + +def validatePropertyName(name): + """ + Check if the given property name is valid. + + @param name: Property name. + @type name: C{unicode} + @raise WrongNameError: if validation fails (see L{datafinder.common.NameCheck.WrongNameError}) + """ + + illegalChar = _propertyRe.search(name) + if illegalChar: + raise WrongNameError(illegalChar.start(), name[illegalChar.start()]) + if not _propertyFirstRe.match(name): + if len(name) > 0: + raise WrongNameError(0, name[0]) + else: + raise WrongNameError(0, 0) + + +def validateResourceName(name): + """ + Check if the given resource name is valid. + + @param name: name of resource/collection + @type name: C{unicode} + @raise WrongNameError: if validation fails (@see L{datafinder.common.NameCheck.WrongNameError}) + """ + + illegalChar = _resourceRe.search(name) + if illegalChar: + raise WrongNameError(illegalChar.start(), name[illegalChar.start()]) + if not _resourceFirstRe.match(name): + if len(name) > 0: + raise WrongNameError(0, name[0]) + else: + raise WrongNameError(0, 0) + + +def getResourceNameErrorPosition(name): + """ + Get position of illegal character (and the error-message). + This method can be used to get this information if L{isValidPropertyName} + or L{isValidResourceName} failed. + + @param name: Resource name. + @type name: C{unicode} + + @return: Tuple of error position and message. + @rtype: C{tuple} of C{int} and C{unicode} + """ + + result = (-1, None) + illegalChar = _resourceRe.search(name) + if illegalChar: + result = (illegalChar.start(), \ + u"Illegal character '%s' at index %d." % \ + (name[illegalChar.start()], illegalChar.start())) + elif not _resourceFirstRe.match(name): + result = (0, u"Illegal character '%s' at index %d." % (name[0], 0)) + return result + + +class WrongNameError(ValueError): + """ + Exception raised if an "illegal" character was found. + + @ivar character: character that caused the exception + @type character: C{unicode} + @ivar position: position of C{character} + @type position: C{int} + """ + + def __init__(self, position, character): + """ + Constructor. + + @param character: Character that caused the exception. + @type character: C{unicode} + @param position: Position of C{character} + @type position: C{int} + """ + + ValueError.__init__(self) + self.character = character + self.position = position + + def __str__(self): + """ Returns string representation. """ + + return ValueError.__str__(self) + \ + "Character '%s' at index %d." % (self.character, self.position) diff --git a/src/webdav/Utils.py b/src/webdav/Utils.py new file mode 100644 index 0000000..ec05755 --- /dev/null +++ b/src/webdav/Utils.py @@ -0,0 +1,154 @@ +# pylint: disable-msg=W0141,R0912 +# +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + + +""" +The module contains functions to support use of the WebDav functionalities. +""" + + +import os +import sys + +from webdav.WebdavClient import CollectionStorer, ResourceStorer +from webdav.Constants import NS_DAV, PROP_RESOURCE_TYPE, CODE_NOT_FOUND, PROP_RESOURCE_TYPE_RESOURCE +from webdav.Connection import WebdavError + + +__version__ = "$Revision$"[11:-2] + + +def resourceExists(node, name = None, resourceType = PROP_RESOURCE_TYPE_RESOURCE): + """ + Check if resource exists. + + Usage: + - resourceExists(ResourceStorer-object): + check if resource exists + - resourceExists(CollectionStorer-object, name): + check if resource name exists in collection + + @param node: node that has to be checked or node of collection + @type node: L{ResourceStorer<webdav.WebdavClient.ResourceStorer>} + @param name: name of resource (in collection node) that has to be checked + @type name: string + + @return: boolean + + @raise WebdavError: all WebDAV errors except WebDAV error 404 (not found) + """ + + exists = False + if not node: + return exists + try: + myResourceType = "" + if name: + # make sure it's unicode: + if not isinstance(name, unicode): + name = name.decode(sys.getfilesystemencoding()) + url = node.url + if url.endswith("/"): + url = url + name + else: + url = url + "/" + name + newNode = ResourceStorer(url, node.connection) + element = newNode.readProperty(NS_DAV, PROP_RESOURCE_TYPE) + else: # name is "None": + element = node.readProperty(NS_DAV, PROP_RESOURCE_TYPE) + + if len(element.children) > 0: + myResourceType = element.children[0].name + if resourceType == myResourceType or resourceType == PROP_RESOURCE_TYPE_RESOURCE: + exists = True + else: + exists = False + except WebdavError, wderr: + if wderr.code == CODE_NOT_FOUND: + # node doesn't exist -> exists = False: + exists = False + else: + # another exception occured -> "re-raise" it: + raise + return exists + + +def downloadCollectionContent(destinationPath, collectionToDownload): + """ + Downloads the resources contained to the given directory. + + @param destinationPath: Path to download the files to, will be created if it not exists. + @type destinationPath: C{String} + @param collectionToDownload: Collection to download the content from. + @type collectionToDownload: instance of L{CollectionStorer<webdav.WebdavClient.CollectionStorer>} + + @raise WebdavError: If something goes wrong. + """ + + from time import mktime, gmtime + + downloadCount = 0 + + listOfItems = collectionToDownload.getCollectionContents() + + if not os.path.exists(destinationPath): + try: + os.makedirs(destinationPath) + except OSError: + errorMessage = "Cannot create download destination directory '%s'." % destinationPath + raise WebdavError(errorMessage) + + try: + itemsInPath = os.listdir(destinationPath) + except OSError: + errorMessage = "Cannot read the content of download destination directory '%s'." % destinationPath + raise WebdavError(errorMessage) + + for item in listOfItems: + # skip collections + if not isinstance(item[0], CollectionStorer): + itemSavePath = os.path.join(destinationPath, item[0].name) + existsItemSavePath = os.path.exists(itemSavePath) + + # update? + if existsItemSavePath: + try: + isUpdateNecessary = mktime(item[1].getLastModified()) > mktime(gmtime(os.path.getmtime(itemSavePath))) + except (ValueError, OverflowError): + isUpdateNecessary = True + # windows is not case sensitive + for realItem in itemsInPath: + if realItem.lower() == item[0].name.lower(): + itemsInPath.remove(realItem) + else: + isUpdateNecessary = True + + # download + if not existsItemSavePath or (existsItemSavePath and isUpdateNecessary): + item[0].downloadFile(itemSavePath) + downloadCount = downloadCount + 1 + + # delete old items + try: + for item in itemsInPath: + os.remove(os.path.join(destinationPath, item)) + except OSError, e: + if e.errno == 13: # permission error + sys.stderr.write("permission problem on '%s' in %s\n" % (e.filename, e.strerror)) + else: + raise + + return downloadCount diff --git a/src/webdav/VersionHandler.py b/src/webdav/VersionHandler.py new file mode 100644 index 0000000..a1962c6 --- /dev/null +++ b/src/webdav/VersionHandler.py @@ -0,0 +1,198 @@ +# pylint: disable-msg=W0612,W0142 +# +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + + +""" +The WebDAV client module forwards Delta-V related method invocations to +the following VersionHandler class. +""" + +__version__ = '$Revision$'[11:-2] + + +import types + +from webdav import Constants +from davlib import XML_CONTENT_TYPE, XML_DOC_HEADER + + +class VersionHandler(object): + """ + Implements a client interface for WebDAV Delta-V methods + For the Delta-V see RFC 3253 at http://www.ietf.org/rfc/rfc3253.txt + """ + + # restrict instance variables + __slots__ = ('path', 'connection') + + + def __init__(self, connection, path): + """ + Construct a VersionHandler with a URL path and a WebDAV connection. + This constructor must not be called outside class ResourceStorer. + + @param connection: L{webdav.Connection} instance + @param path: resource's path part of URL + """ + #assert isinstance(connection, Connection), \ + # "Class of connection is %s." % connection.__class__.__name__ + self.connection = connection + self.path = path + + + def activateVersionControl(self): + """ + Turns version control on for this resource. + The resource becomes a version controlled resource (VCR) + """ + response = self.connection._request(Constants.METHOD_VERSION_CONTROL, self.path, None, {}) + # set auto-versioning to DAV:locked-checkout + ## parse response body in case of an error + + def uncheckout(self, lockToken=None): + """ + Undos a previous check-out operation on this VCR. + The VCR is reverted to the state before the checkout/lock operation. + Beware: Property or content changes will be lost ! + A (optional) lock has to be removed seperatedly. + + @param lockToken: returned by a preceeding lock() method invocation or None + """ + headers = {} + if lockToken: + headers = lockToken.toHeader() + response = self.connection._request(Constants.METHOD_UNCHECKOUT, self.path, None, headers) + ## parse response body in case of an error + + def listAllVersions(self): + """ + List version history. + + @return: List of versions for this VCR. Each version entry is a tuple adhering + to the format (URL-path, name, creator, tuple of successor URL-paths). + If there are no branches then there is at most one successor within the tuple. + """ + # implementation is similar to the propfind method + headers = {} + headers['Content-Type'] = XML_CONTENT_TYPE + body = _createReportVersionTreeBody() + response = self.connection._request(Constants.METHOD_REPORT, self.path, body, headers) + # response is multi-status + result = [] + for path, properties in response.msr.items(): + # parse the successor-set value from XML into alist + result.append( (path, str(properties[Constants.PROP_VERSION_NAME]), \ + str(properties[Constants.PROP_CREATOR]), \ + _extractSuccessorList(properties[Constants.PROP_SUCCESSOR_SET])) ) + ## TODO: sort for path and produce list + result.sort() + return result + + # warning: not tested yet + def readVersionProperties(self): + """ + Provide version related information on this VCR. + This include a reference to the latest version resource, + check-out state information and a comment. + + @return: map of version properties with values. + """ + versionProperties = (Constants.PROP_CHECKEDIN, Constants.PROP_CHECKEDOUT, Constants.PROP_COMMENT) + return self.connection.readProperties(*versionProperties) + + + def revertToVersion(self, oldVersion): + """ + Revert this VCR to the given version. + Beware: All versions succeeding the given version are made unavailable. + + @param oldVersion: URL-path of a previous version of this VCR. + """ + ## send an update request + assert isinstance(oldVersion, types.StringType) or isinstance(oldVersion, types.UnicodeType) + response = self.connection._request(Constants.METHOD_UPDATE, self.path, + _createUpdateBody(oldVersion), {}) + return response + + + # the following is not needed when using auto-versioning + + # warning: not tested yet + def checkout(self): + """ + Marks resource as checked-out + This is usually followed by a GET (download) operation. + """ + response = self.connection._request(Constants.METHOD_CHECKOUT, self.path, None, {}) + ## parse response body in case of an error + + # warning: not tested yet + def checkin(self): + """ + Creates a new version from the VCR's content. + This opeartion is usually preceeded by one or more write operations. + """ + response = self.connection._request(Constants.METHOD_CHECKIN, self.path, None, {}) + versionUrl = response.getheader('Location') + return versionUrl + ## parse response body in case of an error + + + + +# Helper functions +def _createReportVersionTreeBody(): + """ + TBD + + @return: ... + @rtype: string + """ + versions = 'D:' + Constants.TAG_VERSION_TREE + prop = 'D:' + Constants.TAG_PROP + nameList = [Constants.PROP_SUCCESSOR_SET, Constants.PROP_VERSION_NAME, Constants.PROP_CREATOR] + return XML_DOC_HEADER + \ + '<%s xmlns:D="DAV:"><%s>' % (versions, prop) + \ + reduce(lambda xml, name: xml + "<D:%s/>" % name[1], [''] + nameList) + \ + '</%s></%s>' % (prop, versions) + +def _createUpdateBody(path): + """ + TBD + + @return: ... + @rtype: string + """ + update = 'D:' + Constants.TAG_UPDATE + version = 'D:' + Constants.TAG_VERSION + href = 'D:' + Constants.TAG_HREF + #PROP = 'D:' + TAG_PROP + return XML_DOC_HEADER + \ + '<%s xmlns:D="DAV:"><%s><%s>' % (update, version, href) + \ + path + \ + '</%s></%s></%s>' % (href, version, update) + +def _extractSuccessorList(element): + """ + TBD + + @return: ... + @rtype: tuple of strings + """ + result = [] + for href in element.children: + result.append(href.textof()) + return tuple(result) diff --git a/src/webdav/WebdavClient.py b/src/webdav/WebdavClient.py new file mode 100644 index 0000000..ab5cec3 --- /dev/null +++ b/src/webdav/WebdavClient.py @@ -0,0 +1,840 @@ +# pylint: disable-msg=R0904,W0142,W0511,W0104,C0321,E1103,W0212 +# +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + + +""" +This module contains the classes ResourceStorer and CollectionStorer for accessing WebDAV resources. +""" + + +from davlib import XML_CONTENT_TYPE + +from urlparse import urlsplit +import re +import types +import sys +import os +import shutil +import socket + +from webdav import Constants +from webdav.WebdavResponse import LiveProperties +from webdav.WebdavRequests import createFindBody, createUpdateBody, createDeleteBody, createSearchBody +from webdav.Condition import ConditionTerm +from webdav.Connection import Connection, WebdavError, AuthorizationError +from webdav.VersionHandler import VersionHandler + +from webdav.acp.Privilege import Privilege +from webdav.acp.Acl import ACL +from webdav.NameCheck import validateResourceName, WrongNameError + + +__version__ = '$Revision$'[11:-2] + +SOCKET_DEFAULT_TIMEOUT = 10 + + +def switchUnicodeUrlOn(switch): + """ + Configure whether to use unicode (UTF-8) encoded URLs (default) or + Latin-1 encoded URLs. + + @param switch: 1 if unicode URLs shall be used + """ + + assert switch == 0 or switch == 1, "Pass boolean argument, please." + Constants.CONFIG_UNICODE_URL = switch + + +def parseDigestAuthInfo(authInfo): + """ + Parses the authentication information returned from a server and returns + a dictionary containing realm, qop, and nonce. + + @see: L{AuthorizationError<webdav.Connection.AuthorizationError>} + or the main function of this module. + """ + + info = dict() + info["realm"] = re.search('realm="([^"]+)"', authInfo).group(1) + info["qop"] = re.search('qop="([^"]+)"', authInfo).group(1) + info["nonce"] = re.search('nonce="([^"]+)"', authInfo).group(1) + return info + + +class ResourceStorer(object): + """ + This class provides client access to a WebDAV resource + identified by an URI. It provides all WebDAV class 2 features which include + uploading data, getting and setting properties qualified by a XML name space, + locking and unlocking the resource. + This class does not cache resource data. This has to be performed by its clients. + + @author: Roland Betz + """ + + # Instance properties + url = property(lambda self: str(self.connection) + self.path, None, None, "Resource's URL") + + def __init__(self, url, connection=None, validateResourceNames=True): + """ + Creates an instance for the given URL + User must invoke validate() after construction to check the resource on the server. + + @param url: Unique resource location for this storer. + @type url: C{string} + @param connection: this optional parameter contains a Connection object + for the host part of the given URL. Passing a connection saves + memory by sharing this connection. (defaults to None) + @type connection: L{webdav.Connection} + @raise WebdavError: If validation of resource name path parts fails. + """ + + assert connection == None or isinstance(connection, Connection) + parts = urlsplit(url, allow_fragments=False) + self.path = parts[2] + self.validateResourceNames = validateResourceNames + + # validate URL path + for part in self.path.split('/'): + if part != '' and not "ino:" in part: # explicitly allowing this character sequence as a part of a path (Tamino 4.4) + if self.validateResourceNames: + try: + validateResourceName(part) + except WrongNameError: + raise WebdavError("Found invalid resource name part.") + self.name = part + # was: filter(lambda part: part and validateResourceName(part), self.path.split('/')) + # but filter is deprecated + + self.defaultNamespace = None # default XML name space of properties + if connection: + self.connection = connection + else: + conn = parts[1].split(":") + if len(conn) == 1: + self.connection = Connection(conn[0], protocol = parts[0]) # host and protocol + else: + self.connection = Connection(conn[0], int(conn[1]), protocol = parts[0]) # host and port and protocol + self.versionHandler = VersionHandler(self.connection, self.path) + + + def validate(self): + """ + Check whether URL contains a WebDAV resource + Uses the WebDAV OPTIONS method. + + @raise WebdavError: L{WebdavError} if URL does not contain a WebDAV resource + """ + #davHeader = response.getheader(HTTP_HEADER_DAV) + davHeader = self.getSpecificOption(Constants.HTTP_HEADER_DAV) + self.connection.logger.debug("HEADER DAV: %s" % davHeader) + if not(davHeader) or davHeader.find("2") < 0: # DAV class 2 supported ? + raise WebdavError("URL does not support WebDAV", 0) + + def options(self): + """ + Send an OPTIONS request to server and return all HTTP headers. + + @return: map of all HTTP headers returned by the OPTIONS method. + """ + response = self.connection.options(self.path) + result = {} + result.update(response.msg) + self.connection.logger.debug("OPTION returns: " + str(result.keys())) + return result + + def _getAclSupportAvailable(self): + """ + Returns True if the current connection has got ACL support. + + @return: ACL support (True / False) + @rtype: C{bool} + """ + options = self.getSpecificOption(Constants.HTTP_HEADER_DAV) + if options.find(Constants.HTTP_HEADER_OPTION_ACL) >= 0: + return True + else: + return False + + aclSupportAvailable = property(_getAclSupportAvailable) + + def _getDaslBasicsearchSupportAvailable(self): + """ + Returns True if the current connection supports DASL basic search. + + @return: DASL basic search support (True / False) + @rtype: C{bool} + """ + options = self.getSpecificOption(Constants.HTTP_HEADER_DASL) + if not options or \ + not options.find(Constants.HTTP_HEADER_OPTION_DAV_BASIC_SEARCH) >= 0: + return False + else: + return True + + daslBasicsearchSupportAvailable = property(_getDaslBasicsearchSupportAvailable) + + def isConnectedToCatacombServer(self): + """ + Returns True if connected to a Catacomb WebDav server. + + @return: if connected to Catacomb Webdav server (True / False) + @rtype: C{bool} + """ + if not self.connection.serverTypeChecked: + options = self.getSpecificOption(Constants.HTTP_HEADER_SERVER) + if options.find(Constants.HTTP_HEADER_SERVER_TAMINO) >= 0: + self.connection.isConnectedToCatacomb = False + else: + self.connection.isConnectedToCatacomb = True + self.connection.serverTypeChecked = True + return self.connection.isConnectedToCatacomb + + def getSpecificOption(self, option): + """ + Returns specified WebDav options. + @param option: name of the option + + @return: String containing the value of the option. + @rtype: C{string} + """ + options = '' + try: + options = self.options().get(option) + except KeyError: + return options + return options + + ### delegate some method invocations + def __getattr__(self, name): + """ + Build-in method: + Forwards unknow lookups (methods) to delegate object 'versionHandler'. + + @param name: name of unknown attribute + """ + # delegate Delta-V methods + return getattr(self.versionHandler, name) + + def copy(self, toUrl, infinity=True): + """ + Copies this resource. + + @param toUrl: target URI path + @param infinity: Flag that indicates that the complete content of collection is copied. (default) + @type depth: C{boolean} + """ + self.connection.logger.debug("Copy to " + repr(toUrl)); + _checkUrl(toUrl) + if infinity: + response = self.connection.copy(self.path, toUrl) + else: + response = self.connection.copy(self.path, toUrl, 0) + if response.status == Constants.CODE_MULTISTATUS and response.msr.errorCount > 0: + raise WebdavError("Request failed: " + response.msr.reason, response.msr.code) + + def delete(self, lockToken=None): + """ + Deletes this resource. + + @param lockToken: String returned by last lock operation or null. + @type lockToken: L{LockToken} + """ + assert lockToken == None or isinstance(lockToken, LockToken), \ + "Invalid lockToken argument %s" % type(lockToken) + header = {} + if lockToken: + header = lockToken.toHeader() + response = self.connection.delete(self.path, header) + if response.status == Constants.CODE_MULTISTATUS and response.msr.errorCount > 0: + raise WebdavError("Request failed: " + response.msr.reason, response.msr.code) + + def move(self, toUrl): + """ + Moves this resource to the given path or renames it. + + @param toUrl: new (URI) path + """ + self.connection.logger.debug("Move to " + repr(toUrl)); + _checkUrl(toUrl) + response = self.connection.move(self.path, toUrl) + if response.status == Constants.CODE_MULTISTATUS and response.msr.errorCount > 0: + raise WebdavError("Request failed: " + response.msr.reason, response.msr.code) + + + def lock(self, owner): + """ + Locks this resource for exclusive write access. This means that for succeeding + write operations the returned lock token has to be passed. + If the methode does not throw an exception the lock has been granted. + + @param owner: describes the lock holder + @return: lock token string (automatically generated) + @rtype: L{LockToken} + """ + response = self.connection.lock(self.path, owner) + if response.status == Constants.CODE_MULTISTATUS and response.msr.errorCount > 0: + raise WebdavError("Request failed: " + response.msr.reason, response.msr.code) + return LockToken(self.url, response.locktoken) + + def unlock(self, lockToken): + """ + Removes the lock from this resource. + + @param lockToken: which has been return by the lock() methode + @type lockToken: L{LockToken} + """ + self.connection.unlock(self.path, lockToken.token) + + + def deleteContent(self, lockToken=None): + """ + Delete binary data at permanent storage. + + @param lockToken: None or lock token from last lock request + @type lockToken: L{LockToken} + """ + assert lockToken == None or isinstance(lockToken, LockToken), \ + "Invalid lockToken argument %s" % type(lockToken) + header = {} + if lockToken: + header = lockToken.toHeader() + self.connection.put(self.path, "", extra_hdrs=header) + + def uploadContent(self, content, lockToken=None): + """ + Write binary data to permanent storage. + + @param content: containing binary data + @param lockToken: None or lock token from last lock request + @type lockToken: L{LockToken} + """ + assert not content or isinstance(content, types.UnicodeType) or\ + isinstance(content, types.StringType), "Content is not a string: " + content.__class__.__name__ + assert lockToken == None or isinstance(lockToken, LockToken), \ + "Invalid lockToken argument %s" % type(lockToken) + header = {} + if lockToken: + header = lockToken.toHeader() + response = None + if not content is None: + header["Content-length"] = len(content) + else: + header["Content-length"] = 0 + + try: + response = self.connection.put(self.path, content, extra_hdrs=header) + finally: + if response: + self.connection.logger.debug(response.read()) + response.close() + + def uploadFile(self, newFile, lockToken=None): + """ + Write binary data to permanent storage. + + @param newFile: File containing binary data. + @param lockToken: None or lock token from last lock request + @type lockToken: L{LockToken} + """ + assert isinstance(newFile, types.FileType), "Argument is no file: " + file.__class__.__name__ + assert lockToken == None or isinstance(lockToken, LockToken), \ + "Invalid lockToken argument %s" % type(lockToken) + header = {} + if lockToken: + header = lockToken.toHeader() + self.connection.putFile(self.path, newFile, header=header) + + def downloadContent(self): + """ + Read binary data from permanent storage. + """ + response = self.connection.get(self.path) + # TODO: Other interface ? return self.connection.getfile() + return response + + def downloadFile(self, localFileName): + """ + Copy binary data from permanent storage to a local file. + + @param localFileName: file to write binary data to + """ + localFile = open(localFileName, 'wb') + remoteFile = self.downloadContent() + try: + socket.setdefaulttimeout(SOCKET_DEFAULT_TIMEOUT) + _blockCopyFile(remoteFile, localFile, Connection.blockSize) + except socket.error, e: + raise e + remoteFile.close() + localFile.close() + + def readProperties(self, *names): + """ + Reads the given properties. + + @param names: a list of property names. + A property name is a (XmlNameSpace, propertyName) tuple. + @return: a map from property names to DOM Element or String values. + """ + assert names, "Property names are missing." + body = createFindBody(names, self.defaultNamespace) + response = self.connection.propfind(self.path, body, depth=0) + properties = response.msr.values()[0] + if properties.errorCount > 0: + raise WebdavError("Property is missing on '%s': %s" % (self.path, properties.reason), properties.code) + return properties + + def readProperty(self, nameSpace, name): + """ + Reads the given property. + + @param nameSpace: XML-namespace + @type nameSpace: string + @param name: A property name. + @type name: string + + @return: a map from property names to DOM Element or String values. + """ + results = self.readProperties((nameSpace, name)) + if len(results) == 0: + raise WebdavError("Property is missing: " + results.reason) + return results.values()[0] + + def readAllProperties(self): + """ + Reads all properties of this resource. + + @return: a map from property names to DOM Element or String values. + """ + response = self.connection.allprops(self.path, depth=0) + return response.msr.values()[0] + + def readAllPropertyNames(self): + """ + Returns the names of all properties attached to this resource. + + @return: List of property names + """ + response = self.connection.propnames(self.path, depth=0) + return response.msr.values()[0] + + def readStandardProperties(self): + """ + Read all WebDAV live properties. + + @return: A L{LiveProperties} instance which contains a getter method for each live property. + """ + body = createFindBody(LiveProperties.NAMES, Constants.NS_DAV) + response = self.connection.propfind(self.path, body, depth=0) + properties = response.msr.values()[0] + return LiveProperties(properties) + + def writeProperties(self, properties, lockToken=None): + """ + Sets or updates the given properties. + + @param lockToken: if the resource has been locked this is the lock token. + @type lockToken: L{LockToken} + @param properties: a map from property names to a String or + DOM element value for each property to add or update. + """ + assert isinstance(properties, types.DictType) + assert lockToken == None or isinstance(lockToken, LockToken), \ + "Invalid lockToken argument %s" % type(lockToken) + header = {} + if lockToken: + header = lockToken.toHeader() + body = createUpdateBody(properties, self.defaultNamespace) + response = self.connection.proppatch(self.path, body, header) + if response.msr.errorCount > 0: + raise WebdavError("Request failed: " + response.msr.reason, response.msr.code) + + def deleteProperties(self, lockToken=None, *names): + """ + Removes the given properties from this resource. + + @param lockToken: if the resource has been locked this is the lock token. + @type lockToken: L{LockToken} + @param names: a collection of property names. + A property name is a (XmlNameSpace, propertyName) tuple. + """ + assert lockToken == None or isinstance(lockToken, LockToken), \ + "Invalid lockToken argument %s" % type(lockToken) + header = {} + if lockToken: + header = lockToken.toHeader() + body = createDeleteBody(names, self.defaultNamespace) + response = self.connection.proppatch(self.path, body, header) + if response.msr.errorCount > 0: + raise WebdavError("Request failed: " + response.msr.reason, response.msr.code) + + # ACP extension + def setAcl(self, acl, lockToken=None): + """ + Sets ACEs in the non-inherited and non-protected ACL or the resource. + This is the implementation of the ACL method of the WebDAV ACP. + + @param acl: ACL to be set on resource as ACL object. + @param lockToken: If the resource has been locked this is the lock token (defaults to None). + @type lockToken: L{LockToken} + """ + assert lockToken == None or isinstance(lockToken, LockToken), \ + "Invalid lockToken argument %s" % type(lockToken) + headers = {} + if lockToken: + headers = lockToken.toHeader() + headers['Content-Type'] = XML_CONTENT_TYPE + body = acl.toXML() + response = self.connection._request('ACL', self.path, body, headers) + return response + ## TODO: parse DAV:error response + + def getAcl(self): + """ + Returns this resource's ACL in an ACL instance. + + @return: Access Control List. + @rtype: L{ACL<webdav.acp.Acl.ACL>} + """ + xmlAcl = self.readProperty(Constants.NS_DAV, Constants.TAG_ACL) + return ACL(xmlAcl) + + def getCurrentUserPrivileges(self): + """ + Returns a tuple of the current user privileges. + + @return: list of Privilege instances + @rtype: list of L{Privilege<webdav.acp.Privilege.Privilege>} + """ + privileges = self.readProperty(Constants.NS_DAV, Constants.PROP_CURRENT_USER_PRIVILEGE_SET) + result = [] + for child in privileges.children: + result.append(Privilege(domroot=child)) + return result + + def getPrincipalCollections(self): + """ + Returns a list principal collection URLs. + + @return: list of principal collection URLs + @rtype: C{list} of C{unicode} elements + """ + webdavQueryResult = self.readProperty(Constants.NS_DAV, Constants.PROP_PRINCIPAL_COLLECTION_SET) + principalCollectionList = [] + for child in webdavQueryResult.children: + principalCollectionList.append(child.first_cdata) + return principalCollectionList + + def getOwnerUrl(self): + """ Explicitly retireve the Url of the owner. """ + + result = self.readProperty(Constants.NS_DAV, Constants.PROP_OWNER) + if result and len(result.children): + return result.children[0].textof() + return None + +class CollectionStorer(ResourceStorer): + """ + This class provides client access to a WebDAV collection resource identified by an URI. + This class does not cache resource data. This has to be performed by its clients. + + @author: Roland Betz + """ + + def __init__(self, url, connection=None, validateResourceNames=True): + """ + Creates a CollectionStorer instance for a URL and an optional Connection object. + User must invoke validate() after constuction to check the resource on the server. + + @see: L{webdav.WebdavClient.ResourceStorer.__init__} + @param url: unique resource location for this storer + @param connection: this optional parameter contains a Connection object for the host part + of the given URL. Passing a connection saves memory by sharing this connection. + """ + if url[-1] != '/': # Collection URL must end with slash + url += '/' + ResourceStorer.__init__(self, url, connection, validateResourceNames) + + def getResourceStorer(self, name): + """ + Return a ResourceStorer instance for a child resource (member) of this Collection. + + @param name: leaf name of child resource + @return: L{ResourceStorer} instance + """ + assert isinstance(name, types.StringType) or isinstance(name, types.UnicodeType) + return ResourceStorer(self.url + name, self.connection, self.validateResourceNames) + + def validate(self): + """ + Check whether this URL contains a WebDAV collection. + Uses the WebDAV OPTION method. + + @raise WebdavError: L{WebdavError} if URL does not contain a WebDAV collection resource. + """ + super(CollectionStorer, self).validate() + isCollection = self.readProperty(Constants.NS_DAV, Constants.PROP_RESOURCE_TYPE) + if not (isCollection and isCollection.children): + raise WebdavError("Not a collection URL.", 0) + + def addCollection(self, name, lockToken=None): + """ + Make a new WebDAV collection resource within this collection. + + @param name: of the new collection + @param lockToken: None or token returned by last lock operation + @type lockToken: L{LockToken} + """ + assert isinstance(name, types.StringType) or isinstance(name, types.UnicodeType) + assert lockToken == None or isinstance(lockToken, LockToken), \ + "Invalid lockToken argument %s" % type(lockToken) + header = {} + if lockToken: + header = lockToken.toHeader() + if self.validateResourceNames: + validateResourceName(name) + if name[-1] != '/': # Collection URL must end with slash + name += '/' + self.connection.mkcol(self.path + name, header) + return CollectionStorer(self.url + name, self.connection, self.validateResourceNames) + + def addResource(self, name, content=None, properties=None, lockToken=None): + """ + Create a new empty WebDAV resource contained in this collection with the given + properties. + + @param name: leaf name of the new resource + @param content: None or initial binary content of resource + @param properties: name/value-map containing properties + @param lockToken: None or token returned by last lock operation + @type lockToken: L{LockToken} + """ + assert isinstance(name, types.StringType) or isinstance(name, types.UnicodeType) + assert lockToken == None or isinstance(lockToken, LockToken), \ + "Invalid lockToken argument %s" % type(lockToken) + if self.validateResourceNames: + validateResourceName(name) # check for invalid characters + resource_ = ResourceStorer(self.url + name, self.connection, self.validateResourceNames) + resource_.uploadContent(content, lockToken) + if properties: + resource_.writeProperties(properties, lockToken) + return resource_ + + def deleteResource(self, name, lockToken=None): + """ + Delete a collection which is contained within this collection + + @param name: leaf name of a contained collection resource + @param lockToken: None or token returned by last lock operation + @type lockToken: L{LockToken} + """ + assert isinstance(name, types.StringType) or isinstance(name, types.UnicodeType) + assert lockToken == None or isinstance(lockToken, LockToken), \ + "Invalid lockToken argument %s" % type(lockToken) + header = {} + if lockToken: + header = lockToken.toHeader() + if self.validateResourceNames: + validateResourceName(name) + response = self.connection.delete(self.path + name, header) + if response.status == Constants.CODE_MULTISTATUS and response.msr.errorCount > 0: + raise WebdavError("Request failed: %s" % response.msr.reason, response.msr.code) + + def lockAll(self, owner): + """ + Locks this collection resource for exclusive write access. This means that for + succeeding write operations the returned lock token has to be passed. + The operation is applied recursively to all contained resources. + If the methode does not throw an exception then the lock has been granted. + + @param owner: describes the lock holder + @return: Lock token string (automatically generated). + @rtype: L{LockToken} + """ + assert isinstance(owner, types.StringType) or isinstance(owner, types.UnicodeType) + response = self.connection.lock(self.path, owner, depth=Constants.HTTP_HEADER_DEPTH_INFINITY) + return LockToken(self.url, response.locktoken) + + def listResources(self): + """ + Describe all members within this collection. + + @return: map from URI to a L{LiveProperties} instance containing the WebDAV + live attributes of the contained resource + """ + # *LiveProperties.NAMES denotes the list of all live properties as an + # argument to the method call. + response = self.connection.getprops(self.path, + depth=1, + ns=Constants.NS_DAV, + *LiveProperties.NAMES) + result = {} + for path, properties in response.msr.items(): + if path == self.path: # omit this collection resource + continue + ## some servers do not append a trailing slash to collection paths + if self.path.endswith('/') and self.path[0:-1] == path: + continue + result[path] = LiveProperties(properties=properties) + return result + + def getCollectionContents(self): + """ + Return a list of the tuple (resources or collection) / properties) + + @return: a list of the tuple (resources or collection) / properties) + @rtype: C{list} + """ + self.validate() + collectionContents = [] + result = self.listResources() + for url, properties_ in result.items(): + if not self.path == url: + if properties_.getResourceType() == 'resource': + myWebDavStorer = ResourceStorer(url, self.connection, self.validateResourceNames) + else: + myWebDavStorer = CollectionStorer(url, self.connection, self.validateResourceNames) + collectionContents.append((myWebDavStorer, properties_)) + return collectionContents + + def findProperties(self, *names): + """ + Retrieve given properties for this collection and all directly contained resources. + + @param names: a list of property names + @return: a map from resource URI to a map from property name to value. + """ + assert isinstance(names, types.ListType) or isinstance(names, types.TupleType), \ + "Argument name has type %s" % str(type(names)) + body = createFindBody(names, self.defaultNamespace) + response = self.connection.propfind(self.path, body, depth=1) + return response.msr + + def deepFindProperties(self, *names): + """ + Retrieve given properties for this collection and all contained (nested) resources. + + Note: + ===== + This operation can take a long time if used with recursive=true and is therefore + disabled on some WebDAV servers. + + @param names: a list of property names + @return: a map from resource URI to a map from property name to value. + """ + assert isinstance(names, types.ListType.__class__) or isinstance(names, types.TupleType), \ + "Argument name has type %s" % str(type(names)) + body = createFindBody(names, self.defaultNamespace) + response = self.connection.propfind(self.path, body, depth=Constants.HTTP_HEADER_DEPTH_INFINITY) + return response.msr + + def findAllProperties(self): + """ + Retrieve all properties for this collection and all directly contained resources. + + @return: a map from resource URI to a map from property name to value. + """ + response = self.connection.allprops(self.path, depth=1) + return response.msr + + + # DASL extension + def search(self, conditions, selects): + """ + Search for contained resources which match the given search condition. + + @param conditions: tree of ConditionTerm instances representing a logical search term + @param selects: list of property names to retrieve for the found resources + """ + assert isinstance(conditions, ConditionTerm) + headers = { 'Content-Type' : XML_CONTENT_TYPE, "depth": Constants.HTTP_HEADER_DEPTH_INFINITY} + body = createSearchBody(selects, self.path, conditions) + response = self.connection._request('SEARCH', self.path, body, headers) + return response.msr + + +class LockToken(object): + """ + This class provides help on handling WebDAV lock tokens. + + @author: Roland Betz + """ + # restrict instance variables + __slots__ = ('url', 'token') + + def __init__(self, url, token): + assert isinstance(url, types.StringType) or isinstance(url, types.UnicodeType), \ + "Invalid url argument %s" % type(url) + assert isinstance(token, types.StringType) or isinstance(token, types.UnicodeType), \ + "Invalid lockToken argument %s" % type(token) + self.url = url + self.token = token + + def value(self): + """ + Descriptive string containing the lock token's URL and the token itself. + + @return: Descriptive lock token with URL. + @rtype: C{string} + """ + return "<" + self.url + "> (<" + self.token + ">)" + + def toHeader(self): + """ + Header fragment for WebDAV request. + + @return: Dictionary containing an entry for the lock token query. + @rtype: C{dictionary} + """ + return {Constants.HTTP_HEADER_IF: self.value()} + + def __str__(self): + return self.value() + + +def _blockCopyFile(source, dest, blockSize): + """ + Copies a file in chunks of C{blockSize}. + + @param source: Source file. + @type source: FileIO buffer. + @param dest: Destination file. + @type dest: FileIO buffer. + @param blockSize: Size of block in bytes. + @type blockSize: C{int} + """ + transferedBytes = 0 + block = source.read(blockSize) + while len(block): + dest.write(block) + transferedBytes += len(block); + block = source.read(blockSize) + +def _checkUrl(url): + """ + Checks the given URL for validity. + + @param url: URL to check. + @type url: C{string} + + @raise ValueError: If the URL does not contain valid/usable content. + """ + + parts = urlsplit(url, allow_fragments=False) + if len(parts[0]) == 0 or len(parts[1]) == 0 or len(parts[2]) == 0: + raise ValueError("Invalid URL: " + repr(url)) diff --git a/src/webdav/WebdavRequests.py b/src/webdav/WebdavRequests.py new file mode 100644 index 0000000..79e586a --- /dev/null +++ b/src/webdav/WebdavRequests.py @@ -0,0 +1,205 @@ +# pylint: disable-msg=W0511,W0212,E1111 +# +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + + +""" +This module handles WebDav server requests. +""" + + +import types +from webdav import Constants +import qp_xml +from tempfile import TemporaryFile + +from davlib import XML_DOC_HEADER + +from webdav.NameCheck import validatePropertyName + + +__version__ = "$LastChangedRevision$" + + +## TODO: create a property list class + +class XmlNameSpaceMangler(object): + ''' + Handles WebDav requests. + ''' + + # restrict instance variables + __slots__ = ('shortcuts', 'defaultNameSpace') + + def __init__(self, nameList, defaultNameSpace = None): + ''' + + @param nameList: + @param defaultNameSpace: + ''' + + assert isinstance(nameList, types.ListType) or isinstance(nameList, types.TupleType), \ + "1. argument has wrong type %s" % type(nameList) + self.shortcuts = {} + self.defaultNameSpace = defaultNameSpace + for name in nameList: + if not isinstance(name, types.TupleType): + name = (defaultNameSpace, name) + assert isinstance(name, types.TupleType) and len(name) == 2, \ + "Name is not a namespace, name tuple: %s" % type(name) + validatePropertyName(name[1]) + if name[0] and not self.shortcuts.has_key(name[0]): + self.shortcuts[name[0]] = 'ns%d' % len(self.shortcuts) + + def getNameSpaces(self): + ''' + Returns the namespace. + ''' + + result = "" + for namespace, short in self.shortcuts.items(): + result += ' xmlns:%s="%s"' % (short, namespace) + return result + + def getUpdateElements(self, valueMap): + ''' + + @param valueMap: + ''' + + elements = "" + for name in valueMap.keys(): + fullname = name + if isinstance(name, types.StringType): + fullname = (self.defaultNameSpace, name) + if not fullname[0]: + tag = fullname[1] + else: + tag = self.shortcuts[fullname[0]] + ':' + fullname[1] + value = valueMap[name] + if value: + if isinstance(value, qp_xml._element): + tmpFile = TemporaryFile('w+') + value = qp_xml.dump(tmpFile, value) + tmpFile.flush() + tmpFile.seek(0) + tmpFile.readline() + value = tmpFile.read() + else: + value = "<![CDATA[%s]]>" % value + else: + value = "" + elements += "<%s>%s</%s>" % (tag, value, tag) + return elements + + def getNameElements(self, nameList): + ''' + + @param nameList: + ''' + + elements = "" + for name in nameList: + if isinstance(name, types.StringType): + name = (self.defaultNameSpace, name) + if not name[0]: + tag = name[1] + else: + tag = self.shortcuts[name[0]] + ':' + name[1] + elements += "<%s />" % tag + return elements + + + +def createUpdateBody(propertyDict, defaultNameSpace = None): + ''' + + @param propertyDict: + @param defaultNameSpace: + ''' + + updateTag = 'D:' + Constants.TAG_PROPERTY_UPDATE + setTag = 'D:' + Constants.TAG_PROPERTY_SET + propTag = 'D:' + Constants.TAG_PROP + mangler = XmlNameSpaceMangler(propertyDict.keys(), defaultNameSpace) + return XML_DOC_HEADER + \ + '<%s xmlns:D="DAV:"><%s><%s %s>' % (updateTag, setTag, propTag, mangler.getNameSpaces()) + \ + mangler.getUpdateElements(propertyDict) + \ + '</%s></%s></%s>' % (propTag, setTag, updateTag) + + +def createDeleteBody(nameList, defaultNameSpace = None): + ''' + + @param nameList: + @param defaultNameSpace: + ''' + + updateTag = 'D:' + Constants.TAG_PROPERTY_UPDATE + removeTag = 'D:' + Constants.TAG_PROPERTY_REMOVE + propTag = 'D:' + Constants.TAG_PROP + mangler = XmlNameSpaceMangler(nameList, defaultNameSpace) + return XML_DOC_HEADER + \ + '<%s xmlns:D="DAV:"><%s><%s %s>' % (updateTag, removeTag, propTag, mangler.getNameSpaces()) + \ + mangler.getNameElements(nameList) + \ + '</%s></%s></%s>' % (propTag, removeTag, updateTag) + + +def createFindBody(nameList, defaultNameSpace = None): + ''' + + @param nameList: + @param defaultNameSpace: + ''' + + findTag = 'D:' + Constants.TAG_PROPERTY_FIND + propTag = 'D:' + Constants.TAG_PROP + mangler = XmlNameSpaceMangler(nameList, defaultNameSpace) + return XML_DOC_HEADER + \ + '<%s xmlns:D="DAV:"><%s %s>' % (findTag, propTag, mangler.getNameSpaces()) + \ + mangler.getNameElements(nameList) + \ + '</%s></%s>' % (propTag, findTag) + + +def createSearchBody(selects, path, conditions, defaultNameSpace = None): + ''' + Creates DASL XML body. + + @param selects: list of property names to retrieve for the found resources + @param path: list of conditions + @param conditions: tree of ConditionTerm instances representing a logical search term + @param defaultNameSpace: default namespace + ''' + + searchTag = 'D:' + Constants.TAG_SEARCH_REQUEST + basicTag = 'D:' + Constants.TAG_SEARCH_BASIC + selectTag = 'D:' + Constants.TAG_SEARCH_SELECT + fromTag = 'D:' + Constants.TAG_SEARCH_FROM + scopeTag = 'D:' + Constants.TAG_SEARCH_SCOPE + whereTag = 'D:' + Constants.TAG_SEARCH_WHERE + propTag = 'D:' + Constants.TAG_PROP + hrefTag = 'D:' + Constants.TAG_HREF + depthTag = 'D:' + Constants.TAG_LOCK_DEPTH + depthValue = Constants.HTTP_HEADER_DEPTH_INFINITY + mangler = XmlNameSpaceMangler(selects, defaultNameSpace) + return XML_DOC_HEADER + \ + '<%s xmlns:D="DAV:"><%s>' % (searchTag, basicTag) + \ + '<%s><%s %s>%s</%s></%s>' % (selectTag, propTag, mangler.getNameSpaces(), + mangler.getNameElements(selects), propTag, selectTag) + \ + '<%s><%s><%s>%s</%s><%s>%s</%s></%s></%s>' % (fromTag, scopeTag, hrefTag, path, hrefTag, + depthTag, depthValue, depthTag, scopeTag, fromTag) + \ + '<%s>%s</%s>' % (whereTag, conditions.toXML(),whereTag) + \ + '</%s></%s>' % (basicTag, searchTag) +
\ No newline at end of file diff --git a/src/webdav/WebdavResponse.py b/src/webdav/WebdavResponse.py new file mode 100644 index 0000000..c84943d --- /dev/null +++ b/src/webdav/WebdavResponse.py @@ -0,0 +1,525 @@ +# pylint: disable-msg=R0903,W0142,W0221,W0212,W0104,W0511,C0103,R0901 +# +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + + +""" +Handles WebDAV responses. +""" + + +from davlib import _parse_status +import qp_xml +from webdav import Constants +import time +import rfc822 +import urllib +# Handling Jython 2.5 bug concerning the date pattern +# conversion in time.strptime +try: + from java.lang import IllegalArgumentException +except ImportError: + class IllegalArgumentException(object): + pass + + +__version__ = "$LastChangedRevision$" + + +class HttpStatus(object): + """ + TBD + + @ivar code: + @type code: + @ivar reason: + @type reason: + @ivar errorCount: + @type errorCount: int + """ + + def __init__(self, elem): + """ + TBD + + @param elem: ... + @type elem: instance of L{Element} + """ + self.code, self.reason = _parse_status(elem) + self.errorCount = (self.code >= Constants.CODE_LOWEST_ERROR) + def __str__(self): + return "HTTP status %d: %s" % (self.code, self.reason) + + +class MultiStatusResponse(dict): + """ + TBD + + @ivar status: + @type status: + @ivar reason: + @type reason: + @ivar errorCount: + @type errorCount: + """ + + # restrict instance variables + __slots__ = ('errorCount', 'reason', 'status') + + def __init__(self, domroot): + dict.__init__(self) + self.errorCount = 0 + self.reason = None + self.status = Constants.CODE_MULTISTATUS + if (domroot.ns != Constants.NS_DAV) or (domroot.name != Constants.TAG_MULTISTATUS): + raise ResponseFormatError(domroot, 'Invalid response: <DAV:multistatus> expected.') + self._scan(domroot) + + def getCode(self): + if self.errorCount == 0: + return Constants.CODE_SUCCEEDED + if len(self) > self.errorCount: + return Constants.CODE_MULTISTATUS + return self.values()[0].code + + def getReason(self): + result = "" + for response in self.values(): + if response.code > Constants.CODE_LOWEST_ERROR: + result += response.reason + return result + + def __str__(self): + result = "" + for key, value in self.items(): + if isinstance(value, PropertyResponse): + result += "Resource at %s has %d properties and %d errors.\n" % (key, len(value), value.errorCount) + else: + result += "Resource at %s returned " % key + str(value) + return result + + def _scan(self, root): + for child in root.children: + if child.ns != Constants.NS_DAV: + continue + if child.name == Constants.TAG_RESPONSEDESCRIPTION: + self.reason = child.textof() + elif child.name == Constants.TAG_RESPONSE: + self._scanResponse(child) + ### unknown child element + + def _scanResponse(self, elem): + hrefs = [] + response = None + for child in elem.children: + if child.ns != Constants.NS_DAV: + continue + if child.name == Constants.TAG_HREF: + try: + href = _unquoteHref(child.textof()) + except UnicodeDecodeError: + raise ResponseFormatError(child, "Invalid 'href' data encoding.") + hrefs.append(href) + elif child.name == Constants.TAG_STATUS: + self._scanStatus(child, *hrefs) + elif child.name == Constants.TAG_PROPERTY_STATUS: + if not response: + if len(hrefs) != 1: + raise ResponseFormatError(child, 'Invalid response: One <DAV:href> expected.') + response = PropertyResponse() + self[hrefs[0]] = response + response._scan(child) + elif child.name == Constants.TAG_RESPONSEDESCRIPTION: + for href in hrefs: + self[href].reasons.append(child.textOf()) + ### unknown child element + if response and response.errorCount > 0: + self.errorCount += 1 + + def _scanStatus(self, elem, *hrefs): + if len(hrefs) == 0: + raise ResponseFormatError(elem, 'Invalid response: <DAV:href> expected.') + status = HttpStatus(elem) + for href in hrefs: + self[href] = status + if status.errorCount: + self.errorCount += 1 + + # Instance properties + code = property(getCode, None, None, "HTTP response code") + + + +class PropertyResponse(dict): + """ + TBD + + @ivar errors: + @type errors: list of ... + @ivar reasons: + @type reasons: list of ... + @ivar failedProperties: + @type failedProperties: dict of ... + """ + + # restrict instance variables + __slots__ = ('errors', 'reasons', 'failedProperties') + + def __init__(self): + dict.__init__(self) + self.errors = [] + self.reasons = [] + self.failedProperties = {} + + def __str__(self): + result = "" + for value in self.values(): + result += value.name + '= ' + value.textof() + '\n' + result += self.getReason() + return result + + def getCode(self): + if len(self.errors) == 0: + return Constants.CODE_SUCCEEDED + if len(self) > 0: + return Constants.CODE_MULTISTATUS + return self.errors[-1].code + + def getReason(self): + result = "" + if len(self.errors) > 0: + result = "Failed for: " + repr(self.failedProperties.keys()) + "\n" + for error in self.errors: + result += "%s (%d). " % (error.reason, error.code) + for reason in self.reasons: + result += "%s. " % reason + return result + + def _scan(self, element): + status = None + statusElement = element.find(Constants.TAG_STATUS, Constants.NS_DAV) + if statusElement: + status = HttpStatus(statusElement) + if status.errorCount: + self.errors.append(status) + + propElement = element.find(Constants.TAG_PROP, Constants.NS_DAV) + if propElement: + for prop in propElement.children: + if status.errorCount: + self.failedProperties[(prop.ns, prop.name)]= status + else: + prop.__class__ = Element # bad, bad trick + self[prop.fullname] = prop + reasonElement = element.find(Constants.TAG_RESPONSEDESCRIPTION, Constants.NS_DAV) + if reasonElement: + self.reasons.append(reasonElement.textOf()) + + # Instance properties + code = property(getCode, None, None, "HTTP response code") + errorCount = property(lambda self: len(self.errors), None, None, "HTTP response code") + reason = property(getReason, None, None, "HTTP response code") + + + + +class LiveProperties(object): + """ + This class provides convenient access to the WebDAV 'live' properties of a resource. + WebDav 'live' properties are defined in RFC 2518, Section 13. + Each property is converted from string to its natural data type. + + @version: $Revision$ + @author: Roland Betz + """ + + # restrict instance variables + __slots__ = ('properties') + + NAMES = (Constants.PROP_CREATION_DATE, Constants.PROP_DISPLAY_NAME, + Constants.PROP_CONTENT_LENGTH, Constants.PROP_CONTENT_TYPE, Constants.PROP_ETAG, + Constants.PROP_LAST_MODIFIED, Constants.PROP_OWNER, + Constants.PROP_LOCK_DISCOVERY, Constants.PROP_RESOURCE_TYPE, Constants.PROP_SUPPORTED_LOCK ) + + def __init__(self, properties=None, propElement=None): + """ + Construct <code>StandardProperties</code> from a map of properties containing + live properties or from a XML 'prop' element containing live properties + + @param properties: map as implemented by class L{PropertyResponse} + @param propElement: an C{Element} value + """ + assert isinstance(properties, PropertyResponse) or \ + isinstance(propElement, qp_xml._element), \ + "Argument properties has type %s" % str(type(properties)) + self.properties = {} + for name, value in properties.items(): + if name[0] == Constants.NS_DAV and name[1] in self.NAMES: + self.properties[name[1]] = value + + def getContentLanguage(self): + """ + Return the language of a resource's textual content or null + + @return: string + """ + + result = "" + if not self.properties.get(Constants.PROP_CONTENT_LANGUAGE, None) is None: + result = self.properties.get(Constants.PROP_CONTENT_LANGUAGE).textof() + return result + + def getContentLength(self): + """ + Returns the length of the resource's content in bytes. + + @return: number of bytes + """ + + result = 0 + if not self.properties.get(Constants.PROP_CONTENT_LENGTH, None) is None: + result = int(self.properties.get(Constants.PROP_CONTENT_LENGTH).textof()) + return result + + def getContentType(self): + """ + Return the resource's content MIME type. + + @return: MIME type string + """ + + result = "" + if not self.properties.get(Constants.PROP_CONTENT_TYPE, None) is None: + result = self.properties.get(Constants.PROP_CONTENT_TYPE).textof() + return result + + def getCreationDate(self): + """ + Return date of creation as time tuple. + + @return: time tuple + @rtype: C{time.struct_time} + + @raise ValueError: If string is not in the expected format (ISO 8601). + """ + + datetimeString = "" + if not self.properties.get(Constants.PROP_CREATION_DATE, None) is None: + datetimeString = self.properties.get(Constants.PROP_CREATION_DATE).textof() + + result = rfc822.parsedate(datetimeString) + if result is None: + result = _parseIso8601String(datetimeString) + + return time.mktime(result) + + def getEntityTag(self): + """ + Return a entity tag which is unique for a particular version of a resource. + Different resources or one resource before and after modification have different etags. + + @return: entity tag string + """ + + result = "" + if not self.properties.get(Constants.PROP_ETAG, None) is None: + result = self.properties.get(Constants.PROP_ETAG).textof() + return result + + def getDisplayName(self): + """ + Returns a resource's display name. + + @return: string + """ + + result = "" + if not self.properties.get(Constants.PROP_DISPLAY_NAME, None) is None: + result = self.properties.get(Constants.PROP_DISPLAY_NAME).textof() + return result + + def getLastModified(self): + """ + Return last modification of resource as time tuple. + + @return: Modification date time. + @rtype: C{time.struct_time} + + @raise ValueError: If the date time string is not in the expected format (RFC 822 / ISO 8601). + """ + + datetimeString = None + if not self.properties.get(Constants.PROP_LAST_MODIFIED, None) is None: + datetimeString = self.properties.get(Constants.PROP_LAST_MODIFIED).textof() + result = rfc822.parsedate(datetimeString) + if result is None: + result = _parseIso8601String(datetimeString) + return time.mktime(result) + + def getLockDiscovery(self): + """ + Return all current lock's applied to a resource or null if it is not locked. + + @return: a lockdiscovery DOM element according to RFC 2815 + """ + + xml = self.properties.get(Constants.PROP_LOCK_DISCOVERY) + return _scanLockDiscovery(xml) + + def getResourceType(self): + """ + Return a resource's WebDAV type. + + @return: 'collection' or 'resource' + """ + + xml = self.properties.get(Constants.PROP_RESOURCE_TYPE) + if xml and xml.children: + return xml.children[0].name + return "resource" + + def getSupportedLock(self): + """ + Return a DOM element describing all supported lock options for a resource. + Usually this is shared and exclusive write lock. + + @return: supportedlock DOM element according to RFC 2815 + """ + + xml = self.properties.get(Constants.PROP_SUPPORTED_LOCK) + return xml + + def getOwnerAsUrl(self): + """ + Return a resource's owner in form of a URL. + + @return: string + """ + + xml = self.properties.get(Constants.PROP_OWNER) + if xml and len(xml.children): + return xml.children[0].textof() + return None + + def __str__(self): + result = "" + result += " Name=" + self.getDisplayName() + result += "\n Type=" + self.getResourceType() + result += "\n Length=" + str(self.getContentLength()) + result += "\n Content Type="+ self.getContentType() + result += "\n ETag=" + self.getEntityTag() + result += "\n Created=" + time.strftime("%c GMT", self.getCreationDate()) + result += "\n Modified=" + time.strftime("%c GMT", self.getLastModified()) + return result + + +def _parseIso8601String(date): + """ + Parses the given ISO 8601 string and returns a time tuple. + The strings should be formatted according to RFC 3339 (see section 5.6). + But currently there are two exceptions: + 1. Time offset is limited to "Z". + 2. Fragments of seconds are ignored. + """ + + if "." in date and "Z" in date: # Contains fragments of second? + secondFragmentPos = date.rfind(".") + timeOffsetPos = date.rfind("Z") + date = date[:secondFragmentPos] + date[timeOffsetPos:] + try: + timeTuple = time.strptime(date, Constants.DATE_FORMAT_ISO8601) + except IllegalArgumentException: # Handling Jython 2.5 bug concerning the date pattern accordingly + import _strptime # Using the Jython fall back solution directly + timeTuple = _strptime.strptime(date, Constants.DATE_FORMAT_ISO8601) + return timeTuple + + +class ResponseFormatError(IOError): + """ + An instance of this class is raised when the web server returned a webdav + reply which does not adhere to the standard and cannot be recognized. + """ + def __init__(self, element, message= None): + IOError.__init__(self, "ResponseFormatError at element %s: %s" % (element.name, message)) + self.element = element + self.message = message + + +class Element(qp_xml._element): + """ + This class improves the DOM interface (i.e. element interface) provided by the qp_xml module + TODO: substitute qp_xml by 'real' implementation. e.g. domlette + """ + def __init__(self, namespace, name, cdata=''): + qp_xml._element.__init__(self, ns=namespace, name=name, lang=None, parent=None, + children=[], ns_scope={}, attrs={}, + first_cdata=cdata, following_cdata='') + + def __str__(self): + return self.textof() + + def __getattr__(self, name): + if (name == 'fullname'): + return (self.__dict__['ns'], self.__dict__['name']) + raise AttributeError, name + + def add(self, child): + self.children.append(child) + return child + +def _scanLockDiscovery(root): + assert root.name == Constants.PROP_LOCK_DISCOVERY, "Invalid lock discovery XML element" + active = root.find(Constants.TAG_ACTIVE_LOCK, Constants.NS_DAV) + if active: + return _scanActivelock(active) + return None + +def _scanActivelock(root): + assert root.name == Constants.TAG_ACTIVE_LOCK, "Invalid active lock XML element" + token = _scanOrError(root, Constants.TAG_LOCK_TOKEN) + value = _scanOrError(token, Constants.TAG_HREF) + owner = _scanOwner(root) + depth = _scanOrError(root, Constants.TAG_LOCK_DEPTH) + return (value.textof(), owner, depth.textof()) + +def _scanOwner(root): + owner = root.find(Constants.TAG_LOCK_OWNER, Constants.NS_DAV) + if owner: + href = owner.find(Constants.TAG_HREF, Constants.NS_DAV) + if href: + return href.textof() + return owner.textof() + return None + +def _scanOrError(elem, childName): + child = elem.find(childName, Constants.NS_DAV) + if not child: + raise ResponseFormatError(elem, "Invalid response: <"+childName+"> expected") + return child + + +def _unquoteHref(href): + #print "*** Response HREF=", repr(href) + if type(href) == type(u""): + try: + href = href.encode('ascii') + except UnicodeError: # URL contains unescaped non-ascii character + # handle bug in Tamino webdav server + return urllib.unquote(href) + href = urllib.unquote(href) + if Constants.CONFIG_UNICODE_URL: + return unicode(href, 'utf-8') + else: + return unicode(href, 'latin-1') diff --git a/src/webdav/__init__.py b/src/webdav/__init__.py new file mode 100644 index 0000000..3e46609 --- /dev/null +++ b/src/webdav/__init__.py @@ -0,0 +1,16 @@ +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + + +__version__ = "$LastChangedRevision$" diff --git a/src/webdav/acp/Ace.py b/src/webdav/acp/Ace.py new file mode 100644 index 0000000..8321d41 --- /dev/null +++ b/src/webdav/acp/Ace.py @@ -0,0 +1,293 @@ +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + + +""" +ACE object handling according to WebDAV ACP specification. +""" + + +from webdav.acp.Principal import Principal +from webdav.acp.GrantDeny import GrantDeny +from webdav import Constants +from webdav.Connection import WebdavError + + +__version__ = "$LastChangedRevision$" + + +class ACE(object): + """ + This class provides functionality for handling ACEs + + @ivar principal: A principal (user or group) + @type principal: L{Principal} object + @ivar invert: Flag indicating whether ACE should invert the principal. + @type invert: C{bool} + @ivar grantDenies: Grant or deny clauses for privileges + @type grantDenies: C{list} of L{GrantDeny} objects + @ivar protected: Flag indicating whether ACE is protected. + @type protected: C{bool} + @ivar inherited: URL indicating the source from where the ACE is inherited. + @type inherited: C{string} + """ + + # restrict instance variables + __slots__ = ('principal', 'invert', 'grantDenies', 'protected', 'inherited') + + def __init__(self, domroot=None, principal=None, grantDenies=None): + """ + Constructor should be called with either no parameters (create blank ACE), + one parameter (a DOM tree or principal), or two parameters (principal and + sequence of GrantDenies). + + @param domroot: A DOM tree (default: None). + @type domroot: L{webdav.WebdavResponse.Element} object + @param principal: A principal (user or group), (default: None). + @type principal: L{Principal} object + @param grantDenies: Grant and deny clauses for privileges (default: None). + @type grantDenies: sequence of L{GrantDeny} objects + + @raise WebdavError: When non-valid parameters are passed a L{WebdavError} is raised. + """ + self.principal = Principal() + self.protected = None + self.inherited = None + self.invert = None + self.grantDenies = [] + + if domroot: + self.principal = Principal(domroot=domroot.find(Constants.TAG_PRINCIPAL, Constants.NS_DAV)) + self.inherited = domroot.find(Constants.TAG_INHERITED, Constants.NS_DAV) + if self.inherited: + self.inherited = self.inherited.children[0].textof() + if domroot.find(Constants.TAG_PROTECTED, Constants.NS_DAV): + self.protected = 1 + for child in domroot.children: + if child.ns == Constants.NS_DAV \ + and (child.name == Constants.TAG_GRANT or child.name == Constants.TAG_DENY): + self.grantDenies.append(GrantDeny(domroot=child)) + elif isinstance(principal, Principal): + newPrincipal = Principal() + newPrincipal.copy(principal) + self.principal = newPrincipal + if (isinstance(grantDenies, list) or isinstance(grantDenies, tuple)): + self.addGrantDenies(grantDenies) + elif domroot == None and grantDenies == None: + # no param ==> blank ACE + pass + else: + # This shouldn't happen, someone screwed up with the params ... + raise WebdavError('non-valid parameters handed to ACE constructor') + + def __cmp__(self, other): + if not isinstance(other, ACE): + return 1 + if self.principal == other.principal \ + and self.invert == other.invert \ + and self.protected == other.protected \ + and self.inherited == other.inherited: + equal = 1 + for grantDeny in self.grantDenies: + inList = 0 + for otherGrantDeny in other.grantDenies: + if grantDeny == otherGrantDeny: + inList = 1 + if inList == 0: + equal = 0 + return not equal + else: + return 1 + + def __repr__(self): + repr = '<class ACE: ' + if self.invert: + repr += 'inverted principal, ' % (self.invert) + if self.principal: + repr += 'principal: %s, ' % (self.principal) + if self.protected: + repr += 'protected, ' + if self.inherited: + repr += 'inherited href: %s, ' % (self.inherited) + first = 1 + repr += 'grantDenies: [' + for grantDeny in self.grantDenies: + if first: + repr += '%s' % grantDeny + first = 0 + else: + repr += ', %s' % grantDeny + return '%s]>' % (repr) + + def copy(self, other): + '''Copy an ACE object. + + @param other: Another ACE to copy. + @type other: L{ACE} object + + @raise WebdavError: When an object that is not an L{ACE} is passed + a L{WebdavError} is raised. + ''' + if not isinstance(other, ACE): + raise WebdavError('Non-ACE object passed to copy method: %s.' % other.__class__) + self.invert = other.invert + self.protected = other.protected + self.inherited = other.inherited + self.principal = Principal() + if other.principal: + self.principal.copy(other.principal) + if other.grantDenies: + self.addGrantDenies(other.grantDenies) + + def isValid(self): + """ + Returns true/false (1/0) whether necessarry props + principal and grantDenies are set and whether the ACE contains one + grant or deny clauses. + + @return: Validity of ACE. + @rtype: C{bool} + """ + return self.principal and len(self.grantDenies) == 1 + + def isGrant(self): + ''' + Returns true/false (1/0) if ACE contains only grant clauses. + + @return: Value whether the ACE is of grant type. + @rtype: C{bool} + ''' + if self.isMixed() or len(self.grantDenies) < 1: + return 0 + else: + return self.grantDenies[0].isGrant() + + def isDeny(self): + ''' + Returns true/false (1/0) if ACE contains only deny clauses. + + @return: Value whether the ACE is of deny type. + @rtype: C{bool} + ''' + if self.isMixed() or len(self.grantDenies) < 1: + return 0 + else: + return self.grantDenies[0].isDeny() + + def isMixed(self): + ''' + Returns true/false (1/0) if ACE contains both types (grant and deny) of clauses. + + @return: Value whether the ACE is of mixed (grant and deny) type. + @rtype: C{bool} + ''' + mixed = 0 + if len(self.grantDenies): + first = self.grantDenies[0].grantDeny + for grantDeny in self.grantDenies: + if grantDeny.grantDeny != first: + mixed = 1 + return mixed + + def toXML(self, defaultNameSpace=None): + """ + Returns ACE content as a string of valid XML as described in WebDAV ACP. + + @param defaultNameSpace: Name space (default: None). + @type defaultNameSpace: C(string) + """ + assert self.isValid(), "ACE is not initialized or does not contain valid content!" + + ACE = 'D:' + Constants.TAG_ACE + res = self.principal.toXML(self.invert) + for grantDeny in self.grantDenies: + res += grantDeny.toXML() + if self.protected: + res += '<D:protected/>' + if self.inherited: + res += '<D:inherited><D:href>%s</D:href></D:inherited>' % (self.inherited) + return '<%s>%s</%s>' % (ACE, res, ACE) + + def setPrincipal(self, principal): + ''' + Sets the passed principal on the ACE. + + @param principal: A principal. + @type principal: L{Principal} object + ''' + self.principal = Principal() + self.principal.copy(principal) + + def setInherited(self, href): + ''' + Sets the passed URL on the ACE to denote from where it is inherited. + + @param href: A URL. + @type href: C{string} + ''' + self.inherited = href + + def addGrantDeny(self, grantDeny): + ''' + Adds the passed GrantDeny object to list if it's not in it, yet. + + @param grantDeny: A grant or deny clause. + @type grantDeny: L{GrantDeny} object + ''' + # only add it if it's not in the list, yet ... + inList = 0 + for element in self.grantDenies: + if element == grantDeny: + inList = 1 + if not inList: + newGrantDeny = GrantDeny() + newGrantDeny.copy(grantDeny) + self.grantDenies.append(newGrantDeny) + + def addGrantDenies(self, grantDenies): + '''Adds the list of passed grant/deny objects to list. + + @param grantDenies: Grant or deny clauses. + @type grantDenies: sequence of L{GrantDeny} objects + ''' + map(lambda grantDeny: self.addGrantDeny(grantDeny), grantDenies) + + def delGrantDeny(self, grantDeny): + '''Deletes the passed GrantDeny object from list. + + @param grantDeny: A grant or deny clause. + @type grantDeny: L{GrantDeny} object + + @raise WebdavError: A L{WebdavError} is raised if the clause to be + deleted is not present. + ''' + # only add it if it's not in the list, yet ... + count = 0 + index = 0 + for element in self.grantDenies: + count += 1 + if element == grantDeny: + index = count + if index: + self.grantDenies.pop(index - 1) + else: + raise WebdavError('GrantDeny to be deleted not in list: %s.' % grantDeny) + + def delGrantDenies(self, grantDenies): + '''Deletes the list of passed grant/deny objects from list. + + @param grantDenies: Grant or deny clauses. + @type grantDenies: sequence of L{GrantDeny} objects + ''' + map(lambda grantDeny: self.delGrantDeny(grantDeny), grantDenies) diff --git a/src/webdav/acp/AceHandler.py b/src/webdav/acp/AceHandler.py new file mode 100644 index 0000000..e07b74d --- /dev/null +++ b/src/webdav/acp/AceHandler.py @@ -0,0 +1,182 @@ +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + + +""" +Handling of WebDAV Access Protocol Extensions and ACL preparation for UI. +""" + + +from webdav import Constants +from webdav.WebdavClient import ResourceStorer +from webdav.Connection import WebdavError + + +__version__ = "$LastChangedRevision$" + + +def extractSupportedPrivilegeSet(userPrivileges): + """ + Returns a dictionary of supported privileges. + + @param userPrivileges: A DOM tree. + @type userPrivileges: L{webdav.WebdavResponse.Element} object + + @raise WebdavError: When unknown elements appear in the + C{DAV:supported-privilege} appear a L{WebdavError} is raised. + + @return: A dictionary with privilege names as keys and privilege descriptions as values. + @rtype: C{dictionary} + """ + result = {} + for element in userPrivileges.children: + if element.name == Constants.TAG_SUPPORTED_PRIVILEGE: + privName = '' + privDescription = '' + for privilege in element.children: + if privilege.name == Constants.TAG_PRIVILEGE: + privName = privilege.children[0].name + elif privilege.name == Constants.TAG_DESCRIPTION: + privDescription = privilege.textof() + else: + raise WebdavError('Unknown element in DAV:supported-privilege: ' + privilege.name) + + if privName and privDescription: + result[privName] = privDescription + privName = '' + privDescription = '' + else: + raise WebdavError('Invalid element tag in DAV:supported-privilege-set: ' + element.name) + return result + + +def _insertAclDisplaynames(acl): + """ + Modifies the ACL by adding the human readable names + (DAV:displayname property) of each principal found in an ACL. + + This should be done with the REPORT method, but it is not supported by + Jacarta Slide, yet. (As of Aug. 1, 2003 in CVS repository) + + So we are going to do it differently by foot the harder way ... + + @param acl: An ACL object for which the displaynames should be retrieved. + @type acl: L{ACL} object + """ + ## This is redundant code to be still kept for the REPORT method way of doing it ... + ## property = '''<D:prop><D:displayname/></D:prop>''' + ## return self.getReport(REPORT_ACL_PRINCIPAL_PROP_SET, property) + for ace in acl.aces: + if not ace.principal.property: + principalConnection = \ + ResourceStorer(ace.principal.principalURL) + ace.principal.displayname = \ + principalConnection.readProperty(Constants.NS_DAV, Constants.PROP_DISPLAY_NAME) + + +def prepareAcls(acls): + """ + Returns all ACLs describing the behaviour of the resource. The information + in the ACL is modified to contain all information needed to display in the UI. + + @param acls: ACL objects. + @type acls: C{list} of L{ACL} objects + + @return: (non-valid) ACLs that contain both grant and deny clauses in an ACE. + Displaynames are added to the Principals where needed. + @rtype: C{list} of L{ACL} objects + """ + for acl in acls.keys(): + acls[acl] = acls[acl].joinGrantDeny() + _insertAclDisplaynames(acls[acl]) + return acls + + +def prepareAcl(acl): + """ + Returns an ACL describing the behaviour of the resource. The information + in the ACL is modified to contain all information needed to display in the UI. + + @param acl: An ACL object. + @type acl: L{ACL} object + + @return: A (non-valid) ACL that contains both grant and deny clauses in an ACE. + Displaynames are added to the Principals where needed. + @rtype: L{ACL} object + """ + acl = acl.joinGrantDeny() + _insertAclDisplaynames(acl) + return acl + + +def refineAclForSet(acl): + """ + Sets the ACL composed from the UI on the WebDAV server. For that purpose the + ACL object gets refined first to form a well accepted ACL to be set by the + ACL WebDAV method. + + @param acl: An ACL object to be refined. + @type acl: L{ACL} object + + @return: A valid ACL that contains only grant or deny clauses in an ACE. + Inherited and protected ACEs are stripped out. + @rtype: L{ACL} object + """ + acl = acl.splitGrantDeny() + acl = acl.stripAces() + return acl + + +##~ unsupported or unfinished methods: +##~ +##~ def report(self, report, request=None, lockToken=None): +##~ """ +##~ This method implements the WebDAV ACP method: REPORT for given report +##~ types. +##~ +##~ Parameters: +##~ +##~ 'report' -- Report type as a string. +##~ +##~ 'request' -- XML content of the request for the report (defaults to None). +##~ +##~ 'lockToken' -- Lock token to be set (defaults to None). +##~ """ +##~ raise WebdavError('Reports are not supported by our Jacarta Slide, yet (as of Aug. 1, 2003 in CVS).') +##~ +##~ headers = createCondition(lockToken) +##~ headers['Content-Type'] = XML_CONTENT_TYPE +##~ body = '<D:%s xmlns:D="DAV:">%s</D:%s>' % (report, request, report) +##~ #print "Body: ", body +##~ response = self.connection._request('REPORT', self.path, body, headers) +##~ return response +##~ ## TODO: parse DAV:error response +##~ +##~ +##~ def getAllAcls(self): +##~ """ +##~ Returns a dictionary of ACL resources with respective ACL objects +##~ that apply to the given resource. +##~ +##~ ### This method needs to be extended for inherited ACLs when Tamino +##~ support tells me (Guy) how to get to them. +##~ """ +##~ acls = {self.path: self.getAcl()} +##~ for ace in acls[self.path].aces: +##~ if ace.inherited: +##~ if not ace.inherited in acls: +##~ acls[ace.inherited] = self.getAcl() +##~ +##~ # append some more stuff here to acls for possible inherited ACLs +##~ return acls diff --git a/src/webdav/acp/Acl.py b/src/webdav/acp/Acl.py new file mode 100644 index 0000000..8f2b36f --- /dev/null +++ b/src/webdav/acp/Acl.py @@ -0,0 +1,311 @@ +# pylint: disable-msg=W0622 +# +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + + + +""" +ACL object handling according to WebDAV ACP specification. +""" + + +from webdav.acp.Ace import ACE +from webdav import Constants +from webdav.Connection import WebdavError +from webdav.davlib import XML_DOC_HEADER + + +__version__ = "$LastChangedRevision$" + + +class ACL(object): + """ + This class provides access to Access Control List funcionality + as specified in the WebDAV ACP. + + @ivar aces: ACEs in ACL + @type aces: C{list} of L{ACE} objects + @ivar withInherited: Flag indicating whether ACL contains inherited ACEs. + @type withInherited: C{bool} + """ + + # restrict instance variables + __slots__ = ('aces', 'withInherited') + + def __init__(self, domroot=None, aces=None): + """ + Constructor should be called with either no parameters (create blank ACE), + or one parameter (a DOM tree or ACE list). + + @param domroot: A DOM tree (default: None). + @type domroot: L{webdav.WebdavResponse.Element} object + @param aces: ACE objects (default: None) + @type aces: C{list} of L{ACE} objects + + @raise WebdavError: When non-valid parameters are passed a L{WebdavError} is raised. + """ + self.withInherited = None + self.aces = [] + + if domroot: + for child in domroot.children: + if child.name == Constants.TAG_ACE and child.ns == Constants.NS_DAV: + self.addAce(ACE(child)) + else: + # This shouldn't happen, someone screwed up with the params ... + raise WebdavError('Non-ACE tag handed to ACL constructor: ' + child.ns + child.name) + elif isinstance(aces, list) or isinstance(aces, tuple): + self.addAces(aces) + elif domroot == None and aces == None: + # no param ==> blank object + pass + else: + # This shouldn't happen, someone screwed up with the params ... + raise WebdavError('non-valid parameters handed to ACL constructor') + + def __cmp__(self, other): + if not isinstance(other, ACL): + return 1 + if self.withInherited == other.withInherited: + equal = 1 + for ace in self.aces: + inList = 0 + for otherAce in other.aces: + if ace == otherAce: + inList = 1 + if inList == 0: + equal = 0 + return not equal + else: + return 1 + + def __repr__(self): + repr = '<class ACL: ' + if self.withInherited: + repr += 'with inherited, ' + first = 1 + repr += 'aces: [' + for ace in self.aces: + if first: + repr += '%s' % ace + first = 0 + else: + repr += ', %s' % ace + return '%s]>' % (repr) + + def copy(self, other): + '''Copy an ACL object. + + @param other: Another ACL to copy. + @type other: L{ACL} object + + @raise WebdavError: When an object that is not an L{ACL} is passed + a L{WebdavError} is raised. + ''' + if not isinstance(other, ACL): + raise WebdavError('Non-ACL object passed to copy method: %s' % other.__class__) + self.withInherited = other.withInherited + if other.aces: + self.addAces(other.aces) + + def toXML(self): + """ + Returns ACL content as a string of valid XML as described in WebDAV ACP. + """ + aclTag = 'D:' + Constants.TAG_ACL + return XML_DOC_HEADER +\ + '<' + aclTag + ' xmlns:D="DAV:">' + reduce(lambda xml, ace: xml + ace.toXML() + '\n', [''] + self.aces) +\ + '</' + aclTag + '>' + + def addAce(self, ace): + ''' + Adds the passed ACE object to list if it's not in it, yet. + + @param ace: An ACE. + @type ace: L{ACE} object + ''' + newAce = ACE() + newAce.copy(ace) + # only add it if it's not in the list, yet ... + inList = 0 + for element in self.aces: + if element == ace: + inList = 1 + if not inList: + self.aces.append(newAce) + + def addAces(self, aces): + '''Adds the list of passed ACE objects to list. + + @param aces: ACEs + @type aces: sequence of L{ACE} objects + ''' + for ace in aces: + self.addAce(ace) + + def delAce(self, ace): + '''Deletes the passed ACE object from list. + + @param ace: An ACE. + @type ace: L{ACE} object + + @raise WebdavError: When the ACE to be deleted is not within the ACL + a L{WebdavError} is raised. + ''' + # find where it is and delete it ... + count = 0 + index = 0 + for element in self.aces: + count += 1 + if element == ace: + index = count + if index: + self.aces.pop(index - 1) + else: + raise WebdavError('ACE to be deleted not in list: %s.' % ace) + + def delAces(self, aces): + '''Deletes the list of passed ACE objects from list. + + @param aces: ACEs + @type aces: sequence of L{ACE} objects + ''' + for ace in aces: + self.delAce(ace) + + def delPrincipalsAces(self, principal): + """ + Deletes all ACEs in ACL by given principal. + + @param principal: A principal. + @type principal: L{Principal} object + """ + # find where it is and delete it ... + index = 0 + while index < len(self.aces): + if self.aces[index].principal.principalURL == principal.principalURL: + self.aces.pop(index) + else: + index += 1 + + def joinGrantDeny(self): + """ + Returns a "refined" ACL of the ACL for ease of use in the UI. + The purpose is to post the user an ACE that can contain both, granted + and denied, privileges. So possible pairs of grant and deny ACEs are joined + to return them in one ACE. This resulting ACE then of course IS NOT valid + for setting ACLs anymore. They will have to be reconverted to yield valid + ACLs for the ACL method. + + @return: A (non-valid) ACL that contains both grant and deny clauses in an ACE. + @rtype: L{ACL} object + """ + joinedAces = {} + for ace in self.aces: + if not ace.principal.principalURL is None: + principalKey = ace.principal.principalURL + elif not ace.principal.property is None: + principalKey = ace.principal.property + else: + principalKey = None + if ace.inherited: + principalKey = ace.inherited + ":" + principalKey + if principalKey in joinedAces: + joinedAces[principalKey].addGrantDenies(ace.grantDenies) + else: + joinedAces[principalKey] = ACE() + joinedAces[principalKey].copy(ace) + newAcl = ACL() + newAcl.addAces(joinedAces.values()) + return newAcl + + def splitGrantDeny(self): + """ + Returns a "refined" ACL of the ACL for ease of use in the UI. + The purpose is to post the user an ACE that can contain both, granted + and denied, privileges. So possible joined grant and deny clauses in ACEs + splitted to return them in separate ACEs. This resulting ACE then is valid + for setting ACLs again. This method is to be seen in conjunction with the + method joinGrantDeny as it reverts its effect. + + @return: A valid ACL that contains only ACEs with either grant or deny clauses. + @rtype: L{ACL} object + """ + acesGrant = {} + acesDeny = {} + for ace in self.aces: + for grantDeny in ace.grantDenies: + if grantDeny.isGrant(): + if ace.principal.principalURL in acesGrant: + ace.addGrantDeny(grantDeny) + else: + acesGrant[ace.principal.principalURL] = ACE() + acesGrant[ace.principal.principalURL].copy(ace) + acesGrant[ace.principal.principalURL].grantDenies = [] + acesGrant[ace.principal.principalURL].addGrantDeny(grantDeny) + else: + if ace.principal.principalURL in acesDeny: + ace.addGrantDeny(grantDeny) + else: + acesDeny[ace.principal.principalURL] = ACE() + acesDeny[ace.principal.principalURL].copy(ace) + acesDeny[ace.principal.principalURL].grantDenies = [] + acesDeny[ace.principal.principalURL].addGrantDeny(grantDeny) + newAcl = ACL() + newAcl.addAces(acesGrant.values()) + newAcl.addAces(acesDeny.values()) + return newAcl + + def isValid(self): + """ + Returns true (1) if all contained ACE objects are valid, + otherwise false (0) is returned. + + @return: Validity of ACL. + @rtype: C{bool} + """ + valid = 1 + if len(self.aces): + for ace in self.aces: + if not ace.isValid(): + valid = 0 + return valid + + def stripAces(self, inherited=True, protected=True): + """ + Returns an ACL object with all ACEs stripped that are inherited + and/or protected. + + @param inherited: Flag to indicate whether inherited ACEs should + be stripped (default: True). + @type inherited: C{bool} + @param protected: Flag to indicate whether protected ACEs should + be stripped (default: True). + @type protected: C{bool} + + @return: An ACL without the stripped ACEs. + @rtype: L{ACL} object + """ + newAcl = ACL() + if len(self.aces): + for ace in self.aces: + keep = 1 + if inherited and ace.inherited: + keep = 0 + elif protected and ace.protected: + keep = 0 + if keep: + newAcl.addAce(ace) + return newAcl diff --git a/src/webdav/acp/GrantDeny.py b/src/webdav/acp/GrantDeny.py new file mode 100644 index 0000000..52c9b93 --- /dev/null +++ b/src/webdav/acp/GrantDeny.py @@ -0,0 +1,241 @@ +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + + +""" +Handling of grant and deny clauses in ACEs according to WebDAV ACP specification. +""" + + +from webdav.acp.Privilege import Privilege +from webdav import Constants +from webdav.Connection import WebdavError + + +__version__ = "$LastChangedRevision$" + + +class GrantDeny(object): + """ + This class provides functionality for handling + grant and deny clauses in ACEs. + + @ivar grantDeny: Flag indicating whether clause grants or denies. + @type grantDeny: C{bool} + @ivar privileges: Privileges to be granted or denied. + @type privileges: C{list} of L{Privilege} objects + """ + + def __init__(self, domroot=None): + """ + Constructor should be called with either no parameters + (create blank GrantDeny), or one parameter (a DOM tree). + + @param domroot: A DOM tree (default: None). + @type domroot: L{webdav.WebdavResponse.Element} object + + @raise WebdavError: When non-valid parameters are passed a L{WebdavError} is raised. + """ + self.grantDeny = 0 # 0: deny, 1: grant + self.privileges = [] + + if domroot: + self.grantDeny = (domroot.name == Constants.TAG_GRANT) + for child in domroot.children: + if child.name == Constants.TAG_PRIVILEGE and child.ns == Constants.NS_DAV: + self.privileges.append(Privilege(domroot=child)) + else: + # This shouldn't happen, someone screwed up with the params ... + raise WebdavError('Non-privilege tag handed to GrantDeny constructor: %s' \ + % child.name) + elif domroot == None: + # no param ==> blank object + pass + else: + # This shouldn't happen, someone screwed up with the params ... + raise WebdavError('Non-valid parameters handed to GrantDeny constructor.') + + def __cmp__(self, other): + """ Compares two GrantDeny instances. """ + if not isinstance(other, GrantDeny): + return 1 + if self.grantDeny == other.grantDeny: + equal = 1 + for priv in self.privileges: + inList = 0 + for otherPriv in other.privileges: + if priv == otherPriv: + inList = 1 + if inList == 0: + equal = 0 + return not equal + else: + return 1 + + def __repr__(self): + """ Returns the representation of an instance. """ + representation = '<class GrantDeny: ' + if self.grantDeny: + representation += 'grant privileges: [' + else: + representation += 'deny privileges: [' + first = 1 + for priv in self.privileges: + if first: + representation += '%s' % priv + first = 0 + else: + representation += ', %s' % priv + return '%s]>' % (representation) + + def copy(self, other): + """ + Copy a GrantDeny object. + + @param other: Another grant or deny clause to copy. + @type other: L{GrantDeny} object + + @raise WebdavError: When an object that is not an L{GrantDeny} is passed + a L{WebdavError} is raised. + """ + if not isinstance(other, GrantDeny): + raise WebdavError('Non-GrantDeny object passed to copy method: %s' \ + % other) + self.grantDeny = other.grantDeny + if other.privileges: + self.addPrivileges(other.privileges) + + def isGrant(self): + """ + Returns whether the set of privileges is of type "grant" + indicating true or false. + + @return: Value whether the clause is of grant type. + @rtype: C{bool} + """ + return self.grantDeny + + def isDeny(self): + """ + Returns whether the set of privileges is of type "deny" + indicating true or false. + + @return: Value whether the clause is of deny type. + @rtype: C{bool} + """ + return not self.grantDeny + + def setGrantDeny(self, grantDeny): + """ + Sets the set of privileges to given value for grantDeny. + + @param grantDeny: Grant/deny value for clause (grant: True/1, deny: False/0). + @type grantDeny: C{bool} + """ + if grantDeny == 0 or grantDeny == 1: + self.grantDeny = grantDeny + + def setGrant(self): + """ Sets the set of privileges to type "grant". """ + self.grantDeny = 1 + + def setDeny(self): + """ Sets the set of privileges to type "deny". """ + self.grantDeny = 0 + + def isAll(self): + """ + Checks whether the privileges contained are equal + to aggregate DAV:all privilege. + + @return: Value whether all un-aggregated privileges are present. + @rtype: C{bool} + """ + + if len(self.privileges) == 1 and self.privileges[0].name == Constants.TAG_ALL: + return 1 + return 0 + + def addPrivilege(self, privilege): + """ + Adds the passed privilege to list if it's not in it, yet. + + @param privilege: A privilege. + @type privilege: L{Privilege} object + """ + inList = False + for priv in self.privileges: + if priv == privilege: + inList = True + if not inList: + newPrivilege = Privilege() + newPrivilege.copy(privilege) + self.privileges.append(newPrivilege) + + def addPrivileges(self, privileges): + """ + Adds the list of passed privileges to list. + + @param privileges: Several privileges. + @type privileges: sequence of L{Privilege} objects + """ + for priv in privileges: + self.addPrivilege(priv) + + def delPrivilege(self, privilege): + """ + Deletes the passed privilege from list if it's in it. + + @param privilege: A privilege. + @type privilege: L{Privilege} object + + @raise WebdavError: A L{WebdavError} is raised if the privilege to be + deleted is not present. + """ + count = 0 + index = 0 + for priv in self.privileges: + count += 1 + if priv == privilege: + index = count + if index: + self.privileges.pop(index - 1) + else: + raise WebdavError('Privilege to be deleted not in list: %s' % privilege) + + def delPrivileges(self, privileges): + """ + Deletes the list of passed privileges from list. + + @param privileges: Several privileges. + @type privileges: sequence of L{Privilege} objects + """ + for priv in privileges: + self.delPrivilege(priv) + + def toXML(self): + """ + Returns string of GrantDeny content to valid XML as described in WebDAV ACP. + """ + assert self.privileges, "GrantDeny object is not initialized or does not contain content!" + + if self.isGrant(): + tag = 'D:' + Constants.TAG_GRANT + else: + tag = 'D:' + Constants.TAG_DENY + + res = '' + for privilege in self.privileges: + res += privilege.toXML() + return '<%s>%s</%s>' % (tag, res, tag) diff --git a/src/webdav/acp/Makefile.am b/src/webdav/acp/Makefile.am new file mode 100644 index 0000000..506eb92 --- /dev/null +++ b/src/webdav/acp/Makefile.am @@ -0,0 +1,12 @@ +sugardir = $(pythondir)/webdav/acp +sugar_PYTHON = \ + AceHandler.py \ + Ace.py \ + Acl.py \ + GrantDeny.py \ + __init__.py \ + Principal.py \ + Privilege.py + + + diff --git a/src/webdav/acp/Principal.py b/src/webdav/acp/Principal.py new file mode 100644 index 0000000..a0d5ec9 --- /dev/null +++ b/src/webdav/acp/Principal.py @@ -0,0 +1,189 @@ +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + + +""" +Handling of principals for ACEs according to WebDAV ACP specification. +""" + + +from webdav import Constants +from webdav.Connection import WebdavError + + +__version__ = "$LastChangedRevision$" + + +class Principal(object): + """ + This class provides functionality for handling + principals according to the WebDAV ACP. + + @ivar displayname: Name of the principal for output + @type displayname: C{string} + @ivar principalURL: URL under which the principal can be referenced on the server. + @type principalURL: C{string} + @ivar property: Information on type of a pseudo/jproperty principal, e. g. + DAV:owner, DAV:authenticated, etc. + @type property: C{string} + + @cvar _TAG_LIST_PRINCIPALS: List of allowed XML tags within a principal declaration. + @type _TAG_LIST_PRINCIPALS: C{tuple} of C{string}s + @cvar _TAG_LIST_STATUS: List of XML tags for the status of a pseudo principal. + @type _TAG_LIST_STATUS: C{tuple} of C{string}s + """ + + # some local constants for this class to make things easier/more readable: + _TAG_LIST_PRINCIPALS = (Constants.TAG_HREF, # directly by URL + Constants.TAG_ALL, Constants.TAG_AUTHENTICATED, Constants.TAG_UNAUTHENTICATED, + # by log-in status + Constants.TAG_PROPERTY, # for property info, e. g. 'owner' + Constants.TAG_SELF, # only if the resource is the principal itself + Constants.TAG_PROP) # contains property info like 'displayname' + _TAG_LIST_STATUS = (Constants.TAG_ALL, Constants.TAG_AUTHENTICATED, Constants.TAG_UNAUTHENTICATED) + + # restrict instance variables + __slots__ = ('displayname', 'principalURL', 'property') + + def __init__(self, domroot=None, displayname=None, principalURL=None): + """ + Constructor should be called with either no parameters (create blank Principal), + one parameter (a DOM tree), or two parameters (displayname and URL or property tag). + + @param domroot: A DOM tree (default: None). + @type domroot: L{webdav.WebdavResponse.Element} object + @param displayname: The display name of a principal (default: None). + @type displayname: C{string} + @param principalURL: The URL representing a principal (default: None). + @type principalURL: C{string} + + @raise WebdavError: When non-valid parameters or sets of parameters are + passed a L{WebdavError} is raised. + """ + self.displayname = None + self.principalURL = None + self.property = None + + if domroot: + for child in domroot.children: + if child.ns == Constants.NS_DAV and (child.name in self._TAG_LIST_PRINCIPALS): + if child.name == Constants.TAG_PROP: + self.displayname = \ + child.find(Constants.PROP_DISPLAY_NAME, Constants.NS_DAV) + elif child.name == Constants.TAG_HREF: + self.principalURL = child.textof() + if self.principalURL and self.property in self._TAG_LIST_STATUS: + raise WebdavError('Principal cannot contain a URL and "%s"' % (self.property)) + elif child.name == Constants.TAG_PROPERTY: + if child.count() == 1: + if self.property: + raise WebdavError('Property for principal has already been set: old "%s", new "%s"' \ + % (self.property, child.pop().name)) + elif self.principalURL: + raise WebdavError('Principal cannot contain a URL and "%s"' % (self.property)) + else: + self.property = child.pop().name + else: + raise WebdavError("There should be only one value in the property for a principal, we have: %s" \ + % child.name) + else: + if self.property: + raise WebdavError('Property for principal has already been set: old "%s", new "%s"' \ + % (self.property, child.name)) + else: + self.property = child.name + if self.principalURL and self.property in self._TAG_LIST_STATUS: + raise WebdavError('Principal cannot contain a URL and "%s"' % (self.property)) + else: # This shouldn't happen, something's wrong with the DOM tree + raise WebdavError('Non-valid tag in principal DOM tree for constructor: %s' % child.name) + elif displayname == None or principalURL == None: + if displayname: + self.displayname = displayname + if principalURL: + self.principalURL = principalURL + else: + # This shouldn't happen, someone screwed up with the params ... + raise WebdavError('Non-valid parameters handed to Principal constructor.') + + def __cmp__(self, other): + if not isinstance(other, Principal): + return 1 + if self.displayname == other.displayname \ + and self.principalURL == other.principalURL \ + and self.property == other.property: + return 0 + else: + return 1 + + def __repr__(self): + return '<class Principal: displayname: "%s", principalURL: "%s", property: "%s">' \ + % (self.displayname, self.principalURL, self.property) + + def copy(self, other): + """Copy Principal object. + + @param other: Another principal to copy. + @type other: L{Principal} object + + @raise WebdavError: When an object that is not a L{Principal} is passed + a L{WebdavError} is raised. + """ + if not isinstance(other, Principal): + raise WebdavError('Non-Principal object passed to copy method: ' % other.__class__) + self.displayname = other.displayname + self.principalURL = other.principalURL + self.property = other.property + + def isValid(self): + """ + Checks whether necessarry props for principal are set. + + @return: Validity of principal. + @rtype: C{bool} + """ + return (self.displayname and + (self.principalURL or self.property) and + not (self.principalURL and self.property)) + + def toXML(self, invert=False, displayname=False, defaultNameSpace=None): + """Returns string of Principal content in valid XML as described in WebDAV ACP. + + @param defaultNameSpace: Name space (default: None). + @type defaultNameSpace: C(string) + @param invert: True if principal should be inverted (default: False). + @type invert: C{bool} + @param displayname: True if displayname should be in output (default: False). + @type displayname: C{bool} + """ + # this check is needed for setting principals only: + # assert self.isValid(), "principal is not initialized or does not contain valid content!" + + PRINCIPAL = 'D:' + Constants.TAG_PRINCIPAL + res = '' + if self.principalURL: + res += '<D:%s>%s</D:%s>' % (Constants.TAG_HREF, self.principalURL, Constants.TAG_HREF) + elif self.property in self._TAG_LIST_STATUS \ + or self.property == Constants.TAG_SELF: + res += '<D:%s/>' % (self.property) + elif self.property: + res += '<D:%s><D:%s/></D:%s>' \ + % (Constants.TAG_PROPERTY, self.property, Constants.TAG_PROPERTY) + if self.displayname and displayname: + res += '<D:%s><D:%s>%s</D:%s></D:%s>' \ + % (Constants.TAG_PROP, Constants.PROP_DISPLAY_NAME, + self.displayname, + Constants.PROP_DISPLAY_NAME, Constants.TAG_PROP) + if invert: + res = '<D:invert>%s</D:invert>' % (res) + return '<%s>%s</%s>' % (PRINCIPAL, res, PRINCIPAL) diff --git a/src/webdav/acp/Privilege.py b/src/webdav/acp/Privilege.py new file mode 100644 index 0000000..abfdcf9 --- /dev/null +++ b/src/webdav/acp/Privilege.py @@ -0,0 +1,125 @@ +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + + +""" +Handling for privileges for grant and deny clauses in ACEs +according to WebDAV ACP specification. +""" + + +from webdav import Constants +from webdav.Connection import WebdavError + + +__version__ = "$LastChangedRevision$" + + +class Privilege(object): + """This class provides functionality for handling privileges for ACEs. + + @ivar name: Name of the privilege. + @type name: C{string} + + @cvar __privileges: List of allowed XML tags for privileges. + @type __privileges: C{tuple} of C{string}s + """ + + + __privileges = list() + + + def __init__(self, privilege=None, domroot=None): + """ + Constructor should be called with either no parameters (create blank Privilege), + one parameter (a DOM tree or privilege name to initialize it directly). + + @param domroot: A DOM tree (default: None). + @type domroot: L{webdav.WebdavResponse.Element} object + @param privilege: The valid name of a privilege (default: None). + @type privilege: C{string} + + @raise WebdavError: When non-valid parameters or sets of parameters are + passed a L{WebdavError} is raised. + """ + + self.name = None + + if domroot: + if len(domroot.children) != 1: + raise WebdavError('Wrong number of elements for Privilege constructor, we have: %i' \ + % (len(domroot.children))) + else: + child = domroot.children[0] + if child.ns == Constants.NS_DAV and child.name in self.__privileges: + self.name = child.name + else: + raise WebdavError('Not a valid privilege tag, we have: %s%s' \ + % (child.ns, child.name)) + elif privilege: + if privilege in self.__privileges: + self.name = privilege + else: + raise WebdavError('Not a valid privilege tag, we have: %s.' % str(privilege)) + + @classmethod + def registerPrivileges(cls, privileges): + """ + Registers supported privilege tags. + + @param privileges: List of privilege tags. + @type privileges: C{list} of C{unicode} + """ + + for privilege in privileges: + cls.__privileges.append(privilege) + + def __cmp__(self, other): + """ Compares two Privilege instances. """ + if not isinstance(other, Privilege): + return 1 + if self.name != other.name: + return 1 + else: + return 0 + + def __repr__(self): + """ Returns the string representation of an instance. """ + return '<class Privilege: name: "%s">' % (self.name) + + def copy(self, other): + """ + Copy Privilege object. + + @param other: Another privilege to copy. + @type other: L{Privilege} object + + @raise WebdavError: When an object that is not a L{Privilege} is passed + a L{WebdavError} is raised. + """ + if not isinstance(other, Privilege): + raise WebdavError('Non-Privilege object passed to copy method: %s' % other.__class__) + self.name = other.name + + def toXML(self): + """ + Returns privilege content as string in valid XML as described in WebDAV ACP. + + @param defaultNameSpace: Name space (default: None). + @type defaultNameSpace: C(string) + """ + assert self.name != None, "privilege is not initialized or does not contain valid content!" + + privilege = 'D:' + Constants.TAG_PRIVILEGE + return '<%s><D:%s/></%s>' % (privilege, self.name, privilege) diff --git a/src/webdav/acp/__init__.py b/src/webdav/acp/__init__.py new file mode 100644 index 0000000..b5af299 --- /dev/null +++ b/src/webdav/acp/__init__.py @@ -0,0 +1,33 @@ +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + + +from webdav import Constants +from webdav.acp.Acl import ACL +from webdav.acp.Ace import ACE +from webdav.acp.GrantDeny import GrantDeny +from webdav.acp.Privilege import Privilege +from webdav.acp.Principal import Principal + + +__version__ = "$LastChangedRevision$" + + +privileges = [Constants.TAG_READ, Constants.TAG_WRITE, Constants.TAG_WRITE_PROPERTIES, + Constants.TAG_WRITE_CONTENT, Constants.TAG_UNLOCK, Constants.TAG_READ_ACL, + Constants.TAG_READ_CURRENT_USER_PRIVILEGE_SET, Constants.TAG_WRITE_ACL, Constants.TAG_ALL, + Constants.TAG_BIND, Constants.TAG_UNBIND, Constants.TAG_TAMINO_SECURITY, + Constants.TAG_BIND_COLLECTION, Constants.TAG_UNBIND_COLLECTION, Constants.TAG_READ_PRIVATE_PROPERTIES, + Constants.TAG_WRITE_PRIVATE_PROPERTIES] +Privilege.registerPrivileges(privileges) diff --git a/src/webdav/davlib.py b/src/webdav/davlib.py new file mode 100644 index 0000000..f4dac91 --- /dev/null +++ b/src/webdav/davlib.py @@ -0,0 +1,336 @@ +# pylint: disable-msg=W0402,W0231,W0141,R0903,C0321,W0701,R0904,C0103,W0201,W0102,R0913,W0622,E1101,C0111,C0121,R0901 +# DAV client library +# +# Copyright (C) 1998-2000 Guido van Rossum. All Rights Reserved. +# Written by Greg Stein. Given to Guido. Licensed using the Python license. +# +# This module is maintained by Greg and is available at: +# http://www.lyra.org/greg/python/davlib.py +# +# Since this isn't in the Python distribution yet, we'll use the CVS ID +# for tracking: +# $Id: davlib.py 3182 2008-02-22 15:57:55 +0000 (Fr, 22 Feb 2008) schlauch $ +# + +import httplib +import urllib +import string +import types +import mimetypes +import qp_xml + + +INFINITY = 'infinity' +XML_DOC_HEADER = '<?xml version="1.0" encoding="utf-8"?>' +XML_CONTENT_TYPE = 'text/xml; charset="utf-8"' + +# block size for copying files up to the server +BLOCKSIZE = 16384 + + +class HTTPProtocolChooser(httplib.HTTPSConnection): + def __init__(self, *args, **kw): + self.protocol = kw.pop('protocol') + if self.protocol == "https": + self.default_port = 443 + else: + self.default_port = 80 + + apply(httplib.HTTPSConnection.__init__, (self,) + args, kw) + + def connect(self): + if self.protocol == "https": + httplib.HTTPSConnection.connect(self) + else: + httplib.HTTPConnection.connect(self) + + +class HTTPConnectionAuth(HTTPProtocolChooser): + def __init__(self, *args, **kw): + apply(HTTPProtocolChooser.__init__, (self,) + args, kw) + + self.__username = None + self.__password = None + self.__nonce = None + self.__opaque = None + + def setauth(self, username, password): + self.__username = username + self.__password = password + + +def _parse_status(elem): + text = elem.textof() + idx1 = string.find(text, ' ') + idx2 = string.find(text, ' ', idx1+1) + return int(text[idx1:idx2]), text[idx2+1:] + +class _blank: + def __init__(self, **kw): + self.__dict__.update(kw) +class _propstat(_blank): pass +class _response(_blank): pass +class _multistatus(_blank): pass + +def _extract_propstat(elem): + ps = _propstat(prop={}, status=None, responsedescription=None) + for child in elem.children: + if child.ns != 'DAV:': + continue + if child.name == 'prop': + for prop in child.children: + ps.prop[(prop.ns, prop.name)] = prop + elif child.name == 'status': + ps.status = _parse_status(child) + elif child.name == 'responsedescription': + ps.responsedescription = child.textof() + ### unknown element name + + return ps + +def _extract_response(elem): + resp = _response(href=[], status=None, responsedescription=None, propstat=[]) + for child in elem.children: + if child.ns != 'DAV:': + continue + if child.name == 'href': + resp.href.append(child.textof()) + elif child.name == 'status': + resp.status = _parse_status(child) + elif child.name == 'responsedescription': + resp.responsedescription = child.textof() + elif child.name == 'propstat': + resp.propstat.append(_extract_propstat(child)) + ### unknown child element + + return resp + +def _extract_msr(root): + if root.ns != 'DAV:' or root.name != 'multistatus': + raise 'invalid response: <DAV:multistatus> expected' + + msr = _multistatus(responses=[ ], responsedescription=None) + + for child in root.children: + if child.ns != 'DAV:': + continue + if child.name == 'responsedescription': + msr.responsedescription = child.textof() + elif child.name == 'response': + msr.responses.append(_extract_response(child)) + ### unknown child element + + return msr + +def _extract_locktoken(root): + if root.ns != 'DAV:' or root.name != 'prop': + raise 'invalid response: <DAV:prop> expected' + elem = root.find('lockdiscovery', 'DAV:') + if not elem: + raise 'invalid response: <DAV:lockdiscovery> expected' + elem = elem.find('activelock', 'DAV:') + if not elem: + raise 'invalid response: <DAV:activelock> expected' + elem = elem.find('locktoken', 'DAV:') + if not elem: + raise 'invalid response: <DAV:locktoken> expected' + elem = elem.find('href', 'DAV:') + if not elem: + raise 'invalid response: <DAV:href> expected' + return elem.textof() + + +class DAVResponse(httplib.HTTPResponse): + def parse_multistatus(self): + self.root = qp_xml.Parser().parse(self) + self.msr = _extract_msr(self.root) + + def parse_lock_response(self): + self.root = qp_xml.Parser().parse(self) + self.locktoken = _extract_locktoken(self.root) + + +class DAV(HTTPConnectionAuth): + + response_class = DAVResponse + + def get(self, url, extra_hdrs={ }): + return self._request('GET', url, extra_hdrs=extra_hdrs) + + def head(self, url, extra_hdrs={ }): + return self._request('HEAD', url, extra_hdrs=extra_hdrs) + + def post(self, url, data={ }, body=None, extra_hdrs={ }): + headers = extra_hdrs.copy() + + assert body or data, "body or data must be supplied" + assert not (body and data), "cannot supply both body and data" + if data: + body = '' + for key, value in data.items(): + if isinstance(value, types.ListType): + for item in value: + body = body + '&' + key + '=' + urllib.quote(str(item)) + else: + body = body + '&' + key + '=' + urllib.quote(str(value)) + body = body[1:] + headers['Content-Type'] = 'application/x-www-form-urlencoded' + + return self._request('POST', url, body, headers) + + def options(self, url='*', extra_hdrs={ }): + return self._request('OPTIONS', url, extra_hdrs=extra_hdrs) + + def trace(self, url, extra_hdrs={ }): + return self._request('TRACE', url, extra_hdrs=extra_hdrs) + + def put(self, url, contents, + content_type=None, content_enc=None, extra_hdrs={ }): + + if not content_type: + content_type, content_enc = mimetypes.guess_type(url) + + headers = extra_hdrs.copy() + if content_type: + headers['Content-Type'] = content_type + if content_enc: + headers['Content-Encoding'] = content_enc + return self._request('PUT', url, contents, headers) + + def delete(self, url, extra_hdrs={ }): + return self._request('DELETE', url, extra_hdrs=extra_hdrs) + + def propfind(self, url, body=None, depth=None, extra_hdrs={ }): + headers = extra_hdrs.copy() + headers['Content-Type'] = XML_CONTENT_TYPE + if depth is not None: + headers['Depth'] = str(depth) + return self._request('PROPFIND', url, body, headers) + + def proppatch(self, url, body, extra_hdrs={ }): + headers = extra_hdrs.copy() + headers['Content-Type'] = XML_CONTENT_TYPE + return self._request('PROPPATCH', url, body, headers) + + def mkcol(self, url, extra_hdrs={ }): + return self._request('MKCOL', url, extra_hdrs=extra_hdrs) + + def move(self, src, dst, extra_hdrs={ }): + headers = extra_hdrs.copy() + headers['Destination'] = dst + return self._request('MOVE', src, extra_hdrs=headers) + + def copy(self, src, dst, depth=None, extra_hdrs={ }): + headers = extra_hdrs.copy() + headers['Destination'] = dst + if depth is not None: + headers['Depth'] = str(depth) + return self._request('COPY', src, extra_hdrs=headers) + + def lock(self, url, owner='', timeout=None, depth=None, + scope='exclusive', type='write', extra_hdrs={ }): + headers = extra_hdrs.copy() + headers['Content-Type'] = XML_CONTENT_TYPE + if depth is not None: + headers['Depth'] = str(depth) + if timeout is not None: + headers['Timeout'] = timeout + body = XML_DOC_HEADER + \ + '<DAV:lockinfo xmlns:DAV="DAV:">' + \ + '<DAV:lockscope><DAV:%s/></DAV:lockscope>' % scope + \ + '<DAV:locktype><DAV:%s/></DAV:locktype>' % type + \ + '<DAV:owner>' + owner + '</DAV:owner>' + \ + '</DAV:lockinfo>' + return self._request('LOCK', url, body, extra_hdrs=headers) + + def unlock(self, url, locktoken, extra_hdrs={ }): + headers = extra_hdrs.copy() + if locktoken[0] != '<': + locktoken = '<' + locktoken + '>' + headers['Lock-Token'] = locktoken + return self._request('UNLOCK', url, extra_hdrs=headers) + + def _request(self, method, url, body=None, extra_hdrs={}): + "Internal method for sending a request." + + self.request(method, url, body, extra_hdrs) + return self.getresponse() + + + # + # Higher-level methods for typical client use + # + + def allprops(self, url, depth=None): + body = XML_DOC_HEADER + \ + '<DAV:propfind xmlns:DAV="DAV:"><DAV:allprop/></DAV:propfind>' + return self.propfind(url, body, depth=depth) + + def propnames(self, url, depth=None): + body = XML_DOC_HEADER + \ + '<DAV:propfind xmlns:DAV="DAV:"><DAV:propname/></DAV:propfind>' + return self.propfind(url, body, depth) + + def getprops(self, url, *names, **kw): + assert names, 'at least one property name must be provided' + if kw.has_key('ns'): + xmlns = ' xmlns:NS="' + kw['ns'] + '"' + ns = 'NS:' + del kw['ns'] + else: + xmlns = ns = '' + if kw.has_key('depth'): + depth = kw['depth'] + del kw['depth'] + else: + depth = 0 + assert not kw, 'unknown arguments' + body = XML_DOC_HEADER + \ + '<DAV:propfind xmlns:DAV="DAV:"' + xmlns + '><DAV:prop><' + ns + \ + string.joinfields(names, '/><' + ns) + \ + '/></DAV:prop></DAV:propfind>' + return self.propfind(url, body, depth) + + def delprops(self, url, *names, **kw): + assert names, 'at least one property name must be provided' + if kw.has_key('ns'): + xmlns = ' xmlns:NS="' + kw['ns'] + '"' + ns = 'NS:' + del kw['ns'] + else: + xmlns = ns = '' + assert not kw, 'unknown arguments' + body = XML_DOC_HEADER + \ + '<DAV:propertyupdate xmlns:DAV="DAV:"' + xmlns + \ + '><DAV:remove><DAV:prop><' + ns + \ + string.joinfields(names, '/><' + ns) + \ + '/></DAV:prop></DAV:remove></DAV:propertyupdate>' + return self.proppatch(url, body) + + def setprops(self, url, *xmlprops, **props): + assert xmlprops or props, 'at least one property must be provided' + xmlprops = list(xmlprops) + if props.has_key('ns'): + xmlns = ' xmlns:NS="' + props['ns'] + '"' + ns = 'NS:' + del props['ns'] + else: + xmlns = ns = '' + for key, value in props.items(): + if value: + xmlprops.append('<%s%s>%s</%s%s>' % (ns, key, value, ns, key)) + else: + xmlprops.append('<%s%s/>' % (ns, key)) + elems = string.joinfields(xmlprops, '') + body = XML_DOC_HEADER + \ + '<DAV:propertyupdate xmlns:DAV="DAV:"' + xmlns + \ + '><DAV:set><DAV:prop>' + \ + elems + \ + '</DAV:prop></DAV:set></DAV:propertyupdate>' + return self.proppatch(url, body) + + def get_lock(self, url, owner='', timeout=None, depth=None): + response = self.lock(url, owner, timeout, depth) + response.parse_lock_response() + return response.locktoken +
\ No newline at end of file diff --git a/src/webdav/logger.py b/src/webdav/logger.py new file mode 100644 index 0000000..d2538ef --- /dev/null +++ b/src/webdav/logger.py @@ -0,0 +1,51 @@ +# Copyright 2008 German Aerospace Center (DLR) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + + +"""" +Module provides access to a configured logger instance. +The logger writes C{sys.stdout}. +""" + + +import logging +import sys + + +__version__ = "$LastChangedRevision$"[11:-2] + + +_defaultLoggerName = "webdavLogger" +_fileLogFormat = "%(asctime)s: %(levelname)s: %(message)s" + + +def getDefaultLogger(handler=None): + """ + Returns a configured logger object. + + @return: Logger instance. + @rtype: C{logging.Logger} + """ + + myLogger = logging.getLogger(_defaultLoggerName) + if len(myLogger.handlers) == 0: + myLogger.level = logging.DEBUG + formatter = logging.Formatter(_fileLogFormat) + if handler is None: + stdoutHandler = logging.StreamHandler(sys.stdout) + stdoutHandler.setFormatter(formatter) + myLogger.addHandler(stdoutHandler) + else: + myLogger.addHandler(handler) + return myLogger diff --git a/src/webdav/qp_xml.py b/src/webdav/qp_xml.py new file mode 100644 index 0000000..f167e1b --- /dev/null +++ b/src/webdav/qp_xml.py @@ -0,0 +1,240 @@ +# pylint: disable-msg=W0311,E1101,E1103,W0201,C0103,W0622,W0402,W0706,R0911,W0613,W0612,R0912,W0141,C0111,C0121 + +# qp_xml: Quick Parsing for XML +# +# Written by Greg Stein. Public Domain. +# No Copyright, no Rights Reserved, and no Warranties. +# +# This module is maintained by Greg and is available as part of the XML-SIG +# distribution. This module and its changelog can be fetched at: +# http://www.lyra.org/cgi-bin/viewcvs.cgi/xml/xml/utils/qp_xml.py +# +# Additional information can be found on Greg's Python page at: +# http://www.lyra.org/greg/python/ +# +# This module was added to the XML-SIG distribution on February 14, 2000. +# As part of that distribution, it falls under the XML distribution license. +# + +import string +from xml.parsers import expat + + +error = __name__ + '.error' + + +# +# The parsing class. Instantiate and pass a string/file to .parse() +# +class Parser: + def __init__(self): + self.reset() + + def reset(self): + self.root = None + self.cur_elem = None + + def find_prefix(self, prefix): + elem = self.cur_elem + while elem: + if elem.ns_scope.has_key(prefix): + return elem.ns_scope[prefix] + elem = elem.parent + + if prefix == '': + return '' # empty URL for "no namespace" + + return None + + def process_prefix(self, name, use_default): + idx = string.find(name, ':') + if idx == -1: + if use_default: + return self.find_prefix(''), name + return '', name # no namespace + + if string.lower(name[:3]) == 'xml': + return '', name # name is reserved by XML. don't break out a NS. + + ns = self.find_prefix(name[:idx]) + if ns is None: + raise error, 'namespace prefix ("%s") not found' % name[:idx] + + return ns, name[idx+1:] + + def start(self, name, attrs): + elem = _element(name=name, lang=None, parent=None, + children=[], ns_scope={}, attrs={}, + first_cdata='', following_cdata='') + + if self.cur_elem: + elem.parent = self.cur_elem + elem.parent.children.append(elem) + self.cur_elem = elem + else: + self.cur_elem = self.root = elem + + work_attrs = [ ] + + # scan for namespace declarations (and xml:lang while we're at it) + for name, value in attrs.items(): + if name == 'xmlns': + elem.ns_scope[''] = value + elif name[:6] == 'xmlns:': + elem.ns_scope[name[6:]] = value + elif name == 'xml:lang': + elem.lang = value + else: + work_attrs.append((name, value)) + + # inherit xml:lang from parent + if elem.lang is None and elem.parent: + elem.lang = elem.parent.lang + + # process prefix of the element name + elem.ns, elem.name = self.process_prefix(elem.name, 1) + + # process attributes' namespace prefixes + for name, value in work_attrs: + elem.attrs[self.process_prefix(name, 0)] = value + + def end(self, name): + parent = self.cur_elem.parent + + del self.cur_elem.ns_scope + del self.cur_elem.parent + + self.cur_elem = parent + + def cdata(self, data): + elem = self.cur_elem + if elem.children: + last = elem.children[-1] + last.following_cdata = last.following_cdata + data + else: + elem.first_cdata = elem.first_cdata + data + + def parse(self, input): + self.reset() + + p = expat.ParserCreate() + p.StartElementHandler = self.start + p.EndElementHandler = self.end + p.CharacterDataHandler = self.cdata + + try: + if type(input) == type(''): + p.Parse(input, 1) + else: + while 1: + s = input.read(_BLOCKSIZE) + if not s: + p.Parse('', 1) + break + + p.Parse(s, 0) + + finally: + if self.root: + _clean_tree(self.root) + + return self.root + + +# +# handy function for dumping a tree that is returned by Parser +# +def dump(f, root): + f.write('<?xml version="1.0"?>\n') + namespaces = _collect_ns(root) + _dump_recurse(f, root, namespaces, dump_ns=1) + f.write('\n') + + +# +# This function returns the element's CDATA. Note: this is not recursive -- +# it only returns the CDATA immediately within the element, excluding the +# CDATA in child elements. +# +def textof(elem): + return elem.textof() + + +######################################################################### +# +# private stuff for qp_xml +# + +_BLOCKSIZE = 16384 # chunk size for parsing input + +class _element: + def __init__(self, **kw): + self.__dict__.update(kw) + + def textof(self): + '''Return the CDATA of this element. + + Note: this is not recursive -- it only returns the CDATA immediately + within the element, excluding the CDATA in child elements. + ''' + s = self.first_cdata + for child in self.children: + s = s + child.following_cdata + return s + + def find(self, name, ns=''): + for elem in self.children: + if elem.name == name and elem.ns == ns: + return elem + return None + + +def _clean_tree(elem): + elem.parent = None + del elem.parent + map(_clean_tree, elem.children) + + +def _collect_recurse(elem, dict): + dict[elem.ns] = None + for ns, name in elem.attrs.keys(): + dict[ns] = None + for child in elem.children: + _collect_recurse(child, dict) + +def _collect_ns(elem): + "Collect all namespaces into a NAMESPACE -> PREFIX mapping." + d = { '' : None } + _collect_recurse(elem, d) + del d[''] # make sure we don't pick up no-namespace entries + keys = d.keys() + for i in range(len(keys)): + d[keys[i]] = i + return d + +def _dump_recurse(f, elem, namespaces, lang=None, dump_ns=0): + if elem.ns: + f.write('<ns%d:%s' % (namespaces[elem.ns], elem.name)) + else: + f.write('<' + elem.name) + for (ns, name), value in elem.attrs.items(): + if ns: + f.write(' ns%d:%s="%s"' % (namespaces[ns], name, value)) + else: + f.write(' %s="%s"' % (name, value)) + if dump_ns: + for ns, id in namespaces.items(): + f.write(' xmlns:ns%d="%s"' % (id, ns)) + if elem.lang != lang: + f.write(' xml:lang="%s"' % elem.lang) + if elem.children or elem.first_cdata: + f.write('>' + elem.first_cdata) + for child in elem.children: + _dump_recurse(f, child, namespaces, elem.lang) + f.write(child.following_cdata) + if elem.ns: + f.write('</ns%d:%s>' % (namespaces[elem.ns], elem.name)) + else: + f.write('</%s>' % elem.name) + else: + f.write('/>') diff --git a/src/webdav/uuid_.py b/src/webdav/uuid_.py new file mode 100644 index 0000000..3b590e8 --- /dev/null +++ b/src/webdav/uuid_.py @@ -0,0 +1,476 @@ +r"""UUID objects (universally unique identifiers) according to RFC 4122. + +This module provides immutable UUID objects (class UUID) and the functions +uuid1(), uuid3(), uuid4(), uuid5() for generating version 1, 3, 4, and 5 +UUIDs as specified in RFC 4122. + +If all you want is a unique ID, you should probably call uuid1() or uuid4(). +Note that uuid1() may compromise privacy since it creates a UUID containing +the computer's network address. uuid4() creates a random UUID. + +Typical usage: + + >>> import uuid + + # make a UUID based on the host ID and current time + >>> uuid.uuid1() + UUID('a8098c1a-f86e-11da-bd1a-00112444be1e') + + # make a UUID using an MD5 hash of a namespace UUID and a name + >>> uuid.uuid3(uuid.NAMESPACE_DNS, 'python.org') + UUID('6fa459ea-ee8a-3ca4-894e-db77e160355e') + + # make a random UUID + >>> uuid.uuid4() + UUID('16fd2706-8baf-433b-82eb-8c7fada847da') + + # make a UUID using a SHA-1 hash of a namespace UUID and a name + >>> uuid.uuid5(uuid.NAMESPACE_DNS, 'python.org') + UUID('886313e1-3b8a-5372-9b90-0c9aee199e5d') + + # make a UUID from a string of hex digits (braces and hyphens ignored) + >>> x = uuid.UUID('{00010203-0405-0607-0809-0a0b0c0d0e0f}') + + # convert a UUID to a string of hex digits in standard form + >>> str(x) + '00010203-0405-0607-0809-0a0b0c0d0e0f' + + # get the raw 16 bytes of the UUID + >>> x.bytes + '\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f' + + # make a UUID from a 16-byte string + >>> uuid.UUID(bytes=x.bytes) + UUID('00010203-0405-0607-0809-0a0b0c0d0e0f') +""" + +__author__ = 'Ka-Ping Yee <ping@zesty.ca>' +__date__ = '$Date: 2006/06/12 23:15:40 $'.split()[1].replace('/', '-') +__version__ = '$Revision: 1.30 $'.split()[1] + +RESERVED_NCS, RFC_4122, RESERVED_MICROSOFT, RESERVED_FUTURE = [ + 'reserved for NCS compatibility', 'specified in RFC 4122', + 'reserved for Microsoft compatibility', 'reserved for future definition'] + +class UUID(object): + """Instances of the UUID class represent UUIDs as specified in RFC 4122. + UUID objects are immutable, hashable, and usable as dictionary keys. + Converting a UUID to a string with str() yields something in the form + '12345678-1234-1234-1234-123456789abc'. The UUID constructor accepts + four possible forms: a similar string of hexadecimal digits, or a + string of 16 raw bytes as an argument named 'bytes', or a tuple of + six integer fields (with 32-bit, 16-bit, 16-bit, 8-bit, 8-bit, and + 48-bit values respectively) as an argument named 'fields', or a single + 128-bit integer as an argument named 'int'. + + UUIDs have these read-only attributes: + + bytes the UUID as a 16-byte string + + fields a tuple of the six integer fields of the UUID, + which are also available as six individual attributes + and two derived attributes: + + time_low the first 32 bits of the UUID + time_mid the next 16 bits of the UUID + time_hi_version the next 16 bits of the UUID + clock_seq_hi_variant the next 8 bits of the UUID + clock_seq_low the next 8 bits of the UUID + node the last 48 bits of the UUID + + time the 60-bit timestamp + clock_seq the 14-bit sequence number + + hex the UUID as a 32-character hexadecimal string + + int the UUID as a 128-bit integer + + urn the UUID as a URN as specified in RFC 4122 + + variant the UUID variant (one of the constants RESERVED_NCS, + RFC_4122, RESERVED_MICROSOFT, or RESERVED_FUTURE) + + version the UUID version number (1 through 5, meaningful only + when the variant is RFC_4122) + """ + + def __init__(self, hex=None, bytes=None, fields=None, int=None, + version=None): + r"""Create a UUID from either a string of 32 hexadecimal digits, + a string of 16 bytes as the 'bytes' argument, a tuple of six + integers (32-bit time_low, 16-bit time_mid, 16-bit time_hi_version, + 8-bit clock_seq_hi_variant, 8-bit clock_seq_low, 48-bit node) as + the 'fields' argument, or a single 128-bit integer as the 'int' + argument. When a string of hex digits is given, curly braces, + hyphens, and a URN prefix are all optional. For example, these + expressions all yield the same UUID: + + UUID('{12345678-1234-5678-1234-567812345678}') + UUID('12345678123456781234567812345678') + UUID('urn:uuid:12345678-1234-5678-1234-567812345678') + UUID(bytes='\x12\x34\x56\x78'*4) + UUID(fields=(0x12345678, 0x1234, 0x5678, 0x12, 0x34, 0x567812345678)) + UUID(int=0x12345678123456781234567812345678) + + Exactly one of 'hex', 'bytes', 'fields', or 'int' must be given. + The 'version' argument is optional; if given, the resulting UUID + will have its variant and version number set according to RFC 4122, + overriding bits in the given 'hex', 'bytes', 'fields', or 'int'. + """ + + if [hex, bytes, fields, int].count(None) != 3: + raise TypeError('need just one of hex, bytes, fields, or int') + if hex is not None: + hex = hex.replace('urn:', '').replace('uuid:', '') + hex = hex.strip('{}').replace('-', '') + if len(hex) != 32: + raise ValueError('badly formed hexadecimal UUID string') + int = long(hex, 16) + if bytes is not None: + if len(bytes) != 16: + raise ValueError('bytes is not a 16-char string') + int = long(('%02x'*16) % tuple(map(ord, bytes)), 16) + if fields is not None: + if len(fields) != 6: + raise ValueError('fields is not a 6-tuple') + (time_low, time_mid, time_hi_version, + clock_seq_hi_variant, clock_seq_low, node) = fields + if not 0 <= time_low < 1<<32L: + raise ValueError('field 1 out of range (need a 32-bit value)') + if not 0 <= time_mid < 1<<16L: + raise ValueError('field 2 out of range (need a 16-bit value)') + if not 0 <= time_hi_version < 1<<16L: + raise ValueError('field 3 out of range (need a 16-bit value)') + if not 0 <= clock_seq_hi_variant < 1<<8L: + raise ValueError('field 4 out of range (need an 8-bit value)') + if not 0 <= clock_seq_low < 1<<8L: + raise ValueError('field 5 out of range (need an 8-bit value)') + if not 0 <= node < 1<<48L: + raise ValueError('field 6 out of range (need a 48-bit value)') + clock_seq = (clock_seq_hi_variant << 8L) | clock_seq_low + int = ((time_low << 96L) | (time_mid << 80L) | + (time_hi_version << 64L) | (clock_seq << 48L) | node) + if int is not None: + if not 0 <= int < 1<<128L: + raise ValueError('int is out of range (need a 128-bit value)') + if version is not None: + if not 1 <= version <= 5: + raise ValueError('illegal version number') + # Set the variant to RFC 4122. + int &= ~(0xc000 << 48L) + int |= 0x8000 << 48L + # Set the version number. + int &= ~(0xf000 << 64L) + int |= version << 76L + self.__dict__['int'] = int + + def __cmp__(self, other): + if isinstance(other, UUID): + return cmp(self.int, other.int) + return NotImplemented + + def __hash__(self): + return hash(self.int) + + def __int__(self): + return self.int + + def __repr__(self): + return 'UUID(%r)' % str(self) + + def __setattr__(self, name, value): + raise TypeError('UUID objects are immutable') + + def __str__(self): + hex = '%032x' % self.int + return '%s-%s-%s-%s-%s' % ( + hex[:8], hex[8:12], hex[12:16], hex[16:20], hex[20:]) + + def get_bytes(self): + bytes = '' + for shift in range(0, 128, 8): + bytes = chr((self.int >> shift) & 0xff) + bytes + return bytes + + bytes = property(get_bytes) + + def get_fields(self): + return (self.time_low, self.time_mid, self.time_hi_version, + self.clock_seq_hi_variant, self.clock_seq_low, self.node) + + fields = property(get_fields) + + def get_time_low(self): + return self.int >> 96L + + time_low = property(get_time_low) + + def get_time_mid(self): + return (self.int >> 80L) & 0xffff + + time_mid = property(get_time_mid) + + def get_time_hi_version(self): + return (self.int >> 64L) & 0xffff + + time_hi_version = property(get_time_hi_version) + + def get_clock_seq_hi_variant(self): + return (self.int >> 56L) & 0xff + + clock_seq_hi_variant = property(get_clock_seq_hi_variant) + + def get_clock_seq_low(self): + return (self.int >> 48L) & 0xff + + clock_seq_low = property(get_clock_seq_low) + + def get_time(self): + return (((self.time_hi_version & 0x0fffL) << 48L) | + (self.time_mid << 32L) | self.time_low) + + time = property(get_time) + + def get_clock_seq(self): + return (((self.clock_seq_hi_variant & 0x3fL) << 8L) | + self.clock_seq_low) + + clock_seq = property(get_clock_seq) + + def get_node(self): + return self.int & 0xffffffffffff + + node = property(get_node) + + def get_hex(self): + return '%032x' % self.int + + hex = property(get_hex) + + def get_urn(self): + return 'urn:uuid:' + str(self) + + urn = property(get_urn) + + def get_variant(self): + if not self.int & (0x8000 << 48L): + return RESERVED_NCS + elif not self.int & (0x4000 << 48L): + return RFC_4122 + elif not self.int & (0x2000 << 48L): + return RESERVED_MICROSOFT + else: + return RESERVED_FUTURE + + variant = property(get_variant) + + def get_version(self): + # The version bits are only meaningful for RFC 4122 UUIDs. + if self.variant == RFC_4122: + return int((self.int >> 76L) & 0xf) + + version = property(get_version) + +def _ifconfig_getnode(): + """Get the hardware address on Unix by running ifconfig.""" + import os + for dir in ['', '/sbin/', '/usr/sbin']: + try: + pipe = os.popen(os.path.join(dir, 'ifconfig')) + except IOError: + continue + for line in pipe: + words = line.lower().split() + for i in range(len(words)): + if words[i] in ['hwaddr', 'ether']: + return int(words[i + 1].replace(':', ''), 16) + +def _ipconfig_getnode(): + """Get the hardware address on Windows by running ipconfig.exe.""" + import os, re + dirs = ['', r'c:\windows\system32', r'c:\winnt\system32'] + try: + import ctypes + buffer = ctypes.create_string_buffer(300) + ctypes.windll.kernel32.GetSystemDirectoryA(buffer, 300) + dirs.insert(0, buffer.value.decode('mbcs')) + except: + pass + for dir in dirs: + try: + pipe = os.popen(os.path.join(dir, 'ipconfig') + ' /all') + except IOError: + continue + for line in pipe: + value = line.split(':')[-1].strip().lower() + if re.match('([0-9a-f][0-9a-f]-){5}[0-9a-f][0-9a-f]', value): + return int(value.replace('-', ''), 16) + +def _netbios_getnode(): + """Get the hardware address on Windows using NetBIOS calls. + See http://support.microsoft.com/kb/118623 for details.""" + import win32wnet, netbios + ncb = netbios.NCB() + ncb.Command = netbios.NCBENUM + ncb.Buffer = adapters = netbios.LANA_ENUM() + adapters._pack() + if win32wnet.Netbios(ncb) != 0: + return + adapters._unpack() + for i in range(adapters.length): + ncb.Reset() + ncb.Command = netbios.NCBRESET + ncb.Lana_num = ord(adapters.lana[i]) + if win32wnet.Netbios(ncb) != 0: + continue + ncb.Reset() + ncb.Command = netbios.NCBASTAT + ncb.Lana_num = ord(adapters.lana[i]) + ncb.Callname = '*'.ljust(16) + ncb.Buffer = status = netbios.ADAPTER_STATUS() + if win32wnet.Netbios(ncb) != 0: + continue + status._unpack() + bytes = map(ord, status.adapter_address) + return ((bytes[0]<<40L) + (bytes[1]<<32L) + (bytes[2]<<24L) + + (bytes[3]<<16L) + (bytes[4]<<8L) + bytes[5]) + +# Thanks to Thomas Heller for ctypes and for his help with its use here. + +# If ctypes is available, use it to find system routines for UUID generation. +_uuid_generate_random = _uuid_generate_time = _UuidCreate = None +try: + import ctypes, ctypes.util + _buffer = ctypes.create_string_buffer(16) + + # The uuid_generate_* routines are provided by libuuid on at least + # Linux and FreeBSD, and provided by libc on Mac OS X. + for libname in ['uuid', 'c']: + try: + lib = ctypes.CDLL(ctypes.util.find_library(libname)) + except: + continue + if hasattr(lib, 'uuid_generate_random'): + _uuid_generate_random = lib.uuid_generate_random + if hasattr(lib, 'uuid_generate_time'): + _uuid_generate_time = lib.uuid_generate_time + + # On Windows prior to 2000, UuidCreate gives a UUID containing the + # hardware address. On Windows 2000 and later, UuidCreate makes a + # random UUID and UuidCreateSequential gives a UUID containing the + # hardware address. These routines are provided by the RPC runtime. + try: + lib = ctypes.windll.rpcrt4 + except: + lib = None + _UuidCreate = getattr(lib, 'UuidCreateSequential', + getattr(lib, 'UuidCreate', None)) +except: + pass + +def _unixdll_getnode(): + """Get the hardware address on Unix using ctypes.""" + _uuid_generate_time(_buffer) + return UUID(bytes=_buffer.raw).node + +def _windll_getnode(): + """Get the hardware address on Windows using ctypes.""" + if _UuidCreate(_buffer) == 0: + return UUID(bytes=_buffer.raw).node + +def _random_getnode(): + """Get a random node ID, with eighth bit set as suggested by RFC 4122.""" + import random + return random.randrange(0, 1<<48L) | 0x010000000000L + +_node = None + +def getnode(): + """Get the hardware address as a 48-bit integer. The first time this + runs, it may launch a separate program, which could be quite slow. If + all attempts to obtain the hardware address fail, we choose a random + 48-bit number with its eighth bit set to 1 as recommended in RFC 4122.""" + + global _node + if _node is not None: + return _node + + import sys + if sys.platform == 'win32': + getters = [_windll_getnode, _netbios_getnode, _ipconfig_getnode] + else: + getters = [_unixdll_getnode, _ifconfig_getnode] + + for getter in getters + [_random_getnode]: + try: + _node = getter() + except: + continue + if _node is not None: + return _node + +def uuid1(node=None, clock_seq=None): + """Generate a UUID from a host ID, sequence number, and the current time. + If 'node' is not given, getnode() is used to obtain the hardware + address. If 'clock_seq' is given, it is used as the sequence number; + otherwise a random 14-bit sequence number is chosen.""" + + # When the system provides a version-1 UUID generator, use it (but don't + # use UuidCreate here because its UUIDs don't conform to RFC 4122). + if _uuid_generate_time and node is clock_seq is None: + _uuid_generate_time(_buffer) + return UUID(bytes=_buffer.raw) + + import time + nanoseconds = int(time.time() * 1e9) + # 0x01b21dd213814000 is the number of 100-ns intervals between the + # UUID epoch 1582-10-15 00:00:00 and the Unix epoch 1970-01-01 00:00:00. + timestamp = int(nanoseconds/100) + 0x01b21dd213814000L + if clock_seq is None: + import random + clock_seq = random.randrange(1<<14L) # instead of stable storage + time_low = timestamp & 0xffffffffL + time_mid = (timestamp >> 32L) & 0xffffL + time_hi_version = (timestamp >> 48L) & 0x0fffL + clock_seq_low = clock_seq & 0xffL + clock_seq_hi_variant = (clock_seq >> 8L) & 0x3fL + if node is None: + node = getnode() + return UUID(fields=(time_low, time_mid, time_hi_version, + clock_seq_hi_variant, clock_seq_low, node), version=1) + +def uuid3(namespace, name): + """Generate a UUID from the MD5 hash of a namespace UUID and a name.""" + import md5 + hash = md5.md5(namespace.bytes + name).digest() + return UUID(bytes=hash[:16], version=3) + +def uuid4(): + """Generate a random UUID.""" + + # When the system provides a version-4 UUID generator, use it. + if _uuid_generate_random: + _uuid_generate_random(_buffer) + return UUID(bytes=_buffer.raw) + + # Otherwise, get randomness from urandom or the 'random' module. + try: + import os + return UUID(bytes=os.urandom(16), version=4) + except: + import random + bytes = [chr(random.randrange(256)) for i in range(16)] + return UUID(bytes=bytes, version=4) + +def uuid5(namespace, name): + """Generate a UUID from the SHA-1 hash of a namespace UUID and a name.""" + import sha + hash = sha.sha(namespace.bytes + name).digest() + return UUID(bytes=hash[:16], version=5) + +# The following standard UUIDs are for use with uuid3() or uuid5(). + +NAMESPACE_DNS = UUID('6ba7b810-9dad-11d1-80b4-00c04fd430c8') +NAMESPACE_URL = UUID('6ba7b811-9dad-11d1-80b4-00c04fd430c8') +NAMESPACE_OID = UUID('6ba7b812-9dad-11d1-80b4-00c04fd430c8') +NAMESPACE_X500 = UUID('6ba7b814-9dad-11d1-80b4-00c04fd430c8') |