From ec2675005ef538eec625962c6e20c6177beb8cd3 Mon Sep 17 00:00:00 2001 From: Tycho Hob Date: Thu, 16 Apr 2026 08:41:39 -0400 Subject: [PATCH 01/12] style: Add type hinting infrastructure, mypy --- MANIFEST.in | 1 + Makefile | 5 +- mypy.ini | 109 +++++++++++++++++++++++++++++++++++++ openedx_events/py.typed | 0 requirements/ci.txt | 8 +-- requirements/dev.txt | 41 ++++++++++++-- requirements/doc.txt | 2 +- requirements/pip-tools.txt | 2 +- requirements/pip.txt | 2 +- requirements/quality.in | 2 + requirements/quality.txt | 23 +++++++- requirements/test.txt | 2 +- setup.py | 1 + tox.ini | 1 + 14 files changed, 184 insertions(+), 15 deletions(-) create mode 100644 mypy.ini create mode 100644 openedx_events/py.typed diff --git a/MANIFEST.in b/MANIFEST.in index 71f13d55..c49680f9 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,4 +3,5 @@ include LICENSE.txt include README.rst include requirements/base.in recursive-include openedx_events *.py +include openedx_events/py.typed include requirements/constraints.txt diff --git a/Makefile b/Makefile index 1aa61f4c..a35a1891 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: clean clean_tox compile_translations coverage diff_cover docs dummy_translations \ - extract_translations fake_translations help pull_translations push_translations \ + extract_translations fake_translations help mypy pull_translations push_translations \ quality requirements selfcheck test test-all upgrade validate install_transifex_client .DEFAULT_GOAL := help @@ -61,6 +61,9 @@ upgrade: ## update the requirements/*.txt files with the latest packages satisfy quality: ## check coding style with pycodestyle and pylint tox -e quality +mypy: ## run mypy static type checks + mypy openedx_events + piptools: ## install pinned version of pip-compile and pip-sync pip install -r requirements/pip.txt pip install -r requirements/pip-tools.txt diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..2462ce4e --- /dev/null +++ b/mypy.ini @@ -0,0 +1,109 @@ +[mypy] +python_version = 3.12 +; Start permissive at the project level — tighten per-module as each phase lands +warn_return_any = True +warn_unused_ignores = True +warn_unused_configs = True +warn_redundant_casts = True +disallow_untyped_defs = False +disallow_incomplete_defs = False +check_untyped_defs = True +no_implicit_optional = True +strict_equality = True + +plugins = + mypy_django_plugin.main + +[mypy.plugins.django-stubs] +django_settings_module = test_utils.test_settings + +; ----------------------------------------------------------------------- +; Third-party packages without type stubs — silence missing-import errors +; ----------------------------------------------------------------------- + +[mypy-fastavro.*] +ignore_missing_imports = True + +[mypy-ccx_keys.*] +ignore_missing_imports = True + +[mypy-opaque_keys.*] +ignore_missing_imports = True + +[mypy-edx_django_utils.*] +ignore_missing_imports = True + +[mypy-ddt] +ignore_missing_imports = True + + +; ----------------------------------------------------------------------- +; Per-module strictness overrides +; Flip disallow_untyped_defs = True module-by-module as annotations land. +; ----------------------------------------------------------------------- + +[mypy-openedx_events.exceptions] +disallow_untyped_defs = False + +[mypy-openedx_events.utils] +disallow_untyped_defs = False + +[mypy-openedx_events.data] +disallow_untyped_defs = False + +[mypy-openedx_events.analytics.data] +disallow_untyped_defs = False + +[mypy-openedx_events.enterprise.data] +disallow_untyped_defs = False + +[mypy-openedx_events.content_authoring.data] +disallow_untyped_defs = False + +[mypy-openedx_events.learning.data] +disallow_untyped_defs = False + +[mypy-openedx_events.event_bus.avro.types] +disallow_untyped_defs = False + +[mypy-openedx_events.event_bus.avro.custom_serializers] +disallow_untyped_defs = False + +[mypy-openedx_events.event_bus.avro.schema] +disallow_untyped_defs = False + +[mypy-openedx_events.event_bus.avro.serializer] +disallow_untyped_defs = False + +[mypy-openedx_events.event_bus.avro.deserializer] +disallow_untyped_defs = False + +[mypy-openedx_events.tooling] +disallow_untyped_defs = False + +[mypy-openedx_events.event_bus] +disallow_untyped_defs = False + +[mypy-openedx_events.apps] +disallow_untyped_defs = False + +[mypy-openedx_events.testing] +disallow_untyped_defs = False + +[mypy-openedx_events.management.commands.consume_events] +disallow_untyped_defs = False + +[mypy-openedx_events.management.commands.generate_avro_schemas] +disallow_untyped_defs = False + +[mypy-openedx_events.analytics.signals] +disallow_untyped_defs = False + +[mypy-openedx_events.enterprise.signals] +disallow_untyped_defs = False + +[mypy-openedx_events.content_authoring.signals] +disallow_untyped_defs = False + +[mypy-openedx_events.learning.signals] +disallow_untyped_defs = False diff --git a/openedx_events/py.typed b/openedx_events/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/requirements/ci.txt b/requirements/ci.txt index aeefd766..dd3878b0 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -10,12 +10,12 @@ colorama==0.4.6 # via tox distlib==0.4.0 # via virtualenv -filelock==3.25.2 +filelock==3.28.0 # via # python-discovery # tox # virtualenv -packaging==26.0 +packaging==26.1 # via # pyproject-api # tox @@ -34,7 +34,7 @@ python-discovery==1.2.2 # virtualenv tomli-w==1.2.0 # via tox -tox==4.52.1 +tox==4.53.0 # via -r requirements/ci.in -virtualenv==21.2.1 +virtualenv==21.2.4 # via tox diff --git a/requirements/dev.txt b/requirements/dev.txt index c9cfa7f1..9b4b930a 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -32,7 +32,7 @@ cffi==2.0.0 # -r requirements/quality.txt # cryptography # pynacl -chardet==7.4.1 +chardet==7.4.3 # via diff-cover charset-normalizer==3.4.7 # via @@ -84,12 +84,20 @@ django==5.2.13 # -c https://raw.githubusercontent.com/openedx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/quality.txt # django-crum + # django-stubs + # django-stubs-ext # django-waffle # edx-django-utils django-crum==0.7.9 # via # -r requirements/quality.txt # edx-django-utils +django-stubs[compatible-mypy]==6.0.2 + # via -r requirements/quality.txt +django-stubs-ext==6.0.2 + # via + # -r requirements/quality.txt + # django-stubs django-waffle==5.0.0 # via # -r requirements/quality.txt @@ -114,7 +122,7 @@ edx-opaque-keys[django]==4.0.0 # edx-ccx-keys fastavro==1.12.1 # via -r requirements/quality.txt -filelock==3.25.2 +filelock==3.28.0 # via # -r requirements/ci.txt # python-discovery @@ -162,6 +170,10 @@ keyring==25.7.0 # via # -r requirements/quality.txt # twine +librt==0.9.0 + # via + # -r requirements/quality.txt + # mypy markdown-it-py==4.0.0 # via # -r requirements/quality.txt @@ -183,11 +195,19 @@ more-itertools==11.0.2 # -r requirements/quality.txt # jaraco-classes # jaraco-functools +mypy==1.20.1 + # via + # -r requirements/quality.txt + # django-stubs +mypy-extensions==1.1.0 + # via + # -r requirements/quality.txt + # mypy nh3==0.3.4 # via # -r requirements/quality.txt # readme-renderer -packaging==26.0 +packaging==26.1 # via # -r requirements/ci.txt # -r requirements/pip-tools.txt @@ -198,6 +218,10 @@ packaging==26.0 # tox # twine # wheel +pathspec==1.0.4 + # via + # -r requirements/quality.txt + # mypy pip-tools==7.5.3 # via -r requirements/pip-tools.txt platformdirs==4.9.6 @@ -346,21 +370,28 @@ tomlkit==0.14.0 # via # -r requirements/quality.txt # pylint -tox==4.52.1 +tox==4.53.0 # via -r requirements/ci.txt twine==6.2.0 # via -r requirements/quality.txt +types-pyyaml==6.0.12.20260408 + # via + # -r requirements/quality.txt + # django-stubs typing-extensions==4.15.0 # via # -r requirements/quality.txt + # django-stubs + # django-stubs-ext # edx-opaque-keys + # mypy urllib3==2.6.3 # via # -r requirements/quality.txt # id # requests # twine -virtualenv==21.2.1 +virtualenv==21.2.4 # via # -r requirements/ci.txt # tox diff --git a/requirements/doc.txt b/requirements/doc.txt index 2ebf23d2..a0073eaf 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -141,7 +141,7 @@ more-itertools==11.0.2 # jaraco-functools nh3==0.3.4 # via readme-renderer -packaging==26.0 +packaging==26.1 # via # -r requirements/test.txt # build diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index e7d1c474..ef46f514 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -8,7 +8,7 @@ build==1.4.3 # via pip-tools click==8.3.2 # via pip-tools -packaging==26.0 +packaging==26.1 # via # build # wheel diff --git a/requirements/pip.txt b/requirements/pip.txt index c87fe302..b76333d3 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -4,7 +4,7 @@ # # make upgrade # -packaging==26.0 +packaging==26.1 # via wheel wheel==0.46.3 # via -r requirements/pip.in diff --git a/requirements/quality.in b/requirements/quality.in index caf0076f..cdd5d401 100644 --- a/requirements/quality.in +++ b/requirements/quality.in @@ -3,8 +3,10 @@ -r test.txt # Core and testing dependencies for this package +django-stubs[compatible-mypy] # Type stubs for Django, compatible with mypy edx-lint # edX pylint rules and plugins isort # to standardize order of imports +mypy # Static type checker for Python pycodestyle # PEP 8 compliance validation twine # Utility for publishing Python packages on PyPI. ruff # A modern Python code linter and formatter. diff --git a/requirements/quality.txt b/requirements/quality.txt index e69361dd..ef92cefb 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -51,12 +51,18 @@ django==5.2.13 # -c https://raw.githubusercontent.com/openedx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt # django-crum + # django-stubs + # django-stubs-ext # django-waffle # edx-django-utils django-crum==0.7.9 # via # -r requirements/test.txt # edx-django-utils +django-stubs[compatible-mypy]==6.0.2 + # via -r requirements/quality.in +django-stubs-ext==6.0.2 + # via django-stubs django-waffle==5.0.0 # via # -r requirements/test.txt @@ -107,6 +113,8 @@ jinja2==3.1.6 # code-annotations keyring==25.7.0 # via twine +librt==0.9.0 + # via mypy markdown-it-py==4.0.0 # via rich markupsafe==3.0.3 @@ -121,13 +129,21 @@ more-itertools==11.0.2 # via # jaraco-classes # jaraco-functools +mypy==1.20.1 + # via + # -r requirements/quality.in + # django-stubs +mypy-extensions==1.1.0 + # via mypy nh3==0.3.4 # via readme-renderer -packaging==26.0 +packaging==26.1 # via # -r requirements/test.txt # pytest # twine +pathspec==1.0.4 + # via mypy platformdirs==4.9.6 # via pylint pluggy==1.6.0 @@ -229,10 +245,15 @@ tomlkit==0.14.0 # via pylint twine==6.2.0 # via -r requirements/quality.in +types-pyyaml==6.0.12.20260408 + # via django-stubs typing-extensions==4.15.0 # via # -r requirements/test.txt + # django-stubs + # django-stubs-ext # edx-opaque-keys + # mypy urllib3==2.6.3 # via # id diff --git a/requirements/test.txt b/requirements/test.txt index 59280a19..89a40b2a 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -59,7 +59,7 @@ jinja2==3.1.6 # via code-annotations markupsafe==3.0.3 # via jinja2 -packaging==26.0 +packaging==26.1 # via pytest pluggy==1.6.0 # via diff --git a/setup.py b/setup.py index cfb6d14e..b38f8f54 100644 --- a/setup.py +++ b/setup.py @@ -165,6 +165,7 @@ def is_requirement(line): include=["openedx_events", "openedx_events.*"], exclude=["*tests"], ), + package_data={"openedx_events": ["py.typed"]}, install_requires=load_requirements("requirements/base.in"), python_requires=">=3.12", license="Apache 2.0", diff --git a/tox.ini b/tox.ini index 024d493b..9061fa8c 100644 --- a/tox.ini +++ b/tox.ini @@ -55,4 +55,5 @@ commands = pycodestyle openedx_events manage.py setup.py ruff check openedx_events test_utils manage.py setup.py isort --check-only --diff test_utils openedx_events manage.py setup.py + mypy openedx_events make selfcheck From 8b4f4f0ccd1f8b70a093efb54c7c92c0601160b3 Mon Sep 17 00:00:00 2001 From: Tycho Hob Date: Thu, 16 Apr 2026 08:48:23 -0400 Subject: [PATCH 02/12] style: Type hints for exceptions.py --- mypy.ini | 2 +- openedx_events/exceptions.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mypy.ini b/mypy.ini index 2462ce4e..3c13da16 100644 --- a/mypy.ini +++ b/mypy.ini @@ -43,7 +43,7 @@ ignore_missing_imports = True ; ----------------------------------------------------------------------- [mypy-openedx_events.exceptions] -disallow_untyped_defs = False +disallow_untyped_defs = True [mypy-openedx_events.utils] disallow_untyped_defs = False diff --git a/openedx_events/exceptions.py b/openedx_events/exceptions.py index 339e540c..6c4b1836 100644 --- a/openedx_events/exceptions.py +++ b/openedx_events/exceptions.py @@ -8,7 +8,7 @@ class OpenEdxEventException(Exception): Base class for Open edX Events exceptions. """ - def __init__(self, message=""): + def __init__(self, message: str = "") -> None: """ Init method for OpenEdxEventException base class. @@ -18,7 +18,7 @@ def __init__(self, message=""): super().__init__() self.message = message - def __str__(self): + def __str__(self) -> str: """ Show string representation of OpenEdxEventException using its message. @@ -37,7 +37,7 @@ class InstantiationError(OpenEdxEventException): missing. """ - def __init__(self, event_type="", message=""): + def __init__(self, event_type: str = "", message: str = "") -> None: """ Init method for InstantiationError custom exception class. @@ -57,7 +57,7 @@ class SenderValidationError(OpenEdxEventException): Describes errors that occur while validating arguments of send methods. """ - def __init__(self, event_type="", message=""): + def __init__(self, event_type: str = "", message: str = "") -> None: """ Init method for SenderValidationError custom exception class. @@ -77,7 +77,7 @@ class ProducerConfigurationError(OpenEdxEventException): Describes errors that occurs while validating format of producer signal configuration. """ - def __init__(self, event_type="", message=""): + def __init__(self, event_type: str = "", message: str = "") -> None: """ Init method for ProducerConfigurationError custom exception class. From b56a82189bbba54e9c74b17522d36442c27d33ac Mon Sep 17 00:00:00 2001 From: Tycho Hob Date: Thu, 16 Apr 2026 09:12:03 -0400 Subject: [PATCH 03/12] style: Annotate utils.py Some additional changes were needed here to work around problems inherited from the standard library. - Changing isinstance(obj, colletions.abc.Callable) to callable(obj) - Changing obj back to object, which is what upstream calls it, in spite of that being a redefine --- mypy.ini | 2 +- openedx_events/utils.py | 47 ++++++++++++++++++++++++++++++----------- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/mypy.ini b/mypy.ini index 3c13da16..0cc65071 100644 --- a/mypy.ini +++ b/mypy.ini @@ -46,7 +46,7 @@ ignore_missing_imports = True disallow_untyped_defs = True [mypy-openedx_events.utils] -disallow_untyped_defs = False +disallow_untyped_defs = True [mypy-openedx_events.data] disallow_untyped_defs = False diff --git a/openedx_events/utils.py b/openedx_events/utils.py index e23fdf3c..b588a8f6 100644 --- a/openedx_events/utils.py +++ b/openedx_events/utils.py @@ -1,9 +1,10 @@ """ Utilities for Open edX events usage. """ -import collections + import traceback from pprint import PrettyPrinter +from typing import Any class ResponsePrettyPrinter(PrettyPrinter): @@ -13,8 +14,18 @@ class ResponsePrettyPrinter(PrettyPrinter): This class pretty-prints the response of common Django Signals. """ - # pylint: disable-next=arguments-renamed, too-many-positional-arguments - def _format(self, obj, stream, indent, allowance, context, level): + # This overrides a standard library function, these problems are inherited + # from it. + # pylint: disable=too-many-positional-arguments, redefined-builtin + def _format( + self, + object: Any, + stream: Any, + indent: int, + allowance: int, + context: dict[int, int], + level: int, + ) -> None: """ Override format method exposing more information about functions/exceptions. @@ -23,21 +34,33 @@ def _format(self, obj, stream, indent, allowance, context, level): exception. With other objects has the same behavior. """ - if isinstance(obj, Exception): - exc_type, exc_value, exc_traceback = type(obj), obj, obj.__traceback__ + if isinstance(object, Exception): + exc_type, exc_value, exc_traceback = ( + type(object), + object, + object.__traceback__, + ) exc_traceback_formatted = traceback.format_exception( exc_type, exc_value, exc_traceback ) - obj = "".join(exc_traceback_formatted) - if isinstance(obj, collections.abc.Callable): - obj = "{func_module}.{func_name}".format( - func_module=obj.__module__, - func_name=obj.__name__, + object = "".join(exc_traceback_formatted) + if callable(object): + object = "{func_module}.{func_name}".format( + func_module=object.__module__, + func_name=object.__name__, ) - return super()._format(obj, stream, indent, allowance, context, level) + return super()._format(object, stream, indent, allowance, context, level) -def format_responses(obj, indent=1, width=80, depth=None, *, compact=False, sort_dicts=True): +def format_responses( + obj: Any, + indent: int = 1, + width: int = 80, + depth: int | None = None, + *, + compact: bool = False, + sort_dicts: bool = True, +) -> str: """ Format a Django Signal response object into a pretty-printed representation. From af61ce6c59a51d0991a416f0ca3aef71625383a6 Mon Sep 17 00:00:00 2001 From: Tycho Hob Date: Thu, 16 Apr 2026 09:47:17 -0400 Subject: [PATCH 04/12] style: Annotate data.py This includes a major refactor from `attr.s` to `attrs` and moves away from a somewhat improper use of default_if_none to explicit functions that can be type checked (vs just taking a generic lambda). --- mypy.ini | 2 +- openedx_events/data.py | 146 +++++++++++++++++++++++++++++------------ 2 files changed, 104 insertions(+), 44 deletions(-) diff --git a/mypy.ini b/mypy.ini index 0cc65071..6229bac6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -49,7 +49,7 @@ disallow_untyped_defs = True disallow_untyped_defs = True [mypy-openedx_events.data] -disallow_untyped_defs = False +disallow_untyped_defs = True [mypy-openedx_events.analytics.data] disallow_untyped_defs = False diff --git a/openedx_events/data.py b/openedx_events/data.py index 6aecdce1..72e6ff71 100644 --- a/openedx_events/data.py +++ b/openedx_events/data.py @@ -4,19 +4,68 @@ These attributes follow the form of attr objects specified in OEP-49 data pattern. """ + import json import socket from datetime import datetime, timezone +from typing import Any, Self from uuid import UUID, uuid1 -import attr import attrs from django.conf import settings import openedx_events -def _ensure_utc_time(_, attribute, value): +def _default_id(value: UUID | None) -> UUID: + """ + Return value if set, otherwise generate a new UUID1. + """ + return value if value is not None else uuid1() + + +def _default_minorversion(value: int | None) -> int: + """ + Return value if set, otherwise default to 0. + """ + return value if value is not None else 0 + + +def _default_source(value: str | None) -> str: + """ + Return value if set, otherwise derive source from service name. + """ + return value if value is not None else _get_source() + + +def _default_sourcehost(value: str | None) -> str: + """ + Return value if set, otherwise use the current hostname. + """ + return value if value is not None else socket.gethostname() + + +def _default_time(value: datetime | None) -> datetime: + """ + Return value if set, otherwise use the current UTC time. + """ + return value if value is not None else datetime.now(timezone.utc) + + +def _default_sourcelib(value: tuple[int, ...] | None) -> tuple[int, ...]: + """ + Return value if set, otherwise derive from the openedx_events version string. + """ + return ( + value + if value is not None + else tuple(map(int, openedx_events.__version__.split("."))) + ) + + +def _ensure_utc_time( + _: Any, attribute: "attrs.Attribute[Any]", value: datetime +) -> None: """ Ensure the value is a UTC datetime. @@ -27,7 +76,7 @@ def _ensure_utc_time(_, attribute, value): raise ValueError(f"'{attribute.name}' must have timezone.utc") -def get_service_name(): +def get_service_name() -> str | None: """ Get the service name of the producing/consuming service of an event (or None if not set). @@ -37,20 +86,24 @@ def get_service_name(): # .. setting_default: None # .. setting_description: Identifier for the producing/consuming service of an event. For example, "cms" or # "course-discovery." Used, among other places, to determine the source header of the event. - return getattr(settings, "EVENTS_SERVICE_NAME", None) or getattr(settings, "SERVICE_VARIANT", None) + return getattr(settings, "EVENTS_SERVICE_NAME", None) or getattr( + settings, "SERVICE_VARIANT", None + ) -def _get_source(): +def _get_source() -> str: """ Get the source for an event using the service name. If the service name is set, the full source will be set to openedx//web or openedx/SERVICE_NAME_UNSET/web if service name is None. """ - return "openedx/{service}/web".format(service=(get_service_name() or "SERVICE_NAME_UNSET")) + return "openedx/{service}/web".format( + service=(get_service_name() or "SERVICE_NAME_UNSET") + ) -@attr.s(frozen=True) +@attrs.define(frozen=True) class EventsMetadata: """ Attributes defined for Open edX Events metadata object. @@ -71,62 +124,63 @@ class EventsMetadata: strings (e.g. '0.9.0' vs. '0.10.0'). """ - event_type = attr.ib(type=str, validator=attr.validators.instance_of(str)) - id = attr.ib( - type=UUID, default=None, - converter=attr.converters.default_if_none(attr.Factory(lambda: uuid1())), # pylint: disable=unnecessary-lambda - validator=attr.validators.instance_of(UUID), + event_type: str = attrs.field(validator=attrs.validators.instance_of(str)) + id: UUID = attrs.field( + default=None, + converter=_default_id, + validator=attrs.validators.instance_of(UUID), ) - minorversion = attr.ib( - type=int, default=None, - converter=attr.converters.default_if_none(0), validator=attr.validators.instance_of(int) + minorversion: int = attrs.field( + default=None, + converter=_default_minorversion, + validator=attrs.validators.instance_of(int), ) - source = attr.ib( - type=str, default=None, - converter=attr.converters.default_if_none(attr.Factory(_get_source)), - validator=attr.validators.instance_of(str), + source: str = attrs.field( + default=None, + converter=_default_source, + validator=attrs.validators.instance_of(str), ) - sourcehost = attr.ib( - type=str, default=None, - converter=attr.converters.default_if_none( - attr.Factory(lambda: socket.gethostname()) # pylint: disable=unnecessary-lambda - ), - validator=attr.validators.instance_of(str), + sourcehost: str = attrs.field( + default=None, + converter=_default_sourcehost, + validator=attrs.validators.instance_of(str), ) - time = attr.ib( - type=datetime, default=None, - converter=attr.converters.default_if_none(attr.Factory(lambda: datetime.now(timezone.utc))), - validator=[attr.validators.instance_of(datetime), _ensure_utc_time], + time: datetime = attrs.field( + default=None, + converter=_default_time, + validator=[attrs.validators.instance_of(datetime), _ensure_utc_time], ) - sourcelib = attr.ib( - type=tuple, default=None, - converter=attr.converters.default_if_none( - attr.Factory(lambda: tuple(map(int, openedx_events.__version__.split(".")))) - ), - validator=attr.validators.instance_of(tuple), + sourcelib: tuple[int, ...] = attrs.field( + default=None, + converter=_default_sourcelib, + validator=attrs.validators.instance_of(tuple), ) - def to_json_data(self): + def to_json_data(self) -> dict[str, Any]: """ Create a json-compatible dictionary of the instance. """ - def value_serializer(inst, field, value): # pylint: disable="unused-argument" + + def value_serializer( # pylint: disable=unused-argument + inst: type, field: "attrs.Attribute[Any]", value: Any + ) -> Any: if isinstance(value, UUID): return str(value) elif isinstance(value, datetime): return value.isoformat() else: return value + return attrs.asdict(self, value_serializer=value_serializer) - def to_json(self): + def to_json(self) -> str: """ Serialize instance to json string. """ return json.dumps(self.to_json_data()) @classmethod - def from_json(cls, json_string): + def from_json(cls, json_string: str) -> Self: """ Create an instance from a json string. @@ -136,7 +190,13 @@ def from_json(cls, json_string): An EventsMetadata object """ as_json = json.loads(json_string) - time = datetime.fromisoformat(as_json['time']) - sourcelib = tuple(as_json['sourcelib']) - return cls(event_type=as_json['event_type'], id=UUID(as_json['id']), source=as_json['source'], - sourcehost=as_json['sourcehost'], time=time, sourcelib=sourcelib) + time = datetime.fromisoformat(as_json["time"]) + sourcelib = tuple(as_json["sourcelib"]) + return cls( + event_type=as_json["event_type"], + id=UUID(as_json["id"]), + source=as_json["source"], + sourcehost=as_json["sourcehost"], + time=time, + sourcelib=sourcelib, + ) From 2bb757ea6b4a76fea092998ab8186c5e6a5a0114 Mon Sep 17 00:00:00 2001 From: Tycho Hob Date: Thu, 16 Apr 2026 10:10:38 -0400 Subject: [PATCH 05/12] style: Add annotations for analytics and enterprise Note: There is an interim state in enterprise for optional types, that needs a fix in Avro handling when we get there. These are identified by: --- mypy.ini | 4 +- openedx_events/analytics/data.py | 12 ++-- openedx_events/enterprise/data.py | 113 +++++++++++++++--------------- 3 files changed, 65 insertions(+), 64 deletions(-) diff --git a/mypy.ini b/mypy.ini index 6229bac6..e8fb891c 100644 --- a/mypy.ini +++ b/mypy.ini @@ -52,10 +52,10 @@ disallow_untyped_defs = True disallow_untyped_defs = True [mypy-openedx_events.analytics.data] -disallow_untyped_defs = False +disallow_untyped_defs = True [mypy-openedx_events.enterprise.data] -disallow_untyped_defs = False +disallow_untyped_defs = True [mypy-openedx_events.content_authoring.data] disallow_untyped_defs = False diff --git a/openedx_events/analytics/data.py b/openedx_events/analytics/data.py index 4533c1eb..2294ce02 100644 --- a/openedx_events/analytics/data.py +++ b/openedx_events/analytics/data.py @@ -7,10 +7,10 @@ from datetime import datetime -import attr +import attrs -@attr.s(frozen=True) +@attrs.define(frozen=True) class TrackingLogData: """ Data related to tracking events. @@ -24,7 +24,7 @@ class TrackingLogData: defined in https://docs.openedx.org/en/latest/developers/references/internal_data_formats/index.html """ - name = attr.ib(type=str) - timestamp = attr.ib(type=datetime) - data = attr.ib(type=str) - context = attr.ib(type=str) + name: str + timestamp: datetime + data: str + context: str diff --git a/openedx_events/enterprise/data.py b/openedx_events/enterprise/data.py index 4eea894c..823c0ef6 100644 --- a/openedx_events/enterprise/data.py +++ b/openedx_events/enterprise/data.py @@ -4,14 +4,15 @@ These attributes follow the form of attr objects specified in OEP-49 data pattern. """ + from datetime import datetime from uuid import UUID -import attr +import attrs from opaque_keys.edx.keys import CourseKey -@attr.s(frozen=True) +@attrs.define(frozen=True) class SubsidyRedemption: """ Data related to a Subsidy Redemption object. @@ -22,12 +23,12 @@ class SubsidyRedemption: lms_user_id (str): lms user id of subsidy beneficiary """ - subsidy_identifier = attr.ib(type=str) - content_key = attr.ib(type=str) - lms_user_id = attr.ib(type=int) + subsidy_identifier: str + content_key: str + lms_user_id: int -@attr.s(frozen=True) +@attrs.define(frozen=True) class BaseLedgerTransaction: """ Data related to a Ledger Transaction object. @@ -41,15 +42,15 @@ class BaseLedgerTransaction: state (str): Current lifecyle state of the record, one of (created, pending, committed, failed). """ - uuid = attr.ib(type=UUID) - created = attr.ib(type=datetime) - modified = attr.ib(type=datetime) - idempotency_key = attr.ib(type=str) - quantity = attr.ib(type=int) - state = attr.ib(type=str) + uuid: UUID + created: datetime + modified: datetime + idempotency_key: str + quantity: int + state: str -@attr.s(frozen=True) +@attrs.define(frozen=True) class LedgerTransactionReversal(BaseLedgerTransaction): """ Attributes of an ``openedx_ledger.Reversal`` record. @@ -69,7 +70,7 @@ class LedgerTransactionReversal(BaseLedgerTransaction): """ -@attr.s(frozen=True) +@attrs.define(frozen=True) class LedgerTransaction(BaseLedgerTransaction): """ Attributes of an ``openedx_ledger.Transaction`` record. @@ -95,16 +96,16 @@ class LedgerTransaction(BaseLedgerTransaction): reversal (LedgerTransactionReversal): Any reversal associated with this transaction. """ - ledger_uuid = attr.ib(type=UUID) - subsidy_access_policy_uuid = attr.ib(type=UUID) - lms_user_id = attr.ib(type=int) - content_key = attr.ib(type=CourseKey) - parent_content_key = attr.ib(type=str, default=None) - fulfillment_identifier = attr.ib(type=str, default=None) - reversal = attr.ib(type=LedgerTransactionReversal, default=None) + ledger_uuid: UUID + subsidy_access_policy_uuid: UUID + lms_user_id: int + content_key: CourseKey + parent_content_key: str = None # type: ignore[assignment] + fulfillment_identifier: str = None # type: ignore[assignment] + reversal: LedgerTransactionReversal = None # type: ignore[assignment] -@attr.s(frozen=True) +@attrs.define(frozen=True) class EnterpriseCustomerUser: """ Data related to an Enterprise Customer User object. @@ -125,19 +126,19 @@ class EnterpriseCustomerUser: customers for this user will be marked as inactive upon save. """ - id = attr.ib(type=int) - created = attr.ib(type=datetime) - modified = attr.ib(type=datetime) - enterprise_customer_uuid = attr.ib(type=UUID) - user_id = attr.ib(type=int) - active = attr.ib(type=bool) - linked = attr.ib(type=bool) - is_relinkable = attr.ib(type=bool) - should_inactivate_other_customers = attr.ib(type=bool) - invite_key = attr.ib(type=UUID, default=None) + id: int + created: datetime + modified: datetime + enterprise_customer_uuid: UUID + user_id: int + active: bool + linked: bool + is_relinkable: bool + should_inactivate_other_customers: bool + invite_key: UUID = None # type: ignore[assignment] -@attr.s(frozen=True) +@attrs.define(frozen=True) class EnterpriseCourseEnrollment: """ Data related to an Enterprise Course Enrollment object. @@ -156,18 +157,18 @@ class EnterpriseCourseEnrollment: unenrolled_at (datetime): Specifies when the related LMS course enrollment object was unenrolled. """ - id = attr.ib(type=int) - created = attr.ib(type=datetime) - modified = attr.ib(type=datetime) - enterprise_customer_user = attr.ib(type=EnterpriseCustomerUser) - course_id = attr.ib(type=CourseKey) - saved_for_later = attr.ib(type=bool) - source_slug = attr.ib(type=str, default=None) - unenrolled = attr.ib(type=bool, default=None) - unenrolled_at = attr.ib(type=datetime, default=None) + id: int + created: datetime + modified: datetime + enterprise_customer_user: EnterpriseCustomerUser + course_id: CourseKey + saved_for_later: bool + source_slug: str = None # type: ignore[assignment] + unenrolled: bool = None # type: ignore[assignment] + unenrolled_at: datetime = None # type: ignore[assignment] -@attr.s(frozen=True) +@attrs.define(frozen=True) class BaseEnterpriseFulfillment: """ Defines the common attributes of enterprise fulfillment classes, i.e. ``enterprise.EnterpriseFulfillmentSource``. @@ -185,16 +186,16 @@ class BaseEnterpriseFulfillment: is_revoked (bool): Whether the enterprise subsidy is revoked, e.g., when a user's license is revoked. """ - uuid = attr.ib(type=UUID) - created = attr.ib(type=datetime) - modified = attr.ib(type=datetime) - fulfillment_type = attr.ib(type=str) - is_revoked = attr.ib(type=bool) - enterprise_course_entitlement_uuid = attr.ib(type=UUID, default=None) - enterprise_course_enrollment = attr.ib(type=EnterpriseCourseEnrollment, default=None) + uuid: UUID + created: datetime + modified: datetime + fulfillment_type: str + is_revoked: bool + enterprise_course_entitlement_uuid: UUID = None # type: ignore[assignment] + enterprise_course_enrollment: EnterpriseCourseEnrollment = None # type: ignore[assignment] -@attr.s(frozen=True) +@attrs.define(frozen=True) class LearnerCreditEnterpriseCourseEnrollment(BaseEnterpriseFulfillment): """ Attributes of an ``enterprise.LearnerCreditEnterpriseCourseEnrollment`` record. @@ -207,10 +208,10 @@ class LearnerCreditEnterpriseCourseEnrollment(BaseEnterpriseFulfillment): transaction_id (UUID): Ledgered transaction UUID to associate with this learner credit fulfillment. """ - transaction_id = attr.ib(type=UUID, default=None) + transaction_id: UUID = None # type: ignore[assignment] -@attr.s(frozen=True) +@attrs.define(frozen=True) class LicensedEnterpriseCourseEnrollment(BaseEnterpriseFulfillment): """ Attributes of an ``enterprise.LicensedEnterpriseCourseEnrollment`` record. @@ -223,10 +224,10 @@ class LicensedEnterpriseCourseEnrollment(BaseEnterpriseFulfillment): license_uuid (UUID): License UUID to associate with this enterprise license fulfillment. """ - license_uuid = attr.ib(type=UUID, default=None) + license_uuid: UUID = None # type: ignore[assignment] -@attr.s(frozen=True) +@attrs.define(frozen=True) class EnterpriseGroup: """ Attributes of an ``enterprise.EnterpriseGroup`` record. @@ -238,4 +239,4 @@ class EnterpriseGroup: uuid (UUID): Primary identifier of the record. """ - uuid = attr.ib(type=UUID) + uuid: UUID From e2a771d6db591039325104d9c9ad21d95ae79ea3 Mon Sep 17 00:00:00 2001 From: Tycho Hob Date: Thu, 16 Apr 2026 10:17:55 -0400 Subject: [PATCH 06/12] style: Add annotations for content_authoring --- mypy.ini | 2 +- openedx_events/content_authoring/data.py | 102 +++++++++++------------ 2 files changed, 52 insertions(+), 52 deletions(-) diff --git a/mypy.ini b/mypy.ini index e8fb891c..0116748d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -58,7 +58,7 @@ disallow_untyped_defs = True disallow_untyped_defs = True [mypy-openedx_events.content_authoring.data] -disallow_untyped_defs = False +disallow_untyped_defs = True [mypy-openedx_events.learning.data] disallow_untyped_defs = False diff --git a/openedx_events/content_authoring/data.py b/openedx_events/content_authoring/data.py index f0c1ecbb..83ec7fe8 100644 --- a/openedx_events/content_authoring/data.py +++ b/openedx_events/content_authoring/data.py @@ -7,10 +7,11 @@ The attributes for the events come from the CourseDetailView in the LMS, with some unused fields removed (see deprecation proposal at https://github.com/openedx/public-engineering/issues/160) """ + from datetime import datetime -from typing import BinaryIO, List +from typing import BinaryIO -import attr +import attrs from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locator import ( LibraryCollectionLocator, @@ -20,7 +21,7 @@ ) -@attr.s(frozen=True) +@attrs.define(frozen=True) class CourseData: """ Data related to a course object. @@ -29,10 +30,10 @@ class CourseData: course_key (CourseKey): identifier of the Course object. """ - course_key = attr.ib(type=CourseKey) + course_key: CourseKey -@attr.s(frozen=True) +@attrs.define(frozen=True) class CourseScheduleData: """ Data related to a course schedule. @@ -45,14 +46,14 @@ class CourseScheduleData: enrollment_end (datetime): end of course enrollment (optional). """ - start = attr.ib(type=datetime) - pacing = attr.ib(type=str) - end = attr.ib(type=datetime, default=None) - enrollment_start = attr.ib(type=datetime, default=None) - enrollment_end = attr.ib(type=datetime, default=None) + start: datetime + pacing: str + end: datetime = None # type: ignore[assignment] + enrollment_start: datetime = None # type: ignore[assignment] + enrollment_end: datetime = None # type: ignore[assignment] -@attr.s(frozen=True) +@attrs.define(frozen=True) class CourseCatalogData: """ Data related to a course catalog entry. @@ -66,16 +67,16 @@ class CourseCatalogData: """ # basic identifiers - course_key = attr.ib(type=CourseKey) - name = attr.ib(type=str) + course_key: CourseKey + name: str # additional marketing information - schedule_data = attr.ib(type=CourseScheduleData) - hidden = attr.ib(type=bool, default=False) - invitation_only = attr.ib(type=bool, default=False) + schedule_data: CourseScheduleData + hidden: bool = False + invitation_only: bool = False -@attr.s(frozen=True) +@attrs.define(frozen=True) class XBlockData: """ Data related to an XBlock object. @@ -87,12 +88,12 @@ class XBlockData: could be used to get the exact version of the XBlock object. """ - usage_key = attr.ib(type=UsageKey) - block_type = attr.ib(type=str) - version = attr.ib(type=UsageKey, default=None, kw_only=True) + usage_key: UsageKey + block_type: str + version: UsageKey = attrs.field(default=None, kw_only=True) -@attr.s(frozen=True) +@attrs.define(frozen=True) class DuplicatedXBlockData(XBlockData): """ Data related to an XBlock object that has been duplicated. @@ -103,10 +104,10 @@ class DuplicatedXBlockData(XBlockData): source_usage_key (UsageKey): identifier of the source XBlock object. """ - source_usage_key = attr.ib(type=UsageKey) + source_usage_key: UsageKey = attrs.field(kw_only=True) -@attr.s(frozen=True) +@attrs.define(frozen=True) class CertificateSignatoryData: """ Data related to a certificate signatory. Subset of CertificateSignatory object from the LMS. @@ -123,14 +124,13 @@ class CertificateSignatoryData: # It can potentially be large, making it difficult to pass this data structure through the Event Bus # (CloudEvent messages, which should be 64K or less) and store it on disk space. # We suggest referring to MAX_ASSET_UPLOAD_FILE_SIZE_IN_MB, i.e. restriction in the Studio for such cases. - image = attr.ib(type=BinaryIO) - # end Note - name = attr.ib(type=str) - organization = attr.ib(type=str) - title = attr.ib(type=str) + image: BinaryIO + name: str + organization: str + title: str -@attr.s(frozen=True) +@attrs.define(frozen=True) class CertificateConfigData: """ Data related to a certificate configuration. Subset of CertificateConfig object from the LMS. @@ -147,19 +147,19 @@ class CertificateConfigData: - masters. course_key (CourseKey): identifier of the Course object. title (str): certificate title. - signatories (List[CertificateSignatoryData]): contains a collection of signatures + signatories (list[CertificateSignatoryData]): contains a collection of signatures that belong to the certificate configuration. is_active (bool): indicates whether the certifivate configuration is active. """ - certificate_type = attr.ib(type=str) - course_key = attr.ib(type=CourseKey) - title = attr.ib(type=str) - signatories = attr.ib(type=List[CertificateSignatoryData], factory=list) - is_active = attr.ib(type=bool, default=False) + certificate_type: str + course_key: CourseKey + title: str + signatories: list[CertificateSignatoryData] = attrs.field(factory=list) + is_active: bool = False -@attr.s(frozen=True) +@attrs.define(frozen=True) class ContentLibraryData: """ Data related to a content library that has changed. @@ -170,11 +170,11 @@ class ContentLibraryData: Now we send individual events for each updated item instead. """ - library_key = attr.ib(type=LibraryLocatorV2) - update_blocks = attr.ib(type=bool, default=False) + library_key: LibraryLocatorV2 + update_blocks: bool = False -@attr.s(frozen=True) +@attrs.define(frozen=True) class LibraryBlockData: """ Data related to a library block that has changed. @@ -184,11 +184,11 @@ class LibraryBlockData: usage_key (LibraryUsageLocatorV2): a key that represents a XBlock in a Blockstore-based content library. """ - library_key = attr.ib(type=LibraryLocatorV2) - usage_key = attr.ib(type=LibraryUsageLocatorV2) + library_key: LibraryLocatorV2 + usage_key: LibraryUsageLocatorV2 -@attr.s(frozen=True) +@attrs.define(frozen=True) class ContentObjectData: """ Data related to a content object. @@ -199,10 +199,10 @@ class ContentObjectData: >>> block-v1:SampleTaxonomyOrg2+STC1+2023_1+type@vertical+block@f8de78f0897049ce997777a3a31b6ea0 """ - object_id = attr.ib(type=str) + object_id: str -@attr.s(frozen=True) +@attrs.define(frozen=True) class ContentObjectChangedData(ContentObjectData): """ Data related to a content object that has changed. @@ -215,10 +215,10 @@ class ContentObjectChangedData(ContentObjectData): assume everything has changed. """ - changes = attr.ib(type=List[str], factory=list) + changes: list[str] = attrs.field(factory=list) -@attr.s(frozen=True) +@attrs.define(frozen=True) class LibraryCollectionData: """ Data related to a library collection that has changed. @@ -230,11 +230,11 @@ class LibraryCollectionData: send the event(s) from an async celery task if it is expected to result in a lot of handlers being called. """ - collection_key = attr.ib(type=LibraryCollectionLocator) - background = attr.ib(type=bool, default=False) + collection_key: LibraryCollectionLocator + background: bool = False -@attr.s(frozen=True) +@attrs.define(frozen=True) class LibraryContainerData: """ Data related to a library container that has changed. @@ -246,5 +246,5 @@ class LibraryContainerData: send the event(s) from an async celery task if it is expected to result in a lot of handlers being called. """ - container_key = attr.ib(type=LibraryContainerLocator) - background = attr.ib(type=bool, default=False) + container_key: LibraryContainerLocator + background: bool = False From 38e2b4aa7c14410851d7bdcaf6531f6bba9bc9f0 Mon Sep 17 00:00:00 2001 From: Tycho Hob Date: Thu, 16 Apr 2026 10:31:56 -0400 Subject: [PATCH 07/12] style: Add annotations for learning Note: This also includes several fields that will need to be updated when the Avro later is done, these are identified by: # type: ignore[assignment] --- mypy.ini | 2 +- openedx_events/learning/data.py | 404 ++++++++++++++++---------------- 2 files changed, 204 insertions(+), 202 deletions(-) diff --git a/mypy.ini b/mypy.ini index 0116748d..04e07bf2 100644 --- a/mypy.ini +++ b/mypy.ini @@ -61,7 +61,7 @@ disallow_untyped_defs = True disallow_untyped_defs = True [mypy-openedx_events.learning.data] -disallow_untyped_defs = False +disallow_untyped_defs = True [mypy-openedx_events.event_bus.avro.types] disallow_untyped_defs = False diff --git a/openedx_events/learning/data.py b/openedx_events/learning/data.py index a0be3a9e..d0e4712f 100644 --- a/openedx_events/learning/data.py +++ b/openedx_events/learning/data.py @@ -4,15 +4,15 @@ These attributes follow the form of attr objects specified in OEP-49 data pattern. """ + from datetime import datetime -from typing import List -import attr +import attrs from ccx_keys.locator import CCXLocator from opaque_keys.edx.keys import CourseKey, UsageKey -@attr.s(frozen=True) +@attrs.define(frozen=True) class UserNonPersonalData: """ Data related to a user object that does not contain personal information (PII). @@ -22,11 +22,11 @@ class UserNonPersonalData: is_active (bool): indicates whether the user is active. """ - id = attr.ib(type=int) - is_active = attr.ib(type=bool) + id: int + is_active: bool -@attr.s(frozen=True) +@attrs.define(frozen=True) class UserPersonalData: """ Data related to a user object that contains personal information (PII). @@ -37,12 +37,12 @@ class UserPersonalData: name (str): name associated with the user's profile. """ - username = attr.ib(type=str) - email = attr.ib(type=str) - name = attr.ib(type=str, factory=str) + username: str + email: str + name: str = attrs.field(factory=str) -@attr.s(frozen=True) +@attrs.define(frozen=True) class UserData(UserNonPersonalData): """ Data related to a user object, including personal information and non-personal information. @@ -55,10 +55,10 @@ class UserData(UserNonPersonalData): pii (UserPersonalData): user's Personal Identifiable Information. """ - pii = attr.ib(type=UserPersonalData) + pii: UserPersonalData -@attr.s(frozen=True) +@attrs.define(frozen=True) class CourseData: """ Data related to a course object. @@ -73,13 +73,13 @@ class CourseData: end (datetime): end date for the course. Defaults to None. """ - course_key = attr.ib(type=CourseKey) - display_name = attr.ib(type=str, factory=str) - start = attr.ib(type=datetime, default=None) - end = attr.ib(type=datetime, default=None) + course_key: CourseKey + display_name: str = attrs.field(factory=str) + start: datetime = None # type: ignore[assignment] + end: datetime = None # type: ignore[assignment] -@attr.s(frozen=True) +@attrs.define(frozen=True) class CcxCourseData: """ Represents data for a CCX (Custom Courses for edX) course. @@ -95,16 +95,16 @@ class CcxCourseData: Defaults to None, indicating no limit. """ - ccx_course_key = attr.ib(type=CCXLocator) - master_course_key = attr.ib(type=CourseKey) - display_name = attr.ib(type=str, factory=str) - coach_email = attr.ib(type=str, factory=str) - start = attr.ib(type=str, default=None) - end = attr.ib(type=str, default=None) - max_students_allowed = attr.ib(type=int, default=None) + ccx_course_key: CCXLocator + master_course_key: CourseKey + display_name: str = attrs.field(factory=str) + coach_email: str = attrs.field(factory=str) + start: str = None # type: ignore[assignment] + end: str = None # type: ignore[assignment] + max_students_allowed: int = None # type: ignore[assignment] -@attr.s(frozen=True) +@attrs.define(frozen=True) class CourseEnrollmentData: """ Data related to a course enrollment object. @@ -121,15 +121,15 @@ class CourseEnrollmentData: created_by (UserData): if available, who created the enrollment. """ - user = attr.ib(type=UserData) - course = attr.ib(type=CourseData) - mode = attr.ib(type=str) - is_active = attr.ib(type=bool) - creation_date = attr.ib(type=datetime) - created_by = attr.ib(type=UserData, default=None) + user: UserData + course: CourseData + mode: str + is_active: bool + creation_date: datetime + created_by: UserData = None # type: ignore[assignment] -@attr.s(frozen=True) +@attrs.define(frozen=True) class CertificateData: """ Data related to a certificate object. @@ -148,17 +148,17 @@ class CertificateData: previous_status (str): if available, pre-event certificate status. """ - user = attr.ib(type=UserData) - course = attr.ib(type=CourseData) - mode = attr.ib(type=str) - grade = attr.ib(type=str) - download_url = attr.ib(type=str) - name = attr.ib(type=str) - current_status = attr.ib(type=str) - previous_status = attr.ib(type=str, factory=str) + user: UserData + course: CourseData + mode: str + grade: str + download_url: str + name: str + current_status: str + previous_status: str = attrs.field(factory=str) -@attr.s(frozen=True) +@attrs.define(frozen=True) class CohortData: """ Attributes defined for cohort membership object. @@ -172,12 +172,12 @@ class CohortData: name (str): name of the cohort group. """ - user = attr.ib(type=UserData) - course = attr.ib(type=CourseData) - name = attr.ib(type=str) + user: UserData + course: CourseData + name: str -@attr.s(frozen=True) +@attrs.define(frozen=True) class DiscussionTopicContext: """ Data related to a discussion topic context. @@ -196,15 +196,15 @@ class DiscussionTopicContext: which this topic is used, such as the section, subsection etc. """ - title = attr.ib(type=str) - usage_key = attr.ib(type=UsageKey, default=None) - group_id = attr.ib(type=int, default=None) - external_id = attr.ib(type=str, default=None) - ordering = attr.ib(type=int, default=None) - context = attr.ib(type=dict[str, str], factory=dict) + title: str + usage_key: UsageKey = None # type: ignore[assignment] + group_id: int = None # type: ignore[assignment] + external_id: str = None # type: ignore[assignment] + ordering: int = None # type: ignore[assignment] + context: dict[str, str] = attrs.field(factory=dict) -@attr.s(frozen=True) +@attrs.define(frozen=True) class CourseDiscussionConfigurationData: """ Data related to a course discussion configuration object. @@ -221,20 +221,20 @@ class CourseDiscussionConfigurationData: enable_graded_units (bool): If enabled, discussion topics will be created for graded units as well. unit_level_visibility (bool): visibility for unit level. plugin_configuration (dict): The plugin configuration data for this context/provider. - contexts (List[DiscussionTopicContext]): contains all the contexts for which discussion is to be enabled. + contexts (list[DiscussionTopicContext]): contains all the contexts for which discussion is to be enabled. """ - course_key = attr.ib(type=CourseKey) - provider_type = attr.ib(type=str) - enabled = attr.ib(type=bool, default=True) - enable_in_context = attr.ib(type=bool, default=True) - enable_graded_units = attr.ib(type=bool, default=False) - unit_level_visibility = attr.ib(type=bool, default=False) - plugin_configuration = attr.ib(type=dict[str, bool], default=dict) - contexts = attr.ib(type=List[DiscussionTopicContext], factory=list) + course_key: CourseKey + provider_type: str + enabled: bool = True + enable_in_context: bool = True + enable_graded_units: bool = False + unit_level_visibility: bool = False + plugin_configuration: dict[str, bool] = attrs.field(factory=dict) + contexts: list[DiscussionTopicContext] = attrs.field(factory=list) -@attr.s(frozen=True) +@attrs.define(frozen=True) class PersistentCourseGradeData: """ Data related to a persistent course grade object. @@ -253,17 +253,17 @@ class PersistentCourseGradeData: passed_timestamp (datetime): date the course was passed. """ - user_id = attr.ib(type=int) - course = attr.ib(type=CourseData) - course_edited_timestamp = attr.ib(type=datetime) - course_version = attr.ib(type=str) - grading_policy_hash = attr.ib(type=str) - percent_grade = attr.ib(type=float) - letter_grade = attr.ib(type=str) - passed_timestamp = attr.ib(type=datetime) + user_id: int + course: CourseData + course_edited_timestamp: datetime + course_version: str + grading_policy_hash: str + percent_grade: float + letter_grade: str + passed_timestamp: datetime -@attr.s(frozen=True) +@attrs.define(frozen=True) class XBlockSkillVerificationData: """ Data needed to update verification count of tags/skills for an XBlock. @@ -272,22 +272,22 @@ class XBlockSkillVerificationData: Attributes: usage_key (UsageKey): identifier of the XBlock object. - verified_skills (List[int]): list of verified skill ids. - ignored_skills (List[int]): list of ignored skill ids. + verified_skills (list[int]): list of verified skill ids. + ignored_skills (list[int]): list of ignored skill ids. """ - usage_key = attr.ib(type=UsageKey) - verified_skills = attr.ib(type=List[int], factory=list) - ignored_skills = attr.ib(type=List[int], factory=list) + usage_key: UsageKey + verified_skills: list[int] = attrs.field(factory=list) + ignored_skills: list[int] = attrs.field(factory=list) -@attr.s(frozen=True) +@attrs.define(frozen=True) class UserNotificationData: """ Data related to a user notification object. Attributes: - user_ids (List[int]): identifier of the users to which the notification belongs. + user_ids (list[int]): identifier of the users to which the notification belongs. notification_type (str): type of the notification. content_url (str): url of the content. app_name (str): name of the app. @@ -295,15 +295,15 @@ class UserNotificationData: context (dict[str, str]): additional structured information about the context of the notification. """ - user_ids = attr.ib(type=List[int]) - notification_type = attr.ib(type=str) - content_url = attr.ib(type=str) - app_name = attr.ib(type=str) - course_key = attr.ib(type=CourseKey) - context = attr.ib(type=dict[str, str], factory=dict) + user_ids: list[int] + notification_type: str + content_url: str + app_name: str + course_key: CourseKey + context: dict[str, str] = attrs.field(factory=dict) -@attr.s(frozen=True) +@attrs.define(frozen=True) class ProgramData: """ Data related to a program object. @@ -314,12 +314,12 @@ class ProgramData: program_type (str): The type slug of the program (e.g. professional, microbachelors, micromasters, etc.). """ - uuid = attr.ib(type=str) - title = attr.ib(type=str) - program_type = attr.ib(type=str) + uuid: str + title: str + program_type: str -@attr.s(frozen=True) +@attrs.define(frozen=True) class ProgramCertificateData: """ Data related to a Program Certificate object. @@ -334,15 +334,15 @@ class ProgramCertificateData: url (str): A URL to the learner's credential. """ - user = attr.ib(type=UserData) - program = attr.ib(type=ProgramData) - uuid = attr.ib(type=str) - status = attr.ib(type=str) - url = attr.ib(type=str) - certificate_available_date = attr.ib(type=datetime, default=None) + user: UserData + program: ProgramData + uuid: str + status: str + url: str + certificate_available_date: datetime = None # type: ignore[assignment] -@attr.s(frozen=True) +@attrs.define(frozen=True) class ExamAttemptData: """ Data for the Open edX Exam downstream effects. @@ -368,14 +368,14 @@ class ExamAttemptData: requesting_user (UserData): user triggering the event (sometimes a non-learner, e.g. an instructor) """ - student_user = attr.ib(type=UserData) - course_key = attr.ib(type=CourseKey) - usage_key = attr.ib(type=UsageKey) - exam_type = attr.ib(type=str) - requesting_user = attr.ib(type=UserData, default=None) + student_user: UserData + course_key: CourseKey + usage_key: UsageKey + exam_type: str + requesting_user: UserData = None # type: ignore[assignment] -@attr.s(frozen=True) +@attrs.define(frozen=True) class CourseAccessRoleData: """ Data related to a user's access role in a course. @@ -387,13 +387,13 @@ class CourseAccessRoleData: role (str): the role of the user in the course. """ - user = attr.ib(type=UserData) - org_key = attr.ib(type=str) - course_key = attr.ib(type=CourseKey) - role = attr.ib(type=str) + user: UserData + org_key: str + course_key: CourseKey + role: str -@attr.s(frozen=True) +@attrs.define(frozen=True) class DiscussionThreadData: """ Data related to a discussion thread object, used to represent Forum events such as comments, responses, and threads. @@ -419,34 +419,34 @@ class DiscussionThreadData: user (UserData): information of the user that authored the thread/comment/response. course_id (CourseKey): identifier of the course. discussion (dict): discussion data. (optional, specific to comments and responses) - user_course_roles (List[str]): user course roles. - user_forums_roles (List[str]): user forums roles. + user_course_roles (list[str]): user course roles. + user_forums_roles (list[str]): user forums roles. options (dict): options for the thread. """ - body = attr.ib(type=str) - commentable_id = attr.ib(type=str) - id = attr.ib(type=str) - truncated = attr.ib(type=bool) - url = attr.ib(type=str) - user = attr.ib(type=UserData) - course_id = attr.ib(type=CourseKey) - thread_type = attr.ib(type=str, default=None) - anonymous = attr.ib(type=bool, default=None) - anonymous_to_peers = attr.ib(type=bool, default=None) - title = attr.ib(type=str, default=None) - title_truncated = attr.ib(type=bool, default=None) - group_id = attr.ib(type=int, default=None) - team_id = attr.ib(type=int, default=None) - category_id = attr.ib(type=int, default=None) - category_name = attr.ib(type=str, default=None) - discussion = attr.ib(type=dict[str, str], default=None) - user_course_roles = attr.ib(type=List[str], factory=list) - user_forums_roles = attr.ib(type=List[str], factory=list) - options = attr.ib(type=dict[str, bool], factory=dict) - - -@attr.s(frozen=True) + body: str + commentable_id: str + id: str + truncated: bool + url: str + user: UserData + course_id: CourseKey + thread_type: str = None # type: ignore[assignment] + anonymous: bool = None # type: ignore[assignment] + anonymous_to_peers: bool = None # type: ignore[assignment] + title: str = None # type: ignore[assignment] + title_truncated: bool = None # type: ignore[assignment] + group_id: int = None # type: ignore[assignment] + team_id: int = None # type: ignore[assignment] + category_id: int = None # type: ignore[assignment] + category_name: str = None # type: ignore[assignment] + discussion: dict[str, str] = None # type: ignore[assignment] + user_course_roles: list[str] = attrs.field(factory=list) + user_forums_roles: list[str] = attrs.field(factory=list) + options: dict[str, bool] = attrs.field(factory=dict) + + +@attrs.define(frozen=True) class CourseNotificationData: """ Data related to a course notification object. @@ -482,40 +482,40 @@ class CourseNotificationData: } """ - course_key = attr.ib(type=CourseKey) - app_name = attr.ib(type=str) - notification_type = attr.ib(type=str) - content_url = attr.ib(type=str) - content_context = attr.ib(type=dict[str, str], factory=dict) - audience_filters = attr.ib(type=dict[str, List[str]], factory=dict) + course_key: CourseKey + app_name: str + notification_type: str + content_url: str + content_context: dict[str, str] = attrs.field(factory=dict) + audience_filters: dict[str, list[str]] = attrs.field(factory=dict) -@attr.s(frozen=True) +@attrs.define(frozen=True) class ORASubmissionAnswer: """ Data related to the answer submitted by the user in an ORA submission. Attributes: - parts (List[dict]): List with the response text in the ORA submission. + parts (list[dict]): List with the response text in the ORA submission. The following attributes are used to represent the files submitted in the ORA submission: - file_keys (List[str]): List of file keys in the ORA submission. - file_descriptions (List[str]): List of file descriptions in the ORA submission. - file_names (List[str]): List of file names in the ORA submission. - file_sizes (List[int]): List of file sizes in the ORA submission. - file_urls (List[str]): List of file URLs in the ORA submission. + file_keys (list[str]): List of file keys in the ORA submission. + file_descriptions (list[str]): List of file descriptions in the ORA submission. + file_names (list[str]): List of file names in the ORA submission. + file_sizes (list[int]): List of file sizes in the ORA submission. + file_urls (list[str]): List of file URLs in the ORA submission. """ - parts = attr.ib(type=List[dict[str, str]], factory=list) - file_keys = attr.ib(type=List[str], factory=list) - file_descriptions = attr.ib(type=List[str], factory=list) - file_names = attr.ib(type=List[str], factory=list) - file_sizes = attr.ib(type=List[int], factory=list) - file_urls = attr.ib(type=List[str], factory=list) + parts: list[dict[str, str]] = attrs.field(factory=list) + file_keys: list[str] = attrs.field(factory=list) + file_descriptions: list[str] = attrs.field(factory=list) + file_names: list[str] = attrs.field(factory=list) + file_sizes: list[int] = attrs.field(factory=list) + file_urls: list[str] = attrs.field(factory=list) -@attr.s(frozen=True) +@attrs.define(frozen=True) class ORASubmissionData: """ Data associated to the ORA assessment submitted by a user. @@ -530,16 +530,16 @@ class ORASubmissionData: answer (ORASubmissionAnswer): Answer submitted by the user in the ORA submission. """ - uuid = attr.ib(type=str) - anonymous_user_id = attr.ib(type=str) - location = attr.ib(type=str) - attempt_number = attr.ib(type=int) - created_at = attr.ib(type=datetime) - submitted_at = attr.ib(type=datetime) - answer = attr.ib(type=ORASubmissionAnswer) + uuid: str + anonymous_user_id: str + location: str + attempt_number: int + created_at: datetime + submitted_at: datetime + answer: ORASubmissionAnswer -@attr.s(frozen=True) +@attrs.define(frozen=True) class CoursePassingStatusData: """ Represents the event data when a user's grade is updated, indicates if current grade is enough for course passing. @@ -551,12 +551,12 @@ class CoursePassingStatusData: in which the grade was updated. """ - is_passing = attr.ib(type=bool) - course = attr.ib(type=CourseData) - user = attr.ib(type=UserData) + is_passing: bool + course: CourseData + user: UserData -@attr.s(frozen=True) +@attrs.define(frozen=True) class CcxCoursePassingStatusData(CoursePassingStatusData): """ Extends CoursePassingStatusData for CCX courses, specifying CCX course data. @@ -571,10 +571,10 @@ class CcxCoursePassingStatusData(CoursePassingStatusData): All other attributes are inherited from CoursePassingStatusData. """ - course = attr.ib(type=CcxCourseData) + course: CcxCourseData = attrs.field(kw_only=True) # type: ignore[assignment] -@attr.s(frozen=True) +@attrs.define(frozen=True) class BadgeTemplateData: """ Data related to a badge template object. @@ -587,14 +587,14 @@ class BadgeTemplateData: image_url (str): badge image url. """ - uuid = attr.ib(type=str) - origin = attr.ib(type=str) - name = attr.ib(type=str, default=None) - description = attr.ib(type=str, default=None) - image_url = attr.ib(type=str, default=None) + uuid: str + origin: str + name: str = None # type: ignore[assignment] + description: str = None # type: ignore[assignment] + image_url: str = None # type: ignore[assignment] -@attr.s(frozen=True) +@attrs.define(frozen=True) class BadgeData: """ Data related to a badge object. @@ -605,12 +605,12 @@ class BadgeData: template (BadgeTemplateData): badge template data. """ - uuid = attr.ib(type=str) - user = attr.ib(type=UserData) - template = attr.ib(type=BadgeTemplateData) + uuid: str + user: UserData + template: BadgeTemplateData -@attr.s(frozen=True) +@attrs.define(frozen=True) class VerificationAttemptData: """ Data related to a IDV attempt object. @@ -623,14 +623,14 @@ class VerificationAttemptData: expiration_datetime (datetime, optional): When the verification attempt expires. Defaults to None. """ - attempt_id = attr.ib(type=int) - user = attr.ib(type=UserData) - status = attr.ib(type=str) - name = attr.ib(type=str, default=None) - expiration_date = attr.ib(type=datetime, default=None) + attempt_id: int + user: UserData + status: str + name: str = None # type: ignore[assignment] + expiration_date: datetime = None # type: ignore[assignment] -@attr.s(frozen=True) +@attrs.define(frozen=True) class ExternalGraderScoreData: """ Class that encapsulates score data provided by an external grader. @@ -650,18 +650,18 @@ class ExternalGraderScoreData: queue_name (str): Name of the queue that processed the submission """ - points_possible = attr.ib(type=int) - points_earned = attr.ib(type=int) - course_id = attr.ib(type=str) - score_msg = attr.ib(type=str) - submission_id = attr.ib(type=int) - user_id = attr.ib(type=str) - module_id = attr.ib(type=str) - queue_key = attr.ib(type=str) - queue_name = attr.ib(type=str) + points_possible: int + points_earned: int + course_id: str + score_msg: str + submission_id: int + user_id: str + module_id: str + queue_key: str + queue_name: str -@attr.s(frozen=True) +@attrs.define(frozen=True) class LtiProviderLaunchParamsData: """ Data required for a successful LTI launch. @@ -672,13 +672,14 @@ class LtiProviderLaunchParamsData: user_id (str): External (LTI) User ID of user performing the launch. extra_params (dict): A dictionary of other optional launch parameters. """ - roles = attr.ib(type=str) - context_id = attr.ib(type=str) - user_id = attr.ib(type=str) - extra_params = attr.ib(type=dict[str, str], factory=dict) + + roles: str + context_id: str + user_id: str + extra_params: dict[str, str] = attrs.field(factory=dict) -@attr.s(frozen=True) +@attrs.define(frozen=True) class LtiProviderLaunchData: """ Class that encapsulates LTI data for an LTI launch event. @@ -689,7 +690,8 @@ class LtiProviderLaunchData: usage_key (UsageKey): The usage key for the content being luanched via LtiProviderLaunchParamsData. launch_params (LtiProviderLaunchParamsData): The LTI parameters used for the launch. """ - user = attr.ib(type=UserData) - course_key = attr.ib(type=CourseKey) - usage_key = attr.ib(type=UsageKey) - launch_params = attr.ib(type=LtiProviderLaunchParamsData) + + user: UserData + course_key: CourseKey + usage_key: UsageKey + launch_params: LtiProviderLaunchParamsData From 4cbee9e0c6c5911d660d2cfdac038e3b48774808 Mon Sep 17 00:00:00 2001 From: Tycho Hob Date: Thu, 16 Apr 2026 11:57:25 -0400 Subject: [PATCH 08/12] style: Update annotations in Avro This includes the refactor in schema.py to better handle the None-optional types (see _unwrap_optional). Downstream data files are updated to match. --- mypy.ini | 10 +- openedx_events/content_authoring/data.py | 6 +- openedx_events/enterprise/data.py | 22 +-- openedx_events/event_bus/__init__.py | 66 +++++--- .../event_bus/avro/custom_serializers.py | 54 ++++--- openedx_events/event_bus/avro/deserializer.py | 96 +++++++---- openedx_events/event_bus/avro/schema.py | 114 ++++++++++--- openedx_events/event_bus/avro/serializer.py | 69 +++++--- .../event_bus/avro/tests/test_avro.py | 153 ++++++++++++------ openedx_events/event_bus/avro/types.py | 5 +- openedx_events/learning/data.py | 54 +++---- 11 files changed, 426 insertions(+), 223 deletions(-) diff --git a/mypy.ini b/mypy.ini index 04e07bf2..c420c88f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -64,19 +64,19 @@ disallow_untyped_defs = True disallow_untyped_defs = True [mypy-openedx_events.event_bus.avro.types] -disallow_untyped_defs = False +disallow_untyped_defs = True [mypy-openedx_events.event_bus.avro.custom_serializers] -disallow_untyped_defs = False +disallow_untyped_defs = True [mypy-openedx_events.event_bus.avro.schema] -disallow_untyped_defs = False +disallow_untyped_defs = True [mypy-openedx_events.event_bus.avro.serializer] -disallow_untyped_defs = False +disallow_untyped_defs = True [mypy-openedx_events.event_bus.avro.deserializer] -disallow_untyped_defs = False +disallow_untyped_defs = True [mypy-openedx_events.tooling] disallow_untyped_defs = False diff --git a/openedx_events/content_authoring/data.py b/openedx_events/content_authoring/data.py index 83ec7fe8..358122b9 100644 --- a/openedx_events/content_authoring/data.py +++ b/openedx_events/content_authoring/data.py @@ -48,9 +48,9 @@ class CourseScheduleData: start: datetime pacing: str - end: datetime = None # type: ignore[assignment] - enrollment_start: datetime = None # type: ignore[assignment] - enrollment_end: datetime = None # type: ignore[assignment] + end: datetime | None = None + enrollment_start: datetime | None = None + enrollment_end: datetime | None = None @attrs.define(frozen=True) diff --git a/openedx_events/enterprise/data.py b/openedx_events/enterprise/data.py index 823c0ef6..eb34f85d 100644 --- a/openedx_events/enterprise/data.py +++ b/openedx_events/enterprise/data.py @@ -100,9 +100,9 @@ class LedgerTransaction(BaseLedgerTransaction): subsidy_access_policy_uuid: UUID lms_user_id: int content_key: CourseKey - parent_content_key: str = None # type: ignore[assignment] - fulfillment_identifier: str = None # type: ignore[assignment] - reversal: LedgerTransactionReversal = None # type: ignore[assignment] + parent_content_key: str | None = None + fulfillment_identifier: str | None = None + reversal: LedgerTransactionReversal | None = None @attrs.define(frozen=True) @@ -135,7 +135,7 @@ class EnterpriseCustomerUser: linked: bool is_relinkable: bool should_inactivate_other_customers: bool - invite_key: UUID = None # type: ignore[assignment] + invite_key: UUID | None = None @attrs.define(frozen=True) @@ -163,9 +163,9 @@ class EnterpriseCourseEnrollment: enterprise_customer_user: EnterpriseCustomerUser course_id: CourseKey saved_for_later: bool - source_slug: str = None # type: ignore[assignment] - unenrolled: bool = None # type: ignore[assignment] - unenrolled_at: datetime = None # type: ignore[assignment] + source_slug: str | None = None + unenrolled: bool | None = None + unenrolled_at: datetime | None = None @attrs.define(frozen=True) @@ -191,8 +191,8 @@ class BaseEnterpriseFulfillment: modified: datetime fulfillment_type: str is_revoked: bool - enterprise_course_entitlement_uuid: UUID = None # type: ignore[assignment] - enterprise_course_enrollment: EnterpriseCourseEnrollment = None # type: ignore[assignment] + enterprise_course_entitlement_uuid: UUID | None = None + enterprise_course_enrollment: EnterpriseCourseEnrollment | None = None @attrs.define(frozen=True) @@ -208,7 +208,7 @@ class LearnerCreditEnterpriseCourseEnrollment(BaseEnterpriseFulfillment): transaction_id (UUID): Ledgered transaction UUID to associate with this learner credit fulfillment. """ - transaction_id: UUID = None # type: ignore[assignment] + transaction_id: UUID | None = None @attrs.define(frozen=True) @@ -224,7 +224,7 @@ class LicensedEnterpriseCourseEnrollment(BaseEnterpriseFulfillment): license_uuid (UUID): License UUID to associate with this enterprise license fulfillment. """ - license_uuid: UUID = None # type: ignore[assignment] + license_uuid: UUID | None = None @attrs.define(frozen=True) diff --git a/openedx_events/event_bus/__init__.py b/openedx_events/event_bus/__init__.py index 7e32d528..71a248e5 100644 --- a/openedx_events/event_bus/__init__.py +++ b/openedx_events/event_bus/__init__.py @@ -18,6 +18,7 @@ import warnings from abc import ABC, abstractmethod from functools import lru_cache +from typing import TypeVar from django.conf import settings from django.dispatch import receiver @@ -27,8 +28,17 @@ from openedx_events.data import EventsMetadata from openedx_events.tooling import OpenEdxPublicSignal +_T = TypeVar("_T") -def _try_load(*, setting_name: str, args: tuple, kwargs: dict, expected_class: type, default): + +def _try_load( + *, + setting_name: str, + args: tuple, + kwargs: dict, + expected_class: type[_T], + default: _T, +) -> _T: """ Load an instance of ``expected_class`` as indicated by ``setting_name``. @@ -46,7 +56,9 @@ def _try_load(*, setting_name: str, args: tuple, kwargs: dict, expected_class: t """ constructor_path = getattr(settings, setting_name, None) if constructor_path is None: - warnings.warn(f"Event Bus setting {setting_name} is missing; component will be inactive") + warnings.warn( + f"Event Bus setting {setting_name} is missing; component will be inactive" + ) return default try: @@ -75,8 +87,13 @@ class EventBusProducer(ABC): @abstractmethod def send( - self, *, signal: OpenEdxPublicSignal, topic: str, event_key_field: str, event_data: dict, - event_metadata: EventsMetadata + self, + *, + signal: OpenEdxPublicSignal, + topic: str, + event_key_field: str, + event_data: dict, + event_metadata: EventsMetadata, ) -> None: """ Send a signal event to the event bus under the specified topic. @@ -97,8 +114,13 @@ class NoEventBusProducer(EventBusProducer): """ def send( - self, *, signal: OpenEdxPublicSignal, topic: str, event_key_field: str, event_data: dict, - event_metadata: EventsMetadata, + self, + *, + signal: OpenEdxPublicSignal, + topic: str, + event_key_field: str, + event_data: dict, + event_metadata: EventsMetadata, ) -> None: """Do nothing.""" @@ -112,6 +134,7 @@ def send( # by openedx_events. If setting is not supplied or the callable raises an exception or does not return # an instance of EventBusProducer, calls to the producer will be ignored with a warning at startup. + @lru_cache # will just be one cache entry, in practice def get_producer() -> EventBusProducer: """ @@ -120,8 +143,11 @@ def get_producer() -> EventBusProducer: If misconfigured, returns a fake implementation that can be called but does nothing. """ return _try_load( - setting_name='EVENT_BUS_PRODUCER', args=(), kwargs={}, - expected_class=EventBusProducer, default=NoEventBusProducer(), + setting_name="EVENT_BUS_PRODUCER", + args=(), + kwargs={}, + expected_class=EventBusProducer, # type: ignore[type-abstract] + default=NoEventBusProducer(), ) @@ -161,8 +187,7 @@ def consume_indefinitely(self) -> None: # an instance of EventBusConsumer, calls to the consumer will be ignored with a warning at startup. -def make_single_consumer(*, topic: str, group_id: str, - **kwargs) -> EventBusConsumer: +def make_single_consumer(*, topic: str, group_id: str, **kwargs) -> EventBusConsumer: """ Construct a consumer for a given topic, group, and signal. @@ -173,13 +198,16 @@ def make_single_consumer(*, topic: str, group_id: str, group_id: The consumer group to participate in """ options = { - 'topic': topic, - 'group_id': group_id, + "topic": topic, + "group_id": group_id, **kwargs, } return _try_load( - setting_name='EVENT_BUS_CONSUMER', args=(), kwargs=options, - expected_class=EventBusConsumer, default=NoEventBusConsumer(), + setting_name="EVENT_BUS_CONSUMER", + args=(), + kwargs=options, + expected_class=EventBusConsumer, # type: ignore[type-abstract] + default=NoEventBusConsumer(), ) @@ -217,12 +245,14 @@ def merge_producer_configs(producer_config_original, producer_config_overrides): event_type_config_combined = combined.get(event_type, {}) for topic, topic_config_overrides in event_type_config_overrides.items(): topic_config_combined = event_type_config_combined.get(topic, {}) - enabled_override = topic_config_overrides.get('enabled', None) - event_key_field_override = topic_config_overrides.get('event_key_field', None) + enabled_override = topic_config_overrides.get("enabled", None) + event_key_field_override = topic_config_overrides.get( + "event_key_field", None + ) if enabled_override is not None: - topic_config_combined['enabled'] = enabled_override + topic_config_combined["enabled"] = enabled_override if event_key_field_override is not None: - topic_config_combined['event_key_field'] = event_key_field_override + topic_config_combined["event_key_field"] = event_key_field_override event_type_config_combined[topic] = topic_config_combined combined[event_type] = event_type_config_combined return combined diff --git a/openedx_events/event_bus/avro/custom_serializers.py b/openedx_events/event_bus/avro/custom_serializers.py index e786036b..8a84bea6 100644 --- a/openedx_events/event_bus/avro/custom_serializers.py +++ b/openedx_events/event_bus/avro/custom_serializers.py @@ -1,9 +1,10 @@ - """ Classes to serialize and deserialize custom types used by openedx events. See README for usage. """ + from abc import ABC, abstractmethod from datetime import datetime +from typing import Any, ClassVar from uuid import UUID from ccx_keys.locator import CCXLocator @@ -23,17 +24,17 @@ class BaseCustomTypeAvroSerializer(ABC): Used by openedx_events.avro_utilities class to serialize/deserialize custom types. """ - cls: type - field_type: str + cls: ClassVar[type] + field_type: ClassVar[str] @staticmethod @abstractmethod - def serialize(obj) -> str: + def serialize(obj: Any) -> str: """Abstract method to serialize obj into string.""" @staticmethod @abstractmethod - def deserialize(data: str) -> object: + def deserialize(data: str) -> Any: """Abstract method to deserialize string into obj.""" @@ -46,12 +47,12 @@ class CourseKeyAvroSerializer(BaseCustomTypeAvroSerializer): field_type = PYTHON_TYPE_TO_AVRO_MAPPING[str] @staticmethod - def serialize(obj) -> str: + def serialize(obj: Any) -> str: """Serialize obj into string.""" return str(obj) @staticmethod - def deserialize(data: str): + def deserialize(data: str) -> CourseKey: """Deserialize string into obj.""" return CourseKey.from_string(data) @@ -65,12 +66,12 @@ class CcxCourseLocatorAvroSerializer(BaseCustomTypeAvroSerializer): field_type = PYTHON_TYPE_TO_AVRO_MAPPING[str] @staticmethod - def serialize(obj) -> str: + def serialize(obj: Any) -> str: """Serialize obj into string.""" return str(obj) @staticmethod - def deserialize(data: str): + def deserialize(data: str) -> CCXLocator: """Deserialize string into obj.""" return CCXLocator.from_string(data) @@ -88,12 +89,15 @@ class DatetimeAvroSerializer(BaseCustomTypeAvroSerializer): field_type = PYTHON_TYPE_TO_AVRO_MAPPING[str] @staticmethod - def serialize(obj) -> str: + def serialize(obj: Any) -> str: """Serialize obj into string.""" - return obj.isoformat() + # While obj is assumed to be a datetime object, and isoformat() + # returns a str, we need to accept Any here so to satisfy mypy + # we no-op cast it to a str. + return str(obj.isoformat()) @staticmethod - def deserialize(data: str): + def deserialize(data: str) -> datetime: """Deserialize string into obj.""" return datetime.fromisoformat(data) @@ -107,12 +111,12 @@ class UsageKeyAvroSerializer(BaseCustomTypeAvroSerializer): field_type = PYTHON_TYPE_TO_AVRO_MAPPING[str] @staticmethod - def serialize(obj) -> str: + def serialize(obj: Any) -> str: """Serialize obj into string.""" return str(obj) @staticmethod - def deserialize(data: str): + def deserialize(data: str) -> UsageKey: """Deserialize string into obj.""" return UsageKey.from_string(data) @@ -126,12 +130,12 @@ class LibraryCollectionLocatorAvroSerializer(BaseCustomTypeAvroSerializer): field_type = PYTHON_TYPE_TO_AVRO_MAPPING[str] @staticmethod - def serialize(obj) -> str: + def serialize(obj: Any) -> str: """Serialize obj into string.""" return str(obj) @staticmethod - def deserialize(data: str): + def deserialize(data: str) -> LibraryCollectionLocator: """Deserialize string into obj.""" return LibraryCollectionLocator.from_string(data) @@ -145,12 +149,12 @@ class LibraryContainerLocatorAvroSerializer(BaseCustomTypeAvroSerializer): field_type = PYTHON_TYPE_TO_AVRO_MAPPING[str] @staticmethod - def serialize(obj) -> str: + def serialize(obj: Any) -> str: """Serialize obj into string.""" return str(obj) @staticmethod - def deserialize(data: str): + def deserialize(data: str) -> LibraryContainerLocator: """Deserialize string into obj.""" return LibraryContainerLocator.from_string(data) @@ -164,12 +168,12 @@ class LibraryLocatorV2AvroSerializer(BaseCustomTypeAvroSerializer): field_type = PYTHON_TYPE_TO_AVRO_MAPPING[str] @staticmethod - def serialize(obj) -> str: + def serialize(obj: Any) -> str: """Serialize obj into string.""" return str(obj) @staticmethod - def deserialize(data: str): + def deserialize(data: str) -> LibraryLocatorV2: """Deserialize string into obj.""" return LibraryLocatorV2.from_string(data) @@ -183,12 +187,12 @@ class LibraryUsageLocatorV2AvroSerializer(BaseCustomTypeAvroSerializer): field_type = PYTHON_TYPE_TO_AVRO_MAPPING[str] @staticmethod - def serialize(obj) -> str: + def serialize(obj: Any) -> str: """Serialize obj into string.""" return str(obj) @staticmethod - def deserialize(data: str): + def deserialize(data: str) -> LibraryUsageLocatorV2: """Deserialize string into obj.""" return LibraryUsageLocatorV2.from_string(data) @@ -204,17 +208,17 @@ class UuidAvroSerializer(BaseCustomTypeAvroSerializer): field_type = PYTHON_TYPE_TO_AVRO_MAPPING[str] @staticmethod - def serialize(obj) -> str: + def serialize(obj: Any) -> str: """Serialize obj into string.""" return str(obj) @staticmethod - def deserialize(data: str): + def deserialize(data: str) -> UUID: """Deserialize string into obj.""" return UUID(data) -DEFAULT_CUSTOM_SERIALIZERS = [ +DEFAULT_CUSTOM_SERIALIZERS: list[type[BaseCustomTypeAvroSerializer]] = [ CourseKeyAvroSerializer, CcxCourseLocatorAvroSerializer, DatetimeAvroSerializer, diff --git a/openedx_events/event_bus/avro/deserializer.py b/openedx_events/event_bus/avro/deserializer.py index 8b07d596..6f3e0c5c 100644 --- a/openedx_events/event_bus/avro/deserializer.py +++ b/openedx_events/event_bus/avro/deserializer.py @@ -1,22 +1,32 @@ """ Deserialize Avro record dictionaries to events that can be sent with OpenEdxPublicSignals. """ + import io import json -from typing import get_args, get_origin +from typing import TYPE_CHECKING, Any, Callable, get_args, get_origin -import attr +import attrs import fastavro -from .custom_serializers import DEFAULT_CUSTOM_SERIALIZERS -from .schema import schema_from_signal +from .custom_serializers import DEFAULT_CUSTOM_SERIALIZERS, BaseCustomTypeAvroSerializer +from .schema import _unwrap_optional, schema_from_signal from .types import PYTHON_TYPE_TO_AVRO_MAPPING, SIMPLE_PYTHON_TYPE_TO_AVRO_MAPPING +if TYPE_CHECKING: + from openedx_events.tooling import OpenEdxPublicSignal + # Dict of class to deserialize methods (e.g. datetime => DatetimeAvroSerializer.deserialize) -DEFAULT_DESERIALIZERS = {serializer.cls: serializer.deserialize for serializer in DEFAULT_CUSTOM_SERIALIZERS} +DEFAULT_DESERIALIZERS: dict[type, Callable[..., Any]] = { + serializer.cls: serializer.deserialize for serializer in DEFAULT_CUSTOM_SERIALIZERS +} -def _deserialized_avro_record_dict_to_object(data: dict, data_type, deserializers=None): +def _deserialized_avro_record_dict_to_object( + data: Any, + data_type: type, + deserializers: dict[type, Callable[..., Any]] | None = None, +) -> Any: """ Convert Avro record dictionary into an instance of data_type. @@ -55,7 +65,10 @@ def _deserialized_avro_record_dict_to_object(data: dict, data_type, deserializer # Complex nested types like List[List[...]], List[Dict[...]], etc. item_type = arg_data_type[0] - return [_deserialized_avro_record_dict_to_object(sub_data, item_type, deserializers) for sub_data in data] + return [ + _deserialized_avro_record_dict_to_object(sub_data, item_type, deserializers) + for sub_data in data + ] elif data_type_origin is dict: # Returns types of dict contents. # Example: if data_type == Dict[str, int], arg_data_type = (str, int) @@ -71,11 +84,15 @@ def _deserialized_avro_record_dict_to_object(data: dict, data_type, deserializer # Complex dict values that need recursive deserialization key_type, value_type = arg_data_type if key_type is not str: - raise TypeError("Avro maps only support string keys. The key type must be 'str'.") + raise TypeError( + "Avro maps only support string keys. The key type must be 'str'." + ) # Complex nested types like Dict[str, Dict[...]], Dict[str, List[...]], etc. return { - key: _deserialized_avro_record_dict_to_object(value, value_type, deserializers) + key: _deserialized_avro_record_dict_to_object( + value, value_type, deserializers + ) for key, value in data.items() } elif hasattr(data_type, "__attrs_attrs__"): @@ -83,10 +100,14 @@ def _deserialized_avro_record_dict_to_object(data: dict, data_type, deserializer for attribute in data_type.__attrs_attrs__: if attribute.name in data: sub_data = data[attribute.name] - if sub_data or attribute.default is attr.NOTHING: - transformed[attribute.name] = _deserialized_avro_record_dict_to_object(sub_data, - attribute.type, - deserializers=deserializers) + if sub_data or attribute.default is attrs.NOTHING: + transformed[attribute.name] = ( + _deserialized_avro_record_dict_to_object( + sub_data, + _unwrap_optional(attribute.type), + deserializers=deserializers, + ) + ) return data_type(**transformed) raise TypeError( @@ -94,7 +115,11 @@ def _deserialized_avro_record_dict_to_object(data: dict, data_type, deserializer ) -def _avro_record_dict_to_event_data(signal, avro_record_dict, deserializers=None): +def _avro_record_dict_to_event_data( + signal: "OpenEdxPublicSignal", + avro_record_dict: dict[str, Any], + deserializers: dict[type, Callable[..., Any]] | None = None, +) -> dict[str, Any]: """ Convert an Avro record dictionary into event data that can be sent by the given signal. @@ -106,11 +131,18 @@ def _avro_record_dict_to_event_data(signal, avro_record_dict, deserializers=None Returns: - An event data dictionary that can be sent by the given signal """ - return {data_key: _deserialized_avro_record_dict_to_object(avro_record_dict[data_key], data_type, deserializers) - for data_key, data_type in signal.init_data.items()} - - -def deserialize_bytes_to_event_data(bytes_from_wire, signal): + return { + data_key: _deserialized_avro_record_dict_to_object( + avro_record_dict[data_key], data_type, deserializers + ) + for data_key, data_type in signal.init_data.items() + } + + +def deserialize_bytes_to_event_data( + bytes_from_wire: bytes, + signal: "OpenEdxPublicSignal", +) -> dict[str, Any]: """ Deserialize event_bus and Avro-serialized data. @@ -121,7 +153,7 @@ def deserialize_bytes_to_event_data(bytes_from_wire, signal): deserializer = AvroSignalDeserializer(signal) schema_dict = deserializer.schema data_file = io.BytesIO(bytes_from_wire) - as_dict = fastavro.schemaless_reader(data_file, schema_dict) + as_dict: dict[str, Any] = fastavro.schemaless_reader(data_file, schema_dict) # type: ignore[assignment, call-arg] return deserializer.from_dict(as_dict) @@ -137,7 +169,7 @@ class AvroSignalDeserializer: To deserialize events that include data types that are not yet supported, see README. """ - def __init__(self, signal): + def __init__(self, signal: "OpenEdxPublicSignal") -> None: """ Initialize deserializer, creating an Avro schema from signal. @@ -145,19 +177,27 @@ def __init__(self, signal): signal: An instance of OpenEdxPublicSignal. """ self.signal = signal - self.deserializers = {ext.cls: ext.deserialize for ext in self.custom_type_serializers()} - self.custom_types = {ext.cls: ext.field_type for ext in self.custom_type_serializers()} - self.schema = schema_from_signal(self.signal, custom_type_to_avro_type=self.custom_types) + self.deserializers: dict[type, Callable[..., Any]] = { + ext.cls: ext.deserialize for ext in self.custom_type_serializers() + } + self.custom_types: dict[type, str] = { + ext.cls: ext.field_type for ext in self.custom_type_serializers() + } + self.schema: dict[str, Any] = schema_from_signal( + self.signal, custom_type_to_avro_type=self.custom_types + ) - def schema_string(self): + def schema_string(self) -> str: """Get Avro schema as string.""" return json.dumps(self.schema, sort_keys=True) - def from_dict(self, avro_record_dict): + def from_dict(self, avro_record_dict: dict[str, Any]) -> dict[str, Any]: """Convert Avro record dictionary to event data.""" - return _avro_record_dict_to_event_data(self.signal, avro_record_dict, self.deserializers) + return _avro_record_dict_to_event_data( + self.signal, avro_record_dict, self.deserializers + ) - def custom_type_serializers(self): + def custom_type_serializers(self) -> list[type[BaseCustomTypeAvroSerializer]]: """ Override this method to add custom serializers for unhandled classes. diff --git a/openedx_events/event_bus/avro/schema.py b/openedx_events/event_bus/avro/schema.py index 20e5e784..d83063eb 100644 --- a/openedx_events/event_bus/avro/schema.py +++ b/openedx_events/event_bus/avro/schema.py @@ -4,15 +4,48 @@ TODO: Handle optional parameters and allow for schema evolution. https://github.com/edx/edx-arch-experiments/issues/53 """ -from typing import Any, Type, get_args, get_origin +import types +import typing +from typing import TYPE_CHECKING, Any, get_args, get_origin from .custom_serializers import DEFAULT_CUSTOM_SERIALIZERS from .types import PYTHON_TYPE_TO_AVRO_MAPPING, SIMPLE_PYTHON_TYPE_TO_AVRO_MAPPING -DEFAULT_FIELD_TYPES = {serializer.cls: serializer.field_type for serializer in DEFAULT_CUSTOM_SERIALIZERS} +if TYPE_CHECKING: + from openedx_events.tooling import OpenEdxPublicSignal +NoneType = type(None) -def schema_from_signal(signal, custom_type_to_avro_type=None): + +def _unwrap_optional(data_type: type) -> type: + """ + If data_type is a union of X | None, return X. Otherwise return data_type unchanged. + + This allows attrs fields annotated as `X | None` with `default=None` to be + handled by the Avro layer the same way as fields annotated with the plain base + type `X`. The `default_is_none` flag (derived from `attribute.default is + None`) already causes the generated Avro field to be wrapped in a + `["null", ]` union, so we only need to unwrap the Python-level `None` + from the annotation here. + """ + # Handle both `X | None` and `typing.Optional[X]` / `typing.Union[X, None]`. + origin = get_origin(data_type) + if origin is types.UnionType or origin is typing.Union: + args = [a for a in get_args(data_type) if a is not NoneType] + if len(args) == 1: + return args[0] # type: ignore[no-any-return] + return data_type + + +DEFAULT_FIELD_TYPES: dict[type, str] = { + serializer.cls: serializer.field_type for serializer in DEFAULT_CUSTOM_SERIALIZERS +} + + +def schema_from_signal( + signal: "OpenEdxPublicSignal", + custom_type_to_avro_type: dict[type, str] | None = None, +) -> dict[str, Any]: """ Create an Avro schema for events sent by an instance of OpenEdxPublicSignal. @@ -25,9 +58,9 @@ def schema_from_signal(signal, custom_type_to_avro_type=None): """ field_types = custom_type_to_avro_type or {} all_custom_field_types = {**DEFAULT_FIELD_TYPES, **field_types} - previously_seen_types = set() + previously_seen_types: set[str] = set() - base_schema = { + base_schema: dict[str, Any] = { "name": "CloudEvent", "type": "record", "doc": "Avro Event Format for CloudEvents created with openedx_events/schema", @@ -36,14 +69,24 @@ def schema_from_signal(signal, custom_type_to_avro_type=None): } for data_key, data_type in signal.init_data.items(): - base_schema["fields"].append(_create_avro_field_definition(data_key, data_type, - previously_seen_types, - custom_type_to_avro_type=all_custom_field_types)) + base_schema["fields"].append( + _create_avro_field_definition( + data_key, + data_type, + previously_seen_types, + custom_type_to_avro_type=all_custom_field_types, + ) + ) return base_schema -def _create_avro_field_definition(data_key, data_type, previously_seen_types, - custom_type_to_avro_type=None, default_is_none=False): +def _create_avro_field_definition( + data_key: str, + data_type: type, + previously_seen_types: set[str], + custom_type_to_avro_type: dict[type, str] | None = None, + default_is_none: bool = False, +) -> dict[str, Any]: """ Create an Avro schema field definition from an OpenEdxPublicSignal data definition. @@ -57,7 +100,7 @@ def _create_avro_field_definition(data_key, data_type, previously_seen_types, Returns: - An Avro field definition. """ - field = {"name": data_key} + field: dict[str, Any] = {"name": data_key} all_field_type_overrides = custom_type_to_avro_type or {} # get generic type of data_type # if data_type == List[int], data_type_origin = list @@ -70,7 +113,9 @@ def _create_avro_field_definition(data_key, data_type, previously_seen_types, elif data_type in PYTHON_TYPE_TO_AVRO_MAPPING: if PYTHON_TYPE_TO_AVRO_MAPPING[data_type] in ["map", "array"]: # pylint: disable-next=broad-exception-raised - raise Exception("Unable to generate Avro schema for dict or array fields without annotation types.") + raise Exception( + "Unable to generate Avro schema for dict or array fields without annotation types." + ) avro_type = PYTHON_TYPE_TO_AVRO_MAPPING[data_type] field["type"] = avro_type # Case 3: data_type is a list (possibly with complex items) @@ -95,14 +140,21 @@ def _create_avro_field_definition(data_key, data_type, previously_seen_types, field["type"] = data_type.__name__ else: previously_seen_types.add(data_type.__name__) - record_type = {"name": data_type.__name__, "type": 'record', "fields": []} + record_type: dict[str, Any] = { + "name": data_type.__name__, + "type": "record", + "fields": [], + } for attribute in data_type.__attrs_attrs__: record_type["fields"].append( - _create_avro_field_definition(attribute.name, attribute.type, - previously_seen_types, - custom_type_to_avro_type=all_field_type_overrides, - default_is_none=attribute.default is None) + _create_avro_field_definition( + attribute.name, + _unwrap_optional(attribute.type), + previously_seen_types, + custom_type_to_avro_type=all_field_type_overrides, + default_is_none=attribute.default is None, + ) ) field["type"] = record_type else: @@ -119,8 +171,10 @@ def _create_avro_field_definition(data_key, data_type, previously_seen_types, def _get_avro_type_for_dict_item( - data_type: Type[dict], previously_seen_types: set, type_overrides: dict[Any, str] -) -> str | dict[str, str]: + data_type: type, + previously_seen_types: set[str], + type_overrides: dict[type, str], +) -> str | dict[str, Any]: """ Determine the Avro type definition for a dictionary value based on its Python type. @@ -167,12 +221,16 @@ def _get_avro_type_for_dict_item( # Case 2: Complex types (dict, list, or attrs class) if get_origin(value_type) in (dict, list) or hasattr(value_type, "__attrs_attrs__"): # Create a temporary field for the value type and extract its type definition - temp_field = _create_avro_field_definition("temp", value_type, previously_seen_types, type_overrides) - return temp_field["type"] + temp_field = _create_avro_field_definition( + "temp", value_type, previously_seen_types, type_overrides + ) + return temp_field["type"] # type: ignore[no-any-return] # Case 3: Unannotated containers (raise specific errors) if value_type is dict: - raise TypeError("A Dictionary as a dictionary value should have a type annotation.") + raise TypeError( + "A Dictionary as a dictionary value should have a type annotation." + ) if value_type is list: raise TypeError("A List as a dictionary value should have a type annotation.") @@ -181,8 +239,10 @@ def _get_avro_type_for_dict_item( def _get_avro_type_for_list_item( - data_type: Type[list], previously_seen_types: set, type_overrides: dict[Any, str] -) -> str | dict[str, str]: + data_type: type, + previously_seen_types: set[str], + type_overrides: dict[type, str], +) -> str | dict[str, Any]: """ Determine the Avro type definition for a list item based on its Python type. @@ -231,8 +291,10 @@ def _get_avro_type_for_list_item( # Case 2: Complex types (dict, list, or attrs class) if get_origin(item_type) in (dict, list) or hasattr(item_type, "__attrs_attrs__"): # Create a temporary field for the value type and extract its type definition - temp_field = _create_avro_field_definition("temp", item_type, previously_seen_types, type_overrides) - return temp_field["type"] + temp_field = _create_avro_field_definition( + "temp", item_type, previously_seen_types, type_overrides + ) + return temp_field["type"] # type: ignore[no-any-return] # Case 3: Unannotated containers (raise specific errors) if item_type is dict: diff --git a/openedx_events/event_bus/avro/serializer.py b/openedx_events/event_bus/avro/serializer.py index 593a8a4f..eef54fc0 100644 --- a/openedx_events/event_bus/avro/serializer.py +++ b/openedx_events/event_bus/avro/serializer.py @@ -1,21 +1,27 @@ """ Serialize events to Avro records. """ + import io import json +from typing import Any, Callable -import attr +import attrs import fastavro -from .custom_serializers import DEFAULT_CUSTOM_SERIALIZERS +from .custom_serializers import DEFAULT_CUSTOM_SERIALIZERS, BaseCustomTypeAvroSerializer from .schema import schema_from_signal -DEFAULT_SERIALIZERS = {serializer.cls: serializer.serialize for serializer in DEFAULT_CUSTOM_SERIALIZERS} +DEFAULT_SERIALIZERS: dict[type, Callable[..., Any]] = { + serializer.cls: serializer.serialize for serializer in DEFAULT_CUSTOM_SERIALIZERS +} -def _get_non_attrs_serializer(serializers=None): +def _get_non_attrs_serializer( + serializers: dict[type, Callable[..., Any]] | None = None, +) -> Callable[[Any, Any, Any], Any]: """ - Create a method to pass as the value_serializer argument to attr.as_dict to serialize using custom serializers. + Create a method to pass as the value_serializer argument to attrs.asdict to serialize using custom serializers. Arguments: - serializers: A map of Python type to serialization method. @@ -26,7 +32,7 @@ def _get_non_attrs_serializer(serializers=None): param_serializers = serializers or {} all_serializers = {**DEFAULT_SERIALIZERS, **param_serializers} - def _serialize_non_attrs_values(inst, field, value): # pylint: disable=unused-argument + def _serialize_non_attrs_values(inst: Any, field: Any, value: Any) -> Any: # pylint: disable=unused-argument if value is None: # All default=None fields are implicit union types of NoneType # and something else. (See ADR 7.) Note that if there isn't a @@ -41,20 +47,28 @@ def _serialize_non_attrs_values(inst, field, value): # pylint: disable=unused-a # If we ever make a custom serializer that can handle # None as an input, we can remove this check. # pylint: disable-next=broad-exception-raised - raise Exception("None cannot be handled by custom serializers (and default=None was not set)") + raise Exception( + "None cannot be handled by custom serializers (and default=None was not set)" + ) for extended_class, serializer in all_serializers.items(): if field: # Make sure that field.type is a class first. - if isinstance(field.type, type) and issubclass(field.type, extended_class): + if isinstance(field.type, type) and issubclass( + field.type, extended_class + ): return serializer(value) if issubclass(type(value), extended_class): return serializer(value) return value + return _serialize_non_attrs_values -def _event_data_to_avro_record_dict(event_data, serializers=None): +def _event_data_to_avro_record_dict( + event_data: dict[str, Any], + serializers: dict[type, Callable[..., Any]] | None = None, +) -> dict[str, Any]: """ Create an Avro record dictionary from an event data dict. @@ -66,20 +80,19 @@ def _event_data_to_avro_record_dict(event_data, serializers=None): - An Avro record dictionary representation of the event data. """ - def value_to_dict(value): - # Case 1: Value is an instance of an attrs-decorated class + def value_to_dict(value: Any) -> Any: + # Check if value is an attrs-decorated class, this check should work + # for both the old style attr and new style attrs classes. if hasattr(value, "__attrs_attrs__"): - return attr.asdict(value, value_serializer=_get_non_attrs_serializer(serializers)) + return attrs.asdict( + value, value_serializer=_get_non_attrs_serializer(serializers) + ) return _get_non_attrs_serializer(serializers)(None, None, value) - return json.loads( - json.dumps( - event_data, sort_keys=True, default=value_to_dict - ) - ) + return json.loads(json.dumps(event_data, sort_keys=True, default=value_to_dict)) # type: ignore[no-any-return] -def serialize_event_data_to_bytes(event_data, signal): +def serialize_event_data_to_bytes(event_data: dict[str, Any], signal: Any) -> bytes: """ Serialize event data to bytes. @@ -111,7 +124,7 @@ class AvroSignalSerializer: To serialize events that include data types that are not yet supported, see README. """ - def __init__(self, signal): + def __init__(self, signal: Any) -> None: """ Initialize serializer, creating an Avro schema from signal. @@ -119,19 +132,25 @@ def __init__(self, signal): signal: An instance of OpenEdxPublicSignal. """ self.signal = signal - self.serializers = {ext.cls: ext.serialize for ext in self.custom_type_serializers()} - self.custom_types = {ext.cls: ext.field_type for ext in self.custom_type_serializers()} - self.schema = schema_from_signal(self.signal, custom_type_to_avro_type=self.custom_types) + self.serializers: dict[type, Callable[..., Any]] = { + ext.cls: ext.serialize for ext in self.custom_type_serializers() + } + self.custom_types: dict[type, str] = { + ext.cls: ext.field_type for ext in self.custom_type_serializers() + } + self.schema: dict[str, Any] = schema_from_signal( + self.signal, custom_type_to_avro_type=self.custom_types + ) - def schema_string(self): + def schema_string(self) -> str: """Get Avro schema as JSON string.""" return json.dumps(self.schema, sort_keys=True) - def to_dict(self, event_data): + def to_dict(self, event_data: dict[str, Any]) -> dict[str, Any]: """Convert event data to an Avro record dictionary.""" return _event_data_to_avro_record_dict(event_data, serializers=self.serializers) - def custom_type_serializers(self): + def custom_type_serializers(self) -> list[type[BaseCustomTypeAvroSerializer]]: """ Override this method to add custom serializers for unhandled classes. diff --git a/openedx_events/event_bus/avro/tests/test_avro.py b/openedx_events/event_bus/avro/tests/test_avro.py index 4ee75b96..2ca6d2c3 100644 --- a/openedx_events/event_bus/avro/tests/test_avro.py +++ b/openedx_events/event_bus/avro/tests/test_avro.py @@ -1,4 +1,5 @@ """Test interplay of the various Avro helper classes""" + import io import os from datetime import datetime @@ -19,6 +20,7 @@ ) from openedx_events.event_bus.avro.deserializer import AvroSignalDeserializer, deserialize_bytes_to_event_data +from openedx_events.event_bus.avro.schema import _unwrap_optional from openedx_events.event_bus.avro.serializer import AvroSignalSerializer, serialize_event_data_to_bytes from openedx_events.event_bus.avro.tests.test_utilities import ( EventData, @@ -217,53 +219,67 @@ def generate_test_event_data_for_data_type(data_type: Any) -> dict: # pragma: n UsageKey: UsageKey.from_string( "block-v1:edx+DemoX+Demo_course+type@video+block@UaEBjyMjcLW65gaTXggB93WmvoxGAJa0JeHRrDThk", ), - LibraryCollectionLocator: LibraryCollectionLocator.from_string('lib-collection:MITx:reallyhardproblems:col1'), + LibraryCollectionLocator: LibraryCollectionLocator.from_string( + "lib-collection:MITx:reallyhardproblems:col1" + ), LibraryContainerLocator: LibraryContainerLocator.from_string( - 'lct:MITx:reallyhardproblems:unit:test-container', + "lct:MITx:reallyhardproblems:unit:test-container", + ), + LibraryLocatorV2: LibraryLocatorV2.from_string("lib:MITx:reallyhardproblems"), + LibraryUsageLocatorV2: LibraryUsageLocatorV2.from_string( + "lb:MITx:reallyhardproblems:problem:problem1" ), - LibraryLocatorV2: LibraryLocatorV2.from_string('lib:MITx:reallyhardproblems'), - LibraryUsageLocatorV2: LibraryUsageLocatorV2.from_string('lb:MITx:reallyhardproblems:problem:problem1'), List[int]: [1, 2, 3], List[str]: ["hi", "there"], datetime: datetime.now(), - CCXLocator: CCXLocator(org='edx', course='DemoX', run='Demo_course', ccx='1'), + CCXLocator: CCXLocator(org="edx", course="DemoX", run="Demo_course", ccx="1"), UUID: uuid4(), - dict[str, str]: {'key': 'value'}, - dict[str, int]: {'key': 1}, - dict[str, float]: {'key': 1.0}, - dict[str, bool]: {'key': True}, - dict[str, CourseKey]: {'key': CourseKey.from_string("course-v1:edX+DemoX.1+2014")}, - dict[str, UsageKey]: {'key': UsageKey.from_string( - "block-v1:edx+DemoX+Demo_course+type@video+block@UaEBjyMjcLW65gaTXggB93WmvoxGAJa0JeHRrDThk", - )}, - dict[str, LibraryLocatorV2]: {'key': LibraryLocatorV2.from_string('lib:MITx:reallyhardproblems')}, + dict[str, str]: {"key": "value"}, + dict[str, int]: {"key": 1}, + dict[str, float]: {"key": 1.0}, + dict[str, bool]: {"key": True}, + dict[str, CourseKey]: { + "key": CourseKey.from_string("course-v1:edX+DemoX.1+2014") + }, + dict[str, UsageKey]: { + "key": UsageKey.from_string( + "block-v1:edx+DemoX+Demo_course+type@video+block@UaEBjyMjcLW65gaTXggB93WmvoxGAJa0JeHRrDThk", + ) + }, + dict[str, LibraryLocatorV2]: { + "key": LibraryLocatorV2.from_string("lib:MITx:reallyhardproblems") + }, dict[str, LibraryCollectionLocator]: { - 'key': LibraryCollectionLocator.from_string('lib-collection:MITx:reallyhardproblems:col1'), + "key": LibraryCollectionLocator.from_string( + "lib-collection:MITx:reallyhardproblems:col1" + ), }, dict[str, LibraryContainerLocator]: { - 'key': LibraryContainerLocator.from_string('lct:MITx:reallyhardproblems:unit:test-container'), + "key": LibraryContainerLocator.from_string( + "lct:MITx:reallyhardproblems:unit:test-container" + ), }, dict[str, LibraryUsageLocatorV2]: { - 'key': LibraryUsageLocatorV2.from_string('lb:MITx:reallyhardproblems:problem:problem1'), + "key": LibraryUsageLocatorV2.from_string( + "lb:MITx:reallyhardproblems:problem:problem1" + ), }, - dict[str, List[int]]: {'key': [1, 2, 3]}, - dict[str, List[str]]: {'key': ["hi", "there"]}, - dict[str, dict[str, str]]: {'key': {'key': 'value'}}, - dict[str, dict[str, int]]: {'key': {'key': 1}}, - dict[str, Union[str, int]]: {'key': 'value'}, - dict[str, Union[str, int, float]]: {'key': 1.0}, + dict[str, List[int]]: {"key": [1, 2, 3]}, + dict[str, List[str]]: {"key": ["hi", "there"]}, + dict[str, dict[str, str]]: {"key": {"key": "value"}}, + dict[str, dict[str, int]]: {"key": {"key": 1}}, + dict[str, Union[str, int]]: {"key": "value"}, + dict[str, Union[str, int, float]]: {"key": 1.0}, } # Handle origin types origin_type = get_origin(data_type) if origin_type is not None: - args = get_args(data_type) # Handle List types if origin_type is list: - item_type = args[0] # Handle List of simple types, e.g. List[str] @@ -275,15 +291,21 @@ def generate_test_event_data_for_data_type(data_type: Any) -> dict: # pragma: n dict_key_type, dict_value_type = get_args(item_type) # Only support string keys for Avro compatibility if dict_key_type is not str: - raise TypeError("Avro maps only support string keys. The key type must be 'str'.") + raise TypeError( + "Avro maps only support string keys. The key type must be 'str'." + ) sample_dict = {} if get_origin(dict_value_type) is not None: # Handle nested types in dictionary values, e.g. List[str] - sample_dict = {"key": generate_test_event_data_for_data_type(dict_value_type)} + sample_dict = { + "key": generate_test_event_data_for_data_type(dict_value_type) + } else: # Handle simple types in dictionary values, e.g. str - default_value = defaults_per_type.get(dict_value_type, "default_value") + default_value = defaults_per_type.get( + dict_value_type, "default_value" + ) sample_dict = {"key": default_value} return [sample_dict] @@ -294,12 +316,13 @@ def generate_test_event_data_for_data_type(data_type: Any) -> dict: # pragma: n # Handle Dict types elif origin_type is dict: - key_type, value_type = args[0], args[1] # Only support string keys for Avro compatibility if key_type is not str: - raise TypeError("Avro maps only support string keys. The key type must be 'str'.") + raise TypeError( + "Avro maps only support string keys. The key type must be 'str'." + ) # Handle Dict of simple types, e.g. Dict[str, str] if value_type in defaults_per_type: @@ -316,23 +339,29 @@ def generate_test_event_data_for_data_type(data_type: Any) -> dict: # pragma: n # Handle attrs classes if hasattr(data_type, "__attrs_attrs__"): - data_dict = {} for attribute in data_type.__attrs_attrs__: + attr_type = _unwrap_optional(attribute.type) - result = defaults_per_type.get(attribute.type, None) + result = defaults_per_type.get(attr_type, None) # Handle simple types if result is not None: data_dict.update({attribute.name: result}) else: # Handle origin types in attributes - origin = get_origin(attribute.type) + origin = get_origin(attr_type) if origin is not None: - data_dict.update({attribute.name: generate_test_event_data_for_data_type(attribute.type)}) + data_dict.update( + { + attribute.name: generate_test_event_data_for_data_type( + attr_type + ) + } + ) # Handle attrs classes - if hasattr(attribute.type, "__attrs_attrs__"): - attr_data = generate_test_event_data_for_data_type(attribute.type) + if hasattr(attr_type, "__attrs_attrs__"): + attr_data = generate_test_event_data_for_data_type(attr_type) data_dict.update({attribute.name: attr_data}) return data_type(**data_dict) @@ -400,18 +429,26 @@ def test_evolution_is_forward_compatible(self): current_event_bytes = current_out.read() # get stored schema - schema_filename = f"{os.path.dirname(os.path.abspath(__file__))}/schemas/" \ - f"{signal.event_type.replace('.', '+')}_schema.avsc" + schema_filename = ( + f"{os.path.dirname(os.path.abspath(__file__))}/schemas/" + f"{signal.event_type.replace('.', '+')}_schema.avsc" + ) try: stored_schema = load_schema(schema_filename) except SchemaRepositoryError: # pragma: no cover - self.fail(f"Missing file {schema_filename}. If a new signal has been added, you may need to run the" - f" generate_avro_schemas management command to save the signal schema.") + self.fail( + f"Missing file {schema_filename}. If a new signal has been added, you may need to run the" + f" generate_avro_schemas management command to save the signal schema." + ) data_file_current = io.BytesIO(current_event_bytes) # read bytes using stored schema - schemaless_reader(data_file_current, reader_schema=stored_schema, writer_schema=schema_dict) + schemaless_reader( + data_file_current, + reader_schema=stored_schema, + writer_schema=schema_dict, + ) def test_evolution_is_backward_compatible(self): """ @@ -431,13 +468,17 @@ def test_evolution_is_backward_compatible(self): schema_dict = serializer.schema # get stored schema - schema_filename = f"{os.path.dirname(os.path.abspath(__file__))}/schemas/" \ - f"{signal.event_type.replace('.', '+')}_schema.avsc" + schema_filename = ( + f"{os.path.dirname(os.path.abspath(__file__))}/schemas/" + f"{signal.event_type.replace('.', '+')}_schema.avsc" + ) try: old_schema = load_schema(schema_filename) except SchemaRepositoryError: # pragma: no cover - self.fail(f"Missing file {schema_filename}. If a new signal has been added, you may need to run the" - f" generate_avro_schemas management command to save the signal schema.") + self.fail( + f"Missing file {schema_filename}. If a new signal has been added, you may need to run the" + f" generate_avro_schemas management command to save the signal schema." + ) data_dict = generate_test_data_for_schema(old_schema) # write to bytes using stored schema @@ -449,16 +490,20 @@ def test_evolution_is_backward_compatible(self): data_file_stored = io.BytesIO(stored_event_bytes) # read bytes using current schema - schemaless_reader(data_file_stored, reader_schema=schema_dict, writer_schema=old_schema) + schemaless_reader( + data_file_stored, reader_schema=schema_dict, writer_schema=old_schema + ) def test_full_serialize_deserialize(self): SIGNAL = create_simple_signal({"test_data": EventData}) - event_data = {"test_data": EventData( - "foo", - "bar.course", - SubTestData0("a.sub.name", "a.nother.course"), - SubTestData1("b.uber.sub.name", "b.uber.another.course"), - )} + event_data = { + "test_data": EventData( + "foo", + "bar.course", + SubTestData0("a.sub.name", "a.nother.course"), + SubTestData1("b.uber.sub.name", "b.uber.another.course"), + ) + } serialized = serialize_event_data_to_bytes(event_data, SIGNAL) deserialized = deserialize_bytes_to_event_data(serialized, SIGNAL) self.assertIsInstance(deserialized["test_data"], EventData) @@ -468,7 +513,9 @@ def test_full_serialize_deserialize(self): def test_full_serialize_deserialize_with_optional_fields(self): SIGNAL = create_simple_signal({"test_data": NestedAttrsWithDefaults}) - event_data = {"test_data": NestedAttrsWithDefaults(field_0=SimpleAttrsWithDefaults())} + event_data = { + "test_data": NestedAttrsWithDefaults(field_0=SimpleAttrsWithDefaults()) + } serialized = serialize_event_data_to_bytes(event_data, SIGNAL) deserialized = deserialize_bytes_to_event_data(serialized, SIGNAL) self.assertIsInstance(deserialized["test_data"], NestedAttrsWithDefaults) diff --git a/openedx_events/event_bus/avro/types.py b/openedx_events/event_bus/avro/types.py index b757a899..8c2bb34b 100644 --- a/openedx_events/event_bus/avro/types.py +++ b/openedx_events/event_bus/avro/types.py @@ -1,12 +1,13 @@ """A mapping of python types to the Avro type that we want to use make valid avro schema.""" -SIMPLE_PYTHON_TYPE_TO_AVRO_MAPPING = { + +SIMPLE_PYTHON_TYPE_TO_AVRO_MAPPING: dict[type, str] = { bool: "boolean", int: "long", float: "double", bytes: "bytes", str: "string", } -PYTHON_TYPE_TO_AVRO_MAPPING = { +PYTHON_TYPE_TO_AVRO_MAPPING: dict[type | None, str] = { **SIMPLE_PYTHON_TYPE_TO_AVRO_MAPPING, None: "null", dict: "map", diff --git a/openedx_events/learning/data.py b/openedx_events/learning/data.py index d0e4712f..80118b52 100644 --- a/openedx_events/learning/data.py +++ b/openedx_events/learning/data.py @@ -75,8 +75,8 @@ class CourseData: course_key: CourseKey display_name: str = attrs.field(factory=str) - start: datetime = None # type: ignore[assignment] - end: datetime = None # type: ignore[assignment] + start: datetime | None = None + end: datetime | None = None @attrs.define(frozen=True) @@ -99,9 +99,9 @@ class CcxCourseData: master_course_key: CourseKey display_name: str = attrs.field(factory=str) coach_email: str = attrs.field(factory=str) - start: str = None # type: ignore[assignment] - end: str = None # type: ignore[assignment] - max_students_allowed: int = None # type: ignore[assignment] + start: str | None = None + end: str | None = None + max_students_allowed: int | None = None @attrs.define(frozen=True) @@ -126,7 +126,7 @@ class CourseEnrollmentData: mode: str is_active: bool creation_date: datetime - created_by: UserData = None # type: ignore[assignment] + created_by: UserData | None = None @attrs.define(frozen=True) @@ -197,10 +197,10 @@ class DiscussionTopicContext: """ title: str - usage_key: UsageKey = None # type: ignore[assignment] - group_id: int = None # type: ignore[assignment] - external_id: str = None # type: ignore[assignment] - ordering: int = None # type: ignore[assignment] + usage_key: UsageKey | None = None + group_id: int | None = None + external_id: str | None = None + ordering: int | None = None context: dict[str, str] = attrs.field(factory=dict) @@ -339,7 +339,7 @@ class ProgramCertificateData: uuid: str status: str url: str - certificate_available_date: datetime = None # type: ignore[assignment] + certificate_available_date: datetime | None = None @attrs.define(frozen=True) @@ -372,7 +372,7 @@ class ExamAttemptData: course_key: CourseKey usage_key: UsageKey exam_type: str - requesting_user: UserData = None # type: ignore[assignment] + requesting_user: UserData | None = None @attrs.define(frozen=True) @@ -431,16 +431,16 @@ class DiscussionThreadData: url: str user: UserData course_id: CourseKey - thread_type: str = None # type: ignore[assignment] - anonymous: bool = None # type: ignore[assignment] - anonymous_to_peers: bool = None # type: ignore[assignment] - title: str = None # type: ignore[assignment] - title_truncated: bool = None # type: ignore[assignment] - group_id: int = None # type: ignore[assignment] - team_id: int = None # type: ignore[assignment] - category_id: int = None # type: ignore[assignment] - category_name: str = None # type: ignore[assignment] - discussion: dict[str, str] = None # type: ignore[assignment] + thread_type: str | None = None + anonymous: bool | None = None + anonymous_to_peers: bool | None = None + title: str | None = None + title_truncated: bool | None = None + group_id: int | None = None + team_id: int | None = None + category_id: int | None = None + category_name: str | None = None + discussion: dict[str, str] | None = None user_course_roles: list[str] = attrs.field(factory=list) user_forums_roles: list[str] = attrs.field(factory=list) options: dict[str, bool] = attrs.field(factory=dict) @@ -589,9 +589,9 @@ class BadgeTemplateData: uuid: str origin: str - name: str = None # type: ignore[assignment] - description: str = None # type: ignore[assignment] - image_url: str = None # type: ignore[assignment] + name: str | None = None + description: str | None = None + image_url: str | None = None @attrs.define(frozen=True) @@ -626,8 +626,8 @@ class VerificationAttemptData: attempt_id: int user: UserData status: str - name: str = None # type: ignore[assignment] - expiration_date: datetime = None # type: ignore[assignment] + name: str | None = None + expiration_date: datetime | None = None @attrs.define(frozen=True) From 93e7cefd971f008eba6cc16e3f5e27763621e7dd Mon Sep 17 00:00:00 2001 From: Tycho Hob Date: Thu, 16 Apr 2026 13:06:27 -0400 Subject: [PATCH 09/12] style: Annotate tooling.py --- openedx_events/tooling.py | 85 +++++++++++++++++++++++++-------------- 1 file changed, 55 insertions(+), 30 deletions(-) diff --git a/openedx_events/tooling.py b/openedx_events/tooling.py index 9565b392..16440493 100644 --- a/openedx_events/tooling.py +++ b/openedx_events/tooling.py @@ -1,10 +1,13 @@ """ Tooling necessary to use Open edX events. """ + import pkgutil import warnings +from datetime import datetime from importlib import import_module from logging import getLogger +from typing import Any, Callable, ClassVar, Mapping from django.conf import settings from django.db import connection @@ -19,13 +22,13 @@ # If a signal is explicitly not for use with the event bus, add it to this list # and document why in the event's annotations -KNOWN_UNSERIALIZABLE_SIGNALS = [ +KNOWN_UNSERIALIZABLE_SIGNALS: list[str] = [ "org.openedx.content_authoring.course.certificate_config.changed.v1", "org.openedx.content_authoring.course.certificate_config.deleted.v1", "org.openedx.learning.external_grader.score.submitted.v1", ] -SIGNAL_PROCESSED_FROM_EVENT_BUS = "from_event_bus" +SIGNAL_PROCESSED_FROM_EVENT_BUS: str = "from_event_bus" class OpenEdxPublicSignal(Signal): @@ -33,10 +36,12 @@ class OpenEdxPublicSignal(Signal): Standardized Django Signals used to create Open edX events. """ - _mapping = {} - instances = [] + _mapping: ClassVar[dict[str, "OpenEdxPublicSignal"]] = {} + instances: ClassVar[list["OpenEdxPublicSignal"]] = [] - def __init__(self, event_type, data, minor_version=0): + def __init__( + self, event_type: str, data: Mapping[str, type], minor_version: int = 0 + ) -> None: """ Init method for OpenEdxPublicSignal definition class. @@ -54,21 +59,21 @@ def __init__(self, event_type, data, minor_version=0): self.__class__._mapping[self.event_type] = self super().__init__() - def __repr__(self): + def __repr__(self) -> str: """ Represent OpenEdxPublicSignal as a string. """ return "".format(event_type=self.event_type) @classmethod - def all_events(cls): + def all_events(cls) -> list["OpenEdxPublicSignal"]: """ Get all current events. """ return cls.instances @classmethod - def get_signal_by_type(cls, event_type): + def get_signal_by_type(cls, event_type: str) -> "OpenEdxPublicSignal": """ Get event identified by type. @@ -80,7 +85,7 @@ def get_signal_by_type(cls, event_type): """ return cls._mapping[event_type] - def generate_signal_metadata(self, time=None): + def generate_signal_metadata(self, time: datetime | None = None) -> EventsMetadata: """ Generate signal metadata when an event is sent. @@ -94,7 +99,7 @@ def generate_signal_metadata(self, time=None): Example usage: >>> metadata = \ STUDENT_REGISTRATION_COMPLETED.generate_signal_metadata() - attr.asdict(metadata) + attrs.asdict(metadata) >>> { 'event_type': '...learning.student.registration.completed.v1', 'minorversion': 0, @@ -111,7 +116,13 @@ def generate_signal_metadata(self, time=None): time=time, ) - def _send_event_with_metadata(self, metadata, send_robust=True, from_event_bus=False, **kwargs): + def _send_event_with_metadata( + self, + metadata: EventsMetadata, + send_robust: bool = True, + from_event_bus: bool = False, + **kwargs: Any, + ) -> list[tuple[Any, Any]]: """ Send events to all connected receivers with the provided metadata. @@ -128,7 +139,7 @@ def _send_event_with_metadata(self, metadata, send_robust=True, from_event_bus=F See ``send_event`` docstring for more details on its usage and behavior. """ - def validate_sender(): + def validate_sender() -> None: """ Run validations over the send arguments. @@ -175,7 +186,12 @@ def validate_sender(): return responses - def send_event(self, send_robust=True, time=None, **kwargs): + def send_event( + self, + send_robust: bool = True, + time: datetime | None = None, + **kwargs: Any, + ) -> list[tuple[Any, Any]]: """ Send events to all connected receivers. @@ -213,11 +229,18 @@ def send_event(self, send_robust=True, time=None, **kwargs): to this method and arguments used to initialize the event. """ metadata = self.generate_signal_metadata(time=time) - return self._send_event_with_metadata(metadata=metadata, send_robust=send_robust, **kwargs) + return self._send_event_with_metadata( + metadata=metadata, send_robust=send_robust, **kwargs + ) def send_event_with_custom_metadata( - self, metadata, /, *, send_robust=True, **kwargs - ): + self, + metadata: EventsMetadata, + /, + *, + send_robust: bool = True, + **kwargs: Any, + ) -> list[tuple[Any, Any]]: """ Send events to all connected receivers using the provided metadata. @@ -238,13 +261,15 @@ def send_event_with_custom_metadata( metadata=metadata, send_robust=send_robust, from_event_bus=True, **kwargs ) - def send(self, sender, **kwargs): # pylint: disable=unused-argument + def send(self, sender: Any, **kwargs: Any) -> None: # type: ignore[override] # pylint: disable=unused-argument """ Override method used to recommend the sender to adopt our custom send. """ warnings.warn("Please, use 'send_event' when triggering an Open edX event.") - def send_robust(self, sender, **kwargs): # pylint: disable=unused-argument + def send_robust( # type: ignore[override] # pylint: disable=unused-argument + self, sender: Any, **kwargs: Any + ) -> None: """ Override method used to recommend the sender to adopt our custom send. """ @@ -252,19 +277,19 @@ def send_robust(self, sender, **kwargs): # pylint: disable=unused-argument "Please, use 'send_event' with send_robust equals to True when triggering an Open edX event." ) - def enable(self): + def enable(self) -> None: """ Enable all events. Meaning, send_event will send a Django signal. """ self._allow_events = True - def disable(self): + def disable(self) -> None: """ Disable all events. Meaning, send_event will have no effect. """ self._allow_events = False - def allow_send_event_failure(self): + def allow_send_event_failure(self) -> None: """ Allow Django signal to fail. Meaning, uses send_robust instead of send. @@ -273,23 +298,23 @@ def allow_send_event_failure(self): self._allow_send_event_failure = True -def _process_all_signals_modules(func): +def _process_all_signals_modules(func: Callable[[str], Any]) -> None: """ Walk the package tree and apply func on all signals.py files. Arguments: func: A method that takes a module name as its parameter """ - root = import_module('openedx_events') - for m in pkgutil.walk_packages(root.__path__, root.__name__ + '.'): + root = import_module("openedx_events") + for m in pkgutil.walk_packages(root.__path__, root.__name__ + "."): module_name = m.name - if 'tests' in module_name.split('.') or '.test_' in module_name: + if "tests" in module_name.split(".") or ".test_" in module_name: continue - if module_name.endswith('.signals'): + if module_name.endswith(".signals"): func(module_name) -def load_all_signals(): +def load_all_signals() -> None: """ Ensure OpenEdxPublicSignal.all_events() cache is fully populated. @@ -298,7 +323,7 @@ def load_all_signals(): _process_all_signals_modules(import_module) -def _reconnect_to_db_if_needed(): # pragma: no cover +def _reconnect_to_db_if_needed() -> None: # pragma: no cover """ Reconnects the db connection if needed. @@ -313,7 +338,7 @@ def _reconnect_to_db_if_needed(): # pragma: no cover connection.connect() -def _clear_request_cache(): # pragma: no cover +def _clear_request_cache() -> None: # pragma: no cover """ Clear the RequestCache so that each event consumption starts fresh. @@ -323,7 +348,7 @@ def _clear_request_cache(): # pragma: no cover RequestCache.clear_all_namespaces() -def prepare_for_new_work_cycle(): # pragma: no cover +def prepare_for_new_work_cycle() -> None: # pragma: no cover """ Ensure that the application state is appropriate for performing a new unit of work. From 0dec41bcd0dadc82e3b4ba3ed298e9c5395e8d20 Mon Sep 17 00:00:00 2001 From: Tycho Hob Date: Thu, 16 Apr 2026 13:17:11 -0400 Subject: [PATCH 10/12] style: Fix annotation for SIMPLE_PYTHON_TYPE_TO_AVRO_MAPPING --- openedx_events/event_bus/avro/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openedx_events/event_bus/avro/types.py b/openedx_events/event_bus/avro/types.py index 8c2bb34b..78c81926 100644 --- a/openedx_events/event_bus/avro/types.py +++ b/openedx_events/event_bus/avro/types.py @@ -1,6 +1,6 @@ """A mapping of python types to the Avro type that we want to use make valid avro schema.""" -SIMPLE_PYTHON_TYPE_TO_AVRO_MAPPING: dict[type, str] = { +SIMPLE_PYTHON_TYPE_TO_AVRO_MAPPING: dict[type | None, str] = { bool: "boolean", int: "long", float: "double", From e4b961acc4d59bb87bf7a38243a7e64d85372f6e Mon Sep 17 00:00:00 2001 From: Tycho Hob Date: Thu, 16 Apr 2026 13:43:25 -0400 Subject: [PATCH 11/12] style: Update annotations for commands, apps, testing.py --- mypy.ini | 12 +++--- openedx_events/apps.py | 38 +++++++++++------ .../management/commands/consume_events.py | 29 ++++++------- .../commands/generate_avro_schemas.py | 41 +++++++++++-------- openedx_events/testing.py | 41 +++++++++++-------- 5 files changed, 96 insertions(+), 65 deletions(-) diff --git a/mypy.ini b/mypy.ini index c420c88f..3664421b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -79,22 +79,22 @@ disallow_untyped_defs = True disallow_untyped_defs = True [mypy-openedx_events.tooling] -disallow_untyped_defs = False +disallow_untyped_defs = True [mypy-openedx_events.event_bus] -disallow_untyped_defs = False +disallow_untyped_defs = True [mypy-openedx_events.apps] -disallow_untyped_defs = False +disallow_untyped_defs = True [mypy-openedx_events.testing] -disallow_untyped_defs = False +disallow_untyped_defs = True [mypy-openedx_events.management.commands.consume_events] -disallow_untyped_defs = False +disallow_untyped_defs = True [mypy-openedx_events.management.commands.generate_avro_schemas] -disallow_untyped_defs = False +disallow_untyped_defs = True [mypy-openedx_events.analytics.signals] disallow_untyped_defs = False diff --git a/openedx_events/apps.py b/openedx_events/apps.py index ea416eb0..9fffb32f 100644 --- a/openedx_events/apps.py +++ b/openedx_events/apps.py @@ -1,7 +1,9 @@ """ openedx_events Django application initialization. """ + import logging +from typing import Any from django.apps import AppConfig from django.conf import settings @@ -13,11 +15,15 @@ logger = logging.getLogger(__name__) -def general_signal_handler(sender, signal, **kwargs): # pylint: disable=unused-argument +def general_signal_handler( # pylint: disable=unused-argument + sender: Any, signal: OpenEdxPublicSignal, **kwargs: Any +) -> None: """ Signal handler for producing events to configured event bus. """ - event_type_producer_configs = getattr(settings, "EVENT_BUS_PRODUCER_CONFIG", {}).get(signal.event_type, {}) + event_type_producer_configs = getattr( + settings, "EVENT_BUS_PRODUCER_CONFIG", {} + ).get(signal.event_type, {}) # event_type_producer_configs should look something like # { # "topic_a": { "event_key_field": "my.key.field", "enabled": True }, @@ -50,7 +56,9 @@ class OpenedxEventsConfig(AppConfig): name = "openedx_events" - def _get_validated_signal_config(self, event_type, configuration): + def _get_validated_signal_config( + self, event_type: str, configuration: Any + ) -> OpenEdxPublicSignal: """ Validate signal configuration format. @@ -66,34 +74,38 @@ def _get_validated_signal_config(self, event_type, configuration): if not isinstance(configuration, dict): raise ProducerConfigurationError( event_type=event_type, - message="Configuration for event_types should be a dict" + message="Configuration for event_types should be a dict", ) try: signal = OpenEdxPublicSignal.get_signal_by_type(event_type) except KeyError as exc: - raise ProducerConfigurationError(message=f"No OpenEdxPublicSignal of type: '{event_type}'.") from exc + raise ProducerConfigurationError( + message=f"No OpenEdxPublicSignal of type: '{event_type}'." + ) from exc for _, topic_configuration in configuration.items(): if not isinstance(topic_configuration, dict): raise ProducerConfigurationError( event_type=event_type, - message="One of the configuration objects is not a dictionary" + message="One of the configuration objects is not a dictionary", ) expected_keys = {"event_key_field": str, "enabled": bool} for expected_key, expected_type in expected_keys.items(): if expected_key not in topic_configuration.keys(): raise ProducerConfigurationError( event_type=event_type, - message=f"One of the configuration object is missing '{expected_key}' key." + message=f"One of the configuration object is missing '{expected_key}' key.", ) if not isinstance(topic_configuration[expected_key], expected_type): raise ProducerConfigurationError( event_type=event_type, - message=(f"Expected type: {expected_type} for '{expected_key}', " - f"found: {type(topic_configuration[expected_key])}") + message=( + f"Expected type: {expected_type} for '{expected_key}', " + f"found: {type(topic_configuration[expected_key])}" + ), ) return signal - def ready(self): + def ready(self) -> None: """ Read `EVENT_BUS_PRODUCER_CONFIG` setting and connects appropriate handlers to the events based on it. @@ -115,8 +127,10 @@ def ready(self): signals_config = getattr(settings, "EVENT_BUS_PRODUCER_CONFIG", {}) if not isinstance(signals_config, dict): raise ProducerConfigurationError( - message=("Setting 'EVENT_BUS_PRODUCER_CONFIG' should be a dictionary with event_type as" - " key and list or tuple of config dictionaries as values") + message=( + "Setting 'EVENT_BUS_PRODUCER_CONFIG' should be a dictionary with event_type as" + " key and list or tuple of config dictionaries as values" + ) ) for event_type, configurations in signals_config.items(): signal = self._get_validated_signal_config(event_type, configurations) diff --git a/openedx_events/management/commands/consume_events.py b/openedx_events/management/commands/consume_events.py index c9511bc9..1fed47e6 100644 --- a/openedx_events/management/commands/consume_events.py +++ b/openedx_events/management/commands/consume_events.py @@ -1,8 +1,11 @@ """ Makes ``consume_events`` management command available. """ + import json import logging +from argparse import ArgumentParser +from typing import Any from django.core.management.base import BaseCommand @@ -32,41 +35,39 @@ class Command(BaseCommand): --extra '{"last_read_msg_id": "1679676448892-0"}' """ - def add_arguments(self, parser): + def add_arguments(self, parser: ArgumentParser) -> None: """ Add arguments for parsing topic, group, and extra args. """ parser.add_argument( - '-t', '--topic', + "-t", + "--topic", nargs=1, required=True, - help='Topic to consume (without environment prefix)' + help="Topic to consume (without environment prefix)", ) parser.add_argument( - '-g', '--group_id', - nargs=1, - required=True, - help='Consumer group id' + "-g", "--group_id", nargs=1, required=True, help="Consumer group id" ) parser.add_argument( - '--extra', - nargs='?', + "--extra", + nargs="?", type=str, required=False, - help='JSON object to pass additional arguments to the consumer.' + help="JSON object to pass additional arguments to the consumer.", ) - def handle(self, *args, **options): + def handle(self, *args: Any, **options: Any) -> None: """ Create consumer based on django settings and consume events. """ try: # load additional arguments specific for the underlying implementation of event_bus. - extra = json.loads(options.get('extra') or '{}') + extra = json.loads(options.get("extra") or "{}") load_all_signals() event_consumer = make_single_consumer( - topic=options['topic'][0], - group_id=options['group_id'][0], + topic=options["topic"][0], + group_id=options["group_id"][0], **extra, ) event_consumer.consume_indefinitely() diff --git a/openedx_events/management/commands/generate_avro_schemas.py b/openedx_events/management/commands/generate_avro_schemas.py index 181df33e..f3f6a776 100644 --- a/openedx_events/management/commands/generate_avro_schemas.py +++ b/openedx_events/management/commands/generate_avro_schemas.py @@ -1,10 +1,12 @@ """ Management command to generate and store Avro schemas for testing. """ + import json import logging import os from importlib import import_module +from typing import Any from django.core.management.base import BaseCommand @@ -40,52 +42,57 @@ class Command(BaseCommand): """ - def add_arguments(self, parser): + def add_arguments(self, parser: Any) -> None: """ Add arguments for either individual event types or all of them. """ parser.add_argument( - 'types', - nargs='*', + "types", + nargs="*", type=str, - help='Event types for which to write and save the schema, separated by a space' + help="Event types for which to write and save the schema, separated by a space", ) parser.add_argument( - '--all', - action="store_true", - help='Write schema for all event types' + "--all", action="store_true", help="Write schema for all event types" ) - def handle(self, *args, **options): + def handle(self, *args: Any, **options: Any) -> None: """ Create consumer based on django settings and consume events. """ load_all_signals() - if options['all']: + if options["all"]: signals = OpenEdxPublicSignal.all_events() else: - signals = [OpenEdxPublicSignal.get_signal_by_type(event_type) for event_type in options['types']] + signals = [ + OpenEdxPublicSignal.get_signal_by_type(event_type) + for event_type in options["types"] + ] for signal in signals: if signal.event_type in KNOWN_UNSERIALIZABLE_SIGNALS: - logger.info(f"Known unserializable signal: {signal.event_type}. Skipping.") + logger.info( + f"Known unserializable signal: {signal.event_type}. Skipping." + ) continue serializer = AvroSignalSerializer(signal) schema_dict = serializer.schema filename = f"{signal.event_type.replace('.', '+')}_schema.avsc" - root_path = import_module('openedx_events').__path__[0] + root_path = import_module("openedx_events").__path__[0] folder_path = f"{root_path}/event_bus/avro/tests/schemas" full_file_name = f"{folder_path}/{filename}" if os.path.exists(full_file_name): - confirmation = input(f"Warning: overwriting schema for {signal.event_type}. It is recommended to leave" - f" existing schemas unchanged. Are you sure you want to " - f"continue [y/n]? ") - if confirmation.lower().strip() != 'y': + confirmation = input( + f"Warning: overwriting schema for {signal.event_type}. It is recommended to leave" + f" existing schemas unchanged. Are you sure you want to " + f"continue [y/n]? " + ) + if confirmation.lower().strip() != "y": logger.info(f"Skipping generating schema for {signal.event_type}") continue if not os.path.exists(folder_path): os.makedirs(folder_path) logger.info(f"Writing {full_file_name}") - with open(full_file_name, 'w') as writes: + with open(full_file_name, "w") as writes: writes.write(json.dumps(schema_dict, indent=2)) diff --git a/openedx_events/testing.py b/openedx_events/testing.py index d4795c49..a0934d46 100644 --- a/openedx_events/testing.py +++ b/openedx_events/testing.py @@ -4,6 +4,8 @@ Provides mixins that help isolate Open edX event state between test classes. """ +from typing import ClassVar + from openedx_events.tooling import OpenEdxPublicSignal @@ -12,21 +14,24 @@ class FreezeSignalCacheMixin: A mixin to be used by TestCases to avoid new signals persisting in the OpenEdxPublicSignal cache of instances. """ + pre_run_instances: ClassVar[list[OpenEdxPublicSignal]] + pre_run_mapping: ClassVar[dict[str, OpenEdxPublicSignal]] + @classmethod - def setUpClass(cls): + def setUpClass(cls) -> None: """ Save current signal instances. """ - super().setUpClass() + super().setUpClass() # type: ignore[misc] cls.pre_run_instances = list(OpenEdxPublicSignal.instances) cls.pre_run_mapping = dict(OpenEdxPublicSignal._mapping) # pylint: disable=protected-access @classmethod - def tearDownClass(cls): + def tearDownClass(cls) -> None: """ Restore instance cache to pre-test state. """ - super().tearDownClass() + super().tearDownClass() # type: ignore[misc] OpenEdxPublicSignal.instances = cls.pre_run_instances OpenEdxPublicSignal._mapping = cls.pre_run_mapping # pylint: disable=protected-access @@ -37,7 +42,7 @@ class EventsIsolationMixin: """ @classmethod - def disable_all_events(cls): + def disable_all_events(cls) -> None: """ Disable all events Open edX Events from all subdomains. """ @@ -45,7 +50,7 @@ def disable_all_events(cls): event.disable() @classmethod - def enable_all_events(cls): + def enable_all_events(cls) -> None: """ Enable all events Open edX Events from all subdomains. """ @@ -53,7 +58,7 @@ def enable_all_events(cls): event.enable() @classmethod - def enable_events_by_type(cls, *event_types): + def enable_events_by_type(cls, *event_types: str) -> None: """ Enable specific Open edX Events given their type. @@ -64,7 +69,9 @@ def enable_events_by_type(cls, *event_types): try: event = OpenEdxPublicSignal.get_signal_by_type(event_type) except KeyError: - all_event_types = sorted(s.event_type for s in OpenEdxPublicSignal.all_events()) + all_event_types = sorted( + s.event_type for s in OpenEdxPublicSignal.all_events() + ) err_msg = ( "You tried to enable event '{}', but I don't recognize that " "signal type. Did you mean one of these?: {}" @@ -73,7 +80,7 @@ def enable_events_by_type(cls, *event_types): event.enable() @classmethod - def allow_send_events_failure(cls, *event_types): + def allow_send_events_failure(cls, *event_types: str) -> None: """ Allow that send_event method fails for the specified event. @@ -87,7 +94,9 @@ def allow_send_events_failure(cls, *event_types): try: event = OpenEdxPublicSignal.get_signal_by_type(event_type) except KeyError: - all_event_types = sorted(s.event_type for s in OpenEdxPublicSignal.all_events()) + all_event_types = sorted( + s.event_type for s in OpenEdxPublicSignal.all_events() + ) err_msg = ( "You tried to enable event '{}', but I don't recognize that " "signal type. Did you mean one of these?: {}" @@ -109,18 +118,18 @@ class MyTestCase(TestCase, OpenEdxEventsTestMixin): This class assumes that it's being used in conjunction with TestCase or TestCase subclasses. """ - ENABLED_OPENEDX_EVENTS = [] + ENABLED_OPENEDX_EVENTS: list[str] = [] @classmethod - def setUpClass(cls): + def setUpClass(cls) -> None: """ Start events isolation for class. """ - super().setUpClass() + super().setUpClass() # type: ignore[misc] cls().start_events_isolation() @classmethod - def tearDownClass(cls): + def tearDownClass(cls) -> None: """ Re-enable all events after class teardown. @@ -128,10 +137,10 @@ def tearDownClass(cls): process are not affected by this class's event isolation. """ cls().enable_all_events() - super().tearDownClass() + super().tearDownClass() # type: ignore[misc] @classmethod - def start_events_isolation(cls): + def start_events_isolation(cls) -> None: """ Start Open edX Events isolation and then enable events by type. """ From 11037903997dbeacc91953d1801d2ddba57fe41f Mon Sep 17 00:00:00 2001 From: Tycho Hob Date: Fri, 17 Apr 2026 16:15:41 -0400 Subject: [PATCH 12/12] style: Update annotations for tests Note: A lot of these test functions take a large quantity of different input types, so there is a lot of Any usage here. Some of these should probably be broken up, but I'm trying to keep these changes scoped. --- openedx_events/event_bus/__init__.py | 13 +- .../event_bus/avro/tests/test_avro.py | 7 +- .../event_bus/avro/tests/test_deserializer.py | 118 +++++---- .../event_bus/avro/tests/test_serializer.py | 154 +++++++----- .../event_bus/avro/tests/test_utilities.py | 64 +++-- openedx_events/event_bus/tests/test_loader.py | 223 ++++++++++-------- .../tests/test_generate_avro_schemas.py | 95 +++++--- openedx_events/tests/test_producer_config.py | 69 ++++-- openedx_events/tests/test_tooling.py | 86 +++++-- 9 files changed, 524 insertions(+), 305 deletions(-) diff --git a/openedx_events/event_bus/__init__.py b/openedx_events/event_bus/__init__.py index 71a248e5..9d8ea2d9 100644 --- a/openedx_events/event_bus/__init__.py +++ b/openedx_events/event_bus/__init__.py @@ -18,7 +18,7 @@ import warnings from abc import ABC, abstractmethod from functools import lru_cache -from typing import TypeVar +from typing import Any, TypeVar from django.conf import settings from django.dispatch import receiver @@ -187,7 +187,9 @@ def consume_indefinitely(self) -> None: # an instance of EventBusConsumer, calls to the consumer will be ignored with a warning at startup. -def make_single_consumer(*, topic: str, group_id: str, **kwargs) -> EventBusConsumer: +def make_single_consumer( + *, topic: str, group_id: str, **kwargs: Any +) -> EventBusConsumer: """ Construct a consumer for a given topic, group, and signal. @@ -212,7 +214,7 @@ def make_single_consumer(*, topic: str, group_id: str, **kwargs) -> EventBusCons @receiver(setting_changed) -def _reset_state(sender, **kwargs): # pylint: disable=unused-argument +def _reset_state(sender: Any, **kwargs: Any) -> None: # pylint: disable=unused-argument """Reset caches when settings change during unit tests.""" get_producer.cache_clear() @@ -227,7 +229,10 @@ def _reset_state(sender, **kwargs): # pylint: disable=unused-argument # EVENT_BUS_TOPIC_PREFIX setting. See 0012-producing-to-event-bus-via-settings for more details. -def merge_producer_configs(producer_config_original, producer_config_overrides): +def merge_producer_configs( + producer_config_original: dict[str, Any], + producer_config_overrides: dict[str, Any], +) -> dict[str, Any]: """ Merge two EVENT_BUS_PRODUCER_CONFIG maps. diff --git a/openedx_events/event_bus/avro/tests/test_avro.py b/openedx_events/event_bus/avro/tests/test_avro.py index 2ca6d2c3..59447dd3 100644 --- a/openedx_events/event_bus/avro/tests/test_avro.py +++ b/openedx_events/event_bus/avro/tests/test_avro.py @@ -11,6 +11,7 @@ from fastavro import schemaless_reader, schemaless_writer from fastavro.repository.base import SchemaRepositoryError from fastavro.schema import load_schema +from fastavro.types import Schema from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locator import ( LibraryCollectionLocator, @@ -34,7 +35,9 @@ from openedx_events.tooling import KNOWN_UNSERIALIZABLE_SIGNALS, OpenEdxPublicSignal, load_all_signals -def generate_test_data_for_schema(schema: dict[str, Any]) -> dict: # pragma: no cover +def generate_test_data_for_schema( + schema: Schema, +) -> Any | None: # pragma: no cover """ Generates test data dict for the given schema. @@ -192,7 +195,7 @@ def process_type(type_spec: Any) -> Any: return process_schema(schema) -def generate_test_event_data_for_data_type(data_type: Any) -> dict: # pragma: no cover +def generate_test_event_data_for_data_type(data_type: Any) -> Any: # pragma: no cover """ Generates test data for use in the event bus test cases. diff --git a/openedx_events/event_bus/avro/tests/test_deserializer.py b/openedx_events/event_bus/avro/tests/test_deserializer.py index 35ccc9ca..66d66a99 100644 --- a/openedx_events/event_bus/avro/tests/test_deserializer.py +++ b/openedx_events/event_bus/avro/tests/test_deserializer.py @@ -1,4 +1,5 @@ """Tests for avro.deserializer""" + import json from datetime import datetime from typing import Dict, List @@ -74,8 +75,14 @@ def setUp(self) -> None: "name": "ComplexAttrs", "type": "record", "fields": [ - {"name": "list_field", "type": {"type": "array", "items": "long"}}, - {"name": "dict_field", "type": {"type": "map", "values": "long"}}, + { + "name": "list_field", + "type": {"type": "array", "items": "long"}, + }, + { + "name": "dict_field", + "type": {"type": "map", "values": "long"}, + }, ], }, }, @@ -104,23 +111,44 @@ def setUp(self) -> None: "name": "SimpleAttrs", "type": "record", "fields": [ - {"name": "boolean_field", "type": "boolean"}, + { + "name": "boolean_field", + "type": "boolean", + }, {"name": "int_field", "type": "long"}, - {"name": "float_field", "type": "double"}, - {"name": "bytes_field", "type": "bytes"}, - {"name": "string_field", "type": "string"}, + { + "name": "float_field", + "type": "double", + }, + { + "name": "bytes_field", + "type": "bytes", + }, + { + "name": "string_field", + "type": "string", + }, ], }, }, }, - {"name": "dict_of_attr_field", "type": {"type": "map", "values": "SimpleAttrs"}}, + { + "name": "dict_of_attr_field", + "type": {"type": "map", "values": "SimpleAttrs"}, + }, { "name": "list_of_dict_field", - "type": {"type": "array", "items": {"type": "map", "values": "long"}}, + "type": { + "type": "array", + "items": {"type": "map", "values": "long"}, + }, }, { "name": "dict_of_list_field", - "type": {"type": "map", "values": {"type": "array", "items": "long"}}, + "type": { + "type": "map", + "values": {"type": "array", "items": "long"}, + }, }, ], }, @@ -134,9 +162,7 @@ def test_schema_string(self, data_cls, expected_schema): """ Test JSON round-trip; schema creation is tested more fully in test_schema.py. """ - SIGNAL = create_simple_signal({ - "data": data_cls - }) + SIGNAL = create_simple_signal({"data": data_cls}) actual_schema = json.loads(AvroSignalDeserializer(SIGNAL).schema_string()) @@ -148,7 +174,10 @@ def test_convert_dict_to_event_data(self): "test_data": { "course_id": "bar.course", "sub_name": "foo", - "sub_test_0": {"course_id": "a.nother.course", "sub_name": "a.sub.name"}, + "sub_test_0": { + "course_id": "a.nother.course", + "sub_name": "a.sub.name", + }, "sub_test_1": { "course_id": "b.uber.another.course", "sub_name": "b.uber.sub.name", @@ -157,10 +186,12 @@ def test_convert_dict_to_event_data(self): } deserializer = AvroSignalDeserializer(SIGNAL) event_data = deserializer.from_dict(initial_dict) - expected_event_data = EventData("foo", "bar.course", - SubTestData0("a.sub.name", "a.nother.course"), - SubTestData1("b.uber.sub.name", "b.uber.another.course") - ) + expected_event_data = EventData( + "foo", + "bar.course", + SubTestData0("a.sub.name", "a.nother.course"), + SubTestData1("b.uber.sub.name", "b.uber.another.course"), + ) test_data = event_data["test_data"] self.assertIsInstance(test_data, EventData) self.assertEqual(test_data, expected_event_data) @@ -219,7 +250,9 @@ def test_default_libraryusagelocatorv2_deserialization(self): """ SIGNAL = create_simple_signal({"usage_key": LibraryUsageLocatorV2}) deserializer = AvroSignalDeserializer(SIGNAL) - usage_key = LibraryUsageLocatorV2.from_string("lb:MITx:reallyhardproblems:problem:problem1") + usage_key = LibraryUsageLocatorV2.from_string( + "lb:MITx:reallyhardproblems:problem:problem1" + ) as_dict = {"usage_key": str(usage_key)} event_data = deserializer.from_dict(as_dict) usage_key_deserialized = event_data["usage_key"] @@ -229,9 +262,7 @@ def test_default_libraryusagelocatorv2_deserialization(self): def test_deserialization_with_custom_serializer(self): SIGNAL = create_simple_signal({"test_data": NonAttrs}) deserializer = SpecialDeserializer(SIGNAL) - as_dict = { - "test_data": "a.val:a.nother.val" - } + as_dict = {"test_data": "a.val:a.nother.val"} event_data = deserializer.from_dict(as_dict) non_attrs_deserialized = event_data["test_data"] self.assertIsInstance(non_attrs_deserialized, NonAttrs) @@ -239,16 +270,16 @@ def test_deserialization_with_custom_serializer(self): def test_deserialization_with_custom_serializer_on_nested_fields(self): SIGNAL = create_simple_signal({"test_data": NestedNonAttrs}) - as_dict = {"test_data": { - "field_0": "a.val:a.nother.val" - }} + as_dict = {"test_data": {"field_0": "a.val:a.nother.val"}} deserializer = SpecialDeserializer(SIGNAL) event_data = deserializer.from_dict(as_dict) deserialized_nested = event_data["test_data"] self.assertIsInstance(deserialized_nested, NestedNonAttrs) inner_deserialized_non_attrs = deserialized_nested.field_0 self.assertIsInstance(inner_deserialized_non_attrs, NonAttrs) - self.assertEqual(inner_deserialized_non_attrs, NonAttrs("a.val", "a.nother.val")) + self.assertEqual( + inner_deserialized_non_attrs, NonAttrs("a.val", "a.nother.val") + ) def test_deserialization_fails_if_missing_fields(self): # missing "sub_name" field @@ -264,22 +295,18 @@ def test_deserialization_fails_if_missing_fields(self): _ = deserializer.from_dict(initial_dict) def test_deserialization_of_optional_fields(self): - SIGNAL = create_simple_signal({ - "data": SimpleAttrsWithDefaults - }) + SIGNAL = create_simple_signal({"data": SimpleAttrsWithDefaults}) deserializer = AvroSignalDeserializer(SIGNAL) - as_dict = {"data": {}} + as_dict: dict[str, dict] = {"data": {}} data_dict = deserializer.from_dict(as_dict) self.assertIsInstance(data_dict["data"], SimpleAttrsWithDefaults) self.assertEqual(data_dict["data"], SimpleAttrsWithDefaults()) def test_deserialization_of_nested_optional_fields(self): - SIGNAL = create_simple_signal({ - "data": NestedAttrsWithDefaults - }) + SIGNAL = create_simple_signal({"data": NestedAttrsWithDefaults}) deserializer = AvroSignalDeserializer(SIGNAL) - as_dict = {"data": {"field_0": {}}} + as_dict: dict[str, dict] = {"data": {"field_0": {}}} data_dict = deserializer.from_dict(as_dict) nested_field = data_dict["data"].field_0 self.assertIsInstance(nested_field, SimpleAttrsWithDefaults) @@ -452,7 +479,9 @@ def test_deserialization_of_dict_without_annotation(self): def test_deserialization_of_dicts_with_keys_of_complex_types_fails(self): SIGNAL = create_simple_signal({"dict_input": Dict[CourseKey, int]}) deserializer = AvroSignalDeserializer(SIGNAL) - initial_dict = {"dict_input": {CourseKey.from_string("course-v1:edX+DemoX.1+2014"): 1}} + initial_dict = { + "dict_input": {CourseKey.from_string("course-v1:edX+DemoX.1+2014"): 1} + } with self.assertRaises(TypeError): deserializer.from_dict(initial_dict) @@ -463,13 +492,16 @@ def test_deserialization_of_unsupported_data_type(self): Create a dummy signal with a custom class that isn't in the deserializers dictionary and doesn't have __attrs_attrs__ to test the final TypeError case. """ + # Create a custom class that isn't in the deserializers and doesn't have __attrs_attrs__ class CustomUnsupportedType: pass # Create a signal with a valid type first to avoid schema validation errors VALID_SIGNAL = create_simple_signal({"list_input": List[int]}) - INVALID_SIGNAL = create_simple_signal({"list_input": List[CustomUnsupportedType]}) + INVALID_SIGNAL = create_simple_signal( + {"list_input": List[CustomUnsupportedType]} + ) initial_dict = {"list_input": [1, 2, 3]} deserializer = AvroSignalDeserializer(VALID_SIGNAL) # Update signal with invalid type @@ -488,13 +520,15 @@ def test_deserialize_bytes_to_event_data(self): Test deserialize_bytes_to_event_data utility function. """ SIGNAL = create_simple_signal({"test_data": EventData}) - bytes_data = b'\x06foo\x14bar.course\x14a.sub.name\x1ea.nother.course\x1eb.uber.sub.name*b.uber.another.course' - expected = {"test_data": EventData( - "foo", - "bar.course", - SubTestData0("a.sub.name", "a.nother.course"), - SubTestData1("b.uber.sub.name", "b.uber.another.course"), - )} + bytes_data = b"\x06foo\x14bar.course\x14a.sub.name\x1ea.nother.course\x1eb.uber.sub.name*b.uber.another.course" + expected = { + "test_data": EventData( + "foo", + "bar.course", + SubTestData0("a.sub.name", "a.nother.course"), + SubTestData1("b.uber.sub.name", "b.uber.another.course"), + ) + } deserialized = deserialize_bytes_to_event_data(bytes_data, SIGNAL) self.assertIsInstance(deserialized["test_data"], EventData) self.assertEqual(deserialized, expected) diff --git a/openedx_events/event_bus/avro/tests/test_serializer.py b/openedx_events/event_bus/avro/tests/test_serializer.py index f7edbe32..8d53db38 100644 --- a/openedx_events/event_bus/avro/tests/test_serializer.py +++ b/openedx_events/event_bus/avro/tests/test_serializer.py @@ -2,6 +2,7 @@ import json from datetime import datetime +from typing import Any import pytest from django.test import TestCase @@ -33,31 +34,29 @@ def test_schema_string(self): """ Test JSON round-trip; schema creation is tested more fully in test_schema.py. """ - SIGNAL = create_simple_signal({ - "data": SimpleAttrs - }) + SIGNAL = create_simple_signal({"data": SimpleAttrs}) actual_schema = json.loads(AvroSignalSerializer(SIGNAL).schema_string()) expected_schema = { - 'name': 'CloudEvent', - 'type': 'record', - 'doc': 'Avro Event Format for CloudEvents created with openedx_events/schema', - 'namespace': 'simple.signal', - 'fields': [ + "name": "CloudEvent", + "type": "record", + "doc": "Avro Event Format for CloudEvents created with openedx_events/schema", + "namespace": "simple.signal", + "fields": [ { - 'name': 'data', - 'type': { - 'name': 'SimpleAttrs', - 'type': 'record', - 'fields': [ - {'name': 'boolean_field', 'type': 'boolean'}, - {'name': 'int_field', 'type': 'long'}, - {'name': 'float_field', 'type': 'double'}, - {'name': 'bytes_field', 'type': 'bytes'}, - {'name': 'string_field', 'type': 'string'}, - ] - } + "name": "data", + "type": { + "name": "SimpleAttrs", + "type": "record", + "fields": [ + {"name": "boolean_field", "type": "boolean"}, + {"name": "int_field", "type": "long"}, + {"name": "float_field", "type": "double"}, + {"name": "bytes_field", "type": "bytes"}, + {"name": "string_field", "type": "string"}, + ], + }, } - ] + ], } assert actual_schema == expected_schema @@ -82,7 +81,10 @@ def test_convert_event_data_to_dict(self): "test_data": { "course_id": "bar.course", "sub_name": "foo", - "sub_test_0": {"course_id": "a.nother.course", "sub_name": "a.sub.name"}, + "sub_test_0": { + "course_id": "a.nother.course", + "sub_name": "a.sub.name", + }, "sub_test_1": { "course_id": "b.uber.another.course", "sub_name": "b.uber.sub.name", @@ -137,7 +139,9 @@ def test_default_libraryusagelocatorv2_serialization(self): """ SIGNAL = create_simple_signal({"usage_key": LibraryUsageLocatorV2}) serializer = AvroSignalSerializer(SIGNAL) - usage_key = LibraryUsageLocatorV2.from_string("lb:MITx:reallyhardproblems:problem:problem1") + usage_key = LibraryUsageLocatorV2.from_string( + "lb:MITx:reallyhardproblems:problem:problem1" + ) test_data = {"usage_key": usage_key} data_dict = serializer.to_dict(test_data) self.assertDictEqual(data_dict, {"usage_key": str(usage_key)}) @@ -146,9 +150,7 @@ def test_serialization_with_custom_serializer(self): SIGNAL = create_simple_signal({"test_data": NonAttrs}) serializer = SpecialSerializer(SIGNAL) - test_data = { - "test_data": NonAttrs("a.val", "a.nother.val") - } + test_data = {"test_data": NonAttrs("a.val", "a.nother.val")} data_dict = serializer.to_dict(test_data) self.assertDictEqual(data_dict, {"test_data": "a.val:a.nother.val"}) @@ -158,70 +160,98 @@ def test_serialization_with_custom_serializer_on_nested_fields(self): "test_data": NestedNonAttrs(field_0=NonAttrs("a.val", "a.nother.val")) } serializer = SpecialSerializer(SIGNAL) - test_data = {"test_data": NestedNonAttrs(field_0=NonAttrs("a.val", "a.nother.val"))} + test_data = { + "test_data": NestedNonAttrs(field_0=NonAttrs("a.val", "a.nother.val")) + } data_dict = serializer.to_dict(test_data) - self.assertDictEqual(data_dict, {"test_data": { - "field_0": "a.val:a.nother.val" - }}) + self.assertDictEqual( + data_dict, {"test_data": {"field_0": "a.val:a.nother.val"}} + ) def test_serialization_of_optional_simple_fields(self): - SIGNAL = create_simple_signal({ - "data": SimpleAttrsWithDefaults - }) + SIGNAL = create_simple_signal({"data": SimpleAttrsWithDefaults}) serializer = AvroSignalSerializer(SIGNAL) event_data = {"data": SimpleAttrsWithDefaults()} data_dict = serializer.to_dict(event_data) - self.assertDictEqual(data_dict, {"data": {'boolean_field': None, - 'bytes_field': None, - 'float_field': None, - 'int_field': None, - 'string_field': None, - 'attrs_field': None}}) + self.assertDictEqual( + data_dict, + { + "data": { + "boolean_field": None, + "bytes_field": None, + "float_field": None, + "int_field": None, + "string_field": None, + "attrs_field": None, + } + }, + ) def test_serialization_of_optional_custom_fields(self): SIGNAL = create_simple_signal({"data": CustomAttrsWithDefaults}) serializer = AvroSignalSerializer(SIGNAL) - event_data = {"data": CustomAttrsWithDefaults(coursekey_field=None, datetime_field=None)} + event_data = { + "data": CustomAttrsWithDefaults(coursekey_field=None, datetime_field=None) # type: ignore[arg-type] + } data_dict = serializer.to_dict(event_data) - self.assertDictEqual(data_dict, {"data": {'coursekey_field': None, - 'datetime_field': None}}) + self.assertDictEqual( + data_dict, {"data": {"coursekey_field": None, "datetime_field": None}} + ) def test_serialization_of_none_mandatory_custom_fields(self): """Check that None isn't accepted if field not optional.""" SIGNAL = create_simple_signal({"data": CustomAttrsWithoutDefaults}) serializer = AvroSignalSerializer(SIGNAL) - event_data = {"data": CustomAttrsWithoutDefaults(coursekey_field=None, datetime_field=None)} + event_data: Any = { + "data": CustomAttrsWithoutDefaults( + coursekey_field=None, # type: ignore[arg-type] + datetime_field=None, # type: ignore[arg-type] + ) + } with pytest.raises(Exception) as excinfo: serializer.to_dict(event_data) - assert excinfo.value.args[0] == "None cannot be handled by custom serializers (and default=None was not set)" + assert ( + excinfo.value.args[0] + == "None cannot be handled by custom serializers (and default=None was not set)" + ) def test_serialization_of_nested_optional_fields(self): - SIGNAL = create_simple_signal({ - "data": NestedAttrsWithDefaults - }) + SIGNAL = create_simple_signal({"data": NestedAttrsWithDefaults}) serializer = AvroSignalSerializer(SIGNAL) - event_data = {"data": NestedAttrsWithDefaults(field_0=SimpleAttrsWithDefaults())} + event_data = { + "data": NestedAttrsWithDefaults(field_0=SimpleAttrsWithDefaults()) + } data_dict = serializer.to_dict(event_data) - self.assertDictEqual(data_dict, {"data": {"field_0": {'boolean_field': None, - 'bytes_field': None, - 'float_field': None, - 'int_field': None, - 'string_field': None, - 'attrs_field': None - }}}) + self.assertDictEqual( + data_dict, + { + "data": { + "field_0": { + "boolean_field": None, + "bytes_field": None, + "float_field": None, + "int_field": None, + "string_field": None, + "attrs_field": None, + } + } + }, + ) def test_serialize_event_data_to_bytes(self): """ Test serialize_event_data_to_bytes utility function. """ SIGNAL = create_simple_signal({"test_data": EventData}) - event_data = {"test_data": EventData( - "foo", - "bar.course", - SubTestData0("a.sub.name", "a.nother.course"), - SubTestData1("b.uber.sub.name", "b.uber.another.course"), - )} + event_data = { + "test_data": EventData( + "foo", + "bar.course", + SubTestData0("a.sub.name", "a.nother.course"), + SubTestData1("b.uber.sub.name", "b.uber.another.course"), + ) + } serialized = serialize_event_data_to_bytes(event_data, SIGNAL) - expected = b'\x06foo\x14bar.course\x14a.sub.name\x1ea.nother.course\x1eb.uber.sub.name*b.uber.another.course' + expected = b"\x06foo\x14bar.course\x14a.sub.name\x1ea.nother.course\x1eb.uber.sub.name*b.uber.another.course" self.assertEqual(serialized, expected) diff --git a/openedx_events/event_bus/avro/tests/test_utilities.py b/openedx_events/event_bus/avro/tests/test_utilities.py index 2319aae8..60ca2bf7 100644 --- a/openedx_events/event_bus/avro/tests/test_utilities.py +++ b/openedx_events/event_bus/avro/tests/test_utilities.py @@ -1,10 +1,13 @@ """ Utility methods and classes for testing various modules in event_bus.avro. """ + import re from datetime import datetime +from typing import ClassVar import attr +import attrs from opaque_keys.edx.keys import CourseKey from openedx_events.event_bus.avro.custom_serializers import BaseCustomTypeAvroSerializer @@ -23,8 +26,7 @@ def create_simple_signal(data_dict, event_type="simple.signal"): event_type: A custom event type string. Defaults to 'simple.signal' """ return OpenEdxPublicSignal( # pylint: disable=missing-or-incorrect-annotation - event_type=event_type, - data=data_dict + event_type=event_type, data=data_dict ) @@ -32,6 +34,7 @@ def create_simple_signal(data_dict, event_type="simple.signal"): @attr.s(auto_attribs=True) class SimpleAttrs: """Class with all primitive type fields""" + boolean_field: bool int_field: int float_field: float @@ -42,6 +45,7 @@ class SimpleAttrs: @attr.s(auto_attribs=True) class ComplexAttrs: """Class with all complex type fields""" + list_field: list[int] dict_field: dict[str, int] @@ -49,6 +53,7 @@ class ComplexAttrs: @attr.s(auto_attribs=True) class NestedComplexAttrs: """Class with nested complex type fields""" + list_of_attr_field: list[SimpleAttrs] dict_of_attr_field: dict[str, SimpleAttrs] list_of_dict_field: list[dict[str, int]] @@ -58,6 +63,7 @@ class NestedComplexAttrs: @attr.s(auto_attribs=True) class SubTestData0: """Subclass for testing nested attrs""" + sub_name: str course_id: str @@ -65,6 +71,7 @@ class SubTestData0: @attr.s(auto_attribs=True) class SubTestData1: """Subclass for testing nested attrs""" + sub_name: str course_id: str @@ -72,45 +79,51 @@ class SubTestData1: @attr.s(auto_attribs=True) class EventData: """More complex class for testing nested attrs""" + sub_name: str course_id: str sub_test_0: SubTestData0 sub_test_1: SubTestData1 -@attr.s(frozen=True) +@attrs.define(frozen=True) class SimpleAttrsWithDefaults: """Test attrs with nullable values""" - boolean_field = attr.ib(type=bool, default=None) - int_field = attr.ib(type=int, default=None) - float_field = attr.ib(type=float, default=None) - bytes_field = attr.ib(type=bytes, default=None) - string_field = attr.ib(type=str, default=None) - attrs_field = attr.ib(type=SimpleAttrs, default=None) + + boolean_field: bool = None # type: ignore[assignment] + int_field: int = None # type: ignore[assignment] + float_field: float = None # type: ignore[assignment] + bytes_field: bytes = None # type: ignore[assignment] + string_field: str = None # type: ignore[assignment] + attrs_field: SimpleAttrs = None # type: ignore[assignment] -@attr.s(frozen=True) +@attrs.define(frozen=True) class CustomAttrsWithDefaults: """Test attrs with nullable values""" - coursekey_field = attr.ib(type=CourseKey, default=None) - datetime_field = attr.ib(type=datetime, default=None) + coursekey_field: CourseKey = None # type: ignore[assignment] + datetime_field: datetime = None # type: ignore[assignment] -@attr.s(frozen=True) + +@attrs.define(frozen=True) class CustomAttrsWithoutDefaults: """Test attrs without nullable values""" - coursekey_field = attr.ib(type=CourseKey) - datetime_field = attr.ib(type=datetime) + + coursekey_field: CourseKey + datetime_field: datetime -@attr.s(frozen=True) +@attrs.define(frozen=True) class NestedAttrsWithDefaults: """Test attrs with nullable values""" - field_0 = attr.ib(type=SimpleAttrsWithDefaults) + + field_0: SimpleAttrsWithDefaults class NonAttrs: """Test data class not decorated with @attr.""" + def __init__(self, val0, val1): self.val0 = val0 self.val1 = val1 @@ -120,33 +133,38 @@ def __eq__(self, other): return self.val0 == other.val0 and self.val1 == other.val1 -@attr.s(frozen=True) +@attrs.define(frozen=True) class NestedNonAttrs: """Test attrs with nullable values""" - field_0 = attr.ib(type=NonAttrs) + + field_0: NonAttrs class NonAttrsAvroSerializer(BaseCustomTypeAvroSerializer): """Custom serializer for Non-Attrs class""" - cls = NonAttrs - field_type = PYTHON_TYPE_TO_AVRO_MAPPING[str] + cls: ClassVar[type] = NonAttrs + field_type: ClassVar[str] = PYTHON_TYPE_TO_AVRO_MAPPING[str] @staticmethod - def serialize(obj) -> str: + def serialize(obj: NonAttrs) -> str: return f"{obj.val0}:{obj.val1}" @staticmethod - def deserialize(data: str) -> object: + def deserialize(data: str) -> NonAttrs: bits = re.split(":", data) return NonAttrs(bits[0], bits[1]) class SpecialSerializer(AvroSignalSerializer): + """AvroSignalSerializer with NonAttrs support.""" + def custom_type_serializers(self): return [NonAttrsAvroSerializer] class SpecialDeserializer(AvroSignalDeserializer): + """AvroSignalDeserializer with NonAttrs support.""" + def custom_type_serializers(self): return [NonAttrsAvroSerializer] diff --git a/openedx_events/event_bus/tests/test_loader.py b/openedx_events/event_bus/tests/test_loader.py index e1f5892d..ff39b4ba 100644 --- a/openedx_events/event_bus/tests/test_loader.py +++ b/openedx_events/event_bus/tests/test_loader.py @@ -7,6 +7,7 @@ import sys import warnings from contextlib import contextmanager +from typing import Any from unittest import TestCase import pytest @@ -20,101 +21,114 @@ @contextmanager def assert_warnings(warning_messages: list): with warnings.catch_warnings(record=True) as caught_warnings: - warnings.simplefilter('always') + warnings.simplefilter("always") yield assert [str(w.message) for w in caught_warnings] == warning_messages class TestLoader(TestCase): - # No, the "constructors" here don't make much sense, but I didn't # want to create a bunch of test classes/factory functions, so I'm # using built-in functions instead. def test_unconfigured(self): - with assert_warnings(["Event Bus setting DOES_NOT_EXIST is missing; component will be inactive"]): + with assert_warnings( + ["Event Bus setting DOES_NOT_EXIST is missing; component will be inactive"] + ): loaded = _try_load( - setting_name="DOES_NOT_EXIST", args=(1), kwargs={'2': 3}, - expected_class=dict, default={'def': 'ault'}, + setting_name="DOES_NOT_EXIST", + args=(1,), + kwargs={"2": 3}, + expected_class=dict, + default={"def": "ault"}, ) - assert loaded == {'def': 'ault'} + assert loaded == {"def": "ault"} - @override_settings(EB_LOAD_PATH='builtins.dict') + @override_settings(EB_LOAD_PATH="builtins.dict") def test_success(self): with assert_warnings([]): loaded = _try_load( - setting_name="EB_LOAD_PATH", args=(), kwargs={'2': 3}, - expected_class=dict, default={'def': 'ault'}, + setting_name="EB_LOAD_PATH", + args=(), + kwargs={"2": 3}, + expected_class=dict, + default={"def": "ault"}, ) - assert loaded == {'2': 3} + assert loaded == {"2": 3} - @override_settings(EB_LOAD_PATH='builtins.list') + @override_settings(EB_LOAD_PATH="builtins.list") def test_wrong_type(self): - with assert_warnings([ + with assert_warnings( + [ "builtins.list from EB_LOAD_PATH returned unexpected type ; " "component will be inactive" - ]): + ] + ): loaded = _try_load( - setting_name="EB_LOAD_PATH", args=([1, 2, 3],), kwargs={}, - expected_class=dict, default={'def': 'ault'}, + setting_name="EB_LOAD_PATH", + args=([1, 2, 3],), + kwargs={}, + expected_class=dict, + default={"def": "ault"}, ) - assert loaded == {'def': 'ault'} + assert loaded == {"def": "ault"} - @override_settings(EB_LOAD_PATH='no_module_here.foo.nope') + @override_settings(EB_LOAD_PATH="no_module_here.foo.nope") def test_missing_module(self): - with assert_warnings([ + with assert_warnings( + [ "Failed to load from setting EB_LOAD_PATH: " "ModuleNotFoundError(\"No module named 'no_module_here'\"); " "component will be inactive" - ]): + ] + ): loaded = _try_load( - setting_name="EB_LOAD_PATH", args=(1), kwargs={'2': 3}, - expected_class=dict, default={'def': 'ault'}, + setting_name="EB_LOAD_PATH", + args=(1,), + kwargs={"2": 3}, + expected_class=dict, + default={"def": "ault"}, ) - assert loaded == {'def': 'ault'} + assert loaded == {"def": "ault"} - @override_settings(EB_LOAD_PATH='builtins.does_not_exist') + @override_settings(EB_LOAD_PATH="builtins.does_not_exist") def test_missing_attribute(self): - with assert_warnings([ - "Failed to load from setting EB_LOAD_PATH: " - "ImportError('Module \"builtins\" does not define a \"does_not_exist\" attribute/class'); " - "component will be inactive" - ]): - loaded = _try_load( - setting_name="EB_LOAD_PATH", args=(1), kwargs={'2': 3}, - expected_class=dict, default={'def': 'ault'}, - ) - assert loaded == {'def': 'ault'} - - @override_settings(EB_LOAD_PATH='builtins.dict') - def test_bad_args_for_callable(self): - with assert_warnings([ + with assert_warnings( + [ "Failed to load from setting EB_LOAD_PATH: " - "TypeError('type object argument after * must be an iterable, not int'); " + 'ImportError(\'Module "builtins" does not define a "does_not_exist" attribute/class\'); ' "component will be inactive" - ]): + ] + ): loaded = _try_load( - setting_name="EB_LOAD_PATH", args=(1), kwargs={'2': 3}, - expected_class=dict, default={'def': 'ault'}, + setting_name="EB_LOAD_PATH", + args=(1,), + kwargs={"2": 3}, + expected_class=dict, + default={"def": "ault"}, ) - assert loaded == {'def': 'ault'} + assert loaded == {"def": "ault"} - @override_settings(EB_LOAD_PATH='builtins.dict') + @override_settings(EB_LOAD_PATH="builtins.dict") def test_bad_args_for_callable(self): - with assert_warnings([ + with assert_warnings( + [ "Failed to load from setting EB_LOAD_PATH: " - "TypeError('dict() argument after * must be an iterable, not int'); " + "TypeError(\"'int' object is not iterable\"); " "component will be inactive" - ]): + ] + ): loaded = _try_load( - setting_name="EB_LOAD_PATH", args=(1), kwargs={'2': 3}, - expected_class=dict, default={'def': 'ault'}, + setting_name="EB_LOAD_PATH", + args=(1,), + kwargs={"2": 3}, + expected_class=dict, + default={"def": "ault"}, ) - assert loaded == {'def': 'ault'} + assert loaded == {"def": "ault"} class TestProducer(TestCase): - @override_settings(EVENT_BUS_PRODUCER=None) def test_default_does_nothing(self): """ @@ -124,95 +138,112 @@ def test_default_does_nothing(self): with assert_warnings([]): # Nothing thrown, no warnings. - assert producer.send( - signal=SESSION_LOGIN_COMPLETED, topic='user-logins', - event_key_field='user.id', event_data={}, - event_metadata=EventsMetadata(event_type='eh') - ) is None + assert ( + producer.send( + signal=SESSION_LOGIN_COMPLETED, + topic="user-logins", + event_key_field="user.id", + event_data={}, + event_metadata=EventsMetadata(event_type="eh"), + ) + is None + ) class TestConsumer(TestCase): - @override_settings(EVENT_BUS_CONSUMER=None) def test_default_does_nothing(self): """ Test that the default is of the right class but does nothing. """ - with assert_warnings(["Event Bus setting EVENT_BUS_CONSUMER is missing; component will be inactive"]): - consumer = make_single_consumer(topic="test", group_id="test", signal=SESSION_LOGIN_COMPLETED) + with assert_warnings( + [ + "Event Bus setting EVENT_BUS_CONSUMER is missing; component will be inactive" + ] + ): + consumer = make_single_consumer( + topic="test", group_id="test", signal=SESSION_LOGIN_COMPLETED + ) - with pytest.raises(Exception, match=re.escape("Cannot consume events; no consumer configured.")): + with pytest.raises( + Exception, match=re.escape("Cannot consume events; no consumer configured.") + ): consumer.consume_indefinitely() class TestSettings(TestCase): - def setUp(self) -> None: super().setUp() self.base_config = { - 'event_type_0': { - 'topic_a': {'event_key_field': 'field', 'enabled': True}, - 'topic_b': {'event_key_field': 'field', 'enabled': True} + "event_type_0": { + "topic_a": {"event_key_field": "field", "enabled": True}, + "topic_b": {"event_key_field": "field", "enabled": True}, + }, + "event_type_1": { + "topic_c": {"event_key_field": "field", "enabled": True}, }, - 'event_type_1': { - 'topic_c': {'event_key_field': 'field', 'enabled': True}, - } } def test_merge_configs(self): # for ensuring we didn't change the original dict base_copy = copy.deepcopy(self.base_config) overrides = { - 'event_type_0': { + "event_type_0": { # disable an existing event/topic pairing and change the key field - 'topic_a': {'event_key_field': 'new_field', 'enabled': False}, + "topic_a": {"event_key_field": "new_field", "enabled": False}, # add a new topic to an existing event_type - 'topic_d': {'event_key_field': 'field', 'enabled': True}, + "topic_d": {"event_key_field": "field", "enabled": True}, }, # add a new event_type - 'event_type_2': { - 'topic_e': {'event_key_field': 'field', 'enabled': True}, - } + "event_type_2": { + "topic_e": {"event_key_field": "field", "enabled": True}, + }, } overrides_copy = copy.deepcopy(overrides) result = merge_producer_configs(self.base_config, overrides) - self.assertDictEqual(result, { - 'event_type_0': { - 'topic_a': {'event_key_field': 'new_field', 'enabled': False}, - 'topic_b': {'event_key_field': 'field', 'enabled': True}, - 'topic_d': {'event_key_field': 'field', 'enabled': True}, - }, - 'event_type_1': { - 'topic_c': {'event_key_field': 'field', 'enabled': True}, + self.assertDictEqual( + result, + { + "event_type_0": { + "topic_a": {"event_key_field": "new_field", "enabled": False}, + "topic_b": {"event_key_field": "field", "enabled": True}, + "topic_d": {"event_key_field": "field", "enabled": True}, + }, + "event_type_1": { + "topic_c": {"event_key_field": "field", "enabled": True}, + }, + "event_type_2": { + "topic_e": {"event_key_field": "field", "enabled": True}, + }, }, - 'event_type_2': { - 'topic_e': {'event_key_field': 'field', 'enabled': True}, - } - }) + ) self.assertDictEqual(self.base_config, base_copy) self.assertDictEqual(overrides, overrides_copy) def test_merge_configs_with_empty(self): - overrides = {} + overrides: dict[str, Any] = {} result = merge_producer_configs(self.base_config, overrides) self.assertDictEqual(result, self.base_config) def test_merge_configs_with_partial(self): overrides = { - 'event_type_0': { + "event_type_0": { # no override for 'event_key_field' - 'topic_a': {'enabled': False}, + "topic_a": {"enabled": False}, # no override for 'enabled' - 'topic_b': {'event_key_field': 'new_field'} + "topic_b": {"event_key_field": "new_field"}, } } result = merge_producer_configs(self.base_config, overrides) - self.assertDictEqual(result, { - 'event_type_0': { - 'topic_a': {'event_key_field': 'field', 'enabled': False}, - 'topic_b': {'event_key_field': 'new_field', 'enabled': True} + self.assertDictEqual( + result, + { + "event_type_0": { + "topic_a": {"event_key_field": "field", "enabled": False}, + "topic_b": {"event_key_field": "new_field", "enabled": True}, + }, + "event_type_1": { + "topic_c": {"event_key_field": "field", "enabled": True}, + }, }, - 'event_type_1': { - 'topic_c': {'event_key_field': 'field', 'enabled': True}, - } - }) + ) diff --git a/openedx_events/tests/test_generate_avro_schemas.py b/openedx_events/tests/test_generate_avro_schemas.py index 70398dd3..f0fd936e 100644 --- a/openedx_events/tests/test_generate_avro_schemas.py +++ b/openedx_events/tests/test_generate_avro_schemas.py @@ -1,6 +1,8 @@ """Tests for generate_avro_schemas.""" + import os from importlib import import_module +from typing import ClassVar from unittest.mock import call, mock_open, patch from django.core.management import call_command @@ -16,72 +18,103 @@ class TestGenerateAvroCommand(FreezeSignalCacheMixin, TestCase): """ Tests for generate_avro_schemas management command. """ + + root_path: ClassVar[str] + folder_path: ClassVar[str] + @classmethod def setUpClass(cls): super().setUpClass() - cls.root_path = import_module('openedx_events').__path__[0] + cls.root_path = import_module("openedx_events").__path__[0] cls.folder_path = f"{cls.root_path}/event_bus/avro/tests/schemas" # assume the schema folder already exists but has no files so we're not overwriting anything - @patch('os.path.exists', lambda path: path == TestGenerateAvroCommand.folder_path) + @patch("os.path.exists", lambda path: path == TestGenerateAvroCommand.folder_path) def test_generate_multiple_specified_schemas(self): - signal_a = create_simple_signal({'key': str}) - signal_b = create_simple_signal({'key': str}, event_type='simple.signal.B') + signal_a = create_simple_signal({"key": str}) + signal_b = create_simple_signal({"key": str}, event_type="simple.signal.B") with patch("builtins.open", mock_open()) as mock_file: call_command(Command(), signal_a.event_type, signal_b.event_type) # make sure we created files with the correct name - mock_file.assert_has_calls([ - call(f"{TestGenerateAvroCommand.folder_path}/simple+signal_schema.avsc", "w"), - call(f"{TestGenerateAvroCommand.folder_path}/simple+signal+B_schema.avsc", "w"), + mock_file.assert_has_calls( + [ + call( + f"{TestGenerateAvroCommand.folder_path}/simple+signal_schema.avsc", + "w", + ), + call( + f"{TestGenerateAvroCommand.folder_path}/simple+signal+B_schema.avsc", + "w", + ), ], # need any_order=True because opening a file involves a bunch of other secret calls - any_order=True) + any_order=True, + ) handle = mock_file() # make sure we wrote the schemas. Exact JSON determined from testing assert handle.write.mock_calls == [ - call('{\n "name": "CloudEvent",\n "type": "record",\n' - ' "doc": "Avro Event Format for CloudEvents created with openedx_events/schema",' - '\n "fields": [\n {\n "name": "key",\n "type": "string"\n }\n ],\n "namespace":' - ' "simple.signal"\n}'), - call('{\n "name": "CloudEvent",\n "type": "record",\n' - ' "doc": "Avro Event Format for CloudEvents created with openedx_events/schema",' - '\n "fields": [\n {\n "name": "key",\n "type": "string"\n }\n ],\n "namespace":' - ' "simple.signal.B"\n}') + call( + '{\n "name": "CloudEvent",\n "type": "record",\n' + ' "doc": "Avro Event Format for CloudEvents created with openedx_events/schema",' + '\n "fields": [\n {\n "name": "key",\n "type": "string"\n }\n ],\n "namespace":' + ' "simple.signal"\n}' + ), + call( + '{\n "name": "CloudEvent",\n "type": "record",\n' + ' "doc": "Avro Event Format for CloudEvents created with openedx_events/schema",' + '\n "fields": [\n {\n "name": "key",\n "type": "string"\n }\n ],\n "namespace":' + ' "simple.signal.B"\n}' + ), ] # pretend that all files in the schema folder have already been created - @patch('os.path.exists', lambda path: TestGenerateAvroCommand.folder_path in path) + @patch("os.path.exists", lambda path: TestGenerateAvroCommand.folder_path in path) def test_command_warns_if_schema_exists(self): - signal_a = create_simple_signal({'key': str}) + signal_a = create_simple_signal({"key": str}) with patch("builtins.open", mock_open()) as mock_file: # test we skip if the user doesn't want to continue - with patch("builtins.input", return_value='n'): + with patch("builtins.input", return_value="n"): call_command(Command(), signal_a.event_type) mock_file.assert_not_called() # check we continue if the user wants to continue - with patch("builtins.input", return_value='Y'): + with patch("builtins.input", return_value="Y"): call_command(Command(), signal_a.event_type) - mock_file.assert_has_calls([ - call(f"{TestGenerateAvroCommand.folder_path}/simple+signal_schema.avsc", "w"), - ]) + mock_file.assert_has_calls( + [ + call( + f"{TestGenerateAvroCommand.folder_path}/simple+signal_schema.avsc", + "w", + ), + ] + ) - @patch('os.path.exists', lambda path: path == TestGenerateAvroCommand.folder_path) + @patch("os.path.exists", lambda path: path == TestGenerateAvroCommand.folder_path) def test_generate_all(self): load_all_signals() - expected_files = [f"{TestGenerateAvroCommand.folder_path}/{signal.event_type.replace('.', '+')}_schema.avsc" - for signal in OpenEdxPublicSignal.all_events() if signal.event_type - not in KNOWN_UNSERIALIZABLE_SIGNALS] + expected_files = [ + f"{TestGenerateAvroCommand.folder_path}/{signal.event_type.replace('.', '+')}_schema.avsc" + for signal in OpenEdxPublicSignal.all_events() + if signal.event_type not in KNOWN_UNSERIALIZABLE_SIGNALS + ] with patch("builtins.open", mock_open()) as mock_file: call_command(Command(), all=True) - mock_file.assert_has_calls([call(file, 'w') for file in expected_files], any_order=True) + mock_file.assert_has_calls( + [call(file, "w") for file in expected_files], any_order=True + ) # mostly added to appease the coverage gods - @patch('os.makedirs') + @patch("os.makedirs") def test_create_schema_dir_if_not_exists(self, mock_makedirs): signal = create_simple_signal({"key": str}) old_exists = os.path.exists - with patch('os.path.exists', - lambda path: False if path == TestGenerateAvroCommand.folder_path else old_exists(path)): + with patch( + "os.path.exists", + lambda path: ( + False + if path == TestGenerateAvroCommand.folder_path + else old_exists(path) + ), + ): with patch("builtins.open", mock_open()): call_command(Command(), signal.event_type) mock_makedirs.assert_called_once_with(TestGenerateAvroCommand.folder_path) diff --git a/openedx_events/tests/test_producer_config.py b/openedx_events/tests/test_producer_config.py index 51bb5358..acf04d84 100644 --- a/openedx_events/tests/test_producer_config.py +++ b/openedx_events/tests/test_producer_config.py @@ -1,12 +1,14 @@ """ Test for producer configuration. """ + from unittest.mock import Mock, patch import ddt import pytest from django.apps import apps from django.test import TestCase, override_settings +from opaque_keys.edx.keys import UsageKey from openedx_events.content_authoring.data import XBlockData from openedx_events.content_authoring.signals import XBLOCK_DELETED, XBLOCK_PUBLISHED @@ -21,14 +23,17 @@ class ProducerConfiguratonTest(TestCase): Attributes: xblock_info: dummy XBlockData. """ + def setUp(self) -> None: super().setUp() self.xblock_info = XBlockData( - usage_key='block-v1:edx+DemoX+Demo_course+type@video+block@UaEBjyMjcLW65gaTXggB93WmvoxGAJa0JeHRrDThk', - block_type='video', + usage_key=UsageKey.from_string( + "block-v1:edx+DemoX+Demo_course+type@video+block@UaEBjyMjcLW65gaTXggB93WmvoxGAJa0JeHRrDThk" + ), + block_type="video", ) - @patch('openedx_events.apps.get_producer') + @patch("openedx_events.apps.get_producer") def test_enabled_disabled_events(self, mock_producer): """ Check whether XBLOCK_PUBLISHED is connected to the handler and the handler only produces enabled events. @@ -43,8 +48,8 @@ def test_enabled_disabled_events(self, mock_producer): mock_send.send.assert_called() mock_send.send.call_count = 2 expected_call_args = [ - {'topic': 'enabled_topic_a', 'event_key_field': 'xblock_info.usage_key'}, - {'topic': 'enabled_topic_b', 'event_key_field': 'xblock_info.usage_key'} + {"topic": "enabled_topic_a", "event_key_field": "xblock_info.usage_key"}, + {"topic": "enabled_topic_b", "event_key_field": "xblock_info.usage_key"}, ] # check that call_args_list only consists of enabled topics. @@ -54,8 +59,10 @@ def test_enabled_disabled_events(self, mock_producer): self.assertEqual(call_args, {**call_args, **expected_call_args[1]}) @patch("openedx_events.apps.logger") - @patch('openedx_events.apps.get_producer') - def test_send_events_with_custom_metadata_not_replayed_by_handler(self, mock_producer, mock_logger): + @patch("openedx_events.apps.get_producer") + def test_send_events_with_custom_metadata_not_replayed_by_handler( + self, mock_producer, mock_logger + ): """ Check wheter XBLOCK_PUBLISHED is connected to the handler and the handler do not send any events as the signal is marked "from_event_bus". @@ -68,7 +75,9 @@ def test_send_events_with_custom_metadata_not_replayed_by_handler(self, mock_pro mock_producer.return_value = mock_send metadata = XBLOCK_PUBLISHED.generate_signal_metadata() - XBLOCK_PUBLISHED.send_event_with_custom_metadata(metadata, xblock_info=self.xblock_info) + XBLOCK_PUBLISHED.send_event_with_custom_metadata( + metadata, xblock_info=self.xblock_info + ) mock_send.send.assert_not_called() mock_logger.debug.assert_called_once_with( @@ -76,7 +85,7 @@ def test_send_events_with_custom_metadata_not_replayed_by_handler(self, mock_pro f"where it was sent from: {XBLOCK_PUBLISHED.event_type} (preventing recursion)" ) - @patch('openedx_events.apps.get_producer') + @patch("openedx_events.apps.get_producer") @override_settings(EVENT_BUS_PRODUCER_CONFIG={}) def test_events_not_in_config(self, mock_producer): """ @@ -96,46 +105,62 @@ def test_configuration_is_validated(self): Check whether EVENT_BUS_PRODUCER_CONFIG setting is validated before connecting handlers. """ with override_settings(EVENT_BUS_PRODUCER_CONFIG=[]): - with pytest.raises(ProducerConfigurationError, match="should be a dictionary"): + with pytest.raises( + ProducerConfigurationError, match="should be a dictionary" + ): apps.get_app_config("openedx_events").ready() with override_settings(EVENT_BUS_PRODUCER_CONFIG={"invalid.event.type": {}}): - with pytest.raises(ProducerConfigurationError, match="No OpenEdxPublicSignal of type"): + with pytest.raises( + ProducerConfigurationError, match="No OpenEdxPublicSignal of type" + ): apps.get_app_config("openedx_events").ready() - with override_settings(EVENT_BUS_PRODUCER_CONFIG={"org.openedx.content_authoring.xblock.deleted.v1": ""}): + with override_settings( + EVENT_BUS_PRODUCER_CONFIG={ + "org.openedx.content_authoring.xblock.deleted.v1": "" + } + ): with pytest.raises(ProducerConfigurationError, match="should be a dict"): apps.get_app_config("openedx_events").ready() - with override_settings(EVENT_BUS_PRODUCER_CONFIG={"org.openedx.content_authoring.xblock.deleted.v1": - {"topic": ""}}): - with pytest.raises(ProducerConfigurationError, match="One of the configuration objects is not a" - " dictionary"): + with override_settings( + EVENT_BUS_PRODUCER_CONFIG={ + "org.openedx.content_authoring.xblock.deleted.v1": {"topic": ""} + } + ): + with pytest.raises( + ProducerConfigurationError, + match="One of the configuration objects is not a dictionary", + ): apps.get_app_config("openedx_events").ready() with override_settings( EVENT_BUS_PRODUCER_CONFIG={ - "org.openedx.content_authoring.xblock.deleted.v1": {"topic": {"enabled": True}} + "org.openedx.content_authoring.xblock.deleted.v1": { + "topic": {"enabled": True} + } } ): - with pytest.raises(ProducerConfigurationError, match="missing 'event_key_field' key."): + with pytest.raises( + ProducerConfigurationError, match="missing 'event_key_field' key." + ): apps.get_app_config("openedx_events").ready() with override_settings( EVENT_BUS_PRODUCER_CONFIG={ - "org.openedx.content_authoring.xblock.deleted.v1": - { + "org.openedx.content_authoring.xblock.deleted.v1": { "some": {"enabled": 1, "event_key_field": "some"} } } ): with pytest.raises( ProducerConfigurationError, - match="Expected type: for 'enabled', found: " + match="Expected type: for 'enabled', found: ", ): apps.get_app_config("openedx_events").ready() - @patch('openedx_events.apps.get_producer') + @patch("openedx_events.apps.get_producer") def test_event_data_key_in_handler(self, mock_producer): """ Check whether event_data is constructed properly in handlers. diff --git a/openedx_events/tests/test_tooling.py b/openedx_events/tests/test_tooling.py index e152620e..97b5e81d 100644 --- a/openedx_events/tests/test_tooling.py +++ b/openedx_events/tests/test_tooling.py @@ -4,9 +4,11 @@ Classes: EventsToolingTest: Test events tooling. """ + import datetime import sys from contextlib import contextmanager +from typing import Any from unittest.mock import Mock, patch from uuid import UUID, uuid1 @@ -79,18 +81,22 @@ def test_get_signal_by_type(self): Test found and not-found behavior. """ assert isinstance( - OpenEdxPublicSignal.get_signal_by_type('org.openedx.learning.session.login.completed.v1'), - OpenEdxPublicSignal + OpenEdxPublicSignal.get_signal_by_type( + "org.openedx.learning.session.login.completed.v1" + ), + OpenEdxPublicSignal, ) with pytest.raises(KeyError): - OpenEdxPublicSignal.get_signal_by_type('xxx') + OpenEdxPublicSignal.get_signal_by_type("xxx") @override_settings(SERVICE_VARIANT="lms") @patch("openedx_events.data.openedx_events") @patch("openedx_events.data.socket") @patch("openedx_events.data.datetime") - def test_generate_signal_metadata(self, datetime_mock, socket_mock, events_package_mock): + def test_generate_signal_metadata( + self, datetime_mock, socket_mock, events_package_mock + ): """ This methods tests getting the generated metadata for an event. @@ -119,7 +125,9 @@ def test_generate_signal_metadata(self, datetime_mock, socket_mock, events_packa @override_settings(SERVICE_VARIANT="lms") @patch("openedx_events.data.openedx_events") @patch("openedx_events.data.socket") - def test_generate_signal_metadata_with_valid_time(self, socket_mock, events_package_mock): + def test_generate_signal_metadata_with_valid_time( + self, socket_mock, events_package_mock + ): """ Tests getting the generated metadata for an event, providing a valid time in UTC. @@ -145,9 +153,17 @@ def test_generate_signal_metadata_with_valid_time(self, socket_mock, events_pack self.assertIsInstance(metadata.id, UUID) @ddt.data( - (1, TypeError, "'time' must be : \nfake-output" @@ -250,10 +278,12 @@ def test_send_event_with_custom_metadata(self, mock_send_event_with_metadata): time=datetime.datetime.now(datetime.timezone.utc), sourcelib=(6, 1, 7), ) - expected_response = "mock-response" + expected_response: Any = "mock-response" mock_send_event_with_metadata.return_value = expected_response - response = self.public_signal.send_event_with_custom_metadata(metadata, foo="bar") + response = self.public_signal.send_event_with_custom_metadata( + metadata, foo="bar" + ) assert response == expected_response mock_send_event_with_metadata.assert_called_once_with( @@ -334,16 +364,18 @@ def test_send_event_disabled(self, send_mock): class TestLoadAllSignals(FreezeSignalCacheMixin, TestCase): - """ Tests for the load_all_signals method""" + """Tests for the load_all_signals method""" + def setUp(self): # load_all_signals does spooky things with module loading, # so save the current state of any loaded signals modules to avoid disrupting other tests super().setUp() - self.old_signal_modules = {} + self.old_signal_modules: dict[str, Any] = {} def save_module(module_name): if module_name in sys.modules: self.old_signal_modules[module_name] = sys.modules[module_name] + _process_all_signals_modules(save_module) def tearDown(self): @@ -368,16 +400,24 @@ def test_load_all_signals(self): OpenEdxPublicSignal._mapping = {} # pylint: disable=protected-access OpenEdxPublicSignal.instances = [] with pytest.raises(KeyError): - OpenEdxPublicSignal.get_signal_by_type('org.openedx.content_authoring.course.catalog_info.changed.v1') + OpenEdxPublicSignal.get_signal_by_type( + "org.openedx.content_authoring.course.catalog_info.changed.v1" + ) with pytest.raises(KeyError): - OpenEdxPublicSignal.get_signal_by_type('org.openedx.learning.course.enrollment.created.v1') + OpenEdxPublicSignal.get_signal_by_type( + "org.openedx.learning.course.enrollment.created.v1" + ) load_all_signals() assert isinstance( - OpenEdxPublicSignal.get_signal_by_type('org.openedx.content_authoring.course.catalog_info.changed.v1'), - OpenEdxPublicSignal + OpenEdxPublicSignal.get_signal_by_type( + "org.openedx.content_authoring.course.catalog_info.changed.v1" + ), + OpenEdxPublicSignal, ) assert isinstance( - OpenEdxPublicSignal.get_signal_by_type('org.openedx.learning.course.enrollment.created.v1'), - OpenEdxPublicSignal + OpenEdxPublicSignal.get_signal_by_type( + "org.openedx.learning.course.enrollment.created.v1" + ), + OpenEdxPublicSignal, )