Skip to content

Commit 07dca16

Browse files
committed
abstract and read only
1 parent 8cd497f commit 07dca16

35 files changed

+318
-70
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# Basedmypy Changelog
22

33
## [Unreleased]
4+
### Added
5+
- `Abstract` and `abstract` modifiers
6+
- `ReadOnly` attributes
47

58
## [2.9.0]
69
### Added

docs/source/based_features.rst

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,55 @@ represent a set of choices that the ``TypeVar`` can be replaced with:
158158
type C = B[object] # mypy doesn't report the error here
159159
160160
161+
Abstract Classes
162+
----------------
163+
164+
abstract classes are more strict:
165+
166+
.. code-block:: python
167+
168+
class A: # error: abstract class not denoted as abstract
169+
@abstractmethod
170+
def f(self): ...
171+
172+
and more flexable:
173+
174+
.. code-block:: python
175+
176+
from basedtyping import abstract
177+
178+
@abstract
179+
class A:
180+
@abstract
181+
def f(self): ...
182+
183+
184+
and there are abstract attributes:
185+
186+
.. code-block:: python
187+
188+
from basedtyping import abstract, Abstract
189+
190+
@abstract
191+
class A:
192+
a: Abstract[int]
193+
194+
195+
Read-only attributes
196+
--------------------
197+
198+
simply:
199+
200+
.. code-block:: python
201+
202+
from typing import ReadOnly
203+
204+
class A:
205+
a: ReadOnly[int]
206+
207+
A().a = 1 # error: A.a is read-only
208+
209+
161210
Reinvented type guards
162211
----------------------
163212

mypy/checker.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3604,6 +3604,14 @@ def check_assignment(
36043604
self.infer_variable_type(inferred, lvalue, rvalue_type, rvalue)
36053605
self.check_assignment_to_slots(lvalue)
36063606

3607+
if (
3608+
isinstance(lvalue, NameExpr)
3609+
and isinstance(lvalue.node, Var)
3610+
and lvalue.node.is_read_only
3611+
and not self.get_final_context()
3612+
):
3613+
self.msg.read_only(lvalue.node.name, rvalue)
3614+
36073615
# (type, operator) tuples for augmented assignments supported with partial types
36083616
partial_type_augmented_ops: Final = {("builtins.list", "+"), ("builtins.set", "|")}
36093617

mypy/checkmember.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,8 @@ def analyze_var(
807807
if mx.is_lvalue and var.is_property and not var.is_settable_property:
808808
# TODO allow setting attributes in subclass (although it is probably an error)
809809
mx.msg.read_only_property(name, itype.type, mx.context)
810+
if mx.is_lvalue and var.is_read_only:
811+
mx.msg.read_only(name, mx.context, itype.type)
810812
if mx.is_lvalue and var.is_classvar:
811813
mx.msg.cant_assign_to_classvar(name, mx.context)
812814
t = freshen_all_functions_type_vars(typ)

mypy/message_registry.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ def with_additional_msg(self, info: str) -> ErrorMessage:
266266
CLASS_VAR_WITH_TYPEVARS: Final = "ClassVar cannot contain type variables"
267267
CLASS_VAR_WITH_GENERIC_SELF: Final = "ClassVar cannot contain Self type in generic classes"
268268
CLASS_VAR_OUTSIDE_OF_CLASS: Final = "ClassVar can only be used for assignments in class body"
269+
ABSTRACT_OUTSIDE_OF_CLASS: Final = "`Abstract` can only be used for assignments in a class body"
269270

270271
# Protocol
271272
RUNTIME_PROTOCOL_EXPECTED: Final = ErrorMessage(

mypy/messages.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1569,12 +1569,13 @@ def incompatible_conditional_function_def(
15691569
def cannot_instantiate_abstract_class(
15701570
self, class_name: str, abstract_attributes: dict[str, bool], context: Context
15711571
) -> None:
1572-
attrs = format_string_list([f'"{a}"' for a in abstract_attributes])
1572+
if abstract_attributes:
1573+
attrs = format_string_list([f'"{a}"' for a in abstract_attributes])
1574+
rest = f" with abstract attribute{plural_s(abstract_attributes)} {attrs}"
1575+
else:
1576+
rest = ""
15731577
self.fail(
1574-
f'Cannot instantiate abstract class "{class_name}" with abstract '
1575-
f"attribute{plural_s(abstract_attributes)} {attrs}",
1576-
context,
1577-
code=codes.ABSTRACT,
1578+
f'Cannot instantiate abstract class "{class_name}"{rest}', context, code=codes.ABSTRACT
15781579
)
15791580
attrs_with_none = [
15801581
f'"{a}"'
@@ -1655,7 +1656,18 @@ def final_without_value(self, ctx: Context) -> None:
16551656
self.fail("Final name must be initialized with a value", ctx)
16561657

16571658
def read_only_property(self, name: str, type: TypeInfo, context: Context) -> None:
1658-
self.fail(f'Property "{name}" defined in "{type.name}" is read-only', context)
1659+
self.fail(
1660+
f'Property "{name}" defined in "{type.name}" is read-only',
1661+
context,
1662+
code=ErrorCode("read-only", "", ""),
1663+
)
1664+
1665+
def read_only(self, name: str, context: Context, type: TypeInfo | None = None) -> None:
1666+
if type is None:
1667+
prefix = f'Name "{name}"'
1668+
else:
1669+
prefix = f'Attribute "{name}" defined in "{type.name}"'
1670+
self.fail(f"{prefix} is read only", context, code=ErrorCode("read-only", "", ""))
16591671

16601672
def incompatible_typevar_value(
16611673
self,

mypy/metastore.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
import sqlite3
2323

2424

25-
class MetadataStore:
25+
class MetadataStore: # type: ignore[abstract]
2626
"""Generic interface for metadata storage."""
2727

2828
@abstractmethod

mypy/nodes.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ class FakeExpression(Expression):
247247

248248

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

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

507507

508-
class FuncBase(Node):
508+
class FuncBase(Node): # type: ignore[abstract]
509509
"""Abstract base class for function-like nodes.
510510
511511
N.B: Although this has SymbolNode subclasses (FuncDef,
@@ -710,7 +710,7 @@ def __init__(
710710
]
711711

712712

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

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

25602562

2561-
class TypeVarLikeExpr(SymbolNode, Expression):
2563+
class TypeVarLikeExpr(SymbolNode, Expression): # type: ignore[abstract]
25622564
"""Base class for TypeVarExpr, ParamSpecExpr and TypeVarTupleExpr.
25632565
25642566
Note that they are constructed by the semantic analyzer.

mypy/plugin.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ class C: pass
151151

152152

153153
@trait
154-
class TypeAnalyzerPluginInterface:
154+
class TypeAnalyzerPluginInterface: # type: ignore[abstract]
155155
"""Interface for accessing semantic analyzer functionality in plugins.
156156
157157
Methods docstrings contain only basic info. Look for corresponding implementation
@@ -195,7 +195,7 @@ class AnalyzeTypeContext(NamedTuple):
195195

196196

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

218218

219219
@trait
220-
class CheckerPluginInterface:
220+
class CheckerPluginInterface: # type: ignore[abstract]
221221
"""Interface for accessing type checker functionality in plugins.
222222
223223
Methods docstrings contain only basic info. Look for corresponding implementation
@@ -254,7 +254,7 @@ def get_expression_type(self, node: Expression, type_context: Type | None = None
254254

255255

256256
@trait
257-
class SemanticAnalyzerPluginInterface:
257+
class SemanticAnalyzerPluginInterface: # type: ignore[abstract]
258258
"""Interface for accessing semantic analyzer functionality in plugins.
259259
260260
Methods docstrings contain only basic info. Look for corresponding implementation

mypy/report.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -705,7 +705,7 @@ def on_finish(self) -> None:
705705
register_reporter("cobertura-xml", CoberturaXmlReporter, needs_lxml=True)
706706

707707

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

711711
def __init__(self, reports: Reports, output_dir: str) -> None:

mypy/semanal.py

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1821,7 +1821,7 @@ def visit_decorator(self, dec: Decorator) -> None:
18211821
could_be_decorated_property = False
18221822
for i, d in enumerate(dec.decorators):
18231823
# A bunch of decorators are special cased here.
1824-
if refers_to_fullname(d, "abc.abstractmethod"):
1824+
if refers_to_fullname(d, ("abc.abstractmethod", "basedtyping.abstract")):
18251825
removed.append(i)
18261826
dec.func.abstract_status = IS_ABSTRACT
18271827
self.check_decorated_function_is_method("abstractmethod", dec)
@@ -1847,6 +1847,7 @@ def visit_decorator(self, dec: Decorator) -> None:
18471847
(
18481848
"builtins.property",
18491849
"abc.abstractproperty",
1850+
"basedtyping.abstract",
18501851
"functools.cached_property",
18511852
"enum.property",
18521853
"types.DynamicClassAttribute",
@@ -1855,7 +1856,7 @@ def visit_decorator(self, dec: Decorator) -> None:
18551856
removed.append(i)
18561857
dec.func.is_property = True
18571858
dec.var.is_property = True
1858-
if refers_to_fullname(d, "abc.abstractproperty"):
1859+
if refers_to_fullname(d, ("abc.abstractproperty", "basedtyping.abstract")):
18591860
dec.func.abstract_status = IS_ABSTRACT
18601861
elif refers_to_fullname(d, "functools.cached_property"):
18611862
dec.var.is_settable_property = True
@@ -2340,6 +2341,8 @@ def analyze_class_decorator_common(
23402341
"""
23412342
if refers_to_fullname(decorator, FINAL_DECORATOR_NAMES):
23422343
info.is_final = True
2344+
elif refers_to_fullname(decorator, "basedtyping.abstract"):
2345+
info.is_abstract = True
23432346
elif refers_to_fullname(decorator, TYPE_CHECK_ONLY_NAMES):
23442347
info.is_type_check_only = True
23452348
elif (deprecated := self.get_deprecated(decorator)) is not None:
@@ -3410,7 +3413,9 @@ def visit_assignment_stmt(self, s: AssignmentStmt) -> None:
34103413
self.analyze_lvalues(s)
34113414
self.check_final_implicit_def(s)
34123415
self.store_final_status(s)
3413-
self.check_classvar(s)
3416+
# this is a bit lazy, but gets the job done
3417+
while self.check_abstract(s) or self.check_classvar(s) or self.check_read_only(s):
3418+
pass
34143419
self.process_type_annotation(s)
34153420
self.apply_dynamic_class_hook(s)
34163421
if not s.type:
@@ -5226,13 +5231,13 @@ def analyze_value_types(self, items: list[Expression]) -> list[Type]:
52265231
result.append(AnyType(TypeOfAny.from_error))
52275232
return result
52285233

5229-
def check_classvar(self, s: AssignmentStmt) -> None:
5234+
def check_classvar(self, s: AssignmentStmt) -> bool:
52305235
"""Check if assignment defines a class variable."""
52315236
lvalue = s.lvalues[0]
52325237
if len(s.lvalues) != 1 or not isinstance(lvalue, RefExpr):
5233-
return
5238+
return False
52345239
if not s.type or not self.is_classvar(s.type):
5235-
return
5240+
return False
52365241
if self.is_class_scope() and isinstance(lvalue, NameExpr):
52375242
node = lvalue.node
52385243
if isinstance(node, Var):
@@ -5257,26 +5262,88 @@ def check_classvar(self, s: AssignmentStmt) -> None:
52575262
# In case of member access, report error only when assigning to self
52585263
# Other kinds of member assignments should be already reported
52595264
self.fail_invalid_classvar(lvalue)
5265+
if s.type.args:
5266+
s.type = s.type.args[0]
5267+
else:
5268+
s.type = None
5269+
return True
5270+
5271+
def check_abstract(self, s: AssignmentStmt) -> bool:
5272+
"""Check if assignment defines an abstract variable."""
5273+
lvalue = s.lvalues[0]
5274+
if len(s.lvalues) != 1 or not isinstance(lvalue, RefExpr):
5275+
return False
5276+
if not s.type or not self.is_abstract(s.type):
5277+
return False
5278+
if self.is_class_scope() and isinstance(lvalue, NameExpr):
5279+
node = lvalue.node
5280+
if isinstance(node, Var):
5281+
node.is_abstract_var = True
5282+
assert self.type is not None
5283+
elif not isinstance(lvalue, MemberExpr) or self.is_self_member_ref(lvalue):
5284+
# In case of member access, report error only when assigning to self
5285+
# Other kinds of member assignments should be already reported
5286+
self.fail_invalid_abstract(lvalue)
5287+
s.type = s.type.args[0]
5288+
return True
52605289

5261-
def is_classvar(self, typ: Type) -> bool:
5290+
def check_read_only(self, s: AssignmentStmt) -> bool:
5291+
"""Check if assignment defines a read only variable."""
5292+
lvalue = s.lvalues[0]
5293+
if len(s.lvalues) != 1 or not isinstance(lvalue, RefExpr):
5294+
return False
5295+
if not s.type or not self.is_read_only_type(s.type):
5296+
return False
5297+
node = lvalue.node
5298+
if isinstance(node, Var):
5299+
node.is_read_only = True
5300+
s.is_final_def = True
5301+
if not mypy.options._based:
5302+
return False
5303+
if s.type.args:
5304+
s.type = s.type.args[0]
5305+
else:
5306+
s.type = None
5307+
return True
5308+
5309+
def is_classvar(self, typ: Type) -> typ is UnboundType if True else False:
52625310
if not isinstance(typ, UnboundType):
52635311
return False
52645312
sym = self.lookup_qualified(typ.name, typ)
52655313
if not sym or not sym.node:
52665314
return False
52675315
return sym.node.fullname == "typing.ClassVar"
52685316

5269-
def is_final_type(self, typ: Type | None) -> bool:
5317+
def is_abstract(self, typ: Type) -> typ is UnboundType if True else False:
5318+
if not isinstance(typ, UnboundType):
5319+
return False
5320+
sym = self.lookup_qualified(typ.name, typ)
5321+
if not sym or not sym.node:
5322+
return False
5323+
return sym.node.fullname == "basedtyping.Abstract"
5324+
5325+
def is_final_type(self, typ: Type | None) -> typ is UnboundType if True else False:
52705326
if not isinstance(typ, UnboundType):
52715327
return False
52725328
sym = self.lookup_qualified(typ.name, typ)
52735329
if not sym or not sym.node:
52745330
return False
52755331
return sym.node.fullname in FINAL_TYPE_NAMES
52765332

5333+
def is_read_only_type(self, typ: Type | None) -> typ is UnboundType if True else False:
5334+
if not isinstance(typ, UnboundType):
5335+
return False
5336+
sym = self.lookup_qualified(typ.name, typ)
5337+
if not sym or not sym.node:
5338+
return False
5339+
return sym.node.fullname in ("typing.ReadOnly", "typing_extensions.ReadOnly")
5340+
52775341
def fail_invalid_classvar(self, context: Context) -> None:
52785342
self.fail(message_registry.CLASS_VAR_OUTSIDE_OF_CLASS, context)
52795343

5344+
def fail_invalid_abstract(self, context: Context) -> None:
5345+
self.fail(message_registry.ABSTRACT_OUTSIDE_OF_CLASS, context)
5346+
52805347
def process_module_assignment(
52815348
self, lvals: list[Lvalue], rval: Expression, ctx: AssignmentStmt
52825349
) -> None:

0 commit comments

Comments
 (0)