Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-unearth for openSUSE:Factory checked in at 2026-08-18 16:38:28 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-unearth (Old) and /work/SRC/openSUSE:Factory/.python-unearth.new.1258 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-unearth" Tue Aug 18 16:38:28 2026 rev:13 rq:1371773 version:0.18.3 Changes: -------- --- /work/SRC/openSUSE:Factory/python-unearth/python-unearth.changes 2026-02-09 11:44:19.812195987 +0100 +++ /work/SRC/openSUSE:Factory/.python-unearth.new.1258/python-unearth.changes 2026-08-18 16:39:05.653537053 +0200 @@ -1,0 +2,12 @@ +Tue Aug 18 10:56:55 UTC 2026 - Nico Krapp <[email protected]> + +- Update to 0.18.3 (fixes CVE-2026-73030 (bsc#1275440)) + * cli: Download packages without unpacking + * Update actions/checkout and setup actions to v6 + * Support packaging 26.0 changes + * Prevent tar-slip via path traversal and symlink escape in _untar_archive + * Complete path traversal protection by replacing os.makedirs with + safe_makedirs (CVE-2026-73030) +- drop support-packaging-26.patch, merged upstream + +------------------------------------------------------------------- Old: ---- support-packaging-26.patch unearth-0.18.2.tar.gz New: ---- unearth-0.18.3.tar.gz ----------(Old B)---------- Old: safe_makedirs (CVE-2026-73030) - drop support-packaging-26.patch, merged upstream ----------(Old E)---------- ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-unearth.spec ++++++ --- /var/tmp/diff_new_pack.vNnvmq/_old 2026-08-18 16:39:06.930582753 +0200 +++ /var/tmp/diff_new_pack.vNnvmq/_new 2026-08-18 16:39:06.932582825 +0200 @@ -18,14 +18,12 @@ %{?sle15_python_module_pythons} Name: python-unearth -Version: 0.18.2 +Version: 0.18.3 Release: 0 Summary: A utility to fetch and download python packages License: MIT URL: https://unearth.readthedocs.io/ Source: https://files.pythonhosted.org/packages/source/u/unearth/unearth-%{version}.tar.gz -# PATCH-FIX-UPSTREAM gh#frostming/unearth#176 -Patch0: support-packaging-26.patch BuildRequires: %{python_module base >= 3.9} BuildRequires: %{python_module packaging >= 20} BuildRequires: %{python_module pdm-backend} ++++++ unearth-0.18.2.tar.gz -> unearth-0.18.3.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.18.2/PKG-INFO new/unearth-0.18.3/PKG-INFO --- old/unearth-0.18.2/PKG-INFO 1970-01-01 01:00:00.000000000 +0100 +++ new/unearth-0.18.3/PKG-INFO 1970-01-01 01:00:00.000000000 +0100 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: unearth -Version: 0.18.2 +Version: 0.18.3 Summary: A utility to fetch and download python packages Author-Email: Frost Ming <[email protected]> License-Expression: MIT diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.18.2/pyproject.toml new/unearth-0.18.3/pyproject.toml --- old/unearth-0.18.2/pyproject.toml 2025-12-23 07:40:14.274020000 +0100 +++ new/unearth-0.18.3/pyproject.toml 2026-08-14 06:13:52.676418800 +0200 @@ -33,7 +33,7 @@ "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3 :: Only", ] -version = "0.18.2" +version = "0.18.3" [project.urls] Homepage = "https://github.com/frostming/unearth" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.18.2/src/unearth/__main__.py new/unearth-0.18.3/src/unearth/__main__.py --- old/unearth-0.18.2/src/unearth/__main__.py 2025-12-23 07:40:02.004991500 +0100 +++ new/unearth-0.18.3/src/unearth/__main__.py 2026-08-14 06:13:27.665497800 +0200 @@ -7,15 +7,12 @@ import logging import os import sys -import tempfile from dataclasses import dataclass from packaging.requirements import Requirement from unearth.evaluator import TargetPython from unearth.finder import PackageFinder -from unearth.link import Link -from unearth.utils import splitext @dataclass(frozen=True) @@ -147,14 +144,6 @@ return parser -def get_dest_for_package(dest: str, link: Link) -> str: - if link.is_wheel: - return dest - filename = link.filename.rsplit("@", 1)[0] - fn, _ = splitext(filename) - return os.path.join(dest, fn) - - def cli(argv: list[str] | None = None) -> None: parser = cli_parser() args = CLIArgs(**vars(parser.parse_args(argv))) @@ -181,17 +170,14 @@ result = [] if args.download: os.makedirs(args.download, exist_ok=True) - with tempfile.TemporaryDirectory("unearth-download-") as download_dir: - for match in matches: - data = match.as_json() - if args.download is not None: - dest = get_dest_for_package(args.download, match.link) - data["local_path"] = finder.download_and_unpack( - match.link, - dest, - download_dir, - ).as_posix() - result.append(data) + for match in matches: + data = match.as_json() + if args.download is not None: + data["local_path"] = finder.download( + match.link, + args.download, + ).as_posix() + result.append(data) if args.link_only: for item in result: print(item["link"]["url"]) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.18.2/src/unearth/auth.py new/unearth-0.18.3/src/unearth/auth.py --- old/unearth-0.18.2/src/unearth/auth.py 2025-12-23 07:40:02.004991500 +0100 +++ new/unearth-0.18.3/src/unearth/auth.py 2026-08-14 06:13:27.665497800 +0200 @@ -6,7 +6,7 @@ import os import shutil import subprocess -from typing import TYPE_CHECKING, Literal, Optional, Tuple, cast +from typing import TYPE_CHECKING, Literal, Optional, cast from urllib.parse import SplitResult, urlparse, urlsplit from httpx import URL, Auth, BasicAuth @@ -14,7 +14,8 @@ from unearth.utils import commonprefix, get_netrc_auth, split_auth_from_url if TYPE_CHECKING: - from typing import Any, Callable, Generator, Iterable + from collections.abc import Generator, Iterable + from typing import Any, Callable from httpx import Request, Response from requests import Response as RequestsResponse @@ -22,8 +23,8 @@ KEYRING_DISABLED = False -AuthInfo = Tuple[str, str] -MaybeAuth = Optional[Tuple[str, Optional[str]]] +AuthInfo = tuple[str, str] +MaybeAuth = Optional[tuple[str, Optional[str]]] logger = logging.getLogger(__name__) @@ -123,7 +124,11 @@ cmd = [self.keyring, f"--mode={mode}", "get", service_name, username] env = dict(os.environ, PYTHONIOENCODING="utf-8") res = subprocess.run( - cmd, stdin=subprocess.DEVNULL, capture_output=True, env=env + cmd, + stdin=subprocess.DEVNULL, + capture_output=True, + env=env, + check=False, ) if res.returncode: return None @@ -132,7 +137,7 @@ def _set_password(self, service_name: str, username: str, password: str) -> None: """Mirror the implementation of keyring.set_password using cli""" if self.keyring is None: - return None + return cmd = [self.keyring, "set", service_name, username] input_ = (password + os.linesep).encode("utf-8") @@ -149,7 +154,7 @@ return KeyringModuleProvider() except ImportError: pass - except Exception as exc: + except Exception as exc: # noqa: BLE001 logger.warning( "Importing keyring failed: %s, trying to find a keyring executable.", exc, @@ -172,7 +177,7 @@ return None try: return keyring.get_auth_info(url, username) - except Exception as exc: + except Exception as exc: # noqa: BLE001 logger.warning( "Keyring is skipped due to an exception: %s", str(exc), diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.18.2/src/unearth/collector.py new/unearth-0.18.3/src/unearth/collector.py --- old/unearth-0.18.2/src/unearth/collector.py 2025-12-23 07:40:02.004991500 +0100 +++ new/unearth-0.18.3/src/unearth/collector.py 2026-08-14 06:13:27.665497800 +0200 @@ -6,9 +6,10 @@ import json import logging import mimetypes +from collections.abc import Iterable, Mapping from datetime import datetime from html.parser import HTMLParser -from typing import Iterable, Mapping, NamedTuple +from typing import NamedTuple from urllib import parse from unearth.fetchers import Fetcher, Response @@ -234,12 +235,10 @@ resp = session.get( location.normalized, headers={ - "Accept": ", ".join( - [ - "application/vnd.pypi.simple.v1+json", - "application/vnd.pypi.simple.v1+html; q=0.1", - "text/html; q=0.01", - ] + "Accept": ( + "application/vnd.pypi.simple.v1+json, " + "application/vnd.pypi.simple.v1+html; q=0.1, " + "text/html; q=0.01" ), **(headers or {}), }, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.18.2/src/unearth/evaluator.py new/unearth-0.18.3/src/unearth/evaluator.py --- old/unearth-0.18.2/src/unearth/evaluator.py 2025-12-23 07:40:02.004991500 +0100 +++ new/unearth-0.18.3/src/unearth/evaluator.py 2026-08-14 06:13:27.665497800 +0200 @@ -184,10 +184,8 @@ ) from e if not requires_python.contains(py_version, True): raise LinkMismatchError( - "The target python version({}) doesn't match " - "the requires-python specifier {}".format( - py_version, link.requires_python - ), + f"The target python version({py_version}) doesn't match " + f"the requires-python specifier {link.requires_python}", ) def validate_wheel_tag(self, tags: frozenset[Tag]) -> bool: @@ -296,12 +294,13 @@ Returns: bool: True if the package matches the requirement, False otherwise """ - if requirement.name: - if canonicalize_name(package.name) != canonicalize_name(requirement.name): - logger.debug( - "Skipping package %s: name doesn't match %s", package, requirement.name - ) - return False + if requirement.name and canonicalize_name(package.name) != canonicalize_name( + requirement.name + ): + logger.debug( + "Skipping package %s: name doesn't match %s", package, requirement.name + ) + return False if package.version and not requirement.specifier.contains( package.version, prereleases=allow_prereleases @@ -338,9 +337,7 @@ for hash_name, allowed_hashes in hashes.items(): if hash_name in link_hashes: given_hash = link_hashes[hash_name][0] - if given_hash not in allowed_hashes: - return False - return True + return given_hash in allowed_hashes hash_name, allowed_hashes = next(iter(hashes.items())) given_hash = _get_hash(link, hash_name, session) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.18.2/src/unearth/fetchers/__init__.py new/unearth-0.18.3/src/unearth/fetchers/__init__.py --- old/unearth-0.18.2/src/unearth/fetchers/__init__.py 2025-12-23 07:40:02.004991500 +0100 +++ new/unearth-0.18.3/src/unearth/fetchers/__init__.py 2026-08-14 06:13:27.665497800 +0200 @@ -1,6 +1,8 @@ from __future__ import annotations -from typing import ContextManager, Iterable, Iterator, Mapping, Protocol +from collections.abc import Iterable, Iterator, Mapping +from contextlib import AbstractContextManager +from typing import Protocol from unearth.fetchers.sync import PyPIClient as PyPIClient @@ -45,7 +47,7 @@ def get_stream( self, url: str, *, headers: Mapping[str, str] | None = None - ) -> ContextManager[Response]: ... + ) -> AbstractContextManager[Response]: ... def __hash__(self) -> int: ... diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.18.2/src/unearth/fetchers/legacy.py new/unearth-0.18.3/src/unearth/fetchers/legacy.py --- old/unearth-0.18.2/src/unearth/fetchers/legacy.py 2025-12-23 07:40:02.005991500 +0100 +++ new/unearth-0.18.3/src/unearth/fetchers/legacy.py 2026-08-14 06:13:27.665897400 +0200 @@ -7,8 +7,9 @@ import mimetypes import os import warnings +from collections.abc import Iterable, Iterator from pathlib import Path -from typing import Any, Iterable, Iterator, cast +from typing import Any, cast import urllib3 @@ -69,7 +70,7 @@ } ) - resp.raw = open(path, "rb") + resp.raw = open(path, "rb") # noqa: SIM115 resp.close = resp.raw.close # type: ignore[method-assign] return resp diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.18.2/src/unearth/fetchers/sync.py new/unearth-0.18.3/src/unearth/fetchers/sync.py --- old/unearth-0.18.2/src/unearth/fetchers/sync.py 2025-12-23 07:40:02.005991500 +0100 +++ new/unearth-0.18.3/src/unearth/fetchers/sync.py 2026-08-14 06:13:27.665897400 +0200 @@ -14,7 +14,9 @@ if TYPE_CHECKING: import ssl - from typing import Any, ContextManager, Iterable, Mapping + from collections.abc import Iterable, Mapping + from contextlib import AbstractContextManager + from typing import Any from httpx._types import CertTypes, TimeoutTypes @@ -117,7 +119,7 @@ def get_stream( self, url: str, *, headers: Mapping[str, str] | None = None - ) -> ContextManager[httpx.Response]: + ) -> AbstractContextManager[httpx.Response]: return self.stream("GET", url, headers=headers) def iter_secure_origins(self) -> Iterable[tuple[str, str, str]]: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.18.2/src/unearth/finder.py new/unearth-0.18.3/src/unearth/finder.py --- old/unearth-0.18.2/src/unearth/finder.py 2025-12-23 07:40:02.005991500 +0100 +++ new/unearth-0.18.3/src/unearth/finder.py 2026-08-14 06:13:27.665897400 +0200 @@ -9,9 +9,10 @@ import pathlib import posixpath import warnings +from collections.abc import Generator, Iterable, Sequence from datetime import datetime from tempfile import TemporaryDirectory -from typing import TYPE_CHECKING, Any, Generator, Iterable, NamedTuple, Sequence +from typing import TYPE_CHECKING, Any, NamedTuple import packaging.requirements from packaging.utils import BuildTag, canonicalize_name, parse_wheel_filename @@ -31,7 +32,12 @@ from unearth.fetchers import Fetcher from unearth.fetchers.sync import PyPIClient from unearth.link import Link -from unearth.preparer import noop_download_reporter, noop_unpack_reporter, unpack_link +from unearth.preparer import ( + download_link, + noop_download_reporter, + noop_unpack_reporter, + unpack_link, +) from unearth.utils import LazySequence if TYPE_CHECKING: @@ -460,3 +466,31 @@ unpack_reporter=unpack_reporter, ) return file.joinpath(link.subdirectory) if link.subdirectory else file + + def download( + self, + link: Link, + location: str | pathlib.Path, + hashes: dict[str, list[str]] | None = None, + download_reporter: DownloadReporter = noop_download_reporter, + ) -> pathlib.Path: + """Download the package at the given link without unpacking it. + + Args: + link: The link to download. + location: The directory to download the artifact to. + hashes: The optional hash dict for validation. + download_reporter: The download reporter for progress reporting. + + Returns: + The path to the downloaded artifact. + """ + if hashes is None: + hashes = link.hash_option + return download_link( + self.session, + link, + pathlib.Path(location), + hashes, + download_reporter, + ) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.18.2/src/unearth/link.py new/unearth-0.18.3/src/unearth/link.py --- old/unearth-0.18.2/src/unearth/link.py 2025-12-23 07:40:02.005991500 +0100 +++ new/unearth-0.18.3/src/unearth/link.py 2026-08-14 06:13:27.665897400 +0200 @@ -79,7 +79,7 @@ def __hash__(self) -> int: return hash(self.__ident()) - def __eq__(self, __o: object) -> bool: + def __eq__(self, __o: object, /) -> bool: return isinstance(__o, Link) and self.__ident() == __o.__ident() @classmethod diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.18.2/src/unearth/preparer.py new/unearth-0.18.3/src/unearth/preparer.py --- old/unearth-0.18.2/src/unearth/preparer.py 2025-12-23 07:40:02.005991500 +0100 +++ new/unearth-0.18.3/src/unearth/preparer.py 2026-08-14 06:13:27.665897400 +0200 @@ -11,8 +11,9 @@ import stat import tarfile import zipfile +from collections.abc import Iterable from pathlib import Path -from typing import TYPE_CHECKING, Iterable, cast +from typing import TYPE_CHECKING, cast import httpx @@ -79,12 +80,31 @@ def is_within_directory(directory: str | Path, path: str | Path) -> bool: try: - Path(path).relative_to(directory) + Path(os.path.realpath(path)).relative_to(os.path.realpath(directory)) except ValueError: return False return True +def safe_makedirs(dest_dir: str | Path, location: str | Path) -> None: + """Create directory, then verify the resolved path is still within location. + + The post-creation check is necessary because ``is_within_directory`` uses + ``os.path.realpath``, which cannot resolve symlinks along a path that does + not yet exist. A malicious archive can first extract a symlink pointing + outside ``location`` and then reference a path through that symlink. By + re-checking *after* ``os.makedirs`` has materialised the directory (and + any intermediate symlinks have landed on disk), we catch traversals that + the pre-creation check misses. + """ + os.makedirs(dest_dir, exist_ok=True) + if not is_within_directory(location, dest_dir): + raise UnpackError( + f"Path traversal detected: {dest_dir!r} resolves outside " + f"target directory ({location!r})" + ) + + def split_leading_dir(path: str) -> list[str]: path = path.lstrip("/").lstrip("\\") if "/" in path and ( @@ -187,8 +207,7 @@ def _unzip_archive(filename: Path, location: Path, reporter: UnpackReporter) -> None: os.makedirs(location, exist_ok=True) - zipfp = open(filename, "rb") - with zipfile.ZipFile(zipfp, allowZip64=True) as zip: + with zipfile.ZipFile(filename, allowZip64=True) as zip: leading = has_leading_dir(zip.namelist()) callback = functools.partial(reporter, filename, total=len(zip.infolist())) for info in iter_with_callback(zip.infolist(), callback): @@ -204,11 +223,11 @@ f"outside target directory ({location})" ) raise UnpackError(message) - if fn.endswith("/") or fn.endswith("\\"): + if fn.endswith(("/", "\\")): # A directory - os.makedirs(fn, exist_ok=True) + safe_makedirs(fn, location) else: - os.makedirs(dir, exist_ok=True) + safe_makedirs(dir, location) # Don't use read() to avoid allocating an arbitrarily large # chunk of memory for the file's content with zip.open(name) as fp, open(fn, "wb") as destfp: @@ -222,7 +241,7 @@ """Untar the file (with path `filename`) to the destination `location`.""" os.makedirs(location, exist_ok=True) lower_fn = str(filename).lower() - if lower_fn.endswith(".gz") or lower_fn.endswith(".tgz"): + if lower_fn.endswith((".gz", ".tgz")): mode = "r:gz" elif lower_fn.endswith(BZ2_EXTENSIONS): mode = "r:bz2" @@ -251,11 +270,24 @@ ) raise UnpackError(message) if member.isdir(): - os.makedirs(path, exist_ok=True) + safe_makedirs(path, location) elif member.issym(): + if os.path.isabs(member.linkname): + link_target = member.linkname + else: + link_target = os.path.join(os.path.dirname(path), member.linkname) + if not is_within_directory(location, link_target): + logger.warning( + "In the tar file %s the member %s -> %s points outside %s, skipping", + filename, + member.name, + member.linkname, + location, + ) + continue try: tar._extract_member(member, path) - except Exception as exc: + except Exception as exc: # noqa: BLE001 # Some corrupt tar files seem to produce this # (specifically bad symlinks) logger.warning( @@ -278,7 +310,7 @@ exc, ) continue - os.makedirs(os.path.dirname(path), exist_ok=True) + safe_makedirs(os.path.dirname(path), location) assert fp is not None with open(path, "wb") as destfp: shutil.copyfileobj(fp, destfp) @@ -323,6 +355,40 @@ download_reporter(link, 1, 1) return location + artifact = download_link( + session, + link, + download_dir, + hashes, + download_reporter=download_reporter, + ) + if artifact.is_dir(): + return artifact + if link.is_wheel: + if link.is_file: + # Use the local file directly + return artifact + target_file = location / link.filename + if target_file != artifact: + # For wheels downloaded from remote locations, move it to the destination. + os.replace(artifact, target_file) + return target_file + + unpack_archive(artifact, location, reporter=unpack_reporter) + return location + + +def download_link( + session: Fetcher, + link: Link, + download_dir: Path, + hashes: dict[str, list[str]] | None = None, + download_reporter: DownloadReporter = noop_download_reporter, +) -> Path: + """Download an artifact link without unpacking it.""" + if link.is_vcs: + raise UnpackError("VCS links cannot be downloaded without unpacking") + validator = HashValidator(link, hashes) if link.is_file: if link.file_path.is_dir(): @@ -334,7 +400,8 @@ artifact = link.file_path validator.validate_path(artifact) else: - # A remote artfiact link, check the download dir first + # A remote artifact link, check the download dir first. + download_dir.mkdir(parents=True, exist_ok=True) artifact = download_dir / link.filename if not _check_downloaded(artifact, hashes): with session.get_stream(link.normalized) as resp: @@ -362,15 +429,4 @@ validator.update(chunk) f.write(chunk) validator.validate() - if link.is_wheel: - if link.is_file: - # Use the local file directly - return artifact - target_file = location / link.filename - if target_file != artifact: - # For wheels downloaded from remote locations, move it to the destination. - os.replace(artifact, target_file) - return target_file - - unpack_archive(artifact, location, reporter=unpack_reporter) - return location + return artifact diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.18.2/src/unearth/utils.py new/unearth-0.18.3/src/unearth/utils.py --- old/unearth-0.18.2/src/unearth/utils.py 2025-12-23 07:40:02.005991500 +0100 +++ new/unearth-0.18.3/src/unearth/utils.py 2026-08-14 06:13:27.665897400 +0200 @@ -8,10 +8,11 @@ import os import re import sys -import urllib.parse as parse import warnings +from collections.abc import Iterable, Iterator, Sequence from pathlib import Path -from typing import Callable, Iterable, Iterator, Sequence, TypeVar +from typing import Callable, TypeVar +from urllib import parse from urllib.request import url2pathname WINDOWS = sys.platform == "win32" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.18.2/src/unearth/vcs/base.py new/unearth-0.18.3/src/unearth/vcs/base.py --- old/unearth-0.18.2/src/unearth/vcs/base.py 2025-12-23 07:40:02.005991500 +0100 +++ new/unearth-0.18.3/src/unearth/vcs/base.py 2026-08-14 06:13:27.666519600 +0200 @@ -5,8 +5,9 @@ import os import shutil import subprocess +from collections.abc import Collection, Sequence from pathlib import Path -from typing import Collection, Sequence, Type, TypeVar, cast +from typing import TypeVar, cast from unearth.errors import UnpackError, URLError, VCSBackendError from unearth.link import Link @@ -172,7 +173,6 @@ rev (str|None): the revision to checkout args (list[str | HiddenText]): the arguments to pass to the update command """ - pass @abc.abstractmethod def update( @@ -185,7 +185,6 @@ rev (str|None): the revision to checkout args (list[str | HiddenText]): the arguments to pass to the update command """ - pass @abc.abstractmethod def get_remote_url(self, location: Path) -> str: @@ -195,7 +194,6 @@ @abc.abstractmethod def get_revision(self, location: Path) -> str: """Get the commit hash of the repository.""" - pass def is_immutable_revision(self, location: Path, link: Link) -> bool: """Check if the revision is immutable. @@ -230,7 +228,7 @@ return [] -_V = TypeVar("_V", bound=Type[VersionControl]) +_V = TypeVar("_V", bound=type[VersionControl]) class VcsSupport: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.18.2/src/unearth/vcs/git.py new/unearth-0.18.3/src/unearth/vcs/git.py --- old/unearth-0.18.2/src/unearth/vcs/git.py 2025-12-23 07:40:02.005991500 +0100 +++ new/unearth-0.18.3/src/unearth/vcs/git.py 2026-08-14 06:13:27.666519600 +0200 @@ -94,11 +94,8 @@ # Git fetch would fail with abbreviated commits. return False - if self.has_commit(dest, rev): - # Don't fetch if we have the commit locally. - return False - - return True + # Don't fetch if we have the commit locally. + return not self.has_commit(dest, rev) def has_commit(self, location: Path, rev: str) -> bool: """ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.18.2/src/unearth/vcs/svn.py new/unearth-0.18.3/src/unearth/vcs/svn.py --- old/unearth-0.18.2/src/unearth/vcs/svn.py 2025-12-23 07:40:02.005991500 +0100 +++ new/unearth-0.18.3/src/unearth/vcs/svn.py 2026-08-14 06:13:27.666519600 +0200 @@ -138,7 +138,7 @@ data = "" url = None - if data.startswith("8") or data.startswith("9") or data.startswith("10"): + if data.startswith(("8", "9", "10")): entries = list(map(str.splitlines, data.split("\n\x0c\n"))) del entries[0][0] # get rid of the '8' url = entries[0][3] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.18.2/tests/test_cli.py new/unearth-0.18.3/tests/test_cli.py --- old/unearth-0.18.2/tests/test_cli.py 1970-01-01 01:00:00.000000000 +0100 +++ new/unearth-0.18.3/tests/test_cli.py 2026-08-14 06:13:27.668556500 +0200 @@ -0,0 +1,20 @@ +import json + +from unearth.__main__ import cli +from unearth.evaluator import Package +from unearth.link import Link + + +def test_download_does_not_unpack(mocker, tmp_path, capsys): + link = Link("https://example.org/first-2.0.2.tar.gz") + match = Package("first", "2.0.2", link) + finder = mocker.patch("unearth.__main__.PackageFinder").return_value + finder.find_matches.return_value = [match] + downloaded = tmp_path / link.filename + finder.download.return_value = downloaded + + cli(["--no-binary", "--download", str(tmp_path), "first"]) + + finder.download.assert_called_once_with(link, str(tmp_path)) + finder.download_and_unpack.assert_not_called() + assert json.loads(capsys.readouterr().out)["local_path"] == downloaded.as_posix() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.18.2/tests/test_evaluator.py new/unearth-0.18.3/tests/test_evaluator.py --- old/unearth-0.18.2/tests/test_evaluator.py 2025-12-23 07:40:02.007991600 +0100 +++ new/unearth-0.18.3/tests/test_evaluator.py 2026-08-14 06:13:27.668556500 +0200 @@ -145,13 +145,17 @@ "url,match", [ ( - "https://test.pypi.org/files/click-8.1.3-py3-none-any.whl" - "#sha256=1234567890abcdef", + ( + "https://test.pypi.org/files/click-8.1.3-py3-none-any.whl" + "#sha256=1234567890abcdef" + ), True, ), ( - "https://test.pypi.org/files/click-8.1.3-py3-none-any.whl" - "#sha256=fedcba0987654321", + ( + "https://test.pypi.org/files/click-8.1.3-py3-none-any.whl" + "#sha256=fedcba0987654321" + ), True, ), ( @@ -251,9 +255,9 @@ ("8.1.3", ">=8.0", None, True), ("7.1", ">=8.0", None, False), ("8.0.0a0", ">=8.0.0dev0", None, True), - ("8.0.0dev0", ">=7", None, False), + ("8.0.0dev0", ">=7", False, False), ("8.0.0dev0", ">=7", True, True), - ("8.0.0a0", "", None, False), + ("8.0.0a0", "", False, False), ("8.0.0a0", ">=8.0.0dev0", False, False), ], ) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/unearth-0.18.2/tests/test_finder.py new/unearth-0.18.3/tests/test_finder.py --- old/unearth-0.18.2/tests/test_finder.py 2025-12-23 07:40:02.007991600 +0100 +++ new/unearth-0.18.3/tests/test_finder.py 2026-08-14 06:13:27.668556500 +0200 @@ -234,6 +234,34 @@ assert filename == downloaded +def test_download_package_file_without_unpacking(pypi_session, fixtures_dir, tmp_path): + finder = PackageFinder( + session=pypi_session, + index_urls=[DEFAULT_INDEX_URL], + ignore_compatibility=True, + ) + found = finder.find_best_match("first").best.link + download_reports = [] + + def download_reporter(link, completed, total): + download_reports.append((link, completed, total)) + + downloaded = finder.download( + found, + tmp_path / "download", + download_reporter=download_reporter, + ) + + assert downloaded == tmp_path / "download" / found.filename + assert ( + downloaded.read_bytes() + == (fixtures_dir / "files" / found.filename).read_bytes() + ) + assert list(downloaded.parent.iterdir()) == [downloaded] + _, completed, total = download_reports[-1] + assert completed == total == downloaded.stat().st_size + + def test_exclude_newer_than(pypi_session, content_type): finder = PackageFinder( session=pypi_session,
