From 399af08c0c51e05b4774500c778f62f35c537d90 Mon Sep 17 00:00:00 2001 From: "Kyle D. McCormick" Date: Wed, 31 Jan 2024 14:26:13 -0500 Subject: [PATCH] squash: everything checks, except test, utils & reference --- xblock/__init__.py | 2 +- xblock/core.py | 10 +++- xblock/django/request.py | 72 +++++++++++++----------- xblock/fields.py | 116 ++++++++++++++++++++++----------------- xblock/mixins.py | 102 +++++++++++++++++++++------------- xblock/runtime.py | 8 ++- 6 files changed, 185 insertions(+), 125 deletions(-) diff --git a/xblock/__init__.py b/xblock/__init__.py index d10628e18..e6ba45da9 100644 --- a/xblock/__init__.py +++ b/xblock/__init__.py @@ -25,6 +25,6 @@ def __init__(self, *args, **kwargs): # For backwards compatibility, provide the XBlockMixin in xblock.fields # without causing a circular import -xblock.fields.XBlockMixin = XBlockMixin +xblock.fields.XBlockMixin = XBlockMixin # type: ignore[attr-defined] __version__ = '1.10.0' diff --git a/xblock/core.py b/xblock/core.py index 08972e59f..3fc3608cb 100644 --- a/xblock/core.py +++ b/xblock/core.py @@ -11,9 +11,9 @@ import warnings import typing as t from collections import defaultdict -from xml.etree import ElementTree as ET import pkg_resources +from lxml import etree from opaque_keys.edx.keys import LearningContextKey, UsageKey from web_fragments.fragment import Fragment @@ -146,7 +146,9 @@ class XBlock(XmlSerializationMixin, HierarchyMixin, ScopedStorageMixin, RuntimeS entry_point: str = 'xblock.v1' name = String(help="Short name for the block", scope=Scope.settings) - tags = List(help="Tags for this block", scope=Scope.settings) + tags: List[str] = List(help="Tags for this block", scope=Scope.settings) + + has_children: bool @class_lazy def _class_tags(cls: type[XBlock]) -> set[str]: # type: ignore[misc] @@ -219,6 +221,8 @@ def __init__( """ if scope_ids is UNSET: raise TypeError('scope_ids are required') + else: + assert isinstance(scope_ids, ScopeIds) # Provide backwards compatibility for external access through _field_data super().__init__(runtime=runtime, scope_ids=scope_ids, field_data=field_data, *args, **kwargs) @@ -271,7 +275,7 @@ def ugettext(self, text) -> str: runtime_ugettext = runtime_service.ugettext return runtime_ugettext(text) - def add_xml_to_node(self, node: ET.Element) -> None: + def add_xml_to_node(self, node: etree._Element) -> None: """ For exporting, set data on etree.Element `node`. """ diff --git a/xblock/django/request.py b/xblock/django/request.py index 9e17e6721..6c27f05d3 100644 --- a/xblock/django/request.py +++ b/xblock/django/request.py @@ -1,16 +1,21 @@ """Helpers for WebOb requests and responses.""" +from __future__ import annotations + from collections.abc import MutableMapping from itertools import chain, repeat from lazy import lazy +import typing as t import webob -from django.http import HttpResponse -from webob.multidict import MultiDict, NestedMultiDict, NoVars +import webob.multidict +import django.core.files.uploadedfile +import django.http +import django.utils.datastructures -def webob_to_django_response(webob_response): +def webob_to_django_response(webob_response: webob.Response) -> django.http.HttpResponse: """Returns a django response to the `webob_response`""" - django_response = HttpResponse( + django_response = django.http.HttpResponse( webob_response.app_iter, content_type=webob_response.content_type, status=webob_response.status_code, @@ -28,11 +33,11 @@ class HeaderDict(MutableMapping): """ UNPREFIXED_HEADERS = ('CONTENT_TYPE', 'CONTENT_LENGTH') - def __init__(self, meta): + def __init__(self, meta: dict[str, str]): super().__init__() self._meta = meta - def _meta_name(self, name): + def _meta_name(self, name: str) -> str: """ Translate HTTP header names to the format used by Django request objects. @@ -43,7 +48,7 @@ def _meta_name(self, name): name = 'HTTP_' + name return name - def _un_meta_name(self, name): + def _un_meta_name(self, name: str) -> str: """ Reverse of _meta_name """ @@ -51,33 +56,35 @@ def _un_meta_name(self, name): name = name[5:] return name.replace('_', '-').title() - def __getitem__(self, name): + def __getitem__(self, name: str): return self._meta[self._meta_name(name)] - def __setitem__(self, name, value): + def __setitem__(self, name: str, value: str): self._meta[self._meta_name(name)] = value - def __delitem__(self, name): + def __delitem__(self, name: str): del self._meta[self._meta_name(name)] - def __iter__(self): + def __iter__(self) -> t.Iterator[str]: for key in self._meta: if key in self.UNPREFIXED_HEADERS or key.startswith('HTTP_'): yield self._un_meta_name(key) - def __len__(self): + def __len__(self) -> int: return len(list(self)) -def querydict_to_multidict(query_dict, wrap=None): +def querydict_to_multidict( + query_dict: django.utils.datastructures.MultiValueDict, + wrap: t.Callable[[t.Any], t.Any] | None = None +) -> webob.multidict.MultiDict: """ Returns a new `webob.MultiDict` from a `django.http.QueryDict`. If `wrap` is provided, it's used to wrap the values. - """ wrap = wrap or (lambda val: val) - return MultiDict(chain.from_iterable( + return webob.multidict.MultiDict(chain.from_iterable( zip(repeat(key), (wrap(v) for v in vals)) for key, vals in query_dict.lists() )) @@ -87,32 +94,35 @@ class DjangoUploadedFile: """ Looks like a cgi.FieldStorage, but wraps a Django UploadedFile. """ - def __init__(self, uploaded): + def __init__(self, uploaded: django.core.files.uploadedfile.UploadedFile): # FieldStorage needs a file attribute. - self.file = uploaded + self.file: t.Any = uploaded @property - def name(self): + def name(self) -> str: """The name of the input element used to upload the file.""" return self.file.field_name @property - def filename(self): + def filename(self) -> str: """The name of the uploaded file.""" return self.file.name class DjangoWebobRequest(webob.Request): """ - An implementation of the webob request api, backed - by a django request + An implementation of the webob request api, backed by a django request """ - def __init__(self, request): + # Note: + # This implementation is close enough to webob.Request for it to work OK, but it does + # make mypy complain that the type signatures are different, hence the 'type: ignore' pragmas. + + def __init__(self, request: django.http.HttpRequest): self._request = request super().__init__(self.environ) @lazy - def environ(self): + def environ(self) -> dict[str, str]: # type: ignore[override] """ Add path_info to the request's META dictionary. """ @@ -123,40 +133,40 @@ def environ(self): return environ @property - def GET(self): + def GET(self) -> webob.multidict.MultiDict: # type: ignore[override] """ Returns a new `webob.MultiDict` from the request's GET query. """ return querydict_to_multidict(self._request.GET) @property - def POST(self): + def POST(self) -> webob.multidict.MultiDict | webob.multidict.NoVars: # type: ignore[override] if self.method not in ('POST', 'PUT', 'PATCH'): - return NoVars('Not a form request') + return webob.multidict.NoVars('Not a form request') # Webob puts uploaded files into the POST dictionary, so here we # combine the Django POST data and uploaded FILES data into a single # dict. - return NestedMultiDict( + return webob.multidict.NestedMultiDict( querydict_to_multidict(self._request.POST), querydict_to_multidict(self._request.FILES, wrap=DjangoUploadedFile), ) @property - def body(self): + def body(self) -> bytes: # type: ignore[override] """ Return the content of the request body. """ return self._request.body - @property - def body_file(self): + @property # type: ignore[misc] + def body_file(self) -> django.http.HttpRequest: # type: ignore[override] """ Input stream of the request """ return self._request -def django_to_webob_request(django_request): +def django_to_webob_request(django_request: django.http.HttpRequest) -> webob.Request: """Returns a WebOb request to the `django_request`""" return DjangoWebobRequest(django_request) diff --git a/xblock/fields.py b/xblock/fields.py index 932b0ce6d..c30ac690b 100644 --- a/xblock/fields.py +++ b/xblock/fields.py @@ -8,7 +8,6 @@ from __future__ import annotations import copy -import datetime import hashlib import itertools import json @@ -17,6 +16,7 @@ import typing as t import warnings from dataclasses import dataclass +from datetime import datetime, timedelta from enum import Enum import dateutil.parser @@ -28,7 +28,7 @@ from xblock.internal import Nameable if t.TYPE_CHECKING: - from .core import XBlock + from xblock.core import XBlock # __all__ controls what classes end up in the docs, and in what order. @@ -312,8 +312,9 @@ class ExplicitlySet(Sentinel): # because they define the structure of XBlock trees. NO_GENERATED_DEFAULTS = ('parent', 'children') - -FieldValue = t.TypeVar("FieldValue") +# Type parameters of Fields. These only matter for static type analysis (mypy). +FieldValue = t.TypeVar("FieldValue") # What does the field hold? +InnerFieldValue = t.TypeVar("InnerFieldValue") # For Dict/List/Set fields: What do they contain? class Field(Nameable, t.Generic[FieldValue]): @@ -656,7 +657,7 @@ def _warn_deprecated_outside_JSONField(self): # pylint: disable=invalid-name ) self.warned = True - def to_json(self, value: FieldValue | None) -> FieldValue | None: + def to_json(self, value: FieldValue | None) -> t.Any: """ Return value in the form of nested lists and dictionaries (suitable for passing to json.dumps). @@ -667,7 +668,7 @@ def to_json(self, value: FieldValue | None) -> FieldValue | None: self._warn_deprecated_outside_JSONField() return value - def from_json(self, value: FieldValue | None) -> FieldValue | None: + def from_json(self, value) -> FieldValue | None: """ Return value as a native full featured python type (the inverse of to_json) @@ -675,7 +676,7 @@ def from_json(self, value: FieldValue | None) -> FieldValue | None: object """ self._warn_deprecated_outside_JSONField() - return value + return value # type: ignore def to_string(self, value: FieldValue | None) -> str: """ @@ -746,14 +747,14 @@ def __hash__(self) -> int: return hash(self.name) -class JSONField(Field): +class JSONField(Field, t.Generic[FieldValue]): """ Field type which has a convenient JSON representation. """ # for now; we'll bubble functions down when we finish deprecation in Field -class Integer(JSONField): +class Integer(JSONField[int]): """ A field that contains an integer. @@ -767,7 +768,7 @@ class Integer(JSONField): """ MUTABLE = False - def from_json(self, value): + def from_json(self, value) -> int | None: if value is None or value == '': return None return int(value) @@ -775,7 +776,7 @@ def from_json(self, value): enforce_type = from_json -class Float(JSONField): +class Float(JSONField[float]): """ A field that contains a float. @@ -786,7 +787,7 @@ class Float(JSONField): """ MUTABLE = False - def from_json(self, value): + def from_json(self, value) -> float | None: if value is None or value == '': return None return float(value) @@ -794,7 +795,7 @@ def from_json(self, value): enforce_type = from_json -class Boolean(JSONField): +class Boolean(JSONField[bool]): """ A field class for representing a boolean. @@ -823,7 +824,7 @@ def __init__(self, help=None, default=UNSET, scope=Scope.content, display_name=N values=({'display_name': "True", "value": True}, {'display_name': "False", "value": False}), **kwargs) - def from_json(self, value): + def from_json(self, value) -> bool | None: if isinstance(value, bytes): value = value.decode('ascii', errors='replace') if isinstance(value, str): @@ -834,7 +835,7 @@ def from_json(self, value): enforce_type = from_json -class Dict(JSONField): +class Dict(JSONField[t.Dict[str, InnerFieldValue]], t.Generic[InnerFieldValue]): """ A field class for representing a Python dict. @@ -843,7 +844,7 @@ class Dict(JSONField): """ _default = {} - def from_json(self, value): + def from_json(self, value) -> dict[str, InnerFieldValue] | None: if value is None or isinstance(value, dict): return value else: @@ -851,7 +852,7 @@ def from_json(self, value): enforce_type = from_json - def to_string(self, value): + def to_string(self, value) -> str: """ In python3, json.dumps() cannot sort keys of different types, so preconvert None to 'null'. @@ -864,7 +865,7 @@ def to_string(self, value): return super().to_string(value) -class List(JSONField): +class List(JSONField[t.List[InnerFieldValue]], t.Generic[InnerFieldValue]): """ A field class for representing a list. @@ -873,7 +874,7 @@ class List(JSONField): """ _default = [] - def from_json(self, value): + def from_json(self, value) -> list[InnerFieldValue] | None: if value is None or isinstance(value, list): return value else: @@ -882,7 +883,7 @@ def from_json(self, value): enforce_type = from_json -class Set(JSONField): +class Set(JSONField[t.List[InnerFieldValue]], t.Generic[InnerFieldValue]): """ A field class for representing a set. @@ -901,7 +902,7 @@ def __init__(self, *args, **kwargs): self._default = set(self._default) - def from_json(self, value): + def from_json(self, value) -> set[InnerFieldValue] | None: if value is None or isinstance(value, set): return value else: @@ -910,7 +911,7 @@ def from_json(self, value): enforce_type = from_json -class String(JSONField): +class String(JSONField[str]): """ A field class for representing a string. @@ -920,7 +921,7 @@ class String(JSONField): MUTABLE = False BAD_REGEX = re.compile('[\x00-\x08\x0b\x0c\x0e-\x1f\ud800-\udfff\ufffe\uffff]', flags=re.UNICODE) - def _sanitize(self, value): + def _sanitize(self, value) -> str | None: """ Remove the control characters that are not allowed in XML: https://www.w3.org/TR/xml/#charsets @@ -940,7 +941,7 @@ def _sanitize(self, value): else: return value - def from_json(self, value): + def from_json(self, value) -> str | None: if value is None: return None elif isinstance(value, (bytes, str)): @@ -952,20 +953,23 @@ def from_json(self, value): else: raise TypeError('Value stored in a String must be None or a string, found %s' % type(value)) - def from_string(self, serialized): + def from_string(self, serialized) -> str | None: """String gets serialized and deserialized without quote marks.""" return self.from_json(serialized) - def to_string(self, value): + def to_string(self, value) -> str: """String gets serialized and deserialized without quote marks.""" if isinstance(value, bytes): value = value.decode('utf-8') return self.to_json(value) - @property - def none_to_xml(self): - """Returns True to use a XML node for the field and represent None as an attribute.""" - return True + def enforce_type(self, value: t.Any) -> str | None: + """ + (no-op override just to make mypy happy about XMLString.enforce_type) + """ + return super().enforce_type(value) + + none_to_xml = True # Use an XML node for the field, and represent None as an attribute. enforce_type = from_json @@ -979,7 +983,7 @@ class XMLString(String): an lxml.etree.XMLSyntaxError will be raised. """ - def to_json(self, value): + def to_json(self, value) -> t.Any: """ Serialize the data, ensuring that it is valid XML (or None). @@ -990,13 +994,13 @@ def to_json(self, value): value = self.enforce_type(value) return super().to_json(value) - def enforce_type(self, value): + def enforce_type(self, value: t.Any) -> str | None: if value is not None: etree.XML(value) - return value + return value # type: ignore -class DateTime(JSONField): +class DateTime(JSONField[t.Union[datetime, timedelta]]): """ A field for representing a datetime. @@ -1006,7 +1010,7 @@ class DateTime(JSONField): DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%f' - def from_json(self, value): + def from_json(self, value) -> datetime | timedelta | None: """ Parse the date from an ISO-formatted date string, or None. """ @@ -1028,16 +1032,16 @@ def from_json(self, value): # Interpret raw numbers as a relative dates if isinstance(value, (int, float)): - value = datetime.timedelta(seconds=value) + value = timedelta(seconds=value) - if not isinstance(value, (datetime.datetime, datetime.timedelta)): + if not isinstance(value, (datetime, timedelta)): raise TypeError( "Value should be loaded from a string, a datetime object, a timedelta object, or None, not {}".format( type(value) ) ) - if isinstance(value, datetime.datetime): + if isinstance(value, datetime): if value.tzinfo is not None: return value.astimezone(pytz.utc) else: @@ -1045,14 +1049,14 @@ def from_json(self, value): else: return value - def to_json(self, value): + def to_json(self, value) -> t.Any: """ Serialize the date as an ISO-formatted date string, or None. """ - if isinstance(value, datetime.datetime): + if isinstance(value, datetime): return value.strftime(self.DATETIME_FORMAT) - if isinstance(value, datetime.timedelta): + if isinstance(value, timedelta): return value.total_seconds() if value is None: @@ -1060,14 +1064,14 @@ def to_json(self, value): raise TypeError(f"Value stored must be a datetime or timedelta object, not {type(value)}") - def to_string(self, value): + def to_string(self, value) -> str: """DateTime fields get serialized without quote marks.""" return self.to_json(value) enforce_type = from_json -class Any(JSONField): +class Any(JSONField[t.Any]): """ A field class for representing any piece of data; type is not enforced. @@ -1077,7 +1081,7 @@ class Any(JSONField): """ -class Reference(JSONField): +class Reference(JSONField[UsageKey]): """ An xblock reference. That is, a pointer to another xblock. @@ -1086,7 +1090,7 @@ class Reference(JSONField): """ -class ReferenceList(List): +class ReferenceList(List[UsageKey]): """ An list of xblock references. That is, pointers to xblocks. @@ -1097,7 +1101,19 @@ class ReferenceList(List): # but since Reference doesn't stipulate a definition for from/to, that seems unnecessary at this time. -class ReferenceValueDict(Dict): +class ReferenceListNotNone(ReferenceList): + """ + An list of xblock references. Should not equal None. + + Functionally, this is exactly equivalent to ReferenceList. + To the type-checker, this adds that guarantee that accessing the field will always return + a list of UsageKeys, rather than None OR a list of UsageKeys. + """ + def __get__(self, xblock: XBlock, xblock_class: type[XBlock]) -> list[UsageKey]: + return super().__get__(xblock, xblock_class) # type: ignore + + +class ReferenceValueDict(Dict[UsageKey]): """ A dictionary where the values are xblock references. That is, pointers to xblocks. @@ -1108,8 +1124,9 @@ class ReferenceValueDict(Dict): # but since Reference doesn't stipulate a definition for from/to, that seems unnecessary at this time. -def scope_key(instance, xblock): - """Generate a unique key for a scope that can be used as a +def scope_key(instance: Field, xblock: XBlock) -> str: + """ + Generate a unique key for a scope that can be used as a filename, in a URL, or in a KVS. Our goal is to have a pretty, human-readable 1:1 encoding. @@ -1174,11 +1191,10 @@ def scope_key(instance, xblock): key_list = [] - def encode(char): + def encode(char: str) -> str: """ Replace all non-alphanumeric characters with -n- where n is their Unicode codepoint. - TODO: Test for UTF8 which is not ASCII """ if char.isalnum(): return char diff --git a/xblock/mixins.py b/xblock/mixins.py index 962cf53e2..774562aea 100644 --- a/xblock/mixins.py +++ b/xblock/mixins.py @@ -11,16 +11,23 @@ import logging import warnings import json +import typing as t +from opaque_keys.edx.keys import UsageKey from lxml import etree -from webob import Response +from webob import Request, Response from xblock.exceptions import JsonHandlerError, KeyValueMultiSaveError, XBlockSaveError, FieldDataDeprecationWarning -from xblock.fields import Field, Reference, Scope, ScopeIds, ReferenceList +from xblock.fields import Field, Reference, Scope, ScopeIds, ReferenceListNotNone from xblock.field_data import FieldData from xblock.internal import class_lazy, NamedAttributesMetaclass +if t.TYPE_CHECKING: + from xblock.core import XBlock + from xblock.runtime import Runtime + + # OrderedDict is used so that namespace attributes are put in predictable order # This allows for simple string equality assertions in tests and have no other effects XML_NAMESPACES = OrderedDict([ @@ -35,7 +42,7 @@ class HandlersMixin: """ @classmethod - def json_handler(cls, func): + def json_handler(cls, func: t.Callable[[XBlock, t.Any, str], t.Any]) -> t.Callable[[XBlock, Request, str], Response]: """ Wrap a handler to consume and produce JSON. @@ -54,7 +61,7 @@ def json_handler(cls, func): """ @cls.handler @functools.wraps(func) - def wrapper(self, request, suffix=''): + def wrapper(self, request: Request, suffix: str = '') -> Response: """The wrapper function `json_handler` returns.""" if request.method != "POST": return JsonHandlerError(405, "Method must be POST").get_response(allow=["POST"]) @@ -73,18 +80,19 @@ def wrapper(self, request, suffix=''): return wrapper @classmethod - def handler(cls, func): + def handler(cls, func: t.Callable) -> t.Callable: """ A decorator to indicate a function is usable as a handler. The wrapped function must return a `webob.Response` object. """ - func._is_xblock_handler = True # pylint: disable=protected-access + func._is_xblock_handler = True # type: ignore return func - def handle(self, handler_name, request, suffix=''): + def handle(self, handler_name: str, request: Request, suffix: str = '') -> Response: """Handle `request` with this block's runtime.""" - return self.runtime.handle(self, handler_name, request, suffix) + runtime: Runtime = self.runtime # type: ignore + return runtime.handle(self, handler_name, request, suffix) class RuntimeServicesMixin: @@ -215,9 +223,9 @@ def __init__(self, scope_ids: ScopeIds, field_data: FieldData | None = None, **k else: self._deprecated_per_instance_field_data = None # pylint: disable=invalid-name - self._field_data_cache = {} - self._dirty_fields = {} - self.scope_ids = scope_ids + self._field_data_cache: dict[str, t.Any] = {} + self._dirty_fields: dict[Field, t.Any] = {} + self.scope_ids: ScopeIds = scope_ids super().__init__(**kwargs) @@ -331,15 +339,19 @@ def __repr__(self): ) -class ChildrenModelMetaclass(ScopedStorageMixin.__class__): +class ChildrenModelMetaclass(NamedAttributesMetaclass): """ A metaclass that transforms the attribute `has_children = True` into a List field with a children scope. - """ - def __new__(mcs, name, bases, attrs): + def __new__( + mcs: type[ChildrenModelMetaclass], + name: str, + bases: tuple[type, ...], + attrs: dict[str, t.Any], + ): if (attrs.get('has_children', False) or any(getattr(base, 'has_children', False) for base in bases)): - attrs['children'] = ReferenceList( + attrs['children'] = ReferenceListNotNone( help='The ids of the children of this XBlock', scope=Scope.children) else: @@ -351,17 +363,23 @@ def __new__(mcs, name, bases, attrs): class HierarchyMixin(ScopedStorageMixin, metaclass=ChildrenModelMetaclass): """ This adds Fields for parents and children. + + TODO: Why in the world is this separate from XBlock?? + It depends on a bunch of XBlock attributes (runtime, has_childen, children...) + so it's not really useful outside of XBlock. As a future refactoring, consider + smashing this into the XBlock class and removing all the '_as_xblock' stuff below. """ parent = Reference(help='The id of the parent of this XBlock', default=None, scope=Scope.parent) + children: ReferenceListNotNone - def __init__(self, **kwargs): + def __init__(self, **kwargs) -> None: # A cache of the parent block, retrieved from .parent - self._parent_block = None - self._parent_block_id = None - self._child_cache = {} + self._parent_block: XBlock | None = None + self._parent_block_id: UsageKey | None = None + self._child_cache: dict[UsageKey, XBlock] = {} - for_parent = kwargs.pop('for_parent', None) + for_parent: XBlock | None = kwargs.pop('for_parent', None) # type: ignore if for_parent is not None: self._parent_block = for_parent @@ -369,57 +387,67 @@ def __init__(self, **kwargs): super().__init__(**kwargs) - def get_parent(self): + @property + def _as_xblock(self) -> XBlock: + """ + The same as 'self', but type-checks as an XBlock. + + This is a dumb mypy hack that would be great to remove. See TODO comment in class docstring. + """ + return self # type: ignore + + def get_parent(self) -> XBlock | None: """Return the parent block of this block, or None if there isn't one.""" if not self.has_cached_parent: - if self.parent is not None: - self._parent_block = self.runtime.get_block(self.parent) + if self._as_xblock.parent is not None: + self._parent_block = self._as_xblock.runtime.get_block(self._as_xblock.parent) else: self._parent_block = None - self._parent_block_id = self.parent + self._parent_block_id = self._as_xblock.parent return self._parent_block @property - def has_cached_parent(self): + def has_cached_parent(self) -> bool: """Return whether this block has a cached parent block.""" - return self.parent is not None and self._parent_block_id == self.parent + return self._as_xblock.parent is not None and self._parent_block_id == self._as_xblock.parent - def get_child(self, usage_id): + def get_child(self, usage_id: UsageKey) -> XBlock: """Return the child identified by ``usage_id``.""" if usage_id in self._child_cache: return self._child_cache[usage_id] - child_block = self.runtime.get_block(usage_id, for_parent=self) + runtime: Runtime = self.runtime # type: ignore + child_block = runtime.get_block(usage_id, for_parent=self) self._child_cache[usage_id] = child_block return child_block - def get_children(self, usage_id_filter=None): + def get_children(self, usage_id_filter: t.Callable[[UsageKey], bool] | None = None) -> list[XBlock]: """ Return instantiated XBlocks for each of this blocks ``children``. """ - if not self.has_children: + if not self._as_xblock.has_children: return [] return [ self.get_child(usage_id) - for usage_id in self.children + for usage_id in self._as_xblock.children if usage_id_filter is None or usage_id_filter(usage_id) ] - def clear_child_cache(self): + def clear_child_cache(self) -> None: """ Reset the cache of children stored on this XBlock. """ self._child_cache.clear() - def add_children_to_node(self, node): + def add_children_to_node(self, node: etree._Element) -> None: """ Add children to etree.Element `node`. """ - if self.has_children: - for child_id in self.children: - child = self.runtime.get_block(child_id) - self.runtime.add_block_as_child_node(child, node) + if self._as_xblock.has_children: + for child_id in self._as_xblock.children: + child = self._as_xblock.runtime.get_block(child_id) + self._as_xblock.runtime.add_block_as_child_node(child, node) class XmlSerializationMixin(ScopedStorageMixin): diff --git a/xblock/runtime.py b/xblock/runtime.py index 05e8af10a..929f5df85 100644 --- a/xblock/runtime.py +++ b/xblock/runtime.py @@ -1,6 +1,8 @@ """ Machinery to make the common case easy when building new runtimes """ +from __future__ import annotations + from abc import ABCMeta, abstractmethod from collections import namedtuple import functools @@ -360,8 +362,8 @@ def create_definition(self, block_type, slug=None): class MemoryIdManager(IdReader, IdGenerator): """A simple dict-based implementation of IdReader and IdGenerator.""" - ASIDE_USAGE_ID = namedtuple('MemoryAsideUsageId', 'usage_id aside_type') - ASIDE_DEFINITION_ID = namedtuple('MemoryAsideDefinitionId', 'definition_id aside_type') + ASIDE_USAGE_ID = namedtuple('MemoryAsideUsageId', 'usage_id aside_type') # type: ignore[name-match] + ASIDE_DEFINITION_ID = namedtuple('MemoryAsideDefinitionId', 'definition_id aside_type') # type: ignore[name-match] def __init__(self): self._ids = itertools.count() @@ -1246,7 +1248,7 @@ def __delattr__(self, name): # Cache of Mixologist generated classes -_CLASS_CACHE = {} +_CLASS_CACHE: dict[tuple[type, tuple[type, ...]], type] = {} _CLASS_CACHE_LOCK = threading.RLock()