Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion mypy/stubgenc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is typing-extensions 4.16.0+. Should we bump the min requirement here?

mypy/pyproject.toml

Lines 56 to 57 in b645e06

"typing_extensions>=4.6.0; python_version<'3.15'",
"typing_extensions>=4.14.0; python_version>='3.15'",

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)
Expand Down Expand Up @@ -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))
Expand Down
40 changes: 40 additions & 0 deletions mypy/test/teststubgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
36 changes: 36 additions & 0 deletions test-data/unit/stubgen.test
Original file line number Diff line number Diff line change
Expand Up @@ -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: ...
Expand Down
Loading