From 2e81fb66314cb6c9343d421f666f87b2f8aefe2c Mon Sep 17 00:00:00 2001 From: Jose Date: Fri, 14 Aug 2026 12:50:10 +0200 Subject: [PATCH 1/4] Close the gaps in the IcePy stub/module consistency check The checker now compares the stub's 26 annotated attributes against the module's getset docstrings, tolerates paragraph reflow (equal-indent lines join; blank lines, indentation changes, and section underlines still have to match), reports descriptions the module ships that the stub lacks, reports duplicate stub declarations instead of half-comparing them, exempts only documentation CPython itself generates (slot wrappers, members inherited from object, the __hash__ = None of an unhashable type) instead of every dunder by name, and sweeps the module's own inventory in reverse so a public member the stub omits is reported. The reverse-prose check surfaced nine top-level functions the module documents but the stub did not; their docstrings are copied into the stub. --- python/python/IcePy-stubs/__init__.pyi | 176 +++++++++++++++++++++-- scripts/checkIcePyStub.py | 188 +++++++++++++++++++++---- 2 files changed, 325 insertions(+), 39 deletions(-) diff --git a/python/python/IcePy-stubs/__init__.pyi b/python/python/IcePy-stubs/__init__.pyi index 10367b91d36..9322c4791ac 100644 --- a/python/python/IcePy-stubs/__init__.pyi +++ b/python/python/IcePy-stubs/__init__.pyi @@ -715,19 +715,177 @@ class WSEndpointInfo(EndpointInfo): resource: str """str: The URI configured with the endpoint.""" -def stringVersion() -> str: ... -def intVersion() -> int: ... -def createProperties(args: list[str] | None = None, defaults: Ice.Properties | None = None, /) -> Properties: ... -def stringToIdentity(str: str, /) -> Ice.Identity: ... -def identityToString(identity: Ice.Identity, toStringMode: Ice.ToStringMode | None = None, /) -> str: ... -def getProcessLogger() -> Ice.Logger | Logger: ... -def setProcessLogger(logger: Ice.Logger, /) -> None: ... +def stringVersion() -> str: + """ + Returns the Ice version in the form ``A.B.C``, where ``A`` indicates the major version, ``B`` indicates the + minor version, and ``C`` indicates the patch level. + For pre-releases, the version includes a pre-release suffix, for example ``3.9.0-alpha.0``. + + Returns + ------- + str + The Ice version. + """ + ... + +def intVersion() -> int: + """ + Returns the Ice version as an integer in the form ``AABBCC``, where ``AA`` indicates the major version, + ``BB`` indicates the minor version, and ``CC`` indicates the patch level. + For example, for Ice 3.9.1, the returned value is 30901. + For pre-releases, ``CC`` encodes the pre-release; for example, for Ice 3.9.0-alpha.0, the returned value + is 30950. + + Returns + ------- + int + The Ice version. + """ + ... + +def createProperties(args: list[str] | None = None, defaults: Ice.Properties | None = None, /) -> Properties: + """ + Creates a property set initialized from command-line arguments and a default property set. + + Parameters + ---------- + args : list[str] | None, optional + The command-line arguments. + defaults : Ice.Properties | None, optional + Default values for the new property set. + + Returns + ------- + Properties + A new property set. + """ + ... + +def stringToIdentity(str: str, /) -> Ice.Identity: + """ + Converts a stringified identity into an Identity. + + Parameters + ---------- + str : str + The stringified identity. + + Returns + ------- + Ice.Identity + An Identity created from the provided string. + + Raises + ------ + ParseException + If the string cannot be converted to an object identity. + LocalException + If the resulting identity has an empty name. + """ + ... + +def identityToString(identity: Ice.Identity, toStringMode: Ice.ToStringMode | None = None, /) -> str: + """ + Converts an Identity into a string using the specified mode. + + Parameters + ---------- + identity : Ice.Identity + The identity. + toStringMode : Ice.ToStringMode | None, optional + Specifies how to handle non-ASCII characters and non-printable ASCII characters. + The default is :const:`Ice.ToStringMode.Unicode`. + + Returns + ------- + str + The stringified identity. + """ + ... + +def getProcessLogger() -> Ice.Logger | Logger: + """ + Gets the per-process logger. + + Returns + ------- + Ice.Logger | Logger + The current per-process logger instance. + """ + ... + +def setProcessLogger(logger: Ice.Logger, /) -> None: + """ + Sets the per-process logger. Communicators created after this call use this logger unless a logger is set + in InitializationData or configured through logger properties such as Ice.LogFile. + + Parameters + ---------- + logger : Ice.Logger + The new per-process logger instance. + """ + ... # # Functions to load/compile Slice definitions with 'slice2py'. # -def loadSlice(args: list[str], /) -> None: ... -def compileSlice(args: list[str], /) -> int: ... +def loadSlice(args: list[str], /) -> None: + """ + Compiles Slice definitions and loads the generated code directly into the current Python environment. + + This function does not generate any Python source files. Instead, the generated Python code is loaded + directly into the running interpreter. + + This function does not generate any code for Slice files included by the Slice files being loaded. It is + the caller's responsibility to load all necessary Slice definitions. This can be done in a single call to + :func:`Ice.loadSlice` by providing all Slice files (including included files) in the `args` parameter, or + by making multiple calls to :func:`Ice.loadSlice`. + + When :func:`Ice.loadSlice` is called multiple times with the same Slice file, the corresponding Python + code is not reloaded. + + Parameters + ---------- + args : list[str] + The list of command-line arguments for the Slice loader. These arguments may include both compiler options and + the Slice files to compile. + + Supported compiler options: + + - ``-DNAME``: Define NAME as 1. + - ``-DNAME=DEF``: Define NAME as DEF. + - ``-UNAME``: Remove any definition for NAME. + - ``-IDIR``: Put DIR in the include file search path. + - ``-d``, ``--debug``: Print debug messages. + + Raises + ------ + RuntimeError + If an error occurs during Slice parsing or compilation. + """ + ... + +def compileSlice(args: list[str], /) -> int: + """ + Compiles Slice definitions. The behavior is identical to that of the `slice2py` compiler. + + Any errors or warnings emitted during compilation are printed to 'stderr'. + + This is an internal function used in the implementation of the `slice2py` Python script included in the + Ice Python package. + + Parameters + ---------- + args : list[str] + The list of command-line arguments for Slice compilation, following the same syntax as the `slice2py` + compiler. + + Returns + ------- + int + The exit code: 0 indicates success, and a non-zero value indicates failure. + """ + ... # # Internal API for IcePy diff --git a/scripts/checkIcePyStub.py b/scripts/checkIcePyStub.py index a956f6d759c..d1a5f44ffa4 100644 --- a/scripts/checkIcePyStub.py +++ b/scripts/checkIcePyStub.py @@ -13,6 +13,10 @@ the first line of each docstring -- so that line is a signature the stub also spells out, and it can drift just as easily. +Both directions are checked: every stub declaration against what the module ships, and every public +name the module defines against the stub, since a member the stub omits makes the type checker +reject a legitimate call. + Run it from the repository root after building Ice for Python: PYTHONPATH=python/python python3 scripts/checkIcePyStub.py @@ -23,11 +27,18 @@ import ast import copy import difflib +import inspect import sys +import types from pathlib import Path STUB = Path(__file__).parents[1] / "python" / "python" / "IcePy-stubs" / "__init__.pyi" +# Bookkeeping every type carries; nothing for a stub to declare. Dunders that vars(object) lists are +# skipped the same way, which also deliberately leaves the __hash__ = None of an unhashable type +# undeclared -- what a stub must declare is a slot object does not have, like ExecutorCall's __call__. +TYPE_METADATA = {"__dict__", "__weakref__", "__module__", "__qualname__", "__new__", "__slots__"} + def signatureOf(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str: """Render a stub def the way the first line of a C docstring spells it.""" @@ -40,39 +51,105 @@ def signatureOf(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str: return f"{rendered} -> {ast.unparse(node.returns)}" if node.returns else rendered -def stubEntries() -> dict[str, tuple[str | None, str | None]]: - """Map each name in the stub to its (docstring, signature). Classes have no signature.""" +def attributeDoc(body: list[ast.stmt], index: int) -> str | None: + """Return the docstring of the annotated attribute at index: the string literal right below it.""" + if index + 1 < len(body): + after = body[index + 1] + if isinstance(after, ast.Expr) and isinstance(after.value, ast.Constant) and isinstance(after.value.value, str): + return inspect.cleandoc(after.value.value) + return None + + +def stubDeclarations() -> tuple[dict[str, tuple[str | None, str | None]], list[str], dict[str, set[str]], set[str]]: + """ + Parse the stub into (entries, duplicates, classMembers, topLevel). + + entries maps each documented name to its (docstring, signature); classes and attributes have no + signature. duplicates lists the names entries could not keep apart: repeated defs -- an @overload + set, say -- silently overwrite each other, so they are reported instead of half-compared. + classMembers maps each class to every member it declares, its stub base classes included, and + topLevel holds every name the stub declares at module scope; both exist for the reverse sweep. + """ entries: dict[str, tuple[str | None, str | None]] = {} + duplicates: list[str] = [] + ownMembers: dict[str, set[str]] = {} + bases: dict[str, list[str]] = {} + topLevel: set[str] = set() + + def add(name: str, doc: str | None, signature: str | None) -> None: + if name in entries: + duplicates.append(name) + entries[name] = (doc, signature) + for node in ast.parse(STUB.read_text(encoding="utf-8")).body: if isinstance(node, ast.ClassDef): - entries[node.name] = (ast.get_docstring(node), None) - for member in node.body: + topLevel.add(node.name) + add(node.name, ast.get_docstring(node), None) + members = ownMembers[node.name] = set() + bases[node.name] = [base.id for base in node.bases if isinstance(base, ast.Name)] + for index, member in enumerate(node.body): if isinstance(member, (ast.FunctionDef, ast.AsyncFunctionDef)): - entries[f"{node.name}.{member.name}"] = (ast.get_docstring(member), signatureOf(member)) + members.add(member.name) + add(f"{node.name}.{member.name}", ast.get_docstring(member), signatureOf(member)) + elif isinstance(member, ast.AnnAssign) and isinstance(member.target, ast.Name): + members.add(member.target.id) + add(f"{node.name}.{member.target.id}", attributeDoc(node.body, index), None) elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - entries[node.name] = (ast.get_docstring(node), signatureOf(node)) - return entries + topLevel.add(node.name) + add(node.name, ast.get_docstring(node), signatureOf(node)) + elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + topLevel.add(node.target.id) + elif isinstance(node, ast.Assign): + topLevel.update(target.id for target in node.targets if isinstance(target, ast.Name)) + + def membersOf(className: str) -> set[str]: + members = set(ownMembers.get(className, ())) + for base in bases.get(className, ()): + members |= membersOf(base) + return members + + return entries, duplicates, {name: membersOf(name) for name in ownMembers}, topLevel -def shipped(name: str) -> tuple[bool, str | None]: - """Return whether IcePy defines name, and the docstring it ships for it.""" +def shipped(name: str) -> tuple[bool, str | None, bool]: + """ + Return whether IcePy defines name, the docstring it ships for it, and whether that docstring is + CPython's rather than IcePy's. + + A slot wrapper (tp_init, tp_richcompare and friends), a member inherited from object, and the + __hash__ = None of an unhashable type all carry documentation CPython generates -- there is no + hand-written copy behind them for the stub to agree with. Members are resolved through the + class's own MRO because getattr on a class also consults the metaclass, which gives every class + a __call__ its instances do not have. + """ import IcePy - obj: object = IcePy - for part in name.split("."): - if not hasattr(obj, part): - return False, None - obj = getattr(obj, part) - return True, getattr(obj, "__doc__", None) + head, _, member = name.partition(".") + obj = vars(IcePy).get(head) + if obj is None: + return False, None, False + if not member: + doc = obj.__doc__ + if isinstance(obj, type) and doc == f"IcePy.{head}": + # The C sources set tp_doc to the type's own name as a placeholder for "undocumented". + doc = None + return True, doc, False + for cls in obj.__mro__: + if member in vars(cls): + value = vars(cls)[member] + if isinstance(value, (staticmethod, classmethod)): + value = value.__func__ # the wrapper's own __doc__ describes staticmethod itself + pythonSupplied = value is None or isinstance(value, types.WrapperDescriptorType) or cls is object + return True, None if value is None else getattr(value, "__doc__", None), pythonSupplied + return False, None, False def split(doc: str | None, name: str) -> tuple[str | None, str]: """ Separate a shipped docstring into its signature line and its prose. - The line only counts as a signature if it opens with the name being documented. Python supplies - its own docstring for an undocumented dunder -- "Return hash(self)." -- which otherwise looks - close enough to one to be mistaken for it. + The line only counts as a signature if it opens with the name being documented -- an attribute's + description, "bool: ...", must not be mistaken for one. """ if not doc: return None, "" @@ -82,33 +159,82 @@ def split(doc: str | None, name: str) -> tuple[str | None, str]: return None, doc.strip() -def isDunder(name: str) -> bool: - """Python writes its own docstring for a dunder it generates, so those carry no signature.""" - leaf = name.rsplit(".", maxsplit=1)[-1] - return leaf.startswith("__") and leaf.endswith("__") - - -def normalize(signature: str) -> str: +def normalizeSignature(signature: str) -> str: """Ignore spacing around default values: the stub is formatted by ast, the docstring by hand.""" return signature.replace(" = ", "=") +def normalizeProse(text: str) -> str: + """ + Fold hard line wrapping so reflowing a paragraph does not read as drift. + + Consecutive lines with the same indentation join into one; blank lines, indentation changes, and + section underlines (numpydoc's ``-------``) all break a run, so document structure still has to + match exactly. + """ + out: list[str] = [] + joinIndent = None # indentation of the line out[-1] belongs to, when it can accept continuations + for line in text.split("\n"): + stripped = line.strip() + underline = bool(stripped) and not stripped.strip("-=~^\"'`#*+_") + indent = len(line) - len(line.lstrip()) + if stripped and not underline and indent == joinIndent: + out[-1] = f"{out[-1]} {stripped}" + else: + out.append(line) + joinIndent = indent if stripped and not underline else None + return "\n".join(out) + + +def reverseProblems(classMembers: dict[str, set[str]], topLevel: set[str]) -> list[str]: + """The stub-driven walk cannot see a member the stub omits; sweep the module's own inventory.""" + import IcePy + + problems: list[str] = [] + for name in sorted(vars(IcePy)): + if not name.startswith("_") and name not in topLevel: + problems.append(f"{name}: IcePy defines it, but the stub does not declare it") + for className, members in sorted(classMembers.items()): + cls = vars(IcePy).get(className) + if not isinstance(cls, type): + continue # a class IcePy does not define is the stub-driven walk's finding + for member in sorted(vars(cls)): + if member in members or member.startswith("_") and not member.endswith("__"): + continue + if member.startswith("__") and (member in vars(object) or member in TYPE_METADATA): + continue + problems.append(f"{className}.{member}: IcePy defines it, but the stub does not declare it") + return problems + + def main() -> int: problems: list[str] = [] - for name, (stubDoc, stubSignature) in sorted(stubEntries().items()): - defined, shippedDoc = shipped(name) + entries, duplicates, classMembers, topLevel = stubDeclarations() + + for name in duplicates: + problems.append( + f"{name}: the stub declares it more than once; this check compares single definitions" + " only, so teach it about @overload first" + ) + + for name, (stubDoc, stubSignature) in sorted(entries.items()): + defined, shippedDoc, pythonSupplied = shipped(name) if not defined: # A private helper the stub declares for pyright need not be an attribute of the module, # but a public one going missing is the drift this is looking for. if not name.rsplit(".", maxsplit=1)[-1].startswith("_"): problems.append(f"{name}: declared in the stub, but IcePy does not define it") continue + if pythonSupplied: + continue signature, prose = split(shippedDoc, name) if stubDoc and not prose: problems.append(f"{name}: documented in the stub, but IcePy ships no description") - elif stubDoc and prose and stubDoc.strip() != prose: + elif prose and not stubDoc: + problems.append(f"{name}: IcePy ships a description, but the stub does not document it") + elif stubDoc and prose and normalizeProse(stubDoc.strip()) != normalizeProse(prose): diff = "\n".join( f" {line}" for line in difflib.unified_diff( @@ -117,14 +243,16 @@ def main() -> int: ) problems.append(f"{name}: descriptions differ\n{diff}") - if stubSignature and not signature and not isDunder(name): + if stubSignature and not signature: problems.append( f"{name}: the stub gives a signature, but IcePy's docstring opens with no matching one." "\n Sphinx reads the signature from that line, so it has to be there." ) - elif stubSignature and signature and normalize(stubSignature) != normalize(signature): + elif stubSignature and signature and normalizeSignature(stubSignature) != normalizeSignature(signature): problems.append(f"{name}: signatures differ\n stub: {stubSignature}\n IcePy: {signature}") + problems += reverseProblems(classMembers, topLevel) + if problems: print(f"{len(problems)} difference(s) between the IcePy stub and the IcePy module:\n", file=sys.stderr) for p in problems: From c8fc2644e6b31414cb382778626b3d12083ef96b Mon Sep 17 00:00:00 2001 From: Jose Date: Fri, 14 Aug 2026 19:18:33 +0200 Subject: [PATCH 2/4] Address IcePy stub checker review feedback --- scripts/checkIcePyStub.py | 109 +++++++++++++++++++++++++++++++------- 1 file changed, 91 insertions(+), 18 deletions(-) diff --git a/scripts/checkIcePyStub.py b/scripts/checkIcePyStub.py index d1a5f44ffa4..a4de856482f 100644 --- a/scripts/checkIcePyStub.py +++ b/scripts/checkIcePyStub.py @@ -28,6 +28,7 @@ import copy import difflib import inspect +import re import sys import types from pathlib import Path @@ -38,6 +39,9 @@ # skipped the same way, which also deliberately leaves the __hash__ = None of an unhashable type # undeclared -- what a stub must declare is a slot object does not have, like ExecutorCall's __call__. TYPE_METADATA = {"__dict__", "__weakref__", "__module__", "__qualname__", "__new__", "__slots__"} +LIST_ITEM = re.compile(r"(?:[-+*]|#\.|\d+[.)])\s") +FIELD_LIST_ITEM = re.compile(r":[^:\s][^:]*:\s") +NUMPYDOC_FIELD = re.compile(r"[^:]+\s:\s\S") def signatureOf(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str: @@ -51,6 +55,17 @@ def signatureOf(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str: return f"{rendered} -> {ast.unparse(node.returns)}" if node.returns else rendered +def isProperty(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: + """Return whether node declares a property rather than a callable method.""" + return any( + isinstance(decorator, ast.Name) + and decorator.id == "property" + or isinstance(decorator, ast.Attribute) + and decorator.attr == "property" + for decorator in node.decorator_list + ) + + def attributeDoc(body: list[ast.stmt], index: int) -> str | None: """Return the docstring of the annotated attribute at index: the string literal right below it.""" if index + 1 < len(body): @@ -79,7 +94,9 @@ def stubDeclarations() -> tuple[dict[str, tuple[str | None, str | None]], list[s def add(name: str, doc: str | None, signature: str | None) -> None: if name in entries: duplicates.append(name) - entries[name] = (doc, signature) + del entries[name] + elif name not in duplicates: + entries[name] = (doc, signature) for node in ast.parse(STUB.read_text(encoding="utf-8")).body: if isinstance(node, ast.ClassDef): @@ -90,7 +107,11 @@ def add(name: str, doc: str | None, signature: str | None) -> None: for index, member in enumerate(node.body): if isinstance(member, (ast.FunctionDef, ast.AsyncFunctionDef)): members.add(member.name) - add(f"{node.name}.{member.name}", ast.get_docstring(member), signatureOf(member)) + add( + f"{node.name}.{member.name}", + ast.get_docstring(member), + None if isProperty(member) else signatureOf(member), + ) elif isinstance(member, ast.AnnAssign) and isinstance(member.target, ast.Name): members.add(member.target.id) add(f"{node.name}.{member.target.id}", attributeDoc(node.body, index), None) @@ -125,8 +146,9 @@ def shipped(name: str) -> tuple[bool, str | None, bool]: import IcePy head, _, member = name.partition(".") - obj = vars(IcePy).get(head) - if obj is None: + missing = object() + obj = vars(IcePy).get(head, missing) + if obj is missing: return False, None, False if not member: doc = obj.__doc__ @@ -134,12 +156,19 @@ def shipped(name: str) -> tuple[bool, str | None, bool]: # The C sources set tp_doc to the type's own name as a placeholder for "undocumented". doc = None return True, doc, False + if not isinstance(obj, type): + return False, None, False for cls in obj.__mro__: if member in vars(cls): value = vars(cls)[member] if isinstance(value, (staticmethod, classmethod)): value = value.__func__ # the wrapper's own __doc__ describes staticmethod itself - pythonSupplied = value is None or isinstance(value, types.WrapperDescriptorType) or cls is object + pythonSupplied = ( + value is None + and member == "__hash__" + or isinstance(value, types.WrapperDescriptorType) + or cls is object + ) return True, None if value is None else getattr(value, "__doc__", None), pythonSupplied return False, None, False @@ -168,21 +197,58 @@ def normalizeProse(text: str) -> str: """ Fold hard line wrapping so reflowing a paragraph does not read as drift. - Consecutive lines with the same indentation join into one; blank lines, indentation changes, and - section underlines (numpydoc's ``-------``) all break a run, so document structure still has to - match exactly. + Consecutive prose lines with the same indentation join into one. Structural lines such as list + items, fields, directives, and section underlines remain separate, as do indented literal and + preformatted blocks. """ out: list[str] = [] joinIndent = None # indentation of the line out[-1] belongs to, when it can accept continuations + literalMarkerIndent = None + preformattedIndent = None for line in text.split("\n"): stripped = line.strip() - underline = bool(stripped) and not stripped.strip("-=~^\"'`#*+_") indent = len(line) - len(line.lstrip()) - if stripped and not underline and indent == joinIndent: + + if preformattedIndent is not None: + if not stripped or indent >= preformattedIndent: + out.append(line) + joinIndent = None + continue + preformattedIndent = None + + if literalMarkerIndent is not None: + if not stripped: + out.append(line) + joinIndent = None + continue + if indent > literalMarkerIndent: + preformattedIndent = indent + out.append(line) + joinIndent = None + continue + literalMarkerIndent = None + + if not stripped: + out.append(line) + joinIndent = None + continue + + underline = not stripped.strip("-=~^\"'`#*+_") + structural = ( + underline + or LIST_ITEM.match(stripped) is not None + or FIELD_LIST_ITEM.match(stripped) is not None + or NUMPYDOC_FIELD.match(stripped) is not None + or stripped.startswith((".. ", ">>>", "...", "```", "|")) + ) + if not structural and indent == joinIndent: out[-1] = f"{out[-1]} {stripped}" else: out.append(line) - joinIndent = indent if stripped and not underline else None + joinIndent = None if structural else indent + if stripped.endswith("::"): + literalMarkerIndent = indent + joinIndent = None return "\n".join(out) @@ -234,14 +300,21 @@ def main() -> int: problems.append(f"{name}: documented in the stub, but IcePy ships no description") elif prose and not stubDoc: problems.append(f"{name}: IcePy ships a description, but the stub does not document it") - elif stubDoc and prose and normalizeProse(stubDoc.strip()) != normalizeProse(prose): - diff = "\n".join( - f" {line}" - for line in difflib.unified_diff( - stubDoc.strip().splitlines(), prose.splitlines(), "stub", "IcePy", lineterm="" + elif stubDoc and prose: + normalizedStubDoc = normalizeProse(stubDoc.strip()) + normalizedShippedDoc = normalizeProse(prose) + if normalizedStubDoc != normalizedShippedDoc: + diff = "\n".join( + f" {line}" + for line in difflib.unified_diff( + normalizedStubDoc.splitlines(), + normalizedShippedDoc.splitlines(), + "stub", + "IcePy", + lineterm="", + ) ) - ) - problems.append(f"{name}: descriptions differ\n{diff}") + problems.append(f"{name}: descriptions differ\n{diff}") if stubSignature and not signature: problems.append( From 1c4634a7eb94e9b13888248c78fa17ed157650b0 Mon Sep 17 00:00:00 2001 From: Jose Date: Tue, 1 Sep 2026 14:35:08 +0200 Subject: [PATCH 3/4] Keep reflow tolerance and check that a stub value is not callable normalizeProse treated any line opening with a list, field, or directive marker as structural, including one that was only a wrapped continuation. intVersion's "...the returned value is 30901. For pre-releases..." would read as an enumerated item the moment the wrap moved, reporting drift for a paragraph whose words had not changed. A marker now counts only where reST would begin a block -- after a blank line, or at a new indentation -- while a section underline still attaches to the line above it. Declaring a stub member @property left its signature None, which skipped both signature comparisons rather than changing them. A stub could mark a member an attribute while IcePy documented it as callable, and the check stayed green even though pyright would then reject the call. Properties and annotated attributes now carry a flag, and IcePy shipping a signature line for either is reported. Claude-Session: https://claude.ai/code/session_0164LsXFe8qNNDfchEUjoCCN --- scripts/checkIcePyStub.py | 56 ++++++++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/scripts/checkIcePyStub.py b/scripts/checkIcePyStub.py index a4de856482f..da28a705693 100644 --- a/scripts/checkIcePyStub.py +++ b/scripts/checkIcePyStub.py @@ -75,28 +75,33 @@ def attributeDoc(body: list[ast.stmt], index: int) -> str | None: return None -def stubDeclarations() -> tuple[dict[str, tuple[str | None, str | None]], list[str], dict[str, set[str]], set[str]]: +def stubDeclarations() -> tuple[ + dict[str, tuple[str | None, str | None, bool]], list[str], dict[str, set[str]], set[str] +]: """ Parse the stub into (entries, duplicates, classMembers, topLevel). - entries maps each documented name to its (docstring, signature); classes and attributes have no - signature. duplicates lists the names entries could not keep apart: repeated defs -- an @overload - set, say -- silently overwrite each other, so they are reported instead of half-compared. + entries maps each documented name to its (docstring, signature, value). Classes, properties and + attributes carry no signature, and value marks the properties and attributes: IcePy must not + document those as callables either, or the stub promises an attribute for something the caller + has to call. duplicates lists the names entries could not keep apart: repeated defs -- an + @overload set, say -- silently overwrite each other, so they are reported instead of + half-compared. classMembers maps each class to every member it declares, its stub base classes included, and topLevel holds every name the stub declares at module scope; both exist for the reverse sweep. """ - entries: dict[str, tuple[str | None, str | None]] = {} + entries: dict[str, tuple[str | None, str | None, bool]] = {} duplicates: list[str] = [] ownMembers: dict[str, set[str]] = {} bases: dict[str, list[str]] = {} topLevel: set[str] = set() - def add(name: str, doc: str | None, signature: str | None) -> None: + def add(name: str, doc: str | None, signature: str | None, value: bool = False) -> None: if name in entries: duplicates.append(name) del entries[name] elif name not in duplicates: - entries[name] = (doc, signature) + entries[name] = (doc, signature, value) for node in ast.parse(STUB.read_text(encoding="utf-8")).body: if isinstance(node, ast.ClassDef): @@ -107,14 +112,16 @@ def add(name: str, doc: str | None, signature: str | None) -> None: for index, member in enumerate(node.body): if isinstance(member, (ast.FunctionDef, ast.AsyncFunctionDef)): members.add(member.name) + declaresProperty = isProperty(member) add( f"{node.name}.{member.name}", ast.get_docstring(member), - None if isProperty(member) else signatureOf(member), + None if declaresProperty else signatureOf(member), + declaresProperty, ) elif isinstance(member, ast.AnnAssign) and isinstance(member.target, ast.Name): members.add(member.target.id) - add(f"{node.name}.{member.target.id}", attributeDoc(node.body, index), None) + add(f"{node.name}.{member.target.id}", attributeDoc(node.body, index), None, True) elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): topLevel.add(node.name) add(node.name, ast.get_docstring(node), signatureOf(node)) @@ -200,6 +207,12 @@ def normalizeProse(text: str) -> str: Consecutive prose lines with the same indentation join into one. Structural lines such as list items, fields, directives, and section underlines remain separate, as do indented literal and preformatted blocks. + + A marker only makes a line structural where reST would begin a block: after a blank line, or at + a new indentation. reST wants that blank line before a list, a field list, or a directive, so a + wrapped line that merely opens with a marker -- "30901. For pre-releases..." -- is the paragraph + continuing, not an enumerated item. A section underline is the exception: it attaches to the + line right above it. """ out: list[str] = [] joinIndent = None # indentation of the line out[-1] belongs to, when it can accept continuations @@ -233,15 +246,18 @@ def normalizeProse(text: str) -> str: joinIndent = None continue + continuation = indent == joinIndent underline = not stripped.strip("-=~^\"'`#*+_") - structural = ( - underline - or LIST_ITEM.match(stripped) is not None - or FIELD_LIST_ITEM.match(stripped) is not None - or NUMPYDOC_FIELD.match(stripped) is not None - or stripped.startswith((".. ", ">>>", "...", "```", "|")) + structural = underline or ( + not continuation + and ( + LIST_ITEM.match(stripped) is not None + or FIELD_LIST_ITEM.match(stripped) is not None + or NUMPYDOC_FIELD.match(stripped) is not None + or stripped.startswith((".. ", ">>>", "...", "```", "|")) + ) ) - if not structural and indent == joinIndent: + if not structural and continuation: out[-1] = f"{out[-1]} {stripped}" else: out.append(line) @@ -283,7 +299,7 @@ def main() -> int: " only, so teach it about @overload first" ) - for name, (stubDoc, stubSignature) in sorted(entries.items()): + for name, (stubDoc, stubSignature, stubValue) in sorted(entries.items()): defined, shippedDoc, pythonSupplied = shipped(name) if not defined: # A private helper the stub declares for pyright need not be an attribute of the module, @@ -316,7 +332,11 @@ def main() -> int: ) problems.append(f"{name}: descriptions differ\n{diff}") - if stubSignature and not signature: + if stubValue and signature: + problems.append( + f"{name}: the stub declares it as a value, but IcePy documents it as callable.\n IcePy: {signature}" + ) + elif stubSignature and not signature: problems.append( f"{name}: the stub gives a signature, but IcePy's docstring opens with no matching one." "\n Sphinx reads the signature from that line, so it has to be there." From 8ab72eecc8c5e9b6ff7e98c611da7e890cc7ad53 Mon Sep 17 00:00:00 2001 From: Jose Date: Tue, 1 Sep 2026 16:41:00 +0200 Subject: [PATCH 4/4] Check the constructor signatures, module scope, and attribute types Four gaps were left, and closing the first one needs the module's help. A __init__ slot carries the wrapper CPython generates, whose docstring describes object's generic one, so exempting it as CPython-supplied left the signatures the stub declares for Communicator, ObjectPrx, Operation and Properties unverified -- item 4 of #6443, which this claimed to close. Those four types now spell their constructor in tp_doc, the way a C type documents a constructor, and the check holds the stub's __init__ to that line. None of the four is re-exported by Ice, so nothing published moves. A name declared at module scope only suppressed the reverse sweep, so the stub could invent a constant that no module attribute backs. Those names now go through the same existence check as everything else, TypeVar and its kind excepted, since they exist for the type checker alone. Standing a value where IcePy defines a class -- `Logger: Any` -- is reported too: it type-checks anything, and takes every member of the class out of the comparison with it. An attribute's annotation was compared to nothing. The C sources open a data member's description by naming its type, so the two are now held to each other, modulo the module a type is named through: Ice re-exports IcePy's C types unchanged, so Ice.EndpointInfo and EndpointInfo are the same class and both spellings appear. Finally, normalizeProse folds a list item's own wrapping, so rewrapping a bullet is no longer drift, and recognizes a section adornment by its shape rather than a hand-picked character set that missed "::::::". Claude-Session: https://claude.ai/code/session_0164LsXFe8qNNDfchEUjoCCN --- python/modules/IcePy/Communicator.cpp | 10 +- python/modules/IcePy/Operation.cpp | 12 ++- python/modules/IcePy/Properties.cpp | 10 +- python/modules/IcePy/Proxy.cpp | 10 +- scripts/checkIcePyStub.py | 141 ++++++++++++++++++++------ 5 files changed, 148 insertions(+), 35 deletions(-) diff --git a/python/modules/IcePy/Communicator.cpp b/python/modules/IcePy/Communicator.cpp index 732d7b66adf..99f4266d091 100644 --- a/python/modules/IcePy/Communicator.cpp +++ b/python/modules/IcePy/Communicator.cpp @@ -1566,6 +1566,14 @@ static PyMethodDef CommunicatorMethods[] = { {} /* sentinel */ }; +namespace +{ + // The constructor signature: a C type documents __init__ on the class, because that is how it + // is called. checkIcePyStub.py holds the stub's own __init__ declaration to this line. + constexpr const char* IcePy_Communicator_doc = + R"(Communicator(initData: Ice.InitializationData | None, /) -> None)"; +} + namespace IcePy { // clang-format off @@ -1575,7 +1583,7 @@ namespace IcePy .tp_basicsize = sizeof(CommunicatorObject), .tp_dealloc = reinterpret_cast(communicatorDealloc), .tp_flags = Py_TPFLAGS_DEFAULT, - .tp_doc = PyDoc_STR("IcePy.Communicator"), + .tp_doc = PyDoc_STR(IcePy_Communicator_doc), .tp_methods = CommunicatorMethods, .tp_init = reinterpret_cast(communicatorInit), .tp_new = reinterpret_cast(communicatorNew)}; diff --git a/python/modules/IcePy/Operation.cpp b/python/modules/IcePy/Operation.cpp index 4667c2fa63e..f969384ea44 100644 --- a/python/modules/IcePy/Operation.cpp +++ b/python/modules/IcePy/Operation.cpp @@ -873,6 +873,16 @@ static PyMethodDef AsyncInvocationContextMethods[] = { {} /* sentinel */ }; +namespace +{ + // The constructor signature: a C type documents __init__ on the class, because that is how it + // is called. checkIcePyStub.py holds the stub's own __init__ declaration to this line. + constexpr const char* IcePy_Operation_doc = + R"(Operation(sliceName: str, mappedName: str, mode: Ice.OperationMode, format: Ice.FormatType | None, )" + R"(metadata: tuple, inParams: tuple, outParams: tuple, returnType: object, exceptions: tuple, )" + R"(onewayOnly: bool, /) -> None)"; +} + namespace IcePy { // clang-format off @@ -882,7 +892,7 @@ namespace IcePy .tp_basicsize = sizeof(OperationObject), .tp_dealloc = reinterpret_cast(operationDealloc), .tp_flags = Py_TPFLAGS_DEFAULT, - .tp_doc = PyDoc_STR("IcePy.Operation"), + .tp_doc = PyDoc_STR(IcePy_Operation_doc), .tp_methods = OperationMethods, .tp_init = reinterpret_cast(operationInit), .tp_new = reinterpret_cast(operationNew), diff --git a/python/modules/IcePy/Properties.cpp b/python/modules/IcePy/Properties.cpp index 1b113a2f910..5bbb477062d 100644 --- a/python/modules/IcePy/Properties.cpp +++ b/python/modules/IcePy/Properties.cpp @@ -745,6 +745,14 @@ static PyMethodDef PropertyMethods[] = { {} /* sentinel */ }; +namespace +{ + // The constructor signature: a C type documents __init__ on the class, because that is how it + // is called. checkIcePyStub.py holds the stub's own __init__ declaration to this line. + constexpr const char* IcePy_Properties_doc = + R"(Properties(args: list[str] | None = None, defaults: Ice.Properties | None = None, /) -> None)"; +} + namespace IcePy { // clang-format off @@ -755,7 +763,7 @@ namespace IcePy .tp_dealloc = reinterpret_cast(propertiesDealloc), .tp_str = reinterpret_cast(propertiesStr), .tp_flags = Py_TPFLAGS_DEFAULT, - .tp_doc = PyDoc_STR("IcePy.Properties"), + .tp_doc = PyDoc_STR(IcePy_Properties_doc), .tp_methods = PropertyMethods, .tp_init = reinterpret_cast(propertiesInit), .tp_new = reinterpret_cast(propertiesNew), diff --git a/python/modules/IcePy/Proxy.cpp b/python/modules/IcePy/Proxy.cpp index 6c47d0ea612..552c29f265b 100644 --- a/python/modules/IcePy/Proxy.cpp +++ b/python/modules/IcePy/Proxy.cpp @@ -1434,6 +1434,14 @@ static PyMethodDef ProxyMethods[] = { {} /* sentinel */ }; +namespace +{ + // The constructor signature: a C type documents __init__ on the class, because that is how it + // is called. checkIcePyStub.py holds the stub's own __init__ declaration to this line. + constexpr const char* IcePy_ObjectPrx_doc = + R"(ObjectPrx(communicator: Ice.Communicator, proxyString: str, /) -> None)"; +} + namespace IcePy { // clang-format off @@ -1445,7 +1453,7 @@ namespace IcePy .tp_repr = reinterpret_cast(proxyRepr), .tp_hash = reinterpret_cast(proxyHash), .tp_flags = Py_TPFLAGS_BASETYPE, - .tp_doc = PyDoc_STR("IcePy.ObjectPrx"), + .tp_doc = PyDoc_STR(IcePy_ObjectPrx_doc), .tp_richcompare = reinterpret_cast(proxyCompare), .tp_methods = ProxyMethods, .tp_init = reinterpret_cast(proxyInit), diff --git a/scripts/checkIcePyStub.py b/scripts/checkIcePyStub.py index da28a705693..bf2c24a5cd2 100644 --- a/scripts/checkIcePyStub.py +++ b/scripts/checkIcePyStub.py @@ -40,18 +40,28 @@ # undeclared -- what a stub must declare is a slot object does not have, like ExecutorCall's __call__. TYPE_METADATA = {"__dict__", "__weakref__", "__module__", "__qualname__", "__new__", "__slots__"} LIST_ITEM = re.compile(r"(?:[-+*]|#\.|\d+[.)])\s") +# A section underline, or any other adornment: reST spells one as a single punctuation character +# repeated, so recognizing the shape beats naming the characters one by one and missing "::::::". +ADORNMENT = re.compile(r"([!-/:-@\[-`{-~])\1+") FIELD_LIST_ITEM = re.compile(r":[^:\s][^:]*:\s") NUMPYDOC_FIELD = re.compile(r"[^:]+\s:\s\S") +# A stub-only construct: it exists for the type checker and is not an attribute of the module. +TYPING_HELPERS = {"TypeVar", "ParamSpec", "TypeVarTuple", "NewType", "TypeAliasType"} -def signatureOf(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str: - """Render a stub def the way the first line of a C docstring spells it.""" +def signatureOf(node: ast.FunctionDef | ast.AsyncFunctionDef, name: str | None = None) -> str: + """ + Render a stub def the way the first line of a C docstring spells it. + + name replaces the def's own, which is what a constructor needs: a C type documents __init__ on + the class, spelled with the class's name, because that is how it is called. + """ args = copy.deepcopy(node.args) if args.posonlyargs and args.posonlyargs[0].arg in ("self", "cls"): del args.posonlyargs[0] elif args.args and args.args[0].arg in ("self", "cls"): del args.args[0] - rendered = f"{node.name}({ast.unparse(args)})" + rendered = f"{name or node.name}({ast.unparse(args)})" return f"{rendered} -> {ast.unparse(node.returns)}" if node.returns else rendered @@ -76,37 +86,48 @@ def attributeDoc(body: list[ast.stmt], index: int) -> str | None: def stubDeclarations() -> tuple[ - dict[str, tuple[str | None, str | None, bool]], list[str], dict[str, set[str]], set[str] + dict[str, tuple[str | None, str | None, str | None]], list[str], dict[str, set[str]], set[str] ]: """ Parse the stub into (entries, duplicates, classMembers, topLevel). - entries maps each documented name to its (docstring, signature, value). Classes, properties and - attributes carry no signature, and value marks the properties and attributes: IcePy must not - document those as callables either, or the stub promises an attribute for something the caller - has to call. duplicates lists the names entries could not keep apart: repeated defs -- an - @overload set, say -- silently overwrite each other, so they are reported instead of - half-compared. + entries maps each documented name to its (docstring, signature, type). Classes, properties and + attributes carry no signature, and type is the one a property or an attribute declares: IcePy + must not document those as callables, and its description opens by naming the same type. + Everything the stub declares at module scope goes in too, so that a name the stub invents is + caught. duplicates lists the names entries could not keep apart: repeated defs -- an @overload + set, say -- silently overwrite each other, so they are reported instead of half-compared. classMembers maps each class to every member it declares, its stub base classes included, and topLevel holds every name the stub declares at module scope; both exist for the reverse sweep. """ - entries: dict[str, tuple[str | None, str | None, bool]] = {} + entries: dict[str, tuple[str | None, str | None, str | None]] = {} duplicates: list[str] = [] ownMembers: dict[str, set[str]] = {} bases: dict[str, list[str]] = {} topLevel: set[str] = set() - def add(name: str, doc: str | None, signature: str | None, value: bool = False) -> None: + def add(name: str, doc: str | None, signature: str | None, declared: str | None = None) -> None: if name in entries: duplicates.append(name) del entries[name] elif name not in duplicates: - entries[name] = (doc, signature, value) + entries[name] = (doc, signature, declared) for node in ast.parse(STUB.read_text(encoding="utf-8")).body: if isinstance(node, ast.ClassDef): topLevel.add(node.name) - add(node.name, ast.get_docstring(node), None) + # __init__ itself is a slot, and the wrapper CPython puts there documents object's + # generic one. The signature the stub declares for it lives on the class, which is where + # a C type spells its constructor and the only place the module can be held to it. + initializer = next( + (m for m in node.body if isinstance(m, ast.FunctionDef) and m.name == "__init__"), + None, + ) + add( + node.name, + ast.get_docstring(node), + signatureOf(initializer, node.name) if initializer else None, + ) members = ownMembers[node.name] = set() bases[node.name] = [base.id for base in node.bases if isinstance(base, ast.Name)] for index, member in enumerate(node.body): @@ -117,18 +138,32 @@ def add(name: str, doc: str | None, signature: str | None, value: bool = False) f"{node.name}.{member.name}", ast.get_docstring(member), None if declaresProperty else signatureOf(member), - declaresProperty, + ast.unparse(member.returns) if declaresProperty and member.returns else None, ) elif isinstance(member, ast.AnnAssign) and isinstance(member.target, ast.Name): members.add(member.target.id) - add(f"{node.name}.{member.target.id}", attributeDoc(node.body, index), None, True) + add( + f"{node.name}.{member.target.id}", + attributeDoc(node.body, index), + None, + ast.unparse(member.annotation), + ) elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): topLevel.add(node.name) add(node.name, ast.get_docstring(node), signatureOf(node)) elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): topLevel.add(node.target.id) + add(node.target.id, None, None, ast.unparse(node.annotation)) elif isinstance(node, ast.Assign): - topLevel.update(target.id for target in node.targets if isinstance(target, ast.Name)) + # A TypeVar and its kind exist for the type checker alone; every other module-scope name + # is one IcePy has to define, and going through add() is what gets that checked. + call = node.value if isinstance(node.value, ast.Call) else None + helper = call is not None and isinstance(call.func, ast.Name) and call.func.id in TYPING_HELPERS + for target in node.targets: + if isinstance(target, ast.Name): + topLevel.add(target.id) + if not helper: + add(target.id, None, None) def membersOf(className: str) -> set[str]: members = set(ownMembers.get(className, ())) @@ -159,8 +194,14 @@ def shipped(name: str) -> tuple[bool, str | None, bool]: return False, None, False if not member: doc = obj.__doc__ - if isinstance(obj, type) and doc == f"IcePy.{head}": - # The C sources set tp_doc to the type's own name as a placeholder for "undocumented". + if isinstance(obj, type): + if doc == f"IcePy.{head}": + # The C sources set tp_doc to the type's own name as a placeholder for "undocumented". + doc = None + elif doc is not None and doc == getattr(type(obj), "__doc__", None): + # A plain value carries no docstring of its own: __doc__ falls through to its class -- + # a fresh string each time, for a C type -- and the class's description is not a + # description of this module attribute. doc = None return True, doc, False if not isinstance(obj, type): @@ -200,6 +241,16 @@ def normalizeSignature(signature: str) -> str: return signature.replace(" = ", "=") +def normalizeType(declared: str) -> str: + """ + Ignore the module a type is named through, and the spacing inside it. + + Ice re-exports IcePy's C types unchanged -- Ice.EndpointInfo is IcePy.EndpointInfo -- so the two + qualified spellings and the bare one all name the same class, and the C sources use both. + """ + return re.sub(r"\b(?:Ice|IcePy)\.", "", declared).replace(" ", "") + + def normalizeProse(text: str) -> str: """ Fold hard line wrapping so reflowing a paragraph does not read as drift. @@ -213,9 +264,16 @@ def normalizeProse(text: str) -> str: wrapped line that merely opens with a marker -- "30901. For pre-releases..." -- is the paragraph continuing, not an enumerated item. A section underline is the exception: it attaches to the line right above it. + + A list item's own wrapping folds too. Its text continues on the following lines, indented past + the marker, so the first of those opens a paragraph that the rest join -- rewrapping a bullet is + no more a change than rewrapping anything else. That first line is still read as a marker if it + is one, which is what a nested list looks like. A field list and a directive are left alone: + what follows them is an indented body, not the same sentence carrying on. """ out: list[str] = [] joinIndent = None # indentation of the line out[-1] belongs to, when it can accept continuations + itemIndent = None # indentation of a list item whose text may still continue, indented past it literalMarkerIndent = None preformattedIndent = None for line in text.split("\n"): @@ -225,46 +283,48 @@ def normalizeProse(text: str) -> str: if preformattedIndent is not None: if not stripped or indent >= preformattedIndent: out.append(line) - joinIndent = None + joinIndent = itemIndent = None continue preformattedIndent = None if literalMarkerIndent is not None: if not stripped: out.append(line) - joinIndent = None + joinIndent = itemIndent = None continue if indent > literalMarkerIndent: preformattedIndent = indent out.append(line) - joinIndent = None + joinIndent = itemIndent = None continue literalMarkerIndent = None if not stripped: out.append(line) - joinIndent = None + joinIndent = itemIndent = None continue continuation = indent == joinIndent - underline = not stripped.strip("-=~^\"'`#*+_") + listItem = LIST_ITEM.match(stripped) is not None + underline = ADORNMENT.fullmatch(stripped) is not None structural = underline or ( not continuation and ( - LIST_ITEM.match(stripped) is not None + listItem or FIELD_LIST_ITEM.match(stripped) is not None or NUMPYDOC_FIELD.match(stripped) is not None or stripped.startswith((".. ", ">>>", "...", "```", "|")) ) ) - if not structural and continuation: + if not structural and (continuation or (itemIndent is not None and indent > itemIndent)): out[-1] = f"{out[-1]} {stripped}" else: out.append(line) joinIndent = None if structural else indent + itemIndent = indent if structural and listItem and not underline else None if stripped.endswith("::"): literalMarkerIndent = indent - joinIndent = None + joinIndent = itemIndent = None return "\n".join(out) @@ -273,9 +333,15 @@ def reverseProblems(classMembers: dict[str, set[str]], topLevel: set[str]) -> li import IcePy problems: list[str] = [] - for name in sorted(vars(IcePy)): - if not name.startswith("_") and name not in topLevel: + for name, value in sorted(vars(IcePy).items()): + if name.startswith("_"): + continue + if name not in topLevel: problems.append(f"{name}: IcePy defines it, but the stub does not declare it") + elif isinstance(value, type) and name not in classMembers: + # Declaring the name is not enough: `Logger: Any` in place of `class Logger:` type-checks + # anything, and takes every member of the class out of the comparison with it. + problems.append(f"{name}: IcePy defines a class, but the stub declares it as a value") for className, members in sorted(classMembers.items()): cls = vars(IcePy).get(className) if not isinstance(cls, type): @@ -299,7 +365,7 @@ def main() -> int: " only, so teach it about @overload first" ) - for name, (stubDoc, stubSignature, stubValue) in sorted(entries.items()): + for name, (stubDoc, stubSignature, stubType) in sorted(entries.items()): defined, shippedDoc, pythonSupplied = shipped(name) if not defined: # A private helper the stub declares for pyright need not be an attribute of the module, @@ -332,10 +398,23 @@ def main() -> int: ) problems.append(f"{name}: descriptions differ\n{diff}") - if stubValue and signature: + if stubType is not None and signature: problems.append( f"{name}: the stub declares it as a value, but IcePy documents it as callable.\n IcePy: {signature}" ) + elif stubType is not None and prose: + # The C sources open a data member's description by naming its type -- "bool: Specifies + # whether ..." -- which is the only thing the annotation can be checked against. + documented, named, _ = prose.split("\n", maxsplit=1)[0].partition(": ") + if not named: + problems.append( + f"{name}: IcePy's description does not open by naming the type, so the stub's is unchecked" + ) + elif normalizeType(documented) != normalizeType(stubType): + problems.append( + f"{name}: the stub's type and the one IcePy documents differ." + f"\n stub: {stubType}\n IcePy: {documented}" + ) elif stubSignature and not signature: problems.append( f"{name}: the stub gives a signature, but IcePy's docstring opens with no matching one."