diff options
author | Lorry <lorry@roadtrain.codethink.co.uk> | 2012-08-22 15:47:16 +0100 |
---|---|---|
committer | Lorry <lorry@roadtrain.codethink.co.uk> | 2012-08-22 15:47:16 +0100 |
commit | 25335618bf8755ce6b116ee14f47f5a1f2c821e9 (patch) | |
tree | d889d7ab3f9f985d0c54c534cb8052bd2e6d7163 /bzrlib/plugins | |
download | bzr-tarball-25335618bf8755ce6b116ee14f47f5a1f2c821e9.tar.gz |
Tarball conversion
Diffstat (limited to 'bzrlib/plugins')
49 files changed, 10657 insertions, 0 deletions
diff --git a/bzrlib/plugins/__init__.py b/bzrlib/plugins/__init__.py new file mode 100644 index 0000000..efcb160 --- /dev/null +++ b/bzrlib/plugins/__init__.py @@ -0,0 +1,19 @@ +# Copyright (C) 2006 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Null placeholder plugin""" +from __future__ import absolute_import + diff --git a/bzrlib/plugins/bash_completion/README.txt b/bzrlib/plugins/bash_completion/README.txt new file mode 100644 index 0000000..3bd1355 --- /dev/null +++ b/bzrlib/plugins/bash_completion/README.txt @@ -0,0 +1,143 @@ +.. comment + + Copyright (C) 2010 Canonical Ltd + + This file is part of bzr-bash-completion + + bzr-bash-completion free software: you can redistribute it and/or + modify it under the terms of the GNU General Public License as + published by the Free Software Foundation, either version 2 of the + License, or (at your option) any later version. + + bzr-bash-completion is distributed in the hope that it will be + useful, but WITHOUT ANY WARRANTY; without even the implied warranty + of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. + +========================== +bzr bash-completion plugin +========================== + +This plugin generates a shell function which can be used by bash to +automatically complete the currently typed command when the user +presses the completion key (usually tab). + +It is intended as a bzr plugin, but can be used to some extend as a +standalone python script as well. + +| Copyright (C) 2009, 2010 Canonical Ltd + +.. contents:: + +------------------------------- +Bundled and standalone versions +------------------------------- + +This plugin has been merged_ into the main source tree of Bazaar. +Starting with the bzr 2.3 series, a common bzr installation will +include this plugin. + +There is still a standalone version available. It makes the plugin +available for users of older bzr versions. When using both versions, +local configuration might determine which version actually gets used, +and some installations might even overwrite one another, so don't use +the standalone version if you have the bundled one, unless you know +what you are doing. Some effort will be made to keep the two versions +reasonably in sync for some time yet. + +This text here documents the bundled version. + +.. _merged: http://bazaar.launchpad.net/~bzr-pqm/bzr/bzr.dev/revision/5240 + +----- +Using +----- + +Using as a plugin +----------------- + +This is the preferred method of generating the completion function, as +it will ensure proper bzr initialization. + +:: + + eval "`bzr bash-completion`" + +Lazy initialization +------------------- + +Running the above command automatically from your ``~/.bashrc`` file +or similar can cause annoying delays in the startup of your shell. +To avoid this problem, you can delay the generation of the completion +function until you actually need it. + +To do so, source the file ``contrib/bash/bzr`` shipped with the bzr +source distribution from your ``~/.bashrc`` file +or add it to your ``~/.bash_completion`` if +your setup uses such a file. On a system-wide installation, the +directory ``/usr/share/bash-completion/`` might contain such bash +completion scripts. + +Note that the full completion function is generated only once per +shell session. If you update your bzr installation or change the set +of installed plugins, then you might wish to regenerate the completion +function manually as described above in order for completion to take +these changes into account. + +-------------- +Design concept +-------------- + +The plugin is designed to generate a completion function +containing all the required information about the possible +completions. This is usually only done once when bash +initializes. After that, no more invocations of bzr are required. This +makes the function much faster than a possible implementation talking +to bzr for each and every completion. On the other hand, this has the +effect that updates to bzr or its plugins won't show up in the +completions immediately, but only after the completion function has +been regenerated. + +------- +License +------- + +As this is built upon a bash completion script originally included in +the bzr source tree, and as the bzr sources are covered by the GPL 2, +this plugin here is licensed under these same terms. + +If you require a more liberal license, you'll have to contact all +those who contributed code to this plugin, be it for bash or for +python. + +------- +History +------- + +The plugin was created by Martin von Gagern in 2009, building on a +static completion function of very limited scope distributed together +with bzr. + +A version of it was merged into the bzr source tree in May 2010. + +---------- +References +---------- + +Bazaar homepage + | http://bazaar.canonical.com/ +Standalone plugin homepages + | https://launchpad.net/bzr-bash-completion + | http://pypi.python.org/pypi/bzr-bash-completion + + + +.. vim: ft=rst + +.. emacs + Local Variables: + mode: rst + End: diff --git a/bzrlib/plugins/bash_completion/__init__.py b/bzrlib/plugins/bash_completion/__init__.py new file mode 100644 index 0000000..64d506f --- /dev/null +++ b/bzrlib/plugins/bash_completion/__init__.py @@ -0,0 +1,41 @@ +# Copyright (C) 2009, 2010 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +from __future__ import absolute_import + +__doc__ = """Generate a shell function for bash command line completion. + +This plugin provides a command called bash-completion that generates a +bash completion function for bzr. See its documentation for details. +""" + +from bzrlib import commands, version_info + + +bzr_plugin_name = 'bash_completion' +bzr_commands = [ 'bash-completion' ] + +commands.plugin_cmds.register_lazy('cmd_bash_completion', [], + 'bzrlib.plugins.bash_completion.bashcomp') + + +def load_tests(basic_tests, module, loader): + testmod_names = [ + 'tests', + ] + basic_tests.addTest(loader.loadTestsFromModuleNames( + ["%s.%s" % (__name__, tmn) for tmn in testmod_names])) + return basic_tests diff --git a/bzrlib/plugins/bash_completion/bashcomp.py b/bzrlib/plugins/bash_completion/bashcomp.py new file mode 100644 index 0000000..52d740e --- /dev/null +++ b/bzrlib/plugins/bash_completion/bashcomp.py @@ -0,0 +1,482 @@ +#!/usr/bin/env python + +# Copyright (C) 2009, 2010 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +from __future__ import absolute_import + +from bzrlib import ( + cmdline, + commands, + config, + help_topics, + option, + plugin, +) +import bzrlib +import re +import sys + + +class BashCodeGen(object): + """Generate a bash script for given completion data.""" + + def __init__(self, data, function_name='_bzr', debug=False): + self.data = data + self.function_name = function_name + self.debug = debug + + def script(self): + return ("""\ +# Programmable completion for the Bazaar-NG bzr command under bash. +# Known to work with bash 2.05a as well as bash 4.1.2, and probably +# all versions in between as well. + +# Based originally on the svn bash completition script. +# Customized by Sven Wilhelm/Icecrash.com +# Adjusted for automatic generation by Martin von Gagern + +# Generated using the bash_completion plugin. +# See https://launchpad.net/bzr-bash-completion for details. + +# Commands and options of bzr %(bzr_version)s + +shopt -s progcomp +%(function)s +complete -F %(function_name)s -o default bzr +""" % { + "function_name": self.function_name, + "function": self.function(), + "bzr_version": self.bzr_version(), + }) + + def function(self): + return ("""\ +%(function_name)s () +{ + local cur cmds cmdIdx cmd cmdOpts fixedWords i globalOpts + local curOpt optEnums + local IFS=$' \\n' + + COMPREPLY=() + cur=${COMP_WORDS[COMP_CWORD]} + + cmds='%(cmds)s' + globalOpts=( %(global_options)s ) + + # do ordinary expansion if we are anywhere after a -- argument + for ((i = 1; i < COMP_CWORD; ++i)); do + [[ ${COMP_WORDS[i]} == "--" ]] && return 0 + done + + # find the command; it's the first word not starting in - + cmd= + for ((cmdIdx = 1; cmdIdx < ${#COMP_WORDS[@]}; ++cmdIdx)); do + if [[ ${COMP_WORDS[cmdIdx]} != -* ]]; then + cmd=${COMP_WORDS[cmdIdx]} + break + fi + done + + # complete command name if we are not already past the command + if [[ $COMP_CWORD -le cmdIdx ]]; then + COMPREPLY=( $( compgen -W "$cmds ${globalOpts[*]}" -- $cur ) ) + return 0 + fi + + # find the option for which we want to complete a value + curOpt= + if [[ $cur != -* ]] && [[ $COMP_CWORD -gt 1 ]]; then + curOpt=${COMP_WORDS[COMP_CWORD - 1]} + if [[ $curOpt == = ]]; then + curOpt=${COMP_WORDS[COMP_CWORD - 2]} + elif [[ $cur == : ]]; then + cur= + curOpt="$curOpt:" + elif [[ $curOpt == : ]]; then + curOpt=${COMP_WORDS[COMP_CWORD - 2]}: + fi + fi +%(debug)s + cmdOpts=( ) + optEnums=( ) + fixedWords=( ) + case $cmd in +%(cases)s\ + *) + cmdOpts=(--help -h) + ;; + esac + + IFS=$'\\n' + if [[ ${#fixedWords[@]} -eq 0 ]] && [[ ${#optEnums[@]} -eq 0 ]] && [[ $cur != -* ]]; then + case $curOpt in + tag:|*..tag:) + fixedWords=( $(bzr tags 2>/dev/null | sed 's/ *[^ ]*$//; s/ /\\\\\\\\ /g;') ) + ;; + esac + case $cur in + [\\"\\']tag:*) + fixedWords=( $(bzr tags 2>/dev/null | sed 's/ *[^ ]*$//; s/^/tag:/') ) + ;; + [\\"\\']*..tag:*) + fixedWords=( $(bzr tags 2>/dev/null | sed 's/ *[^ ]*$//') ) + fixedWords=( $(for i in "${fixedWords[@]}"; do echo "${cur%%..tag:*}..tag:${i}"; done) ) + ;; + esac + elif [[ $cur == = ]] && [[ ${#optEnums[@]} -gt 0 ]]; then + # complete directly after "--option=", list all enum values + COMPREPLY=( "${optEnums[@]}" ) + return 0 + else + fixedWords=( "${cmdOpts[@]}" + "${globalOpts[@]}" + "${optEnums[@]}" + "${fixedWords[@]}" ) + fi + + if [[ ${#fixedWords[@]} -gt 0 ]]; then + COMPREPLY=( $( compgen -W "${fixedWords[*]}" -- $cur ) ) + fi + + return 0 +} +""" % { + "cmds": self.command_names(), + "function_name": self.function_name, + "cases": self.command_cases(), + "global_options": self.global_options(), + "debug": self.debug_output(), + }) + # Help Emacs terminate strings: " + + def command_names(self): + return " ".join(self.data.all_command_aliases()) + + def debug_output(self): + if not self.debug: + return '' + else: + return (r""" + # Debugging code enabled using the --debug command line switch. + # Will dump some variables to the top portion of the terminal. + echo -ne '\e[s\e[H' + for (( i=0; i < ${#COMP_WORDS[@]}; ++i)); do + echo "\$COMP_WORDS[$i]='${COMP_WORDS[i]}'"$'\e[K' + done + for i in COMP_CWORD COMP_LINE COMP_POINT COMP_TYPE COMP_KEY cur curOpt; do + echo "\$${i}=\"${!i}\""$'\e[K' + done + echo -ne '---\e[K\e[u' +""") + + def bzr_version(self): + bzr_version = bzrlib.version_string + if not self.data.plugins: + bzr_version += "." + else: + bzr_version += " and the following plugins:" + for name, plugin in sorted(self.data.plugins.iteritems()): + bzr_version += "\n# %s" % plugin + return bzr_version + + def global_options(self): + return " ".join(sorted(self.data.global_options)) + + def command_cases(self): + cases = "" + for command in self.data.commands: + cases += self.command_case(command) + return cases + + def command_case(self, command): + case = "\t%s)\n" % "|".join(command.aliases) + if command.plugin: + case += "\t\t# plugin \"%s\"\n" % command.plugin + options = [] + enums = [] + for option in command.options: + for message in option.error_messages: + case += "\t\t# %s\n" % message + if option.registry_keys: + for key in option.registry_keys: + options.append("%s=%s" % (option, key)) + enums.append("%s) optEnums=( %s ) ;;" % + (option, ' '.join(option.registry_keys))) + else: + options.append(str(option)) + case += "\t\tcmdOpts=( %s )\n" % " ".join(options) + if command.fixed_words: + fixed_words = command.fixed_words + if isinstance(fixed_words, list): + fixed_words = "( %s )" + ' '.join(fixed_words) + case += "\t\tfixedWords=%s\n" % fixed_words + if enums: + case += "\t\tcase $curOpt in\n\t\t\t" + case += "\n\t\t\t".join(enums) + case += "\n\t\tesac\n" + case += "\t\t;;\n" + return case + + +class CompletionData(object): + + def __init__(self): + self.plugins = {} + self.global_options = set() + self.commands = [] + + def all_command_aliases(self): + for c in self.commands: + for a in c.aliases: + yield a + + +class CommandData(object): + + def __init__(self, name): + self.name = name + self.aliases = [name] + self.plugin = None + self.options = [] + self.fixed_words = None + + +class PluginData(object): + + def __init__(self, name, version=None): + if version is None: + try: + version = bzrlib.plugin.plugins()[name].__version__ + except: + version = 'unknown' + self.name = name + self.version = version + + def __str__(self): + if self.version == 'unknown': + return self.name + return '%s %s' % (self.name, self.version) + + +class OptionData(object): + + def __init__(self, name): + self.name = name + self.registry_keys = None + self.error_messages = [] + + def __str__(self): + return self.name + + def __cmp__(self, other): + return cmp(self.name, other.name) + + +class DataCollector(object): + + def __init__(self, no_plugins=False, selected_plugins=None): + self.data = CompletionData() + self.user_aliases = {} + if no_plugins: + self.selected_plugins = set() + elif selected_plugins is None: + self.selected_plugins = None + else: + self.selected_plugins = set([x.replace('-', '_') + for x in selected_plugins]) + + def collect(self): + self.global_options() + self.aliases() + self.commands() + return self.data + + def global_options(self): + re_switch = re.compile(r'\n(--[A-Za-z0-9-_]+)(?:, (-\S))?\s') + help_text = help_topics.topic_registry.get_detail('global-options') + for long, short in re_switch.findall(help_text): + self.data.global_options.add(long) + if short: + self.data.global_options.add(short) + + def aliases(self): + for alias, expansion in config.GlobalConfig().get_aliases().iteritems(): + for token in cmdline.split(expansion): + if not token.startswith("-"): + self.user_aliases.setdefault(token, set()).add(alias) + break + + def commands(self): + for name in sorted(commands.all_command_names()): + self.command(name) + + def command(self, name): + cmd = commands.get_cmd_object(name) + cmd_data = CommandData(name) + + plugin_name = cmd.plugin_name() + if plugin_name is not None: + if (self.selected_plugins is not None and + plugin not in self.selected_plugins): + return None + plugin_data = self.data.plugins.get(plugin_name) + if plugin_data is None: + plugin_data = PluginData(plugin_name) + self.data.plugins[plugin_name] = plugin_data + cmd_data.plugin = plugin_data + self.data.commands.append(cmd_data) + + # Find all aliases to the command; both cmd-defined and user-defined. + # We assume a user won't override one command with a different one, + # but will choose completely new names or add options to existing + # ones while maintaining the actual command name unchanged. + cmd_data.aliases.extend(cmd.aliases) + cmd_data.aliases.extend(sorted([useralias + for cmdalias in cmd_data.aliases + if cmdalias in self.user_aliases + for useralias in self.user_aliases[cmdalias] + if useralias not in cmd_data.aliases])) + + opts = cmd.options() + for optname, opt in sorted(opts.iteritems()): + cmd_data.options.extend(self.option(opt)) + + if 'help' == name or 'help' in cmd.aliases: + cmd_data.fixed_words = ('($cmds %s)' % + " ".join(sorted(help_topics.topic_registry.keys()))) + + return cmd_data + + def option(self, opt): + optswitches = {} + parser = option.get_optparser({opt.name: opt}) + parser = self.wrap_parser(optswitches, parser) + optswitches.clear() + opt.add_option(parser, opt.short_name()) + if isinstance(opt, option.RegistryOption) and opt.enum_switch: + enum_switch = '--%s' % opt.name + enum_data = optswitches.get(enum_switch) + if enum_data: + try: + enum_data.registry_keys = opt.registry.keys() + except ImportError, e: + enum_data.error_messages.append( + "ERROR getting registry keys for '--%s': %s" + % (opt.name, str(e).split('\n')[0])) + return sorted(optswitches.values()) + + def wrap_container(self, optswitches, parser): + def tweaked_add_option(*opts, **attrs): + for name in opts: + optswitches[name] = OptionData(name) + parser.add_option = tweaked_add_option + return parser + + def wrap_parser(self, optswitches, parser): + orig_add_option_group = parser.add_option_group + def tweaked_add_option_group(*opts, **attrs): + return self.wrap_container(optswitches, + orig_add_option_group(*opts, **attrs)) + parser.add_option_group = tweaked_add_option_group + return self.wrap_container(optswitches, parser) + + +def bash_completion_function(out, function_name="_bzr", function_only=False, + debug=False, + no_plugins=False, selected_plugins=None): + dc = DataCollector(no_plugins=no_plugins, selected_plugins=selected_plugins) + data = dc.collect() + cg = BashCodeGen(data, function_name=function_name, debug=debug) + if function_only: + res = cg.function() + else: + res = cg.script() + out.write(res) + + +class cmd_bash_completion(commands.Command): + __doc__ = """Generate a shell function for bash command line completion. + + This command generates a shell function which can be used by bash to + automatically complete the currently typed command when the user presses + the completion key (usually tab). + + Commonly used like this: + eval "`bzr bash-completion`" + """ + + takes_options = [ + option.Option("function-name", short_name="f", type=str, argname="name", + help="Name of the generated function (default: _bzr)"), + option.Option("function-only", short_name="o", type=None, + help="Generate only the shell function, don't enable it"), + option.Option("debug", type=None, hidden=True, + help="Enable shell code useful for debugging"), + option.ListOption("plugin", type=str, argname="name", + # param_name="selected_plugins", # doesn't work, bug #387117 + help="Enable completions for the selected plugin" + + " (default: all plugins)"), + ] + + def run(self, **kwargs): + if 'plugin' in kwargs: + # work around bug #387117 which prevents us from using param_name + if len(kwargs['plugin']) > 0: + kwargs['selected_plugins'] = kwargs['plugin'] + del kwargs['plugin'] + bash_completion_function(sys.stdout, **kwargs) + + +if __name__ == '__main__': + + import locale + import optparse + + def plugin_callback(option, opt, value, parser): + values = parser.values.selected_plugins + if value == '-': + del values[:] + else: + values.append(value) + + parser = optparse.OptionParser(usage="%prog [-f NAME] [-o]") + parser.add_option("--function-name", "-f", metavar="NAME", + help="Name of the generated function (default: _bzr)") + parser.add_option("--function-only", "-o", action="store_true", + help="Generate only the shell function, don't enable it") + parser.add_option("--debug", action="store_true", + help=optparse.SUPPRESS_HELP) + parser.add_option("--no-plugins", action="store_true", + help="Don't load any bzr plugins") + parser.add_option("--plugin", metavar="NAME", type="string", + dest="selected_plugins", default=[], + action="callback", callback=plugin_callback, + help="Enable completions for the selected plugin" + + " (default: all plugins)") + (opts, args) = parser.parse_args() + if args: + parser.error("script does not take positional arguments") + kwargs = dict() + for name, value in opts.__dict__.iteritems(): + if value is not None: + kwargs[name] = value + + locale.setlocale(locale.LC_ALL, '') + if not kwargs.get('no_plugins', False): + plugin.load_plugins() + commands.install_bzr_command_hooks() + bash_completion_function(sys.stdout, **kwargs) diff --git a/bzrlib/plugins/bash_completion/tests/__init__.py b/bzrlib/plugins/bash_completion/tests/__init__.py new file mode 100644 index 0000000..fbe9415 --- /dev/null +++ b/bzrlib/plugins/bash_completion/tests/__init__.py @@ -0,0 +1,23 @@ +# Copyright (C) 2010 by Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +def load_tests(basic_tests, module, loader): + testmod_names = [ + 'test_bashcomp', + ] + basic_tests.addTest(loader.loadTestsFromModuleNames( + ["%s.%s" % (__name__, tmn) for tmn in testmod_names])) + return basic_tests diff --git a/bzrlib/plugins/bash_completion/tests/test_bashcomp.py b/bzrlib/plugins/bash_completion/tests/test_bashcomp.py new file mode 100644 index 0000000..e22e6be --- /dev/null +++ b/bzrlib/plugins/bash_completion/tests/test_bashcomp.py @@ -0,0 +1,332 @@ +# Copyright (C) 2010 by Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +import sys + +import bzrlib +from bzrlib import commands, tests +from bzrlib.tests import features +from bzrlib.plugins.bash_completion.bashcomp import * + +import subprocess + + +class BashCompletionMixin(object): + """Component for testing execution of a bash completion script.""" + + _test_needs_features = [features.bash_feature] + script = None + + def complete(self, words, cword=-1): + """Perform a bash completion. + + :param words: a list of words representing the current command. + :param cword: the current word to complete, defaults to the last one. + """ + if self.script is None: + self.script = self.get_script() + proc = subprocess.Popen([features.bash_feature.path, + '--noprofile'], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + if cword < 0: + cword = len(words) + cword + input = '%s\n' % self.script + input += ('COMP_WORDS=( %s )\n' % + ' '.join(["'"+w.replace("'", "'\\''")+"'" for w in words])) + input += 'COMP_CWORD=%d\n' % cword + input += '%s\n' % getattr(self, 'script_name', '_bzr') + input += 'echo ${#COMPREPLY[*]}\n' + input += "IFS=$'\\n'\n" + input += 'echo "${COMPREPLY[*]}"\n' + (out, err) = proc.communicate(input) + if '' != err: + raise AssertionError('Unexpected error message:\n%s' % err) + self.assertEqual('', err, 'No messages to standard error') + #import sys + #print >>sys.stdout, '---\n%s\n---\n%s\n---\n' % (input, out) + lines = out.split('\n') + nlines = int(lines[0]) + del lines[0] + self.assertEqual('', lines[-1], 'Newline at end') + del lines[-1] + if nlines == 0 and len(lines) == 1 and lines[0] == '': + del lines[0] + self.assertEqual(nlines, len(lines), 'No newlines in generated words') + self.completion_result = set(lines) + return self.completion_result + + def assertCompletionEquals(self, *words): + self.assertEqual(set(words), self.completion_result) + + def assertCompletionContains(self, *words): + missing = set(words) - self.completion_result + if missing: + raise AssertionError('Completion should contain %r but it has %r' + % (missing, self.completion_result)) + + def assertCompletionOmits(self, *words): + surplus = set(words) & self.completion_result + if surplus: + raise AssertionError('Completion should omit %r but it has %r' + % (surplus, res, self.completion_result)) + + def get_script(self): + commands.install_bzr_command_hooks() + dc = DataCollector() + data = dc.collect() + cg = BashCodeGen(data) + res = cg.function() + return res + + +class TestBashCompletion(tests.TestCase, BashCompletionMixin): + """Test bash completions that don't execute bzr.""" + + def test_simple_scipt(self): + """Ensure that the test harness works as expected""" + self.script = """ +_bzr() { + COMPREPLY=() + # add all words in reverse order, with some markup around them + for ((i = ${#COMP_WORDS[@]}; i > 0; --i)); do + COMPREPLY+=( "-${COMP_WORDS[i-1]}+" ) + done + # and append the current word + COMPREPLY+=( "+${COMP_WORDS[COMP_CWORD]}-" ) +} +""" + self.complete(['foo', '"bar', "'baz"], cword=1) + self.assertCompletionEquals("-'baz+", '-"bar+', '-foo+', '+"bar-') + + def test_cmd_ini(self): + self.complete(['bzr', 'ini']) + self.assertCompletionContains('init', 'init-repo', 'init-repository') + self.assertCompletionOmits('commit') + + def test_init_opts(self): + self.complete(['bzr', 'init', '-']) + self.assertCompletionContains('-h', '--2a', '--format=2a') + + def test_global_opts(self): + self.complete(['bzr', '-', 'init'], cword=1) + self.assertCompletionContains('--no-plugins', '--builtin') + + def test_commit_dashm(self): + self.complete(['bzr', 'commit', '-m']) + self.assertCompletionEquals('-m') + + def test_status_negated(self): + self.complete(['bzr', 'status', '--n']) + self.assertCompletionContains('--no-versioned', '--no-verbose') + + def test_init_format_any(self): + self.complete(['bzr', 'init', '--format', '=', 'directory'], cword=3) + self.assertCompletionContains('1.9', '2a') + + def test_init_format_2(self): + self.complete(['bzr', 'init', '--format', '=', '2', 'directory'], + cword=4) + self.assertCompletionContains('2a') + self.assertCompletionOmits('1.9') + + +class TestBashCompletionInvoking(tests.TestCaseWithTransport, + BashCompletionMixin): + """Test bash completions that might execute bzr. + + Only the syntax ``$(bzr ...`` is supported so far. The bzr command + will be replaced by the bzr instance running this selftest. + """ + + def setUp(self): + super(TestBashCompletionInvoking, self).setUp() + if sys.platform == 'win32': + raise tests.KnownFailure( + 'see bug #709104, completion is broken on windows') + + def get_script(self): + s = super(TestBashCompletionInvoking, self).get_script() + return s.replace("$(bzr ", "$('%s' " % self.get_bzr_path()) + + def test_revspec_tag_all(self): + self.requireFeature(features.sed_feature) + wt = self.make_branch_and_tree('.', format='dirstate-tags') + wt.branch.tags.set_tag('tag1', 'null:') + wt.branch.tags.set_tag('tag2', 'null:') + wt.branch.tags.set_tag('3tag', 'null:') + self.complete(['bzr', 'log', '-r', 'tag', ':']) + self.assertCompletionEquals('tag1', 'tag2', '3tag') + + def test_revspec_tag_prefix(self): + self.requireFeature(features.sed_feature) + wt = self.make_branch_and_tree('.', format='dirstate-tags') + wt.branch.tags.set_tag('tag1', 'null:') + wt.branch.tags.set_tag('tag2', 'null:') + wt.branch.tags.set_tag('3tag', 'null:') + self.complete(['bzr', 'log', '-r', 'tag', ':', 't']) + self.assertCompletionEquals('tag1', 'tag2') + + def test_revspec_tag_spaces(self): + self.requireFeature(features.sed_feature) + wt = self.make_branch_and_tree('.', format='dirstate-tags') + wt.branch.tags.set_tag('tag with spaces', 'null:') + self.complete(['bzr', 'log', '-r', 'tag', ':', 't']) + self.assertCompletionEquals(r'tag\ with\ spaces') + self.complete(['bzr', 'log', '-r', '"tag:t']) + self.assertCompletionEquals('tag:tag with spaces') + self.complete(['bzr', 'log', '-r', "'tag:t"]) + self.assertCompletionEquals('tag:tag with spaces') + + def test_revspec_tag_endrange(self): + self.requireFeature(features.sed_feature) + wt = self.make_branch_and_tree('.', format='dirstate-tags') + wt.branch.tags.set_tag('tag1', 'null:') + wt.branch.tags.set_tag('tag2', 'null:') + self.complete(['bzr', 'log', '-r', '3..tag', ':', 't']) + self.assertCompletionEquals('tag1', 'tag2') + self.complete(['bzr', 'log', '-r', '"3..tag:t']) + self.assertCompletionEquals('3..tag:tag1', '3..tag:tag2') + self.complete(['bzr', 'log', '-r', "'3..tag:t"]) + self.assertCompletionEquals('3..tag:tag1', '3..tag:tag2') + + +class TestBashCodeGen(tests.TestCase): + + def test_command_names(self): + data = CompletionData() + bar = CommandData('bar') + bar.aliases.append('baz') + data.commands.append(bar) + data.commands.append(CommandData('foo')) + cg = BashCodeGen(data) + self.assertEqual('bar baz foo', cg.command_names()) + + def test_debug_output(self): + data = CompletionData() + self.assertEqual('', BashCodeGen(data, debug=False).debug_output()) + self.assertTrue(BashCodeGen(data, debug=True).debug_output()) + + def test_bzr_version(self): + data = CompletionData() + cg = BashCodeGen(data) + self.assertEqual('%s.' % bzrlib.version_string, cg.bzr_version()) + data.plugins['foo'] = PluginData('foo', '1.0') + data.plugins['bar'] = PluginData('bar', '2.0') + cg = BashCodeGen(data) + self.assertEqual('''\ +%s and the following plugins: +# bar 2.0 +# foo 1.0''' % bzrlib.version_string, cg.bzr_version()) + + def test_global_options(self): + data = CompletionData() + data.global_options.add('--foo') + data.global_options.add('--bar') + cg = BashCodeGen(data) + self.assertEqual('--bar --foo', cg.global_options()) + + def test_command_cases(self): + data = CompletionData() + bar = CommandData('bar') + bar.aliases.append('baz') + bar.options.append(OptionData('--opt')) + data.commands.append(bar) + data.commands.append(CommandData('foo')) + cg = BashCodeGen(data) + self.assertEqualDiff('''\ +\tbar|baz) +\t\tcmdOpts=( --opt ) +\t\t;; +\tfoo) +\t\tcmdOpts=( ) +\t\t;; +''', cg.command_cases()) + + def test_command_case(self): + cmd = CommandData('cmd') + cmd.plugin = PluginData('plugger', '1.0') + bar = OptionData('--bar') + bar.registry_keys = ['that', 'this'] + bar.error_messages.append('Some error message') + cmd.options.append(bar) + cmd.options.append(OptionData('--foo')) + data = CompletionData() + data.commands.append(cmd) + cg = BashCodeGen(data) + self.assertEqualDiff('''\ +\tcmd) +\t\t# plugin "plugger 1.0" +\t\t# Some error message +\t\tcmdOpts=( --bar=that --bar=this --foo ) +\t\tcase $curOpt in +\t\t\t--bar) optEnums=( that this ) ;; +\t\tesac +\t\t;; +''', cg.command_case(cmd)) + + +class TestDataCollector(tests.TestCase): + + def setUp(self): + super(TestDataCollector, self).setUp() + commands.install_bzr_command_hooks() + + def test_global_options(self): + dc = DataCollector() + dc.global_options() + self.assertSubset(['--no-plugins', '--builtin'], + dc.data.global_options) + + def test_commands(self): + dc = DataCollector() + dc.commands() + self.assertSubset(['init', 'init-repo', 'init-repository'], + dc.data.all_command_aliases()) + + def test_commands_from_plugins(self): + dc = DataCollector() + dc.commands() + self.assertSubset(['bash-completion'], + dc.data.all_command_aliases()) + + def test_commit_dashm(self): + dc = DataCollector() + cmd = dc.command('commit') + self.assertSubset(['-m'], + [str(o) for o in cmd.options]) + + def test_status_negated(self): + dc = DataCollector() + cmd = dc.command('status') + self.assertSubset(['--no-versioned', '--no-verbose'], + [str(o) for o in cmd.options]) + + def test_init_format(self): + dc = DataCollector() + cmd = dc.command('init') + for opt in cmd.options: + if opt.name == '--format': + self.assertSubset(['2a'], opt.registry_keys) + return + raise AssertionError('Option --format not found') + + +class BlackboxTests(tests.TestCase): + + def test_bash_completion(self): + self.run_bzr("bash-completion") diff --git a/bzrlib/plugins/changelog_merge/__init__.py b/bzrlib/plugins/changelog_merge/__init__.py new file mode 100644 index 0000000..3a48bd4 --- /dev/null +++ b/bzrlib/plugins/changelog_merge/__init__.py @@ -0,0 +1,78 @@ +# Copyright (C) 2010 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +from __future__ import absolute_import + +__doc__ = """Merge hook for GNU-format ChangeLog files + +To enable this plugin, add a section to your locations.conf +like:: + + [/home/user/proj] + changelog_merge_files = ChangeLog + +Or add an entry to your branch.conf like:: + + changelog_merge_files = ChangeLog + +The changelog_merge_files config option takes a list of file names (not paths), +separated by commas. (This is unlike the news_merge plugin, which matches +paths.) e.g. the above config examples would match both +``src/foolib/ChangeLog`` and ``docs/ChangeLog``. + +The algorithm used to merge the changes can be summarised as: + + * new entries added to the top of OTHER are emitted first + * all other additions, deletions and edits from THIS and OTHER are preserved + * edits (e.g. to fix typos) at the top of OTHER are hard to distinguish from + adding and deleting independent entries; the algorithm tries to guess which + based on how similar the old and new entries are. + +Caveats +------- + +Most changes can be merged, but conflicts are possible if the plugin finds +edits at the top of OTHER to entries that have been deleted (or also edited) by +THIS. In that case the plugin gives up and bzr's default merge logic will be +used. + +No effort is made to deduplicate entries added by both sides. + +The results depend on the choice of the 'base' version, so it might give +strange results if there is a criss-cross merge. +""" + +# Since we are a built-in plugin we share the bzrlib version +from bzrlib import version_info +from bzrlib.hooks import install_lazy_named_hook + +# Put most of the code in a separate module that we lazy-import to keep the +# overhead of this plugin as minimal as possible. +def changelog_merge_hook(merger): + """Merger.merge_file_content hook for GNU-format ChangeLog files.""" + from bzrlib.plugins.changelog_merge.changelog_merge import ChangeLogMerger + return ChangeLogMerger(merger) + +install_lazy_named_hook("bzrlib.merge", "Merger.hooks", "merge_file_content", + changelog_merge_hook, 'GNU ChangeLog file merge') + +def load_tests(basic_tests, module, loader): + testmod_names = [ + 'tests', + ] + basic_tests.addTest(loader.loadTestsFromModuleNames( + ["%s.%s" % (__name__, tmn) for tmn in testmod_names])) + return basic_tests diff --git a/bzrlib/plugins/changelog_merge/changelog_merge.py b/bzrlib/plugins/changelog_merge/changelog_merge.py new file mode 100644 index 0000000..2b3ce8a --- /dev/null +++ b/bzrlib/plugins/changelog_merge/changelog_merge.py @@ -0,0 +1,199 @@ +# Copyright (C) 2010 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Merge logic for changelog_merge plugin.""" + +from __future__ import absolute_import + +import difflib + +from bzrlib import ( + debug, + merge, + urlutils, + ) +from bzrlib.merge3 import Merge3 +from bzrlib.trace import mutter + + +def changelog_entries(lines): + """Return a list of changelog entries. + + :param lines: lines of a changelog file. + :returns: list of entries. Each entry is a tuple of lines. + """ + entries = [] + for line in lines: + if line[0] not in (' ', '\t', '\n'): + # new entry + entries.append([line]) + else: + try: + entry = entries[-1] + except IndexError: + # Cope with leading blank lines. + entries.append([]) + entry = entries[-1] + entry.append(line) + return map(tuple, entries) + + +def entries_to_lines(entries): + """Turn a list of entries into a flat iterable of lines.""" + for entry in entries: + for line in entry: + yield line + + +class ChangeLogMerger(merge.ConfigurableFileMerger): + """Merge GNU-format ChangeLog files.""" + + name_prefix = "changelog" + + def get_filepath(self, params, tree): + """Calculate the path to the file in a tree. + + This is overridden to return just the basename, rather than full path, + so that e.g. if the config says ``changelog_merge_files = ChangeLog``, + then all ChangeLog files in the tree will match (not just one in the + root of the tree). + + :param params: A MergeHookParams describing the file to merge + :param tree: a Tree, e.g. self.merger.this_tree. + """ + return urlutils.basename(tree.id2path(params.file_id)) + + def merge_text(self, params): + """Merge changelog changes. + + * new entries from other will float to the top + * edits to older entries are preserved + """ + # Transform files into lists of changelog entries + this_entries = changelog_entries(params.this_lines) + other_entries = changelog_entries(params.other_lines) + base_entries = changelog_entries(params.base_lines) + try: + result_entries = merge_entries( + base_entries, this_entries, other_entries) + except EntryConflict: + # XXX: generating a nice conflict file would be better + return 'not_applicable', None + # Transform the merged elements back into real blocks of lines. + return 'success', entries_to_lines(result_entries) + + +class EntryConflict(Exception): + pass + + +def default_guess_edits(new_entries, deleted_entries, entry_as_str=''.join): + """Default implementation of guess_edits param of merge_entries. + + This algorithm does O(N^2 * logN) SequenceMatcher.ratio() calls, which is + pretty bad, but it shouldn't be used very often. + """ + deleted_entries_as_strs = map(entry_as_str, deleted_entries) + new_entries_as_strs = map(entry_as_str, new_entries) + result_new = list(new_entries) + result_deleted = list(deleted_entries) + result_edits = [] + sm = difflib.SequenceMatcher() + CUTOFF = 0.8 + while True: + best = None + best_score = CUTOFF + # Compare each new entry with each old entry to find the best match + for new_entry_as_str in new_entries_as_strs: + sm.set_seq1(new_entry_as_str) + for old_entry_as_str in deleted_entries_as_strs: + sm.set_seq2(old_entry_as_str) + score = sm.ratio() + if score > best_score: + best = new_entry_as_str, old_entry_as_str + best_score = score + if best is not None: + # Add the best match to the list of edits, and remove it from the + # the list of new/old entries. Also remove it from the new/old + # lists for the next round. + del_index = deleted_entries_as_strs.index(best[1]) + new_index = new_entries_as_strs.index(best[0]) + result_edits.append( + (result_deleted[del_index], result_new[new_index])) + del deleted_entries_as_strs[del_index], result_deleted[del_index] + del new_entries_as_strs[new_index], result_new[new_index] + else: + # No match better than CUTOFF exists in the remaining new and old + # entries. + break + return result_new, result_deleted, result_edits + + +def merge_entries(base_entries, this_entries, other_entries, + guess_edits=default_guess_edits): + """Merge changelog given base, this, and other versions.""" + m3 = Merge3(base_entries, this_entries, other_entries, allow_objects=True) + result_entries = [] + at_top = True + for group in m3.merge_groups(): + if 'changelog_merge' in debug.debug_flags: + mutter('merge group:\n%r', group) + group_kind = group[0] + if group_kind == 'conflict': + _, base, this, other = group + # Find additions + new_in_other = [ + entry for entry in other if entry not in base] + # Find deletions + deleted_in_other = [ + entry for entry in base if entry not in other] + if at_top and deleted_in_other: + # Magic! Compare deletions and additions to try spot edits + new_in_other, deleted_in_other, edits_in_other = guess_edits( + new_in_other, deleted_in_other) + else: + # Changes not made at the top are always preserved as is, no + # need to try distinguish edits from adds and deletes. + edits_in_other = [] + if 'changelog_merge' in debug.debug_flags: + mutter('at_top: %r', at_top) + mutter('new_in_other: %r', new_in_other) + mutter('deleted_in_other: %r', deleted_in_other) + mutter('edits_in_other: %r', edits_in_other) + # Apply deletes and edits + updated_this = [ + entry for entry in this if entry not in deleted_in_other] + for old_entry, new_entry in edits_in_other: + try: + index = updated_this.index(old_entry) + except ValueError: + # edited entry no longer present in this! Just give up and + # declare a conflict. + raise EntryConflict() + updated_this[index] = new_entry + if 'changelog_merge' in debug.debug_flags: + mutter('updated_this: %r', updated_this) + if at_top: + # Float new entries from other to the top + result_entries = new_in_other + result_entries + else: + result_entries.extend(new_in_other) + result_entries.extend(updated_this) + else: # unchanged, same, a, or b. + lines = group[1] + result_entries.extend(lines) + at_top = False + return result_entries diff --git a/bzrlib/plugins/changelog_merge/tests/__init__.py b/bzrlib/plugins/changelog_merge/tests/__init__.py new file mode 100644 index 0000000..62c2658 --- /dev/null +++ b/bzrlib/plugins/changelog_merge/tests/__init__.py @@ -0,0 +1,24 @@ +# Copyright (C) 2011 by Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +def load_tests(basic_tests, module, loader): + testmod_names = [ + 'test_changelog_merge', + ] + basic_tests.addTest(loader.loadTestsFromModuleNames( + ["%s.%s" % (__name__, tmn) for tmn in testmod_names])) + return basic_tests + diff --git a/bzrlib/plugins/changelog_merge/tests/test_changelog_merge.py b/bzrlib/plugins/changelog_merge/tests/test_changelog_merge.py new file mode 100644 index 0000000..a7b0d3f --- /dev/null +++ b/bzrlib/plugins/changelog_merge/tests/test_changelog_merge.py @@ -0,0 +1,222 @@ +# Copyright (C) 2011 by Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +from bzrlib import ( + merge, + tests, + ) +from bzrlib.tests import test_merge_core +from bzrlib.plugins.changelog_merge import changelog_merge + + +sample_base_entries = [ + 'Base entry B1', + 'Base entry B2', + 'Base entry B3', + ] + +sample_this_entries = [ + 'This entry T1', + 'This entry T2', + #'Base entry B1 updated', + 'Base entry B1', + 'Base entry B2', + 'Base entry B3', + ] + +sample_other_entries = [ + 'Other entry O1', + #'Base entry B1', + 'Base entry B1', + 'Base entry B2 updated', + 'Base entry B3', + ] + + +sample2_base_entries = [ + 'Base entry B1', + 'Base entry B2', + 'Base entry B3', + ] + +sample2_this_entries = [ + 'This entry T1', + 'This entry T2', + #'Base entry B1 updated', + 'Base entry B1', + 'Base entry B2', + ] + +sample2_other_entries = [ + 'Other entry O1', + #'Base entry B1', + 'Base entry B1 edit', # > 80% similar according to difflib + 'Base entry B2', + ] + + +class TestMergeCoreLogic(tests.TestCase): + + def test_new_in_other_floats_to_top(self): + """Changes at the top of 'other' float to the top. + + Given a changelog in THIS containing:: + + NEW-1 + OLD-1 + + and a changelog in OTHER containing:: + + NEW-2 + OLD-1 + + it will merge as:: + + NEW-2 + NEW-1 + OLD-1 + """ + base_entries = ['OLD-1'] + this_entries = ['NEW-1', 'OLD-1'] + other_entries = ['NEW-2', 'OLD-1'] + result_entries = changelog_merge.merge_entries( + base_entries, this_entries, other_entries) + self.assertEqual( + ['NEW-2', 'NEW-1', 'OLD-1'], result_entries) + + def test_acceptance_bug_723968(self): + """Merging a branch that: + + 1. adds a new entry, and + 2. edits an old entry (e.g. to fix a typo or twiddle formatting) + + will: + + 1. add the new entry to the top + 2. keep the edit, without duplicating the edited entry or moving it. + """ + result_entries = changelog_merge.merge_entries( + sample_base_entries, sample_this_entries, sample_other_entries) + self.assertEqual([ + 'Other entry O1', + 'This entry T1', + 'This entry T2', + 'Base entry B1', + 'Base entry B2 updated', + 'Base entry B3', + ], + list(result_entries)) + + def test_more_complex_conflict(self): + """Like test_acceptance_bug_723968, but with a more difficult conflict: + the new entry and the edited entry are adjacent. + """ + def guess_edits(new, deleted): + #import pdb; pdb.set_trace() + return changelog_merge.default_guess_edits(new, deleted, + entry_as_str=lambda x: x) + result_entries = changelog_merge.merge_entries( + sample2_base_entries, sample2_this_entries, sample2_other_entries, + guess_edits=guess_edits) + self.assertEqual([ + 'Other entry O1', + 'This entry T1', + 'This entry T2', + 'Base entry B1 edit', + 'Base entry B2', + ], + list(result_entries)) + + def test_too_hard(self): + """A conflict this plugin cannot resolve raises EntryConflict. + """ + # An entry edited in other but deleted in this is a conflict we can't + # resolve. (Ideally perhaps we'd generate a nice conflict file, but + # for now we just give up.) + self.assertRaises(changelog_merge.EntryConflict, + changelog_merge.merge_entries, + sample2_base_entries, [], sample2_other_entries) + + def test_default_guess_edits(self): + """default_guess_edits matches a new entry only once. + + (Even when that entry is the best match for multiple old entries.) + """ + new_in_other = [('AAAAA',), ('BBBBB',)] + deleted_in_other = [('DDDDD',), ('BBBBBx',), ('BBBBBxx',)] + # BBBBB is the best match for both BBBBBx and BBBBBxx + result = changelog_merge.default_guess_edits( + new_in_other, deleted_in_other) + self.assertEqual( + ([('AAAAA',)], # new + [('DDDDD',), ('BBBBBxx',)], # deleted + [(('BBBBBx',), ('BBBBB',))]), # edits + result) + + +class TestChangeLogMerger(tests.TestCaseWithTransport): + """Tests for ChangeLogMerger class. + + Most tests should be unit tests for merge_entries (and its helpers). + This class is just to cover the handful of lines of code in ChangeLogMerger + itself. + """ + + def make_builder(self): + builder = test_merge_core.MergeBuilder(self.test_base_dir) + self.addCleanup(builder.cleanup) + return builder + + def make_changelog_merger(self, base_text, this_text, other_text): + builder = self.make_builder() + builder.add_file('clog-id', builder.tree_root, 'ChangeLog', + base_text, True) + builder.change_contents('clog-id', other=other_text, this=this_text) + merger = builder.make_merger(merge.Merge3Merger, ['clog-id']) + # The following can't use config stacks until the plugin itself does + # ('this_branch' is already write locked at this point and as such + # won't write the new value to disk where get_user_option can get it). + merger.this_branch.get_config().set_user_option( + 'changelog_merge_files', 'ChangeLog') + merge_hook_params = merge.MergeFileHookParams(merger, 'clog-id', None, + 'file', 'file', 'conflict') + changelog_merger = changelog_merge.ChangeLogMerger(merger) + return changelog_merger, merge_hook_params + + def test_merge_text_returns_not_applicable(self): + """A conflict this plugin cannot resolve returns (not_applicable, None). + """ + # Build same example as TestMergeCoreLogic.test_too_hard: edit an entry + # in other but delete it in this. + def entries_as_str(entries): + return ''.join(entry + '\n' for entry in entries) + changelog_merger, merge_hook_params = self.make_changelog_merger( + entries_as_str(sample2_base_entries), + '', + entries_as_str(sample2_other_entries)) + self.assertEqual( + ('not_applicable', None), + changelog_merger.merge_contents(merge_hook_params)) + + def test_merge_text_returns_success(self): + """A successful merge returns ('success', lines).""" + changelog_merger, merge_hook_params = self.make_changelog_merger( + '', 'this text\n', 'other text\n') + status, lines = changelog_merger.merge_contents(merge_hook_params) + self.assertEqual( + ('success', ['other text\n', 'this text\n']), + (status, list(lines))) + diff --git a/bzrlib/plugins/launchpad/__init__.py b/bzrlib/plugins/launchpad/__init__.py new file mode 100644 index 0000000..5673d95 --- /dev/null +++ b/bzrlib/plugins/launchpad/__init__.py @@ -0,0 +1,201 @@ +# Copyright (C) 2006-2011 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Launchpad.net integration plugin for Bazaar. + +This plugin provides facilities for working with Bazaar branches that are +hosted on Launchpad (http://launchpad.net). It provides a directory service +for referring to Launchpad branches using the "lp:" prefix. For example, +lp:bzr refers to the Bazaar's main development branch and +lp:~username/project/branch-name can be used to refer to a specific branch. + +This plugin provides a bug tracker so that "bzr commit --fixes lp:1234" will +record that revision as fixing Launchpad's bug 1234. + +The plugin also provides the following commands: + + launchpad-login: Show or set the Launchpad user ID + launchpad-open: Open a Launchpad branch page in your web browser + lp-propose-merge: Propose merging a branch on Launchpad + register-branch: Register a branch with launchpad.net + launchpad-mirror: Ask Launchpad to mirror a branch now + +""" + +from __future__ import absolute_import + +# The XMLRPC server address can be overridden by setting the environment +# variable $BZR_LP_XMLRPC_URL + +# see http://wiki.bazaar.canonical.com/Specs/BranchRegistrationTool + +from bzrlib import ( + branch as _mod_branch, + config as _mod_config, + lazy_regex, + # Since we are a built-in plugin we share the bzrlib version + trace, + version_info, + ) +from bzrlib.commands import ( + plugin_cmds, + ) +from bzrlib.directory_service import directories +from bzrlib.help_topics import topic_registry + +for klsname, aliases in [ + ("cmd_register_branch", []), + ("cmd_launchpad_open", ["lp-open"]), + ("cmd_launchpad_login", ["lp-login"]), + ("cmd_launchpad_mirror", ["lp-mirror"]), + ("cmd_lp_propose_merge", ["lp-submit", "lp-propose"]), + ("cmd_lp_find_proposal", [])]: + plugin_cmds.register_lazy(klsname, aliases, + "bzrlib.plugins.launchpad.cmds") + + +def _register_directory(): + directories.register_lazy('lp:', 'bzrlib.plugins.launchpad.lp_directory', + 'LaunchpadDirectory', + 'Launchpad-based directory service',) + directories.register_lazy( + 'debianlp:', 'bzrlib.plugins.launchpad.lp_directory', + 'LaunchpadDirectory', + 'debianlp: shortcut') + directories.register_lazy( + 'ubuntu:', 'bzrlib.plugins.launchpad.lp_directory', + 'LaunchpadDirectory', + 'ubuntu: shortcut') + +_register_directory() + +# This is kept in __init__ so that we don't load lp_api_lite unless the branch +# actually matches. That way we can avoid importing extra dependencies like +# json. +_package_branch = lazy_regex.lazy_compile( + r'bazaar.launchpad.net.*?/' + r'(?P<user>~[^/]+/)?(?P<archive>ubuntu|debian)/(?P<series>[^/]+/)?' + r'(?P<project>[^/]+)(?P<branch>/[^/]+)?' + ) + +def _get_package_branch_info(url): + """Determine the packaging information for this URL. + + :return: If this isn't a packaging branch, return None. If it is, return + (archive, series, project) + """ + if url is None: + return None + m = _package_branch.search(url) + if m is None: + return None + archive, series, project, user = m.group('archive', 'series', + 'project', 'user') + if series is not None: + # series is optional, so the regex includes the extra '/', we don't + # want to send that on (it causes Internal Server Errors.) + series = series.strip('/') + if user is not None: + user = user.strip('~/') + if user != 'ubuntu-branches': + return None + return archive, series, project + + +def _check_is_up_to_date(the_branch): + info = _get_package_branch_info(the_branch.base) + if info is None: + return + c = the_branch.get_config_stack() + verbosity = c.get('launchpad.packaging_verbosity') + if not verbosity: + trace.mutter('not checking %s because verbosity is turned off' + % (the_branch.base,)) + return + archive, series, project = info + from bzrlib.plugins.launchpad import lp_api_lite + latest_pub = lp_api_lite.LatestPublication(archive, series, project) + lp_api_lite.report_freshness(the_branch, verbosity, latest_pub) + + +def _register_hooks(): + _mod_branch.Branch.hooks.install_named_hook('open', + _check_is_up_to_date, 'package-branch-up-to-date') + + +_register_hooks() + +def load_tests(basic_tests, module, loader): + testmod_names = [ + 'test_account', + 'test_register', + 'test_lp_api', + 'test_lp_api_lite', + 'test_lp_directory', + 'test_lp_login', + 'test_lp_open', + 'test_lp_service', + ] + basic_tests.addTest(loader.loadTestsFromModuleNames( + ["%s.%s" % (__name__, tmn) for tmn in testmod_names])) + return basic_tests + + +_launchpad_help = """Integration with Launchpad.net + +Launchpad.net provides free Bazaar branch hosting with integrated bug and +specification tracking. + +The bzr client (through the plugin called 'launchpad') has special +features to communicate with Launchpad: + + * The launchpad-login command tells Bazaar your Launchpad user name. This + is then used by the 'lp:' transport to download your branches using + bzr+ssh://. + + * The 'lp:' transport uses Launchpad as a directory service: for example + 'lp:bzr' and 'lp:python' refer to the main branches of the relevant + projects and may be branched, logged, etc. You can also use the 'lp:' + transport to refer to specific branches, e.g. lp:~bzr/bzr/trunk. + + * The 'lp:' bug tracker alias can expand launchpad bug numbers to their + URLs for use with 'bzr commit --fixes', e.g. 'bzr commit --fixes lp:12345' + will record a revision property that marks that revision as fixing + Launchpad bug 12345. When you push that branch to Launchpad it will + automatically be linked to the bug report. + + * The register-branch command tells Launchpad about the url of a + public branch. Launchpad will then mirror the branch, display + its contents and allow it to be attached to bugs and other + objects. + +For more information see http://help.launchpad.net/ +""" +topic_registry.register('launchpad', + _launchpad_help, + 'Using Bazaar with Launchpad.net') + +_mod_config.option_registry.register( + _mod_config.Option('launchpad.packaging_verbosity', default=True, + from_unicode=_mod_config.bool_from_store, + help="""\ +Whether to warn if a UDD package import branch is accessed that is out of date. + +Setting this option to 'off' will disable verbosity. +""")) +_mod_config.option_registry.register( + _mod_config.Option('launchpad_username', default=None, + help="The username to login with when conneting to Launchpad.")) diff --git a/bzrlib/plugins/launchpad/account.py b/bzrlib/plugins/launchpad/account.py new file mode 100644 index 0000000..657b261 --- /dev/null +++ b/bzrlib/plugins/launchpad/account.py @@ -0,0 +1,113 @@ +# Copyright (C) 2007-2010 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Functions to manage the user's Launchpad user ID. + +This allows the user to configure their Launchpad user ID once, rather +than once for each place that needs to take it into account. +""" + +from __future__ import absolute_import + +from bzrlib import ( + errors, + trace, + transport, + ) +from bzrlib.config import AuthenticationConfig, GlobalStack +from bzrlib.i18n import gettext + +LAUNCHPAD_BASE = 'https://launchpad.net/' + + +class UnknownLaunchpadUsername(errors.BzrError): + _fmt = "The user name %(user)s is not registered on Launchpad." + + +class NoRegisteredSSHKeys(errors.BzrError): + _fmt = "The user %(user)s has not registered any SSH keys with Launchpad.\n" \ + "See <https://launchpad.net/people/+me>" + + +class MismatchedUsernames(errors.BzrError): + + _fmt = ('bazaar.conf and authentication.conf disagree about launchpad' + ' account name. Please re-run launchpad-login.') + + +def get_lp_login(_config=None): + """Return the user's Launchpad username. + + :raises: MismatchedUsername if authentication.conf and bazaar.conf + disagree about username. + """ + if _config is None: + _config = GlobalStack() + + username = _config.get('launchpad_username') + if username is not None: + auth = AuthenticationConfig() + auth_username = _get_auth_user(auth) + # Auto-upgrading + if auth_username is None: + trace.note(gettext('Setting ssh/sftp usernames for launchpad.net.')) + _set_auth_user(username, auth) + elif auth_username != username: + raise MismatchedUsernames() + return username + + +def _set_global_option(username, _config=None): + if _config is None: + _config = GlobalStack() + _config.set('launchpad_username', username) + + +def set_lp_login(username, _config=None): + """Set the user's Launchpad username""" + _set_global_option(username, _config) + _set_auth_user(username) + + +def _get_auth_user(auth=None): + if auth is None: + auth = AuthenticationConfig() + username = auth.get_user('ssh', '.launchpad.net') + return username + +def _set_auth_user(username, auth=None): + if auth is None: + auth = AuthenticationConfig() + auth.set_credentials( + 'Launchpad', '.launchpad.net', username, 'ssh') + + +def check_lp_login(username, _transport=None): + """Check whether the given Launchpad username is okay. + + This will check for both existence and whether the user has + uploaded SSH keys. + """ + if _transport is None: + _transport = transport.get_transport_from_url(LAUNCHPAD_BASE) + + try: + data = _transport.get_bytes('~%s/+sshkeys' % username) + except errors.NoSuchFile: + raise UnknownLaunchpadUsername(user=username) + + if not data: + raise NoRegisteredSSHKeys(user=username) diff --git a/bzrlib/plugins/launchpad/cmds.py b/bzrlib/plugins/launchpad/cmds.py new file mode 100644 index 0000000..1b2844e --- /dev/null +++ b/bzrlib/plugins/launchpad/cmds.py @@ -0,0 +1,410 @@ +# Copyright (C) 2006-2012 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Launchpad plugin commands.""" + +from __future__ import absolute_import + +from bzrlib import ( + branch as _mod_branch, + controldir, + trace, + ) +from bzrlib.commands import ( + Command, + ) +from bzrlib.errors import ( + BzrCommandError, + InvalidRevisionSpec, + InvalidURL, + NoPublicBranch, + NotBranchError, + ) +from bzrlib.i18n import gettext +from bzrlib.option import ( + Option, + ListOption, + ) + + +class cmd_register_branch(Command): + __doc__ = """Register a branch with launchpad.net. + + This command lists a bzr branch in the directory of branches on + launchpad.net. Registration allows the branch to be associated with + bugs or specifications. + + Before using this command you must register the project to which the + branch belongs, and create an account for yourself on launchpad.net. + + arguments: + public_url: The publicly visible url for the branch to register. + This must be an http or https url (which Launchpad can read + from to access the branch). Local file urls, SFTP urls, and + bzr+ssh urls will not work. + If no public_url is provided, bzr will use the configured + public_url if there is one for the current branch, and + otherwise error. + + example: + bzr register-branch http://foo.com/bzr/fooproject.mine \\ + --project fooproject + """ + takes_args = ['public_url?'] + takes_options = [ + Option('project', + 'Launchpad project short name to associate with the branch.', + unicode), + Option('product', + 'Launchpad product short name to associate with the branch.', + unicode, + hidden=True), + Option('branch-name', + 'Short name for the branch; ' + 'by default taken from the last component of the url.', + unicode), + Option('branch-title', + 'One-sentence description of the branch.', + unicode), + Option('branch-description', + 'Longer description of the purpose or contents of the branch.', + unicode), + Option('author', + "Branch author's email address, if not yourself.", + unicode), + Option('link-bug', + 'The bug this branch fixes.', + int), + Option('dry-run', + 'Prepare the request but don\'t actually send it.') + ] + + + def run(self, + public_url=None, + project='', + product=None, + branch_name='', + branch_title='', + branch_description='', + author='', + link_bug=None, + dry_run=False): + from bzrlib.plugins.launchpad.lp_registration import ( + BranchRegistrationRequest, BranchBugLinkRequest, + DryRunLaunchpadService, LaunchpadService) + if public_url is None: + try: + b = _mod_branch.Branch.open_containing('.')[0] + except NotBranchError: + raise BzrCommandError(gettext( + 'register-branch requires a public ' + 'branch url - see bzr help register-branch.')) + public_url = b.get_public_branch() + if public_url is None: + raise NoPublicBranch(b) + if product is not None: + project = product + trace.note(gettext( + '--product is deprecated; please use --project.')) + + + rego = BranchRegistrationRequest(branch_url=public_url, + branch_name=branch_name, + branch_title=branch_title, + branch_description=branch_description, + product_name=project, + author_email=author, + ) + linko = BranchBugLinkRequest(branch_url=public_url, + bug_id=link_bug) + if not dry_run: + service = LaunchpadService() + # This gives back the xmlrpc url that can be used for future + # operations on the branch. It's not so useful to print to the + # user since they can't do anything with it from a web browser; it + # might be nice for the server to tell us about an html url as + # well. + else: + # Run on service entirely in memory + service = DryRunLaunchpadService() + service.gather_user_credentials() + rego.submit(service) + if link_bug: + linko.submit(service) + self.outf.write('Branch registered.\n') + + +class cmd_launchpad_open(Command): + __doc__ = """Open a Launchpad branch page in your web browser.""" + + aliases = ['lp-open'] + takes_options = [ + Option('dry-run', + 'Do not actually open the browser. Just say the URL we would ' + 'use.'), + ] + takes_args = ['location?'] + + def _possible_locations(self, location): + """Yield possible external locations for the branch at 'location'.""" + yield location + try: + branch = _mod_branch.Branch.open_containing(location)[0] + except NotBranchError: + return + branch_url = branch.get_public_branch() + if branch_url is not None: + yield branch_url + branch_url = branch.get_push_location() + if branch_url is not None: + yield branch_url + + def _get_web_url(self, service, location): + from bzrlib.plugins.launchpad.lp_registration import ( + NotLaunchpadBranch) + for branch_url in self._possible_locations(location): + try: + return service.get_web_url_from_branch_url(branch_url) + except (NotLaunchpadBranch, InvalidURL): + pass + raise NotLaunchpadBranch(branch_url) + + def run(self, location=None, dry_run=False): + from bzrlib.plugins.launchpad.lp_registration import ( + LaunchpadService) + if location is None: + location = u'.' + web_url = self._get_web_url(LaunchpadService(), location) + trace.note(gettext('Opening %s in web browser') % web_url) + if not dry_run: + import webbrowser # this import should not be lazy + # otherwise bzr.exe lacks this module + webbrowser.open(web_url) + + +class cmd_launchpad_login(Command): + __doc__ = """Show or set the Launchpad user ID. + + When communicating with Launchpad, some commands need to know your + Launchpad user ID. This command can be used to set or show the + user ID that Bazaar will use for such communication. + + :Examples: + Show the Launchpad ID of the current user:: + + bzr launchpad-login + + Set the Launchpad ID of the current user to 'bob':: + + bzr launchpad-login bob + """ + aliases = ['lp-login'] + takes_args = ['name?'] + takes_options = [ + 'verbose', + Option('no-check', + "Don't check that the user name is valid."), + ] + + def run(self, name=None, no_check=False, verbose=False): + # This is totally separate from any launchpadlib login system. + from bzrlib.plugins.launchpad import account + check_account = not no_check + + if name is None: + username = account.get_lp_login() + if username: + if check_account: + account.check_lp_login(username) + if verbose: + self.outf.write(gettext( + "Launchpad user ID exists and has SSH keys.\n")) + self.outf.write(username + '\n') + else: + self.outf.write(gettext('No Launchpad user ID configured.\n')) + return 1 + else: + name = name.lower() + if check_account: + account.check_lp_login(name) + if verbose: + self.outf.write(gettext( + "Launchpad user ID exists and has SSH keys.\n")) + account.set_lp_login(name) + if verbose: + self.outf.write(gettext("Launchpad user ID set to '%s'.\n") % + (name,)) + + +# XXX: cmd_launchpad_mirror is untested +class cmd_launchpad_mirror(Command): + __doc__ = """Ask Launchpad to mirror a branch now.""" + + aliases = ['lp-mirror'] + takes_args = ['location?'] + + def run(self, location='.'): + from bzrlib.plugins.launchpad import lp_api + from bzrlib.plugins.launchpad.lp_registration import LaunchpadService + branch, _ = _mod_branch.Branch.open_containing(location) + service = LaunchpadService() + launchpad = lp_api.login(service) + lp_branch = lp_api.LaunchpadBranch.from_bzr(launchpad, branch, + create_missing=False) + lp_branch.lp.requestMirror() + + +class cmd_lp_propose_merge(Command): + __doc__ = """Propose merging a branch on Launchpad. + + This will open your usual editor to provide the initial comment. When it + has created the proposal, it will open it in your default web browser. + + The branch will be proposed to merge into SUBMIT_BRANCH. If SUBMIT_BRANCH + is not supplied, the remembered submit branch will be used. If no submit + branch is remembered, the development focus will be used. + + By default, the SUBMIT_BRANCH's review team will be requested to review + the merge proposal. This can be overriden by specifying --review (-R). + The parameter the launchpad account name of the desired reviewer. This + may optionally be followed by '=' and the review type. For example: + + bzr lp-propose-merge --review jrandom --review review-team=qa + + This will propose a merge, request "jrandom" to perform a review of + unspecified type, and request "review-team" to perform a "qa" review. + """ + + takes_options = [Option('staging', + help='Propose the merge on staging.'), + Option('message', short_name='m', type=unicode, + help='Commit message.'), + Option('approve', + help='Mark the proposal as approved immediately.'), + Option('fixes', 'The bug this proposal fixes.', str), + ListOption('review', short_name='R', type=unicode, + help='Requested reviewer and optional type.')] + + takes_args = ['submit_branch?'] + + aliases = ['lp-submit', 'lp-propose'] + + def run(self, submit_branch=None, review=None, staging=False, + message=None, approve=False, fixes=None): + from bzrlib.plugins.launchpad import lp_propose + tree, branch, relpath = controldir.ControlDir.open_containing_tree_or_branch( + '.') + if review is None: + reviews = None + else: + reviews = [] + for review in review: + if '=' in review: + reviews.append(review.split('=', 2)) + else: + reviews.append((review, '')) + if submit_branch is None: + submit_branch = branch.get_submit_branch() + if submit_branch is None: + target = None + else: + target = _mod_branch.Branch.open(submit_branch) + proposer = lp_propose.Proposer(tree, branch, target, message, + reviews, staging, approve=approve, + fixes=fixes) + proposer.check_proposal() + proposer.create_proposal() + + +class cmd_lp_find_proposal(Command): + + __doc__ = """Find the proposal to merge this revision. + + Finds the merge proposal(s) that discussed landing the specified revision. + This works only if the selected branch was the merge proposal target, and + if the merged_revno is recorded for the merge proposal. The proposal(s) + are opened in a web browser. + + Any revision involved in the merge may be specified-- the revision in + which the merge was performed, or one of the revisions that was merged. + + So, to find the merge proposal that reviewed line 1 of README:: + + bzr lp-find-proposal -r annotate:README:1 + """ + + takes_options = ['revision'] + + def run(self, revision=None): + from bzrlib import ui + from bzrlib.plugins.launchpad import lp_api + import webbrowser + b = _mod_branch.Branch.open_containing('.')[0] + pb = ui.ui_factory.nested_progress_bar() + b.lock_read() + try: + revno = self._find_merged_revno(revision, b, pb) + merged = self._find_proposals(revno, b, pb) + if len(merged) == 0: + raise BzrCommandError(gettext('No review found.')) + trace.note(gettext('%d proposals(s) found.') % len(merged)) + for mp in merged: + webbrowser.open(lp_api.canonical_url(mp)) + finally: + b.unlock() + pb.finished() + + def _find_merged_revno(self, revision, b, pb): + if revision is None: + return b.revno() + pb.update(gettext('Finding revision-id')) + revision_id = revision[0].as_revision_id(b) + # a revno spec is necessarily on the mainline. + if self._is_revno_spec(revision[0]): + merging_revision = revision_id + else: + graph = b.repository.get_graph() + pb.update(gettext('Finding merge')) + merging_revision = graph.find_lefthand_merger( + revision_id, b.last_revision()) + if merging_revision is None: + raise InvalidRevisionSpec(revision[0].user_spec, b) + pb.update(gettext('Finding revno')) + return b.revision_id_to_revno(merging_revision) + + def _find_proposals(self, revno, b, pb): + from bzrlib.plugins.launchpad import (lp_api, lp_registration) + launchpad = lp_api.login(lp_registration.LaunchpadService()) + pb.update(gettext('Finding Launchpad branch')) + lpb = lp_api.LaunchpadBranch.from_bzr(launchpad, b, + create_missing=False) + pb.update(gettext('Finding proposals')) + return list(lpb.lp.getMergeProposals(status=['Merged'], + merged_revnos=[revno])) + + + @staticmethod + def _is_revno_spec(spec): + try: + int(spec.user_spec) + except ValueError: + return False + else: + return True + + + diff --git a/bzrlib/plugins/launchpad/lp_api.py b/bzrlib/plugins/launchpad/lp_api.py new file mode 100644 index 0000000..6d5a7a0 --- /dev/null +++ b/bzrlib/plugins/launchpad/lp_api.py @@ -0,0 +1,313 @@ +# Copyright (C) 2009, 2010 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Tools for dealing with the Launchpad API.""" + +from __future__ import absolute_import + +# Importing this module will be expensive, since it imports launchpadlib and +# its dependencies. However, our plan is to only load this module when it is +# needed by a command that uses it. + + +import os +import re +import urlparse + +from bzrlib import ( + branch, + config, + errors, + osutils, + trace, + transport, + ) +from bzrlib.i18n import gettext +from bzrlib.plugins.launchpad.lp_registration import ( + InvalidLaunchpadInstance, + ) + +try: + import launchpadlib +except ImportError, e: + raise errors.DependencyNotPresent('launchpadlib', e) + +from launchpadlib.launchpad import ( + STAGING_SERVICE_ROOT, + Launchpad, + ) + + +# Declare the minimum version of launchpadlib that we need in order to work. +# 1.5.1 is the version of launchpadlib packaged in Ubuntu 9.10, the most +# recent Ubuntu release at the time of writing. +MINIMUM_LAUNCHPADLIB_VERSION = (1, 5, 1) + + +def get_cache_directory(): + """Return the directory to cache launchpadlib objects in.""" + return osutils.pathjoin(config.config_dir(), 'launchpad') + + +def parse_launchpadlib_version(version_number): + """Parse a version number of the style used by launchpadlib.""" + return tuple(map(int, version_number.split('.'))) + + +def check_launchpadlib_compatibility(): + """Raise an error if launchpadlib has the wrong version number.""" + installed_version = parse_launchpadlib_version(launchpadlib.__version__) + if installed_version < MINIMUM_LAUNCHPADLIB_VERSION: + raise errors.IncompatibleAPI( + 'launchpadlib', MINIMUM_LAUNCHPADLIB_VERSION, + installed_version, installed_version) + + +# The older versions of launchpadlib only provided service root constants for +# edge and staging, whilst newer versions drop edge. Therefore service root +# URIs for which we do not always have constants are derived from the staging +# one, which does always exist. +# +# It is necessary to derive, rather than use hardcoded URIs because +# launchpadlib <= 1.5.4 requires service root URIs that end in a path of +# /beta/, whilst launchpadlib >= 1.5.5 requires service root URIs with no path +# info. +# +# Once we have a hard dependency on launchpadlib >= 1.5.4 we can replace all of +# bzr's local knowledge of individual Launchpad instances with use of the +# launchpadlib.uris module. +LAUNCHPAD_API_URLS = { + 'production': STAGING_SERVICE_ROOT.replace('api.staging.launchpad.net', + 'api.launchpad.net'), + 'qastaging': STAGING_SERVICE_ROOT.replace('api.staging.launchpad.net', + 'api.qastaging.launchpad.net'), + 'staging': STAGING_SERVICE_ROOT, + 'dev': STAGING_SERVICE_ROOT.replace('api.staging.launchpad.net', + 'api.launchpad.dev'), + } + + +def _get_api_url(service): + """Return the root URL of the Launchpad API. + + e.g. For the 'staging' Launchpad service, this function returns + launchpadlib.launchpad.STAGING_SERVICE_ROOT. + + :param service: A `LaunchpadService` object. + :return: A URL as a string. + """ + if service._lp_instance is None: + lp_instance = service.DEFAULT_INSTANCE + else: + lp_instance = service._lp_instance + try: + return LAUNCHPAD_API_URLS[lp_instance] + except KeyError: + raise InvalidLaunchpadInstance(lp_instance) + + +class NoLaunchpadBranch(errors.BzrError): + _fmt = 'No launchpad branch could be found for branch "%(url)s".' + + def __init__(self, branch): + errors.BzrError.__init__(self, branch=branch, url=branch.base) + + +def login(service, timeout=None, proxy_info=None): + """Log in to the Launchpad API. + + :return: The root `Launchpad` object from launchpadlib. + """ + cache_directory = get_cache_directory() + launchpad = Launchpad.login_with( + 'bzr', _get_api_url(service), cache_directory, timeout=timeout, + proxy_info=proxy_info) + # XXX: Work-around a minor security bug in launchpadlib 1.5.1, which would + # create this directory with default umask. + osutils.chmod_if_possible(cache_directory, 0700) + return launchpad + + +class LaunchpadBranch(object): + """Provide bzr and lp API access to a Launchpad branch.""" + + def __init__(self, lp_branch, bzr_url, bzr_branch=None, check_update=True): + """Constructor. + + :param lp_branch: The Launchpad branch. + :param bzr_url: The URL of the Bazaar branch. + :param bzr_branch: An instance of the Bazaar branch. + """ + self.bzr_url = bzr_url + self._bzr = bzr_branch + self._push_bzr = None + self._check_update = check_update + self.lp = lp_branch + + @property + def bzr(self): + """Return the bzr branch for this branch.""" + if self._bzr is None: + self._bzr = branch.Branch.open(self.bzr_url) + return self._bzr + + @property + def push_bzr(self): + """Return the push branch for this branch.""" + if self._push_bzr is None: + self._push_bzr = branch.Branch.open(self.lp.bzr_identity) + return self._push_bzr + + @staticmethod + def plausible_launchpad_url(url): + """Is 'url' something that could conceivably be pushed to LP? + + :param url: A URL that may refer to a Launchpad branch. + :return: A boolean. + """ + if url is None: + return False + if url.startswith('lp:'): + return True + regex = re.compile('([a-z]*\+)*(bzr\+ssh|http)' + '://bazaar.*.launchpad.net') + return bool(regex.match(url)) + + @staticmethod + def candidate_urls(bzr_branch): + """Iterate through related URLs that might be Launchpad URLs. + + :param bzr_branch: A Bazaar branch to find URLs from. + :return: a generator of URL strings. + """ + url = bzr_branch.get_public_branch() + if url is not None: + yield url + url = bzr_branch.get_push_location() + if url is not None: + yield url + url = bzr_branch.get_parent() + if url is not None: + yield url + yield bzr_branch.base + + @staticmethod + def tweak_url(url, launchpad): + """Adjust a URL to work with staging, if needed.""" + if str(launchpad._root_uri) == STAGING_SERVICE_ROOT: + return url.replace('bazaar.launchpad.net', + 'bazaar.staging.launchpad.net') + elif str(launchpad._root_uri) == LAUNCHPAD_API_URLS['qastaging']: + return url.replace('bazaar.launchpad.net', + 'bazaar.qastaging.launchpad.net') + return url + + @classmethod + def from_bzr(cls, launchpad, bzr_branch, create_missing=True): + """Find a Launchpad branch from a bzr branch.""" + check_update = True + for url in cls.candidate_urls(bzr_branch): + url = cls.tweak_url(url, launchpad) + if not cls.plausible_launchpad_url(url): + continue + lp_branch = launchpad.branches.getByUrl(url=url) + if lp_branch is not None: + break + else: + if not create_missing: + raise NoLaunchpadBranch(bzr_branch) + lp_branch = cls.create_now(launchpad, bzr_branch) + check_update = False + return cls(lp_branch, bzr_branch.base, bzr_branch, check_update) + + @classmethod + def create_now(cls, launchpad, bzr_branch): + """Create a Bazaar branch on Launchpad for the supplied branch.""" + url = cls.tweak_url(bzr_branch.get_push_location(), launchpad) + if not cls.plausible_launchpad_url(url): + raise errors.BzrError(gettext('%s is not registered on Launchpad') % + bzr_branch.base) + bzr_branch.create_clone_on_transport(transport.get_transport(url)) + lp_branch = launchpad.branches.getByUrl(url=url) + if lp_branch is None: + raise errors.BzrError(gettext('%s is not registered on Launchpad') % + url) + return lp_branch + + def get_target(self): + """Return the 'LaunchpadBranch' for the target of this one.""" + lp_branch = self.lp + if lp_branch.project is not None: + dev_focus = lp_branch.project.development_focus + if dev_focus is None: + raise errors.BzrError(gettext('%s has no development focus.') % + lp_branch.bzr_identity) + target = dev_focus.branch + if target is None: + raise errors.BzrError(gettext( + 'development focus %s has no branch.') % dev_focus) + elif lp_branch.sourcepackage is not None: + target = lp_branch.sourcepackage.getBranch(pocket="Release") + if target is None: + raise errors.BzrError(gettext( + 'source package %s has no branch.') % + lp_branch.sourcepackage) + else: + raise errors.BzrError(gettext( + '%s has no associated product or source package.') % + lp_branch.bzr_identity) + return LaunchpadBranch(target, target.bzr_identity) + + def update_lp(self): + """Update the Launchpad copy of this branch.""" + if not self._check_update: + return + self.bzr.lock_read() + try: + if self.lp.last_scanned_id is not None: + if self.bzr.last_revision() == self.lp.last_scanned_id: + trace.note(gettext('%s is already up-to-date.') % + self.lp.bzr_identity) + return + graph = self.bzr.repository.get_graph() + if not graph.is_ancestor(self.lp.last_scanned_id, + self.bzr.last_revision()): + raise errors.DivergedBranches(self.bzr, self.push_bzr) + trace.note(gettext('Pushing to %s') % self.lp.bzr_identity) + self.bzr.push(self.push_bzr) + finally: + self.bzr.unlock() + + def find_lca_tree(self, other): + """Find the revision tree for the LCA of this branch and other. + + :param other: Another LaunchpadBranch + :return: The RevisionTree of the LCA of this branch and other. + """ + graph = self.bzr.repository.get_graph(other.bzr.repository) + lca = graph.find_unique_lca(self.bzr.last_revision(), + other.bzr.last_revision()) + return self.bzr.repository.revision_tree(lca) + + +def canonical_url(object): + """Return the canonical URL for a branch.""" + scheme, netloc, path, params, query, fragment = urlparse.urlparse( + str(object.self_link)) + path = '/'.join(path.split('/')[2:]) + netloc = netloc.replace('api.', 'code.') + return urlparse.urlunparse((scheme, netloc, path, params, query, + fragment)) diff --git a/bzrlib/plugins/launchpad/lp_api_lite.py b/bzrlib/plugins/launchpad/lp_api_lite.py new file mode 100644 index 0000000..f28a2eb --- /dev/null +++ b/bzrlib/plugins/launchpad/lp_api_lite.py @@ -0,0 +1,288 @@ +# Copyright (C) 2011 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Tools for dealing with the Launchpad API without using launchpadlib. + +The api itself is a RESTful interface, so we can make HTTP queries directly. +loading launchpadlib itself has a fairly high overhead (just calling +Launchpad.login_anonymously() takes a 500ms once the WADL is cached, and 5+s to +get the WADL. +""" + +from __future__ import absolute_import + +try: + # Use simplejson if available, much faster, and can be easily installed in + # older versions of python + import simplejson as json +except ImportError: + # Is present since python 2.6 + try: + import json + except ImportError: + json = None + +import time +import urllib +import urllib2 + +from bzrlib import ( + revision, + trace, + ) + + +class LatestPublication(object): + """Encapsulate how to find the latest publication for a given project.""" + + LP_API_ROOT = 'https://api.launchpad.net/1.0' + + def __init__(self, archive, series, project): + self._archive = archive + self._project = project + self._setup_series_and_pocket(series) + + def _setup_series_and_pocket(self, series): + """Parse the 'series' info into a series and a pocket. + + eg:: + _setup_series_and_pocket('natty-proposed') + => _series == 'natty' + _pocket == 'Proposed' + """ + self._series = series + self._pocket = None + if self._series is not None and '-' in self._series: + self._series, self._pocket = self._series.split('-', 1) + self._pocket = self._pocket.title() + else: + self._pocket = 'Release' + + def _archive_URL(self): + """Return the Launchpad 'Archive' URL that we will query. + This is everything in the URL except the query parameters. + """ + return '%s/%s/+archive/primary' % (self.LP_API_ROOT, self._archive) + + def _publication_status(self): + """Handle the 'status' field. + It seems that Launchpad tracks all 'debian' packages as 'Pending', while + for 'ubuntu' we care about the 'Published' packages. + """ + if self._archive == 'debian': + # Launchpad only tracks debian packages as "Pending", it doesn't mark + # them Published + return 'Pending' + return 'Published' + + def _query_params(self): + """Get the parameters defining our query. + This defines the actions we are making against the archive. + :return: A dict of query parameters. + """ + params = {'ws.op': 'getPublishedSources', + 'exact_match': 'true', + # If we need to use "" shouldn't we quote the project somehow? + 'source_name': '"%s"' % (self._project,), + 'status': self._publication_status(), + # We only need the latest one, the results seem to be properly + # most-recent-debian-version sorted + 'ws.size': '1', + } + if self._series is not None: + params['distro_series'] = '/%s/%s' % (self._archive, self._series) + if self._pocket is not None: + params['pocket'] = self._pocket + return params + + def _query_URL(self): + """Create the full URL that we need to query, including parameters.""" + params = self._query_params() + # We sort to give deterministic results for testing + encoded = urllib.urlencode(sorted(params.items())) + return '%s?%s' % (self._archive_URL(), encoded) + + def _get_lp_info(self): + """Place an actual HTTP query against the Launchpad service.""" + if json is None: + return None + query_URL = self._query_URL() + try: + req = urllib2.Request(query_URL) + response = urllib2.urlopen(req) + json_info = response.read() + # TODO: We haven't tested the HTTPError + except (urllib2.URLError, urllib2.HTTPError), e: + trace.mutter('failed to place query to %r' % (query_URL,)) + trace.log_exception_quietly() + return None + return json_info + + def _parse_json_info(self, json_info): + """Parse the json response from Launchpad into objects.""" + if json is None: + return None + try: + return json.loads(json_info) + except Exception: + trace.mutter('Failed to parse json info: %r' % (json_info,)) + trace.log_exception_quietly() + return None + + def get_latest_version(self): + """Get the latest published version for the given package.""" + json_info = self._get_lp_info() + if json_info is None: + return None + info = self._parse_json_info(json_info) + if info is None: + return None + try: + entries = info['entries'] + if len(entries) == 0: + return None + return entries[0]['source_package_version'] + except KeyError: + trace.log_exception_quietly() + return None + + def place(self): + """Text-form for what location this represents. + + Example:: + ubuntu, natty => Ubuntu Natty + ubuntu, natty-proposed => Ubuntu Natty Proposed + :return: A string representing the location we are checking. + """ + place = self._archive + if self._series is not None: + place = '%s %s' % (place, self._series) + if self._pocket is not None and self._pocket != 'Release': + place = '%s %s' % (place, self._pocket) + return place.title() + + +def get_latest_publication(archive, series, project): + """Get the most recent publication for a given project. + + :param archive: Either 'ubuntu' or 'debian' + :param series: Something like 'natty', 'sid', etc. Can be set as None. Can + also include a pocket such as 'natty-proposed'. + :param project: Something like 'bzr' + :return: A version string indicating the most-recent version published in + Launchpad. Might return None if there is an error. + """ + lp = LatestPublication(archive, series, project) + return lp.get_latest_version() + + +def get_most_recent_tag(tag_dict, the_branch): + """Get the most recent revision that has been tagged.""" + # Note: this assumes that a given rev won't get tagged multiple times. But + # it should be valid for the package importer branches that we care + # about + reverse_dict = dict((rev, tag) for tag, rev in tag_dict.iteritems()) + the_branch.lock_read() + try: + last_rev = the_branch.last_revision() + graph = the_branch.repository.get_graph() + stop_revisions = (None, revision.NULL_REVISION) + for rev_id in graph.iter_lefthand_ancestry(last_rev, stop_revisions): + if rev_id in reverse_dict: + return reverse_dict[rev_id] + finally: + the_branch.unlock() + + +def _get_newest_versions(the_branch, latest_pub): + """Get information about how 'fresh' this packaging branch is. + + :param the_branch: The Branch to check + :param latest_pub: The LatestPublication used to check most recent + published version. + :return: (latest_ver, branch_latest_ver) + """ + t = time.time() + latest_ver = latest_pub.get_latest_version() + t_latest_ver = time.time() - t + trace.mutter('LatestPublication.get_latest_version took: %.3fs' + % (t_latest_ver,)) + if latest_ver is None: + return None, None + t = time.time() + tags = the_branch.tags.get_tag_dict() + t_tag_dict = time.time() - t + trace.mutter('LatestPublication.get_tag_dict took: %.3fs' % (t_tag_dict,)) + if latest_ver in tags: + # branch might have a newer tag, but we don't really care + return latest_ver, latest_ver + else: + best_tag = get_most_recent_tag(tags, the_branch) + return latest_ver, best_tag + + +def _report_freshness(latest_ver, branch_latest_ver, place, verbosity, + report_func): + """Report if the branch is up-to-date.""" + if latest_ver is None: + if verbosity == 'all': + report_func('Most recent %s version: MISSING' % (place,)) + elif verbosity == 'short': + report_func('%s is MISSING a version' % (place,)) + return + elif latest_ver == branch_latest_ver: + if verbosity == 'minimal': + return + elif verbosity == 'short': + report_func('%s is CURRENT in %s' % (latest_ver, place)) + else: + report_func('Most recent %s version: %s\n' + 'Packaging branch status: CURRENT' + % (place, latest_ver)) + else: + if verbosity in ('minimal', 'short'): + if branch_latest_ver is None: + branch_latest_ver = 'Branch' + report_func('%s is OUT-OF-DATE, %s has %s' + % (branch_latest_ver, place, latest_ver)) + else: + report_func('Most recent %s version: %s\n' + 'Packaging branch version: %s\n' + 'Packaging branch status: OUT-OF-DATE' + % (place, latest_ver, branch_latest_ver)) + + +def report_freshness(the_branch, verbosity, latest_pub): + """Report to the user how up-to-date the packaging branch is. + + :param the_branch: A Branch object + :param verbosity: Can be one of: + off: Do not print anything, and skip all checks. + all: Print all information that we have in a verbose manner, this + includes misses, etc. + short: Print information, but only one-line summaries + minimal: Only print a one-line summary when the package branch is + out-of-date + :param latest_pub: A LatestPublication instance + """ + if verbosity == 'off': + return + if verbosity is None: + verbosity = 'all' + latest_ver, branch_ver = _get_newest_versions(the_branch, latest_pub) + place = latest_pub.place() + _report_freshness(latest_ver, branch_ver, place, verbosity, + trace.note) diff --git a/bzrlib/plugins/launchpad/lp_directory.py b/bzrlib/plugins/launchpad/lp_directory.py new file mode 100644 index 0000000..3e88f69 --- /dev/null +++ b/bzrlib/plugins/launchpad/lp_directory.py @@ -0,0 +1,209 @@ +# Copyright (C) 2007-2011 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Directory lookup that uses Launchpad.""" + +from __future__ import absolute_import + +from urlparse import urlsplit +import xmlrpclib + +from bzrlib import ( + debug, + errors, + trace, + transport, + ) +from bzrlib.i18n import gettext + +from bzrlib.plugins.launchpad.lp_registration import ( + LaunchpadService, ResolveLaunchpadPathRequest) +from bzrlib.plugins.launchpad.account import get_lp_login + + +# As bzrlib.transport.remote may not be loaded yet, make sure bzr+ssh +# is counted as a netloc protocol. +transport.register_urlparse_netloc_protocol('bzr+ssh') +transport.register_urlparse_netloc_protocol('lp') + +_ubuntu_series_shortcuts = { + 'n': 'natty', + 'm': 'maverick', + 'l': 'lucid', + 'k': 'karmic', + 'j': 'jaunty', + 'h': 'hardy', + 'd': 'dapper', + } + + +class LaunchpadDirectory(object): + + def _requires_launchpad_login(self, scheme, netloc, path, query, + fragment): + """Does the URL require a Launchpad login in order to be reached? + + The URL is specified by its parsed components, as returned from + urlsplit. + """ + return (scheme in ('bzr+ssh', 'sftp') + and (netloc.endswith('launchpad.net') + or netloc.endswith('launchpad.dev'))) + + def look_up(self, name, url): + """See DirectoryService.look_up""" + return self._resolve(url) + + def _resolve_locally(self, path, url, _request_factory): + # This is the best I could work out about XMLRPC. If an lp: url + # includes ~user, then it is specially validated. Otherwise, it is just + # sent to +branch/$path. + _, netloc, _, _, _ = urlsplit(url) + if netloc == '': + netloc = LaunchpadService.DEFAULT_INSTANCE + base_url = LaunchpadService.LAUNCHPAD_DOMAINS[netloc] + base = 'bzr+ssh://bazaar.%s/' % (base_url,) + maybe_invalid = False + if path.startswith('~'): + # A ~user style path, validate it a bit. + # If a path looks fishy, fall back to asking XMLRPC to + # resolve it for us. That way we still get their nicer error + # messages. + parts = path.split('/') + if (len(parts) < 3 + or (parts[1] in ('ubuntu', 'debian') and len(parts) < 5)): + # This special case requires 5-parts to be valid. + maybe_invalid = True + else: + base += '+branch/' + if maybe_invalid: + return self._resolve_via_xmlrpc(path, url, _request_factory) + return {'urls': [base + path]} + + def _resolve_via_xmlrpc(self, path, url, _request_factory): + service = LaunchpadService.for_url(url) + resolve = _request_factory(path) + try: + result = resolve.submit(service) + except xmlrpclib.Fault, fault: + raise errors.InvalidURL( + path=url, extra=fault.faultString) + return result + + def _update_url_scheme(self, url): + # Do ubuntu: and debianlp: expansions. + scheme, netloc, path, query, fragment = urlsplit(url) + if scheme in ('ubuntu', 'debianlp'): + if scheme == 'ubuntu': + distro = 'ubuntu' + distro_series = _ubuntu_series_shortcuts + elif scheme == 'debianlp': + distro = 'debian' + # No shortcuts for Debian distroseries. + distro_series = {} + else: + raise AssertionError('scheme should be ubuntu: or debianlp:') + # Split the path. It's either going to be 'project' or + # 'series/project', but recognize that it may be a series we don't + # know about. + path_parts = path.split('/') + if len(path_parts) == 1: + # It's just a project name. + lp_url_template = 'lp:%(distro)s/%(project)s' + project = path_parts[0] + series = None + elif len(path_parts) == 2: + # It's a series and project. + lp_url_template = 'lp:%(distro)s/%(series)s/%(project)s' + series, project = path_parts + else: + # There are either 0 or > 2 path parts, neither of which is + # supported for these schemes. + raise errors.InvalidURL('Bad path: %s' % url) + # Expand any series shortcuts, but keep unknown series. + series = distro_series.get(series, series) + # Hack the url and let the following do the final resolution. + url = lp_url_template % dict( + distro=distro, + series=series, + project=project) + scheme, netloc, path, query, fragment = urlsplit(url) + return url, path + + def _expand_user(self, path, url, lp_login): + if path.startswith('~/'): + if lp_login is None: + raise errors.InvalidURL(path=url, + extra='Cannot resolve "~" to your username.' + ' See "bzr help launchpad-login"') + path = '~' + lp_login + path[1:] + return path + + def _resolve(self, url, + _request_factory=ResolveLaunchpadPathRequest, + _lp_login=None): + """Resolve the base URL for this transport.""" + url, path = self._update_url_scheme(url) + if _lp_login is None: + _lp_login = get_lp_login() + path = path.strip('/') + path = self._expand_user(path, url, _lp_login) + if _lp_login is not None: + result = self._resolve_locally(path, url, _request_factory) + if 'launchpad' in debug.debug_flags: + local_res = result + result = self._resolve_via_xmlrpc(path, url, _request_factory) + trace.note(gettext( + 'resolution for {0}\n local: {1}\n remote: {2}').format( + url, local_res['urls'], result['urls'])) + else: + result = self._resolve_via_xmlrpc(path, url, _request_factory) + + if 'launchpad' in debug.debug_flags: + trace.mutter("resolve_lp_path(%r) == %r", url, result) + + _warned_login = False + for url in result['urls']: + scheme, netloc, path, query, fragment = urlsplit(url) + if self._requires_launchpad_login(scheme, netloc, path, query, + fragment): + # Only accept launchpad.net bzr+ssh URLs if we know + # the user's Launchpad login: + if _lp_login is not None: + break + if _lp_login is None: + if not _warned_login: + trace.warning( +'You have not informed bzr of your Launchpad ID, and you must do this to\n' +'write to Launchpad or access private data. See "bzr help launchpad-login".') + _warned_login = True + else: + # Use the URL if we can create a transport for it. + try: + transport.get_transport(url) + except (errors.PathError, errors.TransportError): + pass + else: + break + else: + raise errors.InvalidURL(path=url, extra='no supported schemes') + return url + + +def get_test_permutations(): + # Since this transport doesn't do anything once opened, it's not subjected + # to the usual transport tests. + return [] diff --git a/bzrlib/plugins/launchpad/lp_propose.py b/bzrlib/plugins/launchpad/lp_propose.py new file mode 100644 index 0000000..7142515 --- /dev/null +++ b/bzrlib/plugins/launchpad/lp_propose.py @@ -0,0 +1,221 @@ +# Copyright (C) 2010, 2011 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +from __future__ import absolute_import + +from bzrlib import ( + errors, + hooks, + ) +from bzrlib.lazy_import import lazy_import +lazy_import(globals(), """ +import webbrowser + +from bzrlib import ( + msgeditor, + ) +from bzrlib.i18n import gettext +from bzrlib.plugins.launchpad import ( + lp_api, + lp_registration, + ) +""") + + +class ProposeMergeHooks(hooks.Hooks): + """Hooks for proposing a merge on Launchpad.""" + + def __init__(self): + hooks.Hooks.__init__(self, "bzrlib.plugins.launchpad.lp_propose", + "Proposer.hooks") + self.add_hook('get_prerequisite', + "Return the prerequisite branch for proposing as merge.", (2, 1)) + self.add_hook('merge_proposal_body', + "Return an initial body for the merge proposal message.", (2, 1)) + + +class Proposer(object): + + hooks = ProposeMergeHooks() + + def __init__(self, tree, source_branch, target_branch, message, reviews, + staging=False, approve=False, fixes=None): + """Constructor. + + :param tree: The working tree for the source branch. + :param source_branch: The branch to propose for merging. + :param target_branch: The branch to merge into. + :param message: The commit message to use. (May be None.) + :param reviews: A list of tuples of reviewer, review type. + :param staging: If True, propose the merge against staging instead of + production. + :param approve: If True, mark the new proposal as approved immediately. + This is useful when a project permits some things to be approved + by the submitter (e.g. merges between release and deployment + branches). + """ + self.tree = tree + if staging: + lp_instance = 'staging' + else: + lp_instance = 'production' + service = lp_registration.LaunchpadService(lp_instance=lp_instance) + self.launchpad = lp_api.login(service) + self.source_branch = lp_api.LaunchpadBranch.from_bzr( + self.launchpad, source_branch) + if target_branch is None: + self.target_branch = self.source_branch.get_target() + else: + self.target_branch = lp_api.LaunchpadBranch.from_bzr( + self.launchpad, target_branch) + self.commit_message = message + # XXX: this is where bug lp:583638 could be tackled. + if reviews == []: + self.reviews = [] + else: + self.reviews = [(self.launchpad.people[reviewer], review_type) + for reviewer, review_type in + reviews] + self.approve = approve + self.fixes = fixes + + def get_comment(self, prerequisite_branch): + """Determine the initial comment for the merge proposal.""" + info = ["Source: %s\n" % self.source_branch.lp.bzr_identity] + info.append("Target: %s\n" % self.target_branch.lp.bzr_identity) + if prerequisite_branch is not None: + info.append("Prereq: %s\n" % prerequisite_branch.lp.bzr_identity) + for rdata in self.reviews: + uniquename = "%s (%s)" % (rdata[0].display_name, rdata[0].name) + info.append('Reviewer: %s, type "%s"\n' % (uniquename, rdata[1])) + self.source_branch.bzr.lock_read() + try: + self.target_branch.bzr.lock_read() + try: + body = self.get_initial_body() + finally: + self.target_branch.bzr.unlock() + finally: + self.source_branch.bzr.unlock() + initial_comment = msgeditor.edit_commit_message(''.join(info), + start_message=body) + return initial_comment.strip().encode('utf-8') + + def get_initial_body(self): + """Get a body for the proposal for the user to modify. + + :return: a str or None. + """ + def list_modified_files(): + lca_tree = self.source_branch.find_lca_tree( + self.target_branch) + source_tree = self.source_branch.bzr.basis_tree() + files = modified_files(lca_tree, source_tree) + return list(files) + target_loc = ('bzr+ssh://bazaar.launchpad.net/%s' % + self.target_branch.lp.unique_name) + body = None + for hook in self.hooks['merge_proposal_body']: + body = hook({ + 'tree': self.tree, + 'target_branch': target_loc, + 'modified_files_callback': list_modified_files, + 'old_body': body, + }) + return body + + def check_proposal(self): + """Check that the submission is sensible.""" + if self.source_branch.lp.self_link == self.target_branch.lp.self_link: + raise errors.BzrCommandError( + 'Source and target branches must be different.') + for mp in self.source_branch.lp.landing_targets: + if mp.queue_status in ('Merged', 'Rejected'): + continue + if mp.target_branch.self_link == self.target_branch.lp.self_link: + raise errors.BzrCommandError(gettext( + 'There is already a branch merge proposal: %s') % + lp_api.canonical_url(mp)) + + def _get_prerequisite_branch(self): + hooks = self.hooks['get_prerequisite'] + prerequisite_branch = None + for hook in hooks: + prerequisite_branch = hook( + {'launchpad': self.launchpad, + 'source_branch': self.source_branch, + 'target_branch': self.target_branch, + 'prerequisite_branch': prerequisite_branch}) + return prerequisite_branch + + def call_webservice(self, call, *args, **kwargs): + """Make a call to the webservice, wrapping failures. + + :param call: The call to make. + :param *args: *args for the call. + :param **kwargs: **kwargs for the call. + :return: The result of calling call(*args, *kwargs). + """ + from lazr.restfulclient import errors as restful_errors + try: + return call(*args, **kwargs) + except restful_errors.HTTPError, e: + error_lines = [] + for line in e.content.splitlines(): + if line.startswith('Traceback (most recent call last):'): + break + error_lines.append(line) + raise Exception(''.join(error_lines)) + + def create_proposal(self): + """Perform the submission.""" + prerequisite_branch = self._get_prerequisite_branch() + if prerequisite_branch is None: + prereq = None + else: + prereq = prerequisite_branch.lp + prerequisite_branch.update_lp() + self.source_branch.update_lp() + reviewers = [] + review_types = [] + for reviewer, review_type in self.reviews: + review_types.append(review_type) + reviewers.append(reviewer.self_link) + initial_comment = self.get_comment(prerequisite_branch) + mp = self.call_webservice( + self.source_branch.lp.createMergeProposal, + target_branch=self.target_branch.lp, + prerequisite_branch=prereq, + initial_comment=initial_comment, + commit_message=self.commit_message, reviewers=reviewers, + review_types=review_types) + if self.approve: + self.call_webservice(mp.setStatus, status='Approved') + if self.fixes: + if self.fixes.startswith('lp:'): + self.fixes = self.fixes[3:] + self.call_webservice( + self.source_branch.lp.linkBug, + bug=self.launchpad.bugs[int(self.fixes)]) + webbrowser.open(lp_api.canonical_url(mp)) + + +def modified_files(old_tree, new_tree): + """Return a list of paths in the new tree with modified contents.""" + for f, (op, path), c, v, p, n, (ok, k), e in new_tree.iter_changes( + old_tree): + if c and k == 'file': + yield str(path) diff --git a/bzrlib/plugins/launchpad/lp_registration.py b/bzrlib/plugins/launchpad/lp_registration.py new file mode 100644 index 0000000..dae825f --- /dev/null +++ b/bzrlib/plugins/launchpad/lp_registration.py @@ -0,0 +1,358 @@ +# Copyright (C) 2006-2011 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +from __future__ import absolute_import + + +import os +import socket +from urlparse import urlsplit, urlunsplit +import urllib +import xmlrpclib + +from bzrlib import ( + config, + errors, + urlutils, + __version__ as _bzrlib_version, + ) +from bzrlib.transport.http import _urllib2_wrappers + + +# for testing, do +''' +export BZR_LP_XMLRPC_URL=http://xmlrpc.staging.launchpad.net/bazaar/ +''' + +class InvalidLaunchpadInstance(errors.BzrError): + + _fmt = "%(lp_instance)s is not a valid Launchpad instance." + + def __init__(self, lp_instance): + errors.BzrError.__init__(self, lp_instance=lp_instance) + + +class NotLaunchpadBranch(errors.BzrError): + + _fmt = "%(url)s is not registered on Launchpad." + + def __init__(self, url): + errors.BzrError.__init__(self, url=url) + + +class XMLRPCTransport(xmlrpclib.Transport): + + def __init__(self, scheme): + xmlrpclib.Transport.__init__(self) + self._scheme = scheme + self._opener = _urllib2_wrappers.Opener() + self.verbose = 0 + + def request(self, host, handler, request_body, verbose=0): + self.verbose = verbose + url = self._scheme + "://" + host + handler + request = _urllib2_wrappers.Request("POST", url, request_body) + # FIXME: _urllib2_wrappers will override user-agent with its own + # request.add_header("User-Agent", self.user_agent) + request.add_header("Content-Type", "text/xml") + + response = self._opener.open(request) + if response.code != 200: + raise xmlrpclib.ProtocolError(host + handler, response.code, + response.msg, response.info()) + return self.parse_response(response) + + +class LaunchpadService(object): + """A service to talk to Launchpad via XMLRPC. + + See http://wiki.bazaar.canonical.com/Specs/LaunchpadRpc for the methods we can call. + """ + + LAUNCHPAD_DOMAINS = { + 'production': 'launchpad.net', + 'staging': 'staging.launchpad.net', + 'qastaging': 'qastaging.launchpad.net', + 'demo': 'demo.launchpad.net', + 'dev': 'launchpad.dev', + } + + # NB: these should always end in a slash to avoid xmlrpclib appending + # '/RPC2' + LAUNCHPAD_INSTANCE = {} + for instance, domain in LAUNCHPAD_DOMAINS.iteritems(): + LAUNCHPAD_INSTANCE[instance] = 'https://xmlrpc.%s/bazaar/' % domain + + # We use production as the default because edge has been deprecated circa + # 2010-11 (see bug https://bugs.launchpad.net/bzr/+bug/583667) + DEFAULT_INSTANCE = 'production' + DEFAULT_SERVICE_URL = LAUNCHPAD_INSTANCE[DEFAULT_INSTANCE] + + transport = None + registrant_email = None + registrant_password = None + + + def __init__(self, transport=None, lp_instance=None): + """Construct a new service talking to the launchpad rpc server""" + self._lp_instance = lp_instance + if transport is None: + uri_type = urllib.splittype(self.service_url)[0] + transport = XMLRPCTransport(uri_type) + transport.user_agent = 'bzr/%s (xmlrpclib/%s)' \ + % (_bzrlib_version, xmlrpclib.__version__) + self.transport = transport + + @property + def service_url(self): + """Return the http or https url for the xmlrpc server. + + This does not include the username/password credentials. + """ + key = 'BZR_LP_XMLRPC_URL' + if key in os.environ: + return os.environ[key] + elif self._lp_instance is not None: + try: + return self.LAUNCHPAD_INSTANCE[self._lp_instance] + except KeyError: + raise InvalidLaunchpadInstance(self._lp_instance) + else: + return self.DEFAULT_SERVICE_URL + + @classmethod + def for_url(cls, url, **kwargs): + """Return the Launchpad service corresponding to the given URL.""" + result = urlsplit(url) + lp_instance = result[1] + if lp_instance == '': + lp_instance = None + elif lp_instance not in cls.LAUNCHPAD_INSTANCE: + raise errors.InvalidURL(path=url) + return cls(lp_instance=lp_instance, **kwargs) + + def get_proxy(self, authenticated): + """Return the proxy for XMLRPC requests.""" + if authenticated: + # auth info must be in url + # TODO: if there's no registrant email perhaps we should + # just connect anonymously? + scheme, hostinfo, path = urlsplit(self.service_url)[:3] + if '@' in hostinfo: + raise AssertionError(hostinfo) + if self.registrant_email is None: + raise AssertionError() + if self.registrant_password is None: + raise AssertionError() + # TODO: perhaps fully quote the password to make it very slightly + # obscured + # TODO: can we perhaps add extra Authorization headers + # directly to the request, rather than putting this into + # the url? perhaps a bit more secure against accidentally + # revealing it. std66 s3.2.1 discourages putting the + # password in the url. + hostinfo = '%s:%s@%s' % (urlutils.quote(self.registrant_email), + urlutils.quote(self.registrant_password), + hostinfo) + url = urlunsplit((scheme, hostinfo, path, '', '')) + else: + url = self.service_url + return xmlrpclib.ServerProxy(url, transport=self.transport) + + def gather_user_credentials(self): + """Get the password from the user.""" + the_config = config.GlobalConfig() + self.registrant_email = the_config.user_email() + if self.registrant_password is None: + auth = config.AuthenticationConfig() + scheme, hostinfo = urlsplit(self.service_url)[:2] + prompt = 'launchpad.net password for %s: ' % \ + self.registrant_email + # We will reuse http[s] credentials if we can, prompt user + # otherwise + self.registrant_password = auth.get_password(scheme, hostinfo, + self.registrant_email, + prompt=prompt) + + def send_request(self, method_name, method_params, authenticated): + proxy = self.get_proxy(authenticated) + method = getattr(proxy, method_name) + try: + result = method(*method_params) + except xmlrpclib.ProtocolError, e: + if e.errcode == 301: + # TODO: This can give a ProtocolError representing a 301 error, whose + # e.headers['location'] tells where to go and e.errcode==301; should + # probably log something and retry on the new url. + raise NotImplementedError("should resend request to %s, but this isn't implemented" + % e.headers.get('Location', 'NO-LOCATION-PRESENT')) + else: + # we don't want to print the original message because its + # str representation includes the plaintext password. + # TODO: print more headers to help in tracking down failures + raise errors.BzrError("xmlrpc protocol error connecting to %s: %s %s" + % (self.service_url, e.errcode, e.errmsg)) + except socket.gaierror, e: + raise errors.ConnectionError( + "Could not resolve '%s'" % self.domain, + orig_error=e) + return result + + @property + def domain(self): + if self._lp_instance is None: + instance = self.DEFAULT_INSTANCE + else: + instance = self._lp_instance + return self.LAUNCHPAD_DOMAINS[instance] + + def _guess_branch_path(self, branch_url, _request_factory=None): + scheme, hostinfo, path = urlsplit(branch_url)[:3] + if _request_factory is None: + _request_factory = ResolveLaunchpadPathRequest + if scheme == 'lp': + resolve = _request_factory(path) + try: + result = resolve.submit(self) + except xmlrpclib.Fault, fault: + raise errors.InvalidURL(branch_url, str(fault)) + branch_url = result['urls'][0] + path = urlsplit(branch_url)[2] + else: + domains = ( + 'bazaar.%s' % domain + for domain in self.LAUNCHPAD_DOMAINS.itervalues()) + if hostinfo not in domains: + raise NotLaunchpadBranch(branch_url) + return path.lstrip('/') + + def get_web_url_from_branch_url(self, branch_url, _request_factory=None): + """Get the Launchpad web URL for the given branch URL. + + :raise errors.InvalidURL: if 'branch_url' cannot be identified as a + Launchpad branch URL. + :return: The URL of the branch on Launchpad. + """ + path = self._guess_branch_path(branch_url, _request_factory) + return urlutils.join('https://code.%s' % self.domain, path) + + +class BaseRequest(object): + """Base request for talking to a XMLRPC server.""" + + # Set this to the XMLRPC method name. + _methodname = None + _authenticated = True + + def _request_params(self): + """Return the arguments to pass to the method""" + raise NotImplementedError(self._request_params) + + def submit(self, service): + """Submit request to Launchpad XMLRPC server. + + :param service: LaunchpadService indicating where to send + the request and the authentication credentials. + """ + return service.send_request(self._methodname, self._request_params(), + self._authenticated) + + +class DryRunLaunchpadService(LaunchpadService): + """Service that just absorbs requests without sending to server. + + The dummy service does not need authentication. + """ + + def send_request(self, method_name, method_params, authenticated): + pass + + def gather_user_credentials(self): + pass + + +class BranchRegistrationRequest(BaseRequest): + """Request to tell Launchpad about a bzr branch.""" + + _methodname = 'register_branch' + + def __init__(self, branch_url, + branch_name='', + branch_title='', + branch_description='', + author_email='', + product_name='', + ): + if not branch_url: + raise errors.InvalidURL(branch_url, "You need to specify a non-empty branch URL.") + self.branch_url = branch_url + if branch_name: + self.branch_name = branch_name + else: + self.branch_name = self._find_default_branch_name(self.branch_url) + self.branch_title = branch_title + self.branch_description = branch_description + self.author_email = author_email + self.product_name = product_name + + def _request_params(self): + """Return xmlrpc request parameters""" + # This must match the parameter tuple expected by Launchpad for this + # method + return (self.branch_url, + self.branch_name, + self.branch_title, + self.branch_description, + self.author_email, + self.product_name, + ) + + def _find_default_branch_name(self, branch_url): + i = branch_url.rfind('/') + return branch_url[i+1:] + + +class BranchBugLinkRequest(BaseRequest): + """Request to link a bzr branch in Launchpad to a bug.""" + + _methodname = 'link_branch_to_bug' + + def __init__(self, branch_url, bug_id): + self.bug_id = bug_id + self.branch_url = branch_url + + def _request_params(self): + """Return xmlrpc request parameters""" + # This must match the parameter tuple expected by Launchpad for this + # method + return (self.branch_url, self.bug_id, '') + + +class ResolveLaunchpadPathRequest(BaseRequest): + """Request to resolve the path component of an lp: URL.""" + + _methodname = 'resolve_lp_path' + _authenticated = False + + def __init__(self, path): + if not path: + raise errors.InvalidURL(path=path, + extra="You must specify a project.") + self.path = path + + def _request_params(self): + """Return xmlrpc request parameters""" + return (self.path,) diff --git a/bzrlib/plugins/launchpad/test_account.py b/bzrlib/plugins/launchpad/test_account.py new file mode 100644 index 0000000..ca058a6 --- /dev/null +++ b/bzrlib/plugins/launchpad/test_account.py @@ -0,0 +1,117 @@ +# Copyright (C) 2007-2010 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Tests for Launchpad user ID management functions.""" + +from bzrlib import config +from bzrlib.tests import TestCaseInTempDir, TestCaseWithMemoryTransport +from bzrlib.plugins.launchpad import account + + +class LaunchpadAccountTests(TestCaseInTempDir): + + def test_get_lp_login_unconfigured(self): + # Test that get_lp_login() returns None if no username has + # been configured. + my_config = config.MemoryStack('') + self.assertEqual(None, account.get_lp_login(my_config)) + + def test_get_lp_login(self): + # Test that get_lp_login() returns the configured username + my_config = config.MemoryStack( + '[DEFAULT]\nlaunchpad_username=test-user\n') + self.assertEqual('test-user', account.get_lp_login(my_config)) + + def test_set_lp_login(self): + # Test that set_lp_login() updates the config file. + my_config = config.MemoryStack('') + self.assertEqual(None, my_config.get('launchpad_username')) + account.set_lp_login('test-user', my_config) + self.assertEqual( + 'test-user', my_config.get('launchpad_username')) + + def test_unknown_launchpad_username(self): + # Test formatting of UnknownLaunchpadUsername exception + error = account.UnknownLaunchpadUsername(user='test-user') + self.assertEqualDiff('The user name test-user is not registered ' + 'on Launchpad.', str(error)) + + def test_no_registered_ssh_keys(self): + # Test formatting of NoRegisteredSSHKeys exception + error = account.NoRegisteredSSHKeys(user='test-user') + self.assertEqualDiff('The user test-user has not registered any ' + 'SSH keys with Launchpad.\n' + 'See <https://launchpad.net/people/+me>', + str(error)) + + def test_set_lp_login_updates_authentication_conf(self): + self.assertIs(None, account._get_auth_user()) + account.set_lp_login('foo') + self.assertEqual('foo', account._get_auth_user()) + + def test_get_lp_login_does_not_update_for_none_user(self): + account.get_lp_login() + self.assertIs(None, account._get_auth_user()) + + def test_get_lp_login_updates_authentication_conf(self): + account._set_global_option('foo') + self.assertIs(None, account._get_auth_user()) + account.get_lp_login() + auth = config.AuthenticationConfig() + self.assertEqual('foo', account._get_auth_user(auth)) + self.assertEqual('foo', auth.get_user('ssh', 'bazaar.launchpad.net')) + self.assertEqual('foo', auth.get_user('ssh', + 'bazaar.staging.launchpad.net')) + + def test_get_lp_login_leaves_existing_credentials(self): + auth = config.AuthenticationConfig() + auth.set_credentials('Foo', 'bazaar.launchpad.net', 'foo', 'ssh') + auth.set_credentials('Bar', 'bazaar.staging.launchpad.net', 'foo', + 'ssh') + account._set_global_option('foo') + account.get_lp_login() + auth = config.AuthenticationConfig() + credentials = auth.get_credentials('ssh', 'bazaar.launchpad.net') + self.assertEqual('Foo', credentials['name']) + + def test_get_lp_login_errors_on_mismatch(self): + account._set_auth_user('foo') + account._set_global_option('bar') + e = self.assertRaises(account.MismatchedUsernames, + account.get_lp_login) + self.assertEqual('bazaar.conf and authentication.conf disagree about' + ' launchpad account name. Please re-run launchpad-login.', str(e)) + + +class CheckAccountTests(TestCaseWithMemoryTransport): + + def test_check_lp_login_valid_user(self): + transport = self.get_transport() + transport.mkdir('~test-user') + transport.put_bytes('~test-user/+sshkeys', 'some keys here') + account.check_lp_login('test-user', transport) + + def test_check_lp_login_no_user(self): + transport = self.get_transport() + self.assertRaises(account.UnknownLaunchpadUsername, + account.check_lp_login, 'test-user', transport) + + def test_check_lp_login_no_ssh_keys(self): + transport = self.get_transport() + transport.mkdir('~test-user') + transport.put_bytes('~test-user/+sshkeys', '') + self.assertRaises(account.NoRegisteredSSHKeys, + account.check_lp_login, 'test-user', transport) diff --git a/bzrlib/plugins/launchpad/test_lp_api.py b/bzrlib/plugins/launchpad/test_lp_api.py new file mode 100644 index 0000000..ba8e12f --- /dev/null +++ b/bzrlib/plugins/launchpad/test_lp_api.py @@ -0,0 +1,100 @@ +# Copyright (C) 2009, 2010 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + + +from bzrlib import config, errors, osutils +from bzrlib.tests import ( + TestCase, + TestCaseWithTransport, + ) +from bzrlib.tests.features import ( + ModuleAvailableFeature, + ) + + +launchpadlib_feature = ModuleAvailableFeature('launchpadlib') + + +class TestDependencyManagement(TestCase): + """Tests for managing the dependency on launchpadlib.""" + + _test_needs_features = [launchpadlib_feature] + + def setUp(self): + TestCase.setUp(self) + from bzrlib.plugins.launchpad import lp_api + self.lp_api = lp_api + + def patch(self, obj, name, value): + """Temporarily set the 'name' attribute of 'obj' to 'value'.""" + self.overrideAttr(obj, name, value) + + def test_get_launchpadlib_version(self): + # parse_launchpadlib_version returns a tuple of a version number of + # the style used by launchpadlib. + version_info = self.lp_api.parse_launchpadlib_version('1.5.1') + self.assertEqual((1, 5, 1), version_info) + + def test_supported_launchpadlib_version(self): + # If the installed version of launchpadlib is greater than the minimum + # required version of launchpadlib, check_launchpadlib_compatibility + # doesn't raise an error. + launchpadlib = launchpadlib_feature.module + self.patch(launchpadlib, '__version__', '1.5.1') + self.lp_api.MINIMUM_LAUNCHPADLIB_VERSION = (1, 5, 1) + # Doesn't raise an exception. + self.lp_api.check_launchpadlib_compatibility() + + def test_unsupported_launchpadlib_version(self): + # If the installed version of launchpadlib is less than the minimum + # required version of launchpadlib, check_launchpadlib_compatibility + # raises an IncompatibleAPI error. + launchpadlib = launchpadlib_feature.module + self.patch(launchpadlib, '__version__', '1.5.0') + self.lp_api.MINIMUM_LAUNCHPADLIB_VERSION = (1, 5, 1) + self.assertRaises( + errors.IncompatibleAPI, + self.lp_api.check_launchpadlib_compatibility) + + +class TestCacheDirectory(TestCase): + """Tests for get_cache_directory.""" + + _test_needs_features = [launchpadlib_feature] + + def test_get_cache_directory(self): + # get_cache_directory returns the path to a directory inside the + # Bazaar configuration directory. + from bzrlib.plugins.launchpad import lp_api + expected_path = osutils.pathjoin(config.config_dir(), 'launchpad') + self.assertEqual(expected_path, lp_api.get_cache_directory()) + + +class TestLaunchpadMirror(TestCaseWithTransport): + """Tests for the 'bzr lp-mirror' command.""" + + # Testing the lp-mirror command is quite hard, since it must talk to a + # Launchpad server. Here, we just test that the command exists. + + _test_needs_features = [launchpadlib_feature] + + def test_command_exists(self): + out, err = self.run_bzr(['launchpad-mirror', '--help'], retcode=0) + self.assertEqual('', err) + + def test_alias_exists(self): + out, err = self.run_bzr(['lp-mirror', '--help'], retcode=0) + self.assertEqual('', err) diff --git a/bzrlib/plugins/launchpad/test_lp_api_lite.py b/bzrlib/plugins/launchpad/test_lp_api_lite.py new file mode 100644 index 0000000..f4b5ea6 --- /dev/null +++ b/bzrlib/plugins/launchpad/test_lp_api_lite.py @@ -0,0 +1,549 @@ +# Copyright (C) 2011 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Tools for dealing with the Launchpad API without using launchpadlib. +""" + +import doctest +import socket + +from bzrlib import tests +from bzrlib.tests import features +from bzrlib.plugins import launchpad +from bzrlib.plugins.launchpad import lp_api_lite +from testtools.matchers import DocTestMatches + + +class _JSONParserFeature(features.Feature): + + def _probe(self): + return lp_api_lite.json is not None + + def feature_name(self): + return 'simplejson or json' + + +JSONParserFeature = _JSONParserFeature() + + +_example_response = r""" +{ + "total_size": 2, + "start": 0, + "next_collection_link": "https://api.launchpad.net/1.0/ubuntu/+archive/primary?distro_series=%2Fubuntu%2Flucid&exact_match=true&source_name=%22bzr%22&status=Published&ws.op=getPublishedSources&ws.start=1&ws.size=1", + "entries": [ + { + "package_creator_link": "https://api.launchpad.net/1.0/~maxb", + "package_signer_link": "https://api.launchpad.net/1.0/~jelmer", + "source_package_name": "bzr", + "removal_comment": null, + "display_name": "bzr 2.1.4-0ubuntu1 in lucid", + "date_made_pending": null, + "source_package_version": "2.1.4-0ubuntu1", + "date_superseded": null, + "http_etag": "\"9ba966152dec474dc0fe1629d0bbce2452efaf3b-5f4c3fbb3eaf26d502db4089777a9b6a0537ffab\"", + "self_link": "https://api.launchpad.net/1.0/ubuntu/+archive/primary/+sourcepub/1750327", + "distro_series_link": "https://api.launchpad.net/1.0/ubuntu/lucid", + "component_name": "main", + "status": "Published", + "date_removed": null, + "pocket": "Updates", + "date_published": "2011-05-30T06:09:58.653984+00:00", + "removed_by_link": null, + "section_name": "devel", + "resource_type_link": "https://api.launchpad.net/1.0/#source_package_publishing_history", + "archive_link": "https://api.launchpad.net/1.0/ubuntu/+archive/primary", + "package_maintainer_link": "https://api.launchpad.net/1.0/~ubuntu-devel-discuss-lists", + "date_created": "2011-05-30T05:19:12.233621+00:00", + "scheduled_deletion_date": null + } + ] +}""" + +_no_versions_response = '{"total_size": 0, "start": 0, "entries": []}' + + +class TestLatestPublication(tests.TestCase): + + def make_latest_publication(self, archive='ubuntu', series='natty', + project='bzr'): + return lp_api_lite.LatestPublication(archive, series, project) + + def assertPlace(self, place, archive, series, project): + lp = lp_api_lite.LatestPublication(archive, series, project) + self.assertEqual(place, lp.place()) + + def test_init(self): + latest_pub = self.make_latest_publication() + self.assertEqual('ubuntu', latest_pub._archive) + self.assertEqual('natty', latest_pub._series) + self.assertEqual('bzr', latest_pub._project) + self.assertEqual('Release', latest_pub._pocket) + + def test__archive_URL(self): + latest_pub = self.make_latest_publication() + self.assertEqual( + 'https://api.launchpad.net/1.0/ubuntu/+archive/primary', + latest_pub._archive_URL()) + + def test__publication_status_for_ubuntu(self): + latest_pub = self.make_latest_publication() + self.assertEqual('Published', latest_pub._publication_status()) + + def test__publication_status_for_debian(self): + latest_pub = self.make_latest_publication(archive='debian') + self.assertEqual('Pending', latest_pub._publication_status()) + + def test_pocket(self): + latest_pub = self.make_latest_publication(series='natty-proposed') + self.assertEqual('natty', latest_pub._series) + self.assertEqual('Proposed', latest_pub._pocket) + + def test_series_None(self): + latest_pub = self.make_latest_publication(series=None) + self.assertEqual('ubuntu', latest_pub._archive) + self.assertEqual(None, latest_pub._series) + self.assertEqual('bzr', latest_pub._project) + self.assertEqual('Release', latest_pub._pocket) + + def test__query_params(self): + latest_pub = self.make_latest_publication() + self.assertEqual({'ws.op': 'getPublishedSources', + 'exact_match': 'true', + 'source_name': '"bzr"', + 'status': 'Published', + 'ws.size': '1', + 'distro_series': '/ubuntu/natty', + 'pocket': 'Release', + }, latest_pub._query_params()) + + def test__query_params_no_series(self): + latest_pub = self.make_latest_publication(series=None) + self.assertEqual({'ws.op': 'getPublishedSources', + 'exact_match': 'true', + 'source_name': '"bzr"', + 'status': 'Published', + 'ws.size': '1', + 'pocket': 'Release', + }, latest_pub._query_params()) + + def test__query_params_pocket(self): + latest_pub = self.make_latest_publication(series='natty-proposed') + self.assertEqual({'ws.op': 'getPublishedSources', + 'exact_match': 'true', + 'source_name': '"bzr"', + 'status': 'Published', + 'ws.size': '1', + 'distro_series': '/ubuntu/natty', + 'pocket': 'Proposed', + }, latest_pub._query_params()) + + def test__query_URL(self): + latest_pub = self.make_latest_publication() + # we explicitly sort params, so we can be sure this URL matches exactly + self.assertEqual( + 'https://api.launchpad.net/1.0/ubuntu/+archive/primary' + '?distro_series=%2Fubuntu%2Fnatty&exact_match=true' + '&pocket=Release&source_name=%22bzr%22&status=Published' + '&ws.op=getPublishedSources&ws.size=1', + latest_pub._query_URL()) + + def DONT_test__gracefully_handle_failed_rpc_connection(self): + # TODO: This test kind of sucks. We intentionally create an arbitrary + # port and don't listen to it, because we want the request to fail. + # However, it seems to take 1s for it to timeout. Is there a way + # to make it fail faster? + latest_pub = self.make_latest_publication() + s = socket.socket() + s.bind(('127.0.0.1', 0)) + addr, port = s.getsockname() + latest_pub.LP_API_ROOT = 'http://%s:%s/' % (addr, port) + s.close() + self.assertIs(None, latest_pub._get_lp_info()) + + def DONT_test__query_launchpad(self): + # TODO: This is a test that we are making a valid request against + # launchpad. This seems important, but it is slow, requires net + # access, and requires launchpad to be up and running. So for + # now, it is commented out for production tests. + latest_pub = self.make_latest_publication() + json_txt = latest_pub._get_lp_info() + self.assertIsNot(None, json_txt) + if lp_api_lite.json is None: + # We don't have a way to parse the text + return + # The content should be a valid json result + content = lp_api_lite.json.loads(json_txt) + entries = content['entries'] # It should have an 'entries' field. + # ws.size should mean we get 0 or 1, and there should be something + self.assertEqual(1, len(entries)) + entry = entries[0] + self.assertEqual('bzr', entry['source_package_name']) + version = entry['source_package_version'] + self.assertIsNot(None, version) + + def test__get_lp_info_no_json(self): + # If we can't parse the json, we don't make the query. + self.overrideAttr(lp_api_lite, 'json', None) + latest_pub = self.make_latest_publication() + self.assertIs(None, latest_pub._get_lp_info()) + + def test__parse_json_info_no_module(self): + # If a json parsing module isn't available, we just return None here. + self.overrideAttr(lp_api_lite, 'json', None) + latest_pub = self.make_latest_publication() + self.assertIs(None, latest_pub._parse_json_info(_example_response)) + + def test__parse_json_example_response(self): + self.requireFeature(JSONParserFeature) + latest_pub = self.make_latest_publication() + content = latest_pub._parse_json_info(_example_response) + self.assertIsNot(None, content) + self.assertEqual(2, content['total_size']) + entries = content['entries'] + self.assertEqual(1, len(entries)) + entry = entries[0] + self.assertEqual('bzr', entry['source_package_name']) + self.assertEqual("2.1.4-0ubuntu1", entry["source_package_version"]) + + def test__parse_json_not_json(self): + self.requireFeature(JSONParserFeature) + latest_pub = self.make_latest_publication() + self.assertIs(None, latest_pub._parse_json_info('Not_valid_json')) + + def test_get_latest_version_no_response(self): + latest_pub = self.make_latest_publication() + latest_pub._get_lp_info = lambda: None + self.assertEqual(None, latest_pub.get_latest_version()) + + def test_get_latest_version_no_json(self): + self.overrideAttr(lp_api_lite, 'json', None) + latest_pub = self.make_latest_publication() + self.assertEqual(None, latest_pub.get_latest_version()) + + def test_get_latest_version_invalid_json(self): + self.requireFeature(JSONParserFeature) + latest_pub = self.make_latest_publication() + latest_pub._get_lp_info = lambda: "not json" + self.assertEqual(None, latest_pub.get_latest_version()) + + def test_get_latest_version_no_versions(self): + self.requireFeature(JSONParserFeature) + latest_pub = self.make_latest_publication() + latest_pub._get_lp_info = lambda: _no_versions_response + self.assertEqual(None, latest_pub.get_latest_version()) + + def test_get_latest_version_missing_entries(self): + # Launchpad's no-entries response does have an empty entries value. + # However, lets test that we handle other failures without tracebacks + self.requireFeature(JSONParserFeature) + latest_pub = self.make_latest_publication() + latest_pub._get_lp_info = lambda: '{}' + self.assertEqual(None, latest_pub.get_latest_version()) + + def test_get_latest_version_invalid_entries(self): + # Make sure we sanely handle a json response we don't understand + self.requireFeature(JSONParserFeature) + latest_pub = self.make_latest_publication() + latest_pub._get_lp_info = lambda: '{"entries": {"a": 1}}' + self.assertEqual(None, latest_pub.get_latest_version()) + + def test_get_latest_version_example(self): + self.requireFeature(JSONParserFeature) + latest_pub = self.make_latest_publication() + latest_pub._get_lp_info = lambda: _example_response + self.assertEqual("2.1.4-0ubuntu1", latest_pub.get_latest_version()) + + def DONT_test_get_latest_version_from_launchpad(self): + self.requireFeature(JSONParserFeature) + latest_pub = self.make_latest_publication() + self.assertIsNot(None, latest_pub.get_latest_version()) + + def test_place(self): + self.assertPlace('Ubuntu', 'ubuntu', None, 'bzr') + self.assertPlace('Ubuntu Natty', 'ubuntu', 'natty', 'bzr') + self.assertPlace('Ubuntu Natty Proposed', 'ubuntu', 'natty-proposed', + 'bzr') + self.assertPlace('Debian', 'debian', None, 'bzr') + self.assertPlace('Debian Sid', 'debian', 'sid', 'bzr') + + +class TestIsUpToDate(tests.TestCase): + + def assertPackageBranchRe(self, url, user, archive, series, project): + m = launchpad._package_branch.search(url) + if m is None: + self.fail('package_branch regex did not match url: %s' % (url,)) + self.assertEqual( + (user, archive, series, project), + m.group('user', 'archive', 'series', 'project')) + + def assertNotPackageBranch(self, url): + self.assertIs(None, launchpad._get_package_branch_info(url)) + + def assertBranchInfo(self, url, archive, series, project): + self.assertEqual((archive, series, project), + launchpad._get_package_branch_info(url)) + + def test_package_branch_regex(self): + self.assertPackageBranchRe( + 'http://bazaar.launchpad.net/+branch/ubuntu/foo', + None, 'ubuntu', None, 'foo') + self.assertPackageBranchRe( + 'bzr+ssh://bazaar.launchpad.net/+branch/ubuntu/natty/foo', + None, 'ubuntu', 'natty/', 'foo') + self.assertPackageBranchRe( + 'sftp://bazaar.launchpad.net/+branch/debian/foo', + None, 'debian', None, 'foo') + self.assertPackageBranchRe( + 'http://bazaar.launchpad.net/+branch/debian/sid/foo', + None, 'debian', 'sid/', 'foo') + self.assertPackageBranchRe( + 'http://bazaar.launchpad.net/+branch' + '/~ubuntu-branches/ubuntu/natty/foo/natty', + '~ubuntu-branches/', 'ubuntu', 'natty/', 'foo') + self.assertPackageBranchRe( + 'http://bazaar.launchpad.net/+branch' + '/~user/ubuntu/natty/foo/test', + '~user/', 'ubuntu', 'natty/', 'foo') + + def test_package_branch_doesnt_match(self): + self.assertNotPackageBranch('http://example.com/ubuntu/foo') + self.assertNotPackageBranch( + 'http://bazaar.launchpad.net/+branch/bzr') + self.assertNotPackageBranch( + 'http://bazaar.launchpad.net/+branch/~bzr-pqm/bzr/bzr.dev') + # Not a packaging branch because ~user isn't ~ubuntu-branches + self.assertNotPackageBranch( + 'http://bazaar.launchpad.net/+branch' + '/~user/ubuntu/natty/foo/natty') + # Older versions of bzr-svn/hg/git did not set Branch.base until after + # they called Branch.__init__(). + self.assertNotPackageBranch(None) + + def test__get_package_branch_info(self): + self.assertBranchInfo( + 'bzr+ssh://bazaar.launchpad.net/+branch/ubuntu/natty/foo', + 'ubuntu', 'natty', 'foo') + self.assertBranchInfo( + 'bzr+ssh://bazaar.launchpad.net/+branch' + '/~ubuntu-branches/ubuntu/natty/foo/natty', + 'ubuntu', 'natty', 'foo') + self.assertBranchInfo( + 'http://bazaar.launchpad.net/+branch' + '/~ubuntu-branches/debian/sid/foo/sid', + 'debian', 'sid', 'foo') + + +class TestGetMostRecentTag(tests.TestCaseWithMemoryTransport): + + def make_simple_builder(self): + builder = self.make_branch_builder('tip') + builder.build_snapshot('A', [], [ + ('add', ('', 'root-id', 'directory', None))]) + b = builder.get_branch() + b.tags.set_tag('tip-1.0', 'A') + return builder, b, b.tags.get_tag_dict() + + def test_get_most_recent_tag_tip(self): + builder, b, tag_dict = self.make_simple_builder() + self.assertEqual('tip-1.0', + lp_api_lite.get_most_recent_tag(tag_dict, b)) + + def test_get_most_recent_tag_older(self): + builder, b, tag_dict = self.make_simple_builder() + builder.build_snapshot('B', ['A'], []) + self.assertEqual('B', b.last_revision()) + self.assertEqual('tip-1.0', + lp_api_lite.get_most_recent_tag(tag_dict, b)) + + +class StubLatestPublication(object): + + def __init__(self, latest): + self.called = False + self.latest = latest + + def get_latest_version(self): + self.called = True + return self.latest + + def place(self): + return 'Ubuntu Natty' + + +class TestReportFreshness(tests.TestCaseWithMemoryTransport): + + def setUp(self): + super(TestReportFreshness, self).setUp() + builder = self.make_branch_builder('tip') + builder.build_snapshot('A', [], [ + ('add', ('', 'root-id', 'directory', None))]) + self.branch = builder.get_branch() + + def assertFreshnessReports(self, verbosity, latest_version, content): + """Assert that lp_api_lite.report_freshness reports the given content. + + :param verbosity: The reporting level + :param latest_version: The version reported by StubLatestPublication + :param content: The expected content. This should be in DocTest form. + """ + orig_log_len = len(self.get_log()) + lp_api_lite.report_freshness(self.branch, verbosity, + StubLatestPublication(latest_version)) + new_content = self.get_log()[orig_log_len:] + # Strip out lines that have LatestPublication.get_* because those are + # timing related lines. While interesting to log for now, they aren't + # something we want to be testing + new_content = new_content.split('\n') + for i in range(2): + if (len(new_content) > 0 + and 'LatestPublication.get_' in new_content[0]): + new_content = new_content[1:] + new_content = '\n'.join(new_content) + self.assertThat(new_content, + DocTestMatches(content, + doctest.ELLIPSIS | doctest.REPORT_UDIFF)) + + def test_verbosity_off_skips_check(self): + # We force _get_package_branch_info so that we know it would otherwise + # try to connect to launcphad + self.overrideAttr(launchpad, '_get_package_branch_info', + lambda x: ('ubuntu', 'natty', 'bzr')) + self.overrideAttr(lp_api_lite, 'LatestPublication', + lambda *args: self.fail('Tried to query launchpad')) + c = self.branch.get_config_stack() + c.set('launchpad.packaging_verbosity', 'off') + orig_log_len = len(self.get_log()) + launchpad._check_is_up_to_date(self.branch) + new_content = self.get_log()[orig_log_len:] + self.assertContainsRe(new_content, + 'not checking memory.*/tip/ because verbosity is turned off') + + def test_verbosity_off(self): + latest_pub = StubLatestPublication('1.0-1ubuntu2') + lp_api_lite.report_freshness(self.branch, 'off', latest_pub) + self.assertFalse(latest_pub.called) + + def test_verbosity_all_out_of_date_smoke(self): + self.branch.tags.set_tag('1.0-1ubuntu1', 'A') + self.assertFreshnessReports('all', '1.0-1ubuntu2', + ' INFO Most recent Ubuntu Natty version: 1.0-1ubuntu2\n' + 'Packaging branch version: 1.0-1ubuntu1\n' + 'Packaging branch status: OUT-OF-DATE\n') + + +class Test_GetNewestVersions(tests.TestCaseWithMemoryTransport): + + def setUp(self): + super(Test_GetNewestVersions, self).setUp() + builder = self.make_branch_builder('tip') + builder.build_snapshot('A', [], [ + ('add', ('', 'root-id', 'directory', None))]) + self.branch = builder.get_branch() + + def assertLatestVersions(self, latest_branch_version, pub_version): + if latest_branch_version is not None: + self.branch.tags.set_tag(latest_branch_version, 'A') + latest_pub = StubLatestPublication(pub_version) + self.assertEqual((pub_version, latest_branch_version), + lp_api_lite._get_newest_versions(self.branch, latest_pub)) + + def test_no_tags(self): + self.assertLatestVersions(None, '1.0-1ubuntu2') + + def test_out_of_date(self): + self.assertLatestVersions('1.0-1ubuntu1', '1.0-1ubuntu2') + + def test_up_to_date(self): + self.assertLatestVersions('1.0-1ubuntu2', '1.0-1ubuntu2') + + def test_missing(self): + self.assertLatestVersions(None, None) + + +class Test_ReportFreshness(tests.TestCase): + + def assertReportedFreshness(self, verbosity, latest_ver, branch_latest_ver, + content, place='Ubuntu Natty'): + """Assert that lp_api_lite.report_freshness reports the given content. + """ + reported = [] + def report_func(value): + reported.append(value) + + lp_api_lite._report_freshness(latest_ver, branch_latest_ver, place, + verbosity, report_func) + new_content = '\n'.join(reported) + self.assertThat(new_content, + DocTestMatches(content, + doctest.ELLIPSIS | doctest.REPORT_UDIFF)) + + def test_verbosity_minimal_no_tags(self): + self.assertReportedFreshness('minimal', '1.0-1ubuntu2', None, + 'Branch is OUT-OF-DATE, Ubuntu Natty has 1.0-1ubuntu2\n') + + def test_verbosity_minimal_out_of_date(self): + self.assertReportedFreshness('minimal', '1.0-1ubuntu2', '1.0-1ubuntu1', + '1.0-1ubuntu1 is OUT-OF-DATE,' + ' Ubuntu Natty has 1.0-1ubuntu2\n') + + def test_verbosity_minimal_up_to_date(self): + self.assertReportedFreshness('minimal', '1.0-1ubuntu2', '1.0-1ubuntu2', + '') + + def test_verbosity_minimal_missing(self): + self.assertReportedFreshness('minimal', None, None, + '') + + def test_verbosity_short_out_of_date(self): + self.assertReportedFreshness('short', '1.0-1ubuntu2', '1.0-1ubuntu1', + '1.0-1ubuntu1 is OUT-OF-DATE,' + ' Ubuntu Natty has 1.0-1ubuntu2\n') + + def test_verbosity_short_up_to_date(self): + self.assertReportedFreshness('short', '1.0-1ubuntu2', '1.0-1ubuntu2', + '1.0-1ubuntu2 is CURRENT in Ubuntu Natty') + + def test_verbosity_short_missing(self): + self.assertReportedFreshness('short', None, None, + 'Ubuntu Natty is MISSING a version') + + def test_verbosity_all_no_tags(self): + self.assertReportedFreshness('all', '1.0-1ubuntu2', None, + 'Most recent Ubuntu Natty version: 1.0-1ubuntu2\n' + 'Packaging branch version: None\n' + 'Packaging branch status: OUT-OF-DATE\n') + + def test_verbosity_all_out_of_date(self): + self.assertReportedFreshness('all', '1.0-1ubuntu2', '1.0-1ubuntu1', + 'Most recent Ubuntu Natty version: 1.0-1ubuntu2\n' + 'Packaging branch version: 1.0-1ubuntu1\n' + 'Packaging branch status: OUT-OF-DATE\n') + + def test_verbosity_all_up_to_date(self): + self.assertReportedFreshness('all', '1.0-1ubuntu2', '1.0-1ubuntu2', + 'Most recent Ubuntu Natty version: 1.0-1ubuntu2\n' + 'Packaging branch status: CURRENT\n') + + def test_verbosity_all_missing(self): + self.assertReportedFreshness('all', None, None, + 'Most recent Ubuntu Natty version: MISSING\n') + + def test_verbosity_None_is_all(self): + self.assertReportedFreshness(None, '1.0-1ubuntu2', '1.0-1ubuntu2', + 'Most recent Ubuntu Natty version: 1.0-1ubuntu2\n' + 'Packaging branch status: CURRENT\n') diff --git a/bzrlib/plugins/launchpad/test_lp_directory.py b/bzrlib/plugins/launchpad/test_lp_directory.py new file mode 100644 index 0000000..f678ff3 --- /dev/null +++ b/bzrlib/plugins/launchpad/test_lp_directory.py @@ -0,0 +1,639 @@ +# Copyright (C) 2007-2011 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Tests for directory lookup through Launchpad.net""" + +import os +import xmlrpclib + +import bzrlib +from bzrlib import ( + debug, + errors, + tests, + transport, + ) +from bzrlib.branch import Branch +from bzrlib.directory_service import directories +from bzrlib.tests import ( + features, + ssl_certs, + TestCaseInTempDir, + TestCaseWithMemoryTransport +) +from bzrlib.plugins.launchpad import ( + _register_directory, + lp_registration, + ) +from bzrlib.plugins.launchpad.lp_directory import ( + LaunchpadDirectory) +from bzrlib.plugins.launchpad.account import get_lp_login, set_lp_login +from bzrlib.tests import http_server + + +def load_tests(standard_tests, module, loader): + result = loader.suiteClass() + t_tests, remaining_tests = tests.split_suite_by_condition( + standard_tests, tests.condition_isinstance(( + TestXMLRPCTransport, + ))) + transport_scenarios = [ + ('http', dict(server_class=PreCannedHTTPServer,)), + ] + if features.HTTPSServerFeature.available(): + transport_scenarios.append( + ('https', dict(server_class=PreCannedHTTPSServer,)), + ) + tests.multiply_tests(t_tests, transport_scenarios, result) + + # No parametrization for the remaining tests + result.addTests(remaining_tests) + + return result + + +class FakeResolveFactory(object): + + def __init__(self, test, expected_path, result): + self._test = test + self._expected_path = expected_path + self._result = result + self._submitted = False + + def __call__(self, path): + self._test.assertEqual(self._expected_path, path) + return self + + def submit(self, service): + self._service_url = service.service_url + self._submitted = True + return self._result + + +class LocalDirectoryURLTests(TestCaseInTempDir): + """Tests for branch urls that we try to pass through local resolution.""" + + def assertResolve(self, expected, url, submitted=False): + path = url[url.index(':')+1:].lstrip('/') + factory = FakeResolveFactory(self, path, + dict(urls=['bzr+ssh://fake-resolved'])) + directory = LaunchpadDirectory() + self.assertEqual(expected, + directory._resolve(url, factory, _lp_login='user')) + # We are testing local resolution, and the fallback when necessary. + self.assertEqual(submitted, factory._submitted) + + def test_short_form(self): + self.assertResolve('bzr+ssh://bazaar.launchpad.net/+branch/apt', + 'lp:apt') + + def test_two_part_form(self): + self.assertResolve('bzr+ssh://bazaar.launchpad.net/+branch/apt/2.2', + 'lp:apt/2.2') + + def test_two_part_plus_subdir(self): + # We allow you to pass more than just what resolves. That way you can + # do things like "bzr log lp:apt/2.2/BUGS" + # Though the virtual FS implementation currently aborts when given a + # URL like this, rather than letting you recurse upwards to find the + # real branch at lp:apt/2.2 + self.assertResolve('bzr+ssh://bazaar.launchpad.net/+branch/apt/2.2/BUGS', + 'lp:apt/2.2/BUGS') + + def test_user_expansion(self): + self.assertResolve('bzr+ssh://bazaar.launchpad.net/~user/apt/foo', + 'lp:~/apt/foo') + + def test_ubuntu(self): + # Confirmed against xmlrpc. If you don't have a ~user, xmlrpc doesn't + # care that you are asking for 'ubuntu' + self.assertResolve('bzr+ssh://bazaar.launchpad.net/+branch/ubuntu', + 'lp:ubuntu') + + def test_ubuntu_invalid(self): + """Invalid ubuntu urls don't crash. + + :seealso: http://pad.lv/843900 + """ + # This ought to be natty-updates. + self.assertRaises(errors.InvalidURL, + self.assertResolve, + '', + 'ubuntu:natty/updates/smartpm') + + def test_ubuntu_apt(self): + self.assertResolve('bzr+ssh://bazaar.launchpad.net/+branch/ubuntu/apt', + 'lp:ubuntu/apt') + + def test_ubuntu_natty_apt(self): + self.assertResolve( + 'bzr+ssh://bazaar.launchpad.net/+branch/ubuntu/natty/apt', + 'lp:ubuntu/natty/apt') + + def test_ubuntu_natty_apt_filename(self): + self.assertResolve( + 'bzr+ssh://bazaar.launchpad.net/+branch/ubuntu/natty/apt/filename', + 'lp:ubuntu/natty/apt/filename') + + def test_user_two_part(self): + # We fall back to the ResolveFactory. The real Launchpad one will raise + # InvalidURL for this case. + self.assertResolve('bzr+ssh://fake-resolved', 'lp:~jameinel/apt', + submitted=True) + + def test_user_three_part(self): + self.assertResolve('bzr+ssh://bazaar.launchpad.net/~jameinel/apt/foo', + 'lp:~jameinel/apt/foo') + + def test_user_three_part_plus_filename(self): + self.assertResolve( + 'bzr+ssh://bazaar.launchpad.net/~jameinel/apt/foo/fname', + 'lp:~jameinel/apt/foo/fname') + + def test_user_ubuntu_two_part(self): + self.assertResolve('bzr+ssh://fake-resolved', 'lp:~jameinel/ubuntu', + submitted=True) + self.assertResolve('bzr+ssh://fake-resolved', 'lp:~jameinel/debian', + submitted=True) + + def test_user_ubuntu_three_part(self): + self.assertResolve('bzr+ssh://fake-resolved', + 'lp:~jameinel/ubuntu/natty', submitted=True) + self.assertResolve('bzr+ssh://fake-resolved', + 'lp:~jameinel/debian/sid', submitted=True) + + def test_user_ubuntu_four_part(self): + self.assertResolve('bzr+ssh://fake-resolved', + 'lp:~jameinel/ubuntu/natty/project', submitted=True) + self.assertResolve('bzr+ssh://fake-resolved', + 'lp:~jameinel/debian/sid/project', submitted=True) + + def test_user_ubuntu_five_part(self): + self.assertResolve( + 'bzr+ssh://bazaar.launchpad.net/~jameinel/ubuntu/natty/apt/branch', + 'lp:~jameinel/ubuntu/natty/apt/branch') + self.assertResolve( + 'bzr+ssh://bazaar.launchpad.net/~jameinel/debian/sid/apt/branch', + 'lp:~jameinel/debian/sid/apt/branch') + + def test_user_ubuntu_five_part_plus_subdir(self): + self.assertResolve( + 'bzr+ssh://bazaar.launchpad.net/~jameinel/ubuntu/natty/apt/branch/f', + 'lp:~jameinel/ubuntu/natty/apt/branch/f') + self.assertResolve( + 'bzr+ssh://bazaar.launchpad.net/~jameinel/debian/sid/apt/branch/f', + 'lp:~jameinel/debian/sid/apt/branch/f') + + def test_handles_special_lp(self): + self.assertResolve('bzr+ssh://bazaar.launchpad.net/+branch/apt', 'lp:apt') + self.assertResolve('bzr+ssh://bazaar.launchpad.net/+branch/apt', + 'lp:///apt') + self.assertResolve('bzr+ssh://bazaar.launchpad.net/+branch/apt', + 'lp://production/apt') + self.assertResolve('bzr+ssh://bazaar.launchpad.dev/+branch/apt', + 'lp://dev/apt') + self.assertResolve('bzr+ssh://bazaar.staging.launchpad.net/+branch/apt', + 'lp://staging/apt') + self.assertResolve('bzr+ssh://bazaar.qastaging.launchpad.net/+branch/apt', + 'lp://qastaging/apt') + self.assertResolve('bzr+ssh://bazaar.demo.launchpad.net/+branch/apt', + 'lp://demo/apt') + + def test_debug_launchpad_uses_resolver(self): + self.assertResolve('bzr+ssh://bazaar.launchpad.net/+branch/bzr', + 'lp:bzr', submitted=False) + debug.debug_flags.add('launchpad') + self.addCleanup(debug.debug_flags.discard, 'launchpad') + self.assertResolve('bzr+ssh://fake-resolved', 'lp:bzr', submitted=True) + + +class DirectoryUrlTests(TestCaseInTempDir): + """Tests for branch urls through Launchpad.net directory""" + + def test_short_form(self): + """A launchpad url should map to a http url""" + factory = FakeResolveFactory( + self, 'apt', dict(urls=[ + 'http://bazaar.launchpad.net/~apt/apt/devel'])) + directory = LaunchpadDirectory() + self.assertEquals('http://bazaar.launchpad.net/~apt/apt/devel', + directory._resolve('lp:apt', factory)) + # Make sure that resolve went to the production server. + self.assertEquals('https://xmlrpc.launchpad.net/bazaar/', + factory._service_url) + + def test_qastaging(self): + """A launchpad url should map to a http url""" + factory = FakeResolveFactory( + self, 'apt', dict(urls=[ + 'http://bazaar.qastaging.launchpad.net/~apt/apt/devel'])) + url = 'lp://qastaging/apt' + directory = LaunchpadDirectory() + self.assertEquals('http://bazaar.qastaging.launchpad.net/~apt/apt/devel', + directory._resolve(url, factory)) + # Make sure that resolve went to the qastaging server. + self.assertEquals('https://xmlrpc.qastaging.launchpad.net/bazaar/', + factory._service_url) + + def test_staging(self): + """A launchpad url should map to a http url""" + factory = FakeResolveFactory( + self, 'apt', dict(urls=[ + 'http://bazaar.staging.launchpad.net/~apt/apt/devel'])) + url = 'lp://staging/apt' + directory = LaunchpadDirectory() + self.assertEquals('http://bazaar.staging.launchpad.net/~apt/apt/devel', + directory._resolve(url, factory)) + # Make sure that resolve went to the staging server. + self.assertEquals('https://xmlrpc.staging.launchpad.net/bazaar/', + factory._service_url) + + def test_url_from_directory(self): + """A launchpad url should map to a http url""" + factory = FakeResolveFactory( + self, 'apt', dict(urls=[ + 'http://bazaar.launchpad.net/~apt/apt/devel'])) + directory = LaunchpadDirectory() + self.assertEquals('http://bazaar.launchpad.net/~apt/apt/devel', + directory._resolve('lp:///apt', factory)) + + def test_directory_skip_bad_schemes(self): + factory = FakeResolveFactory( + self, 'apt', dict(urls=[ + 'bad-scheme://bazaar.launchpad.net/~apt/apt/devel', + 'http://bazaar.launchpad.net/~apt/apt/devel', + 'http://another/location'])) + directory = LaunchpadDirectory() + self.assertEquals('http://bazaar.launchpad.net/~apt/apt/devel', + directory._resolve('lp:///apt', factory)) + + def test_directory_no_matching_schemes(self): + # If the XMLRPC call does not return any protocols we support, + # invalidURL is raised. + factory = FakeResolveFactory( + self, 'apt', dict(urls=[ + 'bad-scheme://bazaar.launchpad.net/~apt/apt/devel'])) + directory = LaunchpadDirectory() + self.assertRaises(errors.InvalidURL, + directory._resolve, 'lp:///apt', factory) + + def test_directory_fault(self): + # Test that XMLRPC faults get converted to InvalidURL errors. + factory = FakeResolveFactory(self, 'apt', None) + def submit(service): + raise xmlrpclib.Fault(42, 'something went wrong') + factory.submit = submit + directory = LaunchpadDirectory() + self.assertRaises(errors.InvalidURL, + directory._resolve, 'lp:///apt', factory) + + def test_skip_bzr_ssh_launchpad_net_when_anonymous(self): + # Test that bzr+ssh://bazaar.launchpad.net gets skipped if + # Bazaar does not know the user's Launchpad ID: + self.assertEqual(None, get_lp_login()) + factory = FakeResolveFactory( + self, 'apt', dict(urls=[ + 'bzr+ssh://bazaar.launchpad.net/~apt/apt/devel', + 'http://bazaar.launchpad.net/~apt/apt/devel'])) + directory = LaunchpadDirectory() + self.assertEquals('http://bazaar.launchpad.net/~apt/apt/devel', + directory._resolve('lp:///apt', factory)) + + def test_skip_sftp_launchpad_net_when_anonymous(self): + # Test that sftp://bazaar.launchpad.net gets skipped if + # Bazaar does not know the user's Launchpad ID: + self.assertEqual(None, get_lp_login()) + factory = FakeResolveFactory( + self, 'apt', dict(urls=[ + 'sftp://bazaar.launchpad.net/~apt/apt/devel', + 'http://bazaar.launchpad.net/~apt/apt/devel'])) + directory = LaunchpadDirectory() + self.assertEquals('http://bazaar.launchpad.net/~apt/apt/devel', + directory._resolve('lp:///apt', factory)) + + def test_with_login_avoid_resolve_factory(self): + # Test that bzr+ssh URLs get rewritten to include the user's + # Launchpad ID (assuming we know the Launchpad ID). + factory = FakeResolveFactory( + self, 'apt', dict(urls=[ + 'bzr+ssh://my-super-custom/special/devel', + 'http://bazaar.launchpad.net/~apt/apt/devel'])) + directory = LaunchpadDirectory() + self.assertEquals( + 'bzr+ssh://bazaar.launchpad.net/+branch/apt', + directory._resolve('lp:///apt', factory, _lp_login='username')) + + def test_no_rewrite_of_other_bzr_ssh(self): + # Test that we don't rewrite bzr+ssh URLs for other + self.assertEqual(None, get_lp_login()) + factory = FakeResolveFactory( + self, 'apt', dict(urls=[ + 'bzr+ssh://example.com/~apt/apt/devel', + 'http://bazaar.launchpad.net/~apt/apt/devel'])) + directory = LaunchpadDirectory() + self.assertEquals('bzr+ssh://example.com/~apt/apt/devel', + directory._resolve('lp:///apt', factory)) + + # TODO: check we get an error if the url is unreasonable + def test_error_for_bad_url(self): + directory = LaunchpadDirectory() + self.assertRaises(errors.InvalidURL, + directory._resolve, 'lp://ratotehunoahu') + + def test_resolve_tilde_to_user(self): + factory = FakeResolveFactory( + self, '~username/apt/test', dict(urls=[ + 'bzr+ssh://bazaar.launchpad.net/~username/apt/test'])) + directory = LaunchpadDirectory() + self.assertEquals( + 'bzr+ssh://bazaar.launchpad.net/~username/apt/test', + directory._resolve('lp:~/apt/test', factory, _lp_login='username')) + # Should also happen when the login is just set by config + set_lp_login('username') + self.assertEquals( + 'bzr+ssh://bazaar.launchpad.net/~username/apt/test', + directory._resolve('lp:~/apt/test', factory)) + + def test_tilde_fails_no_login(self): + factory = FakeResolveFactory( + self, '~username/apt/test', dict(urls=[ + 'bzr+ssh://bazaar.launchpad.net/~username/apt/test'])) + self.assertIs(None, get_lp_login()) + directory = LaunchpadDirectory() + self.assertRaises(errors.InvalidURL, + directory._resolve, 'lp:~/apt/test', factory) + + +class DirectoryOpenBranchTests(TestCaseWithMemoryTransport): + + def test_directory_open_branch(self): + # Test that opening an lp: branch redirects to the real location. + target_branch = self.make_branch('target') + class FooService(object): + """A directory service that maps the name to a FILE url""" + + def look_up(self, name, url): + if 'lp:///apt' == url: + return target_branch.base.rstrip('/') + return '!unexpected look_up value!' + + directories.remove('lp:') + directories.remove('ubuntu:') + directories.remove('debianlp:') + directories.register('lp:', FooService, 'Map lp URLs to local urls') + self.addCleanup(_register_directory) + self.addCleanup(directories.remove, 'lp:') + t = transport.get_transport('lp:///apt') + branch = Branch.open_from_transport(t) + self.assertEqual(target_branch.base, branch.base) + + +class PredefinedRequestHandler(http_server.TestingHTTPRequestHandler): + """Request handler for a unique and pre-defined request. + + The only thing we care about here is that we receive a connection. But + since we want to dialog with a real http client, we have to send it correct + responses. + + We expect to receive a *single* request nothing more (and we won't even + check what request it is), the tests will recognize us from our response. + """ + + def handle_one_request(self): + tcs = self.server.test_case_server + requestline = self.rfile.readline() + self.MessageClass(self.rfile, 0) + if requestline.startswith('POST'): + # The body should be a single line (or we don't know where it ends + # and we don't want to issue a blocking read) + self.rfile.readline() + + self.wfile.write(tcs.canned_response) + + +class PreCannedServerMixin(object): + + def __init__(self): + super(PreCannedServerMixin, self).__init__( + request_handler=PredefinedRequestHandler) + # Bytes read and written by the server + self.bytes_read = 0 + self.bytes_written = 0 + self.canned_response = None + + +class PreCannedHTTPServer(PreCannedServerMixin, http_server.HttpServer): + pass + + +if features.HTTPSServerFeature.available(): + from bzrlib.tests import https_server + class PreCannedHTTPSServer(PreCannedServerMixin, https_server.HTTPSServer): + pass + + +class TestXMLRPCTransport(tests.TestCase): + + # set by load_tests + server_class = None + + def setUp(self): + tests.TestCase.setUp(self) + self.server = self.server_class() + self.server.start_server() + self.addCleanup(self.server.stop_server) + # Ensure we don't clobber env + self.overrideEnv('BZR_LP_XMLRPC_URL', None) + # Ensure we use the right certificates for https. + # FIXME: There should be a better way but the only alternative I can + # think of involves carrying the ca_certs through the lp_registration + # infrastructure to _urllib2_wrappers... -- vila 2012-01-20 + bzrlib.global_state.cmdline_overrides._from_cmdline( + ['ssl.ca_certs=%s' % ssl_certs.build_path('ca.crt')]) + + def set_canned_response(self, server, path): + response_format = '''HTTP/1.1 200 OK\r +Date: Tue, 11 Jul 2006 04:32:56 GMT\r +Server: Apache/2.0.54 (Fedora)\r +Last-Modified: Sun, 23 Apr 2006 19:35:20 GMT\r +ETag: "56691-23-38e9ae00"\r +Accept-Ranges: bytes\r +Content-Length: %(length)d\r +Connection: close\r +Content-Type: text/plain; charset=UTF-8\r +\r +<?xml version='1.0'?> +<methodResponse> +<params> +<param> +<value><struct> +<member> +<name>urls</name> +<value><array><data> +<value><string>bzr+ssh://bazaar.launchpad.net/%(path)s</string></value> +<value><string>http://bazaar.launchpad.net/%(path)s</string></value> +</data></array></value> +</member> +</struct></value> +</param> +</params> +</methodResponse> +''' + length = 334 + 2 * len(path) + server.canned_response = response_format % dict(length=length, + path=path) + + def do_request(self, server_url): + os.environ['BZR_LP_XMLRPC_URL'] = self.server.get_url() + service = lp_registration.LaunchpadService() + resolve = lp_registration.ResolveLaunchpadPathRequest('bzr') + result = resolve.submit(service) + return result + + def test_direct_request(self): + self.set_canned_response(self.server, '~bzr-pqm/bzr/bzr.dev') + result = self.do_request(self.server.get_url()) + urls = result.get('urls', None) + self.assertIsNot(None, urls) + self.assertEquals( + ['bzr+ssh://bazaar.launchpad.net/~bzr-pqm/bzr/bzr.dev', + 'http://bazaar.launchpad.net/~bzr-pqm/bzr/bzr.dev'], + urls) + # FIXME: we need to test with a real proxy, I can't find a way so simulate + # CONNECT without leaving one server hanging the test :-/ Since that maybe + # related to the leaking tests problems, I'll punt for now -- vila 20091030 + + +class TestDebuntuExpansions(TestCaseInTempDir): + """Test expansions for ubuntu: and debianlp: schemes.""" + + def setUp(self): + super(TestDebuntuExpansions, self).setUp() + self.directory = LaunchpadDirectory() + + def _make_factory(self, package='foo', distro='ubuntu', series=None): + if series is None: + path = '%s/%s' % (distro, package) + url_suffix = '~branch/%s/%s' % (distro, package) + else: + path = '%s/%s/%s' % (distro, series, package) + url_suffix = '~branch/%s/%s/%s' % (distro, series, package) + return FakeResolveFactory( + self, path, dict(urls=[ + 'http://bazaar.launchpad.net/' + url_suffix])) + + def assertURL(self, expected_url, shortcut, package='foo', distro='ubuntu', + series=None): + factory = self._make_factory(package=package, distro=distro, + series=series) + self.assertEqual('http://bazaar.launchpad.net/~branch/' + expected_url, + self.directory._resolve(shortcut, factory)) + + # Bogus distro. + + def test_bogus_distro(self): + self.assertRaises(errors.InvalidURL, + self.directory._resolve, 'gentoo:foo') + + def test_trick_bogus_distro_u(self): + self.assertRaises(errors.InvalidURL, + self.directory._resolve, 'utube:foo') + + def test_trick_bogus_distro_d(self): + self.assertRaises(errors.InvalidURL, + self.directory._resolve, 'debuntu:foo') + + def test_missing_ubuntu_distroseries_without_project(self): + # Launchpad does not hold source packages for Intrepid. Missing or + # bogus distroseries with no project name is treated like a project. + self.assertURL('ubuntu/intrepid', 'ubuntu:intrepid', package='intrepid') + + def test_missing_ubuntu_distroseries_with_project(self): + # Launchpad does not hold source packages for Intrepid. Missing or + # bogus distroseries with a project name is treated like an unknown + # series (i.e. we keep it verbatim). + self.assertURL('ubuntu/intrepid/foo', + 'ubuntu:intrepid/foo', series='intrepid') + + def test_missing_debian_distroseries(self): + # Launchpad does not hold source packages for unstable. Missing or + # bogus distroseries is treated like a project. + self.assertURL('debian/sid', + 'debianlp:sid', package='sid', distro='debian') + + # Ubuntu Default distro series. + + def test_ubuntu_default_distroseries_expansion(self): + self.assertURL('ubuntu/foo', 'ubuntu:foo') + + def test_ubuntu_natty_distroseries_expansion(self): + self.assertURL('ubuntu/natty/foo', 'ubuntu:natty/foo', series='natty') + + def test_ubuntu_n_distroseries_expansion(self): + self.assertURL('ubuntu/natty/foo', 'ubuntu:n/foo', series='natty') + + def test_ubuntu_maverick_distroseries_expansion(self): + self.assertURL('ubuntu/maverick/foo', 'ubuntu:maverick/foo', + series='maverick') + + def test_ubuntu_m_distroseries_expansion(self): + self.assertURL('ubuntu/maverick/foo', 'ubuntu:m/foo', series='maverick') + + def test_ubuntu_lucid_distroseries_expansion(self): + self.assertURL('ubuntu/lucid/foo', 'ubuntu:lucid/foo', series='lucid') + + def test_ubuntu_l_distroseries_expansion(self): + self.assertURL('ubuntu/lucid/foo', 'ubuntu:l/foo', series='lucid') + + def test_ubuntu_karmic_distroseries_expansion(self): + self.assertURL('ubuntu/karmic/foo', 'ubuntu:karmic/foo', + series='karmic') + + def test_ubuntu_k_distroseries_expansion(self): + self.assertURL('ubuntu/karmic/foo', 'ubuntu:k/foo', series='karmic') + + def test_ubuntu_jaunty_distroseries_expansion(self): + self.assertURL('ubuntu/jaunty/foo', 'ubuntu:jaunty/foo', + series='jaunty') + + def test_ubuntu_j_distroseries_expansion(self): + self.assertURL('ubuntu/jaunty/foo', 'ubuntu:j/foo', series='jaunty') + + def test_ubuntu_hardy_distroseries_expansion(self): + self.assertURL('ubuntu/hardy/foo', 'ubuntu:hardy/foo', series='hardy') + + def test_ubuntu_h_distroseries_expansion(self): + self.assertURL('ubuntu/hardy/foo', 'ubuntu:h/foo', series='hardy') + + def test_ubuntu_dapper_distroseries_expansion(self): + self.assertURL('ubuntu/dapper/foo', 'ubuntu:dapper/foo', + series='dapper') + + def test_ubuntu_d_distroseries_expansion(self): + self.assertURL('ubuntu/dapper/foo', 'ubuntu:d/foo', series='dapper') + + # Debian default distro series. + + def test_debian_default_distroseries_expansion(self): + self.assertURL('debian/foo', 'debianlp:foo', distro='debian') + + def test_debian_squeeze_distroseries_expansion(self): + self.assertURL('debian/squeeze/foo', 'debianlp:squeeze/foo', + distro='debian', series='squeeze') + + def test_debian_lenny_distroseries_expansion(self): + self.assertURL('debian/lenny/foo', 'debianlp:lenny/foo', + distro='debian', series='lenny') diff --git a/bzrlib/plugins/launchpad/test_lp_login.py b/bzrlib/plugins/launchpad/test_lp_login.py new file mode 100644 index 0000000..8439fef --- /dev/null +++ b/bzrlib/plugins/launchpad/test_lp_login.py @@ -0,0 +1,58 @@ +# Copyright (C) 2009 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Tests for the launchpad-login command.""" + +from bzrlib.plugins.launchpad import account +from bzrlib.tests import TestCaseWithTransport + + +class TestLaunchpadLogin(TestCaseWithTransport): + """Tests for launchpad-login.""" + + def test_login_without_name_when_not_logged_in(self): + # lp-login without a 'name' parameter returns the user ID of the + # logged in user. If no one is logged in, we tell the user as much. + out, err = self.run_bzr(['launchpad-login', '--no-check'], retcode=1) + self.assertEqual('No Launchpad user ID configured.\n', out) + self.assertEqual('', err) + + def test_login_with_name_sets_login(self): + # lp-login with a 'name' parameter sets the Launchpad login. + self.run_bzr(['launchpad-login', '--no-check', 'foo']) + self.assertEqual('foo', account.get_lp_login()) + + def test_login_without_name_when_logged_in(self): + # lp-login without a 'name' parameter returns the user ID of the + # logged in user. + account.set_lp_login('foo') + out, err = self.run_bzr(['launchpad-login', '--no-check']) + self.assertEqual('foo\n', out) + self.assertEqual('', err) + + def test_login_with_name_no_output_by_default(self): + # lp-login with a 'name' parameter produces no output by default. + out, err = self.run_bzr(['launchpad-login', '--no-check', 'foo']) + self.assertEqual('', out) + self.assertEqual('', err) + + def test_login_with_name_verbose(self): + # lp-login with a 'name' parameter and a verbose flag produces some + # information about what Bazaar just did. + out, err = self.run_bzr( + ['launchpad-login', '-v', '--no-check', 'foo']) + self.assertEqual("Launchpad user ID set to 'foo'.\n", out) + self.assertEqual('', err) diff --git a/bzrlib/plugins/launchpad/test_lp_open.py b/bzrlib/plugins/launchpad/test_lp_open.py new file mode 100644 index 0000000..5eb8345 --- /dev/null +++ b/bzrlib/plugins/launchpad/test_lp_open.py @@ -0,0 +1,103 @@ +# Copyright (C) 2009, 2010, 2012 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Tests for the launchpad-open command.""" + +from bzrlib.tests import TestCaseWithTransport + + +class TestLaunchpadOpen(TestCaseWithTransport): + + def run_open(self, location, retcode=0, working_dir='.'): + out, err = self.run_bzr(['launchpad-open', '--dry-run', location], + retcode=retcode, + working_dir=working_dir) + return err.splitlines() + + def test_non_branch(self): + # If given a branch with no public or push locations, lp-open will try + # to guess the Launchpad page for the given URL / path. If it cannot + # find one, it will raise an error. + self.assertEqual( + ['bzr: ERROR: . is not registered on Launchpad.'], + self.run_open('.', retcode=3)) + + def test_no_public_location_no_push_location(self): + self.make_branch('not-public') + self.assertEqual( + ['bzr: ERROR: not-public is not registered on Launchpad.'], + self.run_open('not-public', retcode=3)) + + def test_non_launchpad_branch(self): + branch = self.make_branch('non-lp') + url = 'http://example.com/non-lp' + branch.set_public_branch(url) + self.assertEqual( + ['bzr: ERROR: %s is not registered on Launchpad.' % url], + self.run_open('non-lp', retcode=3)) + + def test_launchpad_branch_with_public_location(self): + branch = self.make_branch('lp') + branch.set_public_branch('bzr+ssh://bazaar.launchpad.net/~foo/bar/baz') + self.assertEqual( + ['Opening https://code.launchpad.net/~foo/bar/baz in web ' + 'browser'], + self.run_open('lp')) + + def test_launchpad_branch_with_public_and_push_location(self): + branch = self.make_branch('lp') + branch.lock_write() + try: + branch.set_public_branch( + 'bzr+ssh://bazaar.launchpad.net/~foo/bar/public') + branch.set_push_location( + 'bzr+ssh://bazaar.launchpad.net/~foo/bar/push') + finally: + branch.unlock() + self.assertEqual( + ['Opening https://code.launchpad.net/~foo/bar/public in web ' + 'browser'], + self.run_open('lp')) + + def test_launchpad_branch_with_no_public_but_with_push(self): + # lp-open falls back to the push location if it cannot find a public + # location. + branch = self.make_branch('lp') + branch.set_push_location('bzr+ssh://bazaar.launchpad.net/~foo/bar/baz') + self.assertEqual( + ['Opening https://code.launchpad.net/~foo/bar/baz in web ' + 'browser'], + self.run_open('lp')) + + def test_launchpad_branch_with_no_public_no_push(self): + # If lp-open is given a branch URL and that branch has no public + # location and no push location, then just try to look up the + # Launchpad page for that URL. + self.assertEqual( + ['Opening https://code.launchpad.net/~foo/bar/baz in web ' + 'browser'], + self.run_open('bzr+ssh://bazaar.launchpad.net/~foo/bar/baz')) + + def test_launchpad_branch_subdirectory(self): + # lp-open in a subdirectory of a registered branch should work + wt = self.make_branch_and_tree('lp') + wt.branch.set_push_location( + 'bzr+ssh://bazaar.launchpad.net/~foo/bar/baz') + self.build_tree(['lp/a/']) + self.assertEqual( + ['Opening https://code.launchpad.net/~foo/bar/baz in web ' + 'browser'], + self.run_open('.', working_dir='lp/a')) diff --git a/bzrlib/plugins/launchpad/test_lp_service.py b/bzrlib/plugins/launchpad/test_lp_service.py new file mode 100644 index 0000000..6e28c90 --- /dev/null +++ b/bzrlib/plugins/launchpad/test_lp_service.py @@ -0,0 +1,181 @@ +# Copyright (C) 2008-2011 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Tests for selection of the right Launchpad service by environment""" + +import os +import xmlrpclib + +from bzrlib import errors +from bzrlib.plugins.launchpad.lp_registration import ( + InvalidLaunchpadInstance, LaunchpadService, NotLaunchpadBranch) +from bzrlib.plugins.launchpad.test_lp_directory import FakeResolveFactory +from bzrlib.tests import TestCase + + +class LaunchpadServiceTests(TestCase): + """Test that the correct Launchpad instance is chosen.""" + + def setUp(self): + super(LaunchpadServiceTests, self).setUp() + # make sure we have a reproducible standard environment + self.overrideEnv('BZR_LP_XMLRPC_URL', None) + + def test_default_service(self): + service = LaunchpadService() + self.assertEqual('https://xmlrpc.launchpad.net/bazaar/', + service.service_url) + + def test_alter_default_service_url(self): + LaunchpadService.DEFAULT_SERVICE_URL = 'http://example.com/' + try: + service = LaunchpadService() + self.assertEqual('http://example.com/', + service.service_url) + finally: + LaunchpadService.DEFAULT_SERVICE_URL = \ + LaunchpadService.LAUNCHPAD_INSTANCE['production'] + + def test_staging_service(self): + service = LaunchpadService(lp_instance='staging') + self.assertEqual('https://xmlrpc.staging.launchpad.net/bazaar/', + service.service_url) + + def test_dev_service(self): + service = LaunchpadService(lp_instance='dev') + self.assertEqual('https://xmlrpc.launchpad.dev/bazaar/', + service.service_url) + + def test_demo_service(self): + service = LaunchpadService(lp_instance='demo') + self.assertEqual('https://xmlrpc.demo.launchpad.net/bazaar/', + service.service_url) + + def test_unknown_service(self): + error = self.assertRaises(InvalidLaunchpadInstance, + LaunchpadService, + lp_instance='fubar') + self.assertEqual('fubar is not a valid Launchpad instance.', + str(error)) + + def test_environment_overrides_default(self): + os.environ['BZR_LP_XMLRPC_URL'] = 'http://example.com/' + service = LaunchpadService() + self.assertEqual('http://example.com/', + service.service_url) + + def test_environment_overrides_specified_service(self): + os.environ['BZR_LP_XMLRPC_URL'] = 'http://example.com/' + service = LaunchpadService(lp_instance='staging') + self.assertEqual('http://example.com/', + service.service_url) + + +class TestURLInference(TestCase): + """Test the way we infer Launchpad web pages from branch URLs.""" + + def test_default_bzr_ssh_url(self): + service = LaunchpadService() + web_url = service.get_web_url_from_branch_url( + 'bzr+ssh://bazaar.launchpad.net/~foo/bar/baz') + self.assertEqual( + 'https://code.launchpad.net/~foo/bar/baz', web_url) + + def test_product_bzr_ssh_url(self): + service = LaunchpadService(lp_instance='production') + web_url = service.get_web_url_from_branch_url( + 'bzr+ssh://bazaar.launchpad.net/~foo/bar/baz') + self.assertEqual( + 'https://code.launchpad.net/~foo/bar/baz', web_url) + + def test_sftp_branch_url(self): + service = LaunchpadService(lp_instance='production') + web_url = service.get_web_url_from_branch_url( + 'sftp://bazaar.launchpad.net/~foo/bar/baz') + self.assertEqual( + 'https://code.launchpad.net/~foo/bar/baz', web_url) + + def test_staging_branch_url(self): + service = LaunchpadService(lp_instance='production') + web_url = service.get_web_url_from_branch_url( + 'bzr+ssh://bazaar.staging.launchpad.net/~foo/bar/baz') + self.assertEqual( + 'https://code.launchpad.net/~foo/bar/baz', web_url) + + def test_non_launchpad_url(self): + service = LaunchpadService() + error = self.assertRaises( + NotLaunchpadBranch, service.get_web_url_from_branch_url, + 'bzr+ssh://example.com/~foo/bar/baz') + self.assertEqual( + 'bzr+ssh://example.com/~foo/bar/baz is not registered on Launchpad.', + str(error)) + + def test_dodgy_launchpad_url(self): + service = LaunchpadService() + self.assertRaises( + NotLaunchpadBranch, service.get_web_url_from_branch_url, + 'bzr+ssh://launchpad.net/~foo/bar/baz') + + def test_lp_branch_url(self): + service = LaunchpadService(lp_instance='production') + factory = FakeResolveFactory( + self, '~foo/bar/baz', + dict(urls=['http://bazaar.launchpad.net/~foo/bar/baz'])) + web_url = service.get_web_url_from_branch_url( + 'lp:~foo/bar/baz', factory) + self.assertEqual( + 'https://code.launchpad.net/~foo/bar/baz', web_url) + + def test_lp_branch_shortcut(self): + service = LaunchpadService() + factory = FakeResolveFactory( + self, 'foo', + dict(urls=['http://bazaar.launchpad.net/~foo/bar/baz'])) + web_url = service.get_web_url_from_branch_url('lp:foo', factory) + self.assertEqual( + 'https://code.launchpad.net/~foo/bar/baz', web_url) + + def test_lp_branch_fault(self): + service = LaunchpadService() + factory = FakeResolveFactory(self, 'foo', None) + def submit(service): + raise xmlrpclib.Fault(42, 'something went wrong') + factory.submit = submit + self.assertRaises( + errors.InvalidURL, service.get_web_url_from_branch_url, 'lp:foo', + factory) + + def test_staging_url(self): + service = LaunchpadService(lp_instance='staging') + web_url = service.get_web_url_from_branch_url( + 'bzr+ssh://bazaar.launchpad.net/~foo/bar/baz') + self.assertEqual( + 'https://code.staging.launchpad.net/~foo/bar/baz', web_url) + + def test_dev_url(self): + service = LaunchpadService(lp_instance='dev') + web_url = service.get_web_url_from_branch_url( + 'bzr+ssh://bazaar.launchpad.net/~foo/bar/baz') + self.assertEqual( + 'https://code.launchpad.dev/~foo/bar/baz', web_url) + + def test_demo_url(self): + service = LaunchpadService(lp_instance='demo') + web_url = service.get_web_url_from_branch_url( + 'bzr+ssh://bazaar.launchpad.net/~foo/bar/baz') + self.assertEqual( + 'https://code.demo.launchpad.net/~foo/bar/baz', web_url) diff --git a/bzrlib/plugins/launchpad/test_register.py b/bzrlib/plugins/launchpad/test_register.py new file mode 100644 index 0000000..81aadc7 --- /dev/null +++ b/bzrlib/plugins/launchpad/test_register.py @@ -0,0 +1,366 @@ +# Copyright (C) 2006-2011 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +import base64 +from StringIO import StringIO +import urlparse +import xmlrpclib + +from bzrlib import ( + config, + tests, + ui, + ) +from bzrlib.tests import TestCaseWithTransport + +# local import +from bzrlib.plugins.launchpad.lp_registration import ( + BaseRequest, + BranchBugLinkRequest, + BranchRegistrationRequest, + ResolveLaunchpadPathRequest, + LaunchpadService, + ) + + +# TODO: Test that the command-line client, making sure that it'll pass the +# request through to a dummy transport, and that the transport will validate +# the results passed in. Not sure how to get the transport object back out to +# validate that its OK - may not be necessary. + +# TODO: Add test for (and implement) other command-line options to set +# project, author_email, description. + +# TODO: project_id is not properly handled -- must be passed in rpc or path. + +class InstrumentedXMLRPCConnection(object): + """Stands in place of an http connection for the purposes of testing""" + + def __init__(self, testcase): + self.testcase = testcase + + def getreply(self): + """Fake the http reply. + + :returns: (errcode, errmsg, headers) + """ + return (200, 'OK', []) + + def getresponse(self, buffering=True): + """Fake the http reply. + + This is used when running on Python 2.7, where xmlrpclib uses + httplib.HTTPConnection in a different way than before. + """ + class FakeHttpResponse(object): + + def __init__(self, status, reason, body): + self.status = status + self.reason = reason + self.body = body + + def read(self, size=-1): + return self.body.read(size) + + def getheader(self, name, default): + # We don't have headers + return default + + return FakeHttpResponse(200, 'OK', self.getfile()) + + def getfile(self): + """Return a fake file containing the response content.""" + return StringIO('''\ +<?xml version="1.0" ?> +<methodResponse> + <params> + <param> + <value> + <string>victoria dock</string> + </value> + </param> + </params> +</methodResponse>''') + + + +class InstrumentedXMLRPCTransport(xmlrpclib.Transport): + + # Python 2.5's xmlrpclib looks for this. + _use_datetime = False + + def __init__(self, testcase, expect_auth): + self.testcase = testcase + self.expect_auth = expect_auth + self._connection = (None, None) + + def make_connection(self, host): + host, http_headers, x509 = self.get_host_info(host) + test = self.testcase + self.connected_host = host + if self.expect_auth: + auth_hdrs = [v for k,v in http_headers if k == 'Authorization'] + if len(auth_hdrs) != 1: + raise AssertionError("multiple auth headers: %r" + % (auth_hdrs,)) + authinfo = auth_hdrs[0] + expected_auth = 'testuser@launchpad.net:testpassword' + test.assertEquals(authinfo, + 'Basic ' + base64.encodestring(expected_auth).strip()) + elif http_headers: + raise AssertionError() + return InstrumentedXMLRPCConnection(test) + + def send_request(self, connection, handler_path, request_body): + test = self.testcase + self.got_request = True + + def send_host(self, conn, host): + pass + + def send_user_agent(self, conn): + # TODO: send special user agent string, including bzrlib version + # number + pass + + def send_content(self, conn, request_body): + unpacked, method = xmlrpclib.loads(request_body) + if None in unpacked: + raise AssertionError( + "xmlrpc result %r shouldn't contain None" % (unpacked,)) + self.sent_params = unpacked + + +class MockLaunchpadService(LaunchpadService): + + def send_request(self, method_name, method_params, authenticated): + """Stash away the method details rather than sending them to a real server""" + self.called_method_name = method_name + self.called_method_params = method_params + self.called_authenticated = authenticated + + +class TestBranchRegistration(TestCaseWithTransport): + + def setUp(self): + super(TestBranchRegistration, self).setUp() + # make sure we have a reproducible standard environment + self.overrideEnv('BZR_LP_XMLRPC_URL', None) + + def test_register_help(self): + """register-branch accepts --help""" + out, err = self.run_bzr(['register-branch', '--help']) + self.assertContainsRe(out, r'Register a branch') + + def test_register_no_url_no_branch(self): + """register-branch command requires parameters""" + self.make_repository('.') + self.run_bzr_error( + ['register-branch requires a public branch url - ' + 'see bzr help register-branch'], + 'register-branch') + + def test_register_no_url_in_published_branch_no_error(self): + b = self.make_branch('.') + b.set_public_branch('http://test-server.com/bzr/branch') + out, err = self.run_bzr(['register-branch', '--dry-run']) + self.assertEqual('Branch registered.\n', out) + self.assertEqual('', err) + + def test_register_no_url_in_unpublished_branch_errors(self): + b = self.make_branch('.') + out, err = self.run_bzr_error(['no public branch'], + ['register-branch', '--dry-run']) + self.assertEqual('', out) + + def test_register_dry_run(self): + out, err = self.run_bzr(['register-branch', + 'http://test-server.com/bzr/branch', + '--dry-run']) + self.assertEquals(out, 'Branch registered.\n') + + def test_onto_transport(self): + """How the request is sent by transmitting across a mock Transport""" + # use a real transport, but intercept at the http/xml layer + transport = InstrumentedXMLRPCTransport(self, expect_auth=True) + service = LaunchpadService(transport) + service.registrant_email = 'testuser@launchpad.net' + service.registrant_password = 'testpassword' + rego = BranchRegistrationRequest('http://test-server.com/bzr/branch', + 'branch-id', + 'my test branch', + 'description', + 'author@launchpad.net', + 'product') + rego.submit(service) + self.assertEquals(transport.connected_host, 'xmlrpc.launchpad.net') + self.assertEquals(len(transport.sent_params), 6) + self.assertEquals(transport.sent_params, + ('http://test-server.com/bzr/branch', # branch_url + 'branch-id', # branch_name + 'my test branch', # branch_title + 'description', + 'author@launchpad.net', + 'product')) + self.assertTrue(transport.got_request) + + def test_onto_transport_unauthenticated(self): + """An unauthenticated request is transmitted across a mock Transport""" + transport = InstrumentedXMLRPCTransport(self, expect_auth=False) + service = LaunchpadService(transport) + resolve = ResolveLaunchpadPathRequest('bzr') + resolve.submit(service) + self.assertEquals(transport.connected_host, 'xmlrpc.launchpad.net') + self.assertEquals(len(transport.sent_params), 1) + self.assertEquals(transport.sent_params, ('bzr', )) + self.assertTrue(transport.got_request) + + def test_subclass_request(self): + """Define a new type of xmlrpc request""" + class DummyRequest(BaseRequest): + _methodname = 'dummy_request' + def _request_params(self): + return (42,) + + service = MockLaunchpadService() + service.registrant_email = 'test@launchpad.net' + service.registrant_password = '' + request = DummyRequest() + request.submit(service) + self.assertEquals(service.called_method_name, 'dummy_request') + self.assertEquals(service.called_method_params, (42,)) + + def test_mock_server_registration(self): + """Send registration to mock server""" + test_case = self + class MockRegistrationService(MockLaunchpadService): + def send_request(self, method_name, method_params, authenticated): + test_case.assertEquals(method_name, "register_branch") + test_case.assertEquals(list(method_params), + ['url', 'name', 'title', 'description', 'email', 'name']) + test_case.assertEquals(authenticated, True) + return 'result' + service = MockRegistrationService() + rego = BranchRegistrationRequest('url', 'name', 'title', + 'description', 'email', 'name') + result = rego.submit(service) + self.assertEquals(result, 'result') + + def test_mock_server_registration_with_defaults(self): + """Send registration to mock server""" + test_case = self + class MockRegistrationService(MockLaunchpadService): + def send_request(self, method_name, method_params, authenticated): + test_case.assertEquals(method_name, "register_branch") + test_case.assertEquals(list(method_params), + ['http://server/branch', 'branch', '', '', '', '']) + test_case.assertEquals(authenticated, True) + return 'result' + service = MockRegistrationService() + rego = BranchRegistrationRequest('http://server/branch') + result = rego.submit(service) + self.assertEquals(result, 'result') + + def test_mock_bug_branch_link(self): + """Send bug-branch link to mock server""" + test_case = self + class MockService(MockLaunchpadService): + def send_request(self, method_name, method_params, authenticated): + test_case.assertEquals(method_name, "link_branch_to_bug") + test_case.assertEquals(list(method_params), + ['http://server/branch', 1234, '']) + test_case.assertEquals(authenticated, True) + return 'http://launchpad.net/bug/1234' + service = MockService() + rego = BranchBugLinkRequest('http://server/branch', 1234) + result = rego.submit(service) + self.assertEquals(result, 'http://launchpad.net/bug/1234') + + def test_mock_resolve_lp_url(self): + test_case = self + class MockService(MockLaunchpadService): + def send_request(self, method_name, method_params, authenticated): + test_case.assertEquals(method_name, "resolve_lp_path") + test_case.assertEquals(list(method_params), ['bzr']) + test_case.assertEquals(authenticated, False) + return dict(urls=[ + 'bzr+ssh://bazaar.launchpad.net~bzr/bzr/trunk', + 'sftp://bazaar.launchpad.net~bzr/bzr/trunk', + 'bzr+http://bazaar.launchpad.net~bzr/bzr/trunk', + 'http://bazaar.launchpad.net~bzr/bzr/trunk']) + service = MockService() + resolve = ResolveLaunchpadPathRequest('bzr') + result = resolve.submit(service) + self.assertTrue('urls' in result) + self.assertEquals(result['urls'], [ + 'bzr+ssh://bazaar.launchpad.net~bzr/bzr/trunk', + 'sftp://bazaar.launchpad.net~bzr/bzr/trunk', + 'bzr+http://bazaar.launchpad.net~bzr/bzr/trunk', + 'http://bazaar.launchpad.net~bzr/bzr/trunk']) + + +class TestGatherUserCredentials(tests.TestCaseInTempDir): + + def setUp(self): + super(TestGatherUserCredentials, self).setUp() + # make sure we have a reproducible standard environment + self.overrideEnv('BZR_LP_XMLRPC_URL', None) + + def test_gather_user_credentials_has_password(self): + service = LaunchpadService() + service.registrant_password = 'mypassword' + # This should be a basic no-op, since we already have the password + service.gather_user_credentials() + self.assertEqual('mypassword', service.registrant_password) + + def test_gather_user_credentials_from_auth_conf(self): + auth_path = config.authentication_config_filename() + service = LaunchpadService() + g_conf = config.GlobalStack() + g_conf.set('email', 'Test User <test@user.com>') + f = open(auth_path, 'wb') + try: + scheme, hostinfo = urlparse.urlsplit(service.service_url)[:2] + f.write('[section]\n' + 'scheme=%s\n' + 'host=%s\n' + 'user=test@user.com\n' + 'password=testpass\n' + % (scheme, hostinfo)) + finally: + f.close() + self.assertIs(None, service.registrant_password) + service.gather_user_credentials() + self.assertEqual('test@user.com', service.registrant_email) + self.assertEqual('testpass', service.registrant_password) + + def test_gather_user_credentials_prompts(self): + service = LaunchpadService() + self.assertIs(None, service.registrant_password) + g_conf = config.GlobalStack() + g_conf.set('email', 'Test User <test@user.com>') + stdout = tests.StringIOWrapper() + stderr = tests.StringIOWrapper() + ui.ui_factory = tests.TestUIFactory(stdin='userpass\n', + stdout=stdout, stderr=stderr) + self.assertIs(None, service.registrant_password) + service.gather_user_credentials() + self.assertEqual('test@user.com', service.registrant_email) + self.assertEqual('userpass', service.registrant_password) + self.assertEquals('', stdout.getvalue()) + self.assertContainsRe(stderr.getvalue(), + 'launchpad.net password for test@user\\.com') + diff --git a/bzrlib/plugins/netrc_credential_store/__init__.py b/bzrlib/plugins/netrc_credential_store/__init__.py new file mode 100644 index 0000000..ad8aba0 --- /dev/null +++ b/bzrlib/plugins/netrc_credential_store/__init__.py @@ -0,0 +1,75 @@ +# Copyright (C) 2008-2011 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +from __future__ import absolute_import + +__doc__ = """Use ~/.netrc as a credential store for authentication.conf.""" + +# Since we are a built-in plugin we share the bzrlib version +from bzrlib import version_info + +from bzrlib import ( + config, + lazy_import, + ) + +lazy_import.lazy_import(globals(), """ +import errno +import netrc + +from bzrlib import ( + errors, + ) +""") + + +class NetrcCredentialStore(config.CredentialStore): + + def __init__(self): + super(NetrcCredentialStore, self).__init__() + try: + self._netrc = netrc.netrc() + except IOError, e: + if e.args[0] == errno.ENOENT: + raise errors.NoSuchFile(e.filename) + else: + raise + + def decode_password(self, credentials): + auth = self._netrc.authenticators(credentials['host']) + password = None + if auth is not None: + user, account, password = auth + cred_user = credentials.get('user', None) + if cred_user is None or user != cred_user: + # We don't use the netrc ability to provide a user since there + # is no way to give it back to AuthConfig. So if the user + # doesn't match, we don't return a password. + password = None + return password + + +config.credential_store_registry.register_lazy( + 'netrc', __name__, 'NetrcCredentialStore', help=__doc__) + + +def load_tests(basic_tests, module, loader): + testmod_names = [ + 'tests', + ] + basic_tests.addTest(loader.loadTestsFromModuleNames( + ["%s.%s" % (__name__, tmn) for tmn in testmod_names])) + return basic_tests diff --git a/bzrlib/plugins/netrc_credential_store/tests/__init__.py b/bzrlib/plugins/netrc_credential_store/tests/__init__.py new file mode 100644 index 0000000..ab4bd39 --- /dev/null +++ b/bzrlib/plugins/netrc_credential_store/tests/__init__.py @@ -0,0 +1,23 @@ +# Copyright (C) 2008 by Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +def load_tests(basic_tests, module, loader): + testmod_names = [ + 'test_netrc', + ] + basic_tests.addTest(loader.loadTestsFromModuleNames( + ["%s.%s" % (__name__, tmn) for tmn in testmod_names])) + return basic_tests diff --git a/bzrlib/plugins/netrc_credential_store/tests/test_netrc.py b/bzrlib/plugins/netrc_credential_store/tests/test_netrc.py new file mode 100644 index 0000000..872ee57 --- /dev/null +++ b/bzrlib/plugins/netrc_credential_store/tests/test_netrc.py @@ -0,0 +1,86 @@ +# Copyright (C) 2008 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +from cStringIO import StringIO + +from bzrlib import ( + config, + errors, + osutils, + tests, + ) + +from bzrlib.plugins import netrc_credential_store + + +class TestNetrcCSNoNetrc(tests.TestCaseInTempDir): + + def test_home_netrc_does_not_exist(self): + self.assertRaises(errors.NoSuchFile, + config.credential_store_registry.get_credential_store, + 'netrc') + + +class TestNetrcCS(tests.TestCaseInTempDir): + + def setUp(self): + super(TestNetrcCS, self).setUp() + # Create a .netrc file + netrc_content = """ +machine host login joe password secret +default login anonymous password joe@home +""" + f = open(osutils.pathjoin(self.test_home_dir, '.netrc'), 'wb') + try: + f.write(netrc_content) + finally: + f.close() + + def _get_netrc_cs(self): + return config.credential_store_registry.get_credential_store('netrc') + + def test_not_matching_user(self): + cs = self._get_netrc_cs() + password = cs.decode_password(dict(host='host', user='jim')) + self.assertIs(None, password) + + def test_matching_user(self): + cs = self._get_netrc_cs() + password = cs.decode_password(dict(host='host', user='joe')) + self.assertEquals('secret', password) + + def test_default_password(self): + cs = self._get_netrc_cs() + password = cs.decode_password(dict(host='other', user='anonymous')) + self.assertEquals('joe@home', password) + + def test_default_password_without_user(self): + cs = self._get_netrc_cs() + password = cs.decode_password(dict(host='other')) + self.assertIs(None, password) + + def test_get_netrc_credentials_via_auth_config(self): + # Create a test AuthenticationConfig object + ac_content = """ +[host1] +host = host +user = joe +password_encoding = netrc +""" + conf = config.AuthenticationConfig(_file=StringIO(ac_content)) + credentials = conf.get_credentials('scheme', 'host', user='joe') + self.assertIsNot(None, credentials) + self.assertEquals('secret', credentials.get('password', None)) diff --git a/bzrlib/plugins/news_merge/README b/bzrlib/plugins/news_merge/README new file mode 100644 index 0000000..020f047 --- /dev/null +++ b/bzrlib/plugins/news_merge/README @@ -0,0 +1,7 @@ +A plugin for merging bzr's NEWS file. + +This plugin is activated via configuration variables, see 'bzr help news_merge'. + +This hook can resolve conflicts where both sides add entries at the same place. +If it encounters a more difficult conflict it gives up and bzr will fallback to +the default merge algorithm. diff --git a/bzrlib/plugins/news_merge/__init__.py b/bzrlib/plugins/news_merge/__init__.py new file mode 100644 index 0000000..2e0c33a --- /dev/null +++ b/bzrlib/plugins/news_merge/__init__.py @@ -0,0 +1,58 @@ +# Copyright (C) 2010 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +from __future__ import absolute_import + +__doc__ = """Merge hook for bzr's NEWS file. + +To enable this plugin, add a section to your branch.conf or location.conf +like:: + + [/home/user/code/bzr] + news_merge_files = NEWS + +The news_merge_files config option takes a list of file paths, separated by +commas. + +Limitations: + +* if there's a conflict in more than just bullet points, this doesn't yet know + how to resolve that, so bzr will fallback to the default line-based merge. +""" + +# Since we are a built-in plugin we share the bzrlib version +from bzrlib import version_info +from bzrlib.hooks import install_lazy_named_hook + + +def news_merge_hook(merger): + """Merger.merge_file_content hook for bzr-format NEWS files.""" + from bzrlib.plugins.news_merge.news_merge import NewsMerger + return NewsMerger(merger) + + +install_lazy_named_hook("bzrlib.merge", "Merger.hooks", "merge_file_content", + news_merge_hook, "NEWS file merge") + + +def load_tests(basic_tests, module, loader): + testmod_names = [ + 'tests', + ] + basic_tests.addTest(loader.loadTestsFromModuleNames( + ["%s.%s" % (__name__, tmn) for tmn in testmod_names])) + return basic_tests + diff --git a/bzrlib/plugins/news_merge/news_merge.py b/bzrlib/plugins/news_merge/news_merge.py new file mode 100644 index 0000000..a8b0a22 --- /dev/null +++ b/bzrlib/plugins/news_merge/news_merge.py @@ -0,0 +1,78 @@ +# Copyright (C) 2010 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Merge logic for news_merge plugin.""" + +from __future__ import absolute_import + + +from bzrlib.plugins.news_merge.parser import simple_parse_lines +from bzrlib import merge, merge3 + + +class NewsMerger(merge.ConfigurableFileMerger): + """Merge bzr NEWS files.""" + + name_prefix = "news" + + def merge_text(self, params): + """Perform a simple 3-way merge of a bzr NEWS file. + + Each section of a bzr NEWS file is essentially an ordered set of bullet + points, so we can simply take a set of bullet points, determine which + bullets to add and which to remove, sort, and reserialize. + """ + # Transform the different versions of the NEWS file into a bunch of + # text lines where each line matches one part of the overall + # structure, e.g. a heading or bullet. + this_lines = list(simple_parse_lines(params.this_lines)) + other_lines = list(simple_parse_lines(params.other_lines)) + base_lines = list(simple_parse_lines(params.base_lines)) + m3 = merge3.Merge3(base_lines, this_lines, other_lines, + allow_objects=True) + result_chunks = [] + for group in m3.merge_groups(): + if group[0] == 'conflict': + _, base, a, b = group + # Are all the conflicting lines bullets? If so, we can merge + # this. + for line_set in [base, a, b]: + for line in line_set: + if line[0] != 'bullet': + # Something else :( + # Maybe the default merge can cope. + return 'not_applicable', None + # Calculate additions and deletions. + new_in_a = set(a).difference(base) + new_in_b = set(b).difference(base) + all_new = new_in_a.union(new_in_b) + deleted_in_a = set(base).difference(a) + deleted_in_b = set(base).difference(b) + # Combine into the final set of bullet points. + final = all_new.difference(deleted_in_a).difference( + deleted_in_b) + # Sort, and emit. + final = sorted(final, key=sort_key) + result_chunks.extend(final) + else: + result_chunks.extend(group[1]) + # Transform the merged elements back into real blocks of lines. + result_lines = '\n\n'.join(chunk[1] for chunk in result_chunks) + return 'success', result_lines + + +def sort_key(chunk): + return chunk[1].replace('`', '').lower() diff --git a/bzrlib/plugins/news_merge/parser.py b/bzrlib/plugins/news_merge/parser.py new file mode 100644 index 0000000..4b34543 --- /dev/null +++ b/bzrlib/plugins/news_merge/parser.py @@ -0,0 +1,71 @@ +# Copyright (C) 2010 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Simple parser for bzr's NEWS file. + +Simple as this is, it's a bit over-powered for news_merge's needs, which only +cares about 'bullet' and 'everything else'. + +This module can be run as a standalone Python program; pass it a filename and +it will print the parsed form of a file (a series of 2-tuples, see +simple_parse's docstring). +""" + +from __future__ import absolute_import + + +def simple_parse_lines(lines): + """Same as simple_parse, but takes an iterable of strs rather than a single + str. + """ + return simple_parse(''.join(lines)) + + +def simple_parse(content): + """Returns blocks, where each block is a 2-tuple (kind, text). + + :kind: one of 'heading', 'release', 'section', 'empty' or 'text'. + :text: a str, including newlines. + """ + blocks = content.split('\n\n') + for block in blocks: + if block.startswith('###'): + # First line is ###...: Top heading + yield 'heading', block + continue + last_line = block.rsplit('\n', 1)[-1] + if last_line.startswith('###'): + # last line is ###...: 2nd-level heading + yield 'release', block + elif last_line.startswith('***'): + # last line is ***...: 3rd-level heading + yield 'section', block + elif block.startswith('* '): + # bullet + yield 'bullet', block + elif block.strip() == '': + # empty + yield 'empty', block + else: + # plain text + yield 'text', block + + +if __name__ == '__main__': + import sys + content = open(sys.argv[1], 'rb').read() + for result in simple_parse(content): + print result diff --git a/bzrlib/plugins/news_merge/tests/__init__.py b/bzrlib/plugins/news_merge/tests/__init__.py new file mode 100644 index 0000000..e4171d1 --- /dev/null +++ b/bzrlib/plugins/news_merge/tests/__init__.py @@ -0,0 +1,23 @@ +# Copyright (C) 2010 by Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +def load_tests(basic_tests, module, loader): + testmod_names = [ + 'test_news_merge', + ] + basic_tests.addTest(loader.loadTestsFromModuleNames( + ["%s.%s" % (__name__, tmn) for tmn in testmod_names])) + return basic_tests diff --git a/bzrlib/plugins/news_merge/tests/test_news_merge.py b/bzrlib/plugins/news_merge/tests/test_news_merge.py new file mode 100644 index 0000000..927ca1a --- /dev/null +++ b/bzrlib/plugins/news_merge/tests/test_news_merge.py @@ -0,0 +1,27 @@ +# Copyright (C) 2010 by Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +# FIXME: This is totally incomplete but I'm only the patch pilot :-) +# -- vila 100120 +# Note that the single test from this file is now in +# test_merge.TestConfigurableFileMerger -- rbc 20100129. + +from bzrlib import ( + option, + tests, + ) +from bzrlib.merge import Merger +from bzrlib.plugins import news_merge diff --git a/bzrlib/plugins/po_merge/README b/bzrlib/plugins/po_merge/README new file mode 100644 index 0000000..f6e661c --- /dev/null +++ b/bzrlib/plugins/po_merge/README @@ -0,0 +1,7 @@ +A plugin for merging .po files. + +This plugin is controlled via configuration variables, see 'bzr help po_merge'. + +This hook can avoid conflicts in ``.po` files by invoking msgmerge with the +appropriate options. If it can't apply, it falls back to the default bzr +merge algorithm. diff --git a/bzrlib/plugins/po_merge/__init__.py b/bzrlib/plugins/po_merge/__init__.py new file mode 100644 index 0000000..97c00db --- /dev/null +++ b/bzrlib/plugins/po_merge/__init__.py @@ -0,0 +1,92 @@ +# Copyright (C) 2011 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +from __future__ import absolute_import + +__doc__ = """Merge hook for ``.po`` files. + +To enable this plugin, add a section to your branch.conf or location.conf +like:: + + [/home/user/code/bzr] + po_merge.pot_dirs = po,doc/po4a/po + +The ``po_merge.pot_dirs`` config option takes a list of directories that can +contain ``.po`` files, separated by commas (if several directories are +needed). Each directory should contain a single ``.pot`` file. + +The ``po_merge.command`` is the command whose output is used as the result of +the merge. It defaults to:: + + msgmerge -N "{other}" "{pot_file}" -C "{this}" -o "{result}" + +where: + +* ``this`` is the ``.po`` file content before the merge in the current branch, +* ``other`` is the ``.po`` file content in the branch merged from, +* ``pot_file`` is the path to the ``.pot`` file corresponding to the ``.po`` + file being merged. + +If conflicts occur in a ``.pot`` file during a given merge, the ``.po`` files +will use the ``.pot`` file present in tree before the merge. If this doesn't +suit your needs, you should can disable the plugin during the merge with:: + + bzr merge <usual merge args> -Opo_merge.po_dirs= + +This will allow you to resolve the conflicts in the ``.pot`` file and then +merge the ``.po`` files again with:: + + bzr remerge po/*.po doc/po4a/po/*.po + +""" + +from bzrlib import ( + config, + # Since we are a built-in plugin we share the bzrlib version + version_info, + ) +from bzrlib.hooks import install_lazy_named_hook + + +def register_lazy_option(key, member): + config.option_registry.register_lazy( + key, 'bzrlib.plugins.po_merge.po_merge', member) + + +register_lazy_option('po_merge.command', 'command_option') +register_lazy_option('po_merge.po_dirs', 'po_dirs_option') +register_lazy_option('po_merge.po_glob', 'po_glob_option') +register_lazy_option('po_merge.pot_glob', 'pot_glob_option') + + +def po_merge_hook(merger): + """Merger.merge_file_content hook for po files.""" + from bzrlib.plugins.po_merge.po_merge import PoMerger + return PoMerger(merger) + + +install_lazy_named_hook("bzrlib.merge", "Merger.hooks", "merge_file_content", + po_merge_hook, ".po file merge") + + +def load_tests(basic_tests, module, loader): + testmod_names = [ + 'tests', + ] + basic_tests.addTest(loader.loadTestsFromModuleNames( + ["%s.%s" % (__name__, tmn) for tmn in testmod_names])) + return basic_tests + diff --git a/bzrlib/plugins/po_merge/po_merge.py b/bzrlib/plugins/po_merge/po_merge.py new file mode 100644 index 0000000..49b9a95 --- /dev/null +++ b/bzrlib/plugins/po_merge/po_merge.py @@ -0,0 +1,174 @@ +# Copyright (C) 2011 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Merge logic for po_merge plugin.""" + +from __future__ import absolute_import + +from bzrlib import ( + config, + merge, + ) + + +from bzrlib.lazy_import import lazy_import +lazy_import(globals(), """ +import fnmatch +import subprocess +import tempfile +import sys + +from bzrlib import ( + cmdline, + osutils, + trace, + ) +""") + + +command_option = config.Option( + 'po_merge.command', + default='msgmerge -N "{other}" "{pot_file}" -C "{this}" -o "{result}"', + help='''\ +Command used to create a conflict-free .po file during merge. + +The following parameters are provided by the hook: +``this`` is the ``.po`` file content before the merge in the current branch, +``other`` is the ``.po`` file content in the branch merged from, +``pot_file`` is the path to the ``.pot`` file corresponding to the ``.po`` +file being merged. +``result`` is the path where ``msgmerge`` will output its result. The hook will +use the content of this file to produce the resulting ``.po`` file. + +All paths are absolute. +''') + + +po_dirs_option = config.ListOption( + 'po_merge.po_dirs', default='po,debian/po', + help='List of dirs containing .po files that the hook applies to.') + + +po_glob_option = config.Option( + 'po_merge.po_glob', default='*.po', + help='Glob matching all ``.po`` files in one of ``po_merge.po_dirs``.') + +pot_glob_option = config.Option( + 'po_merge.pot_glob', default='*.pot', + help='Glob matching the ``.pot`` file in one of ``po_merge.po_dirs``.') + + +class PoMerger(merge.PerFileMerger): + """Merge .po files.""" + + def __init__(self, merger): + super(merge.PerFileMerger, self).__init__(merger) + # config options are cached locally until config files are (see + # http://pad.lv/832042) + + # FIXME: We use the branch config as there is no tree config + # -- vila 2011-11-23 + self.conf = merger.this_branch.get_config_stack() + # Which dirs are targeted by the hook + self.po_dirs = self.conf.get('po_merge.po_dirs') + # Which files are targeted by the hook + self.po_glob = self.conf.get('po_merge.po_glob') + # Which .pot file should be used + self.pot_glob = self.conf.get('po_merge.pot_glob') + self.command = self.conf.get('po_merge.command', expand=False) + # file_matches() will set the following for merge_text() + self.pot_file_abspath = None + trace.mutter('PoMerger created') + + def file_matches(self, params): + """Return True if merge_matching should be called on this file.""" + if not self.po_dirs or not self.command: + # Return early if there is no options defined + return False + po_dir = None + po_path = self.get_filepath(params, self.merger.this_tree) + for po_dir in self.po_dirs: + glob = osutils.pathjoin(po_dir, self.po_glob) + if fnmatch.fnmatch(po_path, glob): + trace.mutter('po %s matches: %s' % (po_path, glob)) + break + else: + trace.mutter('PoMerger did not match for %s and %s' + % (self.po_dirs, self.po_glob)) + return False + # Do we have the corresponding .pot file + for inv_entry in self.merger.this_tree.list_files(from_dir=po_dir, + recursive=False): + trace.mutter('inv_entry: %r' % (inv_entry,)) + pot_name, pot_file_id = inv_entry[0], inv_entry[3] + if fnmatch.fnmatch(pot_name, self.pot_glob): + relpath = osutils.pathjoin(po_dir, pot_name) + self.pot_file_abspath = self.merger.this_tree.abspath(relpath) + # FIXME: I can't find an easy way to know if the .pot file has + # conflicts *during* the merge itself. So either the actual + # content on disk is fine and msgmerge will work OR it's not + # and it will fail. Conversely, either the result is ok for the + # user and he's happy OR the user needs to resolve the + # conflicts in the .pot file and use remerge. + # -- vila 2011-11-24 + trace.mutter('will msgmerge %s using %s' + % (po_path, self.pot_file_abspath)) + return True + else: + return False + + def _invoke(self, command): + trace.mutter('Will msgmerge: %s' % (command,)) + # We use only absolute paths so we don't care about the cwd + proc = subprocess.Popen(cmdline.split(command), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + stdin=subprocess.PIPE) + out, err = proc.communicate() + return proc.returncode, out, err + + def merge_matching(self, params): + return self.merge_text(params) + + def merge_text(self, params): + """Calls msgmerge when .po files conflict. + + This requires a valid .pot file to reconcile both sides. + """ + # Create tmp files with the 'this' and 'other' content + tmpdir = tempfile.mkdtemp(prefix='po_merge') + env = {} + env['this'] = osutils.pathjoin(tmpdir, 'this') + env['other'] = osutils.pathjoin(tmpdir, 'other') + env['result'] = osutils.pathjoin(tmpdir, 'result') + env['pot_file'] = self.pot_file_abspath + try: + with osutils.open_file(env['this'], 'wb') as f: + f.writelines(params.this_lines) + with osutils.open_file(env['other'], 'wb') as f: + f.writelines(params.other_lines) + command = self.conf.expand_options(self.command, env) + retcode, out, err = self._invoke(command) + with osutils.open_file(env['result']) as f: + # FIXME: To avoid the list() construct below which means the + # whole 'result' file is kept in memory, there may be a way to + # use an iterator that will close the file when it's done, but + # there is still the issue of removing the tmp dir... + # -- vila 2011-11-24 + return 'success', list(f.readlines()) + finally: + osutils.rmtree(tmpdir) + return 'not applicable', [] diff --git a/bzrlib/plugins/po_merge/tests/__init__.py b/bzrlib/plugins/po_merge/tests/__init__.py new file mode 100644 index 0000000..56a3985 --- /dev/null +++ b/bzrlib/plugins/po_merge/tests/__init__.py @@ -0,0 +1,23 @@ +# Copyright (C) 2011 by Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +def load_tests(basic_tests, module, loader): + testmod_names = [ + 'test_po_merge', + ] + basic_tests.addTest(loader.loadTestsFromModuleNames( + ["%s.%s" % (__name__, tmn) for tmn in testmod_names])) + return basic_tests diff --git a/bzrlib/plugins/po_merge/tests/test_po_merge.py b/bzrlib/plugins/po_merge/tests/test_po_merge.py new file mode 100644 index 0000000..47fbc7c --- /dev/null +++ b/bzrlib/plugins/po_merge/tests/test_po_merge.py @@ -0,0 +1,451 @@ +# -*- coding: utf-8 +# Copyright (C) 2011 by Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +import os + +from bzrlib import ( + merge, + tests, + ) +from bzrlib.tests import ( + features, + script, + ) + +from bzrlib.plugins import po_merge + +class BlackboxTestPoMerger(script.TestCaseWithTransportAndScript): + + _test_needs_features = [features.msgmerge_feature] + + def setUp(self): + super(BlackboxTestPoMerger, self).setUp() + self.builder = make_adduser_branch(self, 'adduser') + # We need to install our hook as the test framework cleared it as part + # of the initialization + merge.Merger.hooks.install_named_hook( + "merge_file_content", po_merge.po_merge_hook, ".po file merge") + + def test_merge_with_hook_gives_unexpected_results(self): + # Since the conflicts in .pot are not seen *during* the merge, the .po + # merge triggers the hook and creates no conflicts for fr.po. But the + # .pot used is the one present in the tree *before* the merge. + self.run_script("""\ +$ bzr branch adduser -rrevid:this work +2>Branched 2 revisions. +$ cd work +$ bzr merge ../adduser -rrevid:other +2> M po/adduser.pot +2> M po/fr.po +2>Text conflict in po/adduser.pot +2>1 conflicts encountered. +""") + + def test_called_on_remerge(self): + # Merge with no config for the hook to create the conflicts + self.run_script("""\ +$ bzr branch adduser -rrevid:this work +2>Branched 2 revisions. +$ cd work +# set po_dirs to an empty list +$ bzr merge ../adduser -rrevid:other -Opo_merge.po_dirs= +2> M po/adduser.pot +2> M po/fr.po +2>Text conflict in po/adduser.pot +2>Text conflict in po/fr.po +2>2 conflicts encountered. +""") + # Fix the conflicts in the .pot file + with open('po/adduser.pot', 'w') as f: + f.write(_Adduser['resolved_pot']) + # Tell bzr the conflict is resolved + self.run_script("""\ +$ bzr resolve po/adduser.pot +2>1 conflict resolved, 1 remaining +# Use remerge to trigger the hook, we use the default config options here +$ bzr remerge po/*.po +2>All changes applied successfully. +# There should be no conflicts anymore +$ bzr conflicts +""") + + +def make_adduser_branch(test, relpath): + """Helper for po_merge blackbox tests. + + This creates a branch containing the needed base revisions so tests can + attempt merges and conflict resolutions. + """ + builder = test.make_branch_builder(relpath) + builder.start_series() + builder.build_snapshot('base', None, + [('add', ('', 'root-id', 'directory', '')), + # Create empty files + ('add', ('po', 'dir-id', 'directory', None),), + ('add', ('po/adduser.pot', 'pot-id', 'file', + _Adduser['base_pot'])), + ('add', ('po/fr.po', 'po-id', 'file', + _Adduser['base_po'])), + ]) + # The 'other' branch + builder.build_snapshot('other', ['base'], + [('modify', ('pot-id', + _Adduser['other_pot'])), + ('modify', ('po-id', + _Adduser['other_po'])), + ]) + # The 'this' branch + builder.build_snapshot('this', ['base'], + [('modify', ('pot-id', _Adduser['this_pot'])), + ('modify', ('po-id', _Adduser['this_po'])), + ]) + # builder.get_branch() tip is now 'this' + builder.finish_series() + return builder + + +class TestAdduserBranch(script.TestCaseWithTransportAndScript): + """Sanity checks on the adduser branch content.""" + + def setUp(self): + super(TestAdduserBranch, self).setUp() + self.builder = make_adduser_branch(self, 'adduser') + + def assertAdduserBranchContent(self, revid): + env = dict(revid=revid, branch_name=revid) + self.run_script("""\ +$ bzr branch adduser -rrevid:%(revid)s %(branch_name)s +""" % env, null_output_matches_anything=True) + self.assertFileEqual(_Adduser['%(revid)s_pot' % env], + '%(branch_name)s/po/adduser.pot' % env) + self.assertFileEqual(_Adduser['%(revid)s_po' % env], + '%(branch_name)s/po/fr.po' % env ) + + def test_base(self): + self.assertAdduserBranchContent('base') + + def test_this(self): + self.assertAdduserBranchContent('this') + + def test_other(self): + self.assertAdduserBranchContent('other') + + +# Real content from the adduser package so we don't have to guess about format +# details. This is declared at the end of the file to avoid cluttering the +# beginning of the file. + +_Adduser = dict( + base_pot = r"""# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: adduser-devel@example.com\n" +"POT-Creation-Date: 2007-01-17 21:50+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: LANGUAGE <LL@example.com>\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" + +#. everyone can issue "--help" and "--version", but only root can go on +#: ../adduser:135 +msgid "Only root may add a user or group to the system.\n" +msgstr "" + +#: ../adduser:188 +msgid "Warning: The home dir you specified already exists.\n" +msgstr "" + +""", + this_pot = r"""# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: adduser-devel@example.com\n" +"POT-Creation-Date: 2011-01-06 21:06+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: LANGUAGE <LL@example.com>\n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" + +#. everyone can issue "--help" and "--version", but only root can go on +#: ../adduser:152 +msgid "Only root may add a user or group to the system.\n" +msgstr "" + +#: ../adduser:208 +#, perl-format +msgid "Warning: The home dir %s you specified already exists.\n" +msgstr "" + +#: ../adduser:210 +#, perl-format +msgid "Warning: The home dir %s you specified can't be accessed: %s\n" +msgstr "" + +""", + other_pot = r"""# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: adduser-devel@example.com\n" +"POT-Creation-Date: 2010-11-21 17:13-0400\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: LANGUAGE <LL@example.com>\n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" + +#. everyone can issue "--help" and "--version", but only root can go on +#: ../adduser:150 +msgid "Only root may add a user or group to the system.\n" +msgstr "" + +#: ../adduser:206 +#, perl-format +msgid "Warning: The home dir %s you specified already exists.\n" +msgstr "" + +#: ../adduser:208 +#, perl-format +msgid "Warning: The home dir %s you specified can't be accessed: %s\n" +msgstr "" + +""", + resolved_pot = r"""# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: adduser-devel@example.com\n" +"POT-Creation-Date: 2011-10-19 12:50-0700\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: LANGUAGE <LL@example.com>\n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=CHARSET\n" +"Content-Transfer-Encoding: 8bit\n" + +#. everyone can issue "--help" and "--version", but only root can go on +#: ../adduser:152 +msgid "Only root may add a user or group to the system.\n" +msgstr "" + +#: ../adduser:208 +#, perl-format +msgid "Warning: The home dir %s you specified already exists.\n" +msgstr "" + +#: ../adduser:210 +#, perl-format +msgid "Warning: The home dir %s you specified can't be accessed: %s\n" +msgstr "" + +""", + base_po = r"""# adduser's manpages translation to French +# Copyright (C) 2004 Software in the Public Interest +# This file is distributed under the same license as the adduser package +# +# Translators: +# Jean-Baka Domelevo Entfellner <domelevo@example.com>, 2009. +# +msgid "" +msgstr "" +"Project-Id-Version: adduser 3.111\n" +"Report-Msgid-Bugs-To: adduser-devel@example.com\n" +"POT-Creation-Date: 2007-01-17 21:50+0100\n" +"PO-Revision-Date: 2010-01-21 10:36+0100\n" +"Last-Translator: Jean-Baka Domelevo Entfellner <domelevo@example.com>\n" +"Language-Team: Debian French Team <debian-l10n-french@example.com>\n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Poedit-Language: French\n" +"X-Poedit-Country: FRANCE\n" + +# type: Plain text +#. everyone can issue "--help" and "--version", but only root can go on +#: ../adduser:135 +msgid "Only root may add a user or group to the system.\n" +msgstr "" +"Seul le superutilisateur est autorisé à ajouter un utilisateur ou un groupe " +"au système.\n" + +#: ../adduser:188 +msgid "Warning: The home dir you specified already exists.\n" +msgstr "" +"Attention ! Le répertoire personnel que vous avez indiqué existe déjà .\n" + +""", + this_po = r"""# adduser's manpages translation to French +# Copyright (C) 2004 Software in the Public Interest +# This file is distributed under the same license as the adduser package +# +# Translators: +# Jean-Baka Domelevo Entfellner <domelevo@example.com>, 2009. +# +msgid "" +msgstr "" +"Project-Id-Version: adduser 3.111\n" +"Report-Msgid-Bugs-To: adduser-devel@example.com\n" +"POT-Creation-Date: 2010-10-12 15:48+0200\n" +"PO-Revision-Date: 2010-01-21 10:36+0100\n" +"Last-Translator: Jean-Baka Domelevo Entfellner <domelevo@example.com>\n" +"Language-Team: Debian French Team <debian-l10n-french@example.com>\n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Poedit-Language: French\n" +"X-Poedit-Country: FRANCE\n" + +# type: Plain text +#. everyone can issue "--help" and "--version", but only root can go on +#: ../adduser:152 +msgid "Only root may add a user or group to the system.\n" +msgstr "" +"Seul le superutilisateur est autorisé à ajouter un utilisateur ou un groupe " +"au système.\n" + +#: ../adduser:208 +#, fuzzy, perl-format +msgid "Warning: The home dir %s you specified already exists.\n" +msgstr "" +"Attention ! Le répertoire personnel que vous avez indiqué existe déjà .\n" + +#: ../adduser:210 +#, fuzzy, perl-format +msgid "Warning: The home dir %s you specified can't be accessed: %s\n" +msgstr "" +"Attention ! Le répertoire personnel que vous avez indiqué existe déjà .\n" + +""", + other_po = r"""# adduser's manpages translation to French +# Copyright (C) 2004 Software in the Public Interest +# This file is distributed under the same license as the adduser package +# +# Translators: +# Jean-Baka Domelevo Entfellner <domelevo@example.com>, 2009, 2010. +# +msgid "" +msgstr "" +"Project-Id-Version: adduser 3.112+nmu2\n" +"Report-Msgid-Bugs-To: adduser-devel@example.com\n" +"POT-Creation-Date: 2010-11-21 17:13-0400\n" +"PO-Revision-Date: 2010-11-10 11:08+0100\n" +"Last-Translator: Jean-Baka Domelevo-Entfellner <domelevo@example.com>\n" +"Language-Team: Debian French Team <debian-l10n-french@example.com>\n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Poedit-Country: FRANCE\n" + +# type: Plain text +#. everyone can issue "--help" and "--version", but only root can go on +#: ../adduser:150 +msgid "Only root may add a user or group to the system.\n" +msgstr "" +"Seul le superutilisateur est autorisé à ajouter un utilisateur ou un groupe " +"au système.\n" + +#: ../adduser:206 +#, perl-format +msgid "Warning: The home dir %s you specified already exists.\n" +msgstr "" +"Attention ! Le répertoire personnel que vous avez indiqué (%s) existe déjà .\n" + +#: ../adduser:208 +#, perl-format +msgid "Warning: The home dir %s you specified can't be accessed: %s\n" +msgstr "" +"Attention ! Impossible d'accéder au répertoire personnel que vous avez " +"indiqué (%s) : %s.\n" + +""", + resolved_po = r"""# adduser's manpages translation to French +# Copyright (C) 2004 Software in the Public Interest +# This file is distributed under the same license as the adduser package +# +# Translators: +# Jean-Baka Domelevo Entfellner <domelevo@example.com>, 2009, 2010. +# +msgid "" +msgstr "" +"Project-Id-Version: adduser 3.112+nmu2\n" +"Report-Msgid-Bugs-To: adduser-devel@example.com\n" +"POT-Creation-Date: 2011-10-19 12:50-0700\n" +"PO-Revision-Date: 2010-11-10 11:08+0100\n" +"Last-Translator: Jean-Baka Domelevo-Entfellner <domelevo@example.com>\n" +"Language-Team: Debian French Team <debian-l10n-french@example.com>\n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Poedit-Country: FRANCE\n" + +# type: Plain text +#. everyone can issue "--help" and "--version", but only root can go on +#: ../adduser:152 +msgid "Only root may add a user or group to the system.\n" +msgstr "" +"Seul le superutilisateur est autorisé à ajouter un utilisateur ou un groupe " +"au système.\n" + +#: ../adduser:208 +#, perl-format +msgid "Warning: The home dir %s you specified already exists.\n" +msgstr "" +"Attention ! Le répertoire personnel que vous avez indiqué (%s) existe déjà .\n" + +#: ../adduser:210 +#, perl-format +msgid "Warning: The home dir %s you specified can't be accessed: %s\n" +msgstr "" +"Attention ! Impossible d'accéder au répertoire personnel que vous avez " +"indiqué (%s) : %s.\n" + +""", +) diff --git a/bzrlib/plugins/weave_fmt/__init__.py b/bzrlib/plugins/weave_fmt/__init__.py new file mode 100644 index 0000000..e7d33d2 --- /dev/null +++ b/bzrlib/plugins/weave_fmt/__init__.py @@ -0,0 +1,128 @@ +# Copyright (C) 2010 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Weave formats. + +These were formats present in pre-1.0 version of Bazaar. +""" + +from __future__ import absolute_import + +# Since we are a built-in plugin we share the bzrlib version +from bzrlib import version_info + +from bzrlib import ( + branch as _mod_branch, + controldir, + repository as _mod_repository, + serializer, + workingtree as _mod_workingtree, + ) +from bzrlib.bzrdir import ( + BzrProber, + register_metadir, + ) + +# Pre-0.8 formats that don't have a disk format string (because they are +# versioned by the matching control directory). We use the control directories +# disk format string as a key for the network_name because they meet the +# constraints (simple string, unique, immutable). +_mod_repository.network_format_registry.register_lazy( + "Bazaar-NG branch, format 5\n", + 'bzrlib.plugins.weave_fmt.repository', + 'RepositoryFormat5', +) +_mod_repository.network_format_registry.register_lazy( + "Bazaar-NG branch, format 6\n", + 'bzrlib.plugins.weave_fmt.repository', + 'RepositoryFormat6', +) + +# weave formats which has no format string and are not discoverable or independently +# creatable on disk, so are not registered in format_registry. They're +# all in bzrlib.plugins.weave_fmt.repository now. When an instance of one of these is +# needed, it's constructed directly by the BzrDir. Non-native formats where +# the repository is not separately opened are similar. + +_mod_repository.format_registry.register_lazy( + 'Bazaar-NG Repository format 7', + 'bzrlib.plugins.weave_fmt.repository', + 'RepositoryFormat7' + ) + +_mod_repository.format_registry.register_extra_lazy( + 'bzrlib.plugins.weave_fmt.repository', + 'RepositoryFormat4') +_mod_repository.format_registry.register_extra_lazy( + 'bzrlib.plugins.weave_fmt.repository', + 'RepositoryFormat5') +_mod_repository.format_registry.register_extra_lazy( + 'bzrlib.plugins.weave_fmt.repository', + 'RepositoryFormat6') + + +# The pre-0.8 formats have their repository format network name registered in +# repository.py. MetaDir formats have their repository format network name +# inferred from their disk format string. +controldir.format_registry.register_lazy('weave', + "bzrlib.plugins.weave_fmt.bzrdir", "BzrDirFormat6", + 'Pre-0.8 format. Slower than knit and does not' + ' support checkouts or shared repositories.', + hidden=True, + deprecated=True) +register_metadir(controldir.format_registry, 'metaweave', + 'bzrlib.plugins.weave_fmt.repository.RepositoryFormat7', + 'Transitional format in 0.8. Slower than knit.', + branch_format='bzrlib.branchfmt.fullhistory.BzrBranchFormat5', + tree_format='bzrlib.workingtree_3.WorkingTreeFormat3', + hidden=True, + deprecated=True) + + +BzrProber.formats.register_lazy( + "Bazaar-NG branch, format 0.0.4\n", "bzrlib.plugins.weave_fmt.bzrdir", + "BzrDirFormat4") +BzrProber.formats.register_lazy( + "Bazaar-NG branch, format 5\n", "bzrlib.plugins.weave_fmt.bzrdir", + "BzrDirFormat5") +BzrProber.formats.register_lazy( + "Bazaar-NG branch, format 6\n", "bzrlib.plugins.weave_fmt.bzrdir", + "BzrDirFormat6") + + +_mod_branch.format_registry.register_extra_lazy( + 'bzrlib.plugins.weave_fmt.branch', 'BzrBranchFormat4') +_mod_branch.network_format_registry.register_lazy( + "Bazaar-NG branch, format 6\n", + 'bzrlib.plugins.weave_fmt.branch', "BzrBranchFormat4") + + +_mod_workingtree.format_registry.register_extra_lazy( + 'bzrlib.plugins.weave_fmt.workingtree', + 'WorkingTreeFormat2') + +serializer.format_registry.register_lazy('4', 'bzrlib.plugins.weave_fmt.xml4', + 'serializer_v4') + +def load_tests(basic_tests, module, loader): + testmod_names = [ + 'test_bzrdir', + 'test_repository', + 'test_workingtree', + ] + basic_tests.addTest(loader.loadTestsFromModuleNames( + ["%s.%s" % (__name__, tmn) for tmn in testmod_names])) + return basic_tests diff --git a/bzrlib/plugins/weave_fmt/branch.py b/bzrlib/plugins/weave_fmt/branch.py new file mode 100644 index 0000000..f9852c2 --- /dev/null +++ b/bzrlib/plugins/weave_fmt/branch.py @@ -0,0 +1,219 @@ +# Copyright (C) 2010 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Weave-era branch implementations.""" + +from __future__ import absolute_import + +from bzrlib import ( + errors, + lockable_files, + ) + +from bzrlib.decorators import ( + needs_read_lock, + needs_write_lock, + only_raises, + ) +from bzrlib.lock import LogicalLockResult +from bzrlib.trace import mutter + +from bzrlib.branch import ( + BranchFormat, + BranchWriteLockResult, + ) +from bzrlib.branchfmt.fullhistory import ( + FullHistoryBzrBranch, + ) + + +class BzrBranch4(FullHistoryBzrBranch): + """Branch format 4.""" + + def lock_write(self, token=None): + """Lock the branch for write operations. + + :param token: A token to permit reacquiring a previously held and + preserved lock. + :return: A BranchWriteLockResult. + """ + if not self.is_locked(): + self._note_lock('w') + # All-in-one needs to always unlock/lock. + self.repository._warn_if_deprecated(self) + self.repository.lock_write() + try: + return BranchWriteLockResult(self.unlock, + self.control_files.lock_write(token=token)) + except: + self.repository.unlock() + raise + + def lock_read(self): + """Lock the branch for read operations. + + :return: A bzrlib.lock.LogicalLockResult. + """ + if not self.is_locked(): + self._note_lock('r') + # All-in-one needs to always unlock/lock. + self.repository._warn_if_deprecated(self) + self.repository.lock_read() + try: + self.control_files.lock_read() + return LogicalLockResult(self.unlock) + except: + self.repository.unlock() + raise + + @only_raises(errors.LockNotHeld, errors.LockBroken) + def unlock(self): + if self.control_files._lock_count == 2 and self.conf_store is not None: + self.conf_store.save_changes() + try: + self.control_files.unlock() + finally: + # All-in-one needs to always unlock/lock. + self.repository.unlock() + if not self.control_files.is_locked(): + # we just released the lock + self._clear_cached_state() + + def _get_checkout_format(self, lightweight=False): + """Return the most suitable metadir for a checkout of this branch. + """ + from bzrlib.plugins.weave_fmt.repository import RepositoryFormat7 + from bzrlib.bzrdir import BzrDirMetaFormat1 + format = BzrDirMetaFormat1() + if lightweight: + format.set_branch_format(self._format) + format.repository_format = self.bzrdir._format.repository_format + else: + format.repository_format = RepositoryFormat7() + return format + + def unbind(self): + raise errors.UpgradeRequired(self.user_url) + + def bind(self, other): + raise errors.UpgradeRequired(self.user_url) + + def set_bound_location(self, location): + raise NotImplementedError(self.set_bound_location) + + def get_bound_location(self): + return None + + def update(self): + return None + + def get_master_branch(self, possible_transports=None): + return None + + +class BzrBranchFormat4(BranchFormat): + """Bzr branch format 4. + + This format has: + - a revision-history file. + - a branch-lock lock file [ to be shared with the bzrdir ] + + It does not support binding. + """ + + def initialize(self, a_bzrdir, name=None, repository=None, + append_revisions_only=None): + """Create a branch of this format in a_bzrdir. + + :param a_bzrdir: The bzrdir to initialize the branch in + :param name: Name of colocated branch to create, if any + :param repository: Repository for this branch (unused) + """ + if append_revisions_only: + raise errors.UpgradeRequired(a_bzrdir.user_url) + if repository is not None: + raise NotImplementedError( + "initialize(repository=<not None>) on %r" % (self,)) + if not [isinstance(a_bzrdir._format, format) for format in + self._compatible_bzrdirs]: + raise errors.IncompatibleFormat(self, a_bzrdir._format) + utf8_files = [('revision-history', ''), + ('branch-name', ''), + ] + mutter('creating branch %r in %s', self, a_bzrdir.user_url) + branch_transport = a_bzrdir.get_branch_transport(self, name=name) + control_files = lockable_files.LockableFiles(branch_transport, + 'branch-lock', lockable_files.TransportLock) + control_files.create_lock() + try: + control_files.lock_write() + except errors.LockContention: + lock_taken = False + else: + lock_taken = True + try: + for (filename, content) in utf8_files: + branch_transport.put_bytes( + filename, content, + mode=a_bzrdir._get_file_mode()) + finally: + if lock_taken: + control_files.unlock() + branch = self.open(a_bzrdir, name, _found=True, + found_repository=None) + self._run_post_branch_init_hooks(a_bzrdir, name, branch) + return branch + + def __init__(self): + super(BzrBranchFormat4, self).__init__() + from bzrlib.plugins.weave_fmt.bzrdir import ( + BzrDirFormat4, BzrDirFormat5, BzrDirFormat6, + ) + self._matchingbzrdir = BzrDirFormat6() + self._compatible_bzrdirs = [BzrDirFormat4, BzrDirFormat5, + BzrDirFormat6] + + def network_name(self): + """The network name for this format is the control dirs disk label.""" + return self._matchingbzrdir.get_format_string() + + def get_format_description(self): + return "Branch format 4" + + def open(self, a_bzrdir, name=None, _found=False, ignore_fallbacks=False, + found_repository=None, possible_transports=None): + """See BranchFormat.open().""" + if name is None: + name = a_bzrdir._get_selected_branch() + if name != "": + raise errors.NoColocatedBranchSupport(self) + if not _found: + # we are being called directly and must probe. + raise NotImplementedError + if found_repository is None: + found_repository = a_bzrdir.open_repository() + return BzrBranch4(_format=self, + _control_files=a_bzrdir._control_files, + a_bzrdir=a_bzrdir, + name=name, + _repository=found_repository, + possible_transports=possible_transports) + + def __str__(self): + return "Bazaar-NG branch format 4" + + def supports_leaving_lock(self): + return False diff --git a/bzrlib/plugins/weave_fmt/bzrdir.py b/bzrlib/plugins/weave_fmt/bzrdir.py new file mode 100644 index 0000000..5b593e8 --- /dev/null +++ b/bzrlib/plugins/weave_fmt/bzrdir.py @@ -0,0 +1,1006 @@ +# Copyright (C) 2006-2010 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Weave-era BzrDir formats.""" + +from __future__ import absolute_import + +from bzrlib.bzrdir import ( + BzrDir, + BzrDirFormat, + BzrDirMetaFormat1, + ) +from bzrlib.controldir import ( + ControlDir, + Converter, + format_registry, + ) +from bzrlib.lazy_import import lazy_import +lazy_import(globals(), """ +import os +import warnings + +from bzrlib import ( + errors, + graph, + lockable_files, + lockdir, + osutils, + revision as _mod_revision, + trace, + ui, + urlutils, + versionedfile, + weave, + xml5, + ) +from bzrlib.i18n import gettext +from bzrlib.store.versioned import VersionedFileStore +from bzrlib.transactions import WriteTransaction +from bzrlib.transport import ( + get_transport, + local, + ) +from bzrlib.plugins.weave_fmt import xml4 +""") + + +class BzrDirFormatAllInOne(BzrDirFormat): + """Common class for formats before meta-dirs.""" + + fixed_components = True + + def initialize_on_transport_ex(self, transport, use_existing_dir=False, + create_prefix=False, force_new_repo=False, stacked_on=None, + stack_on_pwd=None, repo_format_name=None, make_working_trees=None, + shared_repo=False): + """See ControlDir.initialize_on_transport_ex.""" + require_stacking = (stacked_on is not None) + # Format 5 cannot stack, but we've been asked to - actually init + # a Meta1Dir + if require_stacking: + format = BzrDirMetaFormat1() + return format.initialize_on_transport_ex(transport, + use_existing_dir=use_existing_dir, create_prefix=create_prefix, + force_new_repo=force_new_repo, stacked_on=stacked_on, + stack_on_pwd=stack_on_pwd, repo_format_name=repo_format_name, + make_working_trees=make_working_trees, shared_repo=shared_repo) + return BzrDirFormat.initialize_on_transport_ex(self, transport, + use_existing_dir=use_existing_dir, create_prefix=create_prefix, + force_new_repo=force_new_repo, stacked_on=stacked_on, + stack_on_pwd=stack_on_pwd, repo_format_name=repo_format_name, + make_working_trees=make_working_trees, shared_repo=shared_repo) + + @classmethod + def from_string(cls, format_string): + if format_string != cls.get_format_string(): + raise AssertionError("unexpected format string %r" % format_string) + return cls() + + +class BzrDirFormat5(BzrDirFormatAllInOne): + """Bzr control format 5. + + This format is a combined format for working tree, branch and repository. + It has: + - Format 2 working trees [always] + - Format 4 branches [always] + - Format 5 repositories [always] + Unhashed stores in the repository. + """ + + _lock_class = lockable_files.TransportLock + + def __eq__(self, other): + return type(self) == type(other) + + @classmethod + def get_format_string(cls): + """See BzrDirFormat.get_format_string().""" + return "Bazaar-NG branch, format 5\n" + + def get_branch_format(self): + from bzrlib.plugins.weave_fmt.branch import BzrBranchFormat4 + return BzrBranchFormat4() + + def get_format_description(self): + """See ControlDirFormat.get_format_description().""" + return "All-in-one format 5" + + def get_converter(self, format=None): + """See ControlDirFormat.get_converter().""" + # there is one and only one upgrade path here. + return ConvertBzrDir5To6() + + def _initialize_for_clone(self, url): + return self.initialize_on_transport(get_transport(url), _cloning=True) + + def initialize_on_transport(self, transport, _cloning=False): + """Format 5 dirs always have working tree, branch and repository. + + Except when they are being cloned. + """ + from bzrlib.plugins.weave_fmt.branch import BzrBranchFormat4 + from bzrlib.plugins.weave_fmt.repository import RepositoryFormat5 + result = (super(BzrDirFormat5, self).initialize_on_transport(transport)) + RepositoryFormat5().initialize(result, _internal=True) + if not _cloning: + branch = BzrBranchFormat4().initialize(result) + result._init_workingtree() + return result + + def network_name(self): + return self.get_format_string() + + def _open(self, transport): + """See BzrDirFormat._open.""" + return BzrDir5(transport, self) + + def __return_repository_format(self): + """Circular import protection.""" + from bzrlib.plugins.weave_fmt.repository import RepositoryFormat5 + return RepositoryFormat5() + repository_format = property(__return_repository_format) + + +class BzrDirFormat6(BzrDirFormatAllInOne): + """Bzr control format 6. + + This format is a combined format for working tree, branch and repository. + It has: + - Format 2 working trees [always] + - Format 4 branches [always] + - Format 6 repositories [always] + """ + + _lock_class = lockable_files.TransportLock + + def __eq__(self, other): + return type(self) == type(other) + + @classmethod + def get_format_string(cls): + """See BzrDirFormat.get_format_string().""" + return "Bazaar-NG branch, format 6\n" + + def get_format_description(self): + """See ControlDirFormat.get_format_description().""" + return "All-in-one format 6" + + def get_branch_format(self): + from bzrlib.plugins.weave_fmt.branch import BzrBranchFormat4 + return BzrBranchFormat4() + + def get_converter(self, format=None): + """See ControlDirFormat.get_converter().""" + # there is one and only one upgrade path here. + return ConvertBzrDir6ToMeta() + + def _initialize_for_clone(self, url): + return self.initialize_on_transport(get_transport(url), _cloning=True) + + def initialize_on_transport(self, transport, _cloning=False): + """Format 6 dirs always have working tree, branch and repository. + + Except when they are being cloned. + """ + from bzrlib.plugins.weave_fmt.branch import BzrBranchFormat4 + from bzrlib.plugins.weave_fmt.repository import RepositoryFormat6 + result = super(BzrDirFormat6, self).initialize_on_transport(transport) + RepositoryFormat6().initialize(result, _internal=True) + if not _cloning: + branch = BzrBranchFormat4().initialize(result) + result._init_workingtree() + return result + + def network_name(self): + return self.get_format_string() + + def _open(self, transport): + """See BzrDirFormat._open.""" + return BzrDir6(transport, self) + + def __return_repository_format(self): + """Circular import protection.""" + from bzrlib.plugins.weave_fmt.repository import RepositoryFormat6 + return RepositoryFormat6() + repository_format = property(__return_repository_format) + + +class ConvertBzrDir4To5(Converter): + """Converts format 4 bzr dirs to format 5.""" + + def __init__(self): + super(ConvertBzrDir4To5, self).__init__() + self.converted_revs = set() + self.absent_revisions = set() + self.text_count = 0 + self.revisions = {} + + def convert(self, to_convert, pb): + """See Converter.convert().""" + self.bzrdir = to_convert + if pb is not None: + warnings.warn(gettext("pb parameter to convert() is deprecated")) + self.pb = ui.ui_factory.nested_progress_bar() + try: + ui.ui_factory.note(gettext('starting upgrade from format 4 to 5')) + if isinstance(self.bzrdir.transport, local.LocalTransport): + self.bzrdir.get_workingtree_transport(None).delete('stat-cache') + self._convert_to_weaves() + return ControlDir.open(self.bzrdir.user_url) + finally: + self.pb.finished() + + def _convert_to_weaves(self): + ui.ui_factory.note(gettext( + 'note: upgrade may be faster if all store files are ungzipped first')) + try: + # TODO permissions + stat = self.bzrdir.transport.stat('weaves') + if not S_ISDIR(stat.st_mode): + self.bzrdir.transport.delete('weaves') + self.bzrdir.transport.mkdir('weaves') + except errors.NoSuchFile: + self.bzrdir.transport.mkdir('weaves') + # deliberately not a WeaveFile as we want to build it up slowly. + self.inv_weave = weave.Weave('inventory') + # holds in-memory weaves for all files + self.text_weaves = {} + self.bzrdir.transport.delete('branch-format') + self.branch = self.bzrdir.open_branch() + self._convert_working_inv() + rev_history = self.branch._revision_history() + # to_read is a stack holding the revisions we still need to process; + # appending to it adds new highest-priority revisions + self.known_revisions = set(rev_history) + self.to_read = rev_history[-1:] + while self.to_read: + rev_id = self.to_read.pop() + if (rev_id not in self.revisions + and rev_id not in self.absent_revisions): + self._load_one_rev(rev_id) + self.pb.clear() + to_import = self._make_order() + for i, rev_id in enumerate(to_import): + self.pb.update(gettext('converting revision'), i, len(to_import)) + self._convert_one_rev(rev_id) + self.pb.clear() + self._write_all_weaves() + self._write_all_revs() + ui.ui_factory.note(gettext('upgraded to weaves:')) + ui.ui_factory.note(' ' + gettext('%6d revisions and inventories') % + len(self.revisions)) + ui.ui_factory.note(' ' + gettext('%6d revisions not present') % + len(self.absent_revisions)) + ui.ui_factory.note(' ' + gettext('%6d texts') % self.text_count) + self._cleanup_spare_files_after_format4() + self.branch._transport.put_bytes( + 'branch-format', + BzrDirFormat5().get_format_string(), + mode=self.bzrdir._get_file_mode()) + + def _cleanup_spare_files_after_format4(self): + # FIXME working tree upgrade foo. + for n in 'merged-patches', 'pending-merged-patches': + try: + ## assert os.path.getsize(p) == 0 + self.bzrdir.transport.delete(n) + except errors.NoSuchFile: + pass + self.bzrdir.transport.delete_tree('inventory-store') + self.bzrdir.transport.delete_tree('text-store') + + def _convert_working_inv(self): + inv = xml4.serializer_v4.read_inventory( + self.branch._transport.get('inventory')) + new_inv_xml = xml5.serializer_v5.write_inventory_to_string(inv, working=True) + self.branch._transport.put_bytes('inventory', new_inv_xml, + mode=self.bzrdir._get_file_mode()) + + def _write_all_weaves(self): + controlweaves = VersionedFileStore(self.bzrdir.transport, prefixed=False, + versionedfile_class=weave.WeaveFile) + weave_transport = self.bzrdir.transport.clone('weaves') + weaves = VersionedFileStore(weave_transport, prefixed=False, + versionedfile_class=weave.WeaveFile) + transaction = WriteTransaction() + + try: + i = 0 + for file_id, file_weave in self.text_weaves.items(): + self.pb.update(gettext('writing weave'), i, + len(self.text_weaves)) + weaves._put_weave(file_id, file_weave, transaction) + i += 1 + self.pb.update(gettext('inventory'), 0, 1) + controlweaves._put_weave('inventory', self.inv_weave, transaction) + self.pb.update(gettext('inventory'), 1, 1) + finally: + self.pb.clear() + + def _write_all_revs(self): + """Write all revisions out in new form.""" + self.bzrdir.transport.delete_tree('revision-store') + self.bzrdir.transport.mkdir('revision-store') + revision_transport = self.bzrdir.transport.clone('revision-store') + # TODO permissions + from bzrlib.xml5 import serializer_v5 + from bzrlib.plugins.weave_fmt.repository import RevisionTextStore + revision_store = RevisionTextStore(revision_transport, + serializer_v5, False, versionedfile.PrefixMapper(), + lambda:True, lambda:True) + try: + for i, rev_id in enumerate(self.converted_revs): + self.pb.update(gettext('write revision'), i, + len(self.converted_revs)) + text = serializer_v5.write_revision_to_string( + self.revisions[rev_id]) + key = (rev_id,) + revision_store.add_lines(key, None, osutils.split_lines(text)) + finally: + self.pb.clear() + + def _load_one_rev(self, rev_id): + """Load a revision object into memory. + + Any parents not either loaded or abandoned get queued to be + loaded.""" + self.pb.update(gettext('loading revision'), + len(self.revisions), + len(self.known_revisions)) + if not self.branch.repository.has_revision(rev_id): + self.pb.clear() + ui.ui_factory.note(gettext('revision {%s} not present in branch; ' + 'will be converted as a ghost') % + rev_id) + self.absent_revisions.add(rev_id) + else: + rev = self.branch.repository.get_revision(rev_id) + for parent_id in rev.parent_ids: + self.known_revisions.add(parent_id) + self.to_read.append(parent_id) + self.revisions[rev_id] = rev + + def _load_old_inventory(self, rev_id): + f = self.branch.repository.inventory_store.get(rev_id) + try: + old_inv_xml = f.read() + finally: + f.close() + inv = xml4.serializer_v4.read_inventory_from_string(old_inv_xml) + inv.revision_id = rev_id + rev = self.revisions[rev_id] + return inv + + def _load_updated_inventory(self, rev_id): + inv_xml = self.inv_weave.get_text(rev_id) + inv = xml5.serializer_v5.read_inventory_from_string(inv_xml, rev_id) + return inv + + def _convert_one_rev(self, rev_id): + """Convert revision and all referenced objects to new format.""" + rev = self.revisions[rev_id] + inv = self._load_old_inventory(rev_id) + present_parents = [p for p in rev.parent_ids + if p not in self.absent_revisions] + self._convert_revision_contents(rev, inv, present_parents) + self._store_new_inv(rev, inv, present_parents) + self.converted_revs.add(rev_id) + + def _store_new_inv(self, rev, inv, present_parents): + new_inv_xml = xml5.serializer_v5.write_inventory_to_string(inv) + new_inv_sha1 = osutils.sha_string(new_inv_xml) + self.inv_weave.add_lines(rev.revision_id, + present_parents, + new_inv_xml.splitlines(True)) + rev.inventory_sha1 = new_inv_sha1 + + def _convert_revision_contents(self, rev, inv, present_parents): + """Convert all the files within a revision. + + Also upgrade the inventory to refer to the text revision ids.""" + rev_id = rev.revision_id + trace.mutter('converting texts of revision {%s}', rev_id) + parent_invs = map(self._load_updated_inventory, present_parents) + entries = inv.iter_entries() + entries.next() + for path, ie in entries: + self._convert_file_version(rev, ie, parent_invs) + + def _convert_file_version(self, rev, ie, parent_invs): + """Convert one version of one file. + + The file needs to be added into the weave if it is a merge + of >=2 parents or if it's changed from its parent. + """ + file_id = ie.file_id + rev_id = rev.revision_id + w = self.text_weaves.get(file_id) + if w is None: + w = weave.Weave(file_id) + self.text_weaves[file_id] = w + text_changed = False + parent_candiate_entries = ie.parent_candidates(parent_invs) + heads = graph.Graph(self).heads(parent_candiate_entries.keys()) + # XXX: Note that this is unordered - and this is tolerable because + # the previous code was also unordered. + previous_entries = dict((head, parent_candiate_entries[head]) for head + in heads) + self.snapshot_ie(previous_entries, ie, w, rev_id) + + def get_parent_map(self, revision_ids): + """See graph.StackedParentsProvider.get_parent_map""" + return dict((revision_id, self.revisions[revision_id]) + for revision_id in revision_ids + if revision_id in self.revisions) + + def snapshot_ie(self, previous_revisions, ie, w, rev_id): + # TODO: convert this logic, which is ~= snapshot to + # a call to:. This needs the path figured out. rather than a work_tree + # a v4 revision_tree can be given, or something that looks enough like + # one to give the file content to the entry if it needs it. + # and we need something that looks like a weave store for snapshot to + # save against. + #ie.snapshot(rev, PATH, previous_revisions, REVISION_TREE, InMemoryWeaveStore(self.text_weaves)) + if len(previous_revisions) == 1: + previous_ie = previous_revisions.values()[0] + if ie._unchanged(previous_ie): + ie.revision = previous_ie.revision + return + if ie.has_text(): + f = self.branch.repository._text_store.get(ie.text_id) + try: + file_lines = f.readlines() + finally: + f.close() + w.add_lines(rev_id, previous_revisions, file_lines) + self.text_count += 1 + else: + w.add_lines(rev_id, previous_revisions, []) + ie.revision = rev_id + + def _make_order(self): + """Return a suitable order for importing revisions. + + The order must be such that an revision is imported after all + its (present) parents. + """ + todo = set(self.revisions.keys()) + done = self.absent_revisions.copy() + order = [] + while todo: + # scan through looking for a revision whose parents + # are all done + for rev_id in sorted(list(todo)): + rev = self.revisions[rev_id] + parent_ids = set(rev.parent_ids) + if parent_ids.issubset(done): + # can take this one now + order.append(rev_id) + todo.remove(rev_id) + done.add(rev_id) + return order + + +class ConvertBzrDir5To6(Converter): + """Converts format 5 bzr dirs to format 6.""" + + def convert(self, to_convert, pb): + """See Converter.convert().""" + self.bzrdir = to_convert + pb = ui.ui_factory.nested_progress_bar() + try: + ui.ui_factory.note(gettext('starting upgrade from format 5 to 6')) + self._convert_to_prefixed() + return ControlDir.open(self.bzrdir.user_url) + finally: + pb.finished() + + def _convert_to_prefixed(self): + from bzrlib.store import TransportStore + self.bzrdir.transport.delete('branch-format') + for store_name in ["weaves", "revision-store"]: + ui.ui_factory.note(gettext("adding prefixes to %s") % store_name) + store_transport = self.bzrdir.transport.clone(store_name) + store = TransportStore(store_transport, prefixed=True) + for urlfilename in store_transport.list_dir('.'): + filename = urlutils.unescape(urlfilename) + if (filename.endswith(".weave") or + filename.endswith(".gz") or + filename.endswith(".sig")): + file_id, suffix = os.path.splitext(filename) + else: + file_id = filename + suffix = '' + new_name = store._mapper.map((file_id,)) + suffix + # FIXME keep track of the dirs made RBC 20060121 + try: + store_transport.move(filename, new_name) + except errors.NoSuchFile: # catches missing dirs strangely enough + store_transport.mkdir(osutils.dirname(new_name)) + store_transport.move(filename, new_name) + self.bzrdir.transport.put_bytes( + 'branch-format', + BzrDirFormat6().get_format_string(), + mode=self.bzrdir._get_file_mode()) + + +class ConvertBzrDir6ToMeta(Converter): + """Converts format 6 bzr dirs to metadirs.""" + + def convert(self, to_convert, pb): + """See Converter.convert().""" + from bzrlib.plugins.weave_fmt.repository import RepositoryFormat7 + from bzrlib.branchfmt.fullhistory import BzrBranchFormat5 + self.bzrdir = to_convert + self.pb = ui.ui_factory.nested_progress_bar() + self.count = 0 + self.total = 20 # the steps we know about + self.garbage_inventories = [] + self.dir_mode = self.bzrdir._get_dir_mode() + self.file_mode = self.bzrdir._get_file_mode() + + ui.ui_factory.note(gettext('starting upgrade from format 6 to metadir')) + self.bzrdir.transport.put_bytes( + 'branch-format', + "Converting to format 6", + mode=self.file_mode) + # its faster to move specific files around than to open and use the apis... + # first off, nuke ancestry.weave, it was never used. + try: + self.step(gettext('Removing ancestry.weave')) + self.bzrdir.transport.delete('ancestry.weave') + except errors.NoSuchFile: + pass + # find out whats there + self.step(gettext('Finding branch files')) + last_revision = self.bzrdir.open_branch().last_revision() + bzrcontents = self.bzrdir.transport.list_dir('.') + for name in bzrcontents: + if name.startswith('basis-inventory.'): + self.garbage_inventories.append(name) + # create new directories for repository, working tree and branch + repository_names = [('inventory.weave', True), + ('revision-store', True), + ('weaves', True)] + self.step(gettext('Upgrading repository') + ' ') + self.bzrdir.transport.mkdir('repository', mode=self.dir_mode) + self.make_lock('repository') + # we hard code the formats here because we are converting into + # the meta format. The meta format upgrader can take this to a + # future format within each component. + self.put_format('repository', RepositoryFormat7()) + for entry in repository_names: + self.move_entry('repository', entry) + + self.step(gettext('Upgrading branch') + ' ') + self.bzrdir.transport.mkdir('branch', mode=self.dir_mode) + self.make_lock('branch') + self.put_format('branch', BzrBranchFormat5()) + branch_files = [('revision-history', True), + ('branch-name', True), + ('parent', False)] + for entry in branch_files: + self.move_entry('branch', entry) + + checkout_files = [('pending-merges', True), + ('inventory', True), + ('stat-cache', False)] + # If a mandatory checkout file is not present, the branch does not have + # a functional checkout. Do not create a checkout in the converted + # branch. + for name, mandatory in checkout_files: + if mandatory and name not in bzrcontents: + has_checkout = False + break + else: + has_checkout = True + if not has_checkout: + ui.ui_factory.note(gettext('No working tree.')) + # If some checkout files are there, we may as well get rid of them. + for name, mandatory in checkout_files: + if name in bzrcontents: + self.bzrdir.transport.delete(name) + else: + from bzrlib.workingtree_3 import WorkingTreeFormat3 + self.step(gettext('Upgrading working tree')) + self.bzrdir.transport.mkdir('checkout', mode=self.dir_mode) + self.make_lock('checkout') + self.put_format( + 'checkout', WorkingTreeFormat3()) + self.bzrdir.transport.delete_multi( + self.garbage_inventories, self.pb) + for entry in checkout_files: + self.move_entry('checkout', entry) + if last_revision is not None: + self.bzrdir.transport.put_bytes( + 'checkout/last-revision', last_revision) + self.bzrdir.transport.put_bytes( + 'branch-format', + BzrDirMetaFormat1().get_format_string(), + mode=self.file_mode) + self.pb.finished() + return ControlDir.open(self.bzrdir.user_url) + + def make_lock(self, name): + """Make a lock for the new control dir name.""" + self.step(gettext('Make %s lock') % name) + ld = lockdir.LockDir(self.bzrdir.transport, + '%s/lock' % name, + file_modebits=self.file_mode, + dir_modebits=self.dir_mode) + ld.create() + + def move_entry(self, new_dir, entry): + """Move then entry name into new_dir.""" + name = entry[0] + mandatory = entry[1] + self.step(gettext('Moving %s') % name) + try: + self.bzrdir.transport.move(name, '%s/%s' % (new_dir, name)) + except errors.NoSuchFile: + if mandatory: + raise + + def put_format(self, dirname, format): + self.bzrdir.transport.put_bytes('%s/format' % dirname, + format.get_format_string(), + self.file_mode) + + +class BzrDirFormat4(BzrDirFormat): + """Bzr dir format 4. + + This format is a combined format for working tree, branch and repository. + It has: + - Format 1 working trees [always] + - Format 4 branches [always] + - Format 4 repositories [always] + + This format is deprecated: it indexes texts using a text it which is + removed in format 5; write support for this format has been removed. + """ + + _lock_class = lockable_files.TransportLock + + def __eq__(self, other): + return type(self) == type(other) + + @classmethod + def get_format_string(cls): + """See BzrDirFormat.get_format_string().""" + return "Bazaar-NG branch, format 0.0.4\n" + + def get_format_description(self): + """See ControlDirFormat.get_format_description().""" + return "All-in-one format 4" + + def get_converter(self, format=None): + """See ControlDirFormat.get_converter().""" + # there is one and only one upgrade path here. + return ConvertBzrDir4To5() + + def initialize_on_transport(self, transport): + """Format 4 branches cannot be created.""" + raise errors.UninitializableFormat(self) + + def is_supported(self): + """Format 4 is not supported. + + It is not supported because the model changed from 4 to 5 and the + conversion logic is expensive - so doing it on the fly was not + feasible. + """ + return False + + def network_name(self): + return self.get_format_string() + + def _open(self, transport): + """See BzrDirFormat._open.""" + return BzrDir4(transport, self) + + def __return_repository_format(self): + """Circular import protection.""" + from bzrlib.plugins.weave_fmt.repository import RepositoryFormat4 + return RepositoryFormat4() + repository_format = property(__return_repository_format) + + @classmethod + def from_string(cls, format_string): + if format_string != cls.get_format_string(): + raise AssertionError("unexpected format string %r" % format_string) + return cls() + + +class BzrDirPreSplitOut(BzrDir): + """A common class for the all-in-one formats.""" + + def __init__(self, _transport, _format): + """See ControlDir.__init__.""" + super(BzrDirPreSplitOut, self).__init__(_transport, _format) + self._control_files = lockable_files.LockableFiles( + self.get_branch_transport(None), + self._format._lock_file_name, + self._format._lock_class) + + def break_lock(self): + """Pre-splitout bzrdirs do not suffer from stale locks.""" + raise NotImplementedError(self.break_lock) + + def cloning_metadir(self, require_stacking=False): + """Produce a metadir suitable for cloning with.""" + if require_stacking: + return format_registry.make_bzrdir('1.6') + return self._format.__class__() + + def clone(self, url, revision_id=None, force_new_repo=False, + preserve_stacking=False): + """See ControlDir.clone(). + + force_new_repo has no effect, since this family of formats always + require a new repository. + preserve_stacking has no effect, since no source branch using this + family of formats can be stacked, so there is no stacking to preserve. + """ + self._make_tail(url) + result = self._format._initialize_for_clone(url) + self.open_repository().clone(result, revision_id=revision_id) + from_branch = self.open_branch() + from_branch.clone(result, revision_id=revision_id) + try: + tree = self.open_workingtree() + except errors.NotLocalUrl: + # make a new one, this format always has to have one. + result._init_workingtree() + else: + tree.clone(result) + return result + + def create_branch(self, name=None, repository=None, + append_revisions_only=None): + """See ControlDir.create_branch.""" + if repository is not None: + raise NotImplementedError( + "create_branch(repository=<not None>) on %r" % (self,)) + return self._format.get_branch_format().initialize(self, name=name, + append_revisions_only=append_revisions_only) + + def destroy_branch(self, name=None): + """See ControlDir.destroy_branch.""" + raise errors.UnsupportedOperation(self.destroy_branch, self) + + def create_repository(self, shared=False): + """See ControlDir.create_repository.""" + if shared: + raise errors.IncompatibleFormat('shared repository', self._format) + return self.open_repository() + + def destroy_repository(self): + """See ControlDir.destroy_repository.""" + raise errors.UnsupportedOperation(self.destroy_repository, self) + + def create_workingtree(self, revision_id=None, from_branch=None, + accelerator_tree=None, hardlink=False): + """See ControlDir.create_workingtree.""" + # The workingtree is sometimes created when the bzrdir is created, + # but not when cloning. + + # this looks buggy but is not -really- + # because this format creates the workingtree when the bzrdir is + # created + # clone and sprout will have set the revision_id + # and that will have set it for us, its only + # specific uses of create_workingtree in isolation + # that can do wonky stuff here, and that only + # happens for creating checkouts, which cannot be + # done on this format anyway. So - acceptable wart. + if hardlink: + warning("can't support hardlinked working trees in %r" + % (self,)) + try: + result = self.open_workingtree(recommend_upgrade=False) + except errors.NoSuchFile: + result = self._init_workingtree() + if revision_id is not None: + if revision_id == _mod_revision.NULL_REVISION: + result.set_parent_ids([]) + else: + result.set_parent_ids([revision_id]) + return result + + def _init_workingtree(self): + from bzrlib.plugins.weave_fmt.workingtree import WorkingTreeFormat2 + try: + return WorkingTreeFormat2().initialize(self) + except errors.NotLocalUrl: + # Even though we can't access the working tree, we need to + # create its control files. + return WorkingTreeFormat2()._stub_initialize_on_transport( + self.transport, self._control_files._file_mode) + + def destroy_workingtree(self): + """See ControlDir.destroy_workingtree.""" + raise errors.UnsupportedOperation(self.destroy_workingtree, self) + + def destroy_workingtree_metadata(self): + """See ControlDir.destroy_workingtree_metadata.""" + raise errors.UnsupportedOperation(self.destroy_workingtree_metadata, + self) + + def get_branch_transport(self, branch_format, name=None): + """See BzrDir.get_branch_transport().""" + if name is not None: + raise errors.NoColocatedBranchSupport(self) + if branch_format is None: + return self.transport + try: + branch_format.get_format_string() + except NotImplementedError: + return self.transport + raise errors.IncompatibleFormat(branch_format, self._format) + + def get_repository_transport(self, repository_format): + """See BzrDir.get_repository_transport().""" + if repository_format is None: + return self.transport + try: + repository_format.get_format_string() + except NotImplementedError: + return self.transport + raise errors.IncompatibleFormat(repository_format, self._format) + + def get_workingtree_transport(self, workingtree_format): + """See BzrDir.get_workingtree_transport().""" + if workingtree_format is None: + return self.transport + try: + workingtree_format.get_format_string() + except NotImplementedError: + return self.transport + raise errors.IncompatibleFormat(workingtree_format, self._format) + + def needs_format_conversion(self, format=None): + """See ControlDir.needs_format_conversion().""" + # if the format is not the same as the system default, + # an upgrade is needed. + if format is None: + symbol_versioning.warn(symbol_versioning.deprecated_in((1, 13, 0)) + % 'needs_format_conversion(format=None)') + format = BzrDirFormat.get_default_format() + return not isinstance(self._format, format.__class__) + + def open_branch(self, name=None, unsupported=False, + ignore_fallbacks=False, possible_transports=None): + """See ControlDir.open_branch.""" + from bzrlib.plugins.weave_fmt.branch import BzrBranchFormat4 + format = BzrBranchFormat4() + format.check_support_status(unsupported) + return format.open(self, name, _found=True, + possible_transports=possible_transports) + + def sprout(self, url, revision_id=None, force_new_repo=False, + possible_transports=None, accelerator_tree=None, + hardlink=False, stacked=False, create_tree_if_local=True, + source_branch=None): + """See ControlDir.sprout().""" + if source_branch is not None: + my_branch = self.open_branch() + if source_branch.base != my_branch.base: + raise AssertionError( + "source branch %r is not within %r with branch %r" % + (source_branch, self, my_branch)) + if stacked: + raise errors.UnstackableBranchFormat( + self._format, self.root_transport.base) + if not create_tree_if_local: + raise errors.MustHaveWorkingTree( + self._format, self.root_transport.base) + from bzrlib.plugins.weave_fmt.workingtree import WorkingTreeFormat2 + self._make_tail(url) + result = self._format._initialize_for_clone(url) + try: + self.open_repository().clone(result, revision_id=revision_id) + except errors.NoRepositoryPresent: + pass + try: + self.open_branch().sprout(result, revision_id=revision_id) + except errors.NotBranchError: + pass + + # we always want a working tree + WorkingTreeFormat2().initialize(result, + accelerator_tree=accelerator_tree, + hardlink=hardlink) + return result + + def set_branch_reference(self, target_branch, name=None): + from bzrlib.branch import BranchReferenceFormat + if name is not None: + raise errors.NoColocatedBranchSupport(self) + raise errors.IncompatibleFormat(BranchReferenceFormat, self._format) + + +class BzrDir4(BzrDirPreSplitOut): + """A .bzr version 4 control object. + + This is a deprecated format and may be removed after sept 2006. + """ + + def create_repository(self, shared=False): + """See ControlDir.create_repository.""" + return self._format.repository_format.initialize(self, shared) + + def needs_format_conversion(self, format=None): + """Format 4 dirs are always in need of conversion.""" + if format is None: + symbol_versioning.warn(symbol_versioning.deprecated_in((1, 13, 0)) + % 'needs_format_conversion(format=None)') + return True + + def open_repository(self): + """See ControlDir.open_repository.""" + from bzrlib.plugins.weave_fmt.repository import RepositoryFormat4 + return RepositoryFormat4().open(self, _found=True) + + +class BzrDir5(BzrDirPreSplitOut): + """A .bzr version 5 control object. + + This is a deprecated format and may be removed after sept 2006. + """ + + def has_workingtree(self): + """See ControlDir.has_workingtree.""" + return True + + def open_repository(self): + """See ControlDir.open_repository.""" + from bzrlib.plugins.weave_fmt.repository import RepositoryFormat5 + return RepositoryFormat5().open(self, _found=True) + + def open_workingtree(self, unsupported=False, + recommend_upgrade=True): + """See ControlDir.create_workingtree.""" + from bzrlib.plugins.weave_fmt.workingtree import WorkingTreeFormat2 + wt_format = WorkingTreeFormat2() + # we don't warn here about upgrades; that ought to be handled for the + # bzrdir as a whole + return wt_format.open(self, _found=True) + + +class BzrDir6(BzrDirPreSplitOut): + """A .bzr version 6 control object. + + This is a deprecated format and may be removed after sept 2006. + """ + + def has_workingtree(self): + """See ControlDir.has_workingtree.""" + return True + + def open_repository(self): + """See ControlDir.open_repository.""" + from bzrlib.plugins.weave_fmt.repository import RepositoryFormat6 + return RepositoryFormat6().open(self, _found=True) + + def open_workingtree(self, unsupported=False, recommend_upgrade=True): + """See ControlDir.create_workingtree.""" + # we don't warn here about upgrades; that ought to be handled for the + # bzrdir as a whole + from bzrlib.plugins.weave_fmt.workingtree import WorkingTreeFormat2 + return WorkingTreeFormat2().open(self, _found=True) diff --git a/bzrlib/plugins/weave_fmt/repository.py b/bzrlib/plugins/weave_fmt/repository.py new file mode 100644 index 0000000..7af7ad1 --- /dev/null +++ b/bzrlib/plugins/weave_fmt/repository.py @@ -0,0 +1,883 @@ +# Copyright (C) 2007-2011 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Deprecated weave-based repository formats. + +Weave based formats scaled linearly with history size and could not represent +ghosts. +""" + +from __future__ import absolute_import + +import gzip +import os +from cStringIO import StringIO + +from bzrlib.lazy_import import lazy_import +lazy_import(globals(), """ +import itertools + +from bzrlib import ( + xml5, + graph as _mod_graph, + ui, + ) +""") +from bzrlib import ( + debug, + errors, + lockable_files, + lockdir, + osutils, + symbol_versioning, + trace, + tuned_gzip, + urlutils, + versionedfile, + weave, + weavefile, + ) +from bzrlib.decorators import needs_read_lock, needs_write_lock +from bzrlib.repository import ( + InterRepository, + RepositoryFormatMetaDir, + ) +from bzrlib.store.text import TextStore +from bzrlib.versionedfile import ( + AbsentContentFactory, + FulltextContentFactory, + VersionedFiles, + ) +from bzrlib.vf_repository import ( + InterSameDataRepository, + VersionedFileCommitBuilder, + VersionedFileRepository, + VersionedFileRepositoryFormat, + MetaDirVersionedFileRepository, + MetaDirVersionedFileRepositoryFormat, + ) + +from bzrlib.plugins.weave_fmt import bzrdir as weave_bzrdir + + +class AllInOneRepository(VersionedFileRepository): + """Legacy support - the repository behaviour for all-in-one branches.""" + + @property + def _serializer(self): + return xml5.serializer_v5 + + def _escape(self, file_or_path): + if not isinstance(file_or_path, basestring): + file_or_path = '/'.join(file_or_path) + if file_or_path == '': + return u'' + return urlutils.escape(osutils.safe_unicode(file_or_path)) + + def __init__(self, _format, a_bzrdir): + # we reuse one control files instance. + dir_mode = a_bzrdir._get_dir_mode() + file_mode = a_bzrdir._get_file_mode() + + def get_store(name, compressed=True, prefixed=False): + # FIXME: This approach of assuming stores are all entirely compressed + # or entirely uncompressed is tidy, but breaks upgrade from + # some existing branches where there's a mixture; we probably + # still want the option to look for both. + relpath = self._escape(name) + store = TextStore(a_bzrdir.transport.clone(relpath), + prefixed=prefixed, compressed=compressed, + dir_mode=dir_mode, + file_mode=file_mode) + return store + + # not broken out yet because the controlweaves|inventory_store + # and texts bits are still different. + if isinstance(_format, RepositoryFormat4): + # cannot remove these - there is still no consistent api + # which allows access to this old info. + self.inventory_store = get_store('inventory-store') + self._text_store = get_store('text-store') + super(AllInOneRepository, self).__init__(_format, a_bzrdir, a_bzrdir._control_files) + + @needs_read_lock + def _all_possible_ids(self): + """Return all the possible revisions that we could find.""" + if 'evil' in debug.debug_flags: + trace.mutter_callsite( + 3, "_all_possible_ids scales with size of history.") + return [key[-1] for key in self.inventories.keys()] + + @needs_read_lock + def _all_revision_ids(self): + """Returns a list of all the revision ids in the repository. + + These are in as much topological order as the underlying store can + present: for weaves ghosts may lead to a lack of correctness until + the reweave updates the parents list. + """ + return [key[-1] for key in self.revisions.keys()] + + def _activate_new_inventory(self): + """Put a replacement inventory.new into use as inventories.""" + # Copy the content across + t = self.bzrdir._control_files._transport + t.copy('inventory.new.weave', 'inventory.weave') + # delete the temp inventory + t.delete('inventory.new.weave') + # Check we can parse the new weave properly as a sanity check + self.inventories.keys() + + def _backup_inventory(self): + t = self.bzrdir._control_files._transport + t.copy('inventory.weave', 'inventory.backup.weave') + + def _temp_inventories(self): + t = self.bzrdir._control_files._transport + return self._format._get_inventories(t, self, 'inventory.new') + + def get_commit_builder(self, branch, parents, config, timestamp=None, + timezone=None, committer=None, revprops=None, + revision_id=None, lossy=False): + self._check_ascii_revisionid(revision_id, self.get_commit_builder) + result = VersionedFileCommitBuilder(self, parents, config, timestamp, + timezone, committer, revprops, revision_id, lossy=lossy) + self.start_write_group() + return result + + @needs_read_lock + def get_revisions(self, revision_ids): + revs = self._get_revisions(revision_ids) + return revs + + def _inventory_add_lines(self, revision_id, parents, lines, + check_content=True): + """Store lines in inv_vf and return the sha1 of the inventory.""" + present_parents = self.get_graph().get_parent_map(parents) + final_parents = [] + for parent in parents: + if parent in present_parents: + final_parents.append((parent,)) + return self.inventories.add_lines((revision_id,), final_parents, lines, + check_content=check_content)[0] + + def is_shared(self): + """AllInOne repositories cannot be shared.""" + return False + + @needs_write_lock + def set_make_working_trees(self, new_value): + """Set the policy flag for making working trees when creating branches. + + This only applies to branches that use this repository. + + The default is 'True'. + :param new_value: True to restore the default, False to disable making + working trees. + """ + raise errors.RepositoryUpgradeRequired(self.user_url) + + def make_working_trees(self): + """Returns the policy for making working trees on new branches.""" + return True + + +class WeaveMetaDirRepository(MetaDirVersionedFileRepository): + """A subclass of MetaDirRepository to set weave specific policy.""" + + def __init__(self, _format, a_bzrdir, control_files): + super(WeaveMetaDirRepository, self).__init__(_format, a_bzrdir, control_files) + self._serializer = _format._serializer + + @needs_read_lock + def _all_possible_ids(self): + """Return all the possible revisions that we could find.""" + if 'evil' in debug.debug_flags: + trace.mutter_callsite( + 3, "_all_possible_ids scales with size of history.") + return [key[-1] for key in self.inventories.keys()] + + @needs_read_lock + def _all_revision_ids(self): + """Returns a list of all the revision ids in the repository. + + These are in as much topological order as the underlying store can + present: for weaves ghosts may lead to a lack of correctness until + the reweave updates the parents list. + """ + return [key[-1] for key in self.revisions.keys()] + + def _activate_new_inventory(self): + """Put a replacement inventory.new into use as inventories.""" + # Copy the content across + t = self._transport + t.copy('inventory.new.weave', 'inventory.weave') + # delete the temp inventory + t.delete('inventory.new.weave') + # Check we can parse the new weave properly as a sanity check + self.inventories.keys() + + def _backup_inventory(self): + t = self._transport + t.copy('inventory.weave', 'inventory.backup.weave') + + def _temp_inventories(self): + t = self._transport + return self._format._get_inventories(t, self, 'inventory.new') + + def get_commit_builder(self, branch, parents, config, timestamp=None, + timezone=None, committer=None, revprops=None, + revision_id=None, lossy=False): + self._check_ascii_revisionid(revision_id, self.get_commit_builder) + result = VersionedFileCommitBuilder(self, parents, config, timestamp, + timezone, committer, revprops, revision_id, lossy=lossy) + self.start_write_group() + return result + + @needs_read_lock + def get_revision(self, revision_id): + """Return the Revision object for a named revision""" + r = self.get_revision_reconcile(revision_id) + return r + + def _inventory_add_lines(self, revision_id, parents, lines, + check_content=True): + """Store lines in inv_vf and return the sha1 of the inventory.""" + present_parents = self.get_graph().get_parent_map(parents) + final_parents = [] + for parent in parents: + if parent in present_parents: + final_parents.append((parent,)) + return self.inventories.add_lines((revision_id,), final_parents, lines, + check_content=check_content)[0] + + +class PreSplitOutRepositoryFormat(VersionedFileRepositoryFormat): + """Base class for the pre split out repository formats.""" + + rich_root_data = False + supports_tree_reference = False + supports_ghosts = False + supports_external_lookups = False + supports_chks = False + supports_nesting_repositories = True + _fetch_order = 'topological' + _fetch_reconcile = True + fast_deltas = False + supports_leaving_lock = False + # XXX: This is an old format that we don't support full checking on, so + # just claim that checking for this inconsistency is not required. + revision_graph_can_have_wrong_parents = False + + def initialize(self, a_bzrdir, shared=False, _internal=False): + """Create a weave repository.""" + if shared: + raise errors.IncompatibleFormat(self, a_bzrdir._format) + + if not _internal: + # always initialized when the bzrdir is. + return self.open(a_bzrdir, _found=True) + + # Create an empty weave + sio = StringIO() + weavefile.write_weave_v5(weave.Weave(), sio) + empty_weave = sio.getvalue() + + trace.mutter('creating repository in %s.', a_bzrdir.transport.base) + + # FIXME: RBC 20060125 don't peek under the covers + # NB: no need to escape relative paths that are url safe. + control_files = lockable_files.LockableFiles(a_bzrdir.transport, + 'branch-lock', lockable_files.TransportLock) + control_files.create_lock() + control_files.lock_write() + transport = a_bzrdir.transport + try: + transport.mkdir_multi(['revision-store', 'weaves'], + mode=a_bzrdir._get_dir_mode()) + transport.put_bytes_non_atomic('inventory.weave', empty_weave, + mode=a_bzrdir._get_file_mode()) + finally: + control_files.unlock() + repository = self.open(a_bzrdir, _found=True) + self._run_post_repo_init_hooks(repository, a_bzrdir, shared) + return repository + + def open(self, a_bzrdir, _found=False): + """See RepositoryFormat.open().""" + if not _found: + # we are being called directly and must probe. + raise NotImplementedError + + repo_transport = a_bzrdir.get_repository_transport(None) + result = AllInOneRepository(_format=self, a_bzrdir=a_bzrdir) + result.revisions = self._get_revisions(repo_transport, result) + result.signatures = self._get_signatures(repo_transport, result) + result.inventories = self._get_inventories(repo_transport, result) + result.texts = self._get_texts(repo_transport, result) + result.chk_bytes = None + return result + + def is_deprecated(self): + return True + + +class RepositoryFormat4(PreSplitOutRepositoryFormat): + """Bzr repository format 4. + + This repository format has: + - flat stores + - TextStores for texts, inventories,revisions. + + This format is deprecated: it indexes texts using a text id which is + removed in format 5; initialization and write support for this format + has been removed. + """ + + supports_funky_characters = False + + _matchingbzrdir = weave_bzrdir.BzrDirFormat4() + + def get_format_description(self): + """See RepositoryFormat.get_format_description().""" + return "Repository format 4" + + def initialize(self, url, shared=False, _internal=False): + """Format 4 branches cannot be created.""" + raise errors.UninitializableFormat(self) + + def is_supported(self): + """Format 4 is not supported. + + It is not supported because the model changed from 4 to 5 and the + conversion logic is expensive - so doing it on the fly was not + feasible. + """ + return False + + def _get_inventories(self, repo_transport, repo, name='inventory'): + # No inventories store written so far. + return None + + def _get_revisions(self, repo_transport, repo): + from bzrlib.plugins.weave_fmt.xml4 import serializer_v4 + return RevisionTextStore(repo_transport.clone('revision-store'), + serializer_v4, True, versionedfile.PrefixMapper(), + repo.is_locked, repo.is_write_locked) + + def _get_signatures(self, repo_transport, repo): + return SignatureTextStore(repo_transport.clone('revision-store'), + False, versionedfile.PrefixMapper(), + repo.is_locked, repo.is_write_locked) + + def _get_texts(self, repo_transport, repo): + return None + + +class RepositoryFormat5(PreSplitOutRepositoryFormat): + """Bzr control format 5. + + This repository format has: + - weaves for file texts and inventory + - flat stores + - TextStores for revisions and signatures. + """ + + _versionedfile_class = weave.WeaveFile + _matchingbzrdir = weave_bzrdir.BzrDirFormat5() + supports_funky_characters = False + + @property + def _serializer(self): + return xml5.serializer_v5 + + def get_format_description(self): + """See RepositoryFormat.get_format_description().""" + return "Weave repository format 5" + + def network_name(self): + """The network name for this format is the control dirs disk label.""" + return self._matchingbzrdir.get_format_string() + + def _get_inventories(self, repo_transport, repo, name='inventory'): + mapper = versionedfile.ConstantMapper(name) + return versionedfile.ThunkedVersionedFiles(repo_transport, + weave.WeaveFile, mapper, repo.is_locked) + + def _get_revisions(self, repo_transport, repo): + return RevisionTextStore(repo_transport.clone('revision-store'), + xml5.serializer_v5, False, versionedfile.PrefixMapper(), + repo.is_locked, repo.is_write_locked) + + def _get_signatures(self, repo_transport, repo): + return SignatureTextStore(repo_transport.clone('revision-store'), + False, versionedfile.PrefixMapper(), + repo.is_locked, repo.is_write_locked) + + def _get_texts(self, repo_transport, repo): + mapper = versionedfile.PrefixMapper() + base_transport = repo_transport.clone('weaves') + return versionedfile.ThunkedVersionedFiles(base_transport, + weave.WeaveFile, mapper, repo.is_locked) + + +class RepositoryFormat6(PreSplitOutRepositoryFormat): + """Bzr control format 6. + + This repository format has: + - weaves for file texts and inventory + - hash subdirectory based stores. + - TextStores for revisions and signatures. + """ + + _versionedfile_class = weave.WeaveFile + _matchingbzrdir = weave_bzrdir.BzrDirFormat6() + supports_funky_characters = False + @property + def _serializer(self): + return xml5.serializer_v5 + + def get_format_description(self): + """See RepositoryFormat.get_format_description().""" + return "Weave repository format 6" + + def network_name(self): + """The network name for this format is the control dirs disk label.""" + return self._matchingbzrdir.get_format_string() + + def _get_inventories(self, repo_transport, repo, name='inventory'): + mapper = versionedfile.ConstantMapper(name) + return versionedfile.ThunkedVersionedFiles(repo_transport, + weave.WeaveFile, mapper, repo.is_locked) + + def _get_revisions(self, repo_transport, repo): + return RevisionTextStore(repo_transport.clone('revision-store'), + xml5.serializer_v5, False, versionedfile.HashPrefixMapper(), + repo.is_locked, repo.is_write_locked) + + def _get_signatures(self, repo_transport, repo): + return SignatureTextStore(repo_transport.clone('revision-store'), + False, versionedfile.HashPrefixMapper(), + repo.is_locked, repo.is_write_locked) + + def _get_texts(self, repo_transport, repo): + mapper = versionedfile.HashPrefixMapper() + base_transport = repo_transport.clone('weaves') + return versionedfile.ThunkedVersionedFiles(base_transport, + weave.WeaveFile, mapper, repo.is_locked) + + +class RepositoryFormat7(MetaDirVersionedFileRepositoryFormat): + """Bzr repository 7. + + This repository format has: + - weaves for file texts and inventory + - hash subdirectory based stores. + - TextStores for revisions and signatures. + - a format marker of its own + - an optional 'shared-storage' flag + - an optional 'no-working-trees' flag + """ + + _versionedfile_class = weave.WeaveFile + supports_ghosts = False + supports_chks = False + supports_funky_characters = False + revision_graph_can_have_wrong_parents = False + + _fetch_order = 'topological' + _fetch_reconcile = True + fast_deltas = False + @property + def _serializer(self): + return xml5.serializer_v5 + + @classmethod + def get_format_string(cls): + """See RepositoryFormat.get_format_string().""" + return "Bazaar-NG Repository format 7" + + def get_format_description(self): + """See RepositoryFormat.get_format_description().""" + return "Weave repository format 7" + + def _get_inventories(self, repo_transport, repo, name='inventory'): + mapper = versionedfile.ConstantMapper(name) + return versionedfile.ThunkedVersionedFiles(repo_transport, + weave.WeaveFile, mapper, repo.is_locked) + + def _get_revisions(self, repo_transport, repo): + return RevisionTextStore(repo_transport.clone('revision-store'), + xml5.serializer_v5, True, versionedfile.HashPrefixMapper(), + repo.is_locked, repo.is_write_locked) + + def _get_signatures(self, repo_transport, repo): + return SignatureTextStore(repo_transport.clone('revision-store'), + True, versionedfile.HashPrefixMapper(), + repo.is_locked, repo.is_write_locked) + + def _get_texts(self, repo_transport, repo): + mapper = versionedfile.HashPrefixMapper() + base_transport = repo_transport.clone('weaves') + return versionedfile.ThunkedVersionedFiles(base_transport, + weave.WeaveFile, mapper, repo.is_locked) + + def initialize(self, a_bzrdir, shared=False): + """Create a weave repository. + + :param shared: If true the repository will be initialized as a shared + repository. + """ + # Create an empty weave + sio = StringIO() + weavefile.write_weave_v5(weave.Weave(), sio) + empty_weave = sio.getvalue() + + trace.mutter('creating repository in %s.', a_bzrdir.transport.base) + dirs = ['revision-store', 'weaves'] + files = [('inventory.weave', StringIO(empty_weave)), + ] + utf8_files = [('format', self.get_format_string())] + + self._upload_blank_content(a_bzrdir, dirs, files, utf8_files, shared) + return self.open(a_bzrdir=a_bzrdir, _found=True) + + def open(self, a_bzrdir, _found=False, _override_transport=None): + """See RepositoryFormat.open(). + + :param _override_transport: INTERNAL USE ONLY. Allows opening the + repository at a slightly different url + than normal. I.e. during 'upgrade'. + """ + if not _found: + format = RepositoryFormatMetaDir.find_format(a_bzrdir) + if _override_transport is not None: + repo_transport = _override_transport + else: + repo_transport = a_bzrdir.get_repository_transport(None) + control_files = lockable_files.LockableFiles(repo_transport, + 'lock', lockdir.LockDir) + result = WeaveMetaDirRepository(_format=self, a_bzrdir=a_bzrdir, + control_files=control_files) + result.revisions = self._get_revisions(repo_transport, result) + result.signatures = self._get_signatures(repo_transport, result) + result.inventories = self._get_inventories(repo_transport, result) + result.texts = self._get_texts(repo_transport, result) + result.chk_bytes = None + result._transport = repo_transport + return result + + def is_deprecated(self): + return True + + +class TextVersionedFiles(VersionedFiles): + """Just-a-bunch-of-files based VersionedFile stores.""" + + def __init__(self, transport, compressed, mapper, is_locked, can_write): + self._compressed = compressed + self._transport = transport + self._mapper = mapper + if self._compressed: + self._ext = '.gz' + else: + self._ext = '' + self._is_locked = is_locked + self._can_write = can_write + + def add_lines(self, key, parents, lines): + """Add a revision to the store.""" + if not self._is_locked(): + raise errors.ObjectNotLocked(self) + if not self._can_write(): + raise errors.ReadOnlyError(self) + if '/' in key[-1]: + raise ValueError('bad idea to put / in %r' % (key,)) + text = ''.join(lines) + if self._compressed: + text = tuned_gzip.bytes_to_gzip(text) + path = self._map(key) + self._transport.put_bytes_non_atomic(path, text, create_parent_dir=True) + + def insert_record_stream(self, stream): + adapters = {} + for record in stream: + # Raise an error when a record is missing. + if record.storage_kind == 'absent': + raise errors.RevisionNotPresent([record.key[0]], self) + # adapt to non-tuple interface + if record.storage_kind == 'fulltext': + self.add_lines(record.key, None, + osutils.split_lines(record.get_bytes_as('fulltext'))) + else: + adapter_key = record.storage_kind, 'fulltext' + try: + adapter = adapters[adapter_key] + except KeyError: + adapter_factory = adapter_registry.get(adapter_key) + adapter = adapter_factory(self) + adapters[adapter_key] = adapter + lines = osutils.split_lines(adapter.get_bytes( + record, record.get_bytes_as(record.storage_kind))) + try: + self.add_lines(record.key, None, lines) + except errors.RevisionAlreadyPresent: + pass + + def _load_text(self, key): + if not self._is_locked(): + raise errors.ObjectNotLocked(self) + path = self._map(key) + try: + text = self._transport.get_bytes(path) + compressed = self._compressed + except errors.NoSuchFile: + if self._compressed: + # try without the .gz + path = path[:-3] + try: + text = self._transport.get_bytes(path) + compressed = False + except errors.NoSuchFile: + return None + else: + return None + if compressed: + text = gzip.GzipFile(mode='rb', fileobj=StringIO(text)).read() + return text + + def _map(self, key): + return self._mapper.map(key) + self._ext + + +class RevisionTextStore(TextVersionedFiles): + """Legacy thunk for format 4 repositories.""" + + def __init__(self, transport, serializer, compressed, mapper, is_locked, + can_write): + """Create a RevisionTextStore at transport with serializer.""" + TextVersionedFiles.__init__(self, transport, compressed, mapper, + is_locked, can_write) + self._serializer = serializer + + def _load_text_parents(self, key): + text = self._load_text(key) + if text is None: + return None, None + parents = self._serializer.read_revision_from_string(text).parent_ids + return text, tuple((parent,) for parent in parents) + + def get_parent_map(self, keys): + result = {} + for key in keys: + parents = self._load_text_parents(key)[1] + if parents is None: + continue + result[key] = parents + return result + + def get_known_graph_ancestry(self, keys): + """Get a KnownGraph instance with the ancestry of keys.""" + keys = self.keys() + parent_map = self.get_parent_map(keys) + kg = _mod_graph.KnownGraph(parent_map) + return kg + + def get_record_stream(self, keys, sort_order, include_delta_closure): + for key in keys: + text, parents = self._load_text_parents(key) + if text is None: + yield AbsentContentFactory(key) + else: + yield FulltextContentFactory(key, parents, None, text) + + def keys(self): + if not self._is_locked(): + raise errors.ObjectNotLocked(self) + relpaths = set() + for quoted_relpath in self._transport.iter_files_recursive(): + relpath = urlutils.unquote(quoted_relpath) + path, ext = os.path.splitext(relpath) + if ext == '.gz': + relpath = path + if not relpath.endswith('.sig'): + relpaths.add(relpath) + paths = list(relpaths) + return set([self._mapper.unmap(path) for path in paths]) + + +class SignatureTextStore(TextVersionedFiles): + """Legacy thunk for format 4-7 repositories.""" + + def __init__(self, transport, compressed, mapper, is_locked, can_write): + TextVersionedFiles.__init__(self, transport, compressed, mapper, + is_locked, can_write) + self._ext = '.sig' + self._ext + + def get_parent_map(self, keys): + result = {} + for key in keys: + text = self._load_text(key) + if text is None: + continue + result[key] = None + return result + + def get_record_stream(self, keys, sort_order, include_delta_closure): + for key in keys: + text = self._load_text(key) + if text is None: + yield AbsentContentFactory(key) + else: + yield FulltextContentFactory(key, None, None, text) + + def keys(self): + if not self._is_locked(): + raise errors.ObjectNotLocked(self) + relpaths = set() + for quoted_relpath in self._transport.iter_files_recursive(): + relpath = urlutils.unquote(quoted_relpath) + path, ext = os.path.splitext(relpath) + if ext == '.gz': + relpath = path + if not relpath.endswith('.sig'): + continue + relpaths.add(relpath[:-4]) + paths = list(relpaths) + return set([self._mapper.unmap(path) for path in paths]) + + +class InterWeaveRepo(InterSameDataRepository): + """Optimised code paths between Weave based repositories. + """ + + @classmethod + def _get_repo_format_to_test(self): + return RepositoryFormat7() + + @staticmethod + def is_compatible(source, target): + """Be compatible with known Weave formats. + + We don't test for the stores being of specific types because that + could lead to confusing results, and there is no need to be + overly general. + """ + try: + return (isinstance(source._format, (RepositoryFormat5, + RepositoryFormat6, + RepositoryFormat7)) and + isinstance(target._format, (RepositoryFormat5, + RepositoryFormat6, + RepositoryFormat7))) + except AttributeError: + return False + + @needs_write_lock + def copy_content(self, revision_id=None): + """See InterRepository.copy_content().""" + # weave specific optimised path: + try: + self.target.set_make_working_trees(self.source.make_working_trees()) + except (errors.RepositoryUpgradeRequired, NotImplemented): + pass + # FIXME do not peek! + if self.source._transport.listable(): + pb = ui.ui_factory.nested_progress_bar() + try: + self.target.texts.insert_record_stream( + self.source.texts.get_record_stream( + self.source.texts.keys(), 'topological', False)) + pb.update('Copying inventory', 0, 1) + self.target.inventories.insert_record_stream( + self.source.inventories.get_record_stream( + self.source.inventories.keys(), 'topological', False)) + self.target.signatures.insert_record_stream( + self.source.signatures.get_record_stream( + self.source.signatures.keys(), + 'unordered', True)) + self.target.revisions.insert_record_stream( + self.source.revisions.get_record_stream( + self.source.revisions.keys(), + 'topological', True)) + finally: + pb.finished() + else: + self.target.fetch(self.source, revision_id=revision_id) + + @needs_read_lock + def search_missing_revision_ids(self, + revision_id=symbol_versioning.DEPRECATED_PARAMETER, + find_ghosts=True, revision_ids=None, if_present_ids=None, + limit=None): + """See InterRepository.search_missing_revision_ids().""" + # we want all revisions to satisfy revision_id in source. + # but we don't want to stat every file here and there. + # we want then, all revisions other needs to satisfy revision_id + # checked, but not those that we have locally. + # so the first thing is to get a subset of the revisions to + # satisfy revision_id in source, and then eliminate those that + # we do already have. + # this is slow on high latency connection to self, but as this + # disk format scales terribly for push anyway due to rewriting + # inventory.weave, this is considered acceptable. + # - RBC 20060209 + if symbol_versioning.deprecated_passed(revision_id): + symbol_versioning.warn( + 'search_missing_revision_ids(revision_id=...) was ' + 'deprecated in 2.4. Use revision_ids=[...] instead.', + DeprecationWarning, stacklevel=2) + if revision_ids is not None: + raise AssertionError( + 'revision_ids is mutually exclusive with revision_id') + if revision_id is not None: + revision_ids = [revision_id] + del revision_id + source_ids_set = self._present_source_revisions_for( + revision_ids, if_present_ids) + # source_ids is the worst possible case we may need to pull. + # now we want to filter source_ids against what we actually + # have in target, but don't try to check for existence where we know + # we do not have a revision as that would be pointless. + target_ids = set(self.target._all_possible_ids()) + possibly_present_revisions = target_ids.intersection(source_ids_set) + actually_present_revisions = set( + self.target._eliminate_revisions_not_present(possibly_present_revisions)) + required_revisions = source_ids_set.difference(actually_present_revisions) + if revision_ids is not None: + # we used get_ancestry to determine source_ids then we are assured all + # revisions referenced are present as they are installed in topological order. + # and the tip revision was validated by get_ancestry. + result_set = required_revisions + else: + # if we just grabbed the possibly available ids, then + # we only have an estimate of whats available and need to validate + # that against the revision records. + result_set = set( + self.source._eliminate_revisions_not_present(required_revisions)) + if limit is not None: + topo_ordered = self.get_graph().iter_topo_order(result_set) + result_set = set(itertools.islice(topo_ordered, limit)) + return self.source.revision_ids_to_search_result(result_set) + + +InterRepository.register_optimiser(InterWeaveRepo) + + +def get_extra_interrepo_test_combinations(): + from bzrlib.repofmt import knitrepo + return [(InterRepository, RepositoryFormat5(), + knitrepo.RepositoryFormatKnit3())] diff --git a/bzrlib/plugins/weave_fmt/test_bzrdir.py b/bzrlib/plugins/weave_fmt/test_bzrdir.py new file mode 100644 index 0000000..2391aa4 --- /dev/null +++ b/bzrlib/plugins/weave_fmt/test_bzrdir.py @@ -0,0 +1,584 @@ +# Copyright (C) 2006-2011 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Tests for the weave-era BzrDir formats. + +For interface contract tests, see tests/per_bzr_dir. +""" + +from __future__ import absolute_import + +import os +import sys + +from bzrlib import ( + branch, + bzrdir, + controldir, + errors, + repository, + upgrade, + urlutils, + workingtree, + ) +from bzrlib.osutils import ( + getcwd, + ) +from bzrlib.tests.test_bundle import V4BundleTester +from bzrlib.tests.test_sftp_transport import TestCaseWithSFTPServer +from bzrlib.tests import ( + TestCaseWithTransport, + ) + +from bzrlib.plugins.weave_fmt.branch import ( + BzrBranchFormat4, + ) +from bzrlib.plugins.weave_fmt.bzrdir import ( + BzrDirFormat5, + BzrDirFormat6, + ) + + +class TestFormat5(TestCaseWithTransport): + """Tests specific to the version 5 bzrdir format.""" + + def test_same_lockfiles_between_tree_repo_branch(self): + # this checks that only a single lockfiles instance is created + # for format 5 objects + dir = BzrDirFormat5().initialize(self.get_url()) + def check_dir_components_use_same_lock(dir): + ctrl_1 = dir.open_repository().control_files + ctrl_2 = dir.open_branch().control_files + ctrl_3 = dir.open_workingtree()._control_files + self.assertTrue(ctrl_1 is ctrl_2) + self.assertTrue(ctrl_2 is ctrl_3) + check_dir_components_use_same_lock(dir) + # and if we open it normally. + dir = controldir.ControlDir.open(self.get_url()) + check_dir_components_use_same_lock(dir) + + def test_can_convert(self): + # format 5 dirs are convertable + dir = BzrDirFormat5().initialize(self.get_url()) + self.assertTrue(dir.can_convert_format()) + + def test_needs_conversion(self): + # format 5 dirs need a conversion if they are not the default, + # and they aren't + dir = BzrDirFormat5().initialize(self.get_url()) + # don't need to convert it to itself + self.assertFalse(dir.needs_format_conversion(BzrDirFormat5())) + # do need to convert it to the current default + self.assertTrue(dir.needs_format_conversion( + bzrdir.BzrDirFormat.get_default_format())) + + +class TestFormat6(TestCaseWithTransport): + """Tests specific to the version 6 bzrdir format.""" + + def test_same_lockfiles_between_tree_repo_branch(self): + # this checks that only a single lockfiles instance is created + # for format 6 objects + dir = BzrDirFormat6().initialize(self.get_url()) + def check_dir_components_use_same_lock(dir): + ctrl_1 = dir.open_repository().control_files + ctrl_2 = dir.open_branch().control_files + ctrl_3 = dir.open_workingtree()._control_files + self.assertTrue(ctrl_1 is ctrl_2) + self.assertTrue(ctrl_2 is ctrl_3) + check_dir_components_use_same_lock(dir) + # and if we open it normally. + dir = controldir.ControlDir.open(self.get_url()) + check_dir_components_use_same_lock(dir) + + def test_can_convert(self): + # format 6 dirs are convertable + dir = BzrDirFormat6().initialize(self.get_url()) + self.assertTrue(dir.can_convert_format()) + + def test_needs_conversion(self): + # format 6 dirs need an conversion if they are not the default. + dir = BzrDirFormat6().initialize(self.get_url()) + self.assertTrue(dir.needs_format_conversion( + bzrdir.BzrDirFormat.get_default_format())) + + +class TestBreakLockOldBranch(TestCaseWithTransport): + + def test_break_lock_format_5_bzrdir(self): + # break lock on a format 5 bzrdir should just return + self.make_branch_and_tree('foo', format=BzrDirFormat5()) + out, err = self.run_bzr('break-lock foo') + self.assertEqual('', out) + self.assertEqual('', err) + + +_upgrade1_template = \ + [ + ('foo', 'new contents\n'), + ('.bzr/',), + ('.bzr/README', + 'This is a Bazaar control directory.\n' + 'Do not change any files in this directory.\n' + 'See http://bazaar.canonical.com/ for more information about Bazaar.\n'), + ('.bzr/branch-format', 'Bazaar-NG branch, format 0.0.4\n'), + ('.bzr/revision-history', + 'mbp@sourcefrog.net-20051004035611-176b16534b086b3c\n' + 'mbp@sourcefrog.net-20051004035756-235f2b7dcdddd8dd\n'), + ('.bzr/merged-patches', ''), + ('.bzr/pending-merged-patches', ''), + ('.bzr/branch-name', ''), + ('.bzr/branch-lock', ''), + ('.bzr/pending-merges', ''), + ('.bzr/inventory', + '<inventory>\n' + '<entry file_id="foo-20051004035605-91e788d1875603ae" kind="file" name="foo" />\n' + '</inventory>\n'), + ('.bzr/stat-cache', + '### bzr hashcache v5\n' + 'foo// be9f309239729f69a6309e970ef24941d31e042c 13 1128398176 1128398176 303464 770\n'), + ('.bzr/text-store/',), + ('.bzr/text-store/foo-20051004035611-1591048e9dc7c2d4.gz', + '\x1f\x8b\x08\x00[\xfdAC\x02\xff\xcb\xcc\xcb,\xc9L\xccQH\xce\xcf+I\xcd+)\xe6\x02\x00\xdd\xcc\xf90\x11\x00\x00\x00'), + ('.bzr/text-store/foo-20051004035756-4081373d897c3453.gz', + '\x1f\x8b\x08\x00\xc4\xfdAC\x02\xff\xcbK-WH\xce\xcf+I\xcd+)\xe6\x02\x00g\xc3\xdf\xc9\r\x00\x00\x00'), + ('.bzr/inventory-store/',), + ('.bzr/inventory-store/mbp@sourcefrog.net-20051004035611-176b16534b086b3c.gz', + '\x1f\x8b\x08\x00[\xfdAC\x02\xffm\x8f\xcd\n\xc20\x10\x84\xef>E\xc8\xbdt7?M\x02\xad\xaf"\xa1\x99`P[\xa8E\xacOo\x14\x05\x0f\xdef\xe1\xfbv\x98\xbeL7L\xeb\xbcl\xfb]_\xc3\xb2\x89\\\xce8\x944\xc8<\xcf\x8d"\xb2LdH\xdb\x8el\x13\x18\xce\xfb\xc4\xde\xd5SGHq*\xd3\x0b\xad\x8e\x14S\xbc\xe0\xadI\xb1\xe2\xbe\xfe}\xc2\xdc\xb0\rL\xc6#\xa4\xd1\x8d*\x99\x0f}=F\x1e$8G\x9d\xa0\x02\xa1rP9\x01c`FV\xda1qg\x98"\x02}\xa5\xf2\xa8\x95\xec\xa4h\xeb\x80\xf6g\xcd\x13\xb3\x01\xcc\x98\xda\x00\x00\x00'), + ('.bzr/inventory-store/mbp@sourcefrog.net-20051004035756-235f2b7dcdddd8dd.gz', + '\x1f\x8b\x08\x00\xc4\xfdAC\x02\xffm\x8f\xc1\n\xc20\x10D\xef~E\xc8\xbd\xb8\x9bM\x9a,\xb4\xfe\x8a\xc4f\x83Am\xa1\x16\xb1~\xbdQ\x14<x\x9b\x81y3LW\xc6\x9b\x8c\xcb4\xaf\xbbMW\xc5\xbc\xaa\\\xce\xb2/\xa9\xd7y\x9a\x1a\x03\xe0\x10\xc0\x02\xb9\x16\\\xc3(>\x84\x84\xc1WKQ\xb4:\x95\xf1\x15\xad\x8cVc\xbc\xc8\x1b\xd3j\x91\xfb\xf2\xaf\xa4r\x8d\x85\x80\xe4)\x05\xf6\x03YG\x9f\xf4\xf5\x18\xb1\xd7\x07\xe1L\xc0\x86\xd8\x1b\xce-\xc7\xb6:a\x0f\x92\x8de\x8b\x89P\xc0\x9a\xe1\x0b\x95G\x9d\xc4\xda\xb1\xad\x07\xb6?o\x9e\xb5\xff\xf0\xf9\xda\x00\x00\x00'), + ('.bzr/revision-store/',), + ('.bzr/revision-store/mbp@sourcefrog.net-20051004035611-176b16534b086b3c.gz', + '\x1f\x8b\x08\x00[\xfdAC\x02\xff\x9d\x8eKj\xc30\x14E\xe7^\x85\xd0 \xb3$\xefI\xd1\x8f\xd8\xa6\x1b(t\x07E?\xbb\x82H\n\xb2\x1ahW\xdfB1\x14:\xeb\xf4r\xee\xbdgl\xf1\x91\xb6T\x0b\xf15\xe7\xd4{l\x13}\xb6\xad\xa7B^j\xbd\x91\xc3\xad_\xb3\xbb?m\xf5\xbd\xf9\xb8\xb4\xba\x9eJ\xec\x87\xb5_)I\xe5\x11K\xaf\xed\xe35\x85\x89\xfe\xa5\x8e\x0c@ \xc0\x05\xb8\x90\x88GT\xd2\xa1\x14\xfc\xe2@K\xc7\xfd\xef\x85\xed\xcd\xe2D\x95\x8d\x1a\xa47<\x02c2\xb0 \xbc\xd0\x8ay\xa3\xbcp\x8a\x83\x12A3\xb7XJv\xef\x7f_\xf7\x94\xe3\xd6m\xbeO\x14\x91in4*<\x812\x88\xc60\xfc\x01>k\x89\x13\xe5\x12\x00\xe8<\x8c\xdf\x8d\xcd\xaeq\xb6!\x90\xa5\xd6\xf1\xbc\x07\xc3x\xde\x85\xe6\xe1\x0b\xc8\x8a\x98\x03T\x01\x00\x00'), + ('.bzr/revision-store/mbp@sourcefrog.net-20051004035756-235f2b7dcdddd8dd.gz', + '\x1f\x8b\x08\x00\xc4\xfdAC\x02\xff\x9d\x90Kj\x031\x0c\x86\xf79\xc5\xe0Ev\xe9\xc8o\x9b\xcc\x84^\xa0\xd0\x1b\x14\xbf&5d\xec`\xbb\x81\xf6\xf45\x84\xa4\x81\xaeZ\xa1\x85\x84^\xdf\xaf\xa9\x84K\xac1\xa7\xc1\xe5u\x8d\xad\x852\xa3\x17SZL\xc3k\xce\xa7a{j\xfb\xd5\x9e\x9fk\xfe(.,%\x1f\x9fRh\xdbc\xdb\xa3!\xa6KH-\x97\xcf\xb7\xe8g\xf4\xbbkG\x008\x06`@\xb9\xe4bG(_\x88\x95\xde\xf9n\xca\xfb\xc7\r\xf5\xdd\xe0\x19\xa9\x85)\x81\xf5"\xbd\x04j\xb8\x02b\xa8W\\\x0b\xc9\x14\xf4\xbc\xbb\xd7\xd6H4\xdc\xb8\xff}\xba\xc55\xd4f\xd6\xf3\x8c0&\x8ajE\xa4x\xe2@\xa5\xa6\x9a\xf3k\xc3WNaFT\x00\x00:l\xa6>Q\xcd1\x1cjp9\xf9;\xc34\xde\n\x9b\xe9lJWT{t\',a\xf9\x0b\xae\xc0x\x87\xa5\xb0Xp\xca,(a\xa9{\xd0{}\xd4\x12\x04(\xc5\xbb$\xc5$V\xceaI\x19\x01\xa2\x1dh\xed\x82d\x8c.\xccr@\xc3\xd8Q\xc6\x1f\xaa\xf1\xb6\xe8\xb0\xf9\x06QR\r\xf9\xfc\x01\x00\x00')] + + +_ghost_template = [ + ( './foo', + 'hello\n' + ), + ( './.bzr/', ), + ( './.bzr/README', + 'This is a Bazaar control directory.\n' + 'Do not change any files in this directory.\n' + 'See http://bazaar.canonical.com/ for more information about Bazaar.\n' + ), + ( './.bzr/branch-format', + 'Bazaar-NG branch, format 0.0.4\n' + ), + ( './.bzr/branch-lock', + '' + ), + ( './.bzr/branch-name', + '' + ), + ( './.bzr/inventory', + '<inventory>\n' + '<entry file_id="foo-20051004104918-0379cb7c76354cde" kind="file" name="foo" />\n' + '</inventory>\n' + ), + ( './.bzr/merged-patches', + '' + ), + ( './.bzr/pending-merged-patches', + '' + ), + ( './.bzr/pending-merges', + '' + ), + ( './.bzr/revision-history', + 'mbp@sourcefrog.net-20051004104921-a98be2278dd30b7b\n' + 'mbp@sourcefrog.net-20051004104937-c9b7a7bfcc0bb22d\n' + ), + ( './.bzr/stat-cache', + '### bzr hashcache v5\n' + 'foo// f572d396fae9206628714fb2ce00f72e94f2258f 6 1128422956 1128422956 306900 770\n' + ), + ( './.bzr/text-store/', ), + ( './.bzr/text-store/foo-20051004104921-8de8118a71be45ba.gz', + '\x1f\x8b\x08\x081^BC\x00\x03foo-20051004104921-8de8118a71be45ba\x00\xcbH\xcd\xc9\xc9\xe7\x02\x00 0:6\x06\x00\x00\x00' + ), + ( './.bzr/inventory-store/', ), + ( './.bzr/inventory-store/mbp@sourcefrog.net-20051004104921-a98be2278dd30b7b.gz', + '\x1f\x8b\x08\x081^BC\x00\x03mbp@sourcefrog.net-20051004104921-a98be2278dd30b7b\x00m\x8f\xcb\n' + '\xc20\x10E\xf7~E\xc8\xbe83\xcd\x13\xaa\xbf"yL0\xa8-\xd4"\xd6\xaf7\x8a\x82\x0bw\xb38\xe7\xde;C\x1do<.\xd3\xbc\xee7C;\xe6U\x94z\xe6C\xcd;Y\xa6\xa9#\x00\x8d\x00\n' + 'Ayt\x1d\xf4\xd6\xa7h\x935\xbdV)\xb3\x14\xa7:\xbe\xd0\xe6H1\x86\x0b\xbf5)\x16\xbe/\x7fC\x08;\x97\xd9!\xba`1\xb2\xd21|\xe8\xeb1`\xe3\xb5\xa5\xdc{S\x02{\x02c\xc8YT%Rb\x80b\x89\xbd*D\xda\x95\xafT\x1f\xad\xd2H\xb1m\xfb\xb7?\xcf<\x01W}\xb5\x8b\xd9\x00\x00\x00' + ), + ( './.bzr/inventory-store/mbp@sourcefrog.net-20051004104937-c9b7a7bfcc0bb22d.gz', + '\x1f\x8b\x08\x08A^BC\x00\x03mbp@sourcefrog.net-20051004104937-c9b7a7bfcc0bb22d\x00m\x8f\xcb\n' + '\xc20\x10E\xf7~E\xc8\xbe83\xcd\x13\xaa\xbf"yL0\xa8-\xd4"\xd6\xaf7\x8a\x82\x0bw\xb38\xe7\xde;C\x1do<.\xd3\xbc\xee7C;\xe6U\x94z\xe6C\xcd;Y\xa6\xa9#\x00\x8d\x00\n' + 'Ayt\x1d\xf4\xd6\xa7h\x935\xbdV)\xb3\x14\xa7:\xbe\xd0\xe6H1\x86\x0b\xbf5)\x16\xbe/\x7fC\x08;\x97\xd9!\xba`1\xb2\xd21|\xe8\xeb1`\xe3\xb5\xa5\xdc{S\x02{\x02c\xc8YT%Rb\x80b\x89\xbd*D\xda\x95\xafT\x1f\xad\xd2H\xb1m\xfb\xb7?\xcf<\x01W}\xb5\x8b\xd9\x00\x00\x00' + ), + ( './.bzr/revision-store/', ), + ( './.bzr/revision-store/mbp@sourcefrog.net-20051004104921-a98be2278dd30b7b.gz', + '\x1f\x8b\x08\x081^BC\x00\x03mbp@sourcefrog.net-20051004104921-a98be2278dd30b7b\x00\x9d\x8eMj\xc30\x14\x84\xf7>\x85\xd0"\xbb$\xef\xc9\xb6,\x11\xdb\xf4\x02\x85\xde\xa0\xe8\xe7\xd9\x11\xc4R\x90\xd4@{\xfa\x06\x8a\xa1\xd0]\x97\x03\xdf\xcc|c\xa6G(!E\xe6\xd2\xb6\x85Z)O\xfc\xd5\xe4\x1a"{K\xe9\xc6\x0e\xb7z\xd9\xec\xfd\xa5\xa4\x8f\xech\xc9i=E\xaa\x87\xb5^8\x0b\xf1A\xb1\xa6\xfc\xf9\x1e\xfc\xc4\xffRG\x01\xd0#@\x87\xd0i\x81G\xa3\x95%!\x06\xe5}\x0bv\xb0\xbf\x17\xca\xd5\xe0\xc4-\xa0\xb1\x8b\xb6`\xc0I\xa4\xc5\xf4\x9el\xef\x95v [\x94\xcf\x8e\xd5\xcay\xe4l\xf7\xfe\xf7u\r' + '\x1b\x95j\xb6\xfb\xc4\x11\x85\xea\x84\xd0\x12O\x03t\x83D\xad\xc4\x0f\xf0\x95"M\xbc\x95\x00\xc0\xe7f|6\x8aYi^B.u<\xef\xb1\x19\xcf\xbb\xce\xdc|\x038=\xc7\xe6R\x01\x00\x00' + ), + ( './.bzr/revision-store/mbp@sourcefrog.net-20051004104937-c9b7a7bfcc0bb22d.gz', + '\x1f\x8b\x08\x08A^BC\x00\x03mbp@sourcefrog.net-20051004104937-c9b7a7bfcc0bb22d\x00\x9d\x90\xc1j\xc30\x0c\x86\xef}\n' + "\xe3Coie'\xb1c\x9a\x94\xbe\xc0`o0,[N\x03M\\\x1c\xafe{\xfae\x94n\x85\xc1`;Y\x88O\xd2\xff\xb9Mt\x19\xe6!N\xcc\xc5q\x1cr\xa6\xd4\xf1'\x9b\xf20\xb1\xe7\x18Ol}\xca\xbb\x11\xcf\x879\xbe&G!\xc5~3Q^\xf7y\xc7\xd90]h\xca1\xbd\xbd\x0c\xbe\xe3?\xa9B\x02\xd4\x02\xa0\x12P\x99R\x17\xce\xa0\xb6\x1a\x83s\x80(\xa5\x7f\xdc0\x1f\xad\xe88\x82\xb0\x18\x0c\x82\x05\xa7\x04\x05[{\xc2\xda7\xc6\x81*\x85B\x8dh\x1a\xe7\x05g\xf7\xdc\xff>\x9d\x87\x91\xe6l\xc7s\xc7\x85\x90M%\xa5\xd1z#\x85\xa8\x9b\x1a\xaa\xfa\x06\xbc\xc7\x89:^*\x00\xe0\xfbU\xbbL\xcc\xb6\xa7\xfdH\xa9'\x16\x03\xeb\x8fq\xce\xed\xf6\xde_\xb5g\x9b\x16\xa1y\xa9\xbe\x02&\n" + '\x7fJ+EaM\x83$\xa5n\xbc/a\x91~\xd0\xbd\xfd\x135\n' + '\xd0\x9a`\x0c*W\x1aR\xc1\x94du\x08(\t\xb0\x91\xdeZ\xa3\x9cU\x9cm\x7f\x8dr\x1d\x10Ot\xb8\xc6\xcf\xa7\x907|\xfb-\xb1\xbd\xd3\xfb\xd5\x07\xeeD\xee\x08*\x02\x00\x00' + ), +] + +_upgrade_dir_template = [ + ( './.bzr/', ), + ( './.bzr/README', + 'This is a Bazaar control directory.\n' + 'Do not change any files in this directory.\n' + 'See http://bazaar.canonical.com/ for more information about Bazaar.\n' + ), + ( './.bzr/branch-format', + 'Bazaar-NG branch, format 0.0.4\n' + ), + ( './.bzr/branch-lock', + '' + ), + ( './.bzr/branch-name', + '' + ), + ( './.bzr/inventory', + '<inventory>\n' + '<entry file_id="dir-20051005095101-da1441ea3fa6917a" kind="directory" name="dir" />\n' + '</inventory>\n' + ), + ( './.bzr/merged-patches', + '' + ), + ( './.bzr/pending-merged-patches', + '' + ), + ( './.bzr/pending-merges', + '' + ), + ( './.bzr/revision-history', + 'robertc@robertcollins.net-20051005095108-6065fbd8e7d8617e\n' + ), + ( './.bzr/stat-cache', + '### bzr hashcache v5\n' + ), + ( './.bzr/text-store/', ), + ( './.bzr/inventory-store/', ), + ( './.bzr/inventory-store/robertc@robertcollins.net-20051005095108-6065fbd8e7d8617e.gz', + '\x1f\x8b\x08\x00\x0c\xa2CC\x02\xff\xb3\xc9\xcc+K\xcd+\xc9/\xaa\xb4\xe3\xb2\x012\x8a*\x15\xd22sR\xe33Sl\x95R2\x8bt\x8d\x0c\x0cL\r' + "\x81\xd8\xc0\x12H\x19\xea\xa6$\x1a\x9a\x98\x18\xa6&\x1a\xa7%\x9aY\x1a\x9a'*)dg\xe6A\x94\xa6&\x83LQR\xc8K\xccM\x05\x0b()\xe8\x03\xcd\xd4G\xb2\x00\x00\xc2<\x94\xb1m\x00\x00\x00" + ), + ( './.bzr/revision-store/', ), + ( './.bzr/revision-store/robertc@robertcollins.net-20051005095108-6065fbd8e7d8617e.gz', + '\x1f\x8b\x08\x00\x0c\xa2CC\x02\xff\xa5OKj\xc30\x14\xdc\xfb\x14B\x8b\xec\x92<I\xd6\xc7\xc42\x85\xde\xa0\x17(\xb6\xf4\x9c\n' + 'l\xa9H"\x90\x9c\xbe\xa6\xa9\xa1\x9b\xae\xbax\x0c\xcc\xe71\xd3g\xbc\x85\x12R$.\xadk\xa8\x15\xb3\xa5oi\xc2\\\xc9kZ\x96\x10\x0b9,\xf5\x92\xbf)\xf7\xf2\x83O\xe5\x14\xb1\x1e\xae\xf5BI\x887\x8c5\xe5\xfb{\xf0\x96\xfei>r\x00\xc9\xb6\x83n\x03sT\xa0\xe4<y\x83\xda\x1b\xc54\xfe~T>Ff\xe9\xcc:\xdd\x8e\xa6E\xc7@\xa2\x82I\xaaNL\xbas\\313)\x00\xb9\xe6\xe0(\xd9\x87\xfc\xb7A\r' + "+\x96:\xae\x9f\x962\xc6\x8d\x04i\x949\x01\x97R\xb7\x1d\x17O\xc3#E\xb4T(\x00\xa0C\xd3o\x892^q\x18\xbd'>\xe4\xfe\xbc\x13M\x7f\xde{\r" + '\xcd\x17\x85\xea\xba\x03l\x01\x00\x00' + ), + ( './dir/', ), +] + + +class TestUpgrade(TestCaseWithTransport): + + def test_upgrade_v6_to_meta_no_workingtree(self): + # Some format6 branches do not have checkout files. Upgrading + # such a branch to metadir must not setup a working tree. + self.build_tree_contents(_upgrade1_template) + upgrade.upgrade('.', BzrDirFormat6()) + t = self.get_transport('.') + t.delete_multi(['.bzr/pending-merges', '.bzr/inventory']) + self.assertFalse(t.has('.bzr/stat-cache')) + t.delete_tree('backup.bzr.~1~') + # At this point, we have a format6 branch without checkout files. + upgrade.upgrade('.', bzrdir.BzrDirMetaFormat1()) + # The upgrade should not have set up a working tree. + control = controldir.ControlDir.open('.') + self.assertFalse(control.has_workingtree()) + # We have covered the scope of this test, we may as well check that + # upgrade has not eaten our data, even if it's a bit redundant with + # other tests. + self.assertIsInstance(control._format, bzrdir.BzrDirMetaFormat1) + b = control.open_branch() + self.addCleanup(b.lock_read().unlock) + self.assertEquals(b._revision_history(), + ['mbp@sourcefrog.net-20051004035611-176b16534b086b3c', + 'mbp@sourcefrog.net-20051004035756-235f2b7dcdddd8dd']) + + def test_upgrade_simple(self): + """Upgrade simple v0.0.4 format to latest format""" + eq = self.assertEquals + self.build_tree_contents(_upgrade1_template) + upgrade.upgrade(u'.') + control = controldir.ControlDir.open('.') + b = control.open_branch() + # tsk, peeking under the covers. + self.assertIsInstance( + control._format, + bzrdir.BzrDirFormat.get_default_format().__class__) + self.addCleanup(b.lock_read().unlock) + rh = b._revision_history() + eq(rh, + ['mbp@sourcefrog.net-20051004035611-176b16534b086b3c', + 'mbp@sourcefrog.net-20051004035756-235f2b7dcdddd8dd']) + rt = b.repository.revision_tree(rh[0]) + foo_id = 'foo-20051004035605-91e788d1875603ae' + rt.lock_read() + try: + eq(rt.get_file_text(foo_id), 'initial contents\n') + finally: + rt.unlock() + rt = b.repository.revision_tree(rh[1]) + rt.lock_read() + try: + eq(rt.get_file_text(foo_id), 'new contents\n') + finally: + rt.unlock() + # check a backup was made: + backup_dir = 'backup.bzr.~1~' + t = self.get_transport('.') + t.stat(backup_dir) + t.stat(backup_dir + '/README') + t.stat(backup_dir + '/branch-format') + t.stat(backup_dir + '/revision-history') + t.stat(backup_dir + '/merged-patches') + t.stat(backup_dir + '/pending-merged-patches') + t.stat(backup_dir + '/pending-merges') + t.stat(backup_dir + '/branch-name') + t.stat(backup_dir + '/branch-lock') + t.stat(backup_dir + '/inventory') + t.stat(backup_dir + '/stat-cache') + t.stat(backup_dir + '/text-store') + t.stat(backup_dir + '/text-store/foo-20051004035611-1591048e9dc7c2d4.gz') + t.stat(backup_dir + '/text-store/foo-20051004035756-4081373d897c3453.gz') + t.stat(backup_dir + '/inventory-store/') + t.stat(backup_dir + '/inventory-store/mbp@sourcefrog.net-20051004035611-176b16534b086b3c.gz') + t.stat(backup_dir + '/inventory-store/mbp@sourcefrog.net-20051004035756-235f2b7dcdddd8dd.gz') + t.stat(backup_dir + '/revision-store/') + t.stat(backup_dir + '/revision-store/mbp@sourcefrog.net-20051004035611-176b16534b086b3c.gz') + t.stat(backup_dir + '/revision-store/mbp@sourcefrog.net-20051004035756-235f2b7dcdddd8dd.gz') + + def test_upgrade_with_ghosts(self): + """Upgrade v0.0.4 tree containing ghost references. + + That is, some of the parents of revisions mentioned in the branch + aren't present in the branch's storage. + + This shouldn't normally happen in branches created entirely in + bzr, but can happen in branches imported from baz and arch, or from + other systems, where the importer knows about a revision but not + its contents.""" + eq = self.assertEquals + self.build_tree_contents(_ghost_template) + upgrade.upgrade(u'.') + b = branch.Branch.open(u'.') + self.addCleanup(b.lock_read().unlock) + revision_id = b._revision_history()[1] + rev = b.repository.get_revision(revision_id) + eq(len(rev.parent_ids), 2) + eq(rev.parent_ids[1], 'wibble@wobble-2') + + def test_upgrade_makes_dir_weaves(self): + self.build_tree_contents(_upgrade_dir_template) + old_repodir = controldir.ControlDir.open_unsupported('.') + old_repo_format = old_repodir.open_repository()._format + upgrade.upgrade('.') + # this is the path to the literal file. As format changes + # occur it needs to be updated. FIXME: ask the store for the + # path. + repo = repository.Repository.open('.') + # it should have changed the format + self.assertNotEqual(old_repo_format.__class__, repo._format.__class__) + # and we should be able to read the names for the file id + # 'dir-20051005095101-da1441ea3fa6917a' + repo.lock_read() + self.addCleanup(repo.unlock) + text_keys = repo.texts.keys() + dir_keys = [key for key in text_keys if key[0] == + 'dir-20051005095101-da1441ea3fa6917a'] + self.assertNotEqual([], dir_keys) + + def test_upgrade_to_meta_sets_workingtree_last_revision(self): + self.build_tree_contents(_upgrade_dir_template) + upgrade.upgrade('.', bzrdir.BzrDirMetaFormat1()) + tree = workingtree.WorkingTree.open('.') + self.addCleanup(tree.lock_read().unlock) + self.assertEqual([tree.branch._revision_history()[-1]], + tree.get_parent_ids()) + + +class SFTPBranchTest(TestCaseWithSFTPServer): + """Test some stuff when accessing a bzr Branch over sftp""" + + def test_lock_file(self): + # old format branches use a special lock file on sftp. + b = self.make_branch('', format=BzrDirFormat6()) + b = branch.Branch.open(self.get_url()) + self.assertPathExists('.bzr/') + self.assertPathExists('.bzr/branch-format') + self.assertPathExists('.bzr/branch-lock') + + self.assertPathDoesNotExist('.bzr/branch-lock.write-lock') + b.lock_write() + self.assertPathExists('.bzr/branch-lock.write-lock') + b.unlock() + self.assertPathDoesNotExist('.bzr/branch-lock.write-lock') + + +class TestInfo(TestCaseWithTransport): + + def test_info_locking_oslocks(self): + if sys.platform == "win32": + self.skip("don't use oslocks on win32 in unix manner") + # This test tests old (all-in-one, OS lock using) behaviour which + # simply cannot work on windows (and is indeed why we changed our + # design. As such, don't try to remove the thisFailsStrictLockCheck + # call here. + self.thisFailsStrictLockCheck() + + tree = self.make_branch_and_tree('branch', + format=BzrDirFormat6()) + + # Test all permutations of locking the working tree, branch and repository + # XXX: Well not yet, as we can't query oslocks yet. Currently, it's + # implemented by raising NotImplementedError and get_physical_lock_status() + # always returns false. This makes bzr info hide the lock status. (Olaf) + # W B R + + # U U U + out, err = self.run_bzr('info -v branch') + self.assertEqualDiff( +"""Standalone tree (format: weave) +Location: + branch root: %s + +Format: + control: All-in-one format 6 + working tree: Working tree format 2 + branch: Branch format 4 + repository: %s + +In the working tree: + 0 unchanged + 0 modified + 0 added + 0 removed + 0 renamed + 0 unknown + 0 ignored + 0 versioned subdirectories + +Branch history: + 0 revisions + +Repository: + 0 revisions +""" % ('branch', tree.branch.repository._format.get_format_description(), + ), out) + self.assertEqual('', err) + # L L L + tree.lock_write() + out, err = self.run_bzr('info -v branch') + self.assertEqualDiff( +"""Standalone tree (format: weave) +Location: + branch root: %s + +Format: + control: All-in-one format 6 + working tree: Working tree format 2 + branch: Branch format 4 + repository: %s + +In the working tree: + 0 unchanged + 0 modified + 0 added + 0 removed + 0 renamed + 0 unknown + 0 ignored + 0 versioned subdirectories + +Branch history: + 0 revisions + +Repository: + 0 revisions +""" % ('branch', tree.branch.repository._format.get_format_description(), + ), out) + self.assertEqual('', err) + tree.unlock() + + +class TestBranchFormat4(TestCaseWithTransport): + """Tests specific to branch format 4""" + + def test_no_metadir_support(self): + url = self.get_url() + bdir = bzrdir.BzrDirMetaFormat1().initialize(url) + bdir.create_repository() + self.assertRaises(errors.IncompatibleFormat, + BzrBranchFormat4().initialize, bdir) + + def test_supports_bzrdir_6(self): + url = self.get_url() + bdir = BzrDirFormat6().initialize(url) + bdir.create_repository() + BzrBranchFormat4().initialize(bdir) + + +class TestBoundBranch(TestCaseWithTransport): + + def setUp(self): + super(TestBoundBranch, self).setUp() + self.build_tree(['master/', 'child/']) + self.make_branch_and_tree('master') + self.make_branch_and_tree('child', + format=controldir.format_registry.make_bzrdir('weave')) + os.chdir('child') + + def test_bind_format_6_bzrdir(self): + # bind on a format 6 bzrdir should error + out,err = self.run_bzr('bind ../master', retcode=3) + self.assertEqual('', out) + # TODO: jam 20060427 Probably something like this really should + # print out the actual path, rather than the URL + cwd = urlutils.local_path_to_url(getcwd()) + self.assertEqual('bzr: ERROR: To use this feature you must ' + 'upgrade your branch at %s/.\n' % cwd, err) + + def test_unbind_format_6_bzrdir(self): + # bind on a format 6 bzrdir should error + out,err = self.run_bzr('unbind', retcode=3) + self.assertEqual('', out) + cwd = urlutils.local_path_to_url(getcwd()) + self.assertEqual('bzr: ERROR: To use this feature you must ' + 'upgrade your branch at %s/.\n' % cwd, err) + + +class TestInit(TestCaseWithTransport): + + def test_init_weave(self): + # --format=weave should be accepted to allow interoperation with + # old releases when desired. + out, err = self.run_bzr('init --format=weave') + self.assertEqual("""Created a standalone tree (format: weave)\n""", + out) + self.assertEqual('', err) + + +class V4WeaveBundleTester(V4BundleTester): + + def bzrdir_format(self): + return 'metaweave' diff --git a/bzrlib/plugins/weave_fmt/test_repository.py b/bzrlib/plugins/weave_fmt/test_repository.py new file mode 100644 index 0000000..7453296 --- /dev/null +++ b/bzrlib/plugins/weave_fmt/test_repository.py @@ -0,0 +1,331 @@ +# Copyright (C) 2006-2010 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Tests for weave repositories. + +For interface tests see tests/per_repository/*.py. + +""" + +from __future__ import absolute_import + +from cStringIO import StringIO +from stat import S_ISDIR +import sys + +from bzrlib.bzrdir import ( + BzrDirMetaFormat1, + ) +from bzrlib.errors import ( + IllegalPath, + NoSuchFile, + ) +from bzrlib.repository import ( + InterRepository, + Repository, + ) +from bzrlib.serializer import ( + format_registry as serializer_format_registry, + ) +from bzrlib.tests import ( + TestCase, + TestCaseWithTransport, + ) + +from bzrlib.plugins.weave_fmt import xml4 +from bzrlib.plugins.weave_fmt.bzrdir import ( + BzrDirFormat6, + ) +from bzrlib.plugins.weave_fmt.repository import ( + InterWeaveRepo, + RepositoryFormat4, + RepositoryFormat5, + RepositoryFormat6, + RepositoryFormat7, + ) + + +class TestFormat6(TestCaseWithTransport): + + def test_attribute__fetch_order(self): + """Weaves need topological data insertion.""" + control = BzrDirFormat6().initialize(self.get_url()) + repo = RepositoryFormat6().initialize(control) + self.assertEqual('topological', repo._format._fetch_order) + + def test_attribute__fetch_uses_deltas(self): + """Weaves do not reuse deltas.""" + control = BzrDirFormat6().initialize(self.get_url()) + repo = RepositoryFormat6().initialize(control) + self.assertEqual(False, repo._format._fetch_uses_deltas) + + def test_attribute__fetch_reconcile(self): + """Weave repositories need a reconcile after fetch.""" + control = BzrDirFormat6().initialize(self.get_url()) + repo = RepositoryFormat6().initialize(control) + self.assertEqual(True, repo._format._fetch_reconcile) + + def test_no_ancestry_weave(self): + control = BzrDirFormat6().initialize(self.get_url()) + repo = RepositoryFormat6().initialize(control) + # We no longer need to create the ancestry.weave file + # since it is *never* used. + self.assertRaises(NoSuchFile, + control.transport.get, + 'ancestry.weave') + + def test_supports_external_lookups(self): + control = BzrDirFormat6().initialize(self.get_url()) + repo = RepositoryFormat6().initialize(control) + self.assertFalse(repo._format.supports_external_lookups) + + + + +class TestFormat7(TestCaseWithTransport): + + def test_attribute__fetch_order(self): + """Weaves need topological data insertion.""" + control = BzrDirMetaFormat1().initialize(self.get_url()) + repo = RepositoryFormat7().initialize(control) + self.assertEqual('topological', repo._format._fetch_order) + + def test_attribute__fetch_uses_deltas(self): + """Weaves do not reuse deltas.""" + control = BzrDirMetaFormat1().initialize(self.get_url()) + repo = RepositoryFormat7().initialize(control) + self.assertEqual(False, repo._format._fetch_uses_deltas) + + def test_attribute__fetch_reconcile(self): + """Weave repositories need a reconcile after fetch.""" + control = BzrDirMetaFormat1().initialize(self.get_url()) + repo = RepositoryFormat7().initialize(control) + self.assertEqual(True, repo._format._fetch_reconcile) + + def test_disk_layout(self): + control = BzrDirMetaFormat1().initialize(self.get_url()) + repo = RepositoryFormat7().initialize(control) + # in case of side effects of locking. + repo.lock_write() + repo.unlock() + # we want: + # format 'Bazaar-NG Repository format 7' + # lock '' + # inventory.weave == empty_weave + # empty revision-store directory + # empty weaves directory + t = control.get_repository_transport(None) + self.assertEqualDiff('Bazaar-NG Repository format 7', + t.get('format').read()) + self.assertTrue(S_ISDIR(t.stat('revision-store').st_mode)) + self.assertTrue(S_ISDIR(t.stat('weaves').st_mode)) + self.assertEqualDiff('# bzr weave file v5\n' + 'w\n' + 'W\n', + t.get('inventory.weave').read()) + # Creating a file with id Foo:Bar results in a non-escaped file name on + # disk. + control.create_branch() + tree = control.create_workingtree() + tree.add(['foo'], ['Foo:Bar'], ['file']) + tree.put_file_bytes_non_atomic('Foo:Bar', 'content\n') + try: + tree.commit('first post', rev_id='first') + except IllegalPath: + if sys.platform != 'win32': + raise + self.knownFailure('Foo:Bar cannot be used as a file-id on windows' + ' in repo format 7') + return + self.assertEqualDiff( + '# bzr weave file v5\n' + 'i\n' + '1 7fe70820e08a1aac0ef224d9c66ab66831cc4ab1\n' + 'n first\n' + '\n' + 'w\n' + '{ 0\n' + '. content\n' + '}\n' + 'W\n', + t.get('weaves/74/Foo%3ABar.weave').read()) + + def test_shared_disk_layout(self): + control = BzrDirMetaFormat1().initialize(self.get_url()) + repo = RepositoryFormat7().initialize(control, shared=True) + # we want: + # format 'Bazaar-NG Repository format 7' + # inventory.weave == empty_weave + # empty revision-store directory + # empty weaves directory + # a 'shared-storage' marker file. + # lock is not present when unlocked + t = control.get_repository_transport(None) + self.assertEqualDiff('Bazaar-NG Repository format 7', + t.get('format').read()) + self.assertEqualDiff('', t.get('shared-storage').read()) + self.assertTrue(S_ISDIR(t.stat('revision-store').st_mode)) + self.assertTrue(S_ISDIR(t.stat('weaves').st_mode)) + self.assertEqualDiff('# bzr weave file v5\n' + 'w\n' + 'W\n', + t.get('inventory.weave').read()) + self.assertFalse(t.has('branch-lock')) + + def test_creates_lockdir(self): + """Make sure it appears to be controlled by a LockDir existence""" + control = BzrDirMetaFormat1().initialize(self.get_url()) + repo = RepositoryFormat7().initialize(control, shared=True) + t = control.get_repository_transport(None) + # TODO: Should check there is a 'lock' toplevel directory, + # regardless of contents + self.assertFalse(t.has('lock/held/info')) + repo.lock_write() + try: + self.assertTrue(t.has('lock/held/info')) + finally: + # unlock so we don't get a warning about failing to do so + repo.unlock() + + def test_uses_lockdir(self): + """repo format 7 actually locks on lockdir""" + base_url = self.get_url() + control = BzrDirMetaFormat1().initialize(base_url) + repo = RepositoryFormat7().initialize(control, shared=True) + t = control.get_repository_transport(None) + repo.lock_write() + repo.unlock() + del repo + # make sure the same lock is created by opening it + repo = Repository.open(base_url) + repo.lock_write() + self.assertTrue(t.has('lock/held/info')) + repo.unlock() + self.assertFalse(t.has('lock/held/info')) + + def test_shared_no_tree_disk_layout(self): + control = BzrDirMetaFormat1().initialize(self.get_url()) + repo = RepositoryFormat7().initialize(control, shared=True) + repo.set_make_working_trees(False) + # we want: + # format 'Bazaar-NG Repository format 7' + # lock '' + # inventory.weave == empty_weave + # empty revision-store directory + # empty weaves directory + # a 'shared-storage' marker file. + t = control.get_repository_transport(None) + self.assertEqualDiff('Bazaar-NG Repository format 7', + t.get('format').read()) + ## self.assertEqualDiff('', t.get('lock').read()) + self.assertEqualDiff('', t.get('shared-storage').read()) + self.assertEqualDiff('', t.get('no-working-trees').read()) + repo.set_make_working_trees(True) + self.assertFalse(t.has('no-working-trees')) + self.assertTrue(S_ISDIR(t.stat('revision-store').st_mode)) + self.assertTrue(S_ISDIR(t.stat('weaves').st_mode)) + self.assertEqualDiff('# bzr weave file v5\n' + 'w\n' + 'W\n', + t.get('inventory.weave').read()) + + def test_supports_external_lookups(self): + control = BzrDirMetaFormat1().initialize(self.get_url()) + repo = RepositoryFormat7().initialize(control) + self.assertFalse(repo._format.supports_external_lookups) + + +class TestInterWeaveRepo(TestCaseWithTransport): + + def test_is_compatible_and_registered(self): + # InterWeaveRepo is compatible when either side + # is a format 5/6/7 branch + from bzrlib.repofmt import knitrepo + formats = [RepositoryFormat5(), + RepositoryFormat6(), + RepositoryFormat7()] + incompatible_formats = [RepositoryFormat4(), + knitrepo.RepositoryFormatKnit1(), + ] + repo_a = self.make_repository('a') + repo_b = self.make_repository('b') + is_compatible = InterWeaveRepo.is_compatible + for source in incompatible_formats: + # force incompatible left then right + repo_a._format = source + repo_b._format = formats[0] + self.assertFalse(is_compatible(repo_a, repo_b)) + self.assertFalse(is_compatible(repo_b, repo_a)) + for source in formats: + repo_a._format = source + for target in formats: + repo_b._format = target + self.assertTrue(is_compatible(repo_a, repo_b)) + self.assertEqual(InterWeaveRepo, + InterRepository.get(repo_a, repo_b).__class__) + + +_working_inventory_v4 = """<inventory file_id="TREE_ROOT"> +<entry file_id="bar-20050901064931-73b4b1138abc9cd2" kind="file" name="bar" parent_id="TREE_ROOT" /> +<entry file_id="foo-20050801201819-4139aa4a272f4250" kind="directory" name="foo" parent_id="TREE_ROOT" /> +<entry file_id="bar-20050824000535-6bc48cfad47ed134" kind="file" name="bar" parent_id="foo-20050801201819-4139aa4a272f4250" /> +</inventory>""" + + +_revision_v4 = """<revision committer="Martin Pool <mbp@sourcefrog.net>" + inventory_id="mbp@sourcefrog.net-20050905080035-e0439293f8b6b9f9" + inventory_sha1="e79c31c1deb64c163cf660fdedd476dd579ffd41" + revision_id="mbp@sourcefrog.net-20050905080035-e0439293f8b6b9f9" + timestamp="1125907235.212" + timezone="36000"> +<message>- start splitting code for xml (de)serialization away from objects + preparatory to supporting multiple formats by a single library +</message> +<parents> +<revision_ref revision_id="mbp@sourcefrog.net-20050905063503-43948f59fa127d92" revision_sha1="7bdf4cc8c5bdac739f8cf9b10b78cf4b68f915ff" /> +</parents> +</revision> +""" + + +class TestSerializer(TestCase): + """Test serializer""" + + def test_registry(self): + self.assertIs(xml4.serializer_v4, + serializer_format_registry.get('4')) + + def test_canned_inventory(self): + """Test unpacked a canned inventory v4 file.""" + inp = StringIO(_working_inventory_v4) + inv = xml4.serializer_v4.read_inventory(inp) + self.assertEqual(len(inv), 4) + self.assert_(inv.has_id('bar-20050901064931-73b4b1138abc9cd2')) + + def test_unpack_revision(self): + """Test unpacking a canned revision v4""" + inp = StringIO(_revision_v4) + rev = xml4.serializer_v4.read_revision(inp) + eq = self.assertEqual + eq(rev.committer, + "Martin Pool <mbp@sourcefrog.net>") + eq(rev.inventory_id, + "mbp@sourcefrog.net-20050905080035-e0439293f8b6b9f9") + eq(len(rev.parent_ids), 1) + eq(rev.parent_ids[0], + "mbp@sourcefrog.net-20050905063503-43948f59fa127d92") + + diff --git a/bzrlib/plugins/weave_fmt/test_workingtree.py b/bzrlib/plugins/weave_fmt/test_workingtree.py new file mode 100644 index 0000000..cea8c6b --- /dev/null +++ b/bzrlib/plugins/weave_fmt/test_workingtree.py @@ -0,0 +1,89 @@ +# Copyright (C) 2005-2011 Canonical Ltd +# Authors: Robert Collins <robert.collins@canonical.com> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Tests for weave-era working tree formats.""" + +from __future__ import absolute_import + +import os + +from bzrlib import ( + conflicts, + errors, + ) + +from bzrlib.tests import ( + TestCaseWithTransport, + ) + +from bzrlib.plugins.weave_fmt.bzrdir import BzrDirFormat6 + + +class TestFormat2WorkingTree(TestCaseWithTransport): + """Tests that are specific to format 2 trees.""" + + def create_format2_tree(self, url): + return self.make_branch_and_tree( + url, format=BzrDirFormat6()) + + def test_conflicts(self): + # test backwards compatability + tree = self.create_format2_tree('.') + self.assertRaises(errors.UnsupportedOperation, tree.set_conflicts, + None) + file('lala.BASE', 'wb').write('labase') + expected = conflicts.ContentsConflict('lala') + self.assertEqual(list(tree.conflicts()), [expected]) + file('lala', 'wb').write('la') + tree.add('lala', 'lala-id') + expected = conflicts.ContentsConflict('lala', file_id='lala-id') + self.assertEqual(list(tree.conflicts()), [expected]) + file('lala.THIS', 'wb').write('lathis') + file('lala.OTHER', 'wb').write('laother') + # When "text conflict"s happen, stem, THIS and OTHER are text + expected = conflicts.TextConflict('lala', file_id='lala-id') + self.assertEqual(list(tree.conflicts()), [expected]) + os.unlink('lala.OTHER') + os.mkdir('lala.OTHER') + expected = conflicts.ContentsConflict('lala', file_id='lala-id') + self.assertEqual(list(tree.conflicts()), [expected]) + + def test_detect_conflicts(self): + """Conflicts are detected properly""" + tree = self.create_format2_tree('.') + self.build_tree_contents([('hello', 'hello world4'), + ('hello.THIS', 'hello world2'), + ('hello.BASE', 'hello world1'), + ('hello.OTHER', 'hello world3'), + ('hello.sploo.BASE', 'yellowworld'), + ('hello.sploo.OTHER', 'yellowworld2'), + ]) + tree.lock_read() + self.assertLength(6, list(tree.list_files())) + tree.unlock() + tree_conflicts = tree.conflicts() + self.assertLength(2, tree_conflicts) + self.assertTrue('hello' in tree_conflicts[0].path) + self.assertTrue('hello.sploo' in tree_conflicts[1].path) + conflicts.restore('hello') + conflicts.restore('hello.sploo') + self.assertLength(0, tree.conflicts()) + self.assertFileEqual('hello world2', 'hello') + self.assertFalse(os.path.lexists('hello.sploo')) + self.assertRaises(errors.NotConflicted, conflicts.restore, 'hello') + self.assertRaises(errors.NotConflicted, + conflicts.restore, 'hello.sploo') diff --git a/bzrlib/plugins/weave_fmt/workingtree.py b/bzrlib/plugins/weave_fmt/workingtree.py new file mode 100644 index 0000000..2ec443c --- /dev/null +++ b/bzrlib/plugins/weave_fmt/workingtree.py @@ -0,0 +1,243 @@ +# Copyright (C) 2005-2010 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Weave-era working tree objects.""" + +from __future__ import absolute_import + +from cStringIO import StringIO + +from bzrlib import ( + conflicts as _mod_conflicts, + errors, + inventory, + osutils, + revision as _mod_revision, + transform, + xml5, + ) +from bzrlib.decorators import needs_read_lock +from bzrlib.mutabletree import MutableTree +from bzrlib.transport.local import LocalTransport +from bzrlib.workingtree import ( + WorkingTreeFormat, + ) +from bzrlib.workingtree_3 import ( + PreDirStateWorkingTree, + ) + + +def get_conflicted_stem(path): + for suffix in _mod_conflicts.CONFLICT_SUFFIXES: + if path.endswith(suffix): + return path[:-len(suffix)] + + +class WorkingTreeFormat2(WorkingTreeFormat): + """The second working tree format. + + This format modified the hash cache from the format 1 hash cache. + """ + + upgrade_recommended = True + + requires_normalized_unicode_filenames = True + + case_sensitive_filename = "Branch-FoRMaT" + + missing_parent_conflicts = False + + supports_versioned_directories = True + + def get_format_description(self): + """See WorkingTreeFormat.get_format_description().""" + return "Working tree format 2" + + def _stub_initialize_on_transport(self, transport, file_mode): + """Workaround: create control files for a remote working tree. + + This ensures that it can later be updated and dealt with locally, + since BzrDirFormat6 and BzrDirFormat5 cannot represent dirs with + no working tree. (See bug #43064). + """ + sio = StringIO() + inv = inventory.Inventory() + xml5.serializer_v5.write_inventory(inv, sio, working=True) + sio.seek(0) + transport.put_file('inventory', sio, file_mode) + transport.put_bytes('pending-merges', '', file_mode) + + def initialize(self, a_bzrdir, revision_id=None, from_branch=None, + accelerator_tree=None, hardlink=False): + """See WorkingTreeFormat.initialize().""" + if not isinstance(a_bzrdir.transport, LocalTransport): + raise errors.NotLocalUrl(a_bzrdir.transport.base) + if from_branch is not None: + branch = from_branch + else: + branch = a_bzrdir.open_branch() + if revision_id is None: + revision_id = _mod_revision.ensure_null(branch.last_revision()) + branch.lock_write() + try: + branch.generate_revision_history(revision_id) + finally: + branch.unlock() + inv = inventory.Inventory() + wt = WorkingTree2(a_bzrdir.root_transport.local_abspath('.'), + branch, + inv, + _internal=True, + _format=self, + _bzrdir=a_bzrdir, + _control_files=branch.control_files) + basis_tree = branch.repository.revision_tree(revision_id) + if basis_tree.get_root_id() is not None: + wt.set_root_id(basis_tree.get_root_id()) + # set the parent list and cache the basis tree. + if _mod_revision.is_null(revision_id): + parent_trees = [] + else: + parent_trees = [(revision_id, basis_tree)] + wt.set_parent_trees(parent_trees) + transform.build_tree(basis_tree, wt) + for hook in MutableTree.hooks['post_build_tree']: + hook(wt) + return wt + + def __init__(self): + super(WorkingTreeFormat2, self).__init__() + from bzrlib.plugins.weave_fmt.bzrdir import BzrDirFormat6 + self._matchingbzrdir = BzrDirFormat6() + + def open(self, a_bzrdir, _found=False): + """Return the WorkingTree object for a_bzrdir + + _found is a private parameter, do not use it. It is used to indicate + if format probing has already been done. + """ + if not _found: + # we are being called directly and must probe. + raise NotImplementedError + if not isinstance(a_bzrdir.transport, LocalTransport): + raise errors.NotLocalUrl(a_bzrdir.transport.base) + wt = WorkingTree2(a_bzrdir.root_transport.local_abspath('.'), + _internal=True, + _format=self, + _bzrdir=a_bzrdir, + _control_files=a_bzrdir.open_branch().control_files) + return wt + + +class WorkingTree2(PreDirStateWorkingTree): + """This is the Format 2 working tree. + + This was the first weave based working tree. + - uses os locks for locking. + - uses the branch last-revision. + """ + + def __init__(self, basedir, *args, **kwargs): + super(WorkingTree2, self).__init__(basedir, *args, **kwargs) + # WorkingTree2 has more of a constraint that self._inventory must + # exist. Because this is an older format, we don't mind the overhead + # caused by the extra computation here. + + # Newer WorkingTree's should only have self._inventory set when they + # have a read lock. + if self._inventory is None: + self.read_working_inventory() + + def _get_check_refs(self): + """Return the references needed to perform a check of this tree.""" + return [('trees', self.last_revision())] + + + def lock_tree_write(self): + """See WorkingTree.lock_tree_write(). + + In Format2 WorkingTrees we have a single lock for the branch and tree + so lock_tree_write() degrades to lock_write(). + + :return: An object with an unlock method which will release the lock + obtained. + """ + self.branch.lock_write() + try: + self._control_files.lock_write() + return self + except: + self.branch.unlock() + raise + + def unlock(self): + # we share control files: + if self._control_files._lock_count == 3: + # do non-implementation specific cleanup + self._cleanup() + # _inventory_is_modified is always False during a read lock. + if self._inventory_is_modified: + self.flush() + self._write_hashcache_if_dirty() + + # reverse order of locking. + try: + return self._control_files.unlock() + finally: + self.branch.unlock() + + def _iter_conflicts(self): + conflicted = set() + for info in self.list_files(): + path = info[0] + stem = get_conflicted_stem(path) + if stem is None: + continue + if stem not in conflicted: + conflicted.add(stem) + yield stem + + @needs_read_lock + def conflicts(self): + conflicts = _mod_conflicts.ConflictList() + for conflicted in self._iter_conflicts(): + text = True + try: + if osutils.file_kind(self.abspath(conflicted)) != "file": + text = False + except errors.NoSuchFile: + text = False + if text is True: + for suffix in ('.THIS', '.OTHER'): + try: + kind = osutils.file_kind(self.abspath(conflicted+suffix)) + if kind != "file": + text = False + except errors.NoSuchFile: + text = False + if text == False: + break + ctype = {True: 'text conflict', False: 'contents conflict'}[text] + conflicts.append(_mod_conflicts.Conflict.factory(ctype, + path=conflicted, + file_id=self.path2id(conflicted))) + return conflicts + + def set_conflicts(self, arg): + raise errors.UnsupportedOperation(self.set_conflicts, self) + + def add_conflicts(self, arg): + raise errors.UnsupportedOperation(self.add_conflicts, self) diff --git a/bzrlib/plugins/weave_fmt/xml4.py b/bzrlib/plugins/weave_fmt/xml4.py new file mode 100644 index 0000000..f1cd664 --- /dev/null +++ b/bzrlib/plugins/weave_fmt/xml4.py @@ -0,0 +1,190 @@ +# Copyright (C) 2005-2010 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +from __future__ import absolute_import + +from bzrlib.xml_serializer import ( + Element, + SubElement, + XMLSerializer, + escape_invalid_chars, + ) +from bzrlib.inventory import ROOT_ID, Inventory +import bzrlib.inventory as inventory +from bzrlib.revision import Revision +from bzrlib.errors import BzrError + + +class _Serializer_v4(XMLSerializer): + """Version 0.0.4 serializer + + You should use the serializer_v4 singleton. + + v4 serialisation is no longer supported, only deserialisation. + """ + + __slots__ = [] + + def _pack_entry(self, ie): + """Convert InventoryEntry to XML element""" + e = Element('entry') + e.set('name', ie.name) + e.set('file_id', ie.file_id) + e.set('kind', ie.kind) + + if ie.text_size is not None: + e.set('text_size', '%d' % ie.text_size) + + for f in ['text_id', 'text_sha1', 'symlink_target']: + v = getattr(ie, f) + if v is not None: + e.set(f, v) + + # to be conservative, we don't externalize the root pointers + # for now, leaving them as null in the xml form. in a future + # version it will be implied by nested elements. + if ie.parent_id != ROOT_ID: + e.set('parent_id', ie.parent_id) + + e.tail = '\n' + + return e + + + def _unpack_inventory(self, elt, revision_id=None, entry_cache=None, + return_from_cache=False): + """Construct from XML Element + + :param revision_id: Ignored parameter used by xml5. + """ + root_id = elt.get('file_id') or ROOT_ID + inv = Inventory(root_id) + for e in elt: + ie = self._unpack_entry(e, entry_cache=entry_cache, + return_from_cache=return_from_cache) + if ie.parent_id == ROOT_ID: + ie.parent_id = root_id + inv.add(ie) + return inv + + + def _unpack_entry(self, elt, entry_cache=None, return_from_cache=False): + ## original format inventories don't have a parent_id for + ## nodes in the root directory, but it's cleaner to use one + ## internally. + parent_id = elt.get('parent_id') + if parent_id is None: + parent_id = ROOT_ID + + kind = elt.get('kind') + if kind == 'directory': + ie = inventory.InventoryDirectory(elt.get('file_id'), + elt.get('name'), + parent_id) + elif kind == 'file': + ie = inventory.InventoryFile(elt.get('file_id'), + elt.get('name'), + parent_id) + ie.text_id = elt.get('text_id') + ie.text_sha1 = elt.get('text_sha1') + v = elt.get('text_size') + ie.text_size = v and int(v) + elif kind == 'symlink': + ie = inventory.InventoryLink(elt.get('file_id'), + elt.get('name'), + parent_id) + ie.symlink_target = elt.get('symlink_target') + else: + raise BzrError("unknown kind %r" % kind) + + ## mutter("read inventoryentry: %r", elt.attrib) + + return ie + + + def _pack_revision(self, rev): + """Revision object -> xml tree""" + root = Element('revision', + committer = rev.committer, + timestamp = '%.9f' % rev.timestamp, + revision_id = rev.revision_id, + inventory_id = rev.inventory_id, + inventory_sha1 = rev.inventory_sha1, + ) + if rev.timezone: + root.set('timezone', str(rev.timezone)) + root.text = '\n' + + msg = SubElement(root, 'message') + msg.text = escape_invalid_chars(rev.message)[0] + msg.tail = '\n' + + if rev.parents: + pelts = SubElement(root, 'parents') + pelts.tail = pelts.text = '\n' + for i, parent_id in enumerate(rev.parents): + p = SubElement(pelts, 'revision_ref') + p.tail = '\n' + p.set('revision_id', parent_id) + if i < len(rev.parent_sha1s): + p.set('revision_sha1', rev.parent_sha1s[i]) + return root + + + def _unpack_revision(self, elt): + """XML Element -> Revision object""" + + # <changeset> is deprecated... + if elt.tag not in ('revision', 'changeset'): + raise BzrError("unexpected tag in revision file: %r" % elt) + + rev = Revision(committer = elt.get('committer'), + timestamp = float(elt.get('timestamp')), + revision_id = elt.get('revision_id'), + inventory_id = elt.get('inventory_id'), + inventory_sha1 = elt.get('inventory_sha1') + ) + + precursor = elt.get('precursor') + precursor_sha1 = elt.get('precursor_sha1') + + pelts = elt.find('parents') + + if pelts: + for p in pelts: + rev.parent_ids.append(p.get('revision_id')) + rev.parent_sha1s.append(p.get('revision_sha1')) + if precursor: + # must be consistent + prec_parent = rev.parent_ids[0] + elif precursor: + # revisions written prior to 0.0.5 have a single precursor + # give as an attribute + rev.parent_ids.append(precursor) + rev.parent_sha1s.append(precursor_sha1) + + v = elt.get('timezone') + rev.timezone = v and int(v) + + rev.message = elt.findtext('message') # text of <message> + return rev + + + + +"""singleton instance""" +serializer_v4 = _Serializer_v4() + |