Require PIL.
[redakcja.git] / lib / vstorage.py
index b23234a..ae4d22f 100644 (file)
@@ -1,9 +1,17 @@
 # -*- coding: utf-8 -*-
 # -*- 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 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'
 
 # Note: we have to set these before importing Mercurial
 os.environ['HGENCODING'] = 'utf-8'
@@ -15,9 +23,9 @@ import mercurial.revlog
 import mercurial.util
 
 
 import mercurial.util
 
 
-def urlquote(url, safe = '/'):
-    """Quotes URL 
-    
+def urlquote(url, safe='/'):
+    """Quotes URL
+
     >>> urlquote(u'Za\u017c\xf3\u0142\u0107 g\u0119\u015bl\u0105 ja\u017a\u0144')
     'Za%C5%BC%C3%B3%C5%82%C4%87_g%C4%99%C5%9Bl%C4%85_ja%C5%BA%C5%84'
     """
     >>> urlquote(u'Za\u017c\xf3\u0142\u0107 g\u0119\u015bl\u0105 ja\u017a\u0144')
     'Za%C5%BC%C3%B3%C5%82%C4%87_g%C4%99%C5%9Bl%C4%85_ja%C5%BA%C5%84'
     """
@@ -25,8 +33,8 @@ def urlquote(url, safe = '/'):
 
 
 def urlunquote(url):
 
 
 def urlunquote(url):
-    """Unqotes URL 
-    
+    """Unqotes URL
+
     # >>> urlunquote('Za%C5%BC%C3%B3%C5%82%C4%87_g%C4%99%C5%9Bl%C4%85_ja%C5%BA%C5%84')
     # u'Za\u017c\xf3\u0142\u0107 g\u0119\u015bl\u0105 ja\u017a\u0144'
     """
     # >>> urlunquote('Za%C5%BC%C3%B3%C5%82%C4%87_g%C4%99%C5%9Bl%C4%85_ja%C5%BA%C5%84')
     # u'Za\u017c\xf3\u0142\u0107 g\u0119\u015bl\u0105 ja\u017a\u0144'
     """
@@ -42,27 +50,38 @@ def find_repo_path(path):
     return path
 
 
     return path
 
 
-def locked_repo(func):
+def with_working_copy_locked(func):
     """A decorator for locking the repository when calling a method."""
 
     """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."""
         """Wrap the original function in locks."""
-
         wlock = self.repo.wlock()
         wlock = self.repo.wlock()
-        lock = self.repo.lock()
         try:
         try:
-            func(self, *args, **kwargs)
+            return func(self, *args, **kwargs)
         finally:
         finally:
-            lock.release()
             wlock.release()
             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.
 
 
 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'
 
     >>> guess_mime('something.txt')
     'text/plain'
@@ -78,7 +97,7 @@ def guess_mime(file_name):
     'archive/gzip'
     """
 
     '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:
     if encoding:
         mime = 'archive/%s' % encoding
     if mime is None:
@@ -96,7 +115,7 @@ class VersionedStorage(object):
     change history, using Mercurial repository as the storage method.
     """
 
     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
         """
         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,34 +128,31 @@ class VersionedStorage(object):
         if not os.path.exists(self.path):
             os.makedirs(self.path)
         self.repo_path = find_repo_path(self.path)
         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
         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,
         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."""
 
     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):
         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):
 
     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)
 
     def _file_to_title(self, filename):
         assert filename.startswith(self.repo_prefix)
@@ -144,8 +160,7 @@ class VersionedStorage(object):
         return urlunquote(name)
 
     def __contains__(self, title):
         return urlunquote(name)
 
     def __contains__(self, title):
-        print "Checking ", title
-        return urlquote(title) in self.repo.dirstate
+        return urlquote(title) in self.repo['tip']
 
     def __iter__(self):
         return self.all_pages()
 
     def __iter__(self):
         return self.all_pages()
@@ -180,50 +195,41 @@ class VersionedStorage(object):
             pass
         return msg
 
             pass
         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."""
         """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')
         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()
         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
         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 = '<wiki>'
             text = msg.encode('utf-8')
         if parent is not None and current_page_rev != parent:
             msg = self.merge_changes(changectx, repo_file, text, user, parent)
             user = '<wiki>'
             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)
 
 
+        self._commit([repo_file], text, user)
 
 
-    def save_data(self, title, data, author = u'', comment = u'', parent = None):
+    def save_data(self, title, data, **kwargs):
         """Save data as specified page."""
         """Save data as specified page."""
-
         try:
         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()
             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)
         finally:
             try:
                 os.unlink(file_path)
@@ -234,15 +240,16 @@ class VersionedStorage(object):
             except OSError:
                 pass
 
             except OSError:
                 pass
 
-    def save_text(self, title, text, author = u'', comment = u'', parent = None):
+    def save_text(self, text, **kwargs):
         """Save text as specified page, encoded to charset."""
         """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."""
 
     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
         data = self.open_page(title).read()
         text = unicode(data, self.charset, 'replace')
         return text
@@ -251,8 +258,9 @@ class VersionedStorage(object):
         for data in page:
             yield unicode(data, self.charset, 'replace')
 
         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)
         user = author.encode('utf-8') or 'anon'
         text = comment.encode('utf-8') or 'deleted'
         repo_file = self._title_to_file(title)
@@ -264,20 +272,22 @@ class VersionedStorage(object):
         self.repo.remove([repo_file])
         self._commit([repo_file], text, user)
 
         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()
 
     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:
         try:
-            return open(self._file_path(title), "rb")
+            return self.repo.wfile(path, 'rb')
         except IOError:
         except IOError:
-            import traceback
-            print traceback.print_exc()
+            logger.exception("Failed to open page %s", title)
             raise DocumentNotFound()
 
             raise DocumentNotFound()
 
+    @with_working_copy_locked
     def page_file_meta(self, title):
         """Get page's inode number, size and last modification time."""
     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))
         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))
@@ -285,6 +295,7 @@ class VersionedStorage(object):
             return 0, 0, 0
         return st_ino, st_size, st_mtime
 
             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:
     def page_meta(self, title):
         """Get page's revision, date, last editor and his edit comment."""
         if not title in self:
@@ -302,25 +313,19 @@ class VersionedStorage(object):
         return rev, date, author, comment
 
     def repo_revision(self):
         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.
         """
 
     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):
+    def _find_filectx(self, title, rev=None):
         """Find the last revision in which the file existed."""
 
         repo_file = self._title_to_file(title)
         """Find the last revision in which the file existed."""
 
         repo_file = self._title_to_file(title)
@@ -333,14 +338,18 @@ class VersionedStorage(object):
             for parent in changectx.parents():
                 if parent != changectx:
                     stack.append(parent)
             for parent in changectx.parents():
                 if parent != changectx:
                     stack.append(parent)
-        return changectx[repo_file]
+
+        try:
+            fctx = changectx[repo_file]
+            return fctx if rev is None else fctx.filectx(rev)
+        except IndexError, LookupError:
+            raise DocumentNotFound()
 
     def page_history(self, title):
         """Iterate over the page's history."""
 
         filectx_tip = self._find_filectx(title)
 
     def page_history(self, title):
         """Iterate over the page's history."""
 
         filectx_tip = self._find_filectx(title)
-        if filectx_tip is None:
-            return
+
         maxrev = filectx_tip.filerev()
         minrev = 0
         for rev in range(maxrev, minrev - 1, -1):
         maxrev = filectx_tip.filerev()
         minrev = 0
         for rev in range(maxrev, minrev - 1, -1):
@@ -349,25 +358,38 @@ class VersionedStorage(object):
             author = unicode(filectx.user(), "utf-8",
                              'replace').split('<')[0].strip()
             comment = unicode(filectx.description(), "utf-8", 'replace')
             author = unicode(filectx.user(), "utf-8",
                              'replace').split('<')[0].strip()
             comment = unicode(filectx.description(), "utf-8", 'replace')
-            yield rev, date, author, comment
+            tags = [t.rsplit('#', 1)[-1] for t in filectx.changectx().tags() if '#' in t]
+
+            yield {
+                "version": rev,
+                "date": date,
+                "author": author,
+                "description": comment,
+                "tag": tags,
+            }
 
     def page_revision(self, title, rev):
         """Get unicode contents of specified revision of the page."""
 
     def page_revision(self, title, rev):
         """Get unicode contents of specified revision of the page."""
-
-        filectx_tip = self._find_filectx(title)
-        if filectx_tip is None:
-            raise DocumentNotFound()
-        try:
-            data = filectx_tip.filectx(rev).data()
-        except IndexError:
-            raise DocumentNotFound()
-        return data
+        return self._find_filectx(title, rev).data()
 
     def revision_text(self, title, rev):
         data = self.page_revision(title, rev)
         text = unicode(data, self.charset, 'replace')
         return text
 
 
     def revision_text(self, title, rev):
         data = self.page_revision(title, rev)
         text = unicode(data, self.charset, 'replace')
         return text
 
+    @with_working_copy_locked
+    def add_page_tag(self, title, rev, tag, user="<wiki>", doctag=True):
+        if doctag:
+            tag = "{title}#{tag}".format(**locals())
+
+        message = "Assigned tag {tag} to version {rev} of {title}".format(**locals())
+
+        fctx = self._find_filectx(title, rev)
+        self.repo.tag(
+            names=tag, node=fctx.node(), local=False,
+            user=user, message=message, date=None,
+        )
+
     def history(self):
         """Iterate over the history of entire wiki."""
 
     def history(self):
         """Iterate over the history of entire wiki."""
 
@@ -390,12 +412,9 @@ class VersionedStorage(object):
                     yield title, rev, date, author, comment
 
     def all_pages(self):
                     yield title, rev, date, author, comment
 
     def all_pages(self):
+        tip = self.repo['tip']
         """Iterate over the titles of all pages in the wiki."""
         """Iterate over the titles of all pages in the wiki."""
-        return [ urlunquote(filename) for filename in self.repo.dirstate]
-        #status = self.repo.status(self.repo[None], None, None, True, True, True)
-        #clean_files = status[6]
-        #for filename in clean_files:
-        #    yield
+        return [urlunquote(filename) for filename in tip]
 
     def changed_since(self, rev):
         """Return all pages that changed since specified repository revision."""
 
     def changed_since(self, rev):
         """Return all pages that changed since specified repository revision."""