1
 
# (C) 2005 Canonical Ltd
 
3
 
# This program is free software; you can redistribute it and/or modify
 
4
 
# it under the terms of the GNU General Public License as published by
 
5
 
# the Free Software Foundation; either version 2 of the License, or
 
6
 
# (at your option) any later version.
 
8
 
# This program is distributed in the hope that it will be useful,
 
9
 
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
10
 
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
11
 
# GNU General Public License for more details.
 
13
 
# You should have received a copy of the GNU General Public License
 
14
 
# along with this program; if not, write to the Free Software
 
15
 
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 
17
 
"""Inventories map files to their name in a revision."""
 
19
 
# TODO: Maybe store inventory_id in the file?  Not really needed.
 
21
 
__copyright__ = "Copyright (C) 2005 Canonical Ltd."
 
22
 
__author__ = "Martin Pool <mbp@canonical.com>"
 
24
 
import sys, os.path, types
 
28
 
    from cElementTree import Element, ElementTree, SubElement
 
30
 
    from elementtree.ElementTree import Element, ElementTree, SubElement
 
32
 
from xml import XMLMixin
 
33
 
from errors import bailout
 
36
 
from bzrlib.osutils import uuid, quotefn, splitpath, joinpath, appendpath
 
37
 
from bzrlib.trace import mutter
 
39
 
class InventoryEntry(XMLMixin):
 
40
 
    """Description of a versioned file.
 
42
 
    An InventoryEntry has the following fields, which are also
 
43
 
    present in the XML inventory-entry element:
 
46
 
    * *name*: (only the basename within the directory, must not
 
48
 
    * *kind*: "directory" or "file"
 
49
 
    * *directory_id*: (if absent/null means the branch root directory)
 
50
 
    * *text_sha1*: only for files
 
51
 
    * *text_size*: in bytes, only for files 
 
52
 
    * *text_id*: identifier for the text version, only for files
 
54
 
    InventoryEntries can also exist inside a WorkingTree
 
55
 
    inventory, in which case they are not yet bound to a
 
56
 
    particular revision of the file.  In that case the text_sha1,
 
57
 
    text_size and text_id are absent.
 
62
 
    >>> i.add(InventoryEntry('123', 'src', kind='directory'))
 
63
 
    >>> i.add(InventoryEntry('2323', 'hello.c', parent_id='123'))
 
64
 
    >>> for j in i.iter_entries():
 
67
 
    ('src', InventoryEntry('123', 'src', kind='directory', parent_id=None))
 
68
 
    ('src/hello.c', InventoryEntry('2323', 'hello.c', kind='file', parent_id='123'))
 
69
 
    >>> i.add(InventoryEntry('2323', 'bye.c', parent_id='123'))
 
70
 
    Traceback (most recent call last):
 
72
 
    BzrError: ('inventory already contains entry with id {2323}', [])
 
73
 
    >>> i.add(InventoryEntry('2324', 'bye.c', parent_id='123'))
 
74
 
    >>> i.add(InventoryEntry('2325', 'wibble', parent_id='123', kind='directory'))
 
75
 
    >>> i.path2id('src/wibble')
 
79
 
    >>> i.add(InventoryEntry('2326', 'wibble.c', parent_id='2325'))
 
81
 
    InventoryEntry('2326', 'wibble.c', kind='file', parent_id='2325')
 
82
 
    >>> for j in i.iter_entries():
 
84
 
    ...     assert i.path2id(j[0])
 
94
 
    :todo: Maybe also keep the full path of the entry, and the children?
 
95
 
           But those depend on its position within a particular inventory, and
 
96
 
           it would be nice not to need to hold the backpointer here.
 
98
 
    def __init__(self, file_id, name, kind='file', text_id=None,
 
100
 
        """Create an InventoryEntry
 
102
 
        The filename must be a single component, relative to the
 
103
 
        parent directory; it cannot be a whole path or relative name.
 
105
 
        >>> e = InventoryEntry('123', 'hello.c')
 
110
 
        >>> e = InventoryEntry('123', 'src/hello.c')
 
111
 
        Traceback (most recent call last):
 
112
 
        BzrError: ("InventoryEntry name is not a simple filename: 'src/hello.c'", [])
 
115
 
        if len(splitpath(name)) != 1:
 
116
 
            bailout('InventoryEntry name is not a simple filename: %r'
 
119
 
        self.file_id = file_id
 
121
 
        assert kind in ['file', 'directory']
 
123
 
        self.text_id = text_id
 
124
 
        self.parent_id = parent_id
 
125
 
        self.text_sha1 = None
 
126
 
        self.text_size = None
 
127
 
        if kind == 'directory':
 
132
 
        other = InventoryEntry(self.file_id, self.name, self.kind,
 
133
 
                               self.text_id, self.parent_id)
 
134
 
        other.text_sha1 = self.text_sha1
 
135
 
        other.text_size = self.text_size
 
140
 
        return ("%s(%r, %r, kind=%r, parent_id=%r)"
 
141
 
                % (self.__class__.__name__,
 
148
 
    def to_element(self):
 
149
 
        """Convert to XML element"""
 
152
 
        e.set('name', self.name)
 
153
 
        e.set('file_id', self.file_id)
 
154
 
        e.set('kind', self.kind)
 
156
 
        if self.text_size is not None:
 
157
 
            e.set('text_size', '%d' % self.text_size)
 
159
 
        for f in ['text_id', 'text_sha1', 'parent_id']:
 
169
 
    def from_element(cls, elt):
 
170
 
        assert elt.tag == 'entry'
 
171
 
        self = cls(elt.get('file_id'), elt.get('name'), elt.get('kind'))
 
172
 
        self.text_id = elt.get('text_id')
 
173
 
        self.text_sha1 = elt.get('text_sha1')
 
174
 
        self.parent_id = elt.get('parent_id')
 
176
 
        ## mutter("read inventoryentry: %r" % (elt.attrib))
 
178
 
        v = elt.get('text_size')
 
179
 
        self.text_size = v and int(v)
 
184
 
    from_element = classmethod(from_element)
 
186
 
    def __cmp__(self, other):
 
189
 
        if not isinstance(other, InventoryEntry):
 
190
 
            return NotImplemented
 
192
 
        return cmp(self.file_id, other.file_id) \
 
193
 
               or cmp(self.name, other.name) \
 
194
 
               or cmp(self.text_sha1, other.text_sha1) \
 
195
 
               or cmp(self.text_size, other.text_size) \
 
196
 
               or cmp(self.text_id, other.text_id) \
 
197
 
               or cmp(self.parent_id, other.parent_id) \
 
198
 
               or cmp(self.kind, other.kind)
 
202
 
class Inventory(XMLMixin):
 
203
 
    """Inventory of versioned files in a tree.
 
205
 
    An Inventory acts like a set of InventoryEntry items.  You can
 
206
 
    also look files up by their file_id or name.
 
208
 
    May be read from and written to a metadata file in a tree.  To
 
209
 
    manipulate the inventory (for example to add a file), it is read
 
210
 
    in, modified, and then written back out.
 
212
 
    The inventory represents a typical unix file tree, with
 
213
 
    directories containing files and subdirectories.  We never store
 
214
 
    the full path to a file, because renaming a directory implicitly
 
215
 
    moves all of its contents.  This class internally maintains a
 
216
 
    lookup tree that allows the children under a directory to be
 
219
 
    InventoryEntry objects must not be modified after they are
 
222
 
    >>> inv = Inventory()
 
223
 
    >>> inv.write_xml(sys.stdout)
 
226
 
    >>> inv.add(InventoryEntry('123-123', 'hello.c'))
 
227
 
    >>> inv['123-123'].name
 
230
 
    May be treated as an iterator or set to look up file ids:
 
232
 
    >>> bool(inv.path2id('hello.c'))
 
237
 
    May also look up by name:
 
239
 
    >>> [x[0] for x in inv.iter_entries()]
 
242
 
    >>> inv.write_xml(sys.stdout)
 
244
 
    <entry file_id="123-123" kind="file" name="hello.c" />
 
249
 
    ## TODO: Make sure only canonical filenames are stored.
 
251
 
    ## TODO: Do something sensible about the possible collisions on
 
252
 
    ## case-losing filesystems.  Perhaps we should just always forbid
 
255
 
    ## TODO: No special cases for root, rather just give it a file id
 
256
 
    ## like everything else.
 
258
 
    ## TODO: Probably change XML serialization to use nesting
 
261
 
        """Create or read an inventory.
 
263
 
        If a working directory is specified, the inventory is read
 
264
 
        from there.  If the file is specified, read from that. If not,
 
265
 
        the inventory is created empty.
 
267
 
        self._root = InventoryEntry(None, '', kind='directory')
 
268
 
        self._byid = {None: self._root}
 
272
 
        return iter(self._byid)
 
276
 
        """Returns number of entries."""
 
277
 
        return len(self._byid)
 
280
 
    def iter_entries(self, parent_id=None):
 
281
 
        """Return (path, entry) pairs, in order by name."""
 
282
 
        kids = self[parent_id].children.items()
 
284
 
        for name, ie in kids:
 
286
 
            if ie.kind == 'directory':
 
287
 
                for cn, cie in self.iter_entries(parent_id=ie.file_id):
 
288
 
                    yield joinpath([name, cn]), cie
 
291
 
    def directories(self):
 
292
 
        """Return (path, entry) pairs for all directories.
 
295
 
        for path, entry in self.iter_entries():
 
296
 
            if entry.kind == 'directory':
 
301
 
    def __contains__(self, file_id):
 
302
 
        """True if this entry contains a file with given id.
 
304
 
        >>> inv = Inventory()
 
305
 
        >>> inv.add(InventoryEntry('123', 'foo.c'))
 
311
 
        return file_id in self._byid
 
314
 
    def __getitem__(self, file_id):
 
315
 
        """Return the entry for given file_id.
 
317
 
        >>> inv = Inventory()
 
318
 
        >>> inv.add(InventoryEntry('123123', 'hello.c'))
 
319
 
        >>> inv['123123'].name
 
322
 
        return self._byid[file_id]
 
325
 
    def get_child(self, parent_id, filename):
 
326
 
        if parent_id == None:
 
327
 
            return self._root.children.get(filename)
 
329
 
            return self[parent_id].children.get(filename)
 
332
 
    def add(self, entry):
 
333
 
        """Add entry to inventory.
 
335
 
        To add  a file to a branch ready to be committed, use Branch.add,
 
337
 
        if entry.file_id in self._byid:
 
338
 
            bailout("inventory already contains entry with id {%s}" % entry.file_id)
 
340
 
        parent = self._byid[entry.parent_id]
 
341
 
        if parent.kind != 'directory':
 
342
 
            bailout("attempt to add under non-directory {%s}" % parent.file_id)
 
344
 
        if parent.children.has_key(entry.name):
 
345
 
            bailout("%s is already versioned" %
 
346
 
                    appendpath(self.id2path(parent.file_id), entry.name))
 
348
 
        self._byid[entry.file_id] = entry
 
349
 
        parent.children[entry.name] = entry
 
352
 
    def add_path(self, relpath, kind, file_id=None):
 
353
 
        """Add entry from a path.
 
355
 
        The immediate parent must already be versioned"""
 
356
 
        parts = bzrlib.osutils.splitpath(relpath)
 
358
 
            bailout("cannot re-add root of inventory")
 
361
 
            file_id = bzrlib.branch.gen_file_id(relpath)
 
363
 
        parent_id = self.path2id(parts[:-1])
 
364
 
        ie = InventoryEntry(file_id, parts[-1],
 
365
 
                            kind=kind, parent_id=parent_id)
 
369
 
    def __delitem__(self, file_id):
 
370
 
        """Remove entry by id.
 
372
 
        >>> inv = Inventory()
 
373
 
        >>> inv.add(InventoryEntry('123', 'foo.c'))
 
382
 
        assert self[ie.parent_id].children[ie.name] == ie
 
384
 
        # TODO: Test deleting all children; maybe hoist to a separate
 
386
 
        if ie.kind == 'directory':
 
387
 
            for cie in ie.children.values():
 
388
 
                del self[cie.file_id]
 
391
 
        del self._byid[file_id]
 
392
 
        del self[ie.parent_id].children[ie.name]
 
396
 
        return Set(self._byid)
 
399
 
    def to_element(self):
 
400
 
        """Convert to XML Element"""
 
401
 
        e = Element('inventory')
 
403
 
        for path, ie in self.iter_entries():
 
404
 
            e.append(ie.to_element())
 
408
 
    def from_element(cls, elt):
 
409
 
        """Construct from XML Element
 
411
 
        >>> inv = Inventory()
 
412
 
        >>> inv.add(InventoryEntry('foo.c-123981239', 'foo.c'))
 
413
 
        >>> elt = inv.to_element()
 
414
 
        >>> inv2 = Inventory.from_element(elt)
 
418
 
        assert elt.tag == 'inventory'
 
421
 
            o.add(InventoryEntry.from_element(e))
 
424
 
    from_element = classmethod(from_element)
 
427
 
    def __cmp__(self, other):
 
428
 
        """Compare two sets by comparing their contents.
 
434
 
        >>> i1.add(InventoryEntry('123', 'foo'))
 
437
 
        >>> i2.add(InventoryEntry('123', 'foo'))
 
444
 
        if not isinstance(other, Inventory):
 
445
 
            return NotImplemented
 
447
 
        if self.id_set() ^ other.id_set():
 
450
 
        for file_id in self._byid:
 
451
 
            c = cmp(self[file_id], other[file_id])
 
457
 
    def id2path(self, file_id):
 
458
 
        """Return as a list the path to file_id."""
 
460
 
        while file_id != None:
 
461
 
            ie = self._byid[file_id]
 
463
 
            file_id = ie.parent_id
 
468
 
    def path2id(self, name):
 
469
 
        """Walk down through directories to return entry of last component.
 
471
 
        names may be either a list of path components, or a single
 
472
 
        string, in which case it is automatically split.
 
474
 
        This returns the entry of the last component in the path,
 
475
 
        which may be either a file or a directory.
 
477
 
        if isinstance(name, types.StringTypes):
 
478
 
            name = splitpath(name)
 
483
 
                cie = parent.children[f]
 
490
 
        return parent.file_id
 
493
 
    def has_filename(self, names):
 
494
 
        return bool(self.path2id(names))
 
497
 
    def has_id(self, file_id):
 
498
 
        return self._byid.has_key(file_id)
 
504
 
if __name__ == '__main__':
 
505
 
    import doctest, inventory
 
506
 
    doctest.testmod(inventory)