feat: Add types

Refs #716
This commit is contained in:
Victorien 2024-04-04 10:27:00 +02:00 committed by GitHub
parent 2a9c823919
commit a9e95e8c78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 628 additions and 285 deletions

View file

@ -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

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
*.py[cod]
__pycache__
# C extensions
*.so

View file

@ -12,3 +12,6 @@ clean:
lint:
ruff check modeltranslation
ruff format --check modeltranslation *.py
typecheck:
mypy modeltranslation

View file

@ -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, ...]"

View file

@ -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.

View file

@ -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()

View file

@ -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)

View file

@ -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]

View file

@ -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)

View file

@ -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)

View file

@ -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:

View file

@ -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)}
)

View file

@ -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.

View file

@ -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.

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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))

View file

@ -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,
},
)

View file

@ -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.
"""

124
poetry.lock generated
View file

@ -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"

View file

@ -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"]

View file

@ -1,6 +1,6 @@
{
"include": ["."],
"pythonVersion": "3.7",
"pythonVersion": "3.8",
"pythonPlatform": "Linux",
"useLibraryCodeForTypes": true,
"strictListInference": true,

View file

@ -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