02f0503968bdd4bd1c3e8ff51c09d5913efe43df
[redakcja.git] / lib / vstorage.py
1 # -*- coding: utf-8 -*-
2 import os
3 import tempfile
4 import datetime
5 import mimetypes
6 import urllib
7 import functools
8
9 import logging
10 logger = logging.getLogger('fnp.hazlenut.vstorage')
11
12 # Note: we have to set these before importing Mercurial
13 os.environ['HGENCODING'] = 'utf-8'
14 os.environ['HGMERGE'] = "internal:merge"
15
16 import mercurial.hg
17 import mercurial.ui
18 import mercurial.revlog
19 import mercurial.util
20
21
22 def urlquote(url, safe = '/'):
23     """Quotes URL 
24     
25     >>> urlquote(u'Za\u017c\xf3\u0142\u0107 g\u0119\u015bl\u0105 ja\u017a\u0144')
26     'Za%C5%BC%C3%B3%C5%82%C4%87_g%C4%99%C5%9Bl%C4%85_ja%C5%BA%C5%84'
27     """
28     return urllib.quote(url.replace(' ', '_').encode('utf-8', 'ignore'), safe)
29
30
31 def urlunquote(url):
32     """Unqotes URL 
33     
34     # >>> urlunquote('Za%C5%BC%C3%B3%C5%82%C4%87_g%C4%99%C5%9Bl%C4%85_ja%C5%BA%C5%84')
35     # u'Za\u017c\xf3\u0142\u0107 g\u0119\u015bl\u0105 ja\u017a\u0144'
36     """
37     return unicode(urllib.unquote(url), 'utf-8', 'ignore').replace('_', ' ')
38
39
40 def find_repo_path(path):
41     """Go up the directory tree looking for a Mercurial repository (a directory containing a .hg subdirectory)."""
42     while not os.path.isdir(os.path.join(path, ".hg")):
43         old_path, path = path, os.path.dirname(path)
44         if path == old_path:
45             return None
46     return path
47
48
49 def with_working_copy_locked(func):
50     """A decorator for locking the repository when calling a method."""
51
52     @functools.wraps(func)
53     def wrapped(self, *args, **kwargs):
54         """Wrap the original function in locks."""
55         wlock = self.repo.wlock()
56         try:
57             return func(self, *args, **kwargs)
58         finally:            
59             wlock.release()
60     return wrapped
61
62 def with_storage_locked(func):
63     """A decorator for locking the repository when calling a method."""
64
65     @functools.wraps(func)
66     def wrapped(self, *args, **kwargs):
67         """Wrap the original function in locks."""
68         lock = self.repo.lock()
69         try:
70             return func(self, *args, **kwargs)
71         finally:            
72             lock.release()
73     return wrapped
74
75 def guess_mime(file_name):
76     """
77     Guess file's mime type based on extension.
78     Default of text/x-wiki for files without an extension.
79
80     >>> guess_mime('something.txt')
81     'text/plain'
82     >>> guess_mime('SomePage')
83     'text/x-wiki'
84     >>> guess_mime(u'ąęśUnicodePage')
85     'text/x-wiki'
86     >>> guess_mime('image.png')
87     'image/png'
88     >>> guess_mime('style.css')
89     'text/css'
90     >>> guess_mime('archive.tar.gz')
91     'archive/gzip'
92     """
93
94     mime, encoding = mimetypes.guess_type(file_name, strict = False)
95     if encoding:
96         mime = 'archive/%s' % encoding
97     if mime is None:
98         mime = 'text/x-wiki'
99     return mime
100
101
102 class DocumentNotFound(Exception):
103     pass
104
105
106 class VersionedStorage(object):
107     """
108     Provides means of storing text pages and keeping track of their
109     change history, using Mercurial repository as the storage method.
110     """
111
112     def __init__(self, path, charset = None):
113         """
114         Takes the path to the directory where the pages are to be kept.
115         If the directory doen't exist, it will be created. If it's inside
116         a Mercurial repository, that repository will be used, otherwise
117         a new repository will be created in it.
118         """
119
120         self.charset = charset or 'utf-8'
121         self.path = path
122         if not os.path.exists(self.path):
123             os.makedirs(self.path)
124         self.repo_path = find_repo_path(self.path)
125         
126         self.ui = mercurial.ui.ui()
127         self.ui.quiet = True
128         self.ui._report_untrusted = False
129         self.ui.setconfig('ui', 'interactive', False)
130         
131         if self.repo_path is None:
132             self.repo_path = self.path
133             create = True
134         else:
135             create = False
136             
137         self.repo_prefix = self.path[len(self.repo_path):].strip('/')
138         self.repo = mercurial.hg.repository(self.ui, self.repo_path,
139                                             create = create)
140
141     def reopen(self):
142         """Close and reopen the repo, to make sure we are up to date."""
143         self.repo = mercurial.hg.repository(self.ui, self.repo_path)
144
145     def _file_path(self, title):
146         return os.path.join(self.path, urlquote(title, safe = ''))
147
148     def _title_to_file(self, title):
149         return os.path.join(self.repo_prefix, urlquote(title, safe = ''))
150
151     def _file_to_title(self, filename):
152         assert filename.startswith(self.repo_prefix)
153         name = filename[len(self.repo_prefix):].strip('/')
154         return urlunquote(name)
155
156     def __contains__(self, title):                        
157         return urlquote(title) in self.repo['tip']
158
159     def __iter__(self):
160         return self.all_pages()
161
162     def merge_changes(self, changectx, repo_file, text, user, parent):
163         """Commits and merges conflicting changes in the repository."""
164         tip_node = changectx.node()
165         filectx = changectx[repo_file].filectx(parent)
166         parent_node = filectx.changectx().node()
167
168         self.repo.dirstate.setparents(parent_node)
169         node = self._commit([repo_file], text, user)
170
171         partial = lambda filename: repo_file == filename
172
173         # If p1 is equal to p2, there is no work to do. Even the dirstate is correct.
174         p1, p2 = self.repo[None].parents()[0], self.repo[tip_node]
175         if p1 == p2:
176             return text
177
178         try:
179             mercurial.merge.update(self.repo, tip_node, True, False, partial)
180             msg = 'merge of edit conflict'
181         except mercurial.util.Abort:
182             msg = 'failed merge of edit conflict'
183
184         self.repo.dirstate.setparents(tip_node, node)
185         # Mercurial 1.1 and later need updating the merge state
186         try:
187             mercurial.merge.mergestate(self.repo).mark(repo_file, "r")
188         except (AttributeError, KeyError):
189             pass
190         return msg
191
192     @with_working_copy_locked
193     @with_storage_locked
194     def save_file(self, title, file_name, author = u'', comment = u'', parent = None):
195         """Save an existing file as specified page."""
196         user = author.encode('utf-8') or u'anonymous'.encode('utf-8')
197         text = comment.encode('utf-8') or u'comment'.encode('utf-8')
198         
199         repo_file = self._title_to_file(title)
200         file_path = self._file_path(title)
201         mercurial.util.rename(file_name, file_path)
202         changectx = self._changectx()
203         
204         try:
205             filectx_tip = changectx[repo_file]
206             current_page_rev = filectx_tip.filerev()
207         except mercurial.revlog.LookupError:
208             self.repo.add([repo_file])
209             current_page_rev = -1
210         
211         if parent is not None and current_page_rev != parent:
212             msg = self.merge_changes(changectx, repo_file, text, user, parent)
213             user = '<wiki>'
214             text = msg.encode('utf-8')
215             
216         self._commit([repo_file], text, user)
217         
218         
219     def save_data(self, title, data, **kwargs):
220         """Save data as specified page."""
221         try:
222             temp_path = tempfile.mkdtemp(dir = self.path)
223             file_path = os.path.join(temp_path, 'saved')
224             f = open(file_path, "wb")
225             f.write(data)
226             f.close()
227             self.save_file(title = title, file_name = file_path, **kwargs)
228         finally:
229             try:
230                 os.unlink(file_path)
231             except OSError:
232                 pass
233             try:
234                 os.rmdir(temp_path)
235             except OSError:
236                 pass
237
238     def save_text(self, text, **kwargs):
239         """Save text as specified page, encoded to charset.""" 
240         self.save_data(data = text.encode(self.charset), **kwargs)
241
242
243     def _commit(self, files, text, user):
244         match = mercurial.match.exact(self.repo_path, '', list(files))
245         return self.repo.commit(match = match, text = text, user = user, force = True)
246  
247     def page_text(self, title):
248         """Read unicode text of a page."""
249         data = self.open_page(title).read()
250         text = unicode(data, self.charset, 'replace')
251         return text
252
253     def page_lines(self, page):
254         for data in page:
255             yield unicode(data, self.charset, 'replace')
256
257     @with_working_copy_locked
258     @with_storage_locked
259     def delete_page(self, title, author = u'', comment = u''):
260         user = author.encode('utf-8') or 'anon'
261         text = comment.encode('utf-8') or 'deleted'
262         repo_file = self._title_to_file(title)
263         file_path = self._file_path(title)
264         try:
265             os.unlink(file_path)
266         except OSError:
267             pass
268         self.repo.remove([repo_file])
269         self._commit([repo_file], text, user)
270
271     @with_working_copy_locked
272     def open_page(self, title):
273         if title not in self:
274             raise DocumentNotFound()
275         
276         path = self._title_to_file(title)
277         logger.debug("Opening page %s", path)      
278         try:
279             return self.repo.wfile(path, 'rb')            
280         except IOError:
281             logger.exception("Failed to open page %s", title)
282             raise DocumentNotFound()
283
284     @with_working_copy_locked
285     def page_file_meta(self, title):
286         """Get page's inode number, size and last modification time."""
287         try:
288             (st_mode, st_ino, st_dev, st_nlink, st_uid, st_gid, st_size,
289              st_atime, st_mtime, st_ctime) = os.stat(self._file_path(title))
290         except OSError:
291             return 0, 0, 0
292         return st_ino, st_size, st_mtime
293     
294     @with_working_copy_locked
295     def page_meta(self, title):
296         """Get page's revision, date, last editor and his edit comment."""
297         if not title in self:
298             raise DocumentNotFound()
299
300         filectx_tip = self._find_filectx(title)
301         if filectx_tip is None:
302             raise DocumentNotFound()
303         rev = filectx_tip.filerev()
304         filectx = filectx_tip.filectx(rev)
305         date = datetime.datetime.fromtimestamp(filectx.date()[0])
306         author = unicode(filectx.user(), "utf-8",
307                          'replace').split('<')[0].strip()
308         comment = unicode(filectx.description(), "utf-8", 'replace')
309         return rev, date, author, comment
310
311     def repo_revision(self):
312         return self.repo['tip'].rev()
313     
314     def _changectx(self):
315         return self.repo['tip']
316
317     def page_mime(self, title):
318         """
319         Guess page's mime type based on corresponding file name.
320         Default ot text/x-wiki for files without an extension.
321         """
322         return guess_mime(self._file_path(title))
323
324     def _find_filectx(self, title):
325         """Find the last revision in which the file existed."""
326
327         repo_file = self._title_to_file(title)
328         changectx = self._changectx()
329         stack = [changectx]
330         while repo_file not in changectx:
331             if not stack:
332                 return None
333             changectx = stack.pop()
334             for parent in changectx.parents():
335                 if parent != changectx:
336                     stack.append(parent)
337         return changectx[repo_file]
338
339     def page_history(self, title):
340         """Iterate over the page's history."""
341
342         filectx_tip = self._find_filectx(title)
343         if filectx_tip is None:
344             return
345         maxrev = filectx_tip.filerev()
346         minrev = 0
347         for rev in range(maxrev, minrev - 1, -1):
348             filectx = filectx_tip.filectx(rev)
349             date = datetime.datetime.fromtimestamp(filectx.date()[0])
350             author = unicode(filectx.user(), "utf-8",
351                              'replace').split('<')[0].strip()
352             comment = unicode(filectx.description(), "utf-8", 'replace')
353             yield {"version": rev, "date": date, "author": author, "description": comment}
354
355     def page_revision(self, title, rev):
356         """Get unicode contents of specified revision of the page."""
357
358         filectx_tip = self._find_filectx(title)
359         if filectx_tip is None:
360             raise DocumentNotFound()
361         try:
362             data = filectx_tip.filectx(rev).data()
363         except IndexError:
364             raise DocumentNotFound()
365         return data
366
367     def revision_text(self, title, rev):
368         data = self.page_revision(title, rev)
369         text = unicode(data, self.charset, 'replace')
370         return text
371
372     def history(self):
373         """Iterate over the history of entire wiki."""
374
375         changectx = self._changectx()
376         maxrev = changectx.rev()
377         minrev = 0
378         for wiki_rev in range(maxrev, minrev - 1, -1):
379             change = self.repo.changectx(wiki_rev)
380             date = datetime.datetime.fromtimestamp(change.date()[0])
381             author = unicode(change.user(), "utf-8",
382                              'replace').split('<')[0].strip()
383             comment = unicode(change.description(), "utf-8", 'replace')
384             for repo_file in change.files():
385                 if repo_file.startswith(self.repo_prefix):
386                     title = self._file_to_title(repo_file)
387                     try:
388                         rev = change[repo_file].filerev()
389                     except mercurial.revlog.LookupError:
390                         rev = -1
391                     yield title, rev, date, author, comment
392
393     def all_pages(self):
394         tip = self.repo['tip']
395         """Iterate over the titles of all pages in the wiki."""
396         return [ urlunquote(filename) for filename in tip ]        
397
398     def changed_since(self, rev):
399         """Return all pages that changed since specified repository revision."""
400
401         try:
402             last = self.repo.lookup(int(rev))
403         except IndexError:
404             for page in self.all_pages():
405                 yield page
406                 return
407         current = self.repo.lookup('tip')
408         status = self.repo.status(current, last)
409         modified, added, removed, deleted, unknown, ignored, clean = status
410         for filename in modified + added + removed + deleted:
411             if filename.startswith(self.repo_prefix):
412                 yield self._file_to_title(filename)