Browse Source

Merge branch 'master' into stub-archive

Jelmer Vernooij 2 years ago
parent
commit
db5e9e34b0

+ 1 - 1
.github/workflows/pythonpackage.yml

@@ -13,7 +13,7 @@ jobs:
     strategy:
       matrix:
         os: [ubuntu-latest, macos-latest, windows-latest]
-        python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", pypy3]
+        python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11.0-rc - 3.11", pypy3]
         exclude:
           # sqlite3 exit handling seems to get in the way
           - os: macos-latest

+ 5 - 5
.github/workflows/pythonwheels.yml

@@ -13,7 +13,7 @@ jobs:
     strategy:
       matrix:
         os: [macos-latest, windows-latest]
-        python-version: ['3.6', '3.7', '3.8', '3.9', '3.10']
+        python-version: ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11.0-rc - 3.11']
         include:
           - os: ubuntu-latest
             python-version: '3.x'
@@ -50,14 +50,14 @@ jobs:
       name: Set up QEMU
       if: "matrix.os == 'ubuntu-latest'"
     - name: Build (Linux aarch64)
-      uses: RalfG/python-wheels-manylinux-build@v0.3.3-manylinux2014_aarch64
+      uses: RalfG/python-wheels-manylinux-build@v0.5.0-manylinux2014_aarch64
       with:
-        python-versions: 'cp36-cp36m cp37-cp37m cp38-cp38 cp39-cp39 cp310-cp310'
+        python-versions: 'cp36-cp36m cp37-cp37m cp38-cp38 cp39-cp39 cp310-cp310 cp311-cp311'
       if: "matrix.os == 'ubuntu-latest'"
     - name: Build (Linux)
-      uses: RalfG/python-wheels-manylinux-build@v0.3.1
+      uses: RalfG/python-wheels-manylinux-build@v0.5.0
       with:
-        python-versions: 'cp36-cp36m cp37-cp37m cp38-cp38 cp39-cp39 cp310-cp310'
+        python-versions: 'cp36-cp36m cp37-cp37m cp38-cp38 cp39-cp39 cp310-cp310 cp311-cp311'
       env:
         # Temporary fix for LD_LIBRARY_PATH issue. See
         # https://github.com/RalfG/python-wheels-manylinux-build/issues/26

+ 1 - 1
Makefile

@@ -74,4 +74,4 @@ coverage-html: coverage
 .PHONY: apidocs
 
 apidocs:
-	pydoctor --docformat=google dulwich --project-url=https://www.dulwich.io/
+	pydoctor --intersphinx http://urllib3.readthedocs.org/en/latest/objects.inv --intersphinx http://docs.python.org/3/objects.inv --docformat=google dulwich --project-url=https://www.dulwich.io/

+ 6 - 0
docs/conf.py

@@ -206,3 +206,9 @@ latex_documents = [
 
 # If false, no module index is generated.
 # latex_use_modindex = True
+
+# Add mappings
+intersphinx_mapping = {
+    'urllib3': ('http://urllib3.readthedocs.org/en/latest', None),
+    'python': ('http://docs.python.org/3', None),
+}

+ 23 - 10
dulwich/client.py

@@ -46,7 +46,18 @@ 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,
+    Union,
+    TYPE_CHECKING,
+)
 
 from urllib.parse import (
     quote as urlquote,
@@ -57,6 +68,9 @@ from urllib.parse import (
     urlunparse,
 )
 
+if TYPE_CHECKING:
+    import urllib3
+
 
 import dulwich
 from dulwich.config import get_xdg_config_home_path, Config, apply_instead_of
@@ -437,7 +451,6 @@ class _v1ReceivePackHeader(object):
         """Handle the head of a 'git-receive-pack' request.
 
         Args:
-          proto: Protocol object to read from
           capabilities: List of negotiated capabilities
           old_refs: Old refs, as received from the server
           new_refs: Refs to change
@@ -1797,8 +1810,8 @@ def default_user_agent_string():
 
 def default_urllib3_manager(   # noqa: C901
     config, pool_manager_cls=None, proxy_manager_cls=None, **override_kwargs
-):
-    """Return `urllib3` connection pool manager.
+) -> Union["urllib3.ProxyManager", "urllib3.PoolManager"]:
+    """Return urllib3 connection pool manager.
 
     Honour detected proxy configurations.
 
@@ -1807,9 +1820,9 @@ def default_urllib3_manager(   # noqa: C901
       override_kwargs: Additional arguments for `urllib3.ProxyManager`
 
     Returns:
-      `pool_manager_cls` (defaults to `urllib3.ProxyManager`) instance for
-      proxy configurations, `proxy_manager_cls` (defaults to
-      `urllib3.PoolManager`) instance otherwise.
+      Either pool_manager_cls (defaults to `urllib3.ProxyManager`) instance for
+      proxy configurations, proxy_manager_cls
+      (defaults to `urllib3.PoolManager`) instance otherwise
 
     """
     proxy_server = user_agent = None
@@ -1909,9 +1922,9 @@ class AbstractHttpGitClient(GitClient):
           data: Request data.
 
         Returns:
-          Tuple (`response`, `read`), where response is an `urllib3`
-          response object with additional `content_type` and
-          `redirect_location` properties, and `read` is a consumable read
+          Tuple (response, read), where response is an urllib3
+          response object with additional content_type and
+          redirect_location properties, and read is a consumable read
           method for the response data.
 
         """

+ 56 - 0
dulwich/objects.py

@@ -1420,6 +1420,62 @@ class Commit(ShaFile):
 
         # TODO: optionally check for duplicate parents
 
+    def sign(self, keyid: Optional[str] = None):
+        import gpg
+        with gpg.Context(armor=True) as c:
+            if keyid is not None:
+                key = c.get_key(keyid)
+                with gpg.Context(armor=True, signers=[key]) as ctx:
+                    self.gpgsig, unused_result = ctx.sign(
+                        self.as_raw_string(),
+                        mode=gpg.constants.sig.mode.DETACH,
+                    )
+            else:
+                self.gpgsig, unused_result = c.sign(
+                    self.as_raw_string(), mode=gpg.constants.sig.mode.DETACH
+                )
+
+    def verify(self, keyids: Optional[Iterable[str]] = None):
+        """Verify GPG signature for this commit (if it is signed).
+
+        Args:
+          keyids: Optional iterable of trusted keyids for this commit.
+            If this commit is not signed by any key in keyids verification will
+            fail. If not specified, this function only verifies that the commit
+            has a valid signature.
+
+        Raises:
+          gpg.errors.BadSignatures: if GPG signature verification fails
+          gpg.errors.MissingSignatures: if commit was not signed by a key
+            specified in keyids
+        """
+        if self._gpgsig is None:
+            return
+
+        import gpg
+
+        with gpg.Context() as ctx:
+            self_without_gpgsig = self.copy()
+            self_without_gpgsig._gpgsig = None
+            self_without_gpgsig.gpgsig = None
+            data, result = ctx.verify(
+                self_without_gpgsig.as_raw_string(),
+                signature=self._gpgsig,
+            )
+            if keyids:
+                keys = [
+                    ctx.get_key(key)
+                    for key in keyids
+                ]
+                for key in keys:
+                    for subkey in keys:
+                        for sig in result.signatures:
+                            if subkey.can_sign and subkey.fpr == sig.fpr:
+                                return
+                raise gpg.errors.MissingSignatures(
+                    result, keys, results=(data, result)
+                )
+
     def _serialize(self):
         chunks = []
         tree_bytes = self._tree.id if isinstance(self._tree, Tree) else self._tree

+ 7 - 3
dulwich/porcelain.py

@@ -332,6 +332,7 @@ def commit(
     committer=None,
     encoding=None,
     no_verify=False,
+    signoff=False,
 ):
     """Create a new commit.
 
@@ -341,10 +342,12 @@ def commit(
       author: Optional author name and email
       committer: Optional committer name and email
       no_verify: Skip pre-commit and commit-msg hooks
+      signoff: GPG Sign the commit (bool, defaults to False,
+        pass True to use default GPG key,
+        pass a str containing Key ID to use a specific GPG key)
     Returns: SHA1 of the new commit
     """
     # FIXME: Support --all argument
-    # FIXME: Support --signoff argument
     if getattr(message, "encode", None):
         message = message.encode(encoding or DEFAULT_ENCODING)
     if getattr(author, "encode", None):
@@ -358,6 +361,7 @@ def commit(
             committer=committer,
             encoding=encoding,
             no_verify=no_verify,
+            sign=signoff if isinstance(signoff, (str, bool)) else None,
         )
 
 
@@ -1192,10 +1196,10 @@ def status(repo=".", ignored=False, untracked_files="all"):
       untracked_files: How to handle untracked files, defaults to "all":
           "no": do not return untracked files
           "all": include all files in untracked directories
-        Using `untracked_files="no"` can be faster than "all" when the worktreee
+        Using untracked_files="no" can be faster than "all" when the worktreee
           contains many untracked files/directories.
 
-    Note: `untracked_files="normal" (`git`'s default) is not implemented.
+    Note: untracked_files="normal" (git's default) is not implemented.
 
     Returns: GitStatus tuple,
         staged -  dict with lists of staged paths (diff index/HEAD)

+ 17 - 2
dulwich/repo.py

@@ -876,6 +876,7 @@ class BaseRepo(object):
         ref=b"HEAD",
         merge_heads=None,
         no_verify=False,
+        sign=False,
     ):
         """Create a new commit.
 
@@ -899,6 +900,9 @@ class BaseRepo(object):
           ref: Optional ref to commit to (defaults to current branch)
           merge_heads: Merge heads (defaults to .git/MERGE_HEAD)
           no_verify: Skip pre-commit and commit-msg hooks
+          sign: GPG Sign the commit (bool, defaults to False,
+            pass True to use default GPG key,
+            pass a str containing Key ID to use a specific GPG key)
 
         Returns:
           New commit SHA1
@@ -970,14 +974,20 @@ class BaseRepo(object):
         except KeyError:  # no hook defined, message not modified
             c.message = message
 
+        keyid = sign if isinstance(sign, str) else None
+
         if ref is None:
             # Create a dangling commit
             c.parents = merge_heads
+            if sign:
+                c.sign(keyid)
             self.object_store.add_object(c)
         else:
             try:
                 old_head = self.refs[ref]
                 c.parents = [old_head] + merge_heads
+                if sign:
+                    c.sign(keyid)
                 self.object_store.add_object(c)
                 ok = self.refs.set_if_equals(
                     ref,
@@ -990,6 +1000,8 @@ class BaseRepo(object):
                 )
             except KeyError:
                 c.parents = merge_heads
+                if sign:
+                    c.sign(keyid)
                 self.object_store.add_object(c)
                 ok = self.refs.add_if_new(
                     ref,
@@ -1052,11 +1064,14 @@ class Repo(BaseRepo):
 
     Attributes:
 
-      path (str): Path to the working copy (if it exists) or repository control
+      path: Path to the working copy (if it exists) or repository control
         directory (if the repository is bare)
-      bare (bool): Whether this is a bare repository
+      bare: Whether this is a bare repository
     """
 
+    path: str
+    bare: bool
+
     def __init__(
         self,
         root: str,

+ 64 - 0
dulwich/tests/test_porcelain.py

@@ -419,6 +419,70 @@ class CommitTests(PorcelainTestCase):
         self.assertEqual(len(sha), 40)
 
 
+@skipIf(platform.python_implementation() == "PyPy" or sys.platform == "win32", "gpgme not easily available or supported on Windows and PyPy")
+class CommitSignTests(PorcelainGpgTestCase):
+
+    def test_default_key(self):
+        c1, c2, c3 = build_commit_graph(
+            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
+        )
+        self.repo.refs[b"HEAD"] = c3.id
+        cfg = self.repo.get_config()
+        cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
+        self.import_default_key()
+
+        sha = porcelain.commit(
+            self.repo.path,
+            message="Some message",
+            author="Joe <joe@example.com>",
+            committer="Bob <bob@example.com>",
+            signoff=True,
+        )
+        self.assertIsInstance(sha, bytes)
+        self.assertEqual(len(sha), 40)
+
+        commit = self.repo.get_object(sha)
+        # GPG Signatures aren't deterministic, so we can't do a static assertion.
+        commit.verify()
+        commit.verify(keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID])
+
+        self.import_non_default_key()
+        self.assertRaises(
+            gpg.errors.MissingSignatures,
+            commit.verify,
+            keyids=[PorcelainGpgTestCase.NON_DEFAULT_KEY_ID],
+        )
+
+        commit.committer = b"Alice <alice@example.com>"
+        self.assertRaises(
+            gpg.errors.BadSignatures,
+            commit.verify,
+        )
+
+    def test_non_default_key(self):
+        c1, c2, c3 = build_commit_graph(
+            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
+        )
+        self.repo.refs[b"HEAD"] = c3.id
+        cfg = self.repo.get_config()
+        cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
+        self.import_non_default_key()
+
+        sha = porcelain.commit(
+            self.repo.path,
+            message="Some message",
+            author="Joe <joe@example.com>",
+            committer="Bob <bob@example.com>",
+            signoff=PorcelainGpgTestCase.NON_DEFAULT_KEY_ID,
+        )
+        self.assertIsInstance(sha, bytes)
+        self.assertEqual(len(sha), 40)
+
+        commit = self.repo.get_object(sha)
+        # GPG Signatures aren't deterministic, so we can't do a static assertion.
+        commit.verify()
+
+
 class CleanTests(PorcelainTestCase):
     def put_files(self, tracked, ignored, untracked, empty_dirs):
         """Put the described files in the wd"""

+ 1 - 0
setup.py

@@ -126,6 +126,7 @@ setup(name='dulwich',
           'Programming Language :: Python :: 3.8',
           'Programming Language :: Python :: 3.9',
           'Programming Language :: Python :: 3.10',
+          'Programming Language :: Python :: 3.11',
           'Programming Language :: Python :: Implementation :: CPython',
           'Programming Language :: Python :: Implementation :: PyPy',
           'Operating System :: POSIX',