From 45e6b87b6657bcf0c3e5984d0d33fce2f12fe759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= Date: Mon, 21 Sep 2026 12:05:19 -0600 Subject: [PATCH 1/3] Handle arg= in stubgenc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Edgar Ramírez Mondragón --- mypy/stubgenc.py | 16 ++++++++++++++++ mypy/test/teststubgen.py | 26 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/mypy/stubgenc.py b/mypy/stubgenc.py index d86818adf2a43..adb8e32fea0b4 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,10 @@ 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. + argtype = self.add_name("_typeshed.Incomplete") else: argtype = self.get_type_annotation(default_value) if argtype == "None": diff --git a/mypy/test/teststubgen.py b/mypy/test/teststubgen.py index 605409f995232..9b4cf36693f15 100644 --- a/mypy/test/teststubgen.py +++ b/mypy/test/teststubgen.py @@ -1040,6 +1040,32 @@ 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 = ...) -> None: ..."]) + def test_non_c_generate_signature_with_kw_only_args(self) -> None: class TestClass: def test( From dc8858fda04336b2df4791c16314e7a9c7cfcd89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= Date: Mon, 21 Sep 2026 22:59:47 -0600 Subject: [PATCH 2/3] Add cases to stubgen.test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Edgar Ramírez Mondragón --- test-data/unit/stubgen.test | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test-data/unit/stubgen.test b/test-data/unit/stubgen.test index 0407cf1bc798f..75ec1aa8cf251 100644 --- a/test-data/unit/stubgen.test +++ b/test-data/unit/stubgen.test @@ -72,6 +72,22 @@ 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] +from _typeshed import Incomplete + +def f(x: Incomplete = ...): ... + [case testPreserveFunctionAnnotation] def f(x: Foo) -> Bar: ... def g(x: Foo = Foo()) -> Bar: ... From e82ca74e96bc21c6d19d8d6a618d0d51ceca13d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= Date: Tue, 22 Sep 2026 23:11:52 -0600 Subject: [PATCH 3/3] Annotate with sentinel type in generated stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Edgar Ramírez Mondragón --- mypy/stubgenc.py | 48 ++++++++++++++++++++++++++++++++++--- mypy/test/teststubgen.py | 16 ++++++++++++- test-data/unit/stubgen.test | 22 ++++++++++++++++- 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/mypy/stubgenc.py b/mypy/stubgenc.py index adb8e32fea0b4..04bf84c995396 100755 --- a/mypy/stubgenc.py +++ b/mypy/stubgenc.py @@ -337,8 +337,13 @@ def add_args( 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. - argtype = self.add_name("_typeshed.Incomplete") + # 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": @@ -552,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) @@ -929,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 9b4cf36693f15..8b581e1091bcb 100644 --- a/mypy/test/teststubgen.py +++ b/mypy/test/teststubgen.py @@ -1064,7 +1064,21 @@ def test(self, arg0=_MISSING) -> None: # type: ignore[no-untyped-def] docstring=getattr(TestClass, "__doc__", None), ), ) - assert_equal(output, ["def test(self, arg0: Incomplete = ...) -> 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: diff --git a/test-data/unit/stubgen.test b/test-data/unit/stubgen.test index 75ec1aa8cf251..acb2227089561 100644 --- a/test-data/unit/stubgen.test +++ b/test-data/unit/stubgen.test @@ -74,19 +74,39 @@ 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 = ...): ... +def f(x: Incomplete | other.MISSING = ...): ... [case testPreserveFunctionAnnotation] def f(x: Foo) -> Bar: ...