X-Git-Url: https://git.mdrn.pl/redakcja.git/blobdiff_plain/3eec01e4c2a842c7383857e49d68664f04759c1f..1b8ab1430082a145a3e4807de837dcc1568178a3:/lib/vstorage.py?ds=sidebyside diff --git a/lib/vstorage.py b/lib/vstorage.py index 1060bc25..527a245d 100644 --- a/lib/vstorage.py +++ b/lib/vstorage.py @@ -1,9 +1,17 @@ # -*- coding: utf-8 -*- +# +# This file is part of FNP-Redakcja, licensed under GNU Affero GPLv3 or later. +# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information. +# import os import tempfile import datetime import mimetypes import urllib +import functools + +import logging +logger = logging.getLogger('fnp.hazlenut.vstorage') # Note: we have to set these before importing Mercurial os.environ['HGENCODING'] = 'utf-8' @@ -15,7 +23,7 @@ import mercurial.revlog import mercurial.util -def urlquote(url, safe='/'): +def urlquote(url, safe = '/'): """Quotes URL >>> urlquote(u'Za\u017c\xf3\u0142\u0107 g\u0119\u015bl\u0105 ja\u017a\u0144') @@ -42,27 +50,36 @@ def find_repo_path(path): return path -def locked_repo(func): +def with_working_copy_locked(func): """A decorator for locking the repository when calling a method.""" - def new_func(self, *args, **kwargs): + @functools.wraps(func) + def wrapped(self, *args, **kwargs): """Wrap the original function in locks.""" - wlock = self.repo.wlock() - lock = self.repo.lock() try: - func(self, *args, **kwargs) - finally: - lock.release() + return func(self, *args, **kwargs) + finally: wlock.release() + return wrapped - return new_func +def with_storage_locked(func): + """A decorator for locking the repository when calling a method.""" + @functools.wraps(func) + def wrapped(self, *args, **kwargs): + """Wrap the original function in locks.""" + lock = self.repo.lock() + try: + return func(self, *args, **kwargs) + finally: + lock.release() + return wrapped def guess_mime(file_name): """ Guess file's mime type based on extension. - Default ot text/x-wiki for files without an extension. + Default of text/x-wiki for files without an extension. >>> guess_mime('something.txt') 'text/plain' @@ -78,7 +95,7 @@ def guess_mime(file_name): 'archive/gzip' """ - mime, encoding = mimetypes.guess_type(file_name, strict=False) + mime, encoding = mimetypes.guess_type(file_name, strict = False) if encoding: mime = 'archive/%s' % encoding if mime is None: @@ -96,7 +113,7 @@ class VersionedStorage(object): change history, using Mercurial repository as the storage method. """ - def __init__(self, path, charset=None): + def __init__(self, path, charset = None): """ Takes the path to the directory where the pages are to be kept. If the directory doen't exist, it will be created. If it's inside @@ -109,42 +126,39 @@ class VersionedStorage(object): if not os.path.exists(self.path): os.makedirs(self.path) self.repo_path = find_repo_path(self.path) - try: - self.ui = mercurial.ui.ui(report_untrusted=False, - interactive=False, quiet=True) - except TypeError: - # Mercurial 1.3 changed the way we setup the ui object. - self.ui = mercurial.ui.ui() - self.ui.quiet = True - self.ui._report_untrusted = False - self.ui.setconfig('ui', 'interactive', False) + + self.ui = mercurial.ui.ui() + self.ui.quiet = True + self.ui._report_untrusted = False + self.ui.setconfig('ui', 'interactive', False) + if self.repo_path is None: self.repo_path = self.path create = True else: create = False + self.repo_prefix = self.path[len(self.repo_path):].strip('/') self.repo = mercurial.hg.repository(self.ui, self.repo_path, - create=create) + create = create) def reopen(self): """Close and reopen the repo, to make sure we are up to date.""" - self.repo = mercurial.hg.repository(self.ui, self.repo_path) def _file_path(self, title): - return os.path.join(self.path, urlquote(title, safe='')) + return os.path.join(self.path, urlquote(title, safe = '')) def _title_to_file(self, title): - return os.path.join(self.repo_prefix, urlquote(title, safe='')) + return os.path.join(self.repo_prefix, urlquote(title, safe = '')) def _file_to_title(self, filename): assert filename.startswith(self.repo_prefix) name = filename[len(self.repo_prefix):].strip('/') return urlunquote(name) - def __contains__(self, title): - return os.path.exists(self._file_path(title)) + def __contains__(self, title): + return urlquote(title) in self.repo['tip'] def __iter__(self): return self.all_pages() @@ -157,16 +171,19 @@ class VersionedStorage(object): self.repo.dirstate.setparents(parent_node) node = self._commit([repo_file], text, user) - + partial = lambda filename: repo_file == filename - + # If p1 is equal to p2, there is no work to do. Even the dirstate is correct. p1, p2 = self.repo[None].parents()[0], self.repo[tip_node] if p1 == p2: return text - - # TODO: Check if merge was successful - mercurial.merge.update(self.repo, tip_node, True, False, partial) + + try: + mercurial.merge.update(self.repo, tip_node, True, False, partial) + msg = 'merge of edit conflict' + except mercurial.util.Abort: + msg = 'failed merge of edit conflict' self.repo.dirstate.setparents(tip_node, node) # Mercurial 1.1 and later need updating the merge state @@ -174,52 +191,44 @@ class VersionedStorage(object): mercurial.merge.mergestate(self.repo).mark(repo_file, "r") except (AttributeError, KeyError): pass - return u'merge of edit conflict' + return msg - @locked_repo - def save_file(self, title, file_name, author=u'', comment=u'', parent=None): + @with_working_copy_locked + @with_storage_locked + def save_file(self, title, file_name, author = u'', comment = u'', parent = None): """Save an existing file as specified page.""" - - user = author.encode('utf-8') or u'anon'.encode('utf-8') + user = author.encode('utf-8') or u'anonymous'.encode('utf-8') text = comment.encode('utf-8') or u'comment'.encode('utf-8') + repo_file = self._title_to_file(title) file_path = self._file_path(title) mercurial.util.rename(file_name, file_path) changectx = self._changectx() + try: filectx_tip = changectx[repo_file] current_page_rev = filectx_tip.filerev() except mercurial.revlog.LookupError: self.repo.add([repo_file]) current_page_rev = -1 + if parent is not None and current_page_rev != parent: msg = self.merge_changes(changectx, repo_file, text, user, parent) user = '' text = msg.encode('utf-8') + self._commit([repo_file], text, user) - - - def _commit(self, files, text, user): - try: - return self.repo.commit(files=files, text=text, user=user, - force=True, empty_ok=True) - except TypeError: - # Mercurial 1.3 doesn't accept empty_ok or files parameter - match = mercurial.match.exact(self.repo_path, '', list(files)) - return self.repo.commit(match=match, text=text, user=user, - force=True) - - - def save_data(self, title, data, author=u'', comment=u'', parent=None): + + + def save_data(self, title, data, **kwargs): """Save data as specified page.""" - try: - temp_path = tempfile.mkdtemp(dir=self.path) + temp_path = tempfile.mkdtemp(dir = self.path) file_path = os.path.join(temp_path, 'saved') f = open(file_path, "wb") f.write(data) f.close() - self.save_file(title, file_path, author, comment, parent) + self.save_file(title = title, file_name = file_path, **kwargs) finally: try: os.unlink(file_path) @@ -230,15 +239,17 @@ class VersionedStorage(object): except OSError: pass - def save_text(self, title, text, author=u'', comment=u'', parent=None): - """Save text as specified page, encoded to charset.""" + def save_text(self, text, **kwargs): + """Save text as specified page, encoded to charset.""" + self.save_data(data = text.encode(self.charset), **kwargs) - data = text.encode(self.charset) - self.save_data(title, data, author, comment, parent) + def _commit(self, files, text, user): + match = mercurial.match.exact(self.repo_path, '', list(files)) + return self.repo.commit(match = match, text = text, user = user, force = True) + def page_text(self, title): """Read unicode text of a page.""" - data = self.open_page(title).read() text = unicode(data, self.charset, 'replace') return text @@ -247,8 +258,9 @@ class VersionedStorage(object): for data in page: yield unicode(data, self.charset, 'replace') - @locked_repo - def delete_page(self, title, author=u'', comment=u''): + @with_working_copy_locked + @with_storage_locked + def delete_page(self, title, author = u'', comment = u''): user = author.encode('utf-8') or 'anon' text = comment.encode('utf-8') or 'deleted' repo_file = self._title_to_file(title) @@ -260,29 +272,38 @@ class VersionedStorage(object): self.repo.remove([repo_file]) self._commit([repo_file], text, user) + @with_working_copy_locked def open_page(self, title): + if title not in self: + raise DocumentNotFound() + + path = self._title_to_file(title) + logger.debug("Opening page %s", path) try: - return open(self._file_path(title), "rb") + return self.repo.wfile(path, 'rb') except IOError: + logger.exception("Failed to open page %s", title) raise DocumentNotFound() + @with_working_copy_locked def page_file_meta(self, title): """Get page's inode number, size and last modification time.""" - try: (st_mode, st_ino, st_dev, st_nlink, st_uid, st_gid, st_size, st_atime, st_mtime, st_ctime) = os.stat(self._file_path(title)) except OSError: return 0, 0, 0 return st_ino, st_size, st_mtime - + + @with_working_copy_locked def page_meta(self, title): """Get page's revision, date, last editor and his edit comment.""" + if not title in self: + raise DocumentNotFound() filectx_tip = self._find_filectx(title) if filectx_tip is None: raise DocumentNotFound() - #return -1, None, u'', u'' rev = filectx_tip.filerev() filectx = filectx_tip.filectx(rev) date = datetime.datetime.fromtimestamp(filectx.date()[0]) @@ -292,23 +313,17 @@ class VersionedStorage(object): return rev, date, author, comment def repo_revision(self): - return self._changectx().rev() + return self.repo['tip'].rev() + + def _changectx(self): + return self.repo['tip'] def page_mime(self, title): """ Guess page's mime type based on corresponding file name. Default ot text/x-wiki for files without an extension. """ - return guess_type(self._file_path(title)) - - def _changectx(self): - """Get the changectx of the tip.""" - try: - # This is for Mercurial 1.0 - return self.repo.changectx() - except TypeError: - # Mercurial 1.3 (and possibly earlier) needs an argument - return self.repo.changectx('tip') + return guess_mime(self._file_path(title)) def _find_filectx(self, title): """Find the last revision in which the file existed.""" @@ -333,13 +348,20 @@ class VersionedStorage(object): return maxrev = filectx_tip.filerev() minrev = 0 - for rev in range(maxrev, minrev-1, -1): + for rev in range(maxrev, minrev - 1, -1): filectx = filectx_tip.filectx(rev) date = datetime.datetime.fromtimestamp(filectx.date()[0]) author = unicode(filectx.user(), "utf-8", 'replace').split('<')[0].strip() comment = unicode(filectx.description(), "utf-8", 'replace') - yield rev, date, author, comment + tags = filectx.changectx().tags() + yield { + "version": rev, + "date": date, + "author": author, + "description": comment, + "tag": tags[0] if tags else None, + } def page_revision(self, title, rev): """Get unicode contents of specified revision of the page.""" @@ -364,7 +386,7 @@ class VersionedStorage(object): changectx = self._changectx() maxrev = changectx.rev() minrev = 0 - for wiki_rev in range(maxrev, minrev-1, -1): + for wiki_rev in range(maxrev, minrev - 1, -1): change = self.repo.changectx(wiki_rev) date = datetime.datetime.fromtimestamp(change.date()[0]) author = unicode(change.user(), "utf-8", @@ -380,12 +402,9 @@ class VersionedStorage(object): yield title, rev, date, author, comment def all_pages(self): + tip = self.repo['tip'] """Iterate over the titles of all pages in the wiki.""" - - for filename in os.listdir(self.path): - if (os.path.isfile(os.path.join(self.path, filename)) - and not filename.startswith('.')): - yield urlunquote(filename) + return [ urlunquote(filename) for filename in tip ] def changed_since(self, rev): """Return all pages that changed since specified repository revision.""" @@ -399,6 +418,6 @@ class VersionedStorage(object): current = self.repo.lookup('tip') status = self.repo.status(current, last) modified, added, removed, deleted, unknown, ignored, clean = status - for filename in modified+added+removed+deleted: + for filename in modified + added + removed + deleted: if filename.startswith(self.repo_prefix): yield self._file_to_title(filename)