Skip to content

abstract and read only attributes #847

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Basedmypy Changelog

## [Unreleased]
### Added
- `Abstract` and `abstract` modifiers
- `ReadOnly` attributes

## [2.9.0]
### Added
Expand Down
49 changes: 49 additions & 0 deletions docs/source/based_features.rst
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,55 @@ represent a set of choices that the ``TypeVar`` can be replaced with:
type C = B[object] # mypy doesn't report the error here


Abstract Classes
----------------

abstract classes are more strict:

.. code-block:: python

class A: # error: abstract class not denoted as abstract
@abstractmethod
def f(self): ...

and more flexable:

.. code-block:: python

from basedtyping import abstract

@abstract
class A:
@abstract
def f(self): ...


and there are abstract attributes:

.. code-block:: python

from basedtyping import abstract, Abstract

@abstract
class A:
a: Abstract[int]


Read-only attributes
--------------------

simply:

.. code-block:: python

from typing import ReadOnly

class A:
a: ReadOnly[int]

A().a = 1 # error: A.a is read-only


Reinvented type guards
----------------------

Expand Down
8 changes: 8 additions & 0 deletions mypy/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -3604,6 +3604,14 @@ def check_assignment(
self.infer_variable_type(inferred, lvalue, rvalue_type, rvalue)
self.check_assignment_to_slots(lvalue)

if (
isinstance(lvalue, NameExpr)
and isinstance(lvalue.node, Var)
and lvalue.node.is_read_only
and not self.get_final_context()
):
self.msg.read_only(lvalue.node.name, rvalue)

# (type, operator) tuples for augmented assignments supported with partial types
partial_type_augmented_ops: Final = {("builtins.list", "+"), ("builtins.set", "|")}

Expand Down
2 changes: 2 additions & 0 deletions mypy/checkmember.py
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,8 @@ def analyze_var(
if mx.is_lvalue and var.is_property and not var.is_settable_property:
# TODO allow setting attributes in subclass (although it is probably an error)
mx.msg.read_only_property(name, itype.type, mx.context)
if mx.is_lvalue and var.is_read_only:
mx.msg.read_only(name, mx.context, itype.type)
if mx.is_lvalue and var.is_classvar:
mx.msg.cant_assign_to_classvar(name, mx.context)
t = freshen_all_functions_type_vars(typ)
Expand Down
1 change: 1 addition & 0 deletions mypy/message_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ def with_additional_msg(self, info: str) -> ErrorMessage:
CLASS_VAR_WITH_TYPEVARS: Final = "ClassVar cannot contain type variables"
CLASS_VAR_WITH_GENERIC_SELF: Final = "ClassVar cannot contain Self type in generic classes"
CLASS_VAR_OUTSIDE_OF_CLASS: Final = "ClassVar can only be used for assignments in class body"
ABSTRACT_OUTSIDE_OF_CLASS: Final = "`Abstract` can only be used for assignments in a class body"

# Protocol
RUNTIME_PROTOCOL_EXPECTED: Final = ErrorMessage(
Expand Down
24 changes: 18 additions & 6 deletions mypy/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -1569,12 +1569,13 @@ def incompatible_conditional_function_def(
def cannot_instantiate_abstract_class(
self, class_name: str, abstract_attributes: dict[str, bool], context: Context
) -> None:
attrs = format_string_list([f'"{a}"' for a in abstract_attributes])
if abstract_attributes:
attrs = format_string_list([f'"{a}"' for a in abstract_attributes])
rest = f" with abstract attribute{plural_s(abstract_attributes)} {attrs}"
else:
rest = ""
self.fail(
f'Cannot instantiate abstract class "{class_name}" with abstract '
f"attribute{plural_s(abstract_attributes)} {attrs}",
context,
code=codes.ABSTRACT,
f'Cannot instantiate abstract class "{class_name}"{rest}', context, code=codes.ABSTRACT
)
attrs_with_none = [
f'"{a}"'
Expand Down Expand Up @@ -1655,7 +1656,18 @@ def final_without_value(self, ctx: Context) -> None:
self.fail("Final name must be initialized with a value", ctx)

def read_only_property(self, name: str, type: TypeInfo, context: Context) -> None:
self.fail(f'Property "{name}" defined in "{type.name}" is read-only', context)
self.fail(
f'Property "{name}" defined in "{type.name}" is read-only',
context,
code=ErrorCode("read-only", "", ""),
)

def read_only(self, name: str, context: Context, type: TypeInfo | None = None) -> None:
if type is None:
prefix = f'Name "{name}"'
else:
prefix = f'Attribute "{name}" defined in "{type.name}"'
self.fail(f"{prefix} is read only", context, code=ErrorCode("read-only", "", ""))

def incompatible_typevar_value(
self,
Expand Down
2 changes: 1 addition & 1 deletion mypy/metastore.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import sqlite3


class MetadataStore:
class MetadataStore: # type: ignore[abstract]
"""Generic interface for metadata storage."""

@abstractmethod
Expand Down
10 changes: 6 additions & 4 deletions mypy/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ class FakeExpression(Expression):


@trait
class SymbolNode(Node):
class SymbolNode(Node): # type: ignore[abstract]
"""Nodes that can be stored in a symbol table."""

__slots__ = ()
Expand Down Expand Up @@ -505,7 +505,7 @@ def accept(self, visitor: StatementVisitor[T]) -> T:
FUNCBASE_FLAGS: Final = ["is_property", "is_class", "is_static", "is_final", "is_type_check_only"]


class FuncBase(Node):
class FuncBase(Node): # type: ignore[abstract]
"""Abstract base class for function-like nodes.

N.B: Although this has SymbolNode subclasses (FuncDef,
Expand Down Expand Up @@ -710,7 +710,7 @@ def __init__(
]


class FuncItem(FuncBase):
class FuncItem(FuncBase): # type: ignore[abstract]
"""Base class for nodes usable as overloaded function items."""

__slots__ = (
Expand Down Expand Up @@ -1021,6 +1021,7 @@ class Var(SymbolNode):
"is_settable_property",
"is_classvar",
"is_abstract_var",
"is_read_only",
"is_final",
"is_index_var",
"final_unset_in_class",
Expand Down Expand Up @@ -1058,6 +1059,7 @@ def __init__(self, name: str, type: mypy.types.Type | None = None) -> None:
self.is_classvar = False
self.is_abstract_var = False
self.is_index_var = False
self.is_read_only = False
# Set to true when this variable refers to a module we were unable to
# parse for some reason (eg a silenced module)
self.is_suppressed_import = False
Expand Down Expand Up @@ -2558,7 +2560,7 @@ def accept(self, visitor: ExpressionVisitor[T]) -> T:
VARIANCE_NOT_READY: Final = 3 # Variance hasn't been inferred (using Python 3.12 syntax)


class TypeVarLikeExpr(SymbolNode, Expression):
class TypeVarLikeExpr(SymbolNode, Expression): # type: ignore[abstract]
"""Base class for TypeVarExpr, ParamSpecExpr and TypeVarTupleExpr.

Note that they are constructed by the semantic analyzer.
Expand Down
8 changes: 4 additions & 4 deletions mypy/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ class C: pass


@trait
class TypeAnalyzerPluginInterface:
class TypeAnalyzerPluginInterface: # type: ignore[abstract]
"""Interface for accessing semantic analyzer functionality in plugins.

Methods docstrings contain only basic info. Look for corresponding implementation
Expand Down Expand Up @@ -195,7 +195,7 @@ class AnalyzeTypeContext(NamedTuple):


@mypyc_attr(allow_interpreted_subclasses=True)
class CommonPluginApi:
class CommonPluginApi: # type: ignore[abstract]
"""
A common plugin API (shared between semantic analysis and type checking phases)
that all plugin hooks get independently of the context.
Expand All @@ -217,7 +217,7 @@ def lookup_fully_qualified(self, fullname: str) -> SymbolTableNode | None:


@trait
class CheckerPluginInterface:
class CheckerPluginInterface: # type: ignore[abstract]
"""Interface for accessing type checker functionality in plugins.

Methods docstrings contain only basic info. Look for corresponding implementation
Expand Down Expand Up @@ -254,7 +254,7 @@ def get_expression_type(self, node: Expression, type_context: Type | None = None


@trait
class SemanticAnalyzerPluginInterface:
class SemanticAnalyzerPluginInterface: # type: ignore[abstract]
"""Interface for accessing semantic analyzer functionality in plugins.

Methods docstrings contain only basic info. Look for corresponding implementation
Expand Down
2 changes: 1 addition & 1 deletion mypy/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,7 @@ def on_finish(self) -> None:
register_reporter("cobertura-xml", CoberturaXmlReporter, needs_lxml=True)


class AbstractXmlReporter(AbstractReporter):
class AbstractXmlReporter(AbstractReporter): # type: ignore[abstract]
"""Internal abstract class for reporters that work via XML."""

def __init__(self, reports: Reports, output_dir: str) -> None:
Expand Down
83 changes: 75 additions & 8 deletions mypy/semanal.py
Original file line number Diff line number Diff line change
Expand Up @@ -1821,7 +1821,7 @@ def visit_decorator(self, dec: Decorator) -> None:
could_be_decorated_property = False
for i, d in enumerate(dec.decorators):
# A bunch of decorators are special cased here.
if refers_to_fullname(d, "abc.abstractmethod"):
if refers_to_fullname(d, ("abc.abstractmethod", "basedtyping.abstract")):
removed.append(i)
dec.func.abstract_status = IS_ABSTRACT
self.check_decorated_function_is_method("abstractmethod", dec)
Expand All @@ -1847,6 +1847,7 @@ def visit_decorator(self, dec: Decorator) -> None:
(
"builtins.property",
"abc.abstractproperty",
"basedtyping.abstract",
"functools.cached_property",
"enum.property",
"types.DynamicClassAttribute",
Expand All @@ -1855,7 +1856,7 @@ def visit_decorator(self, dec: Decorator) -> None:
removed.append(i)
dec.func.is_property = True
dec.var.is_property = True
if refers_to_fullname(d, "abc.abstractproperty"):
if refers_to_fullname(d, ("abc.abstractproperty", "basedtyping.abstract")):
dec.func.abstract_status = IS_ABSTRACT
elif refers_to_fullname(d, "functools.cached_property"):
dec.var.is_settable_property = True
Expand Down Expand Up @@ -2340,6 +2341,8 @@ def analyze_class_decorator_common(
"""
if refers_to_fullname(decorator, FINAL_DECORATOR_NAMES):
info.is_final = True
elif refers_to_fullname(decorator, "basedtyping.abstract"):
info.is_abstract = True
elif refers_to_fullname(decorator, TYPE_CHECK_ONLY_NAMES):
info.is_type_check_only = True
elif (deprecated := self.get_deprecated(decorator)) is not None:
Expand Down Expand Up @@ -3410,7 +3413,9 @@ def visit_assignment_stmt(self, s: AssignmentStmt) -> None:
self.analyze_lvalues(s)
self.check_final_implicit_def(s)
self.store_final_status(s)
self.check_classvar(s)
# this is a bit lazy, but gets the job done
while self.check_abstract(s) or self.check_classvar(s) or self.check_read_only(s):
pass
self.process_type_annotation(s)
self.apply_dynamic_class_hook(s)
if not s.type:
Expand Down Expand Up @@ -5226,13 +5231,13 @@ def analyze_value_types(self, items: list[Expression]) -> list[Type]:
result.append(AnyType(TypeOfAny.from_error))
return result

def check_classvar(self, s: AssignmentStmt) -> None:
def check_classvar(self, s: AssignmentStmt) -> bool:
"""Check if assignment defines a class variable."""
lvalue = s.lvalues[0]
if len(s.lvalues) != 1 or not isinstance(lvalue, RefExpr):
return
return False
if not s.type or not self.is_classvar(s.type):
return
return False
if self.is_class_scope() and isinstance(lvalue, NameExpr):
node = lvalue.node
if isinstance(node, Var):
Expand All @@ -5257,26 +5262,88 @@ def check_classvar(self, s: AssignmentStmt) -> None:
# In case of member access, report error only when assigning to self
# Other kinds of member assignments should be already reported
self.fail_invalid_classvar(lvalue)
if s.type.args:
s.type = s.type.args[0]
else:
s.type = None
return True

def check_abstract(self, s: AssignmentStmt) -> bool:
"""Check if assignment defines an abstract variable."""
lvalue = s.lvalues[0]
if len(s.lvalues) != 1 or not isinstance(lvalue, RefExpr):
return False
if not s.type or not self.is_abstract(s.type):
return False
if self.is_class_scope() and isinstance(lvalue, NameExpr):
node = lvalue.node
if isinstance(node, Var):
node.is_abstract_var = True
assert self.type is not None
elif not isinstance(lvalue, MemberExpr) or self.is_self_member_ref(lvalue):
# In case of member access, report error only when assigning to self
# Other kinds of member assignments should be already reported
self.fail_invalid_abstract(lvalue)
s.type = s.type.args[0]
return True

def is_classvar(self, typ: Type) -> bool:
def check_read_only(self, s: AssignmentStmt) -> bool:
"""Check if assignment defines a read only variable."""
lvalue = s.lvalues[0]
if len(s.lvalues) != 1 or not isinstance(lvalue, RefExpr):
return False
if not s.type or not self.is_read_only_type(s.type):
return False
node = lvalue.node
if isinstance(node, Var):
node.is_read_only = True
s.is_final_def = True
if not mypy.options._based:
return False
if s.type.args:
s.type = s.type.args[0]
else:
s.type = None
return True

def is_classvar(self, typ: Type) -> typ is UnboundType if True else False:
if not isinstance(typ, UnboundType):
return False
sym = self.lookup_qualified(typ.name, typ)
if not sym or not sym.node:
return False
return sym.node.fullname == "typing.ClassVar"

def is_final_type(self, typ: Type | None) -> bool:
def is_abstract(self, typ: Type) -> typ is UnboundType if True else False:
if not isinstance(typ, UnboundType):
return False
sym = self.lookup_qualified(typ.name, typ)
if not sym or not sym.node:
return False
return sym.node.fullname == "basedtyping.Abstract"

def is_final_type(self, typ: Type | None) -> typ is UnboundType if True else False:
if not isinstance(typ, UnboundType):
return False
sym = self.lookup_qualified(typ.name, typ)
if not sym or not sym.node:
return False
return sym.node.fullname in FINAL_TYPE_NAMES

def is_read_only_type(self, typ: Type | None) -> typ is UnboundType if True else False:
if not isinstance(typ, UnboundType):
return False
sym = self.lookup_qualified(typ.name, typ)
if not sym or not sym.node:
return False
return sym.node.fullname in ("typing.ReadOnly", "typing_extensions.ReadOnly")

def fail_invalid_classvar(self, context: Context) -> None:
self.fail(message_registry.CLASS_VAR_OUTSIDE_OF_CLASS, context)

def fail_invalid_abstract(self, context: Context) -> None:
self.fail(message_registry.ABSTRACT_OUTSIDE_OF_CLASS, context)

def process_module_assignment(
self, lvals: list[Lvalue], rval: Expression, ctx: AssignmentStmt
) -> None:
Expand Down
Loading
Loading