From 72e0a19c227df20915538e6a3ee6976608a4922f Mon Sep 17 00:00:00 2001 From: Vinay Kaushal Date: Fri, 16 Oct 2026 03:37:31 +0530 Subject: [PATCH] item.pyi: sync with item.pyx, add a test that they agree, refs #10021 The hand-maintained stub had drifted from the PropDictProperty table: - add missing properties: Item.target, ArchiveItem.command_line, ArchiveItem.recreate_command_line, ArchiveItem.cwd, Key.crypt_key, EncryptedKey.argon2_time_cost/argon2_memory_cost/argon2_parallelism/argon2_type - remove properties that do not exist: ArchiveItem.csize, Key.enc_key, Key.enc_hmac_key - use precise types instead of Any for ArchiveItem.recreate_args, recreate_partial_chunks and recreate_source_id - ManifestItem.archives is a dict with str keys, not Mapping[bytes, dict] - declare PropDictProperty The new test parses item.pyi and checks, for every PropDict subclass, that the stub declares exactly the PropDictProperty attributes of item.pyx and that the annotated types match their value_type, so they cannot drift apart again. --- src/borg/item.pyi | 75 ++++++++++++++++++++++++--------- src/borg/testsuite/item_test.py | 37 ++++++++++++++++ 2 files changed, 92 insertions(+), 20 deletions(-) diff --git a/src/borg/item.pyi b/src/borg/item.pyi index f4f5ee3a06..cf731cbe7a 100644 --- a/src/borg/item.pyi +++ b/src/borg/item.pyi @@ -1,4 +1,4 @@ -from collections.abc import Callable, Iterator, Mapping +from collections.abc import Callable, Iterator from typing import Any, NamedTuple from .helpers import StableDict @@ -21,6 +21,17 @@ class PropDict: def __contains__(self, key: str) -> bool: ... def __eq__(self, other: object) -> bool: ... +class PropDictProperty: + key: str + value_type: type + def __init__( + self, + value_type: type, + value_type_name: str | None = ..., + encode: Callable | None = ..., + decode: Callable | None = ..., + ) -> None: ... + class ArchiveItem(PropDict): @property def version(self) -> int: ... @@ -75,17 +86,29 @@ class ArchiveItem(PropDict): @recreate_cmdline.setter def recreate_cmdline(self, val: list[str]) -> None: ... @property - def recreate_args(self) -> Any: ... + def command_line(self) -> str: ... + @command_line.setter + def command_line(self, val: str) -> None: ... + @property + def recreate_command_line(self) -> str: ... + @recreate_command_line.setter + def recreate_command_line(self, val: str) -> None: ... + @property + def recreate_args(self) -> list[str]: ... @recreate_args.setter - def recreate_args(self, val: Any) -> None: ... + def recreate_args(self, val: list[str]) -> None: ... @property - def recreate_partial_chunks(self) -> Any: ... + def recreate_partial_chunks(self) -> list: ... @recreate_partial_chunks.setter - def recreate_partial_chunks(self, val: Any) -> None: ... + def recreate_partial_chunks(self, val: list) -> None: ... @property - def recreate_source_id(self) -> Any: ... + def recreate_source_id(self) -> bytes: ... @recreate_source_id.setter - def recreate_source_id(self, val: Any) -> None: ... + def recreate_source_id(self, val: bytes) -> None: ... + @property + def cwd(self) -> str: ... + @cwd.setter + def cwd(self, val: str) -> None: ... @property def nfiles(self) -> int: ... @nfiles.setter @@ -103,10 +126,6 @@ class ArchiveItem(PropDict): @size_parts.setter def size_parts(self, val: int) -> None: ... @property - def csize(self) -> int: ... - @csize.setter - def csize(self, val: int) -> None: ... - @property def items(self) -> list: ... @items.setter def items(self, val: list) -> None: ... @@ -128,6 +147,10 @@ class Item(PropDict): def source(self) -> str: ... @source.setter def source(self, val: str) -> None: ... + @property + def target(self) -> str: ... + @target.setter + def target(self, val: str) -> None: ... def is_dir(self) -> bool: ... def is_link(self) -> bool: ... def _is_type(self, typetest: Callable) -> bool: ... @@ -250,9 +273,9 @@ class ManifestItem(PropDict): @timestamp.setter def timestamp(self, val: str) -> None: ... @property - def archives(self) -> Mapping[bytes, dict]: ... + def archives(self) -> dict[str, dict]: ... @archives.setter - def archives(self, val: Mapping[bytes, dict]) -> None: ... + def archives(self, val: dict[str, dict]) -> None: ... @property def config(self) -> dict: ... @config.setter @@ -313,13 +336,9 @@ class Key(PropDict): @tam_required.setter def tam_required(self, val: bool) -> None: ... @property - def enc_hmac_key(self) -> bytes: ... - @enc_hmac_key.setter - def enc_hmac_key(self, val: bytes) -> None: ... - @property - def enc_key(self) -> bytes: ... - @enc_key.setter - def enc_key(self, val: bytes) -> None: ... + def crypt_key(self) -> bytes: ... + @crypt_key.setter + def crypt_key(self, val: bytes) -> None: ... @property def id_key(self) -> bytes: ... @id_key.setter @@ -355,6 +374,22 @@ class EncryptedKey(PropDict): @hash.setter def hash(self, val: bytes) -> None: ... @property + def argon2_time_cost(self) -> int: ... + @argon2_time_cost.setter + def argon2_time_cost(self, val: int) -> None: ... + @property + def argon2_memory_cost(self) -> int: ... + @argon2_memory_cost.setter + def argon2_memory_cost(self, val: int) -> None: ... + @property + def argon2_parallelism(self) -> int: ... + @argon2_parallelism.setter + def argon2_parallelism(self, val: int) -> None: ... + @property + def argon2_type(self) -> str: ... + @argon2_type.setter + def argon2_type(self, val: str) -> None: ... + @property def label(self) -> str: ... @label.setter def label(self, val: str) -> None: ... diff --git a/src/borg/testsuite/item_test.py b/src/borg/testsuite/item_test.py index 044796a72c..e517132520 100644 --- a/src/borg/testsuite/item_test.py +++ b/src/borg/testsuite/item_test.py @@ -1,7 +1,12 @@ +import ast +from pathlib import Path + import pytest +from .. import item as item_module from ..cache import ChunkListEntry from ..item import MAX_ALIGN_CHUNKS, MAX_ALIGN_WORK, Item, ItemDiff, chunks_contents_equal, chunks_diff_size +from ..item import PropDict, PropDictProperty from ..helpers import StableDict from ..helpers.msgpack import Timestamp from ..platformflags import is_pypy @@ -234,3 +239,35 @@ def test_chunks_diff_size_over_work_limit(): assert chunks_diff_size(chunks1, chunks2) == (0, 0) # a chunk that really was added is still counted correctly. assert chunks_diff_size(chunks1, chunks2 + [CC]) == (10, 0) + + +def stub_properties(): + """Return {class name: {property name: {"get": annotation, "set": annotation}}} as declared in item.pyi.""" + stub = ast.parse(Path(item_module.__file__).with_name("item.pyi").read_text()) + classes = {} + for cls in stub.body: + if not isinstance(cls, ast.ClassDef): + continue + props = classes[cls.name] = {} + for func in cls.body: + if not isinstance(func, ast.FunctionDef): + continue + for decorator in func.decorator_list: + if isinstance(decorator, ast.Name) and decorator.id == "property": + props.setdefault(func.name, {})["get"] = ast.unparse(func.returns) + elif isinstance(decorator, ast.Attribute) and decorator.attr == "setter": + props.setdefault(func.name, {})["set"] = ast.unparse(func.args.args[1].annotation) + return classes + + +@pytest.mark.parametrize("cls", PropDict.__subclasses__(), ids=lambda cls: cls.__name__) +def test_stub_matches_propdict_properties(cls): + """item.pyi is maintained by hand, so check that it agrees with the PropDictProperty table in item.pyx.""" + stub_props = stub_properties()[cls.__name__] + props = {name: value for name, value in vars(cls).items() if isinstance(value, PropDictProperty)} + assert sorted(stub_props) == sorted(props) + for name, prop in props.items(): + getter, setter = stub_props[name].get("get"), stub_props[name].get("set") + assert getter == setter, f"{cls.__name__}.{name}: getter and setter annotations differ" + # compare the base type only, e.g. "list[str]" -> "list". + assert getter.split("[")[0] == prop.value_type.__name__, f"{cls.__name__}.{name}: wrong type"