diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 78fb232..58a4287 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,6 +29,13 @@ jobs: - name: Run linters run: | make lint + - name: Install type checking dependencies + run: | + python -m pip install --upgrade pip + pip install django==4.2.10 django-stubs==4.2.7 mypy==1.8.0 + - name: Run type checking + run: | + make typecheck - name: Test package install run: | python -m pip install --upgrade pip diff --git a/.gitignore b/.gitignore index a7f0c29..078472c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.py[cod] +__pycache__ # C extensions *.so diff --git a/Makefile b/Makefile index 27f505d..fdd8308 100644 --- a/Makefile +++ b/Makefile @@ -12,3 +12,6 @@ clean: lint: ruff check modeltranslation ruff format --check modeltranslation *.py + +typecheck: + mypy modeltranslation diff --git a/modeltranslation/_typing.py b/modeltranslation/_typing.py new file mode 100644 index 0000000..039bbe7 --- /dev/null +++ b/modeltranslation/_typing.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import sys +from typing import Literal, TypeVar + +if sys.version_info >= (3, 11): + from typing import Self, TypeAlias # noqa: F401 +else: + from typing_extensions import Self, TypeAlias # noqa: F401 + +AutoPopulate: TypeAlias = "bool | Literal['all', 'default', 'required']" + +_K = TypeVar("_K") + +# See https://github.com/typeddjango/django-stubs/blob/082955/django-stubs/utils/datastructures.pyi#L12-L14 +_ListOrTuple: TypeAlias = "list[_K] | tuple[_K, ...]" diff --git a/modeltranslation/admin.py b/modeltranslation/admin.py index 3f945ab..97e3ead 100644 --- a/modeltranslation/admin.py +++ b/modeltranslation/admin.py @@ -1,11 +1,18 @@ +from __future__ import annotations + from copy import deepcopy +from typing import Any, Iterable, Sequence from django import forms +from django.db.models import Field from django.contrib import admin from django.contrib.admin.options import BaseModelAdmin, InlineModelAdmin, flatten_fieldsets from django.contrib.contenttypes.admin import GenericStackedInline, GenericTabularInline +from django.forms.models import BaseInlineFormSet +from django.http.request import HttpRequest from modeltranslation import settings as mt_settings +from modeltranslation.fields import TranslationField from modeltranslation.translator import translator from modeltranslation.utils import ( build_css_class, @@ -16,18 +23,21 @@ from modeltranslation.utils import ( unique, ) from modeltranslation.widgets import ClearableWidgetWrapper +from modeltranslation._typing import _ListOrTuple class TranslationBaseModelAdmin(BaseModelAdmin): - _orig_was_required = {} + _orig_was_required: dict[str, bool] = {} both_empty_values_fields = () - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.trans_opts = translator.get_options_for_model(self.model) self._patch_prepopulated_fields() - def _get_declared_fieldsets(self, request, obj=None): + def _get_declared_fieldsets( + self, request: HttpRequest, obj: Any | None = None + ) -> _ListOrTuple[tuple[str | None, dict[str, Any]]] | None: # Take custom modelform fields option into account if not self.fields and hasattr(self.form, "_meta") and self.form._meta.fields: self.fields = self.form._meta.fields @@ -43,12 +53,16 @@ class TranslationBaseModelAdmin(BaseModelAdmin): return [(None, {"fields": self.replace_orig_field(self.get_fields(request, obj))})] return None - def formfield_for_dbfield(self, db_field, request, **kwargs): + def formfield_for_dbfield( + self, db_field: Field, request: HttpRequest, **kwargs: Any + ) -> forms.Field: field = super().formfield_for_dbfield(db_field, request, **kwargs) - self.patch_translation_field(db_field, field, request, **kwargs) - return field + self.patch_translation_field(db_field, field, request, **kwargs) # type: ignore[arg-type] + return field # type: ignore[return-value] - def patch_translation_field(self, db_field, field, request, **kwargs): + def patch_translation_field( + self, db_field: Field, field: forms.Field, request: HttpRequest, **kwargs: Any + ) -> None: if db_field.name in self.trans_opts.fields: if field.required: field.required = False @@ -67,7 +81,8 @@ class TranslationBaseModelAdmin(BaseModelAdmin): attrs = field.widget.attrs # if any widget attrs are defined on the form they should be copied try: - field.widget = deepcopy(self.form._meta.widgets[orig_field.name]) # this is a class + # this is a class: + field.widget = deepcopy(self.form._meta.widgets[orig_field.name]) # type: ignore[index] if isinstance(field.widget, type): # if not initialized field.widget = field.widget(attrs) # initialize form widget with attrs except (AttributeError, TypeError, KeyError): @@ -113,14 +128,14 @@ class TranslationBaseModelAdmin(BaseModelAdmin): field.widget = field.widget.widget self._get_widget_from_field(field).attrs["class"] = " ".join(css_classes) - def _get_widget_from_field(self, field): + def _get_widget_from_field(self, field: forms.Field) -> Any: # retrieve "nested" widget in case of related field if isinstance(field.widget, admin.widgets.RelatedFieldWidgetWrapper): return field.widget.widget else: return field.widget - def _exclude_original_fields(self, exclude=None): + def _exclude_original_fields(self, exclude: _ListOrTuple[str] | None = None) -> tuple[str, ...]: if exclude is None: exclude = tuple() if exclude: @@ -128,7 +143,7 @@ class TranslationBaseModelAdmin(BaseModelAdmin): return exclude_new + tuple(self.trans_opts.fields.keys()) return tuple(self.trans_opts.fields.keys()) - def replace_orig_field(self, option): + def replace_orig_field(self, option: Iterable[str | Sequence[str]]) -> _ListOrTuple[str]: """ Replaces each original field in `option` that is registered for translation by its translation fields. @@ -158,16 +173,18 @@ class TranslationBaseModelAdmin(BaseModelAdmin): for opt in option: if opt in self.trans_opts.fields: index = option_new.index(opt) - option_new[index : index + 1] = get_translation_fields(opt) + option_new[index : index + 1] = get_translation_fields(opt) # type: ignore[arg-type] elif isinstance(opt, (tuple, list)) and ( [o for o in opt if o in self.trans_opts.fields] ): index = option_new.index(opt) option_new[index : index + 1] = self.replace_orig_field(opt) option = option_new - return option + return option # type: ignore[return-value] - def _patch_fieldsets(self, fieldsets): + def _patch_fieldsets( + self, fieldsets: _ListOrTuple[tuple[str | None, dict[str, Any]]] + ) -> _ListOrTuple[tuple[str | None, dict[str, Any]]]: if fieldsets: fieldsets_new = list(fieldsets) for name, dct in fieldsets: @@ -176,18 +193,18 @@ class TranslationBaseModelAdmin(BaseModelAdmin): fieldsets = fieldsets_new return fieldsets - def _patch_prepopulated_fields(self): - def localize(sources, lang): + def _patch_prepopulated_fields(self) -> None: + def localize(sources: Sequence[str], lang: str) -> tuple[str, ...]: "Append lang suffix (if applicable) to field list" - def append_lang(source): + def append_lang(source: str) -> str: if source in self.trans_opts.fields: return build_localized_fieldname(source, lang) return source return tuple(map(append_lang, sources)) - prepopulated_fields = {} + prepopulated_fields: dict[str, Sequence[str]] = {} for dest, sources in self.prepopulated_fields.items(): if dest in self.trans_opts.fields: for lang in mt_settings.AVAILABLE_LANGUAGES: @@ -198,7 +215,9 @@ class TranslationBaseModelAdmin(BaseModelAdmin): prepopulated_fields[dest] = localize(sources, lang) self.prepopulated_fields = prepopulated_fields - def _get_form_or_formset(self, request, obj, **kwargs): + def _get_form_or_formset( + self, request: HttpRequest, obj: Any | None, **kwargs: Any + ) -> dict[str, Any]: """ Generic code shared by get_form and get_formset. """ @@ -220,14 +239,18 @@ class TranslationBaseModelAdmin(BaseModelAdmin): return kwargs - def _get_fieldsets_pre_form_or_formset(self, request, obj=None): + def _get_fieldsets_pre_form_or_formset( + self, request: HttpRequest, obj: Any | None = None + ) -> _ListOrTuple[tuple[str | None, dict[str, Any]]] | None: """ Generic get_fieldsets code, shared by TranslationAdmin and TranslationInlineModelAdmin. """ return self._get_declared_fieldsets(request, obj) - def _get_fieldsets_post_form_or_formset(self, request, form, obj=None): + def _get_fieldsets_post_form_or_formset( + self, request: HttpRequest, form: type[forms.ModelForm], obj: Any | None = None + ) -> list: """ Generic get_fieldsets code, shared by TranslationAdmin and TranslationInlineModelAdmin. @@ -236,7 +259,9 @@ class TranslationBaseModelAdmin(BaseModelAdmin): fields = list(base_fields) + list(self.get_readonly_fields(request, obj)) return [(None, {"fields": self.replace_orig_field(fields)})] - def get_translation_field_excludes(self, exclude_languages=None): + def get_translation_field_excludes( + self, exclude_languages: list[str] | None = None + ) -> tuple[TranslationField, ...]: """ Returns a tuple of translation field names to exclude based on `exclude_languages` arg. @@ -254,7 +279,9 @@ class TranslationBaseModelAdmin(BaseModelAdmin): exclude.append(tfield) return tuple(exclude) - def get_readonly_fields(self, request, obj=None): + def get_readonly_fields( + self, request: HttpRequest, obj: Any | None = None + ) -> _ListOrTuple[str]: """ Hook to specify custom readonly fields. """ @@ -265,11 +292,11 @@ class TranslationAdmin(TranslationBaseModelAdmin, admin.ModelAdmin): # TODO: Consider addition of a setting which allows to override the fallback to True group_fieldsets = False - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self._patch_list_editable() - def _patch_list_editable(self): + def _patch_list_editable(self) -> None: if self.list_editable: editable_new = list(self.list_editable) display_new = list(self.list_display) @@ -283,7 +310,7 @@ class TranslationAdmin(TranslationBaseModelAdmin, admin.ModelAdmin): self.list_editable = editable_new self.list_display = display_new - def _group_fieldsets(self, fieldsets): + def _group_fieldsets(self, fieldsets: list) -> list: # Fieldsets are not grouped by default. The function is activated by # setting TranslationAdmin.group_fieldsets to True. If the admin class # already defines a fieldset, we leave it alone and assume the author @@ -292,7 +319,7 @@ class TranslationAdmin(TranslationBaseModelAdmin, admin.ModelAdmin): flattened_fieldsets = flatten_fieldsets(fieldsets) # Create a fieldset to group each translated field's localized fields - fields = sorted(f for f in self.opts.get_fields() if f.concrete) + fields = sorted(f for f in self.opts.get_fields() if f.concrete) # type: ignore[type-var] untranslated_fields = [ f.name for f in fields @@ -346,11 +373,15 @@ class TranslationAdmin(TranslationBaseModelAdmin, admin.ModelAdmin): return fieldsets - def get_form(self, request, obj=None, **kwargs): + def get_form( + self, request: HttpRequest, obj: Any | None = None, **kwargs: Any + ) -> type[forms.ModelForm]: kwargs = self._get_form_or_formset(request, obj, **kwargs) return super().get_form(request, obj, **kwargs) - def get_fieldsets(self, request, obj=None): + def get_fieldsets( + self, request: HttpRequest, obj: Any | None = None + ) -> _ListOrTuple[tuple[str | None, dict[str, Any]]]: return self._get_fieldsets_pre_form_or_formset(request, obj) or self._group_fieldsets( self._get_fieldsets_post_form_or_formset( request, self.get_form(request, obj, fields=None), obj @@ -359,11 +390,13 @@ class TranslationAdmin(TranslationBaseModelAdmin, admin.ModelAdmin): class TranslationInlineModelAdmin(TranslationBaseModelAdmin, InlineModelAdmin): - def get_formset(self, request, obj=None, **kwargs): + def get_formset( + self, request: HttpRequest, obj: Any | None = None, **kwargs: Any + ) -> type[BaseInlineFormSet]: kwargs = self._get_form_or_formset(request, obj, **kwargs) return super().get_formset(request, obj, **kwargs) - def get_fieldsets(self, request, obj=None): + def get_fieldsets(self, request: HttpRequest, obj: Any | None = None): # FIXME: If fieldsets are declared on an inline some kind of ghost # fieldset line with just the original model verbose_name of the model # is displayed above the new fieldsets. diff --git a/modeltranslation/apps.py b/modeltranslation/apps.py index 767ec25..f748c9f 100644 --- a/modeltranslation/apps.py +++ b/modeltranslation/apps.py @@ -5,7 +5,7 @@ class ModeltranslationConfig(AppConfig): name = "modeltranslation" verbose_name = "Modeltranslation" - def ready(self): + def ready(self) -> None: from modeltranslation.models import handle_translation_registrations handle_translation_registrations() diff --git a/modeltranslation/decorators.py b/modeltranslation/decorators.py index 96fcf53..c65bb96 100644 --- a/modeltranslation/decorators.py +++ b/modeltranslation/decorators.py @@ -1,4 +1,18 @@ -def register(model_or_iterable, **options): +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable, Iterable, TypeVar + +from django.db.models import Model + +if TYPE_CHECKING: + from modeltranslation.translator import TranslationOptions + + _TranslationOptionsTypeT = TypeVar("_TranslationOptionsTypeT", bound=type[TranslationOptions]) + + +def register( + model_or_iterable: type[Model] | Iterable[type[Model]], **options: Any +) -> Callable[[_TranslationOptionsTypeT], _TranslationOptionsTypeT]: """ Registers the given model(s) with the given translation options. @@ -14,7 +28,7 @@ def register(model_or_iterable, **options): """ from modeltranslation.translator import TranslationOptions, translator - def wrapper(opts_class): + def wrapper(opts_class: _TranslationOptionsTypeT) -> _TranslationOptionsTypeT: if not issubclass(opts_class, TranslationOptions): raise ValueError("Wrapped class must subclass TranslationOptions.") translator.register(model_or_iterable, opts_class, **options) diff --git a/modeltranslation/fields.py b/modeltranslation/fields.py index 6515889..e1c08cc 100644 --- a/modeltranslation/fields.py +++ b/modeltranslation/fields.py @@ -1,9 +1,11 @@ +from __future__ import annotations + import copy -from typing import Iterable +from typing import Any, Sequence, cast from django import VERSION, forms from django.core.exceptions import ImproperlyConfigured -from django.db.models import fields +from django.db.models import Model, fields from django.utils.encoding import force_str from django.utils.functional import Promise from django.utils.translation import override @@ -19,6 +21,8 @@ from modeltranslation.utils import ( ) from modeltranslation.widgets import ClearableWidgetWrapper +from ._typing import Self + SUPPORTED_FIELDS = ( fields.CharField, # Above implies also CommaSeparatedIntegerField, EmailField, FilePathField, SlugField @@ -57,7 +61,7 @@ class NONE: pass -def create_translation_field(model, field_name, lang, empty_value): +def create_translation_field(model: type[Model], field_name: str, lang: str, empty_value: Any): """ Translation field factory. Returns a ``TranslationField`` based on a fieldname and a language. @@ -72,7 +76,7 @@ def create_translation_field(model, field_name, lang, empty_value): """ if empty_value not in ("", "both", None, NONE): raise ImproperlyConfigured("%s is not a valid empty_value." % empty_value) - field = model._meta.get_field(field_name) + field = cast(fields.Field, model._meta.get_field(field_name)) cls_name = field.__class__.__name__ if not (isinstance(field, SUPPORTED_FIELDS) or cls_name in mt_settings.CUSTOM_FIELDS): raise ImproperlyConfigured("%s is not supported by modeltranslation." % cls_name) @@ -80,8 +84,8 @@ def create_translation_field(model, field_name, lang, empty_value): return translation_class(translated_field=field, language=lang, empty_value=empty_value) -def field_factory(baseclass): - class TranslationFieldSpecific(TranslationField, baseclass): +def field_factory(baseclass: type[fields.Field]) -> type[TranslationField]: + class TranslationFieldSpecific(TranslationField, baseclass): # type: ignore[valid-type, misc] pass # Reflect baseclass name of returned subclass @@ -109,7 +113,14 @@ class TranslationField: that needs to be specified when the field is created. """ - def __init__(self, translated_field, language, empty_value, *args, **kwargs): + def __init__( + self, + translated_field: fields.Field, + language: str, + empty_value: Any, + *args: Any, + **kwargs: Any, + ) -> None: from modeltranslation.translator import translator # Update the dict of this field with the content of the original one @@ -134,7 +145,7 @@ class TranslationField: trans_opts = translator.get_options_for_model(self.model) if trans_opts.required_languages: required_languages = trans_opts.required_languages - if isinstance(trans_opts.required_languages, (tuple, list)): + if isinstance(required_languages, (tuple, list)): # All fields if self.language in required_languages: # self.null = False @@ -246,20 +257,20 @@ class TranslationField: # original field and fields didn't get added to sets. # So here we override __eq__ and __hash__ to fix the issue while retaining fine with # http://docs.python.org/2.7/reference/datamodel.html#object.__hash__ - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if isinstance(other, fields.Field): return self.creation_counter == other.creation_counter and self.language == getattr( other, "language", None ) return super().__eq__(other) - def __ne__(self, other): + def __ne__(self, other: object) -> bool: return not self.__eq__(other) - def __hash__(self): + def __hash__(self) -> int: return hash((self.creation_counter, self.language)) - def get_default(self): + def get_default(self) -> Any: with override(self.language): default = super().get_default() # we must *force evaluation* at this point, otherwise the lazy translatable @@ -272,7 +283,7 @@ class TranslationField: default = force_str(default, strings_only=True) return default - def formfield(self, *args, **kwargs): + def formfield(self, *args: Any, **kwargs: Any) -> forms.Field: """ Returns proper formfield, according to empty_values setting (only for ``forms.CharField`` subclasses). @@ -317,7 +328,7 @@ class TranslationField: formfield.widget = ClearableWidgetWrapper(formfield.widget) return formfield - def save_form_data(self, instance, data, check=True): + def save_form_data(self, instance: Model, data: Any, check: bool = True) -> None: # Allow 3rd-party apps forms to be saved using only translated field name. # When translated field (e.g. 'name') is specified and translation field (e.g. 'name_en') # not, we assume that form was saved without knowledge of modeltranslation and we make @@ -334,7 +345,7 @@ class TranslationField: else: super().save_form_data(instance, data) - def deconstruct(self): + def deconstruct(self) -> tuple[str, str, Sequence[Any], dict[str, Any]]: name, path, args, kwargs = self.translated_field.deconstruct() if self.null is True: kwargs.update({"null": True}) @@ -342,7 +353,7 @@ class TranslationField: kwargs["db_column"] = self.db_column return self.name, path, args, kwargs - def clone(self): + def clone(self) -> Self: from django.utils.module_loading import import_string name, path, args, kwargs = self.deconstruct() @@ -356,8 +367,12 @@ class TranslationFieldDescriptor: """ def __init__( - self, field, fallback_languages=None, fallback_value=NONE, fallback_undefined=NONE - ): + self, + field: fields.Field, + fallback_languages: dict[str, tuple[str, ...]] | None = None, + fallback_value: Any = NONE, + fallback_undefined: Any = NONE, + ) -> None: """ Stores fallback options and the original field, so we know it's name and default. @@ -434,7 +449,9 @@ class TranslatedRelationIdDescriptor: ForeignKey field. """ - def __init__(self, field_name: str, fallback_languages: Iterable[str]): + def __init__( + self, field_name: str, fallback_languages: dict[str, tuple[str, ...]] | None + ) -> None: self.field_name = field_name # The name of the original field (excluding '_id') self.fallback_languages = fallback_languages @@ -466,7 +483,9 @@ class TranslatedManyToManyDescriptor: A descriptor used to return correct related manager without language fallbacks. """ - def __init__(self, field_name, fallback_languages): + def __init__( + self, field_name: str, fallback_languages: dict[str, tuple[str, ...]] | None + ) -> None: self.field_name = field_name # The name of the original field self.fallback_languages = fallback_languages @@ -490,16 +509,16 @@ class LanguageCacheSingleObjectDescriptor: accessor = None # needs to be set on instance @property - def cache_name(self): + def cache_name(self) -> str: """ Used in django 1.x """ lang = get_language() - cache = build_localized_fieldname(self.accessor, lang) + cache = build_localized_fieldname(self.accessor, lang) # type: ignore[arg-type] return "_%s_cache" % cache - def get_cache_name(self): + def get_cache_name(self) -> str: """ Used in django > 2.x """ - return build_localized_fieldname(self.accessor, get_language()) + return build_localized_fieldname(self.accessor, get_language()) # type: ignore[arg-type] diff --git a/modeltranslation/forms.py b/modeltranslation/forms.py index 63df5d0..e085317 100644 --- a/modeltranslation/forms.py +++ b/modeltranslation/forms.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from typing import Any + from django import forms from django.core import validators @@ -5,7 +9,7 @@ from modeltranslation.fields import TranslationField class TranslationModelForm(forms.ModelForm): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) for f in self._meta.model._meta.fields: if f.name in self.fields and isinstance(f, TranslationField): @@ -17,7 +21,7 @@ class NullCharField(forms.CharField): CharField subclass that returns ``None`` when ``CharField`` would return empty string. """ - def to_python(self, value): + def to_python(self, value: Any | None) -> str | None: if value in validators.EMPTY_VALUES: return None return super().to_python(value) @@ -29,7 +33,7 @@ class NullableField(forms.Field): the empty string with ``CharField`` and its derivatives). """ - def to_python(self, value): + def to_python(self, value: Any | None) -> Any | None: if value is None: return value return super().to_python(value) diff --git a/modeltranslation/management/commands/loaddata.py b/modeltranslation/management/commands/loaddata.py index a70d45f..d754c45 100644 --- a/modeltranslation/management/commands/loaddata.py +++ b/modeltranslation/management/commands/loaddata.py @@ -1,32 +1,50 @@ -import argparse +from __future__ import annotations +from typing import Any + +from argparse import Action, Namespace + +from django.core.management.base import CommandParser from django.core.management.commands.loaddata import Command as LoadDataCommand # Because this command is used (instead of default loaddata), then settings have been imported # and we can safely import MT modules from modeltranslation import settings as mt_settings from modeltranslation.utils import auto_populate +from modeltranslation._typing import AutoPopulate ALLOWED = (None, False, "all", "default", "required") ALLOWED_FOR_PRINT = ", ".join(str(i) for i in (0,) + ALLOWED[1:]) # For pretty-printing -def check_mode(option, opt_str, value, parser, namespace=None): +def check_mode( + option: Command.CheckAction, + opt_str: str | None, + value: str, + parser: CommandParser, + namespace: Namespace | None = None, +) -> None: if value == "0" or value.lower() == "false": - value = False + value = False # type: ignore[assignment] if value not in ALLOWED: raise ValueError("%s option can be only one of: %s" % (opt_str, ALLOWED_FOR_PRINT)) - setattr(namespace or parser.values, option.dest, value) + setattr(namespace or parser.values, option.dest, value) # type: ignore[attr-defined] class Command(LoadDataCommand): leave_locale_alone = mt_settings.LOADDATA_RETAIN_LOCALE # Django 1.6 - class CheckAction(argparse.Action): - def __call__(self, parser, namespace, value, option_string=None): + class CheckAction(Action): + def __call__( + self, + parser: CommandParser, # type: ignore[override] + namespace: Namespace, + value: str, # type: ignore[override] + option_string: str | None = None, + ) -> None: check_mode(self, option_string, value, parser, namespace) - def add_arguments(self, parser): + def add_arguments(self, parser: CommandParser) -> None: super().add_arguments(parser) parser.add_argument( "--populate", @@ -40,8 +58,8 @@ class Command(LoadDataCommand): ), ) - def handle(self, *fixture_labels, **options): - mode = options.get("populate") + def handle(self, *fixture_labels: Any, **options: Any) -> str | None: + mode: AutoPopulate | None = options.get("populate") if mode is not None: with auto_populate(mode): return super().handle(*fixture_labels, **options) diff --git a/modeltranslation/management/commands/sync_translation_fields.py b/modeltranslation/management/commands/sync_translation_fields.py index 6678782..a2ffe1c 100644 --- a/modeltranslation/management/commands/sync_translation_fields.py +++ b/modeltranslation/management/commands/sync_translation_fields.py @@ -8,17 +8,22 @@ You will need to execute this command in two cases: Credits: Heavily inspired by django-transmeta's sync_transmeta_db command. """ +from __future__ import annotations + +from typing import Any, Iterator, cast + from django import VERSION -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandParser from django.core.management.color import no_style from django.db import connection +from django.db.models import Model, Field from modeltranslation.settings import AVAILABLE_LANGUAGES from modeltranslation.translator import translator from modeltranslation.utils import build_localized_fieldname -def ask_for_confirmation(sql_sentences, model_full_name, interactive): +def ask_for_confirmation(sql_sentences: list[str], model_full_name: str, interactive: bool) -> bool: print('\nSQL to synchronize "%s" schema:' % model_full_name) for sentence in sql_sentences: print(" %s" % sentence) @@ -38,7 +43,7 @@ def ask_for_confirmation(sql_sentences, model_full_name, interactive): return False -def print_missing_langs(missing_langs, field_name, model_name): +def print_missing_langs(missing_langs: list[str], field_name: str, model_name: str) -> None: print( 'Missing languages in "%s" field from "%s" model: %s' % (field_name, model_name, ", ".join(missing_langs)) @@ -55,7 +60,7 @@ class Command(BaseCommand): if VERSION < (1, 8): from optparse import make_option - option_list = BaseCommand.option_list + ( + option_list = BaseCommand.option_list + ( # type: ignore make_option( "--noinput", action="store_false", @@ -66,7 +71,7 @@ class Command(BaseCommand): ) else: - def add_arguments(self, parser): + def add_arguments(self, parser: CommandParser) -> None: ( parser.add_argument( "--noinput", @@ -77,7 +82,7 @@ class Command(BaseCommand): ), ) - def handle(self, *args, **options): + def handle(self, *args: Any, **options: Any) -> None: """ Command execution. """ @@ -120,14 +125,14 @@ class Command(BaseCommand): if not found_missing_fields: print("No new translatable fields detected") - def get_table_fields(self, db_table): + def get_table_fields(self, db_table: str) -> list[str]: """ Gets table fields from schema. """ db_table_desc = self.introspection.get_table_description(self.cursor, db_table) return [t[0] for t in db_table_desc] - def get_missing_languages(self, field_name, db_table): + def get_missing_languages(self, field_name: str, db_table: str) -> Iterator[str]: """ Gets only missing fields. """ @@ -136,19 +141,21 @@ class Command(BaseCommand): if build_localized_fieldname(field_name, lang_code) not in db_table_fields: yield lang_code - def get_sync_sql(self, field_name, missing_langs, model): + def get_sync_sql( + self, field_name: str, missing_langs: list[str], model: type[Model] + ) -> list[str]: """ Returns SQL needed for sync schema for a new translatable field. """ qn = connection.ops.quote_name style = no_style() - sql_output = [] + sql_output: list[str] = [] db_table = model._meta.db_table for lang in missing_langs: new_field = build_localized_fieldname(field_name, lang) - f = model._meta.get_field(new_field) + f = cast(Field, model._meta.get_field(new_field)) col_type = f.db_type(connection=connection) - field_sql = [style.SQL_FIELD(qn(f.column)), style.SQL_COLTYPE(col_type)] + field_sql = [style.SQL_FIELD(qn(f.column)), style.SQL_COLTYPE(col_type)] # type: ignore[arg-type] # column creation stmt = "ALTER TABLE %s ADD COLUMN %s" % (qn(db_table), " ".join(field_sql)) if not f.null: diff --git a/modeltranslation/management/commands/update_translation_fields.py b/modeltranslation/management/commands/update_translation_fields.py index 6f3b17e..2c7b171 100644 --- a/modeltranslation/management/commands/update_translation_fields.py +++ b/modeltranslation/management/commands/update_translation_fields.py @@ -1,4 +1,6 @@ -from django.core.management.base import BaseCommand, CommandError +from typing import Any + +from django.core.management.base import BaseCommand, CommandParser, CommandError from django.db.models import F, ManyToManyField, Q from modeltranslation.settings import AVAILABLE_LANGUAGES, DEFAULT_LANGUAGE @@ -14,7 +16,7 @@ class Command(BaseCommand): " values from original fields (in all translated models)." ) - def add_arguments(self, parser): + def add_arguments(self, parser: CommandParser) -> None: parser.add_argument( "app_label", nargs="?", @@ -34,8 +36,8 @@ class Command(BaseCommand): ), ) - def handle(self, *args, **options): - verbosity = options["verbosity"] + def handle(self, *args: Any, **options: Any) -> None: + verbosity: int = options["verbosity"] if verbosity > 0: self.stdout.write("Using default language: %s" % DEFAULT_LANGUAGE) @@ -94,9 +96,9 @@ class Command(BaseCommand): for inst in getattr(model, field_name).through.objects.all() ) continue - if field.empty_strings_allowed: + if field.empty_strings_allowed: # type: ignore[union-attr] q |= Q(**{def_lang_fieldname: ""}) - model._default_manager.filter(q).rewrite(False).order_by().update( + model._default_manager.filter(q).rewrite(False).order_by().update( # type: ignore[attr-defined] **{def_lang_fieldname: F(field_name)} ) diff --git a/modeltranslation/manager.py b/modeltranslation/manager.py index 9ddf170..449e532 100644 --- a/modeltranslation/manager.py +++ b/modeltranslation/manager.py @@ -4,14 +4,17 @@ django-linguo by Zach Mathew https://github.com/zmathew/django-linguo """ +from __future__ import annotations + import itertools from functools import reduce -from typing import List, Tuple, Type, Any, Optional +from typing import Any, Container, Iterator, Literal, Sequence, TypeVar, cast, overload from django import VERSION from django.contrib.admin.utils import get_model_from_relation from django.core.exceptions import FieldDoesNotExist from django.db import models +from django.db.backends.utils import CursorWrapper from django.db.models import Field, Model from django.db.models.expressions import Col from django.db.models.lookups import Lookup @@ -27,12 +30,13 @@ from modeltranslation.utils import ( get_language, resolution_order, ) +from modeltranslation._typing import Self, AutoPopulate -_C2F_CACHE = {} -_F2TM_CACHE = {} +_C2F_CACHE: dict[tuple[type[Model], str], Field] = {} +_F2TM_CACHE: dict[type[Model], dict[str, type[Model]]] = {} -def get_translatable_fields_for_model(model): +def get_translatable_fields_for_model(model: type[Model]) -> list[str] | None: from modeltranslation.translator import NotRegistered, translator try: @@ -41,7 +45,7 @@ def get_translatable_fields_for_model(model): return None -def rewrite_lookup_key(model, lookup_key): +def rewrite_lookup_key(model: type[Model], lookup_key: str) -> str: try: pieces = lookup_key.split("__", 1) original_key = pieces[0] @@ -66,38 +70,38 @@ def rewrite_lookup_key(model, lookup_key): return lookup_key -def append_fallback(model, fields): +def append_fallback(model: type[Model], fields: Sequence[str]) -> tuple[set[str], set[str]]: """ If translated field is encountered, add also all its fallback fields. Returns tuple: (set_of_new_fields_to_use, set_of_translated_field_names) """ - fields = set(fields) - trans = set() + fields_set = set(fields) + trans: set[str] = set() from modeltranslation.translator import translator opts = translator.get_options_for_model(model) for key, _ in opts.fields.items(): - if key in fields: + if key in fields_set: langs = resolution_order(get_language(), getattr(model, key).fallback_languages) - fields = fields.union(build_localized_fieldname(key, lang) for lang in langs) - fields.remove(key) + fields_set = fields_set.union(build_localized_fieldname(key, lang) for lang in langs) + fields_set.remove(key) trans.add(key) - return fields, trans + return fields_set, trans -def append_translated(model, fields): +def append_translated(model: type[Model], fields: Sequence[str]) -> set[str]: "If translated field is encountered, add also all its translation fields." - fields = set(fields) + fields_set = set(fields) from modeltranslation.translator import translator opts = translator.get_options_for_model(model) for key, translated in opts.fields.items(): - if key in fields: - fields = fields.union(f.name for f in translated) - return fields + if key in fields_set: + fields_set = fields_set.union(f.name for f in translated) + return fields_set -def append_lookup_key(model, lookup_key): +def append_lookup_key(model: type[Model], lookup_key: str) -> set[str]: "Transform spanned__lookup__key into all possible translation versions, on all levels" pieces = lookup_key.split("__", 1) @@ -115,19 +119,19 @@ def append_lookup_key(model, lookup_key): return fields -def append_lookup_keys(model, fields): +def append_lookup_keys(model: type[Model], fields: Sequence[str]) -> set[str]: new_fields = [] for field in fields: try: - new_field = append_lookup_key(model, field) + new_field: Container[str] = append_lookup_key(model, field) except AttributeError: new_field = (field,) new_fields.append(new_field) - return reduce(set.union, new_fields, set()) + return reduce(set.union, new_fields, set()) # type: ignore[arg-type] -def rewrite_order_lookup_key(model, lookup_key): +def rewrite_order_lookup_key(model: type[Model], lookup_key: str) -> str: try: if lookup_key.startswith("-"): return "-" + rewrite_lookup_key(model, lookup_key[1:]) @@ -137,33 +141,33 @@ def rewrite_order_lookup_key(model, lookup_key): return lookup_key -def get_fields_to_translatable_models(model): +def get_fields_to_translatable_models(model: type[Model]) -> dict[str, type[Model]]: if model in _F2TM_CACHE: return _F2TM_CACHE[model] - results = [] + results: list[tuple[str, type[Model]]] = [] for f in model._meta.get_fields(): if f.is_relation and f.related_model: # The new get_field() will find GenericForeignKey relations. # In that case the 'related_model' attribute is set to None # so it is necessary to check for this value before trying to # get translatable fields. - related_model = get_model_from_relation(f) + related_model = get_model_from_relation(f) # type: ignore[arg-type] if get_translatable_fields_for_model(related_model) is not None: results.append((f.name, related_model)) _F2TM_CACHE[model] = dict(results) return _F2TM_CACHE[model] -def get_field_by_colum_name(model, col): +def get_field_by_colum_name(model: type[Model], col: str) -> Field: # First, try field with the column name try: - field = model._meta.get_field(col) + field = cast(Field, model._meta.get_field(col)) if field.column == col: return field except FieldDoesNotExist: pass - field = _C2F_CACHE.get((model, col), None) + field = _C2F_CACHE.get((model, col), None) # type: ignore[arg-type] if field: return field # D'oh, need to search through all of them. @@ -174,12 +178,15 @@ def get_field_by_colum_name(model, col): assert False, "No field found for column %s" % col -class MultilingualQuerySet(QuerySet): - def __init__(self, *args, **kwargs): +_T = TypeVar("_T", bound=Model, covariant=True) + + +class MultilingualQuerySet(QuerySet[_T]): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self._post_init() - def _post_init(self): + def _post_init(self) -> None: self._rewrite = True self._populate = None if self.model and self.query.default_ordering and (not self.query.order_by): @@ -194,10 +201,10 @@ class MultilingualQuerySet(QuerySet): def __reduce__(self): return multilingual_queryset_factory, (self.__class__.__bases__[0],), self.__getstate__() - def _clone(self): + def _clone(self) -> Self: return self.__clone() - def __clone(self, **kwargs): + def __clone(self, **kwargs: Any) -> Self: # This method is private, so outside code can use default _clone without `kwargs`, # and we're here can use private version with `kwargs`. # Refs: https://github.com/deschler/django-modeltranslation/issues/483 @@ -211,16 +218,16 @@ class MultilingualQuerySet(QuerySet): cloned.__dict__.update(kwargs) return cloned - def rewrite(self, mode=True): + def rewrite(self, mode: bool = True) -> Self: return self.__clone(_rewrite=mode) - def populate(self, mode="all"): + def populate(self, mode: AutoPopulate = "all") -> Self: """ Overrides the translation fields population mode for this query set. """ return self.__clone(_populate=mode) - def _rewrite_applied_operations(self): + def _rewrite_applied_operations(self) -> None: """ Rewrite fields in already applied filters/ordering. Useful when converting any QuerySet into MultilingualQuerySet. @@ -230,14 +237,14 @@ class MultilingualQuerySet(QuerySet): self._rewrite_select_related() # This method was not present in django-linguo - def select_related(self, *fields, **kwargs): + def select_related(self, *fields: Any, **kwargs: Any) -> Self: if not self._rewrite: return super().select_related(*fields, **kwargs) # TO CONSIDER: whether this should rewrite only current language, or all languages? # fk -> [fk, fk_en] (with en=active) VS fk -> [fk, fk_en, fk_de, fk_fr ...] (for all langs) # new_args = append_lookup_keys(self.model, fields) - new_args = [] + new_args: list[str | None] = [] for key in fields: if key is None: new_args.append(None) @@ -246,7 +253,7 @@ class MultilingualQuerySet(QuerySet): return super().select_related(*new_args, **kwargs) # This method was not present in django-linguo - def _rewrite_col(self, col): + def _rewrite_col(self, col: Col) -> None: """Django >= 1.7 column name rewriting""" if isinstance(col, Col): new_name = rewrite_lookup_key(self.model, col.target.name) @@ -260,7 +267,7 @@ class MultilingualQuerySet(QuerySet): elif hasattr(col, "lhs"): self._rewrite_col(col.lhs) - def _rewrite_where(self, q): + def _rewrite_where(self, q: Lookup | Node) -> None: """ Rewrite field names inside WHERE tree. """ @@ -268,14 +275,14 @@ class MultilingualQuerySet(QuerySet): self._rewrite_col(q.lhs) if isinstance(q, Node): for child in q.children: - self._rewrite_where(child) + self._rewrite_where(child) # type: ignore[arg-type] - def _rewrite_order(self): + def _rewrite_order(self) -> None: self.query.order_by = [ rewrite_order_lookup_key(self.model, field_name) for field_name in self.query.order_by ] - def _rewrite_select_related(self): + def _rewrite_select_related(self) -> None: if isinstance(self.query.select_related, dict): new = {} for field_name, value in self.query.select_related.items(): @@ -283,16 +290,16 @@ class MultilingualQuerySet(QuerySet): self.query.select_related = new # This method was not present in django-linguo - def _rewrite_q(self, q): + def _rewrite_q(self, q: Node | tuple[str, Any]) -> Any: """Rewrite field names inside Q call.""" if isinstance(q, tuple) and len(q) == 2: return rewrite_lookup_key(self.model, q[0]), q[1] if isinstance(q, Node): - q.children = list(map(self._rewrite_q, q.children)) + q.children = list(map(self._rewrite_q, q.children)) # type: ignore[arg-type] return q # This method was not present in django-linguo - def _rewrite_f(self, q): + def _rewrite_f(self, q: models.F | Node) -> models.F | Node: """ Rewrite field names inside F call. """ @@ -300,7 +307,7 @@ class MultilingualQuerySet(QuerySet): q.name = rewrite_lookup_key(self.model, q.name) return q if isinstance(q, Node): - q.children = list(map(self._rewrite_f, q.children)) + q.children = list(map(self._rewrite_f, q.children)) # type: ignore[arg-type] # Django >= 1.8 if hasattr(q, "lhs"): q.lhs = self._rewrite_f(q.lhs) @@ -308,7 +315,7 @@ class MultilingualQuerySet(QuerySet): q.rhs = self._rewrite_f(q.rhs) return q - def _rewrite_filter_or_exclude(self, args, kwargs): + def _rewrite_filter_or_exclude(self, args: Any, kwargs: Any) -> tuple[Any, Any]: if not self._rewrite: return args, kwargs args = tuple(map(self._rewrite_q, args)) @@ -320,17 +327,17 @@ class MultilingualQuerySet(QuerySet): if VERSION >= (3, 2): - def _filter_or_exclude(self, negate, args, kwargs): + def _filter_or_exclude(self, negate: bool, args: Any, kwargs: Any) -> Self: args, kwargs = self._rewrite_filter_or_exclude(args, kwargs) return super()._filter_or_exclude(negate, args, kwargs) else: - def _filter_or_exclude(self, negate, *args, **kwargs): + def _filter_or_exclude(self, negate: bool, *args: Any, **kwargs: Any) -> Self: args, kwargs = self._rewrite_filter_or_exclude(args, kwargs) return super()._filter_or_exclude(negate, *args, **kwargs) - def _get_original_fields(self): + def _get_original_fields(self) -> list[str]: source = ( self.model._meta.concrete_fields if hasattr(self.model._meta, "concrete_fields") @@ -338,7 +345,7 @@ class MultilingualQuerySet(QuerySet): ) return [f.attname for f in source if not isinstance(f, TranslationField)] - def order_by(self, *field_names): + def order_by(self, *field_names: Any) -> Self: """ Change translatable field names in an ``order_by`` argument to translation fields for the current language. @@ -350,7 +357,7 @@ class MultilingualQuerySet(QuerySet): new_args.append(rewrite_order_lookup_key(self.model, key)) return super().order_by(*new_args) - def distinct(self, *field_names): + def distinct(self, *field_names: Any) -> Self: """ Change translatable field names in an ``distinct`` argument to translation fields for the current language. @@ -362,7 +369,7 @@ class MultilingualQuerySet(QuerySet): new_args.append(rewrite_order_lookup_key(self.model, key)) return super().distinct(*new_args) - def update(self, **kwargs): + def update(self, **kwargs: Any) -> int: if not self._rewrite: return super().update(**kwargs) for key, val in list(kwargs.items()): @@ -373,7 +380,7 @@ class MultilingualQuerySet(QuerySet): update.alters_data = True - def _update(self, values: List[Tuple[Field, Optional[Type[Model]], Any]]): + def _update(self, values: list[tuple[Field, type[Model] | None, Any]]) -> CursorWrapper: """ This method is called in .save() method to update an existing record. Here we force to update translation fields as well if the original @@ -384,11 +391,11 @@ class MultilingualQuerySet(QuerySet): # Currently, we don't synchronize values of the original and default translation fields in that case. field_names_to_update = {field.name for field, *_ in values} - translation_values = [] + translation_values: list[tuple[Field, type[Model] | None, Any]] = [] for field, model, value in values: translation_field_name = rewrite_lookup_key(self.model, field.name) if translation_field_name not in field_names_to_update: - translatable_field = self.model._meta.get_field(translation_field_name) + translatable_field = cast(Field, self.model._meta.get_field(translation_field_name)) translation_values.append((translatable_field, model, value)) values += translation_values @@ -396,14 +403,14 @@ class MultilingualQuerySet(QuerySet): # This method was not present in django-linguo @property - def _populate_mode(self): + def _populate_mode(self) -> AutoPopulate: # Populate can be set using a global setting or a manager method. if self._populate is None: return auto_populate_mode() return self._populate # This method was not present in django-linguo - def create(self, **kwargs): + def create(self, **kwargs: Any) -> _T: """ Allows to override population mode with a ``populate`` method. """ @@ -411,7 +418,7 @@ class MultilingualQuerySet(QuerySet): return super().create(**kwargs) # This method was not present in django-linguo - def get_or_create(self, *args, **kwargs): + def get_or_create(self, *args: Any, **kwargs: Any) -> tuple[_T, bool]: """ Allows to override population mode with a ``populate`` method. """ @@ -419,20 +426,20 @@ class MultilingualQuerySet(QuerySet): return super().get_or_create(*args, **kwargs) # This method was not present in django-linguo - def defer(self, *fields): - fields = append_lookup_keys(self.model, fields) + def defer(self, *fields: Any) -> Self: + fields = append_lookup_keys(self.model, fields) # type: ignore[assignment] return super().defer(*fields) # This method was not present in django-linguo - def only(self, *fields): - fields = append_lookup_keys(self.model, fields) + def only(self, *fields: Any) -> Self: + fields = append_lookup_keys(self.model, fields) # type: ignore[assignment] return super().only(*fields) # This method was not present in django-linguo - def raw_values(self, *fields, **expressions): + def raw_values(self, *fields: str, **expressions: Any) -> Self: return super().values(*fields, **expressions) - def _values(self, *original, **kwargs): + def _values(self, *original: str, **kwargs: Any) -> Self: selects_all = kwargs.pop("selects_all", False) if not kwargs.pop("prepare", False): return super()._values(*original, **kwargs) @@ -445,20 +452,20 @@ class MultilingualQuerySet(QuerySet): return clone # This method was not present in django-linguo - def values(self, *fields, **expressions): + def values(self, *fields: str, **expressions: Any) -> Self: if not self._rewrite: return super().values(*fields, **expressions) selects_all = not fields if not fields: # Emulate original queryset behaviour: get all fields that are not translation fields - fields = self._get_original_fields() + fields = self._get_original_fields() # type: ignore[assignment] fields += tuple(expressions) clone = self._values(*fields, prepare=True, selects_all=selects_all, **expressions) clone._iterable_class = FallbackValuesIterable return clone # This method was not present in django-linguo - def values_list(self, *fields, flat=False, named=False): + def values_list(self, *fields: str, flat: bool = False, named: bool = False) -> Self: if not self._rewrite: return super().values_list(*fields, flat=flat, named=named) if flat and named: @@ -470,7 +477,7 @@ class MultilingualQuerySet(QuerySet): selects_all = not fields if not fields: # Emulate original queryset behaviour: get all fields that are not translation fields - fields = self._get_original_fields() + fields = self._get_original_fields() # type: ignore[assignment] field_names = {f for f in fields if not hasattr(f, "resolve_expression")} _fields = [] @@ -500,7 +507,7 @@ class MultilingualQuerySet(QuerySet): return clone # This method was not present in django-linguo - def dates(self, field_name, *args, **kwargs): + def dates(self, field_name: str, *args: Any, **kwargs: Any) -> Self: if not self._rewrite: return super().dates(field_name, *args, **kwargs) new_key = rewrite_lookup_key(self.model, field_name) @@ -508,11 +515,13 @@ class MultilingualQuerySet(QuerySet): class FallbackValuesIterable(ValuesIterable): + queryset: MultilingualQuerySet[Model] + class X: # This stupid class is needed as object use __slots__ and has no __dict__. pass - def __iter__(self): + def __iter__(self) -> Iterator[dict[str, Any]]: instance = self.X() fields = self.queryset.original_fields @@ -527,13 +536,13 @@ class FallbackValuesIterable(ValuesIterable): class FallbackValuesListIterable(FallbackValuesIterable): - def __iter__(self): + def __iter__(self) -> Iterator[tuple[Any, ...]]: for row in super().__iter__(): yield tuple(row.values()) class FallbackNamedValuesListIterable(FallbackValuesIterable): - def __iter__(self): + def __iter__(self) -> Iterator[tuple[Any, ...]]: for row in super().__iter__(): names, values = row.keys(), row.values() tuple_class = create_namedtuple_class(*names) @@ -542,51 +551,68 @@ class FallbackNamedValuesListIterable(FallbackValuesIterable): class FallbackFlatValuesListIterable(FallbackValuesListIterable): - def __iter__(self): + def __iter__(self) -> Iterator[Any]: for row in super().__iter__(): yield row[0] -def multilingual_queryset_factory(old_cls, instantiate=True): +@overload +def multilingual_queryset_factory( + old_cls: type[Any], instantiate: Literal[False] +) -> type[MultilingualQuerySet]: + ... + + +@overload +def multilingual_queryset_factory( + old_cls: type[Any], instantiate: Literal[True] = ... +) -> MultilingualQuerySet: + ... + + +def multilingual_queryset_factory( + old_cls: type[Any], instantiate: bool = True +) -> type[MultilingualQuerySet] | MultilingualQuerySet: if old_cls == models.query.QuerySet: NewClass = MultilingualQuerySet else: - class NewClass(old_cls, MultilingualQuerySet): + class NewClass(old_cls, MultilingualQuerySet): # type: ignore[no-redef] pass NewClass.__name__ = "Multilingual%s" % old_cls.__name__ return NewClass() if instantiate else NewClass -class MultilingualQuerysetManager(models.Manager): +class MultilingualQuerysetManager(models.Manager[_T]): """ This class gets hooked in MRO just before plain Manager, so that every call to get_queryset returns MultilingualQuerySet. """ - def get_queryset(self): + def get_queryset(self) -> MultilingualQuerySet[_T]: qs = super().get_queryset() return self._patch_queryset(qs) - def _patch_queryset(self, qs): + def _patch_queryset(self, qs: QuerySet[_T]) -> MultilingualQuerySet[_T]: qs.__class__ = multilingual_queryset_factory(qs.__class__, instantiate=False) + qs = cast(MultilingualQuerySet[_T], qs) qs._post_init() qs._rewrite_applied_operations() return qs -class MultilingualManager(MultilingualQuerysetManager): - def rewrite(self, *args, **kwargs): +class MultilingualManager(MultilingualQuerysetManager[_T]): + def rewrite(self, *args: Any, **kwargs: Any): return self.get_queryset().rewrite(*args, **kwargs) - def populate(self, *args, **kwargs): + def populate(self, *args: Any, **kwargs: Any): return self.get_queryset().populate(*args, **kwargs) - def raw_values(self, *args, **kwargs): + def raw_values(self, *args: Any, **kwargs: Any): return self.get_queryset().raw_values(*args, **kwargs) - def get_queryset(self): + def get_queryset(self) -> MultilingualQuerySet[_T]: """ This method is repeated because some managers that don't use super() or alter queryset class may return queryset that is not subclass of MultilingualQuerySet. diff --git a/modeltranslation/models.py b/modeltranslation/models.py index 5566fba..2087eed 100644 --- a/modeltranslation/models.py +++ b/modeltranslation/models.py @@ -1,4 +1,7 @@ -def autodiscover(): +from typing import Any + + +def autodiscover() -> None: """ Auto-discover INSTALLED_APPS translation.py modules and fail silently when not present. This forces an import on them to register. @@ -57,7 +60,7 @@ def autodiscover(): pass -def handle_translation_registrations(*args, **kwargs): +def handle_translation_registrations(*args: Any, **kwargs: Any) -> None: """ Ensures that any configuration of the TranslationOption(s) are handled when importing modeltranslation. diff --git a/modeltranslation/settings.py b/modeltranslation/settings.py index 222ef3f..1a659b1 100644 --- a/modeltranslation/settings.py +++ b/modeltranslation/settings.py @@ -1,45 +1,57 @@ +from __future__ import annotations + from django.conf import settings from django.core.exceptions import ImproperlyConfigured -TRANSLATION_FILES = tuple(getattr(settings, "MODELTRANSLATION_TRANSLATION_FILES", ())) +from ._typing import AutoPopulate -AVAILABLE_LANGUAGES = list( +TRANSLATION_FILES: tuple[str, ...] = tuple( + getattr(settings, "MODELTRANSLATION_TRANSLATION_FILES", ()) +) + +AVAILABLE_LANGUAGES: list[str] = list( getattr( settings, "MODELTRANSLATION_LANGUAGES", (val for val, label in settings.LANGUAGES), ) ) -DEFAULT_LANGUAGE = getattr(settings, "MODELTRANSLATION_DEFAULT_LANGUAGE", None) -if DEFAULT_LANGUAGE and DEFAULT_LANGUAGE not in AVAILABLE_LANGUAGES: +_default_language: str | None = getattr(settings, "MODELTRANSLATION_DEFAULT_LANGUAGE", None) +if _default_language and _default_language not in AVAILABLE_LANGUAGES: raise ImproperlyConfigured("MODELTRANSLATION_DEFAULT_LANGUAGE not in LANGUAGES setting.") -elif not DEFAULT_LANGUAGE: - DEFAULT_LANGUAGE = AVAILABLE_LANGUAGES[0] +DEFAULT_LANGUAGE = _default_language or AVAILABLE_LANGUAGES[0] # Fixed base language for prepopulated fields (slugs) # (If not set, the current request language will be used) -PREPOPULATE_LANGUAGE = getattr(settings, "MODELTRANSLATION_PREPOPULATE_LANGUAGE", None) +PREPOPULATE_LANGUAGE: str | None = getattr(settings, "MODELTRANSLATION_PREPOPULATE_LANGUAGE", None) if PREPOPULATE_LANGUAGE and PREPOPULATE_LANGUAGE not in AVAILABLE_LANGUAGES: raise ImproperlyConfigured("MODELTRANSLATION_PREPOPULATE_LANGUAGE not in LANGUAGES setting.") # Load allowed CUSTOM_FIELDS from django settings -CUSTOM_FIELDS = getattr(settings, "MODELTRANSLATION_CUSTOM_FIELDS", ()) +CUSTOM_FIELDS: tuple[str, ...] = getattr(settings, "MODELTRANSLATION_CUSTOM_FIELDS", ()) # Don't change this setting unless you really know what you are doing -ENABLE_REGISTRATIONS = getattr(settings, "MODELTRANSLATION_ENABLE_REGISTRATIONS", settings.USE_I18N) +ENABLE_REGISTRATIONS: bool = getattr( + settings, "MODELTRANSLATION_ENABLE_REGISTRATIONS", settings.USE_I18N +) # Modeltranslation specific debug setting -DEBUG = getattr(settings, "MODELTRANSLATION_DEBUG", False) +DEBUG: bool = getattr(settings, "MODELTRANSLATION_DEBUG", False) -AUTO_POPULATE = getattr(settings, "MODELTRANSLATION_AUTO_POPULATE", False) +AUTO_POPULATE: AutoPopulate = getattr(settings, "MODELTRANSLATION_AUTO_POPULATE", False) # FALLBACK_LANGUAGES should be in either format: # MODELTRANSLATION_FALLBACK_LANGUAGES = ('en', 'de') # MODELTRANSLATION_FALLBACK_LANGUAGES = {'default': ('en', 'de'), 'fr': ('de',)} # By default we fallback to the default language -FALLBACK_LANGUAGES = getattr(settings, "MODELTRANSLATION_FALLBACK_LANGUAGES", (DEFAULT_LANGUAGE,)) -if isinstance(FALLBACK_LANGUAGES, (tuple, list)): - FALLBACK_LANGUAGES = {"default": tuple(FALLBACK_LANGUAGES)} +_fallback_languages = getattr(settings, "MODELTRANSLATION_FALLBACK_LANGUAGES", (DEFAULT_LANGUAGE,)) +if isinstance(_fallback_languages, (tuple, list)): + _fallback_languages = {"default": tuple(_fallback_languages)} + +# To please mypy, explicitly annotate: +FALLBACK_LANGUAGES: dict[str, tuple[str, ...]] = _fallback_languages + + if "default" not in FALLBACK_LANGUAGES: raise ImproperlyConfigured( 'MODELTRANSLATION_FALLBACK_LANGUAGES does not contain "default" key.' @@ -58,16 +70,16 @@ for key, value in FALLBACK_LANGUAGES.items(): raise ImproperlyConfigured( 'MODELTRANSLATION_FALLBACK_LANGUAGES: "%s" not in LANGUAGES setting.' % lang ) -ENABLE_FALLBACKS = getattr(settings, "MODELTRANSLATION_ENABLE_FALLBACKS", True) +ENABLE_FALLBACKS: bool = getattr(settings, "MODELTRANSLATION_ENABLE_FALLBACKS", True) -LOADDATA_RETAIN_LOCALE = getattr(settings, "MODELTRANSLATION_LOADDATA_RETAIN_LOCALE", True) +LOADDATA_RETAIN_LOCALE: bool = getattr(settings, "MODELTRANSLATION_LOADDATA_RETAIN_LOCALE", True) -JQUERY_URL = getattr( +JQUERY_URL: str = getattr( settings, "JQUERY_URL", "//ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js", ) -JQUERY_UI_URL = getattr( +JQUERY_UI_URL: str = getattr( settings, "JQUERY_UI_URL", "//ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js", diff --git a/modeltranslation/tests/migrations/0001_initial.py b/modeltranslation/tests/migrations/0001_initial.py index e47d226..3e3bdeb 100644 --- a/modeltranslation/tests/migrations/0001_initial.py +++ b/modeltranslation/tests/migrations/0001_initial.py @@ -2,8 +2,9 @@ import django.contrib.auth.models import django.core.validators -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models + import modeltranslation.tests.models diff --git a/modeltranslation/tests/tests.py b/modeltranslation/tests/tests.py index 3adab40..adf2701 100644 --- a/modeltranslation/tests/tests.py +++ b/modeltranslation/tests/tests.py @@ -24,9 +24,8 @@ from django.test.utils import override_settings from django.utils.translation import get_language, override, trans_real from parameterized import parameterized -from modeltranslation import admin +from modeltranslation import admin, translator from modeltranslation import settings as mt_settings -from modeltranslation import translator from modeltranslation.forms import TranslationModelForm from modeltranslation.manager import MultilingualManager from modeltranslation.models import autodiscover diff --git a/modeltranslation/thread_context.py b/modeltranslation/thread_context.py index bf5d5ca..a772cfe 100644 --- a/modeltranslation/thread_context.py +++ b/modeltranslation/thread_context.py @@ -1,27 +1,28 @@ +from __future__ import annotations + import threading -from typing import Literal, Union from modeltranslation import settings -AutoPopulate = Union[bool, Literal["all", "default", "required"]] +from ._typing import AutoPopulate class ModelTranslationThreadLocal(threading.local): """Holds thread-local data for modeltranslation.""" - auto_populate: Union[AutoPopulate, None] = None - enable_fallbacks: Union[bool, None] = None + auto_populate: AutoPopulate | None = None + enable_fallbacks: bool | None = None _mt_thread_context = ModelTranslationThreadLocal() -def set_auto_populate(value: Union[AutoPopulate, None]) -> None: +def set_auto_populate(value: AutoPopulate | None) -> None: """Set the auto_populate for the current thread.""" _mt_thread_context.auto_populate = value -def set_enable_fallbacks(value: Union[bool, None]) -> None: +def set_enable_fallbacks(value: bool | None) -> None: """Set the enable_fallbacks for the current thread.""" _mt_thread_context.enable_fallbacks = value diff --git a/modeltranslation/translator.py b/modeltranslation/translator.py index 2fee5ca..0c95e91 100644 --- a/modeltranslation/translator.py +++ b/modeltranslation/translator.py @@ -1,9 +1,19 @@ +from __future__ import annotations + from functools import partial -from typing import Callable, Iterable +from typing import Any, Callable, ClassVar, Collection, Iterable, Sequence, cast import django from django.core.exceptions import ImproperlyConfigured -from django.db.models import ForeignKey, Manager, ManyToManyField, OneToOneField, options +from django.db.models import ( + Field, + ForeignKey, + Manager, + ManyToManyField, + Model, + OneToOneField, + options, +) from django.db.models.base import ModelBase from django.db.models.signals import post_init from django.utils.functional import cached_property @@ -15,6 +25,7 @@ from modeltranslation.fields import ( TranslatedManyToManyDescriptor, TranslatedRelationIdDescriptor, TranslationFieldDescriptor, + TranslationField, create_translation_field, ) from modeltranslation.manager import ( @@ -26,6 +37,8 @@ from modeltranslation.manager import ( from modeltranslation.thread_context import auto_populate_mode from modeltranslation.utils import build_localized_fieldname, parse_field +from ._typing import _ListOrTuple + class AlreadyRegistered(Exception): pass @@ -44,7 +57,7 @@ class FieldsAggregationMetaClass(type): Metaclass to handle custom inheritance of fields between classes. """ - def __new__(cls, name, bases, attrs): + def __new__(cls, name: str, bases: tuple[type, ...], attrs: dict[str, Any]) -> type: attrs["fields"] = set(attrs.get("fields", ())) for base in bases: if isinstance(base, FieldsAggregationMetaClass): @@ -74,20 +87,20 @@ class TranslationOptions(metaclass=FieldsAggregationMetaClass): ``related_fields`` contains names of reverse lookup fields. """ - required_languages = () + required_languages: ClassVar[_ListOrTuple[str] | dict[str, _ListOrTuple[str]]] = () - def __init__(self, model): + def __init__(self, model: Model) -> None: """ Create fields dicts without any translation fields. """ self.model = model self.registered = False self.related = False - self.local_fields = {f: set() for f in self.fields} - self.fields = {f: set() for f in self.fields} - self.related_fields = [] + self.local_fields: dict[str, set[TranslationField]] = {f: set() for f in self.fields} + self.fields: dict[str, set[TranslationField]] = {f: set() for f in self.fields} + self.related_fields: list[str] = [] - def validate(self): + def validate(self) -> None: """ Perform options validation. """ @@ -104,14 +117,18 @@ class TranslationOptions(metaclass=FieldsAggregationMetaClass): "Fieldname in required_languages which is not in fields option." ) - def _check_languages(self, languages, extra=()): + def _check_languages( + self, + languages: Collection[str], + extra: tuple[str, ...] = (), + ) -> None: correct = list(mt_settings.AVAILABLE_LANGUAGES) + list(extra) if any(lang not in correct for lang in languages): raise ImproperlyConfigured( "Language in required_languages which is not in AVAILABLE_LANGUAGES." ) - def update(self, other): + def update(self, other: TranslationOptions): """ Update with options from a superclass. """ @@ -119,20 +136,20 @@ class TranslationOptions(metaclass=FieldsAggregationMetaClass): self.local_fields.update(other.local_fields) self.fields.update(other.fields) - def add_translation_field(self, field, translation_field): + def add_translation_field(self, field: str, translation_field): """ Add a new translation field to both fields dicts. """ self.local_fields[field].add(translation_field) self.fields[field].add(translation_field) - def get_field_names(self): + def get_field_names(self) -> list[str]: """ Return name of all fields that can be used in filtering. """ return list(self.fields.keys()) + self.related_fields - def __str__(self): + def __str__(self) -> str: local = tuple(self.local_fields.keys()) inherited = tuple(set(self.fields.keys()) - set(local)) return "%s: %s + %s" % (self.__class__.__name__, local, inherited) @@ -146,7 +163,7 @@ class MultilingualOptions(options.Options): return manager -def add_translation_fields(model, opts): +def add_translation_fields(model: type[Model], opts: TranslationOptions) -> None: """ Monkey patches the original model class to provide additional fields for every language. @@ -229,7 +246,7 @@ def patch_manager_class(manager): manager.__class__ = NewMultilingualManager -def add_manager(model): +def add_manager(model: type[Model]) -> None: """ Monkey patches the original model to use MultilingualManager instead of default managers (not only ``objects``, but also every manager defined and inherited). @@ -240,7 +257,7 @@ def add_manager(model): return # Make all managers local for this model to fix patching parent model managers added = set(model._meta.managers) - set(model._meta.local_managers) - model._meta.local_managers = model._meta.managers + model._meta.local_managers = model._meta.managers # type: ignore[assignment] for current_manager in model._meta.local_managers: prev_class = current_manager.__class__ @@ -262,7 +279,7 @@ def add_manager(model): model._meta._expire_cache() -def patch_constructor(model): +def patch_constructor(model: type[Model]) -> None: """ Monkey patches the original model to rewrite fields names in __init__ """ @@ -280,19 +297,19 @@ def patch_constructor(model): model.__init__ = new_init -def delete_mt_init(sender, instance, **kwargs): +def delete_mt_init(sender: type[Model], instance: Model, **kwargs: Any) -> None: if hasattr(instance, "_mt_init"): del instance._mt_init -def patch_clean_fields(model): +def patch_clean_fields(model: type[Model]): """ Patch clean_fields method to handle different form types submission. """ old_clean_fields = model.clean_fields - def new_clean_fields(self, exclude=None): - if hasattr(self, "_mt_form_pending_clear"): + def new_clean_fields(self, exclude: Collection[str] | None = None) -> None: + if hasattr(self, "_mt_form_pending_clear") and exclude is not None: # Some form translation fields has been marked as clearing value. # Check if corresponding translated field was also saved (not excluded): # - if yes, it seems like form for MT-unaware app. Ignore clearing (left value from @@ -313,7 +330,7 @@ def patch_clean_fields(model): model.clean_fields = new_clean_fields -def patch_get_deferred_fields(model): +def patch_get_deferred_fields(model: type[Model]) -> None: """ Django >= 1.8: patch detecting deferred fields. Crucial for only/defer to work. """ @@ -321,7 +338,7 @@ def patch_get_deferred_fields(model): return old_get_deferred_fields = model.get_deferred_fields - def new_get_deferred_fields(self): + def new_get_deferred_fields(self) -> set[str]: sup = old_get_deferred_fields(self) if hasattr(self, "_fields_were_deferred"): sup.update(self._fields_were_deferred) @@ -330,7 +347,7 @@ def patch_get_deferred_fields(model): model.get_deferred_fields = new_get_deferred_fields -def patch_refresh_from_db(model): +def patch_refresh_from_db(model: type[Model]) -> None: """ Django >= 1.10: patch refreshing deferred fields. Crucial for only/defer to work. """ @@ -338,15 +355,17 @@ def patch_refresh_from_db(model): return old_refresh_from_db = model.refresh_from_db - def new_refresh_from_db(self, using=None, fields=None): + def new_refresh_from_db( + self, using: str | None = None, fields: Sequence[str] | None = None + ) -> None: if fields is not None: - fields = append_translated(self.__class__, fields) + fields = append_translated(self.__class__, fields) # type: ignore[assignment] return old_refresh_from_db(self, using, fields) model.refresh_from_db = new_refresh_from_db -def delete_cache_fields(model): +def delete_cache_fields(model: type[Model]) -> None: opts = model._meta cached_attrs = ( "_field_cache", @@ -366,7 +385,7 @@ def delete_cache_fields(model): model._meta._expire_cache() -def populate_translation_fields(sender, kwargs): +def populate_translation_fields(sender: type[Model], kwargs: Any): """ When models are created or loaded from fixtures, replicates values provided for translatable fields to some / all empty translation fields, @@ -410,7 +429,7 @@ def populate_translation_fields(sender, kwargs): kwargs.setdefault(default, val) elif populate == "required": default = build_localized_fieldname(key, mt_settings.DEFAULT_LANGUAGE) - if not sender._meta.get_field(key).null: + if not sender._meta.get_field(key).null: # type: ignore[union-attr] kwargs.setdefault(default, val) else: raise AttributeError("Unknown population mode '%s'." % populate) @@ -441,13 +460,18 @@ class Translator: registered with the Translator using the register() method. """ - def __init__(self): + def __init__(self) -> None: # All seen models (model class -> ``TranslationOptions`` instance). - self._registry = {} + self._registry: dict[type[Model], TranslationOptions] = {} # List of funcs to execute after all imports are done. - self._lazy_operations: Iterable[Callable] = [] + self._lazy_operations: list[Callable[..., Any]] = [] - def register(self, model_or_iterable, opts_class=None, **options): + def register( + self, + model_or_iterable: type[Model] | Iterable[type[Model]], + opts_class: type[TranslationOptions] | None = None, + **options: Any, + ) -> None: """ Registers the given model(s) with the given translation options. @@ -491,7 +515,7 @@ class Translator: self._registry[model].registered = False raise - def _register_single_model(self, model, opts): + def _register_single_model(self, model: type[Model], opts: TranslationOptions) -> None: # Now, when all fields are initialized and inherited, validate configuration. opts.validate() @@ -536,7 +560,7 @@ class Translator: model_fallback_values = getattr(opts, "fallback_values", NONE) model_fallback_undefined = getattr(opts, "fallback_undefined", NONE) for field_name in opts.local_fields.keys(): - field = model._meta.get_field(field_name) + field = cast(Field, model._meta.get_field(field_name)) field_fallback_value = parse_field(model_fallback_values, field_name, NONE) field_fallback_undefined = parse_field(model_fallback_undefined, field_name, NONE) descriptor = TranslationFieldDescriptor( @@ -569,11 +593,12 @@ class Translator: if isinstance(field, OneToOneField): # Fix translated_field caching for SingleRelatedObjectDescriptor sro_descriptor = getattr( - field.remote_field.model, field.remote_field.get_accessor_name() + field.remote_field.model, + field.remote_field.get_accessor_name(), # type: ignore[arg-type] ) patch_related_object_descriptor_caching(sro_descriptor) - def unregister(self, model_or_iterable): + def unregister(self, model_or_iterable: type[Model] | Iterable[type[Model]]) -> None: """ Unregisters the given model(s). @@ -600,7 +625,7 @@ class Translator: ) del self._registry[desc] - def get_registered_models(self, abstract=True): + def get_registered_models(self, abstract: bool = True) -> list[type[Model]]: """ Returns a list of all registered models, or just concrete registered models. @@ -611,7 +636,9 @@ class Translator: if opts.registered and (not model._meta.abstract or abstract) ] - def _get_options_for_model(self, model, opts_class=None, **options): + def _get_options_for_model( + self, model: type[Model], opts_class: type[TranslationOptions] | None = None, **options: Any + ) -> TranslationOptions: """ Returns an instance of translation options with translated fields defined for the ``model`` and inherited from superclasses. @@ -640,7 +667,7 @@ class Translator: return self._registry[model] - def get_options_for_model(self, model) -> TranslationOptions: + def get_options_for_model(self, model: type[Model]) -> TranslationOptions: """ Thin wrapper around ``_get_options_for_model`` to preserve the semantic of throwing exception for models not directly registered. @@ -656,7 +683,7 @@ class Translator: while self._lazy_operations: self._lazy_operations.pop(0)(translator=self) - def lazy_operation(self, func: Callable, *args, **kwargs) -> None: + def lazy_operation(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> None: self._lazy_operations.append(partial(func, *args, **kwargs)) diff --git a/modeltranslation/utils.py b/modeltranslation/utils.py index 011cafb..2023ccd 100644 --- a/modeltranslation/utils.py +++ b/modeltranslation/utils.py @@ -1,4 +1,7 @@ +from __future__ import annotations + from contextlib import contextmanager +from typing import Any, Generator, Iterable, Iterator, TypeVar from django.db import models from django.utils.encoding import force_str @@ -13,8 +16,12 @@ from modeltranslation.thread_context import ( set_enable_fallbacks, ) +from ._typing import AutoPopulate -def get_language(): +_T = TypeVar("_T") + + +def get_language() -> str: """ Return an active language code that is guaranteed to be in settings.LANGUAGES (Django does not seem to guarantee this for us). @@ -29,7 +36,7 @@ def get_language(): return settings.DEFAULT_LANGUAGE -def get_language_bidi(lang): +def get_language_bidi(lang: str) -> bool: """ Check if a language is bi-directional. """ @@ -37,14 +44,14 @@ def get_language_bidi(lang): return lang_info["bidi"] -def get_translation_fields(field): +def get_translation_fields(field: str) -> list[str]: """ Returns a list of localized fieldnames for a given field. """ return [build_localized_fieldname(field, lang) for lang in settings.AVAILABLE_LANGUAGES] -def build_localized_fieldname(field_name, lang): +def build_localized_fieldname(field_name: str, lang: str) -> str: if lang == "id": # The 2-letter Indonesian language code is problematic with the # current naming scheme as Django foreign keys also add "id" suffix. @@ -52,7 +59,7 @@ def build_localized_fieldname(field_name, lang): return str("%s_%s" % (field_name, lang.replace("-", "_"))) -def _build_localized_verbose_name(verbose_name, lang): +def _build_localized_verbose_name(verbose_name: Any, lang: str) -> str: if lang == "id": lang = "ind" return force_str("%s [%s]") % (force_str(verbose_name), lang) @@ -61,13 +68,13 @@ def _build_localized_verbose_name(verbose_name, lang): build_localized_verbose_name = lazy(_build_localized_verbose_name, str) -def _join_css_class(bits, offset): +def _join_css_class(bits: list[str], offset: int) -> str: if "-".join(bits[-offset:]) in settings.AVAILABLE_LANGUAGES + ["en-us"]: return "%s-%s" % ("_".join(bits[: len(bits) - offset]), "_".join(bits[-offset:])) return "" -def build_css_class(localized_fieldname, prefix=""): +def build_css_class(localized_fieldname: str, prefix: str = ""): """ Returns a css class based on ``localized_fieldname`` which is easily splittable and capable of regionalized language codes. @@ -99,7 +106,7 @@ def build_css_class(localized_fieldname, prefix=""): return "%s-%s" % (prefix, css_class) if prefix else css_class -def unique(seq): +def unique(seq: Iterable[_T]) -> Generator[_T, None, None]: """ Returns a generator yielding unique sequence members in order @@ -109,10 +116,12 @@ def unique(seq): [1, 2, 3, 4] """ seen = set() - return (x for x in seq if x not in seen and not seen.add(x)) + return (x for x in seq if x not in seen and not seen.add(x)) # type: ignore[func-returns-value] -def resolution_order(lang, override=None): +def resolution_order( + lang: str, override: dict[str, tuple[str, ...]] | None = None +) -> tuple[str, ...]: """ Return order of languages which should be checked for parameter language. First is always the parameter language, later are fallback languages. @@ -130,7 +139,7 @@ def resolution_order(lang, override=None): @contextmanager -def auto_populate(mode="all"): +def auto_populate(mode: AutoPopulate = "all") -> Iterator[None]: """ Overrides translation fields population mode (population mode decides which unprovided translations will be filled during model construction / loading). @@ -156,7 +165,7 @@ def auto_populate(mode="all"): @contextmanager -def fallbacks(enable=True): +def fallbacks(enable: bool | None = True) -> Iterator[None]: """ Temporarily switch all language fallbacks on or off. @@ -176,7 +185,7 @@ def fallbacks(enable=True): set_enable_fallbacks(None) -def parse_field(setting, field_name, default): +def parse_field(setting: Any | dict[str, Any], field_name: str, default: Any) -> Any: """ Extract result from single-value or dict-type setting like fallback_values. """ @@ -186,7 +195,9 @@ def parse_field(setting, field_name, default): return setting -def build_localized_intermediary_model(intermediary_model: models.Model, lang: str) -> models.Model: +def build_localized_intermediary_model( + intermediary_model: type[models.Model], lang: str +) -> type[models.Model]: from modeltranslation.translator import translator meta = type( @@ -212,7 +223,7 @@ def build_localized_intermediary_model(intermediary_model: models.Model, lang: s (models.Model,), { **{k: v for k, v in dict(intermediary_model.__dict__).items() if k != "_meta"}, - **{f.name: f.clone() for f in intermediary_model._meta.fields}, + **{f.name: f.clone() for f in intermediary_model._meta.fields}, # type: ignore[attr-defined] "Meta": meta, }, ) diff --git a/modeltranslation/widgets.py b/modeltranslation/widgets.py index 994fa87..1569e43 100644 --- a/modeltranslation/widgets.py +++ b/modeltranslation/widgets.py @@ -1,6 +1,13 @@ +from __future__ import annotations + +from typing import Any, Mapping + +from django.core.files.uploadedfile import UploadedFile +from django.forms.renderers import BaseRenderer from django.forms.widgets import CheckboxInput, Media, Widget +from django.utils.datastructures import MultiValueDict from django.utils.html import conditional_escape -from django.utils.safestring import mark_safe +from django.utils.safestring import SafeString, mark_safe from django.utils.translation import gettext_lazy as _ @@ -30,7 +37,7 @@ class ClearableWidgetWrapper(Widget): class Media: js = ("modeltranslation/js/clearable_inputs.js",) - def __init__(self, widget, empty_value=None): + def __init__(self, widget: Widget, empty_value: Any | None = None) -> None: """ Remebers the widget we are wrapping and precreates a checkbox input. Allows overriding the empty value. @@ -39,7 +46,7 @@ class ClearableWidgetWrapper(Widget): self.checkbox = CheckboxInput(attrs={"tabindex": "-1"}) self.empty_value = empty_value - def __getattr__(self, name): + def __getattr__(self, name: str) -> Any: """ If we don't have a property or a method, chances are the wrapped widget does. @@ -56,7 +63,13 @@ class ClearableWidgetWrapper(Widget): """ return self.widget.media + self.checkbox.media + Media(self.Media) - def render(self, name, value, attrs=None, renderer=None): + def render( + self, + name: str, + value: Any, + attrs: dict[str, Any] | None = None, + renderer: BaseRenderer | None = None, + ) -> SafeString: """ Appends a checkbox for clearing the value (that is, setting the field with the ``empty_value``). @@ -77,7 +90,9 @@ class ClearableWidgetWrapper(Widget): ) ) - def value_from_datadict(self, data, files, name): + def value_from_datadict( + self, data: Mapping[str, Any], files: MultiValueDict[str, UploadedFile], name: str + ) -> Any: """ If the clear checkbox is checked returns the configured empty value, completely ignoring the original input. @@ -87,13 +102,13 @@ class ClearableWidgetWrapper(Widget): return self.empty_value return self.widget.value_from_datadict(data, files, name) - def clear_checkbox_name(self, name): + def clear_checkbox_name(self, name: str) -> str: """ Given the name of the input, returns the name of the clear checkbox. """ return name + "-clear" - def clear_checkbox_id(self, name): + def clear_checkbox_id(self, name: str) -> str: """ Given the name of the clear checkbox input, returns the HTML id for it. """ diff --git a/poetry.lock b/poetry.lock index a2f9488..12395b2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -163,18 +163,41 @@ argon2 = ["argon2-cffi (>=19.1.0)"] bcrypt = ["bcrypt"] [[package]] -name = "django-types" -version = "0.19.1" -description = "Type stubs for Django" +name = "django-stubs" +version = "4.2.7" +description = "Mypy stubs for Django" optional = false -python-versions = ">=3.7,<4.0" +python-versions = ">=3.8" files = [ - {file = "django_types-0.19.1-py3-none-any.whl", hash = "sha256:b3f529de17f6374d41ca67232aa01330c531bbbaa3ac4097896f31ac33c96c30"}, - {file = "django_types-0.19.1.tar.gz", hash = "sha256:5ae7988612cf6fbc357b018bbc3b3a878b65e04275cc46e0d35d66a708daff12"}, + {file = "django-stubs-4.2.7.tar.gz", hash = "sha256:8ccd2ff4ee5adf22b9e3b7b1a516d2e1c2191e9d94e672c35cc2bc3dd61e0f6b"}, + {file = "django_stubs-4.2.7-py3-none-any.whl", hash = "sha256:4cf4de258fa71adc6f2799e983091b9d46cfc67c6eebc68fe111218c9a62b3b8"}, ] [package.dependencies] -types-psycopg2 = ">=2.9.21.13" +django = "*" +django-stubs-ext = ">=4.2.7" +tomli = {version = "*", markers = "python_version < \"3.11\""} +types-pytz = "*" +types-PyYAML = "*" +typing-extensions = "*" + +[package.extras] +compatible-mypy = ["mypy (>=1.7.0,<1.8.0)"] + +[[package]] +name = "django-stubs-ext" +version = "4.2.7" +description = "Monkey-patching and extensions for django-stubs" +optional = false +python-versions = ">=3.8" +files = [ + {file = "django-stubs-ext-4.2.7.tar.gz", hash = "sha256:519342ac0849cda1559746c9a563f03ff99f636b0ebe7c14b75e816a00dfddc3"}, + {file = "django_stubs_ext-4.2.7-py3-none-any.whl", hash = "sha256:45a5d102417a412e3606e3c358adb4744988a92b7b58ccf3fd64bddd5d04d14c"}, +] + +[package.dependencies] +django = "*" +typing-extensions = "*" [[package]] name = "exceptiongroup" @@ -216,6 +239,64 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "mypy" +version = "1.8.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3"}, + {file = "mypy-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4"}, + {file = "mypy-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d"}, + {file = "mypy-1.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9"}, + {file = "mypy-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410"}, + {file = "mypy-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae"}, + {file = "mypy-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3"}, + {file = "mypy-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817"}, + {file = "mypy-1.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d"}, + {file = "mypy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835"}, + {file = "mypy-1.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd"}, + {file = "mypy-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55"}, + {file = "mypy-1.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218"}, + {file = "mypy-1.8.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3"}, + {file = "mypy-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e"}, + {file = "mypy-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6"}, + {file = "mypy-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66"}, + {file = "mypy-1.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6"}, + {file = "mypy-1.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d"}, + {file = "mypy-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02"}, + {file = "mypy-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8"}, + {file = "mypy-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259"}, + {file = "mypy-1.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b"}, + {file = "mypy-1.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592"}, + {file = "mypy-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a"}, + {file = "mypy-1.8.0-py3-none-any.whl", hash = "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d"}, + {file = "mypy-1.8.0.tar.gz", hash = "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=4.1.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + [[package]] name = "packaging" version = "23.2" @@ -455,25 +536,36 @@ files = [ ] [[package]] -name = "types-psycopg2" -version = "2.9.21.20240218" -description = "Typing stubs for psycopg2" +name = "types-pytz" +version = "2024.1.0.20240203" +description = "Typing stubs for pytz" optional = false python-versions = ">=3.8" files = [ - {file = "types-psycopg2-2.9.21.20240218.tar.gz", hash = "sha256:3084cd807038a62c80fb5be78b41d855b48a060316101ea59fd85c302efb57d4"}, - {file = "types_psycopg2-2.9.21.20240218-py3-none-any.whl", hash = "sha256:cac96264e063cbce28dee337a973d39e6df4ca671252343cb4f8e5ef6db5e67d"}, + {file = "types-pytz-2024.1.0.20240203.tar.gz", hash = "sha256:c93751ee20dfc6e054a0148f8f5227b9a00b79c90a4d3c9f464711a73179c89e"}, + {file = "types_pytz-2024.1.0.20240203-py3-none-any.whl", hash = "sha256:9679eef0365db3af91ef7722c199dbb75ee5c1b67e3c4dd7bfbeb1b8a71c21a3"}, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.12" +description = "Typing stubs for PyYAML" +optional = false +python-versions = "*" +files = [ + {file = "types-PyYAML-6.0.12.12.tar.gz", hash = "sha256:334373d392fde0fdf95af5c3f1661885fa10c52167b14593eb856289e1855062"}, + {file = "types_PyYAML-6.0.12.12-py3-none-any.whl", hash = "sha256:c05bc6c158facb0676674b7f11fe3960db4f389718e19e62bd2b84d6205cfd24"}, ] [[package]] name = "typing-extensions" -version = "4.8.0" +version = "4.9.0" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"}, - {file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"}, + {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, + {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, ] [[package]] @@ -507,4 +599,4 @@ test = ["pytest"] [metadata] lock-version = "2.0" python-versions = ">=3.8,<4" -content-hash = "1e5d8f3a7c1a4e2bbff30e4030c0bbb35ea30fce8f47e3977d6a41757b3dba2f" +content-hash = "e0e62ada8d6bf5e773ac7baee0f04747335ae082f9c3b23db06cb59f9f647e9b" diff --git a/pyproject.toml b/pyproject.toml index df6c03f..10b93a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,15 +12,17 @@ packages = [ python = ">=3.8,<4" django = ">=4.2" ruff = "^0.0.292" +typing-extensions = {version = ">=4.0.1", python = "<3.11"} -[tool.poetry.dev-dependencies] +[tool.poetry.group.dev.dependencies] pdbpp = "*" parameterized = "*" pytest-cov = "*" pytest = "*" pytest-sugar = "*" pytest-django = "*" -django-types = "*" +django-stubs = "^4.2.7" +mypy = "^1.8.0" [tool.ruff] line-length = 100 @@ -28,3 +30,32 @@ target-version = "py38" ignore = [ "E501", # line length is handled by formatter ] + +[tool.mypy] +python_version = "3.8" +incremental = true +warn_unused_ignores = true +warn_redundant_casts = true +warn_unused_configs = true +pretty = true +show_error_context = true +exclude = [ + 'tests/' +] +disable_error_code = ["method-assign"] + +[[tool.mypy.overrides]] +module = ["modeltranslation.fields"] +disable_error_code = ["attr-defined", "has-type", "misc"] + +[[tool.mypy.overrides]] +module = ["modeltranslation.admin"] +disable_error_code = ["override", "attr-defined"] + +[[tool.mypy.overrides]] +module = ["modeltranslation.translator"] +disable_error_code = ["override", "attr-defined"] + +[[tool.mypy.overrides]] +module = ["modeltranslation.manager"] +disable_error_code = ["override", "attr-defined", "return-value", "misc"] diff --git a/pyrightconfig.json b/pyrightconfig.json index 3226b70..d7a6470 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -1,6 +1,6 @@ { "include": ["."], - "pythonVersion": "3.7", + "pythonVersion": "3.8", "pythonPlatform": "Linux", "useLibraryCodeForTypes": true, "strictListInference": true, diff --git a/setup.cfg b/setup.cfg index 0982770..fd70d8b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,6 +28,7 @@ license = New BSD [options] install_requires = Django>=4.2 + typing-extensions>=4.0.1; python_version<"3.11" packages = modeltranslation modeltranslation.management