1#!/usr/bin/env python
2#
3# ====================================================================
4#    Licensed to the Apache Software Foundation (ASF) under one
5#    or more contributor license agreements.  See the NOTICE file
6#    distributed with this work for additional information
7#    regarding copyright ownership.  The ASF licenses this file
8#    to you under the Apache License, Version 2.0 (the
9#    "License"); you may not use this file except in compliance
10#    with the License.  You may obtain a copy of the License at
11#
12#      http://www.apache.org/licenses/LICENSE-2.0
13#
14#    Unless required by applicable law or agreed to in writing,
15#    software distributed under the License is distributed on an
16#    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
17#    KIND, either express or implied.  See the License for the
18#    specific language governing permissions and limitations
19#    under the License.
20# ====================================================================
21
22"""\
23__SCRIPTNAME__: checkout utility for sparse Subversion working copies
24
25Usage: 1. __SCRIPTNAME__ checkout VIEWSPEC-FILE TARGET-DIR
26       2. __SCRIPTNAME__ examine VIEWSPEC-FILE
27       3. __SCRIPTNAME__ help
28       4. __SCRIPTNAME__ help-format
29
30VIEWSPEC-FILE is the path of a file whose contents describe a
31Subversion sparse checkouts layout, or '-' if that description should
32be read from stdin.  TARGET-DIR is the working copy directory created
33by this script as it checks out the specified layout.
34
351. Parse VIEWSPEC-FILE and execute the necessary 'svn' command-line
36   operations to build out a working copy tree at TARGET-DIR.
37
382. Parse VIEWSPEC-FILE and dump out a human-readable representation of
39   the tree described in the specification.
40
413. Show this usage message.
42
434. Show information about the file format this program expects.
44
45"""
46
47FORMAT_HELP = """\
48Viewspec File Format
49====================
50
51The viewspec file format used by this tool is a collection of headers
52(using the typical one-per-line name:value syntax), followed by an
53empty line, followed by a set of one-per-line rules.
54
55The headers must contain at least the following:
56
57    Format   - version of the viewspec format used throughout the file
58    Url      - base URL applied to all rules; tree checkout location
59
60The following headers are optional:
61
62    Revision - version of the tree items to checkout
63
64Following the headers and blank line separator are the path rules.
65The rules are list of URLs -- relative to the base URL stated in the
66headers -- with optional annotations to specify the desired working
67copy depth of each item:
68
69    PATH/**  - checkout PATH and all its children to infinite depth
70    PATH/*   - checkout PATH and its immediate children
71    PATH/~   - checkout PATH and its file children
72    PATH     - checkout PATH non-recursively
73
74By default, the top-level directory (associated with the base URL) is
75checked out with empty depth.  You can override this using the special
76rules '**', '*', and '~' as appropriate.
77
78It is not necessary to explicitly list the parent directories of each
79path associated with a rule.  If the parent directory of a given path
80is not "covered" by a previous rule, it will be checked out with empty
81depth.
82
83Examples
84========
85
86Here's a sample viewspec file:
87
88    Format: 1
89    Url: http://svn.apache.org/repos/asf/subversion
90    Revision: 36366
91
92    trunk/**
93    branches/1.5.x/**
94    branches/1.6.x/**
95    README
96    branches/1.4.x/STATUS
97    branches/1.4.x/subversion/tests/cmdline/~
98
99You may wish to version your viewspec files.  If so, you can use this
100script in conjunction with 'svn cat' to fetch, parse, and act on a
101versioned viewspec file:
102
103    $ svn cat http://svn.example.com/specs/dev-spec.txt |
104         __SCRIPTNAME__ checkout - /path/to/target/directory
105
106"""
107
108#########################################################################
109###  Possible future improvements that could be made:
110###
111###    - support for excluded paths (PATH!)
112###    - support for static revisions of individual paths (PATH@REV/**)
113###
114
115import sys
116import os
117import urllib
118
119DEPTH_EMPTY      = 'empty'
120DEPTH_FILES      = 'files'
121DEPTH_IMMEDIATES = 'immediates'
122DEPTH_INFINITY   = 'infinity'
123
124os_system = None
125args = None
126
127class TreeNode:
128    """A representation of a single node in a Subversion sparse
129    checkout tree."""
130
131    def __init__(self, name, depth):
132        self.name = name    # the basename of this tree item
133        self.depth = depth  # its depth (one of the DEPTH_* values)
134        self.children = {}  # its children (basename -> TreeNode)
135
136    def add_child(self, child_node):
137        child_name = child_node.name
138        assert not self.children.has_key(child_node)
139        self.children[child_name] = child_node
140
141    def dump(self, recurse=False, indent=0):
142        sys.stderr.write(" " * indent)
143        sys.stderr.write("Path: %s (depth=%s)\n" % (self.name, self.depth))
144        if recurse:
145            child_names = self.children.keys()
146            child_names.sort(svn_path_compare_paths)
147            for child_name in child_names:
148                self.children[child_name].dump(recurse, indent + 2)
149
150class SubversionViewspec:
151    """A representation of a Subversion sparse checkout specification."""
152
153    def __init__(self, base_url, revision, tree):
154        self.base_url = base_url  # base URL of the checkout
155        self.revision = revision  # revision of the checkout (-1 == HEAD)
156        self.tree = tree          # the top-most TreeNode item
157
158def svn_path_compare_paths(path1, path2):
159    """Compare PATH1 and PATH2 as paths, sorting depth-first-ily.
160
161    NOTE: Stolen unapologetically from Subversion's Python bindings
162    module svn.core."""
163
164    path1_len = len(path1)
165    path2_len = len(path2)
166    min_len = min(path1_len, path2_len)
167    i = 0
168
169    # Are the paths exactly the same?
170    if path1 == path2:
171        return 0
172
173    # Skip past common prefix
174    while (i < min_len) and (path1[i] == path2[i]):
175        i = i + 1
176
177    # Children of paths are greater than their parents, but less than
178    # greater siblings of their parents
179    char1 = '\0'
180    char2 = '\0'
181    if (i < path1_len):
182        char1 = path1[i]
183    if (i < path2_len):
184        char2 = path2[i]
185
186    if (char1 == '/') and (i == path2_len):
187        return 1
188    if (char2 == '/') and (i == path1_len):
189        return -1
190    if (i < path1_len) and (char1 == '/'):
191        return -1
192    if (i < path2_len) and (char2 == '/'):
193        return 1
194
195    # Common prefix was skipped above, next character is compared to
196    # determine order
197    return cmp(char1, char2)
198
199def parse_viewspec_headers(viewspec_fp):
200    """Parse the headers from the viewspec file, return them as a
201    dictionary mapping header names to values."""
202
203    headers = {}
204    while 1:
205        line = viewspec_fp.readline().strip()
206        if not line:
207            break
208        name, value = [x.strip() for x in line.split(':', 1)]
209        headers[name] = value
210    return headers
211
212def parse_viewspec(viewspec_fp):
213    """Parse the viewspec file, returning a SubversionViewspec object
214    that represents the specification."""
215
216    headers = parse_viewspec_headers(viewspec_fp)
217    format = headers['Format']
218    assert format == '1'
219    base_url = headers['Url']
220    revision = int(headers.get('Revision', -1))
221    root_depth = DEPTH_EMPTY
222    rules = {}
223    while 1:
224        line = viewspec_fp.readline()
225        if not line:
226            break
227        line = line.rstrip()
228
229        # These are special rules for the top-most dir; don't fall thru.
230        if line == '**':
231            root_depth = DEPTH_INFINITY
232            continue
233        elif line == '*':
234            root_depth = DEPTH_IMMEDIATES
235            continue
236        elif line == '~':
237            root_depth = DEPTH_FILES
238            continue
239
240        # These are the regular per-path rules.
241        elif line[-3:] == '/**':
242            depth = DEPTH_INFINITY
243            path = line[:-3]
244        elif line[-2:] == '/*':
245            depth = DEPTH_IMMEDIATES
246            path = line[:-2]
247        elif line[-2:] == '/~':
248            depth = DEPTH_FILES
249            path = line[:-2]
250        else:
251            depth = DEPTH_EMPTY
252            path = line
253
254        # Add our rule to the set thereof.
255        assert not rules.has_key(path)
256        rules[path] = depth
257
258    tree = TreeNode('', root_depth)
259    paths = rules.keys()
260    paths.sort(svn_path_compare_paths)
261    for path in paths:
262        depth = rules[path]
263        path_parts = filter(None, path.split('/'))
264        tree_ptr = tree
265        for part in path_parts[:-1]:
266            child_node = tree_ptr.children.get(part, None)
267            if not child_node:
268                child_node = TreeNode(part, DEPTH_EMPTY)
269                tree_ptr.add_child(child_node)
270            tree_ptr = child_node
271        tree_ptr.add_child(TreeNode(path_parts[-1], depth))
272    return SubversionViewspec(base_url, revision, tree)
273
274def checkout_tree(base_url, revision, tree_node, target_dir, is_top=True):
275    """Checkout from BASE_URL, and into TARGET_DIR, the TREE_NODE
276    sparse checkout item.  IS_TOP is set iff this node represents the
277    root of the checkout tree.  REVISION is the revision to checkout,
278    or -1 if checking out HEAD."""
279
280    depth = tree_node.depth
281    revision_str = ''
282    if revision != -1:
283        revision_str = "--revision=%d " % (revision)
284    if is_top:
285        os_system('svn checkout "%s" "%s" --depth=%s %s'
286                  % (base_url, target_dir, depth, revision_str))
287    else:
288        os_system('svn update "%s" --set-depth=%s %s'
289                  % (target_dir, depth, revision_str))
290    child_names = tree_node.children.keys()
291    child_names.sort(svn_path_compare_paths)
292    for child_name in child_names:
293        checkout_tree(base_url + '/' + child_name,
294                      revision,
295                      tree_node.children[child_name],
296                      os.path.join(target_dir, urllib.unquote(child_name)),
297                      False)
298
299def checkout_spec(viewspec, target_dir):
300    """Checkout the view specification VIEWSPEC into TARGET_DIR."""
301
302    checkout_tree(viewspec.base_url,
303                  viewspec.revision,
304                  viewspec.tree,
305                  target_dir)
306
307def usage_and_exit(errmsg=None):
308    stream = errmsg and sys.stderr or sys.stdout
309    msg = __doc__.replace("__SCRIPTNAME__", os.path.basename(args[0]))
310    stream.write(msg)
311    if errmsg:
312        stream.write("ERROR: %s\n" % (errmsg))
313        return 1
314    return 0
315
316def main(os_sys, args_in):
317    global os_system
318    global args
319    os_system = os_sys
320    args = args_in
321
322    argc = len(args)
323    if argc < 2:
324        return usage_and_exit('Not enough arguments.')
325    subcommand = args[1]
326    if subcommand == 'help':
327        return usage_and_exit()
328    elif subcommand == 'help-format':
329        msg = FORMAT_HELP.replace("__SCRIPTNAME__",
330                                  os.path.basename(args[0]))
331        sys.stdout.write(msg)
332        return 1
333    elif subcommand == 'examine':
334        if argc < 3:
335            return usage_and_exit('No viewspec file specified.')
336        fp = (args[2] == '-') and sys.stdin or open(args[2], 'r')
337        viewspec = parse_viewspec(fp)
338        sys.stdout.write("Url: %s\n" % (viewspec.base_url))
339        revision = viewspec.revision
340        if revision != -1:
341            sys.stdout.write("Revision: %s\n" % (revision))
342        else:
343            sys.stdout.write("Revision: HEAD\n")
344        sys.stdout.write("\n")
345        viewspec.tree.dump(True)
346    elif subcommand == 'checkout':
347        if argc < 3:
348            return usage_and_exit('No viewspec file specified.')
349        if argc < 4:
350            return usage_and_exit('No target directory specified.')
351        fp = (args[2] == '-') and sys.stdin or open(args[2], 'r')
352        checkout_spec(parse_viewspec(fp), args[3])
353    else:
354        return usage_and_exit('Unknown subcommand "%s".' % (subcommand))
355
356if __name__ == "__main__":
357    if main(os.system, sys.argv):
358        sys.exit(1)
359