diff options
-rw-r--r-- | .appveyor.yml | 20 | ||||
-rw-r--r-- | .dockerignore | 2 | ||||
-rw-r--r-- | .travis.yml | 6 | ||||
-rw-r--r-- | AUTHORS | 2 | ||||
-rw-r--r-- | Dockerfile | 80 | ||||
-rw-r--r-- | MANIFEST.in | 4 | ||||
-rw-r--r-- | Makefile | 18 | ||||
-rw-r--r-- | README.md | 2 | ||||
-rw-r--r-- | doc/source/changes.rst | 11 | ||||
-rwxr-xr-x | dockernose.sh | 10 | ||||
-rw-r--r-- | git/cmd.py | 28 | ||||
-rw-r--r-- | git/config.py | 137 | ||||
m--------- | git/ext/gitdb | 0 | ||||
-rw-r--r-- | git/objects/tag.py | 5 | ||||
-rw-r--r-- | git/remote.py | 2 | ||||
-rw-r--r-- | git/repo/base.py | 24 | ||||
-rw-r--r-- | git/test/fixtures/git_config_multiple | 7 | ||||
-rw-r--r-- | git/test/test_config.py | 109 | ||||
-rw-r--r-- | git/test/test_remote.py | 2 | ||||
-rw-r--r-- | git/test/test_repo.py | 16 | ||||
-rw-r--r-- | git/test/test_util.py | 6 | ||||
-rw-r--r-- | git/util.py | 4 | ||||
-rw-r--r-- | requirements.txt | 3 | ||||
-rwxr-xr-x | setup.py | 14 | ||||
-rw-r--r-- | test-requirements.txt | 6 |
25 files changed, 455 insertions, 63 deletions
diff --git a/.appveyor.yml b/.appveyor.yml index 8eeca501..017cf120 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -5,8 +5,6 @@ environment: CYGWIN64_GIT_PATH: "C:\\cygwin64\\bin;%GIT_DAEMON_PATH%" matrix: - ## MINGW - # - PYTHON: "C:\\Python27" PYTHON_VERSION: "2.7" GIT_PATH: "%GIT_DAEMON_PATH%" @@ -16,24 +14,33 @@ environment: - PYTHON: "C:\\Python35-x64" PYTHON_VERSION: "3.5" GIT_PATH: "%GIT_DAEMON_PATH%" + - PYTHON: "C:\\Python36-x64" + PYTHON_VERSION: "3.6" + GIT_PATH: "%GIT_DAEMON_PATH%" + - PYTHON: "C:\\Python37-x64" + PYTHON_VERSION: "3.7" + GIT_PATH: "%GIT_DAEMON_PATH%" - PYTHON: "C:\\Miniconda35-x64" PYTHON_VERSION: "3.5" IS_CONDA: "yes" + MAYFAIL: "yes" GIT_PATH: "%GIT_DAEMON_PATH%" - ## Cygwin - # - PYTHON: "C:\\Miniconda-x64" PYTHON_VERSION: "2.7" IS_CONDA: "yes" IS_CYGWIN: "yes" + MAYFAIL: "yes" GIT_PATH: "%CYGWIN_GIT_PATH%" - PYTHON: "C:\\Python35-x64" PYTHON_VERSION: "3.5" - GIT_PATH: "%CYGWIN64_GIT_PATH%" IS_CYGWIN: "yes" + MAYFAIL: "yes" + GIT_PATH: "%CYGWIN64_GIT_PATH%" - +matrix: + allow_failures: + - MAYFAIL: "yes" install: - set PATH=%PYTHON%;%PYTHON%\Scripts;%GIT_PATH%;%PATH% @@ -51,6 +58,7 @@ install: conda info -a & conda install --yes --quiet pip ) + - pip install -r requirements.txt - pip install -r test-requirements.txt - pip install codecov diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..b59962d2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +.git/ +.tox/ diff --git a/.travis.yml b/.travis.yml index adb693dd..aed714af 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,12 +9,11 @@ matrix: include: - python: 3.7 dist: xenial - sudo: required - python: "nightly" dist: xenial - sudo: required allow_failures: - python: "nightly" + dist: xenial git: # a higher depth is needed for most of the tests - must be high enough to not actually be shallow # as we clone our own repository in the process @@ -40,8 +39,7 @@ script: - ulimit -n 128 - ulimit -n - nosetests -v --with-coverage - - if [ "$TRAVIS_PYTHON_VERSION" == '3.4' ]; then flake8 --ignore=W293,E265,E266,W503,W504,E731; fi - if [ "$TRAVIS_PYTHON_VERSION" == '3.5' ]; then cd doc && make html; fi - - + - if [ "$TRAVIS_PYTHON_VERSION" == '3.6' ]; then flake8 --ignore=W293,E265,E266,W503,W504,E731; fi after_success: - codecov @@ -29,6 +29,8 @@ Contributors are: -Tim Swast <swast _at_ google.com> -William Luc Ritchie -David Host <hostdm _at_ outlook.com> +-A. Jesse Jiryu Davis <jesse _at_ emptysquare.net> +-Steven Whitman <ninloot _at_ gmail.com> -Stefan Stancu <stefan.stancu _at_ gmail.com> Portions derived from other open source works and are clearly marked. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..fc42f18f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,80 @@ +# +# Contributed by: James E. King III (@jeking3) <jking@apache.org> +# +# This Dockerfile creates an Ubuntu Xenial build environment +# that can run the same test suite as Travis CI. +# + +FROM ubuntu:xenial +MAINTAINER James E. King III <jking@apache.org> +ENV CONTAINER_USER=user +ENV DEBIAN_FRONTEND noninteractive + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + add-apt-key \ + apt \ + apt-transport-https \ + apt-utils \ + ca-certificates \ + curl \ + git \ + net-tools \ + openssh-client \ + sudo \ + vim \ + wget + +RUN add-apt-key -v 6A755776 -k keyserver.ubuntu.com && \ + add-apt-key -v E1DF1F24 -k keyserver.ubuntu.com && \ + echo "deb http://ppa.launchpad.net/git-core/ppa/ubuntu xenial main" >> /etc/apt/sources.list && \ + echo "deb http://ppa.launchpad.net/deadsnakes/ppa/ubuntu xenial main" >> /etc/apt/sources.list && \ + apt-get update && \ + apt-get install -y --install-recommends git python2.7 python3.4 python3.5 python3.6 python3.7 && \ + update-alternatives --install /usr/bin/python3 python3 /usr/bin/python2.7 27 && \ + update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.4 34 && \ + update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.5 35 && \ + update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.6 36 && \ + update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.7 37 + +RUN curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py && \ + python3 get-pip.py && \ + pip3 install tox + +# Clean up +RUN rm -rf /var/cache/apt/* && \ + rm -rf /var/lib/apt/lists/* && \ + rm -rf /tmp/* && \ + rm -rf /var/tmp/* + +################################################################# +# Build as a regular user +# Credit: https://github.com/delcypher/docker-ubuntu-cxx-dev/blob/master/Dockerfile +# License: None specified at time of import +# Add non-root user for container but give it sudo access. +# Password is the same as the username +RUN useradd -m ${CONTAINER_USER} && \ + echo ${CONTAINER_USER}:${CONTAINER_USER} | chpasswd && \ + echo "${CONTAINER_USER} ALL=(root) ALL" >> /etc/sudoers +RUN chsh --shell /bin/bash ${CONTAINER_USER} +USER ${CONTAINER_USER} +################################################################# + +# The test suite will not tolerate running against a branch that isn't "master", so +# check out the project to a well-known location that can be used by the test suite. +# This has the added benefit of protecting the local repo fed into the container +# as a volume from getting destroyed by a bug exposed by the test suite. :) +ENV TRAVIS=ON +RUN git clone --recursive https://github.com/gitpython-developers/GitPython.git /home/${CONTAINER_USER}/testrepo && \ + cd /home/${CONTAINER_USER}/testrepo && \ + ./init-tests-after-clone.sh +ENV GIT_PYTHON_TEST_GIT_REPO_BASE=/home/${CONTAINER_USER}/testrepo +ENV TRAVIS= + +# Ensure any local pip installations get on the path +ENV PATH=/home/${CONTAINER_USER}/.local/bin:${PATH} + +# Set the global default git user to be someone non-descript +RUN git config --global user.email ci@gitpython.org && \ + git config --global user.name "GitPython CI User" + diff --git a/MANIFEST.in b/MANIFEST.in index 15ac959e..e6bf5249 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,8 +2,10 @@ include VERSION include LICENSE include CHANGES include AUTHORS -include README +include CONTRIBUTING.md +include README.md include requirements.txt +include test-requirements.txt recursive-include doc * @@ -1,8 +1,10 @@ +.PHONY: all clean release force_release docker-build test nose-pdb + all: @grep -Ee '^[a-z].*:' Makefile | cut -d: -f1 | grep -vF all clean: - rm -rf build/ dist/ + rm -rf build/ dist/ .eggs/ .tox/ release: clean # Check if latest tag is the current head we're releasing @@ -16,3 +18,17 @@ force_release: clean git push --tags origin master python3 setup.py sdist bdist_wheel twine upload -s -i byronimo@gmail.com dist/* + +docker-build: + docker build --quiet -t gitpython:xenial -f Dockerfile . + +test: docker-build + # NOTE!!! + # NOTE!!! If you are not running from master or have local changes then tests will fail + # NOTE!!! + docker run --rm -v ${CURDIR}:/src -w /src -t gitpython:xenial tox + +nose-pdb: docker-build + # run tests under nose and break on error or failure into python debugger + # HINT: set PYVER to "pyXX" to change from the default of py37 to pyXX for nose tests + docker run --rm --env PYVER=${PYVER} -v ${CURDIR}:/src -w /src -it gitpython:xenial /bin/bash dockernose.sh @@ -192,8 +192,10 @@ New BSD License. See the LICENSE file. [](https://codeclimate.com/github/gitpython-developers/GitPython) [](https://readthedocs.org/projects/gitpython/?badge=stable) [](https://waffle.io/gitpython-developers/GitPython) +[](https://repology.org/metapackage/python:gitpython/versions) [](https://waffle.io/gitpython-developers/GitPython/metrics/throughput) + Now that there seems to be a massive user base, this should be motivation enough to let git-python return to a proper state, which means diff --git a/doc/source/changes.rst b/doc/source/changes.rst index 32e58c7f..92c28b69 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -2,6 +2,17 @@ Changelog ========= +2.1.12 - Bugfixes and Features +============================== + +* Multi-value support and interface improvements for Git configuration. Thanks to A. Jesse Jiryu Davis. + +see the following for (most) details: +https://github.com/gitpython-developers/gitpython/milestone/27?closed=1 + +or run have a look at the difference between tags v2.1.11 and v2.1.12: +https://github.com/gitpython-developers/GitPython/compare/2.1.11...2.1.12 + 2.1.11 - Bugfixes ================= diff --git a/dockernose.sh b/dockernose.sh new file mode 100755 index 00000000..c9227118 --- /dev/null +++ b/dockernose.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -ex +if [ -z "${PYVER}" ]; then + PYVER=py37 +fi + +# remember to use "-s" if you inject pdb.set_trace() as this disables nosetests capture of streams + +tox -e ${PYVER} --notest +PYTHONPATH=/src/.tox/${PYVER}/lib/python*/site-packages /src/.tox/${PYVER}/bin/nosetests --pdb $* @@ -43,6 +43,10 @@ from .util import ( stream_copy, ) +try: + PermissionError +except NameError: # Python < 3.3 + PermissionError = OSError execute_kwargs = {'istream', 'with_extended_output', 'with_exceptions', 'as_process', 'stdout_as_string', @@ -211,22 +215,15 @@ class Git(LazyMixin): # test if the new git executable path is valid - if sys.version_info < (3,): - # - a GitCommandNotFound error is spawned by ourselves - # - a OSError is spawned if the git executable provided - # cannot be executed for whatever reason - exceptions = (GitCommandNotFound, OSError) - else: - # - a GitCommandNotFound error is spawned by ourselves - # - a PermissionError is spawned if the git executable provided - # cannot be executed for whatever reason - exceptions = (GitCommandNotFound, PermissionError) - + # - a GitCommandNotFound error is spawned by ourselves + # - a PermissionError is spawned if the git executable provided + # cannot be executed for whatever reason + has_git = False try: cls().version() has_git = True - except exceptions: + except (GitCommandNotFound, PermissionError): pass # warn or raise exception if test failed @@ -718,8 +715,11 @@ class Git(LazyMixin): stdout_sink = (PIPE if with_stdout else getattr(subprocess, 'DEVNULL', None) or open(os.devnull, 'wb')) - log.debug("Popen(%s, cwd=%s, universal_newlines=%s, shell=%s)", - command, cwd, universal_newlines, shell) + istream_ok = "None" + if istream: + istream_ok = "<valid stream>" + log.debug("Popen(%s, cwd=%s, universal_newlines=%s, shell=%s, istream=%s)", + command, cwd, universal_newlines, shell, istream_ok) try: proc = Popen(command, env=env, diff --git a/git/config.py b/git/config.py index 68d65ae9..b03d9d42 100644 --- a/git/config.py +++ b/git/config.py @@ -146,6 +146,51 @@ class SectionConstraint(object): self._config.__exit__(exception_type, exception_value, traceback) +class _OMD(OrderedDict): + """Ordered multi-dict.""" + + def __setitem__(self, key, value): + super(_OMD, self).__setitem__(key, [value]) + + def add(self, key, value): + if key not in self: + super(_OMD, self).__setitem__(key, [value]) + return + + super(_OMD, self).__getitem__(key).append(value) + + def setall(self, key, values): + super(_OMD, self).__setitem__(key, values) + + def __getitem__(self, key): + return super(_OMD, self).__getitem__(key)[-1] + + def getlast(self, key): + return super(_OMD, self).__getitem__(key)[-1] + + def setlast(self, key, value): + if key not in self: + super(_OMD, self).__setitem__(key, [value]) + return + + prior = super(_OMD, self).__getitem__(key) + prior[-1] = value + + def get(self, key, default=None): + return super(_OMD, self).get(key, [default])[-1] + + def getall(self, key): + return super(_OMD, self).__getitem__(key) + + def items(self): + """List of (key, last value for key).""" + return [(k, self[k]) for k in self] + + def items_all(self): + """List of (key, list of values for key).""" + return [(k, self.getall(k)) for k in self] + + class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, object)): """Implements specifics required to read git style configuration files. @@ -200,7 +245,7 @@ class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, obje contents into ours. This makes it impossible to write back an individual configuration file. Thus, if you want to modify a single configuration file, turn this off to leave the original dataset unaltered when reading it.""" - cp.RawConfigParser.__init__(self, dict_type=OrderedDict) + cp.RawConfigParser.__init__(self, dict_type=_OMD) # Used in python 3, needs to stay in sync with sections for underlying implementation to work if not hasattr(self, '_proxies'): @@ -348,7 +393,8 @@ class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, obje is_multi_line = True optval = string_decode(optval[1:]) # end handle multi-line - cursect[optname] = optval + # preserves multiple values for duplicate optnames + cursect.add(optname, optval) else: # check if it's an option with no value - it's just ignored by git if not self.OPTVALUEONLY.match(line): @@ -362,7 +408,8 @@ class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, obje is_multi_line = False line = line[:-1] # end handle quotations - cursect[optname] += string_decode(line) + optval = cursect.getlast(optname) + cursect.setlast(optname, optval + string_decode(line)) # END parse section or option # END while reading @@ -442,9 +489,12 @@ class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, obje git compatible format""" def write_section(name, section_dict): fp.write(("[%s]\n" % name).encode(defenc)) - for (key, value) in section_dict.items(): - if key != "__name__": - fp.write(("\t%s = %s\n" % (key, self._value_to_string(value).replace('\n', '\n\t'))).encode(defenc)) + for (key, values) in section_dict.items_all(): + if key == "__name__": + continue + + for v in values: + fp.write(("\t%s = %s\n" % (key, self._value_to_string(v).replace('\n', '\n\t'))).encode(defenc)) # END if key is not __name__ # END section writing @@ -457,6 +507,22 @@ class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, obje """:return: list((option, value), ...) pairs of all items in the given section""" return [(k, v) for k, v in super(GitConfigParser, self).items(section_name) if k != '__name__'] + def items_all(self, section_name): + """:return: list((option, [values...]), ...) pairs of all items in the given section""" + rv = _OMD(self._defaults) + + for k, vs in self._sections[section_name].items_all(): + if k == '__name__': + continue + + if k in rv and rv.getall(k) == vs: + continue + + for v in vs: + rv.add(k, v) + + return rv.items_all() + @needs_values def write(self): """Write changes to our file, if there are changes at all @@ -508,7 +574,11 @@ class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, obje return self._read_only def get_value(self, section, option, default=None): - """ + """Get an option's value. + + If multiple values are specified for this option in the section, the + last one specified is returned. + :param default: If not None, the given default value will be returned in case the option did not exist @@ -523,6 +593,31 @@ class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, obje return default raise + return self._string_to_value(valuestr) + + def get_values(self, section, option, default=None): + """Get an option's values. + + If multiple values are specified for this option in the section, all are + returned. + + :param default: + If not None, a list containing the given default value will be + returned in case the option did not exist + :return: a list of properly typed values, either int, float or string + + :raise TypeError: in case the value could not be understood + Otherwise the exceptions known to the ConfigParser will be raised.""" + try: + lst = self._sections[section].getall(option) + except Exception: + if default is not None: + return [default] + raise + + return [self._string_to_value(valuestr) for valuestr in lst] + + def _string_to_value(self, valuestr): types = (int, float) for numtype in types: try: @@ -545,7 +640,9 @@ class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, obje return True if not isinstance(valuestr, string_types): - raise TypeError("Invalid value type: only int, long, float and str are allowed", valuestr) + raise TypeError( + "Invalid value type: only int, long, float and str are allowed", + valuestr) return valuestr @@ -572,6 +669,25 @@ class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, obje self.set(section, option, self._value_to_string(value)) return self + @needs_values + @set_dirty_and_flush_changes + def add_value(self, section, option, value): + """Adds a value for the given option in section. + It will create the section if required, and will not throw as opposed to the default + ConfigParser 'set' method. The value becomes the new value of the option as returned + by 'get_value', and appends to the list of values returned by 'get_values`'. + + :param section: Name of the section in which the option resides or should reside + :param option: Name of the option + + :param value: Value to add to option. It must be a string or convertible + to a string + :return: this instance""" + if not self.has_section(section): + self.add_section(section) + self._sections[section].add(option, self._value_to_string(value)) + return self + def rename_section(self, section, new_name): """rename the given section to new_name :raise ValueError: if section doesn't exit @@ -584,8 +700,9 @@ class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, obje raise ValueError("Destination section '%s' already exists" % new_name) super(GitConfigParser, self).add_section(new_name) - for k, v in self.items(section): - self.set(new_name, k, self._value_to_string(v)) + new_section = self._sections[new_name] + for k, vs in self.items_all(section): + new_section.setall(k, vs) # end for each value to copy # This call writes back the changes, which is why we don't have the respective decorator diff --git a/git/ext/gitdb b/git/ext/gitdb -Subproject 3e71833044718f60be36ac47494668469a2a235 +Subproject c0fd43b5ff8c356fcf9cdebbbbd1803a502b465 diff --git a/git/objects/tag.py b/git/objects/tag.py index 19cb04bf..4295a03a 100644 --- a/git/objects/tag.py +++ b/git/objects/tag.py @@ -59,8 +59,9 @@ class TagObject(base.Object): self.tag = lines[2][4:] # tag <tag name> - tagger_info = lines[3] # tagger <actor> <date> - self.tagger, self.tagged_date, self.tagger_tz_offset = parse_actor_and_date(tagger_info) + if len(lines) > 3: + tagger_info = lines[3] # tagger <actor> <date> + self.tagger, self.tagged_date, self.tagger_tz_offset = parse_actor_and_date(tagger_info) # line 4 empty - it could mark the beginning of the next header # in case there really is no message, it would not exist. Otherwise diff --git a/git/remote.py b/git/remote.py index 8c28e636..4f32540f 100644 --- a/git/remote.py +++ b/git/remote.py @@ -694,6 +694,8 @@ class Remote(LazyMixin, Iterable): msg += "Will ignore extra progress lines or fetch head lines." msg %= (l_fil, l_fhi) log.debug(msg) + log.debug("info lines: " + str(fetch_info_lines)) + log.debug("head info : " + str(fetch_head_info)) if l_fil < l_fhi: fetch_head_info = fetch_head_info[:l_fil] else: diff --git a/git/repo/base.py b/git/repo/base.py index 58f11e51..f3587080 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -559,11 +559,11 @@ class Repo(object): return res def is_ancestor(self, ancestor_rev, rev): - """Check if a commit is an ancestor of another + """Check if a commit is an ancestor of another :param ancestor_rev: Rev which should be an ancestor :param rev: Rev to test against ancestor_rev - :return: ``True``, ancestor_rev is an accestor to rev. + :return: ``True``, ancestor_rev is an ancestor to rev. """ try: self.git.merge_base(ancestor_rev, rev, is_ancestor=True) @@ -931,7 +931,7 @@ class Repo(object): return cls(path, odbt=odbt) @classmethod - def _clone(cls, git, url, path, odb_default_type, progress, **kwargs): + def _clone(cls, git, url, path, odb_default_type, progress, multi_options=None, **kwargs): if progress is not None: progress = to_progress_instance(progress) @@ -953,7 +953,10 @@ class Repo(object): sep_dir = kwargs.get('separate_git_dir') if sep_dir: kwargs['separate_git_dir'] = Git.polish_url(sep_dir) - proc = git.clone(Git.polish_url(url), clone_path, with_extended_output=True, as_process=True, + multi = None + if multi_options: + multi = ' '.join(multi_options).split(' ') + proc = git.clone(multi, Git.polish_url(url), clone_path, with_extended_output=True, as_process=True, v=True, universal_newlines=True, **add_progress(kwargs, git, progress)) if progress: handle_process_output(proc, None, progress.new_message_handler(), finalize_process, decode_streams=False) @@ -983,33 +986,38 @@ class Repo(object): # END handle remote repo return repo - def clone(self, path, progress=None, **kwargs): + def clone(self, path, progress=None, multi_options=None, **kwargs): """Create a clone from this repository. :param path: is the full path of the new repo (traditionally ends with ./<name>.git). :param progress: See 'git.remote.Remote.push'. + :param multi_options: A list of Clone options that can be provided multiple times. One + option per list item which is passed exactly as specified to clone. + For example ['--config core.filemode=false', '--config core.ignorecase', + '--recurse-submodule=repo1_path', '--recurse-submodule=repo2_path'] :param kwargs: * odbt = ObjectDatabase Type, allowing to determine the object database implementation used by the returned Repo instance * All remaining keyword arguments are given to the git-clone command :return: ``git.Repo`` (the newly cloned repo)""" - return self._clone(self.git, self.common_dir, path, type(self.odb), progress, **kwargs) + return self._clone(self.git, self.common_dir, path, type(self.odb), progress, multi_options, **kwargs) @classmethod - def clone_from(cls, url, to_path, progress=None, env=None, **kwargs): + def clone_from(cls, url, to_path, progress=None, env=None, multi_options=None, **kwargs): """Create a clone from the given URL :param url: valid git url, see http://www.kernel.org/pub/software/scm/git/docs/git-clone.html#URLS :param to_path: Path to which the repository should be cloned to :param progress: See 'git.remote.Remote.push'. :param env: Optional dictionary containing the desired environment variables. + :param mutli_options: See ``clone`` method :param kwargs: see the ``clone`` method :return: Repo instance pointing to the cloned directory""" git = Git(os.getcwd()) if env is not None: git.update_environment(**env) - return cls._clone(git, url, to_path, GitCmdObjectDB, progress, **kwargs) + return cls._clone(git, url, to_path, GitCmdObjectDB, progress, multi_options, **kwargs) def archive(self, ostream, treeish=None, prefix=None, **kwargs): """Archive the tree at the given revision. diff --git a/git/test/fixtures/git_config_multiple b/git/test/fixtures/git_config_multiple new file mode 100644 index 00000000..03a97568 --- /dev/null +++ b/git/test/fixtures/git_config_multiple @@ -0,0 +1,7 @@ +[section0] + option0 = value0 + +[section1] + option1 = value1a + option1 = value1b + other_option1 = other_value1 diff --git a/git/test/test_config.py b/git/test/test_config.py index 4d6c8236..93f94748 100644 --- a/git/test/test_config.py +++ b/git/test/test_config.py @@ -11,7 +11,7 @@ from git import ( GitConfigParser ) from git.compat import string_types -from git.config import cp +from git.config import _OMD, cp from git.test.lib import ( TestCase, fixture_path, @@ -265,3 +265,110 @@ class TestBase(TestCase): with self.assertRaises(cp.NoOptionError): cr.get_value('color', 'ui') + + def test_multiple_values(self): + file_obj = self._to_memcache(fixture_path('git_config_multiple')) + with GitConfigParser(file_obj, read_only=False) as cw: + self.assertEqual(cw.get('section0', 'option0'), 'value0') + self.assertEqual(cw.get_values('section0', 'option0'), ['value0']) + self.assertEqual(cw.items('section0'), [('option0', 'value0')]) + + # Where there are multiple values, "get" returns the last. + self.assertEqual(cw.get('section1', 'option1'), 'value1b') + self.assertEqual(cw.get_values('section1', 'option1'), + ['value1a', 'value1b']) + self.assertEqual(cw.items('section1'), + [('option1', 'value1b'), + ('other_option1', 'other_value1')]) + self.assertEqual(cw.items_all('section1'), + [('option1', ['value1a', 'value1b']), + ('other_option1', ['other_value1'])]) + with self.assertRaises(KeyError): + cw.get_values('section1', 'missing') + + self.assertEqual(cw.get_values('section1', 'missing', 1), [1]) + self.assertEqual(cw.get_values('section1', 'missing', 's'), ['s']) + + def test_multiple_values_rename(self): + file_obj = self._to_memcache(fixture_path('git_config_multiple')) + with GitConfigParser(file_obj, read_only=False) as cw: + cw.rename_section('section1', 'section2') + cw.write() + file_obj.seek(0) + cr = GitConfigParser(file_obj, read_only=True) + self.assertEqual(cr.get_value('section2', 'option1'), 'value1b') + self.assertEqual(cr.get_values('section2', 'option1'), + ['value1a', 'value1b']) + self.assertEqual(cr.items('section2'), + [('option1', 'value1b'), + ('other_option1', 'other_value1')]) + self.assertEqual(cr.items_all('section2'), + [('option1', ['value1a', 'value1b']), + ('other_option1', ['other_value1'])]) + + def test_multiple_to_single(self): + file_obj = self._to_memcache(fixture_path('git_config_multiple')) + with GitConfigParser(file_obj, read_only=False) as cw: + cw.set_value('section1', 'option1', 'value1c') + + cw.write() + file_obj.seek(0) + cr = GitConfigParser(file_obj, read_only=True) + self.assertEqual(cr.get_value('section1', 'option1'), 'value1c') + self.assertEqual(cr.get_values('section1', 'option1'), ['value1c']) + self.assertEqual(cr.items('section1'), + [('option1', 'value1c'), + ('other_option1', 'other_value1')]) + self.assertEqual(cr.items_all('section1'), + [('option1', ['value1c']), + ('other_option1', ['other_value1'])]) + + def test_single_to_multiple(self): + file_obj = self._to_memcache(fixture_path('git_config_multiple')) + with GitConfigParser(file_obj, read_only=False) as cw: + cw.add_value('section1', 'other_option1', 'other_value1a') + + cw.write() + file_obj.seek(0) + cr = GitConfigParser(file_obj, read_only=True) + self.assertEqual(cr.get_value('section1', 'option1'), 'value1b') + self.assertEqual(cr.get_values('section1', 'option1'), + ['value1a', 'value1b']) + self.assertEqual(cr.get_value('section1', 'other_option1'), + 'other_value1a') + self.assertEqual(cr.get_values('section1', 'other_option1'), + ['other_value1', 'other_value1a']) + self.assertEqual(cr.items('section1'), + [('option1', 'value1b'), + ('other_option1', 'other_value1a')]) + self.assertEqual( + cr.items_all('section1'), + [('option1', ['value1a', 'value1b']), + ('other_option1', ['other_value1', 'other_value1a'])]) + + def test_add_to_multiple(self): + file_obj = self._to_memcache(fixture_path('git_config_multiple')) + with GitConfigParser(file_obj, read_only=False) as cw: + cw.add_value('section1', 'option1', 'value1c') + cw.write() + file_obj.seek(0) + cr = GitConfigParser(file_obj, read_only=True) + self.assertEqual(cr.get_value('section1', 'option1'), 'value1c') + self.assertEqual(cr.get_values('section1', 'option1'), + ['value1a', 'value1b', 'value1c']) + self.assertEqual(cr.items('section1'), + [('option1', 'value1c'), + ('other_option1', 'other_value1')]) + self.assertEqual(cr.items_all('section1'), + [('option1', ['value1a', 'value1b', 'value1c']), + ('other_option1', ['other_value1'])]) + + def test_setlast(self): + # Test directly, not covered by higher-level tests. + omd = _OMD() + omd.setlast('key', 'value1') + self.assertEqual(omd['key'], 'value1') + self.assertEqual(omd.getall('key'), ['value1']) + omd.setlast('key', 'value2') + self.assertEqual(omd['key'], 'value2') + self.assertEqual(omd.getall('key'), ['value2']) diff --git a/git/test/test_remote.py b/git/test/test_remote.py index f3a214cb..99949b9e 100644 --- a/git/test/test_remote.py +++ b/git/test/test_remote.py @@ -638,7 +638,7 @@ class TestRemote(TestBase): def test_fetch_error(self): rem = self.rorepo.remote('origin') - with self.assertRaisesRegex(GitCommandError, "Couldn't find remote ref __BAD_REF__"): + with self.assertRaisesRegex(GitCommandError, "[Cc]ouldn't find remote ref __BAD_REF__"): rem.fetch('__BAD_REF__') @with_rw_repo('0.1.6', bare=False) diff --git a/git/test/test_repo.py b/git/test/test_repo.py index 7fc49f3b..0577bd58 100644 --- a/git/test/test_repo.py +++ b/git/test/test_repo.py @@ -229,6 +229,22 @@ class TestRepo(TestBase): Repo.clone_from(original_repo.git_dir, pathlib.Path(rw_dir) / "clone_pathlib") + @with_rw_directory + def test_clone_from_pathlib_withConfig(self, rw_dir): + if pathlib is None: # pythons bellow 3.4 don't have pathlib + raise SkipTest("pathlib was introduced in 3.4") + + original_repo = Repo.init(osp.join(rw_dir, "repo")) + + cloned = Repo.clone_from(original_repo.git_dir, pathlib.Path(rw_dir) / "clone_pathlib_withConfig", + multi_options=["--recurse-submodules=repo", + "--config core.filemode=false", + "--config submodule.repo.update=checkout"]) + + assert_equal(cloned.config_reader().get_value('submodule', 'active'), 'repo') + assert_equal(cloned.config_reader().get_value('core', 'filemode'), False) + assert_equal(cloned.config_reader().get_value('submodule "repo"', 'update'), 'checkout') + @with_rw_repo('HEAD') def test_max_chunk_size(self, repo): class TestOutputStream(object): diff --git a/git/test/test_util.py b/git/test/test_util.py index 9c993205..b5f9d222 100644 --- a/git/test/test_util.py +++ b/git/test/test_util.py @@ -212,6 +212,12 @@ class TestUtils(TestBase): self.assertIsInstance(Actor.author(cr), Actor) # END assure config reader is handled + def test_actor_from_string(self): + self.assertEqual(Actor._from_string("name"), Actor("name", None)) + self.assertEqual(Actor._from_string("name <>"), Actor("name", "")) + self.assertEqual(Actor._from_string("name last another <some-very-long-email@example.com>"), + Actor("name last another", "some-very-long-email@example.com")) + @ddt.data(('name', ''), ('name', 'prefix_')) def test_iterable_list(self, case): name, prefix = case diff --git a/git/util.py b/git/util.py index a3b1fbfb..3ba58857 100644 --- a/git/util.py +++ b/git/util.py @@ -534,8 +534,8 @@ class Actor(object): can be committers and authors or anything with a name and an email as mentioned in the git log entries.""" # PRECOMPILED REGEX - name_only_regex = re.compile(r'<(.+)>') - name_email_regex = re.compile(r'(.*) <(.+?)>') + name_only_regex = re.compile(r'<(.*)>') + name_email_regex = re.compile(r'(.*) <(.*?)>') # ENVIRONMENT VARIABLES # read when creating new commits diff --git a/requirements.txt b/requirements.txt index 39644606..63d5ddfe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ -gitdb>=0.6.4 -ddt>=1.1.1 +gitdb2 (>=2.0.0) @@ -19,6 +19,9 @@ with open(path.join(path.dirname(__file__), 'VERSION')) as v: with open('requirements.txt') as reqs_file: requirements = reqs_file.read().splitlines() +with open('test-requirements.txt') as reqs_file: + test_requirements = reqs_file.read().splitlines() + class build_py(_build_py): @@ -63,10 +66,6 @@ def _stamp_version(filename): print("WARNING: Couldn't find version line in file %s" % filename, file=sys.stderr) -install_requires = ['gitdb2 >= 2.0.0'] -test_requires = ['ddt>=1.1.1'] -# end - setup( name="GitPython", cmdclass={'build_py': build_py, 'sdist': sdist}, @@ -81,9 +80,8 @@ setup( package_dir={'git': 'git'}, license="BSD License", python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', - requires=['gitdb2 (>=2.0.0)'], - install_requires=install_requires, - test_requirements=test_requires + install_requires, + install_requires=requirements, + tests_require=requirements + test_requirements, zip_safe=False, long_description="""GitPython is a python library used to interact with Git repositories""", classifiers=[ @@ -110,6 +108,6 @@ setup( "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.7" ] ) diff --git a/test-requirements.txt b/test-requirements.txt index 1cea3aa2..ec0e4c56 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,6 +1,6 @@ --r requirements.txt - +ddt>=1.1.1 coverage flake8 nose -mock; python_version=='2.7'
\ No newline at end of file +tox +mock; python_version=='2.7' |