1# Copyright (C) 2008 Canonical Ltd
2#
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.
7#
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.
12#
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., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
16
17"""View management.
18
19Views are contained within a working tree and normally constructed
20when first accessed.  Clients should do, for example, ...
21
22  tree.views.lookup_view()
23"""
24
25import re
26
27from . import (
28    errors,
29    osutils,
30    )
31
32
33_VIEWS_FORMAT_MARKER_RE = re.compile(b'Bazaar views format (\\d+)')
34_VIEWS_FORMAT1_MARKER = b"Bazaar views format 1\n"
35
36
37class NoSuchView(errors.BzrError):
38    """A view does not exist.
39    """
40
41    _fmt = u"No such view: %(view_name)s."
42
43    def __init__(self, view_name):
44        self.view_name = view_name
45
46
47class ViewsNotSupported(errors.BzrError):
48    """Views are not supported by a tree format.
49    """
50
51    _fmt = ("Views are not supported by %(tree)s;"
52            " use 'brz upgrade' to change your tree to a later format.")
53
54    def __init__(self, tree):
55        self.tree = tree
56
57
58class FileOutsideView(errors.BzrError):
59
60    _fmt = ('Specified file "%(file_name)s" is outside the current view: '
61            '%(view_str)s')
62
63    def __init__(self, file_name, view_files):
64        self.file_name = file_name
65        self.view_str = ", ".join(view_files)
66
67
68class _Views(object):
69    """Base class for View managers."""
70
71    def supports_views(self):
72        raise NotImplementedError(self.supports_views)
73
74
75class PathBasedViews(_Views):
76    """View storage in an unversioned tree control file.
77
78    Views are stored in terms of paths relative to the tree root.
79
80    The top line of the control file is a format marker in the format:
81
82      Bazaar views format X
83
84    where X is an integer number. After this top line, version 1 format is
85    stored as follows:
86
87     * optional name-values pairs in the format 'name=value'
88
89     * optional view definitions, one per line in the format
90
91       views:
92       name file1 file2 ...
93       name file1 file2 ...
94
95    where the fields are separated by a nul character (\0). The views file
96    is encoded in utf-8. The only supported keyword in version 1 is
97    'current' which stores the name of the current view, if any.
98    """
99
100    def __init__(self, tree):
101        self.tree = tree
102        self._loaded = False
103        self._current = None
104        self._views = {}
105
106    def supports_views(self):
107        return True
108
109    def get_view_info(self):
110        """Get the current view and dictionary of views.
111
112        :return: current, views where
113          current = the name of the current view or None if no view is enabled
114          views = a map from view name to list of files/directories
115        """
116        self._load_view_info()
117        return self._current, self._views
118
119    def set_view_info(self, current, views):
120        """Set the current view and dictionary of views.
121
122        :param current: the name of the current view or None if no view is
123          enabled
124        :param views: a map from view name to list of files/directories
125        """
126        if current is not None and current not in views:
127            raise NoSuchView(current)
128        with self.tree.lock_write():
129            self._current = current
130            self._views = views
131            self._save_view_info()
132
133    def lookup_view(self, view_name=None):
134        """Return the contents of a view.
135
136        :param view_Name: name of the view or None to lookup the current view
137        :return: the list of files/directories in the requested view
138        """
139        self._load_view_info()
140        try:
141            if view_name is None:
142                if self._current:
143                    view_name = self._current
144                else:
145                    return []
146            return self._views[view_name]
147        except KeyError:
148            raise NoSuchView(view_name)
149
150    def set_view(self, view_name, view_files, make_current=True):
151        """Add or update a view definition.
152
153        :param view_name: the name of the view
154        :param view_files: the list of files/directories in the view
155        :param make_current: make this view the current one or not
156        """
157        with self.tree.lock_write():
158            self._load_view_info()
159            self._views[view_name] = view_files
160            if make_current:
161                self._current = view_name
162            self._save_view_info()
163
164    def delete_view(self, view_name):
165        """Delete a view definition.
166
167        If the view deleted is the current one, the current view is reset.
168        """
169        with self.tree.lock_write():
170            self._load_view_info()
171            try:
172                del self._views[view_name]
173            except KeyError:
174                raise NoSuchView(view_name)
175            if view_name == self._current:
176                self._current = None
177            self._save_view_info()
178
179    def _save_view_info(self):
180        """Save the current view and all view definitions.
181
182        Be sure to have initialised self._current and self._views before
183        calling this method.
184        """
185        with self.tree.lock_write():
186            if self._current is None:
187                keywords = {}
188            else:
189                keywords = {'current': self._current}
190            self.tree._transport.put_bytes(
191                'views', self._serialize_view_content(keywords, self._views))
192
193    def _load_view_info(self):
194        """Load the current view and dictionary of view definitions."""
195        if not self._loaded:
196            with self.tree.lock_read():
197                try:
198                    view_content = self.tree._transport.get_bytes('views')
199                except errors.NoSuchFile:
200                    self._current, self._views = None, {}
201                else:
202                    keywords, self._views = \
203                        self._deserialize_view_content(view_content)
204                    self._current = keywords.get('current')
205            self._loaded = True
206
207    def _serialize_view_content(self, keywords, view_dict):
208        """Convert view keywords and a view dictionary into a stream."""
209        lines = [_VIEWS_FORMAT1_MARKER]
210        for key in keywords:
211            line = "%s=%s\n" % (key, keywords[key])
212            lines.append(line.encode('utf-8'))
213        if view_dict:
214            lines.append("views:\n".encode('utf-8'))
215            for view in sorted(view_dict):
216                view_data = "%s\0%s\n" % (view, "\0".join(view_dict[view]))
217                lines.append(view_data.encode('utf-8'))
218        return b"".join(lines)
219
220    def _deserialize_view_content(self, view_content):
221        """Convert a stream into view keywords and a dictionary of views."""
222        # as a special case to make initialization easy, an empty definition
223        # maps to no current view and an empty view dictionary
224        if view_content == b'':
225            return {}, {}
226        lines = view_content.splitlines()
227        match = _VIEWS_FORMAT_MARKER_RE.match(lines[0])
228        if not match:
229            raise ValueError(
230                "format marker missing from top of views file")
231        elif match.group(1) != b'1':
232            raise ValueError(
233                "cannot decode views format %s" % match.group(1))
234        try:
235            keywords = {}
236            views = {}
237            in_views = False
238            for line in lines[1:]:
239                text = line.decode('utf-8')
240                if in_views:
241                    parts = text.split('\0')
242                    view = parts.pop(0)
243                    views[view] = parts
244                elif text == 'views:':
245                    in_views = True
246                    continue
247                elif text.find('=') >= 0:
248                    # must be a name-value pair
249                    keyword, value = text.split('=', 1)
250                    keywords[keyword] = value
251                else:
252                    raise ValueError("failed to deserialize views line %s",
253                                     text)
254            return keywords, views
255        except ValueError as e:
256            raise ValueError("failed to deserialize views content %r: %s"
257                             % (view_content, e))
258
259
260class DisabledViews(_Views):
261    """View storage that refuses to store anything.
262
263    This is used by older formats that can't store views.
264    """
265
266    def __init__(self, tree):
267        self.tree = tree
268
269    def supports_views(self):
270        return False
271
272    def _not_supported(self, *a, **k):
273        raise ViewsNotSupported(self.tree)
274
275    get_view_info = _not_supported
276    set_view_info = _not_supported
277    lookup_view = _not_supported
278    set_view = _not_supported
279    delete_view = _not_supported
280
281
282def view_display_str(view_files, encoding=None):
283    """Get the display string for a list of view files.
284
285    :param view_files: the list of file names
286    :param encoding: the encoding to display the files in
287    """
288    if encoding is None:
289        return ", ".join(view_files)
290    else:
291        return ", ".join([v.encode(encoding, 'replace') for v in view_files])
292
293
294def check_path_in_view(tree, relpath):
295    """If a working tree has a view enabled, check the path is within it."""
296    if tree.supports_views():
297        view_files = tree.views.lookup_view()
298        if view_files and not osutils.is_inside_any(view_files, relpath):
299            raise FileOutsideView(relpath, view_files)
300