commit 6324a262f6030f58bca298b384da0fbd41ba3bbc from: Jelmer Vernooij date: Thu Jun 30 18:37:37 2022 UTC Import upstream version 0.20.44 commit - 1d60719c4c8d7806c7b2df74340c6c29d9443541 commit + 6324a262f6030f58bca298b384da0fbd41ba3bbc blob - 4263bf409b25caea88ee8477d865f9009b26ea69 blob + 956ea765820765587c72d393dd007aa4fb95592a --- NEWS +++ NEWS @@ -1,3 +1,10 @@ +0.20.44 2022-06-30 + + * Fix reading of chunks in server. (Jelmer Vernooij, #977) + + * Support applying of URL rewriting using ``insteadOf`` / ``pushInsteadOf``. + (Jelmer Vernooij, #706) + 0.20.43 2022-06-07 * Lazily import url2pathname. blob - 5daca8bf089b001f523873510c0fc3ee5bffe1ac blob + 3de0b5b9f6f458c21bdbe15bb6a586b4bbcc5588 --- PKG-INFO +++ PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: dulwich -Version: 0.20.43 +Version: 0.20.44 Summary: Python Git Library Home-page: https://www.dulwich.io/ Author: Jelmer Vernooij blob - 1645488ae85bca0627e86cebf0a6397d1fcda0f0 blob + 4544902ed0a6ad3a3d904cd6c64d39e9cb72860f --- dulwich/__init__.py +++ dulwich/__init__.py @@ -22,4 +22,4 @@ """Python implementation of the Git file formats and protocols.""" -__version__ = (0, 20, 43) +__version__ = (0, 20, 44) blob - 168e28f0da1198afd1fbb89e87ebedda13f17406 blob + 19f540e69893a800afb90122765f0e02d003faf1 --- dulwich/client.py +++ dulwich/client.py @@ -46,7 +46,7 @@ import select import socket import subprocess import sys -from typing import Any, Callable, Dict, List, Optional, Set, Tuple, IO +from typing import Any, Callable, Dict, List, Optional, Set, Tuple, IO, Iterable from urllib.parse import ( quote as urlquote, @@ -59,7 +59,7 @@ from urllib.parse import ( import dulwich -from dulwich.config import get_xdg_config_home_path +from dulwich.config import get_xdg_config_home_path, Config from dulwich.errors import ( GitProtocolError, NotGitRepository, @@ -2181,7 +2181,7 @@ class Urllib3HttpGitClient(AbstractHttpGitClient): if username is not None: # No escaping needed: ":" is not allowed in username: # https://tools.ietf.org/html/rfc2617#section-2 - credentials = "%s:%s" % (username, password) + credentials = f"{username}:{password or ''}" import urllib3.util basic_auth = urllib3.util.make_headers(basic_auth=credentials) @@ -2268,12 +2268,49 @@ def _win32_url_to_path(parsed) -> str: return url2pathname(netloc + path) # type: ignore -def get_transport_and_path_from_url(url, config=None, **kwargs): +def iter_instead_of(config: Config, push: bool = False) -> Iterable[Tuple[str, str]]: + """Iterate over insteadOf / pushInsteadOf values. + """ + for section in config.sections(): + if section[0] != b'url': + continue + replacement = section[1] + try: + needles = list(config.get_multivar(section, "insteadOf")) + except KeyError: + needles = [] + if push: + try: + needles += list(config.get_multivar(section, "pushInsteadOf")) + except KeyError: + pass + for needle in needles: + yield needle.decode('utf-8'), replacement.decode('utf-8') + + +def apply_instead_of(config: Config, orig_url: str, push: bool = False) -> str: + """Apply insteadOf / pushInsteadOf to a URL. + """ + longest_needle = "" + updated_url = orig_url + for needle, replacement in iter_instead_of(config, push): + if not orig_url.startswith(needle): + continue + if len(longest_needle) < len(needle): + longest_needle = needle + updated_url = replacement + orig_url[len(needle):] + return updated_url + + +def get_transport_and_path_from_url( + url: str, config: Optional[Config] = None, + operation: Optional[str] = None, **kwargs) -> Tuple[GitClient, str]: """Obtain a git client from a URL. Args: url: URL to open (a unicode string) config: Optional config object + operation: Kind of operation that'll be performed; "pull" or "push" thin_packs: Whether or not thin packs should be retrieved report_activity: Optional callback for reporting transport activity. @@ -2282,6 +2319,8 @@ def get_transport_and_path_from_url(url, config=None, Tuple with client instance and relative path. """ + if config is not None: + url = apply_instead_of(config, url, push=(operation == "push")) parsed = urlparse(url) if parsed.scheme == "git": return (TCPGitClient.from_parsedurl(parsed, **kwargs), parsed.path) @@ -2303,7 +2342,7 @@ def get_transport_and_path_from_url(url, config=None, raise ValueError("unknown scheme '%s'" % parsed.scheme) -def parse_rsync_url(location): +def parse_rsync_url(location: str) -> Tuple[Optional[str], str, str]: """Parse a rsync-style URL.""" if ":" in location and "@" not in location: # SSH with no user@, zero or one leading slash. @@ -2324,6 +2363,7 @@ def parse_rsync_url(location): def get_transport_and_path( location: str, + operation: Optional[str] = None, **kwargs: Any ) -> Tuple[GitClient, str]: """Obtain a git client from a URL. @@ -2331,6 +2371,7 @@ def get_transport_and_path( Args: location: URL or path (a string) config: Optional config object + operation: Kind of operation that'll be performed; "pull" or "push" thin_packs: Whether or not thin packs should be retrieved report_activity: Optional callback for reporting transport activity. @@ -2341,7 +2382,7 @@ def get_transport_and_path( """ # First, try to parse it as a URL try: - return get_transport_and_path_from_url(location, **kwargs) + return get_transport_and_path_from_url(location, operation=operation, **kwargs) except ValueError: pass blob - 4ceecab182fd7d58ab8dab0dd4d0d18371d75afc blob + e52d468e3bc66f6fa295218061a2656e207ac800 --- dulwich/config.py +++ dulwich/config.py @@ -29,16 +29,17 @@ TODO: import os import sys import warnings - from typing import ( BinaryIO, Iterable, Iterator, KeysView, + List, MutableMapping, Optional, Tuple, Union, + overload, ) from dulwich.file import GitFile @@ -137,14 +138,22 @@ class CaseInsensitiveOrderedMultiDict(MutableMapping): return self[key] +Name = bytes +NameLike = Union[bytes, str] +Section = Tuple[bytes, ...] +SectionLike = Union[bytes, str, Tuple[Union[bytes, str], ...]] +Value = bytes +ValueLike = Union[bytes, str] + + class Config(object): """A Git configuration.""" - def get(self, section, name): + def get(self, section: SectionLike, name: NameLike) -> Value: """Retrieve the contents of a configuration setting. Args: - section: Tuple with section name and optional subsection namee + section: Tuple with section name and optional subsection name name: Variable name Returns: Contents of the setting @@ -153,7 +162,7 @@ class Config(object): """ raise NotImplementedError(self.get) - def get_multivar(self, section, name): + def get_multivar(self, section: SectionLike, name: NameLike) -> Iterator[Value]: """Retrieve the contents of a multivar configuration setting. Args: @@ -166,7 +175,17 @@ class Config(object): """ raise NotImplementedError(self.get_multivar) - def get_boolean(self, section, name, default=None): + @overload + def get_boolean(self, section: SectionLike, name: NameLike, default: bool) -> bool: + ... + + @overload + def get_boolean(self, section: SectionLike, name: NameLike) -> Optional[bool]: + ... + + def get_boolean( + self, section: SectionLike, name: NameLike, default: Optional[bool] = None + ) -> Optional[bool]: """Retrieve a configuration setting as boolean. Args: @@ -175,8 +194,6 @@ class Config(object): subsection. Returns: Contents of the setting - Raises: - KeyError: if the value is not set """ try: value = self.get(section, name) @@ -188,7 +205,12 @@ class Config(object): return False raise ValueError("not a valid boolean string: %r" % value) - def set(self, section, name, value): + def set( + self, + section: SectionLike, + name: NameLike, + value: Union[ValueLike, bool] + ) -> None: """Set a configuration value. Args: @@ -199,7 +221,7 @@ class Config(object): """ raise NotImplementedError(self.set) - def items(self, section): + def items(self, section: SectionLike) -> Iterator[Tuple[Name, Value]]: """Iterate over the configuration pairs for a specific section. Args: @@ -209,7 +231,7 @@ class Config(object): """ raise NotImplementedError(self.items) - def iteritems(self, section): + def iteritems(self, section: SectionLike) -> Iterator[Tuple[Name, Value]]: """Iterate over the configuration pairs for a specific section. Args: @@ -224,7 +246,7 @@ class Config(object): ) return self.items(section) - def itersections(self): + def itersections(self) -> Iterator[Section]: warnings.warn( "Use %s.items instead." % type(self).__name__, DeprecationWarning, @@ -232,14 +254,14 @@ class Config(object): ) return self.sections() - def sections(self): + def sections(self) -> Iterator[Section]: """Iterate over the sections. Returns: Iterator over section tuples """ raise NotImplementedError(self.sections) - def has_section(self, name: Tuple[bytes, ...]) -> bool: + def has_section(self, name: Section) -> bool: """Check if a specified section exists. Args: @@ -250,20 +272,13 @@ class Config(object): return name in self.sections() -BytesLike = Union[bytes, str] -Key = Tuple[bytes, ...] -KeyLike = Union[bytes, str, Tuple[BytesLike, ...]] -Value = Union[bytes, bool] -ValueLike = Union[bytes, str, bool] - - -class ConfigDict(Config, MutableMapping[Key, MutableMapping[bytes, Value]]): +class ConfigDict(Config, MutableMapping[Section, MutableMapping[Name, Value]]): """Git configuration stored in a dictionary.""" def __init__( self, values: Union[ - MutableMapping[Key, MutableMapping[bytes, Value]], None + MutableMapping[Section, MutableMapping[Name, Value]], None ] = None, encoding: Union[str, None] = None ) -> None: @@ -279,27 +294,27 @@ class ConfigDict(Config, MutableMapping[Key, MutableMa def __eq__(self, other: object) -> bool: return isinstance(other, self.__class__) and other._values == self._values - def __getitem__(self, key: Key) -> MutableMapping[bytes, Value]: + def __getitem__(self, key: Section) -> MutableMapping[Name, Value]: return self._values.__getitem__(key) def __setitem__( self, - key: Key, - value: MutableMapping[bytes, Value] + key: Section, + value: MutableMapping[Name, Value] ) -> None: return self._values.__setitem__(key, value) - def __delitem__(self, key: Key) -> None: + def __delitem__(self, key: Section) -> None: return self._values.__delitem__(key) - def __iter__(self) -> Iterator[Key]: + def __iter__(self) -> Iterator[Section]: return self._values.__iter__() def __len__(self) -> int: return self._values.__len__() @classmethod - def _parse_setting(cls, name): + def _parse_setting(cls, name: str): parts = name.split(".") if len(parts) == 3: return (parts[0], parts[1], parts[2]) @@ -308,9 +323,9 @@ class ConfigDict(Config, MutableMapping[Key, MutableMa def _check_section_and_name( self, - section: KeyLike, - name: BytesLike - ) -> Tuple[Key, bytes]: + section: SectionLike, + name: NameLike + ) -> Tuple[Section, Name]: if not isinstance(section, tuple): section = (section,) @@ -330,14 +345,14 @@ class ConfigDict(Config, MutableMapping[Key, MutableMa def get_multivar( self, - section: KeyLike, - name: BytesLike + section: SectionLike, + name: NameLike ) -> Iterator[Value]: section, name = self._check_section_and_name(section, name) if len(section) > 1: try: - return self._values[section][name] + return self._values[section].get_all(name) except KeyError: pass @@ -345,9 +360,9 @@ class ConfigDict(Config, MutableMapping[Key, MutableMa def get( # type: ignore[override] self, - section: KeyLike, - name: BytesLike, - ) -> Optional[Value]: + section: SectionLike, + name: NameLike, + ) -> Value: section, name = self._check_section_and_name(section, name) if len(section) > 1: @@ -360,28 +375,31 @@ class ConfigDict(Config, MutableMapping[Key, MutableMa def set( self, - section: KeyLike, - name: BytesLike, - value: ValueLike, + section: SectionLike, + name: NameLike, + value: Union[ValueLike, bool], ) -> None: section, name = self._check_section_and_name(section, name) - if not isinstance(value, (bytes, bool)): + if isinstance(value, bool): + value = b"true" if value else b"false" + + if not isinstance(value, bytes): value = value.encode(self.encoding) self._values.setdefault(section)[name] = value def items( # type: ignore[override] self, - section: Key - ) -> Iterator[Value]: + section: Section + ) -> Iterator[Tuple[Name, Value]]: return self._values.get(section).items() - def sections(self) -> Iterator[Key]: + def sections(self) -> Iterator[Section]: return self._values.keys() -def _format_string(value): +def _format_string(value: bytes) -> bytes: if ( value.startswith(b" ") or value.startswith(b"\t") @@ -405,7 +423,7 @@ _COMMENT_CHARS = [ord(b"#"), ord(b";")] _WHITESPACE_CHARS = [ord(b"\t"), ord(b" ")] -def _parse_string(value): +def _parse_string(value: bytes) -> bytes: value = bytearray(value.strip()) ret = bytearray() whitespace = bytearray() @@ -450,7 +468,7 @@ def _parse_string(value): return bytes(ret) -def _escape_value(value): +def _escape_value(value: bytes) -> bytes: """Escape a value.""" value = value.replace(b"\\", b"\\\\") value = value.replace(b"\n", b"\\n") @@ -459,7 +477,7 @@ def _escape_value(value): return value -def _check_variable_name(name): +def _check_variable_name(name: bytes) -> bool: for i in range(len(name)): c = name[i : i + 1] if not c.isalnum() and c != b"-": @@ -467,7 +485,7 @@ def _check_variable_name(name): return True -def _check_section_name(name): +def _check_section_name(name: bytes) -> bool: for i in range(len(name)): c = name[i : i + 1] if not c.isalnum() and c not in (b"-", b"."): @@ -475,7 +493,7 @@ def _check_section_name(name): return True -def _strip_comments(line): +def _strip_comments(line: bytes) -> bytes: comment_bytes = {ord(b"#"), ord(b";")} quote = ord(b'"') string_open = False @@ -492,15 +510,21 @@ def _strip_comments(line): class ConfigFile(ConfigDict): """A Git configuration file, like .git/config or ~/.gitconfig.""" - def __init__(self, values=None, encoding=None): + def __init__( + self, + values: Union[ + MutableMapping[Section, MutableMapping[Name, Value]], None + ] = None, + encoding: Union[str, None] = None + ) -> None: super(ConfigFile, self).__init__(values=values, encoding=encoding) - self.path = None + self.path: Optional[str] = None @classmethod # noqa: C901 def from_file(cls, f: BinaryIO) -> "ConfigFile": # noqa: C901 """Read configuration from a file-like object.""" ret = cls() - section = None # type: Optional[Tuple[bytes, ...]] + section: Optional[Section] = None setting = None continuation = None for lineno, line in enumerate(f.readlines()): @@ -565,14 +589,14 @@ class ConfigFile(ConfigDict): return ret @classmethod - def from_path(cls, path) -> "ConfigFile": + def from_path(cls, path: str) -> "ConfigFile": """Read configuration from a file on disk.""" with GitFile(path, "rb") as f: ret = cls.from_file(f) ret.path = path return ret - def write_to_path(self, path=None) -> None: + def write_to_path(self, path: Optional[str] = None) -> None: """Write configuration to a file on disk.""" if path is None: path = self.path @@ -592,12 +616,7 @@ class ConfigFile(ConfigDict): else: f.write(b"[" + section_name + b' "' + subsection_name + b'"]\n') for key, value in values.items(): - if value is True: - value = b"true" - elif value is False: - value = b"false" - else: - value = _format_string(value) + value = _format_string(value) f.write(b"\t" + key + b" = " + value + b"\n") @@ -663,19 +682,21 @@ def get_win_system_paths(): class StackedConfig(Config): """Configuration which reads from multiple config files..""" - def __init__(self, backends, writable=None): + def __init__( + self, backends: List[ConfigFile], writable: Optional[ConfigFile] = None + ): self.backends = backends self.writable = writable - def __repr__(self): + def __repr__(self) -> str: return "<%s for %r>" % (self.__class__.__name__, self.backends) @classmethod - def default(cls): + def default(cls) -> "StackedConfig": return cls(cls.default_backends()) @classmethod - def default_backends(cls): + def default_backends(cls) -> List[ConfigFile]: """Retrieve the default configuration. See git-config(1) for details on the files searched. @@ -698,7 +719,7 @@ class StackedConfig(Config): backends.append(cf) return backends - def get(self, section, name): + def get(self, section: SectionLike, name: NameLike) -> Value: if not isinstance(section, tuple): section = (section,) for backend in self.backends: @@ -708,12 +729,34 @@ class StackedConfig(Config): pass raise KeyError(name) - def set(self, section, name, value): + def get_multivar(self, section: SectionLike, name: NameLike) -> Iterator[Value]: + if not isinstance(section, tuple): + section = (section,) + for backend in self.backends: + try: + yield from backend.get_multivar(section, name) + except KeyError: + pass + + def set( + self, + section: SectionLike, + name: NameLike, + value: Union[ValueLike, bool] + ) -> None: if self.writable is None: raise NotImplementedError(self.set) return self.writable.set(section, name, value) + def sections(self) -> Iterator[Section]: + seen = set() + for backend in self.backends: + for section in backend.sections(): + if section not in seen: + seen.add(section) + yield section + def parse_submodules(config: ConfigFile) -> Iterator[Tuple[bytes, bytes, bytes]]: """Parse a gitmodules GitConfig file, returning submodules. @@ -727,9 +770,5 @@ def parse_submodules(config: ConfigFile) -> Iterator[T section_kind, section_name = section if section_kind == b"submodule": sm_path = config.get(section, b"path") - assert isinstance(sm_path, bytes) - assert sm_path is not None sm_url = config.get(section, b"url") - assert sm_url is not None - assert isinstance(sm_url, bytes) yield (sm_path, sm_url, section_name) blob - 55a821f46c14bf7e8392d1d7d4ca85dd77cf3551 blob + 1831fe7d0266f3e7414db8ddae86af302fa42e1b --- dulwich/ignore.py +++ dulwich/ignore.py @@ -286,7 +286,9 @@ def default_user_ignore_filter_path(config: Config) -> Path to a global ignore file """ try: - return config.get((b"core",), b"excludesFile") + value = config.get((b"core",), b"excludesFile") + assert isinstance(value, bytes) + return value.decode(encoding="utf-8") except KeyError: pass blob - 34e8dda184436f34544f1badd646cde24bcf7851 blob + d34dac7e8a6f3a6113e2b30e97f9f4f27dbe1a1a --- dulwich/porcelain.py +++ dulwich/porcelain.py @@ -1001,10 +1001,7 @@ def get_remote_repo( if config.has_section(section): remote_name = encoded_location.decode() - url = config.get(section, "url") - assert url is not None - assert isinstance(url, bytes) - encoded_location = url + encoded_location = config.get(section, "url") else: remote_name = None @@ -1139,13 +1136,14 @@ def pull( path, r, progress=errstream.write, determine_wants=determine_wants ) for (lh, rh, force_ref) in selected_refs: - try: - check_diverged(r, r.refs[rh], fetch_result.refs[lh]) - except DivergedBranches: - if fast_forward: - raise - else: - raise NotImplementedError("merge is not yet supported") + if not force_ref and rh in r.refs: + try: + check_diverged(r, r.refs.follow(rh)[1], fetch_result.refs[lh]) + except DivergedBranches: + if fast_forward: + raise + else: + raise NotImplementedError("merge is not yet supported") r.refs[rh] = fetch_result.refs[lh] if selected_refs: r[b"HEAD"] = fetch_result.refs[selected_refs[0][1]] blob - 1fd839da6664491e553d2413929d81faeadb24d2 blob + 1309ecf8cfb5dd6c274538d8b5c94b1fb54b04b2 --- dulwich/tests/__init__.py +++ dulwich/tests/__init__.py @@ -20,6 +20,14 @@ """Tests for Dulwich.""" +__all__ = [ + 'SkipTest', + 'TestCase', + 'BlackboxTestCase', + 'skipIf', + 'expectedFailure', +] + import doctest import os import shutil blob - 12f62d15e3ef2f408dc60a906e06e474720ff45a blob + d8c00b557f47b03af641d3e2624c4142616b8c12 --- dulwich/tests/test_client.py +++ dulwich/tests/test_client.py @@ -52,6 +52,7 @@ from dulwich.client import ( PLinkSSHVendor, HangupException, GitProtocolError, + apply_instead_of, check_wants, default_urllib3_manager, get_credentials_from_store, @@ -1019,6 +1020,19 @@ class HttpGitClientTests(TestCase): auth_string = "%s:%s" % ("user", "passwd") b64_credentials = base64.b64encode(auth_string.encode("latin1")) expected_basic_auth = "Basic %s" % b64_credentials.decode("latin1") + self.assertEqual(basic_auth, expected_basic_auth) + + def test_init_username_set_no_password(self): + url = "https://github.com/jelmer/dulwich" + + c = HttpGitClient(url, config=None, username="user") + self.assertEqual("user", c._username) + self.assertIs(c._password, None) + + basic_auth = c.pool_manager.headers["authorization"] + auth_string = b"user:" + b64_credentials = base64.b64encode(auth_string) + expected_basic_auth = f"Basic {b64_credentials.decode('ascii')}" self.assertEqual(basic_auth, expected_basic_auth) def test_init_no_username_passwd(self): @@ -1028,6 +1042,20 @@ class HttpGitClientTests(TestCase): self.assertIs(None, c._username) self.assertIs(None, c._password) self.assertNotIn("authorization", c.pool_manager.headers) + + def test_from_parsedurl_username_only(self): + username = "user" + url = f"https://{username}@github.com/jelmer/dulwich" + + c = HttpGitClient.from_parsedurl(urlparse(url)) + self.assertEqual(c._username, username) + self.assertEqual(c._password, None) + + basic_auth = c.pool_manager.headers["authorization"] + auth_string = username.encode('ascii') + b":" + b64_credentials = base64.b64encode(auth_string) + expected_basic_auth = f"Basic {b64_credentials.decode('ascii')}" + self.assertEqual(basic_auth, expected_basic_auth) def test_from_parsedurl_on_url_with_quoted_credentials(self): original_username = "john|the|first" @@ -1588,3 +1616,31 @@ And this line is just random noise, too. ] ), ) + + +class ApplyInsteadOfTests(TestCase): + def test_none(self): + config = ConfigDict() + self.assertEqual( + 'https://example.com/', apply_instead_of(config, 'https://example.com/')) + + def test_apply(self): + config = ConfigDict() + config.set( + ('url', 'https://samba.org/'), 'insteadOf', 'https://example.com/') + self.assertEqual( + 'https://samba.org/', + apply_instead_of(config, 'https://example.com/')) + + def test_apply_multiple(self): + config = ConfigDict() + config.set( + ('url', 'https://samba.org/'), 'insteadOf', 'https://blah.com/') + config.set( + ('url', 'https://samba.org/'), 'insteadOf', 'https://example.com/') + self.assertEqual( + [b'https://blah.com/', b'https://example.com/'], + list(config.get_multivar(('url', 'https://samba.org/'), 'insteadOf'))) + self.assertEqual( + 'https://samba.org/', + apply_instead_of(config, 'https://example.com/')) blob - 201669ebff9d296ddd2ea89404ca02dfa5debb6e blob + 6ed9cac2c3b9e595b3eba070fd0ccef979c56148 --- dulwich/tests/test_porcelain.py +++ dulwich/tests/test_porcelain.py @@ -20,6 +20,7 @@ """Tests for dulwich.porcelain.""" +import contextlib from io import BytesIO, StringIO import os import platform @@ -30,6 +31,7 @@ import subprocess import sys import tarfile import tempfile +import threading import time from unittest import skipIf @@ -48,6 +50,9 @@ from dulwich.repo import ( NoIndexPresent, Repo, ) +from dulwich.server import ( + DictBackend, +) from dulwich.tests import ( TestCase, ) @@ -55,6 +60,10 @@ from dulwich.tests.utils import ( build_commit_graph, make_commit, make_object, +) +from dulwich.web import ( + make_server, + make_wsgi_chain, ) @@ -2867,3 +2876,42 @@ class FindUniqueAbbrevTests(PorcelainTestCase): self.assertEqual( c1.id.decode('ascii')[:7], porcelain.find_unique_abbrev(self.repo.object_store, c1.id)) + + +class ServerTests(PorcelainTestCase): + @contextlib.contextmanager + def _serving(self): + with make_server('localhost', 0, self.app) as server: + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + try: + yield f"http://localhost:{server.server_port}" + + finally: + server.shutdown() + thread.join(10) + + def setUp(self): + super().setUp() + + self.served_repo_path = os.path.join(self.test_dir, "served_repo.git") + self.served_repo = Repo.init_bare(self.served_repo_path, mkdir=True) + self.addCleanup(self.served_repo.close) + + backend = DictBackend({"/": self.served_repo}) + self.app = make_wsgi_chain(backend) + + def test_pull(self): + c1, = build_commit_graph(self.served_repo.object_store, [[1]]) + self.served_repo.refs[b"refs/heads/master"] = c1.id + + with self._serving() as url: + porcelain.pull(self.repo, url, "master") + + def test_push(self): + c1, = build_commit_graph(self.repo.object_store, [[1]]) + self.repo.refs[b"refs/heads/master"] = c1.id + + with self._serving() as url: + porcelain.push(self.repo, url, "master") blob - 27ac8577ad6eb73a7fb204437a432ad9c37402e9 blob + daa728944f853c304a7de8e628b34f70b1c6f4fa --- dulwich/web.py +++ dulwich/web.py @@ -237,6 +237,34 @@ def get_info_packs(req, backend, mat): req.respond(HTTP_OK, "text/plain") logger.info("Emulating dumb info/packs") return generate_objects_info_packs(get_repo(backend, mat)) + + +def _chunk_iter(f): + while True: + line = f.readline() + length = int(line.rstrip(), 16) + chunk = f.read(length + 2) + if length == 0: + break + yield chunk[:-2] + + +class ChunkReader(object): + + def __init__(self, f): + self._iter = _chunk_iter(f) + self._buffer = [] + + def read(self, n): + while sum(map(len, self._buffer)) < n: + try: + self._buffer.append(next(self._iter)) + except StopIteration: + break + f = b''.join(self._buffer) + ret = f[:n] + self._buffer = [f[n:]] + return ret class _LengthLimitedFile(object): @@ -276,7 +304,11 @@ def handle_service_request(req, backend, mat): return req.nocache() write = req.respond(HTTP_OK, "application/x-%s-result" % service) - proto = ReceivableProtocol(req.environ["wsgi.input"].read, write) + if req.environ.get('HTTP_TRANSFER_ENCODING') == 'chunked': + read = ChunkReader(req.environ["wsgi.input"]).read + else: + read = req.environ["wsgi.input"].read + proto = ReceivableProtocol(read, write) # TODO(jelmer): Find a way to pass in repo, rather than having handler_cls # reopen. handler = handler_cls(backend, [url_prefix(mat)], proto, stateless_rpc=req) blob - 5daca8bf089b001f523873510c0fc3ee5bffe1ac blob + 3de0b5b9f6f458c21bdbe15bb6a586b4bbcc5588 --- dulwich.egg-info/PKG-INFO +++ dulwich.egg-info/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: dulwich -Version: 0.20.43 +Version: 0.20.44 Summary: Python Git Library Home-page: https://www.dulwich.io/ Author: Jelmer Vernooij blob - a2983826a60d21673c6e8b02788cf1c6cb4a5842 blob + 3c557ab7db8b44fb11b1ad1d677fee8ec3467d63 --- setup.py +++ setup.py @@ -23,7 +23,7 @@ if sys.version_info < (3, 6): 'For 2.7 support, please install a version prior to 0.20') -dulwich_version_string = '0.20.43' +dulwich_version_string = '0.20.44' class DulwichDistribution(Distribution):