diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 1b270fb1..1e35209b 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -21,7 +21,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - pyv: ['3.9', '3.10', '3.11', '3.12', '3.13'] + pyv: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] steps: - name: Check out the repository diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fb33d081..2f4f20ca 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,7 +20,7 @@ repos: - id: sort-simple-yaml - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.13.1' + rev: 'v0.14.3' hooks: - id: ruff-check args: [--fix, --exit-non-zero-on-fix] diff --git a/pyproject.toml b/pyproject.toml index 69098df1..70c4d50c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Development Status :: 4 - Beta", ] requires-python = ">=3.9" @@ -52,7 +53,7 @@ tests = [ "proxy.py", ] dev = [ - "mypy==1.17.1", + "mypy==1.18.2", "scmrepo[tests]", "types-certifi", "types-mock", diff --git a/src/scmrepo/git/backend/dulwich/__init__.py b/src/scmrepo/git/backend/dulwich/__init__.py index 6b64c348..30a66c68 100644 --- a/src/scmrepo/git/backend/dulwich/__init__.py +++ b/src/scmrepo/git/backend/dulwich/__init__.py @@ -250,7 +250,7 @@ def clone( url, target=to_path, errstream=( - DulwichProgressReporter(progress) if progress else NoneStream() + DulwichProgressReporter(progress) if progress else NoneStream() # type: ignore[arg-type] ), bare=bare, ) @@ -278,8 +278,11 @@ def clone( def _set_default_tracking_branch(repo: "Repo"): from dulwich.refs import LOCAL_BRANCH_PREFIX, parse_symref_value + head_ref = repo.refs.read_ref(b"HEAD") + if head_ref is None: + return try: - ref = parse_symref_value(repo.refs.read_ref(b"HEAD")) + ref = parse_symref_value(head_ref) except ValueError: return if ref.startswith(LOCAL_BRANCH_PREFIX): @@ -307,7 +310,7 @@ def _set_mirror( fetch( repo, remote_location=b"origin", - errstream=(DulwichProgressReporter(progress) if progress else NoneStream()), + errstream=(DulwichProgressReporter(progress) if progress else NoneStream()), # type: ignore[arg-type] ) @staticmethod @@ -599,11 +602,11 @@ def iter_remote_refs(self, url: str, base: Optional[str] = None, **kwargs): if base: yield from ( os.fsdecode(ref) - for ref in client.get_refs(path) + for ref in client.get_refs(path.encode()) if ref.startswith(os.fsencode(base)) ) else: - yield from (os.fsdecode(ref) for ref in client.get_refs(path)) + yield from (os.fsdecode(ref) for ref in client.get_refs(path.encode())) except NotGitRepository as exc: raise InvalidRemote(url) from exc except HTTPUnauthorized as exc: @@ -634,7 +637,7 @@ def push_refspecs( # noqa: C901 raise SCMError(f"'{url}' is not a valid Git remote or URL") from exc change_result = {} - selected_refs = [] + selected_refs: list[tuple[Optional[bytes], Optional[bytes], bool]] = [] def update_refs(refs): from dulwich.objects import ZERO_SHA @@ -649,6 +652,7 @@ def update_refs(refs): ) new_refs = {} for lh, rh, _ in selected_refs: + assert rh is not None refname = os.fsdecode(rh) if rh in refs and lh is not None: if refs[rh] == self.repo.refs[lh]: @@ -679,9 +683,9 @@ def update_refs(refs): try: result = client.send_pack( - path, + path.encode(), update_refs, - generate_pack_data=self.repo.generate_pack_data, + generate_pack_data=self.repo.generate_pack_data, # type: ignore[arg-type] progress=(DulwichProgressReporter(progress) if progress else None), ) except (NotGitRepository, SendPackError) as exc: @@ -717,7 +721,7 @@ def fetch_refspecs( from dulwich.porcelain import DivergedBranches, check_diverged, get_remote_repo from dulwich.refs import DictRefsContainer - fetch_refs = [] + fetch_refs: list[tuple[Optional[bytes], Optional[bytes], bool]] = [] def determine_wants( remote_refs: dict[bytes, bytes], @@ -736,7 +740,7 @@ def determine_wants( return [ remote_refs[lh] for (lh, _, _) in fetch_refs - if remote_refs[lh] not in self.repo.object_store + if lh is not None and remote_refs[lh] not in self.repo.object_store ] with reraise(Exception, SCMError(f"'{url}' is not a valid Git remote or URL")): @@ -749,33 +753,34 @@ def determine_wants( SCMError(f"Git failed to fetch ref from '{url}'"), ): fetch_result = client.fetch( - path, + path.encode(), self.repo, progress=DulwichProgressReporter(progress) if progress else None, - determine_wants=determine_wants, + determine_wants=determine_wants, # type: ignore[arg-type] ) result = {} for lh, rh, _ in fetch_refs: + assert rh is not None refname = os.fsdecode(rh) + assert lh is not None + fetch_ref_lh = fetch_result.refs[lh] + assert fetch_ref_lh is not None if rh in self.repo.refs: - if self.repo.refs[rh] == fetch_result.refs[lh]: + if self.repo.refs[rh] == fetch_ref_lh: result[refname] = SyncStatus.UP_TO_DATE continue try: check_diverged( self.repo, self.repo.refs[rh], - fetch_result.refs[lh], + fetch_ref_lh, ) except DivergedBranches: if not force: overwrite = ( - on_diverged( - os.fsdecode(rh), - os.fsdecode(fetch_result.refs[lh]), - ) + on_diverged(os.fsdecode(rh), os.fsdecode(fetch_ref_lh)) if on_diverged else False ) @@ -783,7 +788,7 @@ def determine_wants( result[refname] = SyncStatus.DIVERGED continue - self.repo.refs[rh] = fetch_result.refs[lh] + self.repo.refs[rh] = fetch_ref_lh result[refname] = SyncStatus.SUCCESS return result @@ -865,6 +870,7 @@ def _describe( return results def diff(self, rev_a: str, rev_b: str, binary=False) -> str: + from dulwich.objects import Commit from dulwich.patch import write_tree_diff try: @@ -873,6 +879,9 @@ def diff(self, rev_a: str, rev_b: str, binary=False) -> str: except KeyError as exc: raise RevError("Invalid revision") from exc + assert isinstance(commit_a, Commit) + assert isinstance(commit_b, Commit) + buf = BytesIO() write_tree_diff(buf, self.repo.object_store, commit_a.tree, commit_b.tree) return buf.getvalue().decode("utf-8") @@ -958,20 +967,22 @@ def get_tag(self, name: str) -> Optional[Union[str, "GitTag"]]: ref = self.repo.refs[name_b] except KeyError: return None - if ref in self.repo and isinstance(self.repo[ref], Tag): - tag = self.repo[ref] - _typ, target_sha = tag.object - tagger_name, tagger_email = _parse_identity(tag.tagger.decode("utf-8")) - return GitTag( - os.fsdecode(tag.name), - tag.id, - target_sha.decode("ascii"), - tagger_name, - tagger_email, - tag.tag_time, - tag.tag_timezone, - tag.message.decode("utf-8"), - ) + if ref in self.repo: + shafile = self.repo[ref] + if isinstance(shafile, Tag): + tag = shafile + _typ, target_sha = tag.object + tagger_name, tagger_email = _parse_identity(tag.tagger.decode("utf-8")) + return GitTag( + os.fsdecode(tag.name), + tag.id.decode("ascii"), + target_sha.decode("ascii"), + tagger_name, + tagger_email, + tag.tag_time, + tag.tag_timezone, + tag.message.decode("utf-8"), + ) return os.fsdecode(ref) def get_config(self, path: Optional[str] = None) -> "Config": @@ -1000,13 +1011,16 @@ def _parse_identity(identity: str) -> tuple[str, str]: return m.group("name"), m.group("email") -def ls_remote(url: str) -> dict[str, str]: +def ls_remote(url: str) -> dict[str, Optional[str]]: from dulwich import porcelain from dulwich.client import HTTPUnauthorized try: refs = porcelain.ls_remote(url).refs - return {os.fsdecode(ref): sha.decode("ascii") for ref, sha in refs.items()} + return { + os.fsdecode(ref): sha.decode("ascii") if sha is not None else None + for ref, sha in refs.items() + } except HTTPUnauthorized as exc: raise AuthError(url) from exc except Exception as exc: diff --git a/src/scmrepo/git/backend/dulwich/asyncssh_vendor.py b/src/scmrepo/git/backend/dulwich/asyncssh_vendor.py index b12d2d76..b9c40115 100644 --- a/src/scmrepo/git/backend/dulwich/asyncssh_vendor.py +++ b/src/scmrepo/git/backend/dulwich/asyncssh_vendor.py @@ -168,7 +168,7 @@ async def public_key_auth_requested( # noqa: C901 pubkey = read_public_key(pubkey_to_load) except (OSError, KeyImportError): pubkey = None - return SSHLocalKeyPair(key, pubkey) + return SSHLocalKeyPair(key, pubkey, cert=None, enc_key=None) return None async def _read_private_key_interactive(self, path: "FilePath") -> "SSHKey": diff --git a/src/scmrepo/git/backend/pygit2/__init__.py b/src/scmrepo/git/backend/pygit2/__init__.py index 46460946..fa400320 100644 --- a/src/scmrepo/git/backend/pygit2/__init__.py +++ b/src/scmrepo/git/backend/pygit2/__init__.py @@ -32,7 +32,7 @@ if TYPE_CHECKING: - from pygit2 import Commit, Oid, Signature + from pygit2 import Commit, Oid, Reference, Signature, Tag from pygit2.config import Config as _Pygit2Config from pygit2.enums import CheckoutStrategy from pygit2.remotes import Remote @@ -67,15 +67,11 @@ def open( else: pass if raw: - blob_kwargs = {} + blobio = BlobIO(self.obj) else: assert key is not None path = "/".join(key) - blob_kwargs = { - "as_path": path, - "commit_id": commit.id, - } - blobio = BlobIO(self.obj, **blob_kwargs) + blobio = BlobIO(self.obj, as_path=path, commit_id=commit.id) if mode == "rb": return blobio return TextIOWrapper(blobio, encoding=encoding) @@ -190,14 +186,17 @@ def _refdb(self): return RefdbFsBackend(self.repo) - def _resolve_refish(self, refish: str): + def _resolve_refish( + self, refish: str + ) -> tuple["Commit", Union["Reference", "Tag"]]: from pygit2 import Tag from pygit2.enums import ObjectType - commit, ref = self.repo.resolve_refish(refish) # type: ignore[attr-defined] + ref: Union[Reference, Tag] + commit, ref = self.repo.resolve_refish(refish) if isinstance(commit, Tag): ref = commit - commit = commit.peel(ObjectType.COMMIT) # type: ignore[call-overload] + commit = commit.peel(ObjectType.COMMIT) return commit, ref @property @@ -313,6 +312,7 @@ def _set_mirror( # duplicate config section for each remote config entry. We just edit # the config directly so that it creates a single section to be # consistent with CLI Git + assert url is not None repo.config["remote.origin.url"] = url repo.config["remote.origin.fetch"] = "+refs/*:refs/*" repo.config["remote.origin.mirror"] = True @@ -347,7 +347,7 @@ def checkout( force: bool = False, **kwargs, ): - from pygit2 import GitError + from pygit2 import Commit, GitError from pygit2.enums import CheckoutStrategy strategy = self._get_checkout_strategy( @@ -356,9 +356,10 @@ def checkout( with self.release_odb_handles(): if create_new: - commit = self.repo.revparse_single("HEAD") - new_branch = self.repo.branches.local.create(branch, commit) # type: ignore[arg-type] - self.repo.checkout(new_branch, strategy=strategy) # type: ignore[attr-defined] + _commit = self.repo.revparse_single("HEAD") + assert isinstance(_commit, Commit) + new_branch = self.repo.branches.local.create(branch, _commit) + self.repo.checkout(new_branch, strategy=strategy) else: if branch == "-": branch = "@{-1}" @@ -582,7 +583,7 @@ def get_ref(self, name, follow: bool = True) -> Optional[str]: def remove_ref(self, name: str, old_ref: Optional[str] = None): ref = self.repo.references.get(name) - if not ref and not old_ref: + if not ref: return if old_ref and old_ref != str(ref.target): raise SCMError(f"Failed to remove '{name}'") @@ -676,6 +677,7 @@ def _get_remote(self, url: str) -> Iterator["Remote"]: """Return a pygit2.Remote suitable for the specified Git URL or remote name.""" try: remote = self.repo.remotes[url] + assert remote.url is not None url = remote.url except ValueError: pass @@ -685,6 +687,7 @@ def _get_remote(self, url: str) -> Iterator["Remote"]: if os.name == "nt": url = url.removeprefix("file://") remote = self.repo.remotes.create_anonymous(url) + assert remote.url is not None parsed = urlparse(remote.url) if parsed.scheme in ("git", "git+ssh", "ssh") or is_scp_style_url(remote.url): raise NotImplementedError @@ -838,7 +841,7 @@ def _apply(index): _apply(i) return - self.set_ref(Stash.DEFAULT_STASH, commit.id, message=commit.message) + self.set_ref(Stash.DEFAULT_STASH, commit.id, message=commit.message) # type: ignore[arg-type] try: _apply(0) finally: @@ -936,7 +939,9 @@ def checkout_index( assert self.root_dir path = os.path.join(self.root_dir, entry.path) with open(path, "wb") as fobj: - fobj.write(self.repo.get(entry.id).read_raw()) # type: ignore[attr-defined] + obj = self.repo.get(entry.id) + assert obj is not None + fobj.write(obj.read_raw()) index.add(entry.path) index.write() @@ -1074,7 +1079,9 @@ def validate_git_remote(self, url: str, **kwargs): def get_remote_url(self, remote: str) -> str: try: - return self.repo.remotes[remote].url + url = self.repo.remotes[remote].url + assert url is not None + return url except KeyError as exc: raise InvalidRemote(remote) from exc diff --git a/src/scmrepo/git/lfs/client.py b/src/scmrepo/git/lfs/client.py index 7fa3322b..344b4213 100644 --- a/src/scmrepo/git/lfs/client.py +++ b/src/scmrepo/git/lfs/client.py @@ -267,7 +267,7 @@ def _git_lfs_authenticate( action = "upload" if upload else "download" return json.loads( self._ssh.run_command( - command=f"git-lfs-authenticate {path} {action}", + command=f"git-lfs-authenticate {path} {action}".encode(), host=host, port=port, username=username, diff --git a/tests/conftest.py b/tests/conftest.py index f39e7962..1a6be10c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -48,6 +48,7 @@ def _isolate(tmp_dir_factory: TempDirFactory, monkeypatch: pytest.MonkeyPatch) - monkeypatch.setenv("HOMEPATH", home_path) else: monkeypatch.setenv("HOME", str(home_dir)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home_dir / ".config")) monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") contents = b""" @@ -58,7 +59,7 @@ def _isolate(tmp_dir_factory: TempDirFactory, monkeypatch: pytest.MonkeyPatch) - defaultBranch=master """ (home_dir / ".gitconfig").write_bytes(contents) - pygit2.settings.search_path[pygit2.GIT_CONFIG_LEVEL_GLOBAL] = str(home_dir) # type: ignore[attr-defined] + pygit2.settings.search_path[pygit2.GIT_CONFIG_LEVEL_GLOBAL] = str(home_dir) # type: ignore[attr-defined, index] @pytest.fixture diff --git a/tests/test_pygit2.py b/tests/test_pygit2.py index b8e7938c..52a0c7fe 100644 --- a/tests/test_pygit2.py +++ b/tests/test_pygit2.py @@ -22,8 +22,9 @@ def test_pygit_resolve_refish(tmp_dir: TmpDir, scm: Git, use_sha: str): if use_sha: # refish will be annotated tag SHA (not commit SHA) - ref = backend.repo.references.get(f"refs/tags/{tag}") - refish = str(ref.target) + _ref = backend.repo.references.get(f"refs/tags/{tag}") + assert _ref + refish = str(_ref.target) else: refish = tag