]> arthur.barton.de Git - bup.git/blob - lib/bup/vfs.py
lib/bup/vfs: Add docstrings
[bup.git] / lib / bup / vfs.py
1 """Virtual File System representing bup's repository contents.
2
3 The vfs.py library makes it possible to expose contents from bup's repository
4 and abstracts internal name mangling and storage from the exposition layer.
5 """
6 import os, re, stat, time
7 from bup import git
8 from helpers import *
9
10 EMPTY_SHA='\0'*20
11
12 _cp = None
13 def cp():
14     """Create a git.CatPipe object or reuse the already existing one."""
15     global _cp
16     if not _cp:
17         _cp = git.CatPipe()
18     return _cp
19
20 class NodeError(Exception):
21     """VFS base exception."""
22     pass
23
24 class NoSuchFile(NodeError):
25     """Request of a file that does not exist."""
26     pass
27
28 class NotDir(NodeError):
29     """Attempt to do a directory action on a file that is not one."""
30     pass
31
32 class NotFile(NodeError):
33     """Access to a node that does not represent a file."""
34     pass
35
36 class TooManySymlinks(NodeError):
37     """Symlink dereferencing level is too deep."""
38     pass
39
40
41 def _treeget(hash):
42     it = cp().get(hash.encode('hex'))
43     type = it.next()
44     assert(type == 'tree')
45     return git.treeparse(''.join(it))
46
47
48 def _tree_decode(hash):
49     tree = [(int(name,16),stat.S_ISDIR(int(mode,8)),sha)
50             for (mode,name,sha)
51             in _treeget(hash)]
52     assert(tree == list(sorted(tree)))
53     return tree
54
55
56 def _chunk_len(hash):
57     return sum(len(b) for b in cp().join(hash.encode('hex')))
58
59
60 def _last_chunk_info(hash):
61     tree = _tree_decode(hash)
62     assert(tree)
63     (ofs,isdir,sha) = tree[-1]
64     if isdir:
65         (subofs, sublen) = _last_chunk_info(sha)
66         return (ofs+subofs, sublen)
67     else:
68         return (ofs, _chunk_len(sha))
69
70
71 def _total_size(hash):
72     (lastofs, lastsize) = _last_chunk_info(hash)
73     return lastofs + lastsize
74
75
76 def _chunkiter(hash, startofs):
77     assert(startofs >= 0)
78     tree = _tree_decode(hash)
79
80     # skip elements before startofs
81     for i in xrange(len(tree)):
82         if i+1 >= len(tree) or tree[i+1][0] > startofs:
83             break
84     first = i
85
86     # iterate through what's left
87     for i in xrange(first, len(tree)):
88         (ofs,isdir,sha) = tree[i]
89         skipmore = startofs-ofs
90         if skipmore < 0:
91             skipmore = 0
92         if isdir:
93             for b in _chunkiter(sha, skipmore):
94                 yield b
95         else:
96             yield ''.join(cp().join(sha.encode('hex')))[skipmore:]
97
98
99 class _ChunkReader(object):
100     def __init__(self, hash, isdir, startofs):
101         if isdir:
102             self.it = _chunkiter(hash, startofs)
103             self.blob = None
104         else:
105             self.it = None
106             self.blob = ''.join(cp().join(hash.encode('hex')))[startofs:]
107         self.ofs = startofs
108
109     def next(self, size):
110         out = ''
111         while len(out) < size:
112             if self.it and not self.blob:
113                 try:
114                     self.blob = self.it.next()
115                 except StopIteration:
116                     self.it = None
117             if self.blob:
118                 want = size - len(out)
119                 out += self.blob[:want]
120                 self.blob = self.blob[want:]
121             if not self.it:
122                 break
123         log('next(%d) returned %d\n' % (size, len(out)))
124         self.ofs += len(out)
125         return out
126
127
128 class _FileReader(object):
129     def __init__(self, hash, size, isdir):
130         self.hash = hash
131         self.ofs = 0
132         self.size = size
133         self.isdir = isdir
134         self.reader = None
135
136     def seek(self, ofs):
137         if ofs > self.size:
138             self.ofs = self.size
139         elif ofs < 0:
140             self.ofs = 0
141         else:
142             self.ofs = ofs
143
144     def tell(self):
145         return self.ofs
146
147     def read(self, count = -1):
148         if count < 0:
149             count = self.size - self.ofs
150         if not self.reader or self.reader.ofs != self.ofs:
151             self.reader = _ChunkReader(self.hash, self.isdir, self.ofs)
152         try:
153             buf = self.reader.next(count)
154         except:
155             self.reader = None
156             raise  # our offsets will be all screwed up otherwise
157         self.ofs += len(buf)
158         return buf
159
160     def close(self):
161         pass
162
163
164 class Node(object):
165     """Base class for file representation."""
166     def __init__(self, parent, name, mode, hash):
167         self.parent = parent
168         self.name = name
169         self.mode = mode
170         self.hash = hash
171         self.ctime = self.mtime = self.atime = 0
172         self._subs = None
173
174     def __cmp__(a, b):
175         return cmp(a.name or None, b.name or None)
176
177     def __iter__(self):
178         return iter(self.subs())
179
180     def fullname(self):
181         """Get this file's full path."""
182         if self.parent:
183             return os.path.join(self.parent.fullname(), self.name)
184         else:
185             return self.name
186
187     def _mksubs(self):
188         self._subs = {}
189
190     def subs(self):
191         """Get a list of nodes that are contained in this node."""
192         if self._subs == None:
193             self._mksubs()
194         return sorted(self._subs.values())
195
196     def sub(self, name):
197         """Get node named 'name' that is contained in this node."""
198         if self._subs == None:
199             self._mksubs()
200         ret = self._subs.get(name)
201         if not ret:
202             raise NoSuchFile("no file %r in %r" % (name, self.name))
203         return ret
204
205     def top(self):
206         """Return the very top node of the tree."""
207         if self.parent:
208             return self.parent.top()
209         else:
210             return self
211
212     def fs_top(self):
213         """Return the top node of the particular backup set.
214
215         If this node isn't inside a backup set, return the root level.
216         """
217         if self.parent and not isinstance(self.parent, CommitList):
218             return self.parent.fs_top()
219         else:
220             return self
221
222     def _lresolve(self, parts):
223         #log('_lresolve %r in %r\n' % (parts, self.name))
224         if not parts:
225             return self
226         (first, rest) = (parts[0], parts[1:])
227         if first == '.':
228             return self._lresolve(rest)
229         elif first == '..':
230             if not self.parent:
231                 raise NoSuchFile("no parent dir for %r" % self.name)
232             return self.parent._lresolve(rest)
233         elif rest:
234             return self.sub(first)._lresolve(rest)
235         else:
236             return self.sub(first)
237
238     def lresolve(self, path, stay_inside_fs=False):
239         """Walk into a given sub-path of this node.
240
241         If the last element is a symlink, leave it as a symlink, don't resolve
242         it.  (like lstat())
243         """
244         start = self
245         if not path:
246             return start
247         if path.startswith('/'):
248             if stay_inside_fs:
249                 start = self.fs_top()
250             else:
251                 start = self.top()
252             path = path[1:]
253         parts = re.split(r'/+', path or '.')
254         if not parts[-1]:
255             parts[-1] = '.'
256         #log('parts: %r %r\n' % (path, parts))
257         return start._lresolve(parts)
258
259     def resolve(self, path = ''):
260         """Like lresolve(), and dereference it if it was a symlink."""
261         return self.lresolve(path).lresolve('.')
262
263     def try_resolve(self, path = ''):
264         """Like resolve(), but don't worry if a symlink uses an invalid path.
265
266         Returns an error if any intermediate nodes were invalid.
267         """
268         n = self.lresolve(path)
269         try:
270             n = n.lresolve('.')
271         except NoSuchFile:
272             pass
273         return n
274
275     def nlinks(self):
276         """Get the number of hard links to the current node."""
277         if self._subs == None:
278             self._mksubs()
279         return 1
280
281     def size(self):
282         """Get the size of the current node."""
283         return 0
284
285     def open(self):
286         """Open the current node. It is an error to open a non-file node."""
287         raise NotFile('%s is not a regular file' % self.name)
288
289
290 class File(Node):
291     """A normal file from bup's repository."""
292     def __init__(self, parent, name, mode, hash, bupmode):
293         Node.__init__(self, parent, name, mode, hash)
294         self.bupmode = bupmode
295         self._cached_size = None
296         self._filereader = None
297
298     def open(self):
299         """Open the file."""
300         # You'd think FUSE might call this only once each time a file is
301         # opened, but no; it's really more of a refcount, and it's called
302         # once per read().  Thus, it's important to cache the filereader
303         # object here so we're not constantly re-seeking.
304         if not self._filereader:
305             self._filereader = _FileReader(self.hash, self.size(),
306                                            self.bupmode == git.BUP_CHUNKED)
307         self._filereader.seek(0)
308         return self._filereader
309
310     def size(self):
311         """Get this file's size."""
312         if self._cached_size == None:
313             log('<<<<File.size() is calculating...\n')
314             if self.bupmode == git.BUP_CHUNKED:
315                 self._cached_size = _total_size(self.hash)
316             else:
317                 self._cached_size = _chunk_len(self.hash)
318             log('<<<<File.size() done.\n')
319         return self._cached_size
320
321
322 _symrefs = 0
323 class Symlink(File):
324     """A symbolic link from bup's repository."""
325     def __init__(self, parent, name, hash, bupmode):
326         File.__init__(self, parent, name, 0120000, hash, bupmode)
327
328     def size(self):
329         """Get the file size of the file at which this link points."""
330         return len(self.readlink())
331
332     def readlink(self):
333         """Get the path that this link points at."""
334         return ''.join(cp().join(self.hash.encode('hex')))
335
336     def dereference(self):
337         """Get the node that this link points at.
338
339         If the path is invalid, raise a NoSuchFile exception. If the level of
340         indirection of symlinks is 100 levels deep, raise a TooManySymlinks
341         exception.
342         """
343         global _symrefs
344         if _symrefs > 100:
345             raise TooManySymlinks('too many levels of symlinks: %r'
346                                   % self.fullname())
347         _symrefs += 1
348         try:
349             return self.parent.lresolve(self.readlink(),
350                                         stay_inside_fs=True)
351         except NoSuchFile:
352             raise NoSuchFile("%s: broken symlink to %r"
353                              % (self.fullname(), self.readlink()))
354         finally:
355             _symrefs -= 1
356
357     def _lresolve(self, parts):
358         return self.dereference()._lresolve(parts)
359
360
361 class FakeSymlink(Symlink):
362     """A symlink that is not stored in the bup repository."""
363     def __init__(self, parent, name, toname):
364         Symlink.__init__(self, parent, name, EMPTY_SHA, git.BUP_NORMAL)
365         self.toname = toname
366
367     def readlink(self):
368         """Get the path that this link points at."""
369         return self.toname
370
371
372 class Dir(Node):
373     """A directory stored inside of bup's repository."""
374     def _mksubs(self):
375         self._subs = {}
376         it = cp().get(self.hash.encode('hex'))
377         type = it.next()
378         if type == 'commit':
379             del it
380             it = cp().get(self.hash.encode('hex') + ':')
381             type = it.next()
382         assert(type == 'tree')
383         for (mode,mangled_name,sha) in git.treeparse(''.join(it)):
384             mode = int(mode, 8)
385             name = mangled_name
386             (name,bupmode) = git.demangle_name(mangled_name)
387             if bupmode == git.BUP_CHUNKED:
388                 mode = 0100644
389             if stat.S_ISDIR(mode):
390                 self._subs[name] = Dir(self, name, mode, sha)
391             elif stat.S_ISLNK(mode):
392                 self._subs[name] = Symlink(self, name, sha, bupmode)
393             else:
394                 self._subs[name] = File(self, name, mode, sha, bupmode)
395
396
397 class CommitList(Node):
398     """A reverse-chronological list of commits on a branch in bup's repository.
399
400     Represents each commit as a directory and a symlink that points to the
401     directory. The symlink is named after the date. Prepends a dot to each hash
402     to make commits look like hidden directories.
403     """
404     def __init__(self, parent, name, hash):
405         Node.__init__(self, parent, name, 040000, hash)
406
407     def _mksubs(self):
408         self._subs = {}
409         revs = list(git.rev_list(self.hash.encode('hex')))
410         for (date, commit) in revs:
411             l = time.localtime(date)
412             ls = time.strftime('%Y-%m-%d-%H%M%S', l)
413             commithex = '.' + commit.encode('hex')
414             n1 = Dir(self, commithex, 040000, commit)
415             n2 = FakeSymlink(self, ls, commithex)
416             n1.ctime = n1.mtime = n2.ctime = n2.mtime = date
417             self._subs[commithex] = n1
418             self._subs[ls] = n2
419             latest = max(revs)
420         if latest:
421             (date, commit) = latest
422             commithex = '.' + commit.encode('hex')
423             n2 = FakeSymlink(self, 'latest', commithex)
424             n2.ctime = n2.mtime = date
425             self._subs['latest'] = n2
426
427
428 class RefList(Node):
429     """A list of branches in bup's repository.
430
431     The sub-nodes of the ref list are a series of CommitList for each commit
432     hash pointed to by a branch.
433     """
434     def __init__(self, parent):
435         Node.__init__(self, parent, '/', 040000, EMPTY_SHA)
436
437     def _mksubs(self):
438         self._subs = {}
439         for (name,sha) in git.list_refs():
440             if name.startswith('refs/heads/'):
441                 name = name[11:]
442                 date = git.rev_get_date(sha.encode('hex'))
443                 n1 = CommitList(self, name, sha)
444                 n1.ctime = n1.mtime = date
445                 self._subs[name] = n1
446
447