diff --git a/mypy/stubgenc.py b/mypy/stubgenc.py index d86818adf2a4..04bf84c99539 100755 --- a/mypy/stubgenc.py +++ b/mypy/stubgenc.py @@ -216,6 +216,18 @@ def __get__(self) -> None: # noqa: PLE0302 _Missing = enum.Enum("_Missing", "VALUE") +def _is_sentinel_object(obj: object) -> bool: + """Is ``obj`` an instance of ``typing_extensions.sentinel()``/``Sentinel()``?""" + typ = type(obj) + return typ.__module__ in ("builtins", "typing_extensions") and typ.__name__ in ( + "sentinel", + # typing_extensions 4.14.0-4.15.x named the class `Sentinel`, with no + # lowercase alias; the rename (and `Sentinel = sentinel` alias) landed + # in 4.16.0. + "Sentinel", + ) + + class InspectionStubGenerator(BaseStubGenerator): """Stub generator that does not parse code. @@ -323,6 +335,15 @@ def add_args( if default_value is not _Missing.VALUE: if arg in annotations: argtype = get_annotation(arg) + elif _is_sentinel_object(default_value): + # The runtime type of a `sentinel()` marker object is not a + # useful annotation for the parameter it defaults. Reference + # the sentinel itself by name when possible, e.g. + # `Incomplete | _MISSING`, importing it if it lives in + # another module. + incomplete = self.add_name("_typeshed.Incomplete") + sentinel_ref = self._sentinel_type_ref(default_value) + argtype = f"{incomplete} | {sentinel_ref}" if sentinel_ref else incomplete else: argtype = self.get_type_annotation(default_value) if argtype == "None": @@ -536,9 +557,38 @@ def get_type_annotation(self, obj: object) -> str: return self.add_name("typing.Callable") elif isinstance(obj, ModuleType): return self.add_name("types.ModuleType", require=False) + elif _is_sentinel_object(obj): + # On 3.15+, typing_extensions.sentinel is builtins.sentinel, so + # type(obj).__module__ is "builtins" there and "typing_extensions" + # otherwise. Always use the portable spelling instead of whichever + # one happens to match the interpreter running stubgen. Use the + # dotted form (not add_name) since a module constructing a + # sentinel will itself have already imported the bare `sentinel` + # name, which would otherwise force an alias here. + return "typing_extensions.sentinel" else: return self.get_type_fullname(type(obj)) + def _sentinel_type_ref(self, sentinel_obj: object) -> str | None: + """Return a reference to the name a sentinel object is bound to. + + ``sentinel()``/``Sentinel()`` objects record the module and name they + were assigned to (this is how they support pickling), so we can point + directly at them (importing from another module if needed), rather + than using their unhelpful runtime type. Returns None if we can't (e.g. + the name isn't a valid identifier). + + A leading-underscore sentinel name is treated the same as a public + one: ``generate_variable_stub`` always emits sentinel declarations + regardless of privacy, since a sentinel used as a default is part of + the (typed) signature even when its name looks private. + """ + name = getattr(sentinel_obj, "__name__", None) + module = getattr(sentinel_obj, "__module__", None) + if not isinstance(name, str) or not name.isidentifier() or not module: + return None + return f"{module}.{name}" + def is_function(self, obj: object) -> bool: if self.is_c_module: return inspect.isbuiltin(obj) @@ -913,7 +963,15 @@ def generate_variable_stub(self, name: str, obj: object, output: list[str]) -> N The result lines will be appended to 'output'. If necessary, any required names will be added to 'imports'. """ - if self.is_private_name(name, f"{self.module_name}.{name}") or self.is_not_in_all(name): + if ( + # A sentinel's name is emitted regardless of privacy/`__all__`, since it may + # be referenced from the type of a default value elsewhere in the stub. + not _is_sentinel_object(obj) + and ( + self.is_private_name(name, f"{self.module_name}.{name}") + or self.is_not_in_all(name) + ) + ): return self.record_name(name) type_str = self.strip_or_import(self.get_type_annotation(obj)) diff --git a/mypy/test/teststubgen.py b/mypy/test/teststubgen.py index 605409f99523..8b581e1091bc 100644 --- a/mypy/test/teststubgen.py +++ b/mypy/test/teststubgen.py @@ -1040,6 +1040,46 @@ def test(self, arg0=1, arg1=None) -> None: # type: ignore[no-untyped-def] output, ["def test(self, arg0: int = ..., arg1: Incomplete | None = ...) -> None: ..."] ) + def test_generate_c_type_sentinel_default(self) -> None: + from typing_extensions import sentinel + + _MISSING = sentinel("_MISSING") + + class TestClass: + def test(self, arg0=_MISSING) -> None: # type: ignore[no-untyped-def] + pass + + output: list[str] = [] + mod = ModuleType(TestClass.__module__, "") + gen = InspectionStubGenerator(mod.__name__, known_modules=[mod.__name__], module=mod) + gen.is_c_module = False + gen.generate_function_stub( + "test", + TestClass.test, + output=output, + class_info=ClassInfo( + self_var="self", + cls=TestClass, + name="TestClass", + docstring=getattr(TestClass, "__doc__", None), + ), + ) + assert_equal(output, ["def test(self, arg0: Incomplete | _MISSING = ...) -> None: ..."]) + + def test_generate_variable_stub_sentinel_ignores_privacy(self) -> None: + from typing_extensions import sentinel + + _MISSING = sentinel("_MISSING") + mod = ModuleType(__name__, "") + gen = InspectionStubGenerator(mod.__name__, known_modules=[mod.__name__], module=mod) + gen.is_c_module = False + output: list[str] = [] + gen.generate_variable_stub("_MISSING", _MISSING, output=output) + # A sentinel's declaration is always emitted, even for a private-looking + # name, since it may be referenced from a default value's type elsewhere. + assert len(output) == 1 + assert output[0].startswith("_MISSING: ") + def test_non_c_generate_signature_with_kw_only_args(self) -> None: class TestClass: def test( diff --git a/test-data/unit/stubgen.test b/test-data/unit/stubgen.test index 0407cf1bc798..acb222708956 100644 --- a/test-data/unit/stubgen.test +++ b/test-data/unit/stubgen.test @@ -72,6 +72,42 @@ def f(x=ord): ... [out] def f(x=...) -> None: ... +[case testDefaultArgSentinel] +from typing_extensions import sentinel + +_MISSING = sentinel('_MISSING') + +def f(x=_MISSING): ... +[out] +def f(x=...) -> None: ... + +[case testDefaultArgSentinel_inspect] +from typing_extensions import sentinel + +_MISSING = sentinel('_MISSING') + +def f(x=_MISSING): ... +[out] +import typing_extensions +from _typeshed import Incomplete + +_MISSING: typing_extensions.sentinel +def f(x: Incomplete | _MISSING = ...): ... + +[case testDefaultArgSentinelCrossModule_inspect] +from other import MISSING + +def f(x=MISSING): ... +[file other.py] +from typing_extensions import sentinel + +MISSING = sentinel('MISSING') +[out] +import other +from _typeshed import Incomplete + +def f(x: Incomplete | other.MISSING = ...): ... + [case testPreserveFunctionAnnotation] def f(x: Foo) -> Bar: ... def g(x: Foo = Foo()) -> Bar: ...