Skip to content
Snippets Groups Projects
mercurial_path_pattern.py 13.8 KiB
Newer Older
Marcin Kasperski's avatar
Marcin Kasperski committed
# -*- coding: utf-8 -*-
#
# path pattern: define global path aliases, cloneto command
#
# Copyright (c) 2015-2016 Marcin Kasperski <Marcin.Kasperski@mekk.waw.pl>
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright
#    notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the distribution.
# 3. The name of the author may not be used to endorse or promote products
#    derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
# See README.txt for more details.

"""Define [paths] once and reuse over many repositories.

path_pattern
------------

This extension frees you from defining and maintaining [paths]
individually for every repository.  Instead, you may define general
patterns of how to resolve paths.

For example, write::

    [path_pattern]
    abc.local = ~/abcdevel/{repo}
    abc.remote =  ssh://johny@devel.abc.com/sources/{repo}
    dev.local = ~/sources/{repo}
    dev.remote =  https://tim@devel-department.local/{repo}

and use ``hg push abc`` in any repo kept below ``~/abcdevel`` or ``hg
pull dev`` in any repo below ``~/sources``.

cloneto
-------

The extension defines also ``cloneto`` helper, which clones current
repo to address specified by given path.  It works especially well
together with patterns.  With the example above:

    cd ~/sources/libs
    hg init xyz
    cd xyz
    hg cloneto dev

More information
----------------

For more information, see path_pattern README or
http://bitbucket.org/Mekk/mercurial-path_pattern/
"""

from mercurial import commands, util
from mercurial.i18n import _
import os
import sys
import re


def import_meu():
    """Importing mercurial_extension_utils so it can be found also outside
    Python PATH (support for TortoiseHG/Win and similar setups)"""
    try:
        import mercurial_extension_utils
    except ImportError:
        my_dir = os.path.dirname(__file__)
        sys.path.extend([
            # In the same dir (manual or site-packages after pip)
            my_dir,
            # Developer clone
            os.path.join(os.path.dirname(my_dir), "extension_utils"),
            # Side clone
            os.path.join(os.path.dirname(my_dir), "mercurial-extension_utils"),
        ])
        try:
            import mercurial_extension_utils
        except ImportError:
            raise util.Abort(_("""Can not import mercurial_extension_utils.
Please install this module in Python path.
See Installation chapter in https://bitbucket.org/Mekk/mercurial-dynamic_username/ for details
(and for info about TortoiseHG on Windows, or other bundled Python)."""))
    return mercurial_extension_utils

meu = import_meu()

# pylint:disable=fixme,line-too-long,invalid-name
#   (invalid-name because of ui and cmdtable)

############################################################
# Utility classes and functions
############################################################


class PatternPair(object):
    """
    Represents individual path pattern - pair of local (like
    "~/sources/{path}") and remote (like "ssh://some/where/{path}")
    """

    re_dotted_alias = re.compile(r'^ ([^\.]+) \. .*$', re.VERBOSE)

    def __init__(self, prefix):
        self.prefix = prefix
        match = self.re_dotted_alias.search(prefix)
        if match:
            self.alias = match.group(1)
        else:
            self.alias = prefix
        self.local = None
        self.remote = None
        self.enforce = False

    def lookup_remote(self, local_directory, ui):
        """
        Checks whether local directory matches, if so, returns
        matching remote, if not, returns None.
        """
        value = None
        if self.local and self.remote:
            match = self.local.search(local_directory)
            if match is not None:
                value = self.remote.fill(**match)
                if not value:
                    ui.warn(_("path_pattern: Invalid pattern - markers mismatch between %s.local and %s.remote\n")
                            % (self.prefix, self.prefix))
        return value

    def learn_local(self, path_text, ui):
        """Parse and save local path"""
        self.local = meu.DirectoryPattern(path_text, ui)

    def learn_remote(self, path_text, ui):
        """Parse and save remote path"""
        self.remote = meu.TextFiller(path_text, ui)
        # TODO: validate whether path_text looks like path

    def validate(self, ui):
        """Checks whether both sides are defined and valid. Warns about problems"""
        for name, obj in [("local", self.local),
                          ("remote", self.remote)]:
            if not obj:
                ui.warn(
                    _("path_pattern: Incomplete pattern - missing %s.%s\n") % (self.prefix, name))
                return False
            if not obj.is_valid():
                ui.warn(
                    _("path_pattern: Invalid pattern %s.%s - bad syntax\n") % (self.prefix, name))
                return False
        return True

    def describe(self):
        return _("%s%s\n    local:  %s\n    remote: %s") % (
            self.alias,
            self.enforce and " [*]" or "",
            self.local.pattern_text, self.remote.fill_text)

    _re_lead = re.compile(r'^([^{(]*)')
    _re_fill = re.compile(r'(?: { [^}]* }   |   \( [^)]* \)  )', re.VERBOSE)

    def calc_score(self):
        """Calculates ”strength” of local part of the pattern, used to prioritize
        patterns."""
        match = self._re_lead.search(self.local.pattern_text)
        lead_items = match.group(1).replace('\\', '/').split('/')
        text_without_fillers = self._re_fill.sub('', self.local.pattern_text)
        return (2 if self.enforce else 0, len(lead_items), len(text_without_fillers))


class PathPatterns(object):
    """
    Loads and parses pattern definitions
    """
    def __init__(self, ui):
        rgxp_prefix = r'^(\w+(?:\.[\w0-9-]+)?)\.'
        self.patterns = {}   # prefix → PatternPair
        # Read «sth».local
        for prefix, value in meu.rgxp_config_items(
                ui, "path_pattern", re.compile(rgxp_prefix + "local")):
            if prefix not in self.patterns:
                self.patterns[prefix] = PatternPair(prefix)
            ui.debug(_("path_pattern: Parsing local side of path pattern %s\n") % prefix)
            self.patterns[prefix].learn_local(value, ui)
        # Read «sth».remote
        for prefix, value in meu.rgxp_config_items(
                ui, "path_pattern", re.compile(rgxp_prefix + "remote")):
            if prefix not in self.patterns:
                self.patterns[prefix] = PatternPair(prefix)
            ui.debug(_("path_pattern: Parsing remote side of path pattern %s\n") % prefix)
            self.patterns[prefix].learn_remote(value, ui)
        # Read «sth».enforce
        for prefix, value in meu.rgxp_configbool_items(
                ui, "path_pattern", re.compile(rgxp_prefix + "enforce")):
            if prefix in self.patterns:
                self.patterns[prefix].enforce = value
        # Read «sth».alias
        for prefix, value in meu.rgxp_config_items(
                ui, "path_pattern", re.compile(rgxp_prefix + "alias")):
            if prefix in self.patterns:
                self.patterns[prefix].alias = value
        # Check for incomplete and invalid items
        for prefix in self.patterns.keys()[:]:
            if not self.patterns[prefix].validate(ui):
                del self.patterns[prefix]

    def generate_paths(self, ui, repo):
        """
        Updates ui config with new path's for given repo, generated from patterns
        """
        HGRC_PATH_SCORE = (1,)

        # Support for priority management. This is a mapping
        #    alias → (name, score)
        # where score is a tuple reflecting the priority (first item is
        # 0 for normal patterns, 1 for per-repo paths, 2 for enforced patterns,
        # see calc_score)
        known_aliases = {}

        # Reading known paths to avoid overwriting them (unless enforced).
        for key, value in ui.configitems("paths"):
            known_aliases[key] = (".hg/hgrc in " + repo.root, HGRC_PATH_SCORE)

        # Actually applying new patterns (with respect to prioritization)
        for prefix, pattern_pair in self.patterns.iteritems():
            expanded = pattern_pair.lookup_remote(repo.root, ui)
            if expanded:
                path_alias = pattern_pair.alias
                score = pattern_pair.calc_score()
                if path_alias in known_aliases:
                    prev_name, prev_score = known_aliases[path_alias]
                    if score < prev_score:
                        ui.debug(_("path_pattern: NOT defining path %s as %s (pattern %s is less important than %s)\n") % (path_alias, expanded, prefix, prev_name))
                        continue
                    else:
                        ui.debug(_("path_pattern: Defining path %s as %s (using pattern %s, overriding %s applied earlier as new pattern is stronger\n") % (path_alias, expanded, prefix, prev_name))
                else:
                    ui.debug(_("path_pattern: Defining path %s as %s (using pattern %s)\n") % (path_alias, expanded, prefix))
                known_aliases[path_alias] = (prefix, score)
                ui.setconfig("paths", path_alias, expanded)


    def print_patterns(self, ui, list_repos=False):
        """
        Prints pattern information to standard output
        """
        if self.patterns:
            has_enforced = any(p.enforce for p in self.patterns.values())
            ui.status(_("Defined path patterns:\n%s\n%s") % (
                "\n".join(
                    self.patterns[pname].describe()
                    for pname in sorted(self.patterns.keys())),
                _("[*]-marked patterns are enforced over .hg/hgrc\n") if has_enforced else "",
            ))
        else:
            ui.status(_("No path patterns defined. Add [path_pattern] section to ~/.hgrc\n"))


############################################################
# Mercurial extension hooks
############################################################

patterns = None

# def uisetup(ui):
# Not used, better to load patterns later, config can be updated by plugins


def extsetup(ui):
    """Setup extension: load patterns definitions from config"""
    global patterns    # pylint:disable=global-statement
    patterns = PathPatterns(ui)


def reposetup(ui, repo):
    """Setup repo: add pattern-based paths to repository config"""
    # Checking whether this is local repository, for other types extension
    # is pointless. Unfortunately we can't test repo type, as some extensions
    # change it (for example hgext.git.hgrepo.hgrepo happens to me…)
    if not hasattr(repo, 'root'):
        return
    patterns.generate_paths(ui, repo)


############################################################
# Commands
############################################################

cmdtable = {}
command = meu.command(cmdtable)


@command("list_path_patterns",
         [
             # ('r', 'list-repos', None, 'List repositories matching the pattern'),
         ],
         _("list_path_patterns"),
         norepo=True)
def cmd_list_path_patterns(ui, **opts):
    """
    List all active path patterns.
    """
    patterns.print_patterns(ui)


@command("cloneto",
         [],
         _("cloneto ALIAS"))
def cmd_cloneto(ui, repo, path_alias, **opts):
    """
    Clone current repository to (usually remote) url
    pointed by already defined path alias::

        hg cloneto somealias

    is equivalent to::

        hg clone . <aliaspath>

    where <aliaspath> is whatever somealias expands to
    according to ``hg paths``.

    Command most useful together with path_pattern. 
    """
    known_paths = []
    for key, value in ui.configitems("paths"):
        if key == path_alias:
            ui.status(_("Cloning current repository to %s (resolved from: %s)\n" % (value, path_alias)))
            return commands.clone(ui, source=repo, dest=value)
        else:
            known_paths.append(key)
    # Failing helpfully
    if known_paths:
        raise util.Abort(_("Unknown alias: %s. Defined path aliases: %s") % (
            path_alias, ", ".join(known_paths)))
    else:
        raise util.Abort(_("Uknown alias: %s. No paths defined, consider creating some paths or path_patterns") % path_alias)

############################################################
# Extension setup
############################################################

testedwith = '2.7 2.9 3.0 3.3 3.6 3.7 3.8 4.0'
buglink = 'https://bitbucket.org/Mekk/mercurial-path_pattern/issues'