From d5a88f2e3b03cb5d930de066e0f528a5a20c0b0f Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Mon, 20 Jan 2025 16:24:11 +0100 Subject: [PATCH 01/25] feat(i18n): implement loading and installing of translations --- examples/i18n/.gitignore | 2 + examples/i18n/python/local/i18n/__init__.py | 8 ++ .../i18n/python/local/i18n/question_type.py | 14 +++ examples/i18n/qpy_config.yml | 9 ++ examples/i18n/templates/formulation.xhtml.j2 | 4 + questionpy/__init__.py | 7 +- questionpy/_ui.py | 6 + questionpy/i18n.py | 113 ++++++++++++++++++ 8 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 examples/i18n/.gitignore create mode 100644 examples/i18n/python/local/i18n/__init__.py create mode 100644 examples/i18n/python/local/i18n/question_type.py create mode 100644 examples/i18n/qpy_config.yml create mode 100644 examples/i18n/templates/formulation.xhtml.j2 create mode 100644 questionpy/i18n.py diff --git a/examples/i18n/.gitignore b/examples/i18n/.gitignore new file mode 100644 index 00000000..098b60fc --- /dev/null +++ b/examples/i18n/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +/dist diff --git a/examples/i18n/python/local/i18n/__init__.py b/examples/i18n/python/local/i18n/__init__.py new file mode 100644 index 00000000..edd88dd2 --- /dev/null +++ b/examples/i18n/python/local/i18n/__init__.py @@ -0,0 +1,8 @@ +# This file is part of the QuestionPy SDK. (https://questionpy.org) +# The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. +# (c) Technische Universität Berlin, innoCampus +from questionpy import make_question_type_init + +from .question_type import I18NQuestion + +init = make_question_type_init(I18NQuestion) diff --git a/examples/i18n/python/local/i18n/question_type.py b/examples/i18n/python/local/i18n/question_type.py new file mode 100644 index 00000000..e243d87b --- /dev/null +++ b/examples/i18n/python/local/i18n/question_type.py @@ -0,0 +1,14 @@ +from questionpy import Attempt, NeedsManualScoringError, Question + + +class I18NAttempt(Attempt): + def _compute_score(self) -> float: + raise NeedsManualScoringError + + @property + def formulation(self) -> str: + return self.jinja2.get_template("formulation.xhtml.j2").render() + + +class I18NQuestion(Question): + attempt_class = I18NAttempt diff --git a/examples/i18n/qpy_config.yml b/examples/i18n/qpy_config.yml new file mode 100644 index 00000000..d24ac546 --- /dev/null +++ b/examples/i18n/qpy_config.yml @@ -0,0 +1,9 @@ +short_name: i18n +namespace: local +version: 0.1.0 +api_version: "0.1" +author: Jane Doe +name: + de: Beispiel mit Internationalisierung (i18n) + en: Internationalisation (i18n) Example +languages: [de, en] diff --git a/examples/i18n/templates/formulation.xhtml.j2 b/examples/i18n/templates/formulation.xhtml.j2 new file mode 100644 index 00000000..46dffec5 --- /dev/null +++ b/examples/i18n/templates/formulation.xhtml.j2 @@ -0,0 +1,4 @@ +
+

{{ _("This string should be localized!") }}

+
diff --git a/questionpy/__init__.py b/questionpy/__init__.py index 1664b702..5daa6e95 100644 --- a/questionpy/__init__.py +++ b/questionpy/__init__.py @@ -37,6 +37,7 @@ ) from questionpy_common.manifest import Manifest, PackageType, SourceManifest +from . import i18n from ._attempt import ( Attempt, AttemptUiPart, @@ -100,4 +101,8 @@ def make_question_type_init( question_class: type[Question], *, wrap_question: Callable[[Question], QuestionInterface] = QuestionWrapper ) -> PackageInitFunction: - return lambda package, env: QuestionTypeWrapper(question_class, package, wrap_question=wrap_question) + def init(package: Package, env: Environment) -> QuestionTypeWrapper: + i18n.initialize(package, env) + return QuestionTypeWrapper(question_class, package, wrap_question=wrap_question) + + return init diff --git a/questionpy/_ui.py b/questionpy/_ui.py index 794b81c9..0ade1627 100644 --- a/questionpy/_ui.py +++ b/questionpy/_ui.py @@ -5,6 +5,7 @@ import jinja2 +from questionpy import i18n from questionpy._util import get_package_by_attempt from questionpy_common.environment import Package, get_qpy_environment @@ -91,4 +92,9 @@ def create_jinja2_environment(attempt: "Attempt", question: "Question") -> jinja "question_type": type(question), }) + if i18n.is_initialized(): + env.add_extension("jinja2.ext.i18n") + _, translations = i18n.get_state() + env.install_gettext_translations(translations, newstyle=True) + return env diff --git a/questionpy/i18n.py b/questionpy/i18n.py new file mode 100644 index 00000000..7707cd14 --- /dev/null +++ b/questionpy/i18n.py @@ -0,0 +1,113 @@ +import logging +from gettext import GNUTranslations, NullTranslations +from importlib.resources.abc import Traversable + +from questionpy_common.environment import Environment, Package, RequestUser + +_DEFAULT_CATEGORY = "LC_MESSAGES" +_DEFAULT_DOMAIN = "package" +_NULL_TRANSLATIONS = NullTranslations() + +_STATE: tuple[str, NullTranslations] | None = None + +log = logging.getLogger(__name__) + + +def _guess_untranslated_language(package: Package) -> str: + # We'll assume that the untranslated messages are in the first supported language according to the manifest. + if package.manifest.languages: + return next(iter(package.manifest.languages)) + # If the package lists no supported languages in its manifest, we'll assume it's english. + # TODO: An alternative might be "C" or "unknown"? + return "en" + + +def _build_translations(mos: list[Traversable]) -> NullTranslations: + if not mos: + return _NULL_TRANSLATIONS + + with mos[0].open("rb") as mo_file: + # GNUTranslations reads the file immediately, so we can safely close it afterward. + translations = GNUTranslations(mo_file) + + for fallback_mo in mos[1:]: + with fallback_mo.open("rb") as mo_file: + translations.add_fallback(GNUTranslations(mo_file)) + + return translations + + +def _get_available_mos(package: Package) -> dict[str, Traversable]: + result = {} + locale_dir = package.get_path("locale") + + for lang_dir in locale_dir.iterdir() if locale_dir.is_dir() else (): + if not lang_dir.is_dir(): + continue + + mo_file = lang_dir / _DEFAULT_CATEGORY / f"{_DEFAULT_DOMAIN}.mo" + if mo_file.is_file(): + result[lang_dir.name] = mo_file + + return result + + +def get_state() -> tuple[str, NullTranslations]: + """Get the current i18n state of the worker.""" + if not _STATE: + msg = f"i18n was not initialized. Call {__name__}.{initialize.__name__} first." + raise RuntimeError(msg) + return _STATE + + +def is_initialized() -> bool: + return _STATE is not None + + +def initialize(package: Package, env: Environment) -> None: + # PLW0603 discourages the global statement, but the alternative would be much less readable. + # ruff: noqa: PLW0603 + + global _STATE + if _STATE: + # Prevent multiple initializations, which would add multiple on_request_callbacks overwriting each other. + return + + untranslated_lang = _guess_untranslated_language(package) + _NULL_TRANSLATIONS.install() + _STATE = (untranslated_lang, _NULL_TRANSLATIONS) + + available_mos = _get_available_mos(package) + + if not available_mos: + # We'd never translate anything anyway, prepare_i18n would install the NullTranslations every time. + # (Ohne MOs nix los) + log.debug( + "No MO files found, messages will not be translated. We'll assume the untranslated strings to be in '%s'.", + untranslated_lang, + ) + return + + log.debug("Found MO files for the following languages: %s", available_mos.keys()) + + def prepare_i18n(request_user: RequestUser) -> None: + langs_to_use = [lang for lang in request_user.preferred_languages if lang in available_mos] + + if langs_to_use: + log.debug("Using the following languages for this request: %s", langs_to_use) + primary_lang = langs_to_use[0] + else: + log.debug( + "There are no MO files for any of the user's preferred languages. Messages will not be translated " + "and we'll assume the untranslated strings to be in '%s'.", + untranslated_lang, + ) + primary_lang = untranslated_lang + + translations = _build_translations([available_mos[lang] for lang in langs_to_use]) + translations.install() + + global _STATE + _STATE = primary_lang, translations + + env.register_on_request_callback(prepare_i18n) From 8baa1b1335fc87c22842ce4408b9982bf502af14 Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Thu, 23 Jan 2025 18:27:32 +0100 Subject: [PATCH 02/25] feat(i18n): add commands to manage .po(t) files --- examples/i18n/locale/de.po | 37 + examples/i18n/locale/en.po | 37 + examples/i18n/locale/local.i18n.pot | 36 + examples/i18n/python/local/i18n/__init__.py | 31 +- .../i18n/python/local/i18n/question_type.py | 14 - examples/i18n/templates/formulation.xhtml.j2 | 1 + poetry.lock | 696 +++++++++--------- pyproject.toml | 1 + questionpy/i18n.py | 20 +- questionpy_sdk/__main__.py | 2 + questionpy_sdk/commands/i18n/__init__.py | 15 + questionpy_sdk/commands/i18n/_extract.py | 42 ++ questionpy_sdk/commands/i18n/_init.py | 94 +++ questionpy_sdk/commands/i18n/_update.py | 64 ++ questionpy_sdk/package/source.py | 12 +- 15 files changed, 741 insertions(+), 361 deletions(-) create mode 100644 examples/i18n/locale/de.po create mode 100644 examples/i18n/locale/en.po create mode 100644 examples/i18n/locale/local.i18n.pot delete mode 100644 examples/i18n/python/local/i18n/question_type.py create mode 100644 questionpy_sdk/commands/i18n/__init__.py create mode 100644 questionpy_sdk/commands/i18n/_extract.py create mode 100644 questionpy_sdk/commands/i18n/_init.py create mode 100644 questionpy_sdk/commands/i18n/_update.py diff --git a/examples/i18n/locale/de.po b/examples/i18n/locale/de.po new file mode 100644 index 00000000..5b95327d --- /dev/null +++ b/examples/i18n/locale/de.po @@ -0,0 +1,37 @@ +# German translations for @local/i18n. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the @local/i18n +# project. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: @local/i18n 0.1.0\n" +"Report-Msgid-Bugs-To: Jane Doe \n" +"POT-Creation-Date: 2025-01-23 18:23+0100\n" +"PO-Revision-Date: 2025-01-23 18:23+0100\n" +"Last-Translator: FULL NAME \n" +"Language: de\n" +"Language-Team: de \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.16.0\n" + +#. Bla bla bla 2 +#: python/local/i18n/__init__.py:17 +msgid "Important Notice" +msgstr "" + +#: python/local/i18n/__init__.py:18 +msgid "" +"If you or a loved one has been diagnosed with mesothelioma, you may be " +"entitled to financial compensation." +msgstr "" + +#. Bla bla bla +#: templates/formulation.xhtml.j2:4 +msgid "This string should be localized!" +msgstr "" + diff --git a/examples/i18n/locale/en.po b/examples/i18n/locale/en.po new file mode 100644 index 00000000..04b599ad --- /dev/null +++ b/examples/i18n/locale/en.po @@ -0,0 +1,37 @@ +# English translations for @local/i18n. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the @local/i18n +# project. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: @local/i18n 0.1.0\n" +"Report-Msgid-Bugs-To: Jane Doe \n" +"POT-Creation-Date: 2025-01-23 18:23+0100\n" +"PO-Revision-Date: 2025-01-23 18:23+0100\n" +"Last-Translator: FULL NAME \n" +"Language: en\n" +"Language-Team: en \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.16.0\n" + +#. Bla bla bla 2 +#: python/local/i18n/__init__.py:17 +msgid "Important Notice" +msgstr "" + +#: python/local/i18n/__init__.py:18 +msgid "" +"If you or a loved one has been diagnosed with mesothelioma, you may be " +"entitled to financial compensation." +msgstr "" + +#. Bla bla bla +#: templates/formulation.xhtml.j2:4 +msgid "This string should be localized!" +msgstr "" + diff --git a/examples/i18n/locale/local.i18n.pot b/examples/i18n/locale/local.i18n.pot new file mode 100644 index 00000000..aa3aac20 --- /dev/null +++ b/examples/i18n/locale/local.i18n.pot @@ -0,0 +1,36 @@ +# Translations template for @local/i18n. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the @local/i18n +# project. +# FIRST AUTHOR , 2025. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: @local/i18n 0.1.0\n" +"Report-Msgid-Bugs-To: Jane Doe \n" +"POT-Creation-Date: 2025-01-23 18:23+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.16.0\n" + +#. Bla bla bla 2 +#: python/local/i18n/__init__.py:17 +msgid "Important Notice" +msgstr "" + +#: python/local/i18n/__init__.py:18 +msgid "" +"If you or a loved one has been diagnosed with mesothelioma, you may be " +"entitled to financial compensation." +msgstr "" + +#. Bla bla bla +#: templates/formulation.xhtml.j2:4 +msgid "This string should be localized!" +msgstr "" + diff --git a/examples/i18n/python/local/i18n/__init__.py b/examples/i18n/python/local/i18n/__init__.py index edd88dd2..db8d2984 100644 --- a/examples/i18n/python/local/i18n/__init__.py +++ b/examples/i18n/python/local/i18n/__init__.py @@ -1,8 +1,35 @@ # This file is part of the QuestionPy SDK. (https://questionpy.org) # The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. # (c) Technische Universität Berlin, innoCampus -from questionpy import make_question_type_init +from questionpy import Attempt, NeedsManualScoringError, Question, make_question_type_init +from questionpy.form import FormModel, static_text +from questionpy.i18n import gettext as _ +from questionpy_common.elements import StaticTextElement + + +def _(a): + return a + + +class I18NModel(FormModel): + txt: StaticTextElement = static_text( + # TRANSLATORS: Bla bla bla 2 + _("Important Notice"), + _("If you or a loved one has been diagnosed with mesothelioma, you may be entitled to financial compensation."), + ) + + +class I18NAttempt(Attempt): + def _compute_score(self) -> float: + raise NeedsManualScoringError + + @property + def formulation(self) -> str: + return self.jinja2.get_template("formulation.xhtml.j2").render() + + +class I18NQuestion(Question): + attempt_class = I18NAttempt -from .question_type import I18NQuestion init = make_question_type_init(I18NQuestion) diff --git a/examples/i18n/python/local/i18n/question_type.py b/examples/i18n/python/local/i18n/question_type.py deleted file mode 100644 index e243d87b..00000000 --- a/examples/i18n/python/local/i18n/question_type.py +++ /dev/null @@ -1,14 +0,0 @@ -from questionpy import Attempt, NeedsManualScoringError, Question - - -class I18NAttempt(Attempt): - def _compute_score(self) -> float: - raise NeedsManualScoringError - - @property - def formulation(self) -> str: - return self.jinja2.get_template("formulation.xhtml.j2").render() - - -class I18NQuestion(Question): - attempt_class = I18NAttempt diff --git a/examples/i18n/templates/formulation.xhtml.j2 b/examples/i18n/templates/formulation.xhtml.j2 index 46dffec5..a549a5ec 100644 --- a/examples/i18n/templates/formulation.xhtml.j2 +++ b/examples/i18n/templates/formulation.xhtml.j2 @@ -1,4 +1,5 @@
+ {# TRANSLATORS: Bla bla bla #}

{{ _("This string should be localized!") }}

diff --git a/poetry.lock b/poetry.lock index eb023c00..8a316927 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,100 +2,105 @@ [[package]] name = "aiohappyeyeballs" -version = "2.4.4" +version = "2.4.6" description = "Happy Eyeballs for asyncio" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main", "test"] files = [ - {file = "aiohappyeyeballs-2.4.4-py3-none-any.whl", hash = "sha256:a980909d50efcd44795c4afeca523296716d50cd756ddca6af8c65b996e27de8"}, - {file = "aiohappyeyeballs-2.4.4.tar.gz", hash = "sha256:5fdd7d87889c63183afc18ce9271f9b0a7d32c2303e394468dd45d514a757745"}, + {file = "aiohappyeyeballs-2.4.6-py3-none-any.whl", hash = "sha256:147ec992cf873d74f5062644332c539fcd42956dc69453fe5204195e560517e1"}, + {file = "aiohappyeyeballs-2.4.6.tar.gz", hash = "sha256:9b05052f9042985d32ecbe4b59a77ae19c006a78f1344d7fdad69d28ded3d0b0"}, ] [[package]] name = "aiohttp" -version = "3.11.11" +version = "3.11.13" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.9" groups = ["main", "test"] files = [ - {file = "aiohttp-3.11.11-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a60804bff28662cbcf340a4d61598891f12eea3a66af48ecfdc975ceec21e3c8"}, - {file = "aiohttp-3.11.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b4fa1cb5f270fb3eab079536b764ad740bb749ce69a94d4ec30ceee1b5940d5"}, - {file = "aiohttp-3.11.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:731468f555656767cda219ab42e033355fe48c85fbe3ba83a349631541715ba2"}, - {file = "aiohttp-3.11.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb23d8bb86282b342481cad4370ea0853a39e4a32a0042bb52ca6bdde132df43"}, - {file = "aiohttp-3.11.11-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f047569d655f81cb70ea5be942ee5d4421b6219c3f05d131f64088c73bb0917f"}, - {file = "aiohttp-3.11.11-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd7659baae9ccf94ae5fe8bfaa2c7bc2e94d24611528395ce88d009107e00c6d"}, - {file = "aiohttp-3.11.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af01e42ad87ae24932138f154105e88da13ce7d202a6de93fafdafb2883a00ef"}, - {file = "aiohttp-3.11.11-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5854be2f3e5a729800bac57a8d76af464e160f19676ab6aea74bde18ad19d438"}, - {file = "aiohttp-3.11.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6526e5fb4e14f4bbf30411216780c9967c20c5a55f2f51d3abd6de68320cc2f3"}, - {file = "aiohttp-3.11.11-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:85992ee30a31835fc482468637b3e5bd085fa8fe9392ba0bdcbdc1ef5e9e3c55"}, - {file = "aiohttp-3.11.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:88a12ad8ccf325a8a5ed80e6d7c3bdc247d66175afedbe104ee2aaca72960d8e"}, - {file = "aiohttp-3.11.11-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0a6d3fbf2232e3a08c41eca81ae4f1dff3d8f1a30bae415ebe0af2d2458b8a33"}, - {file = "aiohttp-3.11.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:84a585799c58b795573c7fa9b84c455adf3e1d72f19a2bf498b54a95ae0d194c"}, - {file = "aiohttp-3.11.11-cp310-cp310-win32.whl", hash = "sha256:bfde76a8f430cf5c5584553adf9926534352251d379dcb266ad2b93c54a29745"}, - {file = "aiohttp-3.11.11-cp310-cp310-win_amd64.whl", hash = "sha256:0fd82b8e9c383af11d2b26f27a478640b6b83d669440c0a71481f7c865a51da9"}, - {file = "aiohttp-3.11.11-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ba74ec819177af1ef7f59063c6d35a214a8fde6f987f7661f4f0eecc468a8f76"}, - {file = "aiohttp-3.11.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4af57160800b7a815f3fe0eba9b46bf28aafc195555f1824555fa2cfab6c1538"}, - {file = "aiohttp-3.11.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ffa336210cf9cd8ed117011085817d00abe4c08f99968deef0013ea283547204"}, - {file = "aiohttp-3.11.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b8fe282183e4a3c7a1b72f5ade1094ed1c6345a8f153506d114af5bf8accd9"}, - {file = "aiohttp-3.11.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3af41686ccec6a0f2bdc66686dc0f403c41ac2089f80e2214a0f82d001052c03"}, - {file = "aiohttp-3.11.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70d1f9dde0e5dd9e292a6d4d00058737052b01f3532f69c0c65818dac26dc287"}, - {file = "aiohttp-3.11.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:249cc6912405917344192b9f9ea5cd5b139d49e0d2f5c7f70bdfaf6b4dbf3a2e"}, - {file = "aiohttp-3.11.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0eb98d90b6690827dcc84c246811feeb4e1eea683c0eac6caed7549be9c84665"}, - {file = "aiohttp-3.11.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec82bf1fda6cecce7f7b915f9196601a1bd1a3079796b76d16ae4cce6d0ef89b"}, - {file = "aiohttp-3.11.11-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9fd46ce0845cfe28f108888b3ab17abff84ff695e01e73657eec3f96d72eef34"}, - {file = "aiohttp-3.11.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd176afcf8f5d2aed50c3647d4925d0db0579d96f75a31e77cbaf67d8a87742d"}, - {file = "aiohttp-3.11.11-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ec2aa89305006fba9ffb98970db6c8221541be7bee4c1d027421d6f6df7d1ce2"}, - {file = "aiohttp-3.11.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:92cde43018a2e17d48bb09c79e4d4cb0e236de5063ce897a5e40ac7cb4878773"}, - {file = "aiohttp-3.11.11-cp311-cp311-win32.whl", hash = "sha256:aba807f9569455cba566882c8938f1a549f205ee43c27b126e5450dc9f83cc62"}, - {file = "aiohttp-3.11.11-cp311-cp311-win_amd64.whl", hash = "sha256:ae545f31489548c87b0cced5755cfe5a5308d00407000e72c4fa30b19c3220ac"}, - {file = "aiohttp-3.11.11-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e595c591a48bbc295ebf47cb91aebf9bd32f3ff76749ecf282ea7f9f6bb73886"}, - {file = "aiohttp-3.11.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ea1b59dc06396b0b424740a10a0a63974c725b1c64736ff788a3689d36c02d2"}, - {file = "aiohttp-3.11.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8811f3f098a78ffa16e0ea36dffd577eb031aea797cbdba81be039a4169e242c"}, - {file = "aiohttp-3.11.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7227b87a355ce1f4bf83bfae4399b1f5bb42e0259cb9405824bd03d2f4336a"}, - {file = "aiohttp-3.11.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d40f9da8cabbf295d3a9dae1295c69975b86d941bc20f0a087f0477fa0a66231"}, - {file = "aiohttp-3.11.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ffb3dc385f6bb1568aa974fe65da84723210e5d9707e360e9ecb51f59406cd2e"}, - {file = "aiohttp-3.11.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8f5f7515f3552d899c61202d99dcb17d6e3b0de777900405611cd747cecd1b8"}, - {file = "aiohttp-3.11.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3499c7ffbfd9c6a3d8d6a2b01c26639da7e43d47c7b4f788016226b1e711caa8"}, - {file = "aiohttp-3.11.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8e2bf8029dbf0810c7bfbc3e594b51c4cc9101fbffb583a3923aea184724203c"}, - {file = "aiohttp-3.11.11-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b6212a60e5c482ef90f2d788835387070a88d52cf6241d3916733c9176d39eab"}, - {file = "aiohttp-3.11.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d119fafe7b634dbfa25a8c597718e69a930e4847f0b88e172744be24515140da"}, - {file = "aiohttp-3.11.11-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:6fba278063559acc730abf49845d0e9a9e1ba74f85f0ee6efd5803f08b285853"}, - {file = "aiohttp-3.11.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:92fc484e34b733704ad77210c7957679c5c3877bd1e6b6d74b185e9320cc716e"}, - {file = "aiohttp-3.11.11-cp312-cp312-win32.whl", hash = "sha256:9f5b3c1ed63c8fa937a920b6c1bec78b74ee09593b3f5b979ab2ae5ef60d7600"}, - {file = "aiohttp-3.11.11-cp312-cp312-win_amd64.whl", hash = "sha256:1e69966ea6ef0c14ee53ef7a3d68b564cc408121ea56c0caa2dc918c1b2f553d"}, - {file = "aiohttp-3.11.11-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:541d823548ab69d13d23730a06f97460f4238ad2e5ed966aaf850d7c369782d9"}, - {file = "aiohttp-3.11.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:929f3ed33743a49ab127c58c3e0a827de0664bfcda566108989a14068f820194"}, - {file = "aiohttp-3.11.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0882c2820fd0132240edbb4a51eb8ceb6eef8181db9ad5291ab3332e0d71df5f"}, - {file = "aiohttp-3.11.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63de12e44935d5aca7ed7ed98a255a11e5cb47f83a9fded7a5e41c40277d104"}, - {file = "aiohttp-3.11.11-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aa54f8ef31d23c506910c21163f22b124facb573bff73930735cf9fe38bf7dff"}, - {file = "aiohttp-3.11.11-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a344d5dc18074e3872777b62f5f7d584ae4344cd6006c17ba12103759d407af3"}, - {file = "aiohttp-3.11.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7fb429ab1aafa1f48578eb315ca45bd46e9c37de11fe45c7f5f4138091e2f1"}, - {file = "aiohttp-3.11.11-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c341c7d868750e31961d6d8e60ff040fb9d3d3a46d77fd85e1ab8e76c3e9a5c4"}, - {file = "aiohttp-3.11.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ed9ee95614a71e87f1a70bc81603f6c6760128b140bc4030abe6abaa988f1c3d"}, - {file = "aiohttp-3.11.11-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de8d38f1c2810fa2a4f1d995a2e9c70bb8737b18da04ac2afbf3971f65781d87"}, - {file = "aiohttp-3.11.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a9b7371665d4f00deb8f32208c7c5e652059b0fda41cf6dbcac6114a041f1cc2"}, - {file = "aiohttp-3.11.11-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:620598717fce1b3bd14dd09947ea53e1ad510317c85dda2c9c65b622edc96b12"}, - {file = "aiohttp-3.11.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf8d9bfee991d8acc72d060d53860f356e07a50f0e0d09a8dfedea1c554dd0d5"}, - {file = "aiohttp-3.11.11-cp313-cp313-win32.whl", hash = "sha256:9d73ee3725b7a737ad86c2eac5c57a4a97793d9f442599bea5ec67ac9f4bdc3d"}, - {file = "aiohttp-3.11.11-cp313-cp313-win_amd64.whl", hash = "sha256:c7a06301c2fb096bdb0bd25fe2011531c1453b9f2c163c8031600ec73af1cc99"}, - {file = "aiohttp-3.11.11-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3e23419d832d969f659c208557de4a123e30a10d26e1e14b73431d3c13444c2e"}, - {file = "aiohttp-3.11.11-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:21fef42317cf02e05d3b09c028712e1d73a9606f02467fd803f7c1f39cc59add"}, - {file = "aiohttp-3.11.11-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1f21bb8d0235fc10c09ce1d11ffbd40fc50d3f08a89e4cf3a0c503dc2562247a"}, - {file = "aiohttp-3.11.11-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1642eceeaa5ab6c9b6dfeaaa626ae314d808188ab23ae196a34c9d97efb68350"}, - {file = "aiohttp-3.11.11-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2170816e34e10f2fd120f603e951630f8a112e1be3b60963a1f159f5699059a6"}, - {file = "aiohttp-3.11.11-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8be8508d110d93061197fd2d6a74f7401f73b6d12f8822bbcd6d74f2b55d71b1"}, - {file = "aiohttp-3.11.11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4eed954b161e6b9b65f6be446ed448ed3921763cc432053ceb606f89d793927e"}, - {file = "aiohttp-3.11.11-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6c9af134da4bc9b3bd3e6a70072509f295d10ee60c697826225b60b9959acdd"}, - {file = "aiohttp-3.11.11-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:44167fc6a763d534a6908bdb2592269b4bf30a03239bcb1654781adf5e49caf1"}, - {file = "aiohttp-3.11.11-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:479b8c6ebd12aedfe64563b85920525d05d394b85f166b7873c8bde6da612f9c"}, - {file = "aiohttp-3.11.11-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:10b4ff0ad793d98605958089fabfa350e8e62bd5d40aa65cdc69d6785859f94e"}, - {file = "aiohttp-3.11.11-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:b540bd67cfb54e6f0865ceccd9979687210d7ed1a1cc8c01f8e67e2f1e883d28"}, - {file = "aiohttp-3.11.11-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1dac54e8ce2ed83b1f6b1a54005c87dfed139cf3f777fdc8afc76e7841101226"}, - {file = "aiohttp-3.11.11-cp39-cp39-win32.whl", hash = "sha256:568c1236b2fde93b7720f95a890741854c1200fba4a3471ff48b2934d2d93fd3"}, - {file = "aiohttp-3.11.11-cp39-cp39-win_amd64.whl", hash = "sha256:943a8b052e54dfd6439fd7989f67fc6a7f2138d0a2cf0a7de5f18aa4fe7eb3b1"}, - {file = "aiohttp-3.11.11.tar.gz", hash = "sha256:bb49c7f1e6ebf3821a42d81d494f538107610c3a705987f53068546b0e90303e"}, + {file = "aiohttp-3.11.13-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a4fe27dbbeec445e6e1291e61d61eb212ee9fed6e47998b27de71d70d3e8777d"}, + {file = "aiohttp-3.11.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9e64ca2dbea28807f8484c13f684a2f761e69ba2640ec49dacd342763cc265ef"}, + {file = "aiohttp-3.11.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9840be675de208d1f68f84d578eaa4d1a36eee70b16ae31ab933520c49ba1325"}, + {file = "aiohttp-3.11.13-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28a772757c9067e2aee8a6b2b425d0efaa628c264d6416d283694c3d86da7689"}, + {file = "aiohttp-3.11.13-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b88aca5adbf4625e11118df45acac29616b425833c3be7a05ef63a6a4017bfdb"}, + {file = "aiohttp-3.11.13-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce10ddfbe26ed5856d6902162f71b8fe08545380570a885b4ab56aecfdcb07f4"}, + {file = "aiohttp-3.11.13-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa48dac27f41b36735c807d1ab093a8386701bbf00eb6b89a0f69d9fa26b3671"}, + {file = "aiohttp-3.11.13-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89ce611b1eac93ce2ade68f1470889e0173d606de20c85a012bfa24be96cf867"}, + {file = "aiohttp-3.11.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:78e4dd9c34ec7b8b121854eb5342bac8b02aa03075ae8618b6210a06bbb8a115"}, + {file = "aiohttp-3.11.13-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:66047eacbc73e6fe2462b77ce39fc170ab51235caf331e735eae91c95e6a11e4"}, + {file = "aiohttp-3.11.13-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5ad8f1c19fe277eeb8bc45741c6d60ddd11d705c12a4d8ee17546acff98e0802"}, + {file = "aiohttp-3.11.13-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64815c6f02e8506b10113ddbc6b196f58dbef135751cc7c32136df27b736db09"}, + {file = "aiohttp-3.11.13-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:967b93f21b426f23ca37329230d5bd122f25516ae2f24a9cea95a30023ff8283"}, + {file = "aiohttp-3.11.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cf1f31f83d16ec344136359001c5e871915c6ab685a3d8dee38e2961b4c81730"}, + {file = "aiohttp-3.11.13-cp310-cp310-win32.whl", hash = "sha256:00c8ac69e259c60976aa2edae3f13d9991cf079aaa4d3cd5a49168ae3748dee3"}, + {file = "aiohttp-3.11.13-cp310-cp310-win_amd64.whl", hash = "sha256:90d571c98d19a8b6e793b34aa4df4cee1e8fe2862d65cc49185a3a3d0a1a3996"}, + {file = "aiohttp-3.11.13-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6b35aab22419ba45f8fc290d0010898de7a6ad131e468ffa3922b1b0b24e9d2e"}, + {file = "aiohttp-3.11.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f81cba651db8795f688c589dd11a4fbb834f2e59bbf9bb50908be36e416dc760"}, + {file = "aiohttp-3.11.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f55d0f242c2d1fcdf802c8fabcff25a9d85550a4cf3a9cf5f2a6b5742c992839"}, + {file = "aiohttp-3.11.13-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4bea08a6aad9195ac9b1be6b0c7e8a702a9cec57ce6b713698b4a5afa9c2e33"}, + {file = "aiohttp-3.11.13-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6070bcf2173a7146bb9e4735b3c62b2accba459a6eae44deea0eb23e0035a23"}, + {file = "aiohttp-3.11.13-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:718d5deb678bc4b9d575bfe83a59270861417da071ab44542d0fcb6faa686636"}, + {file = "aiohttp-3.11.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f6b2c5b4a4d22b8fb2c92ac98e0747f5f195e8e9448bfb7404cd77e7bfa243f"}, + {file = "aiohttp-3.11.13-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:747ec46290107a490d21fe1ff4183bef8022b848cf9516970cb31de6d9460088"}, + {file = "aiohttp-3.11.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:01816f07c9cc9d80f858615b1365f8319d6a5fd079cd668cc58e15aafbc76a54"}, + {file = "aiohttp-3.11.13-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a08ad95fcbd595803e0c4280671d808eb170a64ca3f2980dd38e7a72ed8d1fea"}, + {file = "aiohttp-3.11.13-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c97be90d70f7db3aa041d720bfb95f4869d6063fcdf2bb8333764d97e319b7d0"}, + {file = "aiohttp-3.11.13-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ab915a57c65f7a29353c8014ac4be685c8e4a19e792a79fe133a8e101111438e"}, + {file = "aiohttp-3.11.13-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:35cda4e07f5e058a723436c4d2b7ba2124ab4e0aa49e6325aed5896507a8a42e"}, + {file = "aiohttp-3.11.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:af55314407714fe77a68a9ccaab90fdb5deb57342585fd4a3a8102b6d4370080"}, + {file = "aiohttp-3.11.13-cp311-cp311-win32.whl", hash = "sha256:42d689a5c0a0c357018993e471893e939f555e302313d5c61dfc566c2cad6185"}, + {file = "aiohttp-3.11.13-cp311-cp311-win_amd64.whl", hash = "sha256:b73a2b139782a07658fbf170fe4bcdf70fc597fae5ffe75e5b67674c27434a9f"}, + {file = "aiohttp-3.11.13-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2eabb269dc3852537d57589b36d7f7362e57d1ece308842ef44d9830d2dc3c90"}, + {file = "aiohttp-3.11.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b77ee42addbb1c36d35aca55e8cc6d0958f8419e458bb70888d8c69a4ca833d"}, + {file = "aiohttp-3.11.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55789e93c5ed71832e7fac868167276beadf9877b85697020c46e9a75471f55f"}, + {file = "aiohttp-3.11.13-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c929f9a7249a11e4aa5c157091cfad7f49cc6b13f4eecf9b747104befd9f56f2"}, + {file = "aiohttp-3.11.13-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d33851d85537bbf0f6291ddc97926a754c8f041af759e0aa0230fe939168852b"}, + {file = "aiohttp-3.11.13-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9229d8613bd8401182868fe95688f7581673e1c18ff78855671a4b8284f47bcb"}, + {file = "aiohttp-3.11.13-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:669dd33f028e54fe4c96576f406ebb242ba534dd3a981ce009961bf49960f117"}, + {file = "aiohttp-3.11.13-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c1b20a1ace54af7db1f95af85da530fe97407d9063b7aaf9ce6a32f44730778"}, + {file = "aiohttp-3.11.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5724cc77f4e648362ebbb49bdecb9e2b86d9b172c68a295263fa072e679ee69d"}, + {file = "aiohttp-3.11.13-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:aa36c35e94ecdb478246dd60db12aba57cfcd0abcad43c927a8876f25734d496"}, + {file = "aiohttp-3.11.13-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9b5b37c863ad5b0892cc7a4ceb1e435e5e6acd3f2f8d3e11fa56f08d3c67b820"}, + {file = "aiohttp-3.11.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e06cf4852ce8c4442a59bae5a3ea01162b8fcb49ab438d8548b8dc79375dad8a"}, + {file = "aiohttp-3.11.13-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5194143927e494616e335d074e77a5dac7cd353a04755330c9adc984ac5a628e"}, + {file = "aiohttp-3.11.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:afcb6b275c2d2ba5d8418bf30a9654fa978b4f819c2e8db6311b3525c86fe637"}, + {file = "aiohttp-3.11.13-cp312-cp312-win32.whl", hash = "sha256:7104d5b3943c6351d1ad7027d90bdd0ea002903e9f610735ac99df3b81f102ee"}, + {file = "aiohttp-3.11.13-cp312-cp312-win_amd64.whl", hash = "sha256:47dc018b1b220c48089b5b9382fbab94db35bef2fa192995be22cbad3c5730c8"}, + {file = "aiohttp-3.11.13-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9862d077b9ffa015dbe3ce6c081bdf35135948cb89116e26667dd183550833d1"}, + {file = "aiohttp-3.11.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbfef0666ae9e07abfa2c54c212ac18a1f63e13e0760a769f70b5717742f3ece"}, + {file = "aiohttp-3.11.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a1f7d857c4fcf7cabb1178058182c789b30d85de379e04f64c15b7e88d66fb"}, + {file = "aiohttp-3.11.13-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba40b7ae0f81c7029583a338853f6607b6d83a341a3dcde8bed1ea58a3af1df9"}, + {file = "aiohttp-3.11.13-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5b95787335c483cd5f29577f42bbe027a412c5431f2f80a749c80d040f7ca9f"}, + {file = "aiohttp-3.11.13-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7d474c5c1f0b9405c1565fafdc4429fa7d986ccbec7ce55bc6a330f36409cad"}, + {file = "aiohttp-3.11.13-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e83fb1991e9d8982b3b36aea1e7ad27ea0ce18c14d054c7a404d68b0319eebb"}, + {file = "aiohttp-3.11.13-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4586a68730bd2f2b04a83e83f79d271d8ed13763f64b75920f18a3a677b9a7f0"}, + {file = "aiohttp-3.11.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fe4eb0e7f50cdb99b26250d9328faef30b1175a5dbcfd6d0578d18456bac567"}, + {file = "aiohttp-3.11.13-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2a8a6bc19818ac3e5596310ace5aa50d918e1ebdcc204dc96e2f4d505d51740c"}, + {file = "aiohttp-3.11.13-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f27eec42f6c3c1df09cfc1f6786308f8b525b8efaaf6d6bd76c1f52c6511f6a"}, + {file = "aiohttp-3.11.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2a4a13dfbb23977a51853b419141cd0a9b9573ab8d3a1455c6e63561387b52ff"}, + {file = "aiohttp-3.11.13-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:02876bf2f69b062584965507b07bc06903c2dc93c57a554b64e012d636952654"}, + {file = "aiohttp-3.11.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b992778d95b60a21c4d8d4a5f15aaab2bd3c3e16466a72d7f9bfd86e8cea0d4b"}, + {file = "aiohttp-3.11.13-cp313-cp313-win32.whl", hash = "sha256:507ab05d90586dacb4f26a001c3abf912eb719d05635cbfad930bdbeb469b36c"}, + {file = "aiohttp-3.11.13-cp313-cp313-win_amd64.whl", hash = "sha256:5ceb81a4db2decdfa087381b5fc5847aa448244f973e5da232610304e199e7b2"}, + {file = "aiohttp-3.11.13-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:51c3ff9c7a25f3cad5c09d9aacbc5aefb9267167c4652c1eb737989b554fe278"}, + {file = "aiohttp-3.11.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e271beb2b1dabec5cd84eb488bdabf9758d22ad13471e9c356be07ad139b3012"}, + {file = "aiohttp-3.11.13-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0e9eb7e5764abcb49f0e2bd8f5731849b8728efbf26d0cac8e81384c95acec3f"}, + {file = "aiohttp-3.11.13-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baae005092e3f200de02699314ac8933ec20abf998ec0be39448f6605bce93df"}, + {file = "aiohttp-3.11.13-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1982c98ac62c132d2b773d50e2fcc941eb0b8bad3ec078ce7e7877c4d5a2dce7"}, + {file = "aiohttp-3.11.13-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2b25b2eeb35707113b2d570cadc7c612a57f1c5d3e7bb2b13870fe284e08fc0"}, + {file = "aiohttp-3.11.13-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b27961d65639128336b7a7c3f0046dcc62a9443d5ef962e3c84170ac620cec47"}, + {file = "aiohttp-3.11.13-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a01fe9f1e05025eacdd97590895e2737b9f851d0eb2e017ae9574d9a4f0b6252"}, + {file = "aiohttp-3.11.13-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa1fb1b61881c8405829c50e9cc5c875bfdbf685edf57a76817dfb50643e4a1a"}, + {file = "aiohttp-3.11.13-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:25de43bb3cf83ad83efc8295af7310219af6dbe4c543c2e74988d8e9c8a2a917"}, + {file = "aiohttp-3.11.13-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:fe7065e2215e4bba63dc00db9ae654c1ba3950a5fff691475a32f511142fcddb"}, + {file = "aiohttp-3.11.13-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:7836587eef675a17d835ec3d98a8c9acdbeb2c1d72b0556f0edf4e855a25e9c1"}, + {file = "aiohttp-3.11.13-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:85fa0b18558eb1427090912bd456a01f71edab0872f4e0f9e4285571941e4090"}, + {file = "aiohttp-3.11.13-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a86dc177eb4c286c19d1823ac296299f59ed8106c9536d2b559f65836e0fb2c6"}, + {file = "aiohttp-3.11.13-cp39-cp39-win32.whl", hash = "sha256:684eea71ab6e8ade86b9021bb62af4bf0881f6be4e926b6b5455de74e420783a"}, + {file = "aiohttp-3.11.13-cp39-cp39-win_amd64.whl", hash = "sha256:82c249f2bfa5ecbe4a1a7902c81c0fba52ed9ebd0176ab3047395d02ad96cfcb"}, + {file = "aiohttp-3.11.13.tar.gz", hash = "sha256:8ce789231404ca8fff7f693cdce398abf6d90fd5dae2b1847477196c243b1fbb"}, ] [package.dependencies] @@ -155,14 +160,14 @@ files = [ [[package]] name = "attrs" -version = "24.3.0" +version = "25.1.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.8" groups = ["main", "test"] files = [ - {file = "attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308"}, - {file = "attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff"}, + {file = "attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a"}, + {file = "attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e"}, ] [package.extras] @@ -173,16 +178,31 @@ docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphi tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] +[[package]] +name = "babel" +version = "2.17.0" +description = "Internationalization utilities" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"}, + {file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"}, +] + +[package.extras] +dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] + [[package]] name = "certifi" -version = "2024.12.14" +version = "2025.1.31" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" groups = ["test"] files = [ - {file = "certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56"}, - {file = "certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"}, + {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, + {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, ] [[package]] @@ -296,74 +316,75 @@ markers = {main = "platform_system == \"Windows\"", test = "sys_platform == \"wi [[package]] name = "coverage" -version = "7.6.10" +version = "7.6.12" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" groups = ["test"] files = [ - {file = "coverage-7.6.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5c912978f7fbf47ef99cec50c4401340436d200d41d714c7a4766f377c5b7b78"}, - {file = "coverage-7.6.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a01ec4af7dfeb96ff0078ad9a48810bb0cc8abcb0115180c6013a6b26237626c"}, - {file = "coverage-7.6.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3b204c11e2b2d883946fe1d97f89403aa1811df28ce0447439178cc7463448a"}, - {file = "coverage-7.6.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32ee6d8491fcfc82652a37109f69dee9a830e9379166cb73c16d8dc5c2915165"}, - {file = "coverage-7.6.10-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675cefc4c06e3b4c876b85bfb7c59c5e2218167bbd4da5075cbe3b5790a28988"}, - {file = "coverage-7.6.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f4f620668dbc6f5e909a0946a877310fb3d57aea8198bde792aae369ee1c23b5"}, - {file = "coverage-7.6.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4eea95ef275de7abaef630c9b2c002ffbc01918b726a39f5a4353916ec72d2f3"}, - {file = "coverage-7.6.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e2f0280519e42b0a17550072861e0bc8a80a0870de260f9796157d3fca2733c5"}, - {file = "coverage-7.6.10-cp310-cp310-win32.whl", hash = "sha256:bc67deb76bc3717f22e765ab3e07ee9c7a5e26b9019ca19a3b063d9f4b874244"}, - {file = "coverage-7.6.10-cp310-cp310-win_amd64.whl", hash = "sha256:0f460286cb94036455e703c66988851d970fdfd8acc2a1122ab7f4f904e4029e"}, - {file = "coverage-7.6.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ea3c8f04b3e4af80e17bab607c386a830ffc2fb88a5484e1df756478cf70d1d3"}, - {file = "coverage-7.6.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:507a20fc863cae1d5720797761b42d2d87a04b3e5aeb682ef3b7332e90598f43"}, - {file = "coverage-7.6.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d37a84878285b903c0fe21ac8794c6dab58150e9359f1aaebbeddd6412d53132"}, - {file = "coverage-7.6.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a534738b47b0de1995f85f582d983d94031dffb48ab86c95bdf88dc62212142f"}, - {file = "coverage-7.6.10-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d7a2bf79378d8fb8afaa994f91bfd8215134f8631d27eba3e0e2c13546ce994"}, - {file = "coverage-7.6.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6713ba4b4ebc330f3def51df1d5d38fad60b66720948112f114968feb52d3f99"}, - {file = "coverage-7.6.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ab32947f481f7e8c763fa2c92fd9f44eeb143e7610c4ca9ecd6a36adab4081bd"}, - {file = "coverage-7.6.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7bbd8c8f1b115b892e34ba66a097b915d3871db7ce0e6b9901f462ff3a975377"}, - {file = "coverage-7.6.10-cp311-cp311-win32.whl", hash = "sha256:299e91b274c5c9cdb64cbdf1b3e4a8fe538a7a86acdd08fae52301b28ba297f8"}, - {file = "coverage-7.6.10-cp311-cp311-win_amd64.whl", hash = "sha256:489a01f94aa581dbd961f306e37d75d4ba16104bbfa2b0edb21d29b73be83609"}, - {file = "coverage-7.6.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27c6e64726b307782fa5cbe531e7647aee385a29b2107cd87ba7c0105a5d3853"}, - {file = "coverage-7.6.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c56e097019e72c373bae32d946ecf9858fda841e48d82df7e81c63ac25554078"}, - {file = "coverage-7.6.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7827a5bc7bdb197b9e066cdf650b2887597ad124dd99777332776f7b7c7d0d0"}, - {file = "coverage-7.6.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204a8238afe787323a8b47d8be4df89772d5c1e4651b9ffa808552bdf20e1d50"}, - {file = "coverage-7.6.10-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67926f51821b8e9deb6426ff3164870976fe414d033ad90ea75e7ed0c2e5022"}, - {file = "coverage-7.6.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e78b270eadb5702938c3dbe9367f878249b5ef9a2fcc5360ac7bff694310d17b"}, - {file = "coverage-7.6.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:714f942b9c15c3a7a5fe6876ce30af831c2ad4ce902410b7466b662358c852c0"}, - {file = "coverage-7.6.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:abb02e2f5a3187b2ac4cd46b8ced85a0858230b577ccb2c62c81482ca7d18852"}, - {file = "coverage-7.6.10-cp312-cp312-win32.whl", hash = "sha256:55b201b97286cf61f5e76063f9e2a1d8d2972fc2fcfd2c1272530172fd28c359"}, - {file = "coverage-7.6.10-cp312-cp312-win_amd64.whl", hash = "sha256:e4ae5ac5e0d1e4edfc9b4b57b4cbecd5bc266a6915c500f358817a8496739247"}, - {file = "coverage-7.6.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05fca8ba6a87aabdd2d30d0b6c838b50510b56cdcfc604d40760dae7153b73d9"}, - {file = "coverage-7.6.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9e80eba8801c386f72e0712a0453431259c45c3249f0009aff537a517b52942b"}, - {file = "coverage-7.6.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a372c89c939d57abe09e08c0578c1d212e7a678135d53aa16eec4430adc5e690"}, - {file = "coverage-7.6.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec22b5e7fe7a0fa8509181c4aac1db48f3dd4d3a566131b313d1efc102892c18"}, - {file = "coverage-7.6.10-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26bcf5c4df41cad1b19c84af71c22cbc9ea9a547fc973f1f2cc9a290002c8b3c"}, - {file = "coverage-7.6.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e4630c26b6084c9b3cb53b15bd488f30ceb50b73c35c5ad7871b869cb7365fd"}, - {file = "coverage-7.6.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2396e8116db77789f819d2bc8a7e200232b7a282c66e0ae2d2cd84581a89757e"}, - {file = "coverage-7.6.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79109c70cc0882e4d2d002fe69a24aa504dec0cc17169b3c7f41a1d341a73694"}, - {file = "coverage-7.6.10-cp313-cp313-win32.whl", hash = "sha256:9e1747bab246d6ff2c4f28b4d186b205adced9f7bd9dc362051cc37c4a0c7bd6"}, - {file = "coverage-7.6.10-cp313-cp313-win_amd64.whl", hash = "sha256:254f1a3b1eef5f7ed23ef265eaa89c65c8c5b6b257327c149db1ca9d4a35f25e"}, - {file = "coverage-7.6.10-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2ccf240eb719789cedbb9fd1338055de2761088202a9a0b73032857e53f612fe"}, - {file = "coverage-7.6.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0c807ca74d5a5e64427c8805de15b9ca140bba13572d6d74e262f46f50b13273"}, - {file = "coverage-7.6.10-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bcfa46d7709b5a7ffe089075799b902020b62e7ee56ebaed2f4bdac04c508d8"}, - {file = "coverage-7.6.10-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e0de1e902669dccbf80b0415fb6b43d27edca2fbd48c74da378923b05316098"}, - {file = "coverage-7.6.10-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7b444c42bbc533aaae6b5a2166fd1a797cdb5eb58ee51a92bee1eb94a1e1cb"}, - {file = "coverage-7.6.10-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b330368cb99ef72fcd2dc3ed260adf67b31499584dc8a20225e85bfe6f6cfed0"}, - {file = "coverage-7.6.10-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9a7cfb50515f87f7ed30bc882f68812fd98bc2852957df69f3003d22a2aa0abf"}, - {file = "coverage-7.6.10-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f93531882a5f68c28090f901b1d135de61b56331bba82028489bc51bdd818d2"}, - {file = "coverage-7.6.10-cp313-cp313t-win32.whl", hash = "sha256:89d76815a26197c858f53c7f6a656686ec392b25991f9e409bcef020cd532312"}, - {file = "coverage-7.6.10-cp313-cp313t-win_amd64.whl", hash = "sha256:54a5f0f43950a36312155dae55c505a76cd7f2b12d26abeebbe7a0b36dbc868d"}, - {file = "coverage-7.6.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:656c82b8a0ead8bba147de9a89bda95064874c91a3ed43a00e687f23cc19d53a"}, - {file = "coverage-7.6.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ccc2b70a7ed475c68ceb548bf69cec1e27305c1c2606a5eb7c3afff56a1b3b27"}, - {file = "coverage-7.6.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5e37dc41d57ceba70956fa2fc5b63c26dba863c946ace9705f8eca99daecdc4"}, - {file = "coverage-7.6.10-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0aa9692b4fdd83a4647eeb7db46410ea1322b5ed94cd1715ef09d1d5922ba87f"}, - {file = "coverage-7.6.10-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa744da1820678b475e4ba3dfd994c321c5b13381d1041fe9c608620e6676e25"}, - {file = "coverage-7.6.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c0b1818063dc9e9d838c09e3a473c1422f517889436dd980f5d721899e66f315"}, - {file = "coverage-7.6.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:59af35558ba08b758aec4d56182b222976330ef8d2feacbb93964f576a7e7a90"}, - {file = "coverage-7.6.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7ed2f37cfce1ce101e6dffdfd1c99e729dd2ffc291d02d3e2d0af8b53d13840d"}, - {file = "coverage-7.6.10-cp39-cp39-win32.whl", hash = "sha256:4bcc276261505d82f0ad426870c3b12cb177752834a633e737ec5ee79bbdff18"}, - {file = "coverage-7.6.10-cp39-cp39-win_amd64.whl", hash = "sha256:457574f4599d2b00f7f637a0700a6422243b3565509457b2dbd3f50703e11f59"}, - {file = "coverage-7.6.10-pp39.pp310-none-any.whl", hash = "sha256:fd34e7b3405f0cc7ab03d54a334c17a9e802897580d964bd8c2001f4b9fd488f"}, - {file = "coverage-7.6.10.tar.gz", hash = "sha256:7fb105327c8f8f0682e29843e2ff96af9dcbe5bab8eeb4b398c6a33a16d80a23"}, + {file = "coverage-7.6.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:704c8c8c6ce6569286ae9622e534b4f5b9759b6f2cd643f1c1a61f666d534fe8"}, + {file = "coverage-7.6.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ad7525bf0241e5502168ae9c643a2f6c219fa0a283001cee4cf23a9b7da75879"}, + {file = "coverage-7.6.12-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06097c7abfa611c91edb9e6920264e5be1d6ceb374efb4986f38b09eed4cb2fe"}, + {file = "coverage-7.6.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:220fa6c0ad7d9caef57f2c8771918324563ef0d8272c94974717c3909664e674"}, + {file = "coverage-7.6.12-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3688b99604a24492bcfe1c106278c45586eb819bf66a654d8a9a1433022fb2eb"}, + {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d1a987778b9c71da2fc8948e6f2656da6ef68f59298b7e9786849634c35d2c3c"}, + {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cec6b9ce3bd2b7853d4a4563801292bfee40b030c05a3d29555fd2a8ee9bd68c"}, + {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ace9048de91293e467b44bce0f0381345078389814ff6e18dbac8fdbf896360e"}, + {file = "coverage-7.6.12-cp310-cp310-win32.whl", hash = "sha256:ea31689f05043d520113e0552f039603c4dd71fa4c287b64cb3606140c66f425"}, + {file = "coverage-7.6.12-cp310-cp310-win_amd64.whl", hash = "sha256:676f92141e3c5492d2a1596d52287d0d963df21bf5e55c8b03075a60e1ddf8aa"}, + {file = "coverage-7.6.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e18aafdfb3e9ec0d261c942d35bd7c28d031c5855dadb491d2723ba54f4c3015"}, + {file = "coverage-7.6.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66fe626fd7aa5982cdebad23e49e78ef7dbb3e3c2a5960a2b53632f1f703ea45"}, + {file = "coverage-7.6.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ef01d70198431719af0b1f5dcbefc557d44a190e749004042927b2a3fed0702"}, + {file = "coverage-7.6.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e92ae5a289a4bc4c0aae710c0948d3c7892e20fd3588224ebe242039573bf0"}, + {file = "coverage-7.6.12-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e695df2c58ce526eeab11a2e915448d3eb76f75dffe338ea613c1201b33bab2f"}, + {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d74c08e9aaef995f8c4ef6d202dbd219c318450fe2a76da624f2ebb9c8ec5d9f"}, + {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e995b3b76ccedc27fe4f477b349b7d64597e53a43fc2961db9d3fbace085d69d"}, + {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b1f097878d74fe51e1ddd1be62d8e3682748875b461232cf4b52ddc6e6db0bba"}, + {file = "coverage-7.6.12-cp311-cp311-win32.whl", hash = "sha256:1f7ffa05da41754e20512202c866d0ebfc440bba3b0ed15133070e20bf5aeb5f"}, + {file = "coverage-7.6.12-cp311-cp311-win_amd64.whl", hash = "sha256:e216c5c45f89ef8971373fd1c5d8d1164b81f7f5f06bbf23c37e7908d19e8558"}, + {file = "coverage-7.6.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b172f8e030e8ef247b3104902cc671e20df80163b60a203653150d2fc204d1ad"}, + {file = "coverage-7.6.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:641dfe0ab73deb7069fb972d4d9725bf11c239c309ce694dd50b1473c0f641c3"}, + {file = "coverage-7.6.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e549f54ac5f301e8e04c569dfdb907f7be71b06b88b5063ce9d6953d2d58574"}, + {file = "coverage-7.6.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:959244a17184515f8c52dcb65fb662808767c0bd233c1d8a166e7cf74c9ea985"}, + {file = "coverage-7.6.12-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bda1c5f347550c359f841d6614fb8ca42ae5cb0b74d39f8a1e204815ebe25750"}, + {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ceeb90c3eda1f2d8c4c578c14167dbd8c674ecd7d38e45647543f19839dd6ea"}, + {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f16f44025c06792e0fb09571ae454bcc7a3ec75eeb3c36b025eccf501b1a4c3"}, + {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b076e625396e787448d27a411aefff867db2bffac8ed04e8f7056b07024eed5a"}, + {file = "coverage-7.6.12-cp312-cp312-win32.whl", hash = "sha256:00b2086892cf06c7c2d74983c9595dc511acca00665480b3ddff749ec4fb2a95"}, + {file = "coverage-7.6.12-cp312-cp312-win_amd64.whl", hash = "sha256:7ae6eabf519bc7871ce117fb18bf14e0e343eeb96c377667e3e5dd12095e0288"}, + {file = "coverage-7.6.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:488c27b3db0ebee97a830e6b5a3ea930c4a6e2c07f27a5e67e1b3532e76b9ef1"}, + {file = "coverage-7.6.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d1095bbee1851269f79fd8e0c9b5544e4c00c0c24965e66d8cba2eb5bb535fd"}, + {file = "coverage-7.6.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0533adc29adf6a69c1baa88c3d7dbcaadcffa21afbed3ca7a225a440e4744bf9"}, + {file = "coverage-7.6.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53c56358d470fa507a2b6e67a68fd002364d23c83741dbc4c2e0680d80ca227e"}, + {file = "coverage-7.6.12-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64cbb1a3027c79ca6310bf101014614f6e6e18c226474606cf725238cf5bc2d4"}, + {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79cac3390bfa9836bb795be377395f28410811c9066bc4eefd8015258a7578c6"}, + {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b148068e881faa26d878ff63e79650e208e95cf1c22bd3f77c3ca7b1d9821a3"}, + {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8bec2ac5da793c2685ce5319ca9bcf4eee683b8a1679051f8e6ec04c4f2fd7dc"}, + {file = "coverage-7.6.12-cp313-cp313-win32.whl", hash = "sha256:200e10beb6ddd7c3ded322a4186313d5ca9e63e33d8fab4faa67ef46d3460af3"}, + {file = "coverage-7.6.12-cp313-cp313-win_amd64.whl", hash = "sha256:2b996819ced9f7dbb812c701485d58f261bef08f9b85304d41219b1496b591ef"}, + {file = "coverage-7.6.12-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:299cf973a7abff87a30609879c10df0b3bfc33d021e1adabc29138a48888841e"}, + {file = "coverage-7.6.12-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b467a8c56974bf06e543e69ad803c6865249d7a5ccf6980457ed2bc50312703"}, + {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2458f275944db8129f95d91aee32c828a408481ecde3b30af31d552c2ce284a0"}, + {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a9d8be07fb0832636a0f72b80d2a652fe665e80e720301fb22b191c3434d924"}, + {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14d47376a4f445e9743f6c83291e60adb1b127607a3618e3185bbc8091f0467b"}, + {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b95574d06aa9d2bd6e5cc35a5bbe35696342c96760b69dc4287dbd5abd4ad51d"}, + {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:ecea0c38c9079570163d663c0433a9af4094a60aafdca491c6a3d248c7432827"}, + {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2251fabcfee0a55a8578a9d29cecfee5f2de02f11530e7d5c5a05859aa85aee9"}, + {file = "coverage-7.6.12-cp313-cp313t-win32.whl", hash = "sha256:eb5507795caabd9b2ae3f1adc95f67b1104971c22c624bb354232d65c4fc90b3"}, + {file = "coverage-7.6.12-cp313-cp313t-win_amd64.whl", hash = "sha256:f60a297c3987c6c02ffb29effc70eadcbb412fe76947d394a1091a3615948e2f"}, + {file = "coverage-7.6.12-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e7575ab65ca8399c8c4f9a7d61bbd2d204c8b8e447aab9d355682205c9dd948d"}, + {file = "coverage-7.6.12-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8161d9fbc7e9fe2326de89cd0abb9f3599bccc1287db0aba285cb68d204ce929"}, + {file = "coverage-7.6.12-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a1e465f398c713f1b212400b4e79a09829cd42aebd360362cd89c5bdc44eb87"}, + {file = "coverage-7.6.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f25d8b92a4e31ff1bd873654ec367ae811b3a943583e05432ea29264782dc32c"}, + {file = "coverage-7.6.12-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a936309a65cc5ca80fa9f20a442ff9e2d06927ec9a4f54bcba9c14c066323f2"}, + {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aa6f302a3a0b5f240ee201297fff0bbfe2fa0d415a94aeb257d8b461032389bd"}, + {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f973643ef532d4f9be71dd88cf7588936685fdb576d93a79fe9f65bc337d9d73"}, + {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:78f5243bb6b1060aed6213d5107744c19f9571ec76d54c99cc15938eb69e0e86"}, + {file = "coverage-7.6.12-cp39-cp39-win32.whl", hash = "sha256:69e62c5034291c845fc4df7f8155e8544178b6c774f97a99e2734b05eb5bed31"}, + {file = "coverage-7.6.12-cp39-cp39-win_amd64.whl", hash = "sha256:b01a840ecc25dce235ae4c1b6a0daefb2a203dba0e6e980637ee9c2f6ee0df57"}, + {file = "coverage-7.6.12-pp39.pp310-none-any.whl", hash = "sha256:7e39e845c4d764208e7b8f6a21c541ade741e2c41afabdfa1caa28687a3c98cf"}, + {file = "coverage-7.6.12-py3-none-any.whl", hash = "sha256:eb8668cfbc279a536c633137deeb9435d2962caec279c3f8cf8b91fff6ff8953"}, + {file = "coverage-7.6.12.tar.gz", hash = "sha256:48cfc4641d95d34766ad41d9573cc0f22a48aa88d22657a1fe01dca0dbae4de2"}, ] [package.extras] @@ -371,19 +392,18 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "faker" -version = "33.3.0" +version = "36.1.1" description = "Faker is a Python package that generates fake data for you." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "Faker-33.3.0-py3-none-any.whl", hash = "sha256:ae074d9c7ef65817a93b448141a5531a16b2ea2e563dc5774578197c7c84060c"}, - {file = "faker-33.3.0.tar.gz", hash = "sha256:2abb551a05b75d268780b6095100a48afc43c53e97422002efbfc1272ebf5f26"}, + {file = "Faker-36.1.1-py3-none-any.whl", hash = "sha256:ad1f1be7fd692ec0256517404a9d7f007ab36ac5d4674082fa72404049725eaa"}, + {file = "faker-36.1.1.tar.gz", hash = "sha256:7cb2bbd4c8f040e4a340ae4019e9a48b6cf1db6a71bda4e5a61d8d13b7bef28d"}, ] [package.dependencies] -python-dateutil = ">=2.4" -typing-extensions = "*" +tzdata = "*" [[package]] name = "frozenlist" @@ -907,50 +927,44 @@ files = [ [[package]] name = "mypy" -version = "1.14.1" +version = "1.15.0" description = "Optional static typing for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["type-checker"] files = [ - {file = "mypy-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb"}, - {file = "mypy-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0"}, - {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90716d8b2d1f4cd503309788e51366f07c56635a3309b0f6a32547eaaa36a64d"}, - {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae753f5c9fef278bcf12e1a564351764f2a6da579d4a81347e1d5a15819997b"}, - {file = "mypy-1.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0fe0f5feaafcb04505bcf439e991c6d8f1bf8b15f12b05feeed96e9e7bf1427"}, - {file = "mypy-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:7d54bd85b925e501c555a3227f3ec0cfc54ee8b6930bd6141ec872d1c572f81f"}, - {file = "mypy-1.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c"}, - {file = "mypy-1.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1"}, - {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8"}, - {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f"}, - {file = "mypy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1"}, - {file = "mypy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae"}, - {file = "mypy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14"}, - {file = "mypy-1.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9"}, - {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11"}, - {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e"}, - {file = "mypy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89"}, - {file = "mypy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b"}, - {file = "mypy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255"}, - {file = "mypy-1.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34"}, - {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a"}, - {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9"}, - {file = "mypy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd"}, - {file = "mypy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107"}, - {file = "mypy-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7084fb8f1128c76cd9cf68fe5971b37072598e7c31b2f9f95586b65c741a9d31"}, - {file = "mypy-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8f845a00b4f420f693f870eaee5f3e2692fa84cc8514496114649cfa8fd5e2c6"}, - {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44bf464499f0e3a2d14d58b54674dee25c031703b2ffc35064bd0df2e0fac319"}, - {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c99f27732c0b7dc847adb21c9d47ce57eb48fa33a17bc6d7d5c5e9f9e7ae5bac"}, - {file = "mypy-1.14.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:bce23c7377b43602baa0bd22ea3265c49b9ff0b76eb315d6c34721af4cdf1d9b"}, - {file = "mypy-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:8edc07eeade7ebc771ff9cf6b211b9a7d93687ff892150cb5692e4f4272b0837"}, - {file = "mypy-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3888a1816d69f7ab92092f785a462944b3ca16d7c470d564165fe703b0970c35"}, - {file = "mypy-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46c756a444117c43ee984bd055db99e498bc613a70bbbc120272bd13ca579fbc"}, - {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27fc248022907e72abfd8e22ab1f10e903915ff69961174784a3900a8cba9ad9"}, - {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499d6a72fb7e5de92218db961f1a66d5f11783f9ae549d214617edab5d4dbdbb"}, - {file = "mypy-1.14.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:57961db9795eb566dc1d1b4e9139ebc4c6b0cb6e7254ecde69d1552bf7613f60"}, - {file = "mypy-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:07ba89fdcc9451f2ebb02853deb6aaaa3d2239a236669a63ab3801bbf923ef5c"}, - {file = "mypy-1.14.1-py3-none-any.whl", hash = "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1"}, - {file = "mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6"}, + {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"}, + {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"}, + {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b"}, + {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3"}, + {file = "mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b"}, + {file = "mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828"}, + {file = "mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f"}, + {file = "mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5"}, + {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e"}, + {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c"}, + {file = "mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f"}, + {file = "mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f"}, + {file = "mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd"}, + {file = "mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f"}, + {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464"}, + {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee"}, + {file = "mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e"}, + {file = "mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22"}, + {file = "mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445"}, + {file = "mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d"}, + {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5"}, + {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036"}, + {file = "mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357"}, + {file = "mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf"}, + {file = "mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078"}, + {file = "mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba"}, + {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5"}, + {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b"}, + {file = "mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2"}, + {file = "mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980"}, + {file = "mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e"}, + {file = "mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43"}, ] [package.dependencies] @@ -1021,14 +1035,14 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "polyfactory" -version = "2.18.1" +version = "2.19.0" description = "Mock data generation factories" optional = false python-versions = "<4.0,>=3.8" groups = ["main"] files = [ - {file = "polyfactory-2.18.1-py3-none-any.whl", hash = "sha256:1a2b0715e08bfe9f14abc838fc013ab8772cb90e66f2e601e15e1127f0bc1b18"}, - {file = "polyfactory-2.18.1.tar.gz", hash = "sha256:17c9db18afe4fb8d7dd8e5ba296e69da0fcf7d0f3b63d1840eb10d135aed5aad"}, + {file = "polyfactory-2.19.0-py3-none-any.whl", hash = "sha256:0137f5eaf1bc31c62c16ccbab9467e96a7352748ca426ef363bd081c149a3e3f"}, + {file = "polyfactory-2.19.0.tar.gz", hash = "sha256:6d4273fb1f23e1fccc7aa7c64e28ddc3c20105cc499df32ebc478465daa7fa72"}, ] [package.dependencies] @@ -1041,99 +1055,115 @@ beanie = ["beanie", "pydantic[email]"] full = ["attrs", "beanie", "msgspec", "odmantic", "pydantic", "sqlalchemy"] msgspec = ["msgspec"] odmantic = ["odmantic (<1.0.0)", "pydantic[email]"] -pydantic = ["pydantic[email]"] +pydantic = ["pydantic[email] (>=1.10)"] sqlalchemy = ["sqlalchemy (>=1.4.29)"] [[package]] name = "propcache" -version = "0.2.1" +version = "0.3.0" description = "Accelerated property cache" optional = false python-versions = ">=3.9" groups = ["main", "test"] files = [ - {file = "propcache-0.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6b3f39a85d671436ee3d12c017f8fdea38509e4f25b28eb25877293c98c243f6"}, - {file = "propcache-0.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d51fbe4285d5db5d92a929e3e21536ea3dd43732c5b177c7ef03f918dff9f2"}, - {file = "propcache-0.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6445804cf4ec763dc70de65a3b0d9954e868609e83850a47ca4f0cb64bd79fea"}, - {file = "propcache-0.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9479aa06a793c5aeba49ce5c5692ffb51fcd9a7016e017d555d5e2b0045d212"}, - {file = "propcache-0.2.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9631c5e8b5b3a0fda99cb0d29c18133bca1e18aea9effe55adb3da1adef80d3"}, - {file = "propcache-0.2.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3156628250f46a0895f1f36e1d4fbe062a1af8718ec3ebeb746f1d23f0c5dc4d"}, - {file = "propcache-0.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b6fb63ae352e13748289f04f37868099e69dba4c2b3e271c46061e82c745634"}, - {file = "propcache-0.2.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:887d9b0a65404929641a9fabb6452b07fe4572b269d901d622d8a34a4e9043b2"}, - {file = "propcache-0.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a96dc1fa45bd8c407a0af03b2d5218392729e1822b0c32e62c5bf7eeb5fb3958"}, - {file = "propcache-0.2.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a7e65eb5c003a303b94aa2c3852ef130230ec79e349632d030e9571b87c4698c"}, - {file = "propcache-0.2.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:999779addc413181912e984b942fbcc951be1f5b3663cd80b2687758f434c583"}, - {file = "propcache-0.2.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:19a0f89a7bb9d8048d9c4370c9c543c396e894c76be5525f5e1ad287f1750ddf"}, - {file = "propcache-0.2.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:1ac2f5fe02fa75f56e1ad473f1175e11f475606ec9bd0be2e78e4734ad575034"}, - {file = "propcache-0.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:574faa3b79e8ebac7cb1d7930f51184ba1ccf69adfdec53a12f319a06030a68b"}, - {file = "propcache-0.2.1-cp310-cp310-win32.whl", hash = "sha256:03ff9d3f665769b2a85e6157ac8b439644f2d7fd17615a82fa55739bc97863f4"}, - {file = "propcache-0.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:2d3af2e79991102678f53e0dbf4c35de99b6b8b58f29a27ca0325816364caaba"}, - {file = "propcache-0.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ffc3cca89bb438fb9c95c13fc874012f7b9466b89328c3c8b1aa93cdcfadd16"}, - {file = "propcache-0.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f174bbd484294ed9fdf09437f889f95807e5f229d5d93588d34e92106fbf6717"}, - {file = "propcache-0.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:70693319e0b8fd35dd863e3e29513875eb15c51945bf32519ef52927ca883bc3"}, - {file = "propcache-0.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b480c6a4e1138e1aa137c0079b9b6305ec6dcc1098a8ca5196283e8a49df95a9"}, - {file = "propcache-0.2.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d27b84d5880f6d8aa9ae3edb253c59d9f6642ffbb2c889b78b60361eed449787"}, - {file = "propcache-0.2.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:857112b22acd417c40fa4595db2fe28ab900c8c5fe4670c7989b1c0230955465"}, - {file = "propcache-0.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf6c4150f8c0e32d241436526f3c3f9cbd34429492abddbada2ffcff506c51af"}, - {file = "propcache-0.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66d4cfda1d8ed687daa4bc0274fcfd5267873db9a5bc0418c2da19273040eeb7"}, - {file = "propcache-0.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2f992c07c0fca81655066705beae35fc95a2fa7366467366db627d9f2ee097f"}, - {file = "propcache-0.2.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:4a571d97dbe66ef38e472703067021b1467025ec85707d57e78711c085984e54"}, - {file = "propcache-0.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bb6178c241278d5fe853b3de743087be7f5f4c6f7d6d22a3b524d323eecec505"}, - {file = "propcache-0.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ad1af54a62ffe39cf34db1aa6ed1a1873bd548f6401db39d8e7cd060b9211f82"}, - {file = "propcache-0.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e7048abd75fe40712005bcfc06bb44b9dfcd8e101dda2ecf2f5aa46115ad07ca"}, - {file = "propcache-0.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:160291c60081f23ee43d44b08a7e5fb76681221a8e10b3139618c5a9a291b84e"}, - {file = "propcache-0.2.1-cp311-cp311-win32.whl", hash = "sha256:819ce3b883b7576ca28da3861c7e1a88afd08cc8c96908e08a3f4dd64a228034"}, - {file = "propcache-0.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:edc9fc7051e3350643ad929df55c451899bb9ae6d24998a949d2e4c87fb596d3"}, - {file = "propcache-0.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:081a430aa8d5e8876c6909b67bd2d937bfd531b0382d3fdedb82612c618bc41a"}, - {file = "propcache-0.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2ccec9ac47cf4e04897619c0e0c1a48c54a71bdf045117d3a26f80d38ab1fb0"}, - {file = "propcache-0.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:14d86fe14b7e04fa306e0c43cdbeebe6b2c2156a0c9ce56b815faacc193e320d"}, - {file = "propcache-0.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:049324ee97bb67285b49632132db351b41e77833678432be52bdd0289c0e05e4"}, - {file = "propcache-0.2.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1cd9a1d071158de1cc1c71a26014dcdfa7dd3d5f4f88c298c7f90ad6f27bb46d"}, - {file = "propcache-0.2.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98110aa363f1bb4c073e8dcfaefd3a5cea0f0834c2aab23dda657e4dab2f53b5"}, - {file = "propcache-0.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:647894f5ae99c4cf6bb82a1bb3a796f6e06af3caa3d32e26d2350d0e3e3faf24"}, - {file = "propcache-0.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfd3223c15bebe26518d58ccf9a39b93948d3dcb3e57a20480dfdd315356baff"}, - {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d71264a80f3fcf512eb4f18f59423fe82d6e346ee97b90625f283df56aee103f"}, - {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e73091191e4280403bde6c9a52a6999d69cdfde498f1fdf629105247599b57ec"}, - {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3935bfa5fede35fb202c4b569bb9c042f337ca4ff7bd540a0aa5e37131659348"}, - {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f508b0491767bb1f2b87fdfacaba5f7eddc2f867740ec69ece6d1946d29029a6"}, - {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1672137af7c46662a1c2be1e8dc78cb6d224319aaa40271c9257d886be4363a6"}, - {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b74c261802d3d2b85c9df2dfb2fa81b6f90deeef63c2db9f0e029a3cac50b518"}, - {file = "propcache-0.2.1-cp312-cp312-win32.whl", hash = "sha256:d09c333d36c1409d56a9d29b3a1b800a42c76a57a5a8907eacdbce3f18768246"}, - {file = "propcache-0.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:c214999039d4f2a5b2073ac506bba279945233da8c786e490d411dfc30f855c1"}, - {file = "propcache-0.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aca405706e0b0a44cc6bfd41fbe89919a6a56999157f6de7e182a990c36e37bc"}, - {file = "propcache-0.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:12d1083f001ace206fe34b6bdc2cb94be66d57a850866f0b908972f90996b3e9"}, - {file = "propcache-0.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d93f3307ad32a27bda2e88ec81134b823c240aa3abb55821a8da553eed8d9439"}, - {file = "propcache-0.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba278acf14471d36316159c94a802933d10b6a1e117b8554fe0d0d9b75c9d536"}, - {file = "propcache-0.2.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4e6281aedfca15301c41f74d7005e6e3f4ca143584ba696ac69df4f02f40d629"}, - {file = "propcache-0.2.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b750a8e5a1262434fb1517ddf64b5de58327f1adc3524a5e44c2ca43305eb0b"}, - {file = "propcache-0.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf72af5e0fb40e9babf594308911436c8efde3cb5e75b6f206c34ad18be5c052"}, - {file = "propcache-0.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2d0a12018b04f4cb820781ec0dffb5f7c7c1d2a5cd22bff7fb055a2cb19ebce"}, - {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e800776a79a5aabdb17dcc2346a7d66d0777e942e4cd251defeb084762ecd17d"}, - {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4160d9283bd382fa6c0c2b5e017acc95bc183570cd70968b9202ad6d8fc48dce"}, - {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b43e74f1359353341a7adb783c8f1b1c676367b011709f466f42fda2045e95"}, - {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:58791550b27d5488b1bb52bc96328456095d96206a250d28d874fafe11b3dfaf"}, - {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0f022d381747f0dfe27e99d928e31bc51a18b65bb9e481ae0af1380a6725dd1f"}, - {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:297878dc9d0a334358f9b608b56d02e72899f3b8499fc6044133f0d319e2ec30"}, - {file = "propcache-0.2.1-cp313-cp313-win32.whl", hash = "sha256:ddfab44e4489bd79bda09d84c430677fc7f0a4939a73d2bba3073036f487a0a6"}, - {file = "propcache-0.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:556fc6c10989f19a179e4321e5d678db8eb2924131e64652a51fe83e4c3db0e1"}, - {file = "propcache-0.2.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6a9a8c34fb7bb609419a211e59da8887eeca40d300b5ea8e56af98f6fbbb1541"}, - {file = "propcache-0.2.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ae1aa1cd222c6d205853b3013c69cd04515f9d6ab6de4b0603e2e1c33221303e"}, - {file = "propcache-0.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:accb6150ce61c9c4b7738d45550806aa2b71c7668c6942f17b0ac182b6142fd4"}, - {file = "propcache-0.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5eee736daafa7af6d0a2dc15cc75e05c64f37fc37bafef2e00d77c14171c2097"}, - {file = "propcache-0.2.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7a31fc1e1bd362874863fdeed71aed92d348f5336fd84f2197ba40c59f061bd"}, - {file = "propcache-0.2.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba4cfa1052819d16699e1d55d18c92b6e094d4517c41dd231a8b9f87b6fa681"}, - {file = "propcache-0.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f089118d584e859c62b3da0892b88a83d611c2033ac410e929cb6754eec0ed16"}, - {file = "propcache-0.2.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:781e65134efaf88feb447e8c97a51772aa75e48b794352f94cb7ea717dedda0d"}, - {file = "propcache-0.2.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:31f5af773530fd3c658b32b6bdc2d0838543de70eb9a2156c03e410f7b0d3aae"}, - {file = "propcache-0.2.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:a7a078f5d37bee6690959c813977da5291b24286e7b962e62a94cec31aa5188b"}, - {file = "propcache-0.2.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cea7daf9fc7ae6687cf1e2c049752f19f146fdc37c2cc376e7d0032cf4f25347"}, - {file = "propcache-0.2.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:8b3489ff1ed1e8315674d0775dc7d2195fb13ca17b3808721b54dbe9fd020faf"}, - {file = "propcache-0.2.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9403db39be1393618dd80c746cb22ccda168efce239c73af13c3763ef56ffc04"}, - {file = "propcache-0.2.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5d97151bc92d2b2578ff7ce779cdb9174337390a535953cbb9452fb65164c587"}, - {file = "propcache-0.2.1-cp39-cp39-win32.whl", hash = "sha256:9caac6b54914bdf41bcc91e7eb9147d331d29235a7c967c150ef5df6464fd1bb"}, - {file = "propcache-0.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:92fc4500fcb33899b05ba73276dfb684a20d31caa567b7cb5252d48f896a91b1"}, - {file = "propcache-0.2.1-py3-none-any.whl", hash = "sha256:52277518d6aae65536e9cea52d4e7fd2f7a66f4aa2d30ed3f2fcea620ace3c54"}, - {file = "propcache-0.2.1.tar.gz", hash = "sha256:3f77ce728b19cb537714499928fe800c3dda29e8d9428778fc7c186da4c09a64"}, + {file = "propcache-0.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:efa44f64c37cc30c9f05932c740a8b40ce359f51882c70883cc95feac842da4d"}, + {file = "propcache-0.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2383a17385d9800b6eb5855c2f05ee550f803878f344f58b6e194de08b96352c"}, + {file = "propcache-0.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d3e7420211f5a65a54675fd860ea04173cde60a7cc20ccfbafcccd155225f8bc"}, + {file = "propcache-0.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3302c5287e504d23bb0e64d2a921d1eb4a03fb93a0a0aa3b53de059f5a5d737d"}, + {file = "propcache-0.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7e2e068a83552ddf7a39a99488bcba05ac13454fb205c847674da0352602082f"}, + {file = "propcache-0.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d913d36bdaf368637b4f88d554fb9cb9d53d6920b9c5563846555938d5450bf"}, + {file = "propcache-0.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ee1983728964d6070ab443399c476de93d5d741f71e8f6e7880a065f878e0b9"}, + {file = "propcache-0.3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:36ca5e9a21822cc1746023e88f5c0af6fce3af3b85d4520efb1ce4221bed75cc"}, + {file = "propcache-0.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9ecde3671e62eeb99e977f5221abcf40c208f69b5eb986b061ccec317c82ebd0"}, + {file = "propcache-0.3.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d383bf5e045d7f9d239b38e6acadd7b7fdf6c0087259a84ae3475d18e9a2ae8b"}, + {file = "propcache-0.3.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:8cb625bcb5add899cb8ba7bf716ec1d3e8f7cdea9b0713fa99eadf73b6d4986f"}, + {file = "propcache-0.3.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:5fa159dcee5dba00c1def3231c249cf261185189205073bde13797e57dd7540a"}, + {file = "propcache-0.3.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a7080b0159ce05f179cfac592cda1a82898ca9cd097dacf8ea20ae33474fbb25"}, + {file = "propcache-0.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed7161bccab7696a473fe7ddb619c1d75963732b37da4618ba12e60899fefe4f"}, + {file = "propcache-0.3.0-cp310-cp310-win32.whl", hash = "sha256:bf0d9a171908f32d54f651648c7290397b8792f4303821c42a74e7805bfb813c"}, + {file = "propcache-0.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:42924dc0c9d73e49908e35bbdec87adedd651ea24c53c29cac103ede0ea1d340"}, + {file = "propcache-0.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9ddd49258610499aab83b4f5b61b32e11fce873586282a0e972e5ab3bcadee51"}, + {file = "propcache-0.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2578541776769b500bada3f8a4eeaf944530516b6e90c089aa368266ed70c49e"}, + {file = "propcache-0.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8074c5dd61c8a3e915fa8fc04754fa55cfa5978200d2daa1e2d4294c1f136aa"}, + {file = "propcache-0.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b58229a844931bca61b3a20efd2be2a2acb4ad1622fc026504309a6883686fbf"}, + {file = "propcache-0.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e45377d5d6fefe1677da2a2c07b024a6dac782088e37c0b1efea4cfe2b1be19b"}, + {file = "propcache-0.3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ec5060592d83454e8063e487696ac3783cc48c9a329498bafae0d972bc7816c9"}, + {file = "propcache-0.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15010f29fbed80e711db272909a074dc79858c6d28e2915704cfc487a8ac89c6"}, + {file = "propcache-0.3.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a254537b9b696ede293bfdbc0a65200e8e4507bc9f37831e2a0318a9b333c85c"}, + {file = "propcache-0.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2b975528998de037dfbc10144b8aed9b8dd5a99ec547f14d1cb7c5665a43f075"}, + {file = "propcache-0.3.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:19d36bb351ad5554ff20f2ae75f88ce205b0748c38b146c75628577020351e3c"}, + {file = "propcache-0.3.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6032231d4a5abd67c7f71168fd64a47b6b451fbcb91c8397c2f7610e67683810"}, + {file = "propcache-0.3.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6985a593417cdbc94c7f9c3403747335e450c1599da1647a5af76539672464d3"}, + {file = "propcache-0.3.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6a1948df1bb1d56b5e7b0553c0fa04fd0e320997ae99689488201f19fa90d2e7"}, + {file = "propcache-0.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8319293e85feadbbfe2150a5659dbc2ebc4afdeaf7d98936fb9a2f2ba0d4c35c"}, + {file = "propcache-0.3.0-cp311-cp311-win32.whl", hash = "sha256:63f26258a163c34542c24808f03d734b338da66ba91f410a703e505c8485791d"}, + {file = "propcache-0.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:cacea77ef7a2195f04f9279297684955e3d1ae4241092ff0cfcef532bb7a1c32"}, + {file = "propcache-0.3.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e53d19c2bf7d0d1e6998a7e693c7e87300dd971808e6618964621ccd0e01fe4e"}, + {file = "propcache-0.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a61a68d630e812b67b5bf097ab84e2cd79b48c792857dc10ba8a223f5b06a2af"}, + {file = "propcache-0.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fb91d20fa2d3b13deea98a690534697742029f4fb83673a3501ae6e3746508b5"}, + {file = "propcache-0.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67054e47c01b7b349b94ed0840ccae075449503cf1fdd0a1fdd98ab5ddc2667b"}, + {file = "propcache-0.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:997e7b8f173a391987df40f3b52c423e5850be6f6df0dcfb5376365440b56667"}, + {file = "propcache-0.3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d663fd71491dde7dfdfc899d13a067a94198e90695b4321084c6e450743b8c7"}, + {file = "propcache-0.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8884ba1a0fe7210b775106b25850f5e5a9dc3c840d1ae9924ee6ea2eb3acbfe7"}, + {file = "propcache-0.3.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa806bbc13eac1ab6291ed21ecd2dd426063ca5417dd507e6be58de20e58dfcf"}, + {file = "propcache-0.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6f4d7a7c0aff92e8354cceca6fe223973ddf08401047920df0fcb24be2bd5138"}, + {file = "propcache-0.3.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:9be90eebc9842a93ef8335291f57b3b7488ac24f70df96a6034a13cb58e6ff86"}, + {file = "propcache-0.3.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bf15fc0b45914d9d1b706f7c9c4f66f2b7b053e9517e40123e137e8ca8958b3d"}, + {file = "propcache-0.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a16167118677d94bb48bfcd91e420088854eb0737b76ec374b91498fb77a70e"}, + {file = "propcache-0.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:41de3da5458edd5678b0f6ff66691507f9885f5fe6a0fb99a5d10d10c0fd2d64"}, + {file = "propcache-0.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:728af36011bb5d344c4fe4af79cfe186729efb649d2f8b395d1572fb088a996c"}, + {file = "propcache-0.3.0-cp312-cp312-win32.whl", hash = "sha256:6b5b7fd6ee7b54e01759f2044f936dcf7dea6e7585f35490f7ca0420fe723c0d"}, + {file = "propcache-0.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:2d15bc27163cd4df433e75f546b9ac31c1ba7b0b128bfb1b90df19082466ff57"}, + {file = "propcache-0.3.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a2b9bf8c79b660d0ca1ad95e587818c30ccdb11f787657458d6f26a1ea18c568"}, + {file = "propcache-0.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b0c1a133d42c6fc1f5fbcf5c91331657a1ff822e87989bf4a6e2e39b818d0ee9"}, + {file = "propcache-0.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bb2f144c6d98bb5cbc94adeb0447cfd4c0f991341baa68eee3f3b0c9c0e83767"}, + {file = "propcache-0.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1323cd04d6e92150bcc79d0174ce347ed4b349d748b9358fd2e497b121e03c8"}, + {file = "propcache-0.3.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b812b3cb6caacd072276ac0492d249f210006c57726b6484a1e1805b3cfeea0"}, + {file = "propcache-0.3.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:742840d1d0438eb7ea4280f3347598f507a199a35a08294afdcc560c3739989d"}, + {file = "propcache-0.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c6e7e4f9167fddc438cd653d826f2222222564daed4116a02a184b464d3ef05"}, + {file = "propcache-0.3.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a94ffc66738da99232ddffcf7910e0f69e2bbe3a0802e54426dbf0714e1c2ffe"}, + {file = "propcache-0.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c6ec957025bf32b15cbc6b67afe233c65b30005e4c55fe5768e4bb518d712f1"}, + {file = "propcache-0.3.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:549722908de62aa0b47a78b90531c022fa6e139f9166be634f667ff45632cc92"}, + {file = "propcache-0.3.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5d62c4f6706bff5d8a52fd51fec6069bef69e7202ed481486c0bc3874912c787"}, + {file = "propcache-0.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:24c04f8fbf60094c531667b8207acbae54146661657a1b1be6d3ca7773b7a545"}, + {file = "propcache-0.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7c5f5290799a3f6539cc5e6f474c3e5c5fbeba74a5e1e5be75587746a940d51e"}, + {file = "propcache-0.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4fa0e7c9c3cf7c276d4f6ab9af8adddc127d04e0fcabede315904d2ff76db626"}, + {file = "propcache-0.3.0-cp313-cp313-win32.whl", hash = "sha256:ee0bd3a7b2e184e88d25c9baa6a9dc609ba25b76daae942edfb14499ac7ec374"}, + {file = "propcache-0.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1c8f7d896a16da9455f882870a507567d4f58c53504dc2d4b1e1d386dfe4588a"}, + {file = "propcache-0.3.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e560fd75aaf3e5693b91bcaddd8b314f4d57e99aef8a6c6dc692f935cc1e6bbf"}, + {file = "propcache-0.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65a37714b8ad9aba5780325228598a5b16c47ba0f8aeb3dc0514701e4413d7c0"}, + {file = "propcache-0.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:07700939b2cbd67bfb3b76a12e1412405d71019df00ca5697ce75e5ef789d829"}, + {file = "propcache-0.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c0fdbdf6983526e269e5a8d53b7ae3622dd6998468821d660d0daf72779aefa"}, + {file = "propcache-0.3.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:794c3dd744fad478b6232289c866c25406ecdfc47e294618bdf1697e69bd64a6"}, + {file = "propcache-0.3.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4544699674faf66fb6b4473a1518ae4999c1b614f0b8297b1cef96bac25381db"}, + {file = "propcache-0.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fddb8870bdb83456a489ab67c6b3040a8d5a55069aa6f72f9d872235fbc52f54"}, + {file = "propcache-0.3.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f857034dc68d5ceb30fb60afb6ff2103087aea10a01b613985610e007053a121"}, + {file = "propcache-0.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:02df07041e0820cacc8f739510078f2aadcfd3fc57eaeeb16d5ded85c872c89e"}, + {file = "propcache-0.3.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f47d52fd9b2ac418c4890aad2f6d21a6b96183c98021f0a48497a904199f006e"}, + {file = "propcache-0.3.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9ff4e9ecb6e4b363430edf2c6e50173a63e0820e549918adef70515f87ced19a"}, + {file = "propcache-0.3.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ecc2920630283e0783c22e2ac94427f8cca29a04cfdf331467d4f661f4072dac"}, + {file = "propcache-0.3.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:c441c841e82c5ba7a85ad25986014be8d7849c3cfbdb6004541873505929a74e"}, + {file = "propcache-0.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c929916cbdb540d3407c66f19f73387f43e7c12fa318a66f64ac99da601bcdf"}, + {file = "propcache-0.3.0-cp313-cp313t-win32.whl", hash = "sha256:0c3e893c4464ebd751b44ae76c12c5f5c1e4f6cbd6fbf67e3783cd93ad221863"}, + {file = "propcache-0.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:75e872573220d1ee2305b35c9813626e620768248425f58798413e9c39741f46"}, + {file = "propcache-0.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:03c091bb752349402f23ee43bb2bff6bd80ccab7c9df6b88ad4322258d6960fc"}, + {file = "propcache-0.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:46ed02532cb66612d42ae5c3929b5e98ae330ea0f3900bc66ec5f4862069519b"}, + {file = "propcache-0.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11ae6a8a01b8a4dc79093b5d3ca2c8a4436f5ee251a9840d7790dccbd96cb649"}, + {file = "propcache-0.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df03cd88f95b1b99052b52b1bb92173229d7a674df0ab06d2b25765ee8404bce"}, + {file = "propcache-0.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03acd9ff19021bd0567582ac88f821b66883e158274183b9e5586f678984f8fe"}, + {file = "propcache-0.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd54895e4ae7d32f1e3dd91261df46ee7483a735017dc6f987904f194aa5fd14"}, + {file = "propcache-0.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26a67e5c04e3119594d8cfae517f4b9330c395df07ea65eab16f3d559b7068fe"}, + {file = "propcache-0.3.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee25f1ac091def37c4b59d192bbe3a206298feeb89132a470325bf76ad122a1e"}, + {file = "propcache-0.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:58e6d2a5a7cb3e5f166fd58e71e9a4ff504be9dc61b88167e75f835da5764d07"}, + {file = "propcache-0.3.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:be90c94570840939fecedf99fa72839aed70b0ced449b415c85e01ae67422c90"}, + {file = "propcache-0.3.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:49ea05212a529c2caffe411e25a59308b07d6e10bf2505d77da72891f9a05641"}, + {file = "propcache-0.3.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:119e244ab40f70a98c91906d4c1f4c5f2e68bd0b14e7ab0a06922038fae8a20f"}, + {file = "propcache-0.3.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:507c5357a8d8b4593b97fb669c50598f4e6cccbbf77e22fa9598aba78292b4d7"}, + {file = "propcache-0.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8526b0941ec5a40220fc4dfde76aed58808e2b309c03e9fa8e2260083ef7157f"}, + {file = "propcache-0.3.0-cp39-cp39-win32.whl", hash = "sha256:7cedd25e5f678f7738da38037435b340694ab34d424938041aa630d8bac42663"}, + {file = "propcache-0.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:bf4298f366ca7e1ad1d21bbb58300a6985015909964077afd37559084590c929"}, + {file = "propcache-0.3.0-py3-none-any.whl", hash = "sha256:67dda3c7325691c2081510e92c561f465ba61b975f481735aefdfc845d2cd043"}, + {file = "propcache-0.3.0.tar.gz", hash = "sha256:a8fd93de4e1d278046345f49e2238cdb298589325849b2645d4a94c53faeffc5"}, ] [[package]] @@ -1182,14 +1212,14 @@ files = [ [[package]] name = "pydantic" -version = "2.10.4" +version = "2.10.6" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "pydantic-2.10.4-py3-none-any.whl", hash = "sha256:597e135ea68be3a37552fb524bc7d0d66dcf93d395acd93a00682f1efcb8ee3d"}, - {file = "pydantic-2.10.4.tar.gz", hash = "sha256:82f12e9723da6de4fe2ba888b5971157b3be7ad914267dea8f05f82b28254f06"}, + {file = "pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584"}, + {file = "pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236"}, ] [package.dependencies] @@ -1316,14 +1346,14 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" [[package]] name = "pydantic-settings" -version = "2.7.1" +version = "2.8.0" description = "Settings management using Pydantic" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "pydantic_settings-2.7.1-py3-none-any.whl", hash = "sha256:590be9e6e24d06db33a4262829edef682500ef008565a969c73d39d5f8bfb3fd"}, - {file = "pydantic_settings-2.7.1.tar.gz", hash = "sha256:10c9caad35e64bfb3c2fbf70a078c0e25cc92499782e5200747f942a065dec93"}, + {file = "pydantic_settings-2.8.0-py3-none-any.whl", hash = "sha256:c782c7dc3fb40e97b238e713c25d26f64314aece2e91abcff592fcac15f71820"}, + {file = "pydantic_settings-2.8.0.tar.gz", hash = "sha256:88e2ca28f6e68ea102c99c3c401d6c9078e68a5df600e97b43891c34e089500a"}, ] [package.dependencies] @@ -1371,23 +1401,23 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments [[package]] name = "pytest-aiohttp" -version = "1.0.5" +version = "1.1.0" description = "Pytest plugin for aiohttp support" optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" groups = ["test"] files = [ - {file = "pytest-aiohttp-1.0.5.tar.gz", hash = "sha256:880262bc5951e934463b15e3af8bb298f11f7d4d3ebac970aab425aff10a780a"}, - {file = "pytest_aiohttp-1.0.5-py3-none-any.whl", hash = "sha256:63a5360fd2f34dda4ab8e6baee4c5f5be4cd186a403cabd498fced82ac9c561e"}, + {file = "pytest_aiohttp-1.1.0-py3-none-any.whl", hash = "sha256:f39a11693a0dce08dd6c542d241e199dd8047a6e6596b2bcfa60d373f143456d"}, + {file = "pytest_aiohttp-1.1.0.tar.gz", hash = "sha256:147de8cb164f3fc9d7196967f109ab3c0b93ea3463ab50631e56438eab7b5adc"}, ] [package.dependencies] -aiohttp = ">=3.8.1" +aiohttp = ">=3.11.0b0" pytest = ">=6.1.0" pytest-asyncio = ">=0.17.2" [package.extras] -testing = ["coverage (==6.2)", "mypy (==0.931)"] +testing = ["coverage (==6.2)", "mypy (==1.12.1)"] [[package]] name = "pytest-archon" @@ -1409,14 +1439,14 @@ dev = ["black", "check-manifest", "check-wheel-contents", "coverage", "flake8", [[package]] name = "pytest-asyncio" -version = "0.25.2" +version = "0.25.3" description = "Pytest support for asyncio" optional = false python-versions = ">=3.9" groups = ["test"] files = [ - {file = "pytest_asyncio-0.25.2-py3-none-any.whl", hash = "sha256:0d0bb693f7b99da304a0634afc0a4b19e49d5e0de2d670f38dc4bfa5727c5075"}, - {file = "pytest_asyncio-0.25.2.tar.gz", hash = "sha256:3f8ef9a98f45948ea91a0ed3dc4268b5326c0e7bce73892acc654df4262ad45f"}, + {file = "pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3"}, + {file = "pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a"}, ] [package.dependencies] @@ -1441,21 +1471,6 @@ files = [ [package.dependencies] pytest = ">=4.2.1" -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[package.dependencies] -six = ">=1.5" - [[package]] name = "python-dotenv" version = "1.0.1" @@ -1590,14 +1605,14 @@ files = [ [[package]] name = "selenium" -version = "4.27.1" +version = "4.29.0" description = "Official Python bindings for Selenium WebDriver" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["test"] files = [ - {file = "selenium-4.27.1-py3-none-any.whl", hash = "sha256:b89b1f62b5cfe8025868556fe82360d6b649d464f75d2655cb966c8f8447ea18"}, - {file = "selenium-4.27.1.tar.gz", hash = "sha256:5296c425a75ff1b44d0d5199042b36a6d1ef76c04fb775b97b40be739a9caae2"}, + {file = "selenium-4.29.0-py3-none-any.whl", hash = "sha256:ce5d26f1ddc1111641113653af33694c13947dd36c2df09cdd33f554351d372e"}, + {file = "selenium-4.29.0.tar.gz", hash = "sha256:3a62f7ec33e669364a6c0562a701deb69745b569c50d55f1a912bf8eb33358ba"}, ] [package.dependencies] @@ -1610,26 +1625,14 @@ websocket-client = ">=1.8,<2.0" [[package]] name = "semver" -version = "3.0.2" +version = "3.0.4" description = "Python helper for Semantic Versioning (https://semver.org)" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "semver-3.0.2-py3-none-any.whl", hash = "sha256:b1ea4686fe70b981f85359eda33199d60c53964284e0cfb4977d243e37cf4bf4"}, - {file = "semver-3.0.2.tar.gz", hash = "sha256:6253adb39c70f6e51afed2fa7152bcd414c411286088fb4b9effb133885ab4cc"}, -] - -[[package]] -name = "six" -version = "1.17.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] -files = [ - {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, - {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, + {file = "semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746"}, + {file = "semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602"}, ] [[package]] @@ -1658,14 +1661,14 @@ files = [ [[package]] name = "trio" -version = "0.28.0" +version = "0.29.0" description = "A friendly Python library for async concurrency and I/O" optional = false python-versions = ">=3.9" groups = ["test"] files = [ - {file = "trio-0.28.0-py3-none-any.whl", hash = "sha256:56d58977acc1635735a96581ec70513cc781b8b6decd299c487d3be2a721cd94"}, - {file = "trio-0.28.0.tar.gz", hash = "sha256:4e547896fe9e8a5658e54e4c7c5fa1db748cbbbaa7c965e7d40505b928c73c05"}, + {file = "trio-0.29.0-py3-none-any.whl", hash = "sha256:d8c463f1a9cc776ff63e331aba44c125f423a5a13c684307e828d930e625ba66"}, + {file = "trio-0.29.0.tar.gz", hash = "sha256:ea0d3967159fc130acb6939a0be0e558e364fee26b5deeecc893a6b08c361bdf"}, ] [package.dependencies] @@ -1678,17 +1681,18 @@ sortedcontainers = "*" [[package]] name = "trio-websocket" -version = "0.11.1" +version = "0.12.2" description = "WebSocket library for Trio" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" groups = ["test"] files = [ - {file = "trio-websocket-0.11.1.tar.gz", hash = "sha256:18c11793647703c158b1f6e62de638acada927344d534e3c7628eedcb746839f"}, - {file = "trio_websocket-0.11.1-py3-none-any.whl", hash = "sha256:520d046b0d030cf970b8b2b2e00c4c2245b3807853ecd44214acd33d74581638"}, + {file = "trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6"}, + {file = "trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae"}, ] [package.dependencies] +outcome = ">=1.2.0" trio = ">=0.11" wsproto = ">=0.14" @@ -1716,6 +1720,18 @@ files = [ {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] +[[package]] +name = "tzdata" +version = "2025.1" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["main"] +files = [ + {file = "tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639"}, + {file = "tzdata-2025.1.tar.gz", hash = "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694"}, +] + [[package]] name = "urllib3" version = "2.3.0" @@ -1912,4 +1928,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "59b8a732fca85772cd843a10fbfe859c4c7e0908c262e01c992f19668430f0fe" +content-hash = "e164e029099d7c5a7030e321c03b33e97f79519c7e3c06efba5cf1b6e51ec90d" diff --git a/pyproject.toml b/pyproject.toml index 51226dd3..e0f4c521 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server jinja2 = "^3.1.3" aiohttp-jinja2 = "^1.6" lxml = {version = "~5.3.0", extras = ["html_clean"]} +babel = "^2.16.0" [tool.poetry.group.dev.dependencies] types-PyYAML = "^6.0.12.20240311" diff --git a/questionpy/i18n.py b/questionpy/i18n.py index 7707cd14..87f1835d 100644 --- a/questionpy/i18n.py +++ b/questionpy/i18n.py @@ -3,9 +3,9 @@ from importlib.resources.abc import Traversable from questionpy_common.environment import Environment, Package, RequestUser +from questionpy_common.manifest import SourceManifest -_DEFAULT_CATEGORY = "LC_MESSAGES" -_DEFAULT_DOMAIN = "package" +DEFAULT_CATEGORY = "LC_MESSAGES" _NULL_TRANSLATIONS = NullTranslations() _STATE: tuple[str, NullTranslations] | None = None @@ -13,6 +13,10 @@ log = logging.getLogger(__name__) +def domain_of(manifest: SourceManifest) -> str: + return f"{manifest.namespace}.{manifest.short_name}" + + def _guess_untranslated_language(package: Package) -> str: # We'll assume that the untranslated messages are in the first supported language according to the manifest. if package.manifest.languages: @@ -45,7 +49,7 @@ def _get_available_mos(package: Package) -> dict[str, Traversable]: if not lang_dir.is_dir(): continue - mo_file = lang_dir / _DEFAULT_CATEGORY / f"{_DEFAULT_DOMAIN}.mo" + mo_file = lang_dir / DEFAULT_CATEGORY / f"{domain_of(package.manifest)}.mo" if mo_file.is_file(): result[lang_dir.name] = mo_file @@ -88,7 +92,7 @@ def initialize(package: Package, env: Environment) -> None: ) return - log.debug("Found MO files for the following languages: %s", available_mos.keys()) + log.debug("Found MO files for the following languages: %s", ", ".join(available_mos.keys())) def prepare_i18n(request_user: RequestUser) -> None: langs_to_use = [lang for lang in request_user.preferred_languages if lang in available_mos] @@ -111,3 +115,11 @@ def prepare_i18n(request_user: RequestUser) -> None: _STATE = primary_lang, translations env.register_on_request_callback(prepare_i18n) + + +def gettext(msgid: str) -> str: + _, translations = get_state() + return translations.gettext(msgid) + + +__all__ = ["DEFAULT_CATEGORY", "domain_of", "get_state", "gettext", "initialize", "is_initialized"] diff --git a/questionpy_sdk/__main__.py b/questionpy_sdk/__main__.py index bfab60e1..30f9fd50 100644 --- a/questionpy_sdk/__main__.py +++ b/questionpy_sdk/__main__.py @@ -9,6 +9,7 @@ import click from questionpy_sdk.commands.create import create +from questionpy_sdk.commands.i18n import i18n from questionpy_sdk.commands.package import package from questionpy_sdk.commands.repo import repo from questionpy_sdk.commands.run import run @@ -38,6 +39,7 @@ def cli(ctx: click.Context, *, verbose: bool, no_interaction: bool) -> None: cli.add_command(package) cli.add_command(run) cli.add_command(repo) +cli.add_command(i18n) if __name__ == "__main__": cli() diff --git a/questionpy_sdk/commands/i18n/__init__.py b/questionpy_sdk/commands/i18n/__init__.py new file mode 100644 index 00000000..8921bc4b --- /dev/null +++ b/questionpy_sdk/commands/i18n/__init__.py @@ -0,0 +1,15 @@ +import click + +from questionpy_sdk.commands.i18n._extract import extract +from questionpy_sdk.commands.i18n._init import init +from questionpy_sdk.commands.i18n._update import update + + +@click.group() +def i18n() -> None: + pass + + +i18n.add_command(extract) +i18n.add_command(init) +i18n.add_command(update) diff --git a/questionpy_sdk/commands/i18n/_extract.py b/questionpy_sdk/commands/i18n/_extract.py new file mode 100644 index 00000000..9509c561 --- /dev/null +++ b/questionpy_sdk/commands/i18n/_extract.py @@ -0,0 +1,42 @@ +from pathlib import Path + +import babel.messages +import babel.messages.pofile +import click +from babel.messages.extract import extract_from_dir + +from questionpy.i18n import domain_of +from questionpy_sdk.package.source import PackageSource + +_BABEL_MAPPING = [("python/**.py", "python"), ("templates/**.j2", "jinja2")] + + +@click.command +@click.argument("source", type=click.Path(exists=True, file_okay=False, path_type=Path)) +@click.option("-o", "--output", type=click.Path(dir_okay=False, path_type=Path)) +def extract(source: Path, output: Path | None = None) -> None: + # TODO: Support extraction from already-zipped-up packages. + + package = PackageSource(source) + domain = domain_of(package.config) + + if not output: + output = source / "locale" / f"{domain}.pot" + output.parent.mkdir(parents=True, exist_ok=True) + + catalog = babel.messages.Catalog( + domain=domain, + project=package.config.identifier, + version=package.config.version, + msgid_bugs_address=package.config.author, + ) + + for filename, lineno, message, comments, context in extract_from_dir( + source, _BABEL_MAPPING, comment_tags=("TRANSLATORS:",), strip_comment_tags=True + ): + catalog.add(message, None, [(filename, lineno)], auto_comments=comments, context=context) + + with output.open("wb") as outfile: + babel.messages.pofile.write_po(outfile, catalog) + + click.echo(f"Catalog template has been written to {output}.") diff --git a/questionpy_sdk/commands/i18n/_init.py b/questionpy_sdk/commands/i18n/_init.py new file mode 100644 index 00000000..4e63d9cd --- /dev/null +++ b/questionpy_sdk/commands/i18n/_init.py @@ -0,0 +1,94 @@ +from collections.abc import Collection +from pathlib import Path + +import babel.messages.extract +import babel.messages.frontend +import babel.messages.pofile +import click +from click import ClickException + +from questionpy.i18n import domain_of +from questionpy_sdk.commands.i18n import extract +from questionpy_sdk.package.source import PackageSource + + +def _init_explicit(ctx: click.Context, pot: Path, localedir: Path, locale: str, *, force: bool) -> None: + init_cmd = babel.messages.frontend.InitCatalog() + init_cmd.locale = locale + init_cmd.input_file = pot + init_cmd.output_file = localedir / f"{locale}.po" + + if init_cmd.output_file.exists() and not force: + # TODO: Change after implementing update. + update_cmd = f"{ctx.parent.command_path} update" if ctx.parent else "update" + msg = ( + f"Output file '{init_cmd.output_file}' already exists. Use '{update_cmd}' to update existing translations " + f"or pass '--force' to overwrite." + ) + raise click.ClickException(msg) + + init_cmd.ensure_finalized() + init_cmd.run() + + +def _init_in_source_dir(ctx: click.Context, package: PackageSource, locale: str, *, force: bool) -> None: + domain = domain_of(package.config) + + pot_file = package.path / "locale" / f"{domain}.pot" + if not pot_file.exists(): + ctx.invoke(extract, source=package.path) + + output_mo_file = package.path / "locale" / f"{locale}.po" + if output_mo_file.exists() and not force: + # TODO: Change after implementing update. + update_cmd = f"{ctx.parent.command_path} update" if ctx.parent else "update" + msg = ( + f"Output file '{output_mo_file}' already exists. Use '{update_cmd}' to update existing translations " + f"or pass '--force' to overwrite." + ) + raise click.ClickException(msg) + + _init_explicit(ctx, pot_file, package.path / "locale", locale, force=force) + + +@click.command() +@click.argument("pot_or_package", type=click.Path(exists=True, path_type=Path)) +@click.argument("locales", nargs=-1) +@click.option("--force", "-f", is_flag=True) +@click.pass_context +def init(ctx: click.Context, pot_or_package: Path, locales: Collection[str] = (), *, force: bool) -> None: + if pot_or_package.is_file() and pot_or_package.suffix == ".pot": + if not locales: + msg = "When initializing from an explicit .pot file, you must specify which locales to initialize." + raise click.UsageError(msg) + + for locale in locales: + _init_explicit(ctx, pot_or_package, pot_or_package.parent / f"{locale}.po", locale, force=force) + + elif pot_or_package.is_dir(): + package = PackageSource(pot_or_package) + + if not locales: + locales = package.config.languages.copy() + + if not force: + for already_present_locale, _, _ in package.discover_po_files(): + locales.remove(already_present_locale) + + if not locales: + # TODO: Change after implementing update. + update_cmd = f"{ctx.parent.command_path} update" if ctx.parent else "update" + msg = ( + f"The package contains no uninitialized locales. Use '{update_cmd} {package.path}' to update them." + ) + raise ClickException(msg) + + click.echo(f"Will initialize PO files for locale(s) {', '.join(locales)}.") + + for locale in locales: + _init_in_source_dir(ctx, package, locale, force=force) + + else: + # TODO: Support zipped-up packages. + msg = f"Expected .pot file or package source directory, got '{pot_or_package}'." + raise click.ClickException(msg) diff --git a/questionpy_sdk/commands/i18n/_update.py b/questionpy_sdk/commands/i18n/_update.py new file mode 100644 index 00000000..51dc27f3 --- /dev/null +++ b/questionpy_sdk/commands/i18n/_update.py @@ -0,0 +1,64 @@ +import itertools +import operator +from collections.abc import Iterator +from datetime import datetime, timedelta +from pathlib import Path + +import babel.messages.frontend +import babel.messages.pofile +import click + +from questionpy.i18n import domain_of +from questionpy_sdk.commands.i18n import extract +from questionpy_sdk.package.source import PackageSource + + +def _update_domain(ctx: click.Context, package: PackageSource, domain: str, po_files: list[tuple[str, str, Path]], + pot_file: Path | None = None) -> None: + default_pot_path = package.path / "locale" / f"{domain}.pot" + if not pot_file: + pot_file = default_pot_path + + if not pot_file.exists(): + if pot_file.samefile(default_pot_path) and domain == domain_of(package.config): + ctx.invoke(extract, package.path) + else: + msg = f"Template catalog '{pot_file}' not found." + raise click.ClickException(msg) + + with pot_file.open("rb") as pot_fd: + template_catalog = babel.messages.pofile.read_po(pot_fd) + + if isinstance(template_catalog.creation_date, datetime): + now = datetime.now(template_catalog.creation_date.tzinfo) + if now - template_catalog.creation_date > timedelta(hours=1): + click.echo("Warning: .pot file is more than 1 hour old.") + + for (locale, _, po_file) in po_files: + cmd = babel.messages.frontend.UpdateCatalog() + cmd.locale = locale + cmd.input_file = pot_file + cmd.output_file = po_file + + cmd.ensure_finalized() + cmd.run() + + +@click.command +@click.argument("package_path", type=click.Path(exists=True, file_okay=False, path_type=Path)) +@click.option("--pot", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option("--domain") +@click.pass_context +def update(ctx: click.Context, package_path: Path, pot: Path | None = None, domain: str | None = None) -> None: + package = PackageSource(package_path) + pos_by_domain = {domain: list(pos) for domain, pos in + itertools.groupby(package.discover_po_files(), operator.itemgetter(1))} + + if domain: + if domain not in pos_by_domain: + msg = f"Package '{package.path}' contains no PO files for domain '{domain}'." + raise click.ClickException(msg) + _update_domain(ctx, package, domain, pos_by_domain[domain], pot) + else: + for po_domain, po_files in pos_by_domain.items(): + _update_domain(ctx, package, po_domain, po_files, pot) diff --git a/questionpy_sdk/package/source.py b/questionpy_sdk/package/source.py index edfd33f9..773248ed 100644 --- a/questionpy_sdk/package/source.py +++ b/questionpy_sdk/package/source.py @@ -1,7 +1,7 @@ # This file is part of the QuestionPy SDK. (https://questionpy.org) # The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. # (c) Technische Universität Berlin, innoCampus - +from collections.abc import Iterator from functools import cached_property from pathlib import Path @@ -9,6 +9,7 @@ from pydantic import ValidationError from yaml import YAMLError +from questionpy import i18n from questionpy_sdk.constants import PACKAGE_CONFIG_FILENAME from questionpy_sdk.models import PackageConfig from questionpy_sdk.package.errors import PackageSourceValidationError @@ -68,3 +69,12 @@ def normalized_filename(self) -> str: @property def path(self) -> Path: return self._path + + def discover_po_files(self) -> Iterator[tuple[str, str, Path]]: + locale_dir = self.path / "locale" + po_file: Path + for po_file in locale_dir.glob("*.po"): + yield po_file.stem, i18n.domain_of(self.config), po_file + + for po_file in locale_dir.glob("*/*.po"): + yield po_file.parent.name, po_file.stem, po_file From 8d36fe0990147ea725ea876dd90c369f244ed503 Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Thu, 23 Jan 2025 18:28:08 +0100 Subject: [PATCH 03/25] feat(i18n): compile .po files during package build --- questionpy_sdk/package/builder.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/questionpy_sdk/package/builder.py b/questionpy_sdk/package/builder.py index 684fb29b..f125a415 100644 --- a/questionpy_sdk/package/builder.py +++ b/questionpy_sdk/package/builder.py @@ -7,6 +7,7 @@ import logging import shutil import subprocess +import tempfile import zipfile from abc import abstractmethod from contextlib import AbstractContextManager @@ -16,7 +17,10 @@ from tempfile import TemporaryDirectory from types import TracebackType +import babel.messages.frontend + import questionpy +from questionpy import i18n from questionpy_common.constants import DIST_DIR, MANIFEST_FILENAME from questionpy_common.manifest import Manifest, PackageFile from questionpy_sdk.models import BuildHookName @@ -49,6 +53,7 @@ def write_package(self) -> None: self._install_questionpy() self._install_requirements() self._write_package_files() + self._compile_pos() self._write_manifest() if self._copy_sources: self._copy_source_files() @@ -92,7 +97,7 @@ def _install_requirements(self) -> None: # pip doesn't offer a public API, so we have to resort to subprocess (pypa/pip#3121) try: with TemporaryDirectory(prefix=f"qpy_{config.short_name}") as tempdir: - subprocess.run(["pip", "install", "--target", tempdir, *pip_args], check=True, capture_output=True) # noqa: S603, S607 + subprocess.run(["pip", "install", "--target", tempdir, *pip_args], check=True, capture_output=True) self._write_glob(Path(tempdir), "**/*", Path(DIST_DIR) / "dependencies" / "site-packages") except subprocess.CalledProcessError as exc: msg = f"Failed to install requirements: {exc.stderr.decode()}" @@ -145,6 +150,22 @@ def _copy_source_files(self) -> None: log.debug("%s: %s", source_file, path_in_pkg) self._write_file(source_file, path_in_pkg) + def _compile_pos(self) -> None: + with tempfile.TemporaryDirectory(prefix="qpy_build_locales_") as tempdir_str: + tempdir = Path(tempdir_str) + for locale, domain, po_file in self._source.discover_po_files(): + outfile = tempdir / locale / i18n.DEFAULT_CATEGORY / f"{domain}.mo" + outfile.parent.mkdir(parents=True, exist_ok=True) + + cmd = babel.messages.frontend.CompileCatalog() + cmd.locale = locale + cmd.input_file = po_file + cmd.output_file = outfile + cmd.ensure_finalized() + cmd.run() + + self._write_glob(tempdir, "**/*.mo", f"{DIST_DIR}/locale") + def _write_glob( self, source_dir: Path, glob: str, prefix: str | Path = "", *, add_to_static_files: bool = False ) -> None: From d38c7e2d206e7c5dbf3adcf205db5ecd201db088 Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Mon, 27 Jan 2025 14:11:46 +0100 Subject: [PATCH 04/25] fix(i18n): use a ContextVar for the current state --- questionpy/i18n.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/questionpy/i18n.py b/questionpy/i18n.py index 87f1835d..4035f26e 100644 --- a/questionpy/i18n.py +++ b/questionpy/i18n.py @@ -1,4 +1,5 @@ import logging +from contextvars import ContextVar from gettext import GNUTranslations, NullTranslations from importlib.resources.abc import Traversable @@ -8,9 +9,9 @@ DEFAULT_CATEGORY = "LC_MESSAGES" _NULL_TRANSLATIONS = NullTranslations() -_STATE: tuple[str, NullTranslations] | None = None +_i18n_state: ContextVar[tuple[str, NullTranslations]] = ContextVar(f"_i18n_state") -log = logging.getLogger(__name__) +_log = logging.getLogger(__name__) def domain_of(manifest: SourceManifest) -> str: @@ -58,50 +59,50 @@ def _get_available_mos(package: Package) -> dict[str, Traversable]: def get_state() -> tuple[str, NullTranslations]: """Get the current i18n state of the worker.""" - if not _STATE: + state = _i18n_state.get(None) + if not state: msg = f"i18n was not initialized. Call {__name__}.{initialize.__name__} first." raise RuntimeError(msg) - return _STATE + return state def is_initialized() -> bool: - return _STATE is not None + return _i18n_state.get(None) is not None def initialize(package: Package, env: Environment) -> None: # PLW0603 discourages the global statement, but the alternative would be much less readable. # ruff: noqa: PLW0603 - global _STATE - if _STATE: + if is_initialized(): # Prevent multiple initializations, which would add multiple on_request_callbacks overwriting each other. return untranslated_lang = _guess_untranslated_language(package) _NULL_TRANSLATIONS.install() - _STATE = (untranslated_lang, _NULL_TRANSLATIONS) + _i18n_state.set((untranslated_lang, _NULL_TRANSLATIONS)) available_mos = _get_available_mos(package) if not available_mos: # We'd never translate anything anyway, prepare_i18n would install the NullTranslations every time. # (Ohne MOs nix los) - log.debug( + _log.debug( "No MO files found, messages will not be translated. We'll assume the untranslated strings to be in '%s'.", untranslated_lang, ) return - log.debug("Found MO files for the following languages: %s", ", ".join(available_mos.keys())) + _log.debug("Found MO files for the following languages: %s", ", ".join(available_mos.keys())) def prepare_i18n(request_user: RequestUser) -> None: langs_to_use = [lang for lang in request_user.preferred_languages if lang in available_mos] if langs_to_use: - log.debug("Using the following languages for this request: %s", langs_to_use) + _log.debug("Using the following languages for this request: %s", langs_to_use) primary_lang = langs_to_use[0] else: - log.debug( + _log.debug( "There are no MO files for any of the user's preferred languages. Messages will not be translated " "and we'll assume the untranslated strings to be in '%s'.", untranslated_lang, @@ -111,8 +112,7 @@ def prepare_i18n(request_user: RequestUser) -> None: translations = _build_translations([available_mos[lang] for lang in langs_to_use]) translations.install() - global _STATE - _STATE = primary_lang, translations + _i18n_state.set((primary_lang, translations)) env.register_on_request_callback(prepare_i18n) From aba75ea92ad00599947725c662e0ed0199a08ecb Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Mon, 27 Jan 2025 14:12:09 +0100 Subject: [PATCH 05/25] feat(i18n): add german translations to the example --- examples/i18n/locale/de.po | 23 +++++++++++--------- examples/i18n/locale/en.po | 8 +++---- examples/i18n/locale/local.i18n.pot | 6 ++--- examples/i18n/python/local/i18n/__init__.py | 2 +- examples/i18n/templates/formulation.xhtml.j2 | 2 +- 5 files changed, 22 insertions(+), 19 deletions(-) diff --git a/examples/i18n/locale/de.po b/examples/i18n/locale/de.po index 5b95327d..ce37b0aa 100644 --- a/examples/i18n/locale/de.po +++ b/examples/i18n/locale/de.po @@ -8,30 +8,33 @@ msgid "" msgstr "" "Project-Id-Version: @local/i18n 0.1.0\n" "Report-Msgid-Bugs-To: Jane Doe \n" -"POT-Creation-Date: 2025-01-23 18:23+0100\n" -"PO-Revision-Date: 2025-01-23 18:23+0100\n" -"Last-Translator: FULL NAME \n" -"Language: de\n" +"POT-Creation-Date: 2025-01-27 14:01+0100\n" +"PO-Revision-Date: 2025-01-27 14:07+0100\n" +"Last-Translator: \n" "Language-Team: de \n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Language: de\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Generated-By: Babel 2.16.0\n" +"X-Generator: Poedit 3.5\n" -#. Bla bla bla 2 +#. This comment will be shown in the .pot file. #: python/local/i18n/__init__.py:17 msgid "Important Notice" -msgstr "" +msgstr "Wichtige Mitteilung" #: python/local/i18n/__init__.py:18 msgid "" "If you or a loved one has been diagnosed with mesothelioma, you may be " "entitled to financial compensation." msgstr "" +"Falls Sie oder eine Ihnen nahestehende Person eine Mesotheliom-" +"Diagnose erhalten haben, könnten Sie für finanzielle Entschädigung in " +"Frage kommen." -#. Bla bla bla +#. This comment will be shown in the .pot file. #: templates/formulation.xhtml.j2:4 msgid "This string should be localized!" -msgstr "" - +msgstr "Dieser String sollte lokalisiert sein!" diff --git a/examples/i18n/locale/en.po b/examples/i18n/locale/en.po index 04b599ad..9b52cfda 100644 --- a/examples/i18n/locale/en.po +++ b/examples/i18n/locale/en.po @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: @local/i18n 0.1.0\n" "Report-Msgid-Bugs-To: Jane Doe \n" -"POT-Creation-Date: 2025-01-23 18:23+0100\n" -"PO-Revision-Date: 2025-01-23 18:23+0100\n" +"POT-Creation-Date: 2025-01-27 14:01+0100\n" +"PO-Revision-Date: 2025-01-27 14:01+0100\n" "Last-Translator: FULL NAME \n" "Language: en\n" "Language-Team: en \n" @@ -19,7 +19,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.16.0\n" -#. Bla bla bla 2 +#. This comment will be shown in the .pot file. #: python/local/i18n/__init__.py:17 msgid "Important Notice" msgstr "" @@ -30,7 +30,7 @@ msgid "" "entitled to financial compensation." msgstr "" -#. Bla bla bla +#. This comment will be shown in the .pot file. #: templates/formulation.xhtml.j2:4 msgid "This string should be localized!" msgstr "" diff --git a/examples/i18n/locale/local.i18n.pot b/examples/i18n/locale/local.i18n.pot index aa3aac20..d9978e40 100644 --- a/examples/i18n/locale/local.i18n.pot +++ b/examples/i18n/locale/local.i18n.pot @@ -9,7 +9,7 @@ msgid "" msgstr "" "Project-Id-Version: @local/i18n 0.1.0\n" "Report-Msgid-Bugs-To: Jane Doe \n" -"POT-Creation-Date: 2025-01-23 18:23+0100\n" +"POT-Creation-Date: 2025-01-27 14:01+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,7 +18,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.16.0\n" -#. Bla bla bla 2 +#. This comment will be shown in the .pot file. #: python/local/i18n/__init__.py:17 msgid "Important Notice" msgstr "" @@ -29,7 +29,7 @@ msgid "" "entitled to financial compensation." msgstr "" -#. Bla bla bla +#. This comment will be shown in the .pot file. #: templates/formulation.xhtml.j2:4 msgid "This string should be localized!" msgstr "" diff --git a/examples/i18n/python/local/i18n/__init__.py b/examples/i18n/python/local/i18n/__init__.py index db8d2984..cc57e663 100644 --- a/examples/i18n/python/local/i18n/__init__.py +++ b/examples/i18n/python/local/i18n/__init__.py @@ -13,7 +13,7 @@ def _(a): class I18NModel(FormModel): txt: StaticTextElement = static_text( - # TRANSLATORS: Bla bla bla 2 + # TRANSLATORS: This comment will be shown in the .pot file. _("Important Notice"), _("If you or a loved one has been diagnosed with mesothelioma, you may be entitled to financial compensation."), ) diff --git a/examples/i18n/templates/formulation.xhtml.j2 b/examples/i18n/templates/formulation.xhtml.j2 index a549a5ec..262fb24d 100644 --- a/examples/i18n/templates/formulation.xhtml.j2 +++ b/examples/i18n/templates/formulation.xhtml.j2 @@ -1,5 +1,5 @@
- {# TRANSLATORS: Bla bla bla #} + {# TRANSLATORS: This comment will be shown in the .pot file. #}

{{ _("This string should be localized!") }}

From ce56c222bf2a8d020e802e7cef59ec47b0cb32c3 Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Mon, 27 Jan 2025 15:33:25 +0100 Subject: [PATCH 06/25] feat(i18n): warn when .mo files already exist --- questionpy_sdk/package/builder.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/questionpy_sdk/package/builder.py b/questionpy_sdk/package/builder.py index f125a415..5c3677af 100644 --- a/questionpy_sdk/package/builder.py +++ b/questionpy_sdk/package/builder.py @@ -154,6 +154,11 @@ def _compile_pos(self) -> None: with tempfile.TemporaryDirectory(prefix="qpy_build_locales_") as tempdir_str: tempdir = Path(tempdir_str) for locale, domain, po_file in self._source.discover_po_files(): + if po_file.with_suffix(".mo").exists(): + # By default, Poedit also saves a compiled .mo file, so we warn the user if one exists. + log.warning("The existing .mo file at '%s' will not be used, '%s' will be compiled instead.", + po_file.with_suffix(".mo"), po_file.name) + outfile = tempdir / locale / i18n.DEFAULT_CATEGORY / f"{domain}.mo" outfile.parent.mkdir(parents=True, exist_ok=True) From 9c6376bf8912de05d5d6a6f20fa6835b617c9a4b Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Wed, 19 Feb 2025 15:03:01 +0100 Subject: [PATCH 07/25] feat(i18n): separate state by domain --- examples/i18n/locale/de.po | 17 ++- examples/i18n/locale/en.po | 7 +- examples/i18n/locale/local.i18n.pot | 6 +- examples/i18n/python/local/i18n/__init__.py | 14 +- poetry.lock | 6 +- pyproject.toml | 2 +- questionpy/__init__.py | 4 +- questionpy/_ui.py | 6 +- questionpy/i18n.py | 155 ++++++++++++++------ 9 files changed, 152 insertions(+), 65 deletions(-) diff --git a/examples/i18n/locale/de.po b/examples/i18n/locale/de.po index ce37b0aa..22736089 100644 --- a/examples/i18n/locale/de.po +++ b/examples/i18n/locale/de.po @@ -8,17 +8,16 @@ msgid "" msgstr "" "Project-Id-Version: @local/i18n 0.1.0\n" "Report-Msgid-Bugs-To: Jane Doe \n" -"POT-Creation-Date: 2025-01-27 14:01+0100\n" +"POT-Creation-Date: 2025-02-19 14:57+0100\n" "PO-Revision-Date: 2025-01-27 14:07+0100\n" "Last-Translator: \n" -"Language-Team: de \n" "Language: de\n" +"Language-Team: de \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Generated-By: Babel 2.16.0\n" -"X-Generator: Poedit 3.5\n" #. This comment will be shown in the .pot file. #: python/local/i18n/__init__.py:17 @@ -30,9 +29,13 @@ msgid "" "If you or a loved one has been diagnosed with mesothelioma, you may be " "entitled to financial compensation." msgstr "" -"Falls Sie oder eine Ihnen nahestehende Person eine Mesotheliom-" -"Diagnose erhalten haben, könnten Sie für finanzielle Entschädigung in " -"Frage kommen." +"Falls Sie oder eine Ihnen nahestehende Person eine Mesotheliom-Diagnose " +"erhalten haben, könnten Sie für finanzielle Entschädigung in Frage " +"kommen." + +#: python/local/i18n/__init__.py:32 +msgid "It's all wrong!" +msgstr "Es ist alles falsch!" #. This comment will be shown in the .pot file. #: templates/formulation.xhtml.j2:4 diff --git a/examples/i18n/locale/en.po b/examples/i18n/locale/en.po index 9b52cfda..64634075 100644 --- a/examples/i18n/locale/en.po +++ b/examples/i18n/locale/en.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: @local/i18n 0.1.0\n" "Report-Msgid-Bugs-To: Jane Doe \n" -"POT-Creation-Date: 2025-01-27 14:01+0100\n" +"POT-Creation-Date: 2025-02-19 14:57+0100\n" "PO-Revision-Date: 2025-01-27 14:01+0100\n" "Last-Translator: FULL NAME \n" "Language: en\n" @@ -30,8 +30,11 @@ msgid "" "entitled to financial compensation." msgstr "" +#: python/local/i18n/__init__.py:32 +msgid "It's all wrong!" +msgstr "" + #. This comment will be shown in the .pot file. #: templates/formulation.xhtml.j2:4 msgid "This string should be localized!" msgstr "" - diff --git a/examples/i18n/locale/local.i18n.pot b/examples/i18n/locale/local.i18n.pot index d9978e40..d8215a7e 100644 --- a/examples/i18n/locale/local.i18n.pot +++ b/examples/i18n/locale/local.i18n.pot @@ -9,7 +9,7 @@ msgid "" msgstr "" "Project-Id-Version: @local/i18n 0.1.0\n" "Report-Msgid-Bugs-To: Jane Doe \n" -"POT-Creation-Date: 2025-01-27 14:01+0100\n" +"POT-Creation-Date: 2025-02-19 14:57+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -29,6 +29,10 @@ msgid "" "entitled to financial compensation." msgstr "" +#: python/local/i18n/__init__.py:32 +msgid "It's all wrong!" +msgstr "" + #. This comment will be shown in the .pot file. #: templates/formulation.xhtml.j2:4 msgid "This string should be localized!" diff --git a/examples/i18n/python/local/i18n/__init__.py b/examples/i18n/python/local/i18n/__init__.py index cc57e663..9ff31949 100644 --- a/examples/i18n/python/local/i18n/__init__.py +++ b/examples/i18n/python/local/i18n/__init__.py @@ -1,17 +1,17 @@ # This file is part of the QuestionPy SDK. (https://questionpy.org) # The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. # (c) Technische Universität Berlin, innoCampus -from questionpy import Attempt, NeedsManualScoringError, Question, make_question_type_init +from questionpy import Attempt, NeedsManualScoringError, Question, i18n, make_question_type_init from questionpy.form import FormModel, static_text -from questionpy.i18n import gettext as _ from questionpy_common.elements import StaticTextElement - -def _(a): - return a +_, _N = i18n.get_for(__package__) class I18NModel(FormModel): + # TODO: Implement deferred translation. + _ = lambda x: x # noqa: E731 + txt: StaticTextElement = static_text( # TRANSLATORS: This comment will be shown in the .pot file. _("Important Notice"), @@ -27,6 +27,10 @@ def _compute_score(self) -> float: def formulation(self) -> str: return self.jinja2.get_template("formulation.xhtml.j2").render() + @property + def general_feedback(self) -> str | None: + return "
" + _("It's all wrong!") + "
" + class I18NQuestion(Question): attempt_class = I18NAttempt diff --git a/poetry.lock b/poetry.lock index 8a316927..26b761a6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1572,8 +1572,8 @@ watchdog = "^6.0.0" [package.source] type = "git" url = "https://github.com/questionpy-org/questionpy-server.git" -reference = "3f8f2e6199d2c718aef79b07e3fcd5234282a2b3" -resolved_reference = "3f8f2e6199d2c718aef79b07e3fcd5234282a2b3" +reference = "81bf74d80757a218d7fdb4f8c6ed2fb57f45897a" +resolved_reference = "81bf74d80757a218d7fdb4f8c6ed2fb57f45897a" [[package]] name = "ruff" @@ -1928,4 +1928,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "e164e029099d7c5a7030e321c03b33e97f79519c7e3c06efba5cf1b6e51ec90d" +content-hash = "f22824f37329a40607ba1eb6d37e7221e838f9ad6b1f31e97294d26a3d09676e" diff --git a/pyproject.toml b/pyproject.toml index e0f4c521..e4ecf72b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ python = "^3.11" aiohttp = "^3.9.3" pydantic = "^2.6.4" PyYAML = "^6.0.1" -questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "3f8f2e6199d2c718aef79b07e3fcd5234282a2b3" } +questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "81bf74d80757a218d7fdb4f8c6ed2fb57f45897a" } jinja2 = "^3.1.3" aiohttp-jinja2 = "^1.6" lxml = {version = "~5.3.0", extras = ["html_clean"]} diff --git a/questionpy/__init__.py b/questionpy/__init__.py index 5daa6e95..44126a31 100644 --- a/questionpy/__init__.py +++ b/questionpy/__init__.py @@ -93,6 +93,7 @@ "WorkerResourceLimits", "create_jinja2_environment", "get_qpy_environment", + "i18n", "make_question_type_init", "set_qpy_environment", ] @@ -101,8 +102,7 @@ def make_question_type_init( question_class: type[Question], *, wrap_question: Callable[[Question], QuestionInterface] = QuestionWrapper ) -> PackageInitFunction: - def init(package: Package, env: Environment) -> QuestionTypeWrapper: - i18n.initialize(package, env) + def init(package: Package) -> QuestionTypeWrapper: return QuestionTypeWrapper(question_class, package, wrap_question=wrap_question) return init diff --git a/questionpy/_ui.py b/questionpy/_ui.py index 0ade1627..a3ae5455 100644 --- a/questionpy/_ui.py +++ b/questionpy/_ui.py @@ -92,9 +92,9 @@ def create_jinja2_environment(attempt: "Attempt", question: "Question") -> jinja "question_type": type(question), }) - if i18n.is_initialized(): + translations = i18n.get_translations_of_package(package) + if translations: env.add_extension("jinja2.ext.i18n") - _, translations = i18n.get_state() - env.install_gettext_translations(translations, newstyle=True) + env.install_gettext_translations(translations, newstyle=True) # type: ignore[attr-defined] return env diff --git a/questionpy/i18n.py b/questionpy/i18n.py index 4035f26e..1f58adba 100644 --- a/questionpy/i18n.py +++ b/questionpy/i18n.py @@ -1,21 +1,45 @@ import logging +from collections.abc import Callable from contextvars import ContextVar +from dataclasses import dataclass from gettext import GNUTranslations, NullTranslations from importlib.resources.abc import Traversable - -from questionpy_common.environment import Environment, Package, RequestUser +from typing import TypeAlias + +from questionpy_common.environment import ( + Environment, + Package, + PackageNamespaceAndShortName, + RequestUser, + get_qpy_environment, +) from questionpy_common.manifest import SourceManifest DEFAULT_CATEGORY = "LC_MESSAGES" _NULL_TRANSLATIONS = NullTranslations() -_i18n_state: ContextVar[tuple[str, NullTranslations]] = ContextVar(f"_i18n_state") + +@dataclass +class _RequestState: + user: RequestUser + primary_lang: str + translations: NullTranslations + + +@dataclass +class _DomainState: + untranslated_lang: str + available_mos: dict[str, Traversable] + request_state: _RequestState | None = None + + +_i18n_state: ContextVar[dict[str, _DomainState]] = ContextVar("_i18n_state") _log = logging.getLogger(__name__) -def domain_of(manifest: SourceManifest) -> str: - return f"{manifest.namespace}.{manifest.short_name}" +def domain_of(package: SourceManifest | PackageNamespaceAndShortName) -> str: + return f"{package.namespace}.{package.short_name}" def _guess_untranslated_language(package: Package) -> str: @@ -57,46 +81,80 @@ def _get_available_mos(package: Package) -> dict[str, Traversable]: return result -def get_state() -> tuple[str, NullTranslations]: - """Get the current i18n state of the worker.""" - state = _i18n_state.get(None) - if not state: - msg = f"i18n was not initialized. Call {__name__}.{initialize.__name__} first." +def _require_request_user() -> RequestUser: + env = get_qpy_environment() + if not env.request_user: + msg = "No request is currently being processed." raise RuntimeError(msg) - return state + return env.request_user -def is_initialized() -> bool: - return _i18n_state.get(None) is not None +def _require_request_state(domain: str, domain_state: _DomainState) -> _RequestState: + request_user = _require_request_user() + if not domain_state.request_state or domain_state.request_state.user != request_user: + msg = f"i18n domain '{domain}' was not initialized for the current request." + raise RuntimeError(msg) -def initialize(package: Package, env: Environment) -> None: - # PLW0603 discourages the global statement, but the alternative would be much less readable. - # ruff: noqa: PLW0603 + return domain_state.request_state - if is_initialized(): - # Prevent multiple initializations, which would add multiple on_request_callbacks overwriting each other. - return - untranslated_lang = _guess_untranslated_language(package) - _NULL_TRANSLATIONS.install() - _i18n_state.set((untranslated_lang, _NULL_TRANSLATIONS)) +def get_translations_of_package(package: Package) -> NullTranslations: + """Get the current i18n state of the worker.""" + domain = domain_of(package.manifest) + domain_state = _ensure_initialized(domain, package, get_qpy_environment()) + request_state = _require_request_state(domain, domain_state) + return request_state.translations + + +_GettextFun: TypeAlias = Callable[[str], str] + + +def _get_package_owning_module(module_name: str) -> Package: + # TODO: Dedupe when #152 is in dev. + try: + namespace, short_name, *_ = module_name.split(".", maxsplit=2) + env = get_qpy_environment() + key = PackageNamespaceAndShortName(namespace=namespace, short_name=short_name) + return env.packages[key] + except (KeyError, ValueError) as e: + msg = ( + "Current package namespace and shortname could not be determined from '__module__' attribute. Please do " + "not modify the '__module__' attribute." + ) + raise ValueError(msg) from e - available_mos = _get_available_mos(package) - if not available_mos: - # We'd never translate anything anyway, prepare_i18n would install the NullTranslations every time. - # (Ohne MOs nix los) +def _ensure_initialized(domain: str, package: Package, env: Environment) -> _DomainState: + states_by_domain = _i18n_state.get(None) + if states_by_domain is None: + states_by_domain = {} + _i18n_state.set(states_by_domain) + + domain_state = states_by_domain.get(domain) + if domain_state: + # Already initialized. + return domain_state + + untranslated_lang = _guess_untranslated_language(package) + available_mos = _get_available_mos(package) + if available_mos: _log.debug( - "No MO files found, messages will not be translated. We'll assume the untranslated strings to be in '%s'.", + "For domain '%s', MO files for the following languages were found: %s", + domain, + ", ".join(available_mos.keys()), + ) + else: + _log.debug( + "For domain '%s', no MO files were found. Messages will not be translated. We'll assume the " + "untranslated strings to be in '%s'.", untranslated_lang, ) - return - _log.debug("Found MO files for the following languages: %s", ", ".join(available_mos.keys())) + domain_state = states_by_domain[domain] = _DomainState(untranslated_lang, available_mos) - def prepare_i18n(request_user: RequestUser) -> None: - langs_to_use = [lang for lang in request_user.preferred_languages if lang in available_mos] + def initialize_for_request(request_user: RequestUser) -> None: + langs_to_use = [lang for lang in request_user.preferred_languages if lang in domain_state.available_mos] if langs_to_use: _log.debug("Using the following languages for this request: %s", langs_to_use) @@ -105,21 +163,36 @@ def prepare_i18n(request_user: RequestUser) -> None: _log.debug( "There are no MO files for any of the user's preferred languages. Messages will not be translated " "and we'll assume the untranslated strings to be in '%s'.", - untranslated_lang, + domain_state.untranslated_lang, ) - primary_lang = untranslated_lang + primary_lang = domain_state.untranslated_lang + + translations = _build_translations([domain_state.available_mos[lang] for lang in langs_to_use]) + domain_state.request_state = _RequestState(request_user, primary_lang, translations) + + if env.request_user: + # In case we are called during a request. + initialize_for_request(env.request_user) + + env.register_on_request_callback(initialize_for_request) + + return domain_state - translations = _build_translations([available_mos[lang] for lang in langs_to_use]) - translations.install() - _i18n_state.set((primary_lang, translations)) +def get_for(module_name: str) -> tuple[_GettextFun, _GettextFun]: + # TODO: Maybe cache this? + package = _get_package_owning_module(module_name) + domain = domain_of(package.manifest) + domain_state = _ensure_initialized(domain, package, get_qpy_environment()) - env.register_on_request_callback(prepare_i18n) + def gettext(message: str) -> str: + request_state = _require_request_state(domain, domain_state) + return request_state.translations.gettext(message) + def ngettext(message: str) -> str: + return message -def gettext(msgid: str) -> str: - _, translations = get_state() - return translations.gettext(msgid) + return gettext, ngettext -__all__ = ["DEFAULT_CATEGORY", "domain_of", "get_state", "gettext", "initialize", "is_initialized"] +__all__ = ["DEFAULT_CATEGORY", "domain_of", "get_for", "get_translations_of_package"] From 3f8167ada7311c1b8dc6f36d7fcaa2900bdd6c30 Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Thu, 20 Feb 2025 19:56:47 +0100 Subject: [PATCH 08/25] refactor: introduce NewTypes for domain and BCP 47 lang --- questionpy/i18n.py | 34 ++++++++++--------- questionpy_sdk/_i18n_utils.py | 12 +++++++ questionpy_sdk/package/builder.py | 12 ++++--- questionpy_sdk/package/source.py | 9 +++-- questionpy_sdk/webserver/app.py | 5 +-- questionpy_sdk/webserver/routes/attempt.py | 9 +++-- questionpy_sdk/webserver/routes/options.py | 7 ++-- tests/e2e/test_templates.py | 10 ++++-- tests/questionpy/wrappers/conftest.py | 5 +-- .../questionpy_sdk/commands/repo/conftest.py | 8 ++++- tests/questionpy_sdk/commands/test_package.py | 5 ++- tests/questionpy_sdk/test_models.py | 8 ++--- 12 files changed, 79 insertions(+), 45 deletions(-) create mode 100644 questionpy_sdk/_i18n_utils.py diff --git a/questionpy/i18n.py b/questionpy/i18n.py index 1f58adba..db4a8696 100644 --- a/questionpy/i18n.py +++ b/questionpy/i18n.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from gettext import GNUTranslations, NullTranslations from importlib.resources.abc import Traversable -from typing import TypeAlias +from typing import NewType, TypeAlias from questionpy_common.environment import ( Environment, @@ -13,42 +13,44 @@ RequestUser, get_qpy_environment, ) -from questionpy_common.manifest import SourceManifest +from questionpy_common.manifest import Bcp47LanguageTag, SourceManifest DEFAULT_CATEGORY = "LC_MESSAGES" _NULL_TRANSLATIONS = NullTranslations() +GettextDomain = NewType("GettextDomain", str) + @dataclass class _RequestState: user: RequestUser - primary_lang: str + primary_lang: Bcp47LanguageTag translations: NullTranslations @dataclass class _DomainState: - untranslated_lang: str - available_mos: dict[str, Traversable] + untranslated_lang: Bcp47LanguageTag + available_mos: dict[Bcp47LanguageTag, Traversable] request_state: _RequestState | None = None -_i18n_state: ContextVar[dict[str, _DomainState]] = ContextVar("_i18n_state") +_i18n_state: ContextVar[dict[GettextDomain, _DomainState]] = ContextVar("_i18n_state") _log = logging.getLogger(__name__) -def domain_of(package: SourceManifest | PackageNamespaceAndShortName) -> str: - return f"{package.namespace}.{package.short_name}" +def domain_of(package: SourceManifest | PackageNamespaceAndShortName) -> GettextDomain: + return GettextDomain(f"{package.namespace}.{package.short_name}") -def _guess_untranslated_language(package: Package) -> str: +def _guess_untranslated_language(package: Package) -> Bcp47LanguageTag: # We'll assume that the untranslated messages are in the first supported language according to the manifest. if package.manifest.languages: - return next(iter(package.manifest.languages)) + return package.manifest.languages[0] # If the package lists no supported languages in its manifest, we'll assume it's english. # TODO: An alternative might be "C" or "unknown"? - return "en" + return Bcp47LanguageTag("en") def _build_translations(mos: list[Traversable]) -> NullTranslations: @@ -66,7 +68,7 @@ def _build_translations(mos: list[Traversable]) -> NullTranslations: return translations -def _get_available_mos(package: Package) -> dict[str, Traversable]: +def _get_available_mos(package: Package) -> dict[Bcp47LanguageTag, Traversable]: result = {} locale_dir = package.get_path("locale") @@ -76,7 +78,7 @@ def _get_available_mos(package: Package) -> dict[str, Traversable]: mo_file = lang_dir / DEFAULT_CATEGORY / f"{domain_of(package.manifest)}.mo" if mo_file.is_file(): - result[lang_dir.name] = mo_file + result[Bcp47LanguageTag(lang_dir.name)] = mo_file return result @@ -90,7 +92,7 @@ def _require_request_user() -> RequestUser: return env.request_user -def _require_request_state(domain: str, domain_state: _DomainState) -> _RequestState: +def _require_request_state(domain: GettextDomain, domain_state: _DomainState) -> _RequestState: request_user = _require_request_user() if not domain_state.request_state or domain_state.request_state.user != request_user: msg = f"i18n domain '{domain}' was not initialized for the current request." @@ -125,7 +127,7 @@ def _get_package_owning_module(module_name: str) -> Package: raise ValueError(msg) from e -def _ensure_initialized(domain: str, package: Package, env: Environment) -> _DomainState: +def _ensure_initialized(domain: GettextDomain, package: Package, env: Environment) -> _DomainState: states_by_domain = _i18n_state.get(None) if states_by_domain is None: states_by_domain = {} @@ -195,4 +197,4 @@ def ngettext(message: str) -> str: return gettext, ngettext -__all__ = ["DEFAULT_CATEGORY", "domain_of", "get_for", "get_translations_of_package"] +__all__ = ["DEFAULT_CATEGORY", "GettextDomain", "domain_of", "get_for", "get_translations_of_package"] diff --git a/questionpy_sdk/_i18n_utils.py b/questionpy_sdk/_i18n_utils.py new file mode 100644 index 00000000..87e74b6f --- /dev/null +++ b/questionpy_sdk/_i18n_utils.py @@ -0,0 +1,12 @@ +import babel.messages + +from questionpy_common.manifest import Bcp47LanguageTag + + +def bcp47_to_posix(bcp47: Bcp47LanguageTag) -> str: + locale = babel.Locale.parse(bcp47, sep="-") + result = locale.language + if locale.territory: + result += f"_{locale.territory}" + + return result diff --git a/questionpy_sdk/package/builder.py b/questionpy_sdk/package/builder.py index 5c3677af..fecf58a1 100644 --- a/questionpy_sdk/package/builder.py +++ b/questionpy_sdk/package/builder.py @@ -23,6 +23,7 @@ from questionpy import i18n from questionpy_common.constants import DIST_DIR, MANIFEST_FILENAME from questionpy_common.manifest import Manifest, PackageFile +from questionpy_sdk._i18n_utils import bcp47_to_posix from questionpy_sdk.models import BuildHookName from questionpy_sdk.package.errors import PackageBuildError from questionpy_sdk.package.source import PackageSource @@ -153,17 +154,20 @@ def _copy_source_files(self) -> None: def _compile_pos(self) -> None: with tempfile.TemporaryDirectory(prefix="qpy_build_locales_") as tempdir_str: tempdir = Path(tempdir_str) - for locale, domain, po_file in self._source.discover_po_files(): + for domain, locale, po_file in self._source.discover_po_files(): if po_file.with_suffix(".mo").exists(): # By default, Poedit also saves a compiled .mo file, so we warn the user if one exists. - log.warning("The existing .mo file at '%s' will not be used, '%s' will be compiled instead.", - po_file.with_suffix(".mo"), po_file.name) + log.warning( + "The existing .mo file at '%s' will not be used, '%s' will be compiled instead.", + po_file.with_suffix(".mo"), + po_file.name, + ) outfile = tempdir / locale / i18n.DEFAULT_CATEGORY / f"{domain}.mo" outfile.parent.mkdir(parents=True, exist_ok=True) cmd = babel.messages.frontend.CompileCatalog() - cmd.locale = locale + cmd.locale = bcp47_to_posix(locale) cmd.input_file = po_file cmd.output_file = outfile cmd.ensure_finalized() diff --git a/questionpy_sdk/package/source.py b/questionpy_sdk/package/source.py index 773248ed..fd9540e5 100644 --- a/questionpy_sdk/package/source.py +++ b/questionpy_sdk/package/source.py @@ -10,6 +10,8 @@ from yaml import YAMLError from questionpy import i18n +from questionpy.i18n import GettextDomain +from questionpy_common.manifest import Bcp47LanguageTag from questionpy_sdk.constants import PACKAGE_CONFIG_FILENAME from questionpy_sdk.models import PackageConfig from questionpy_sdk.package.errors import PackageSourceValidationError @@ -70,11 +72,12 @@ def normalized_filename(self) -> str: def path(self) -> Path: return self._path - def discover_po_files(self) -> Iterator[tuple[str, str, Path]]: + def discover_po_files(self) -> Iterator[tuple[GettextDomain, Bcp47LanguageTag, Path]]: locale_dir = self.path / "locale" po_file: Path for po_file in locale_dir.glob("*.po"): - yield po_file.stem, i18n.domain_of(self.config), po_file + yield i18n.domain_of(self.config), Bcp47LanguageTag(po_file.stem), po_file for po_file in locale_dir.glob("*/*.po"): - yield po_file.parent.name, po_file.stem, po_file + yield GettextDomain(po_file.stem), Bcp47LanguageTag(po_file.parent.name), po_file + diff --git a/questionpy_sdk/webserver/app.py b/questionpy_sdk/webserver/app.py index f76f4012..6c42ee7e 100644 --- a/questionpy_sdk/webserver/app.py +++ b/questionpy_sdk/webserver/app.py @@ -16,7 +16,8 @@ from questionpy_common.api.qtype import InvalidQuestionStateError from questionpy_common.constants import MiB -from questionpy_common.manifest import Manifest +from questionpy_common.environment import RequestUser +from questionpy_common.manifest import Bcp47LanguageTag, Manifest from questionpy_server import WorkerPool from questionpy_server.worker.impl.thread import ThreadWorker from questionpy_server.worker.runtime.package_location import PackageLocation @@ -55,7 +56,6 @@ class StateFilename(StrEnum): LAST_ATTEMPT_DATA = "last_attempt_data.json" -DEFAULT_STATE_STORAGE_PATH = Path(__file__).parent / "question_state_storage" DEFAULT_STATE_STORAGE_PATH = Path(__file__).parent / "question_state_storage" LEN_AF_INET = 2 LEN_AF_INET6 = 4 @@ -169,3 +169,4 @@ def _print_urls(self) -> None: SDK_WEBSERVER_APP_KEY = web.AppKey("sdk_webserver_app", WebServer) MANIFEST_APP_KEY = web.AppKey("manifest", Manifest) +DEFAULT_REQUEST_USER = RequestUser([Bcp47LanguageTag("de"), Bcp47LanguageTag("en")]) diff --git a/questionpy_sdk/webserver/routes/attempt.py b/questionpy_sdk/webserver/routes/attempt.py index 7c5987fc..f98d2ea4 100644 --- a/questionpy_sdk/webserver/routes/attempt.py +++ b/questionpy_sdk/webserver/routes/attempt.py @@ -11,8 +11,7 @@ from pydantic import TypeAdapter from questionpy_common.api.attempt import AttemptScoredModel, ScoreModel -from questionpy_common.environment import RequestUser -from questionpy_sdk.webserver.app import SDK_WEBSERVER_APP_KEY, StateFilename +from questionpy_sdk.webserver.app import DEFAULT_REQUEST_USER, SDK_WEBSERVER_APP_KEY, StateFilename from questionpy_sdk.webserver.attempt import get_attempt_render_context from questionpy_sdk.webserver.question_ui import QuestionDisplayOptions @@ -58,7 +57,7 @@ async def get_attempt(request: web.Request) -> web.Response: # Display a previously started attempt. async with webserver.worker_pool.get_worker(webserver.package_location, 0, None) as worker: attempt = await worker.get_attempt( - request_user=RequestUser(["de", "en"]), + request_user=DEFAULT_REQUEST_USER, question_state=question_state, attempt_state=attempt_state, scoring_state=score.scoring_state if score else None, @@ -71,7 +70,7 @@ async def get_attempt(request: web.Request) -> web.Response: # Start a new attempt. async with webserver.worker_pool.get_worker(webserver.package_location, 0, None) as worker: attempt = await worker.start_attempt( - request_user=RequestUser(["de", "en"]), question_state=question_state, variant=1 + request_user=DEFAULT_REQUEST_USER, question_state=question_state, variant=1 ) attempt_state = attempt.attempt_state @@ -115,7 +114,7 @@ async def _score_attempt(request: web.Request, data: Any) -> web.Response: worker: Worker async with webserver.worker_pool.get_worker(webserver.package_location, 0, None) as worker: attempt_scored = await worker.score_attempt( - request_user=RequestUser(["de", "en"]), + request_user=DEFAULT_REQUEST_USER, question_state=question_state, attempt_state=attempt_state, response=data, diff --git a/questionpy_sdk/webserver/routes/options.py b/questionpy_sdk/webserver/routes/options.py index a438e629..acd18e63 100644 --- a/questionpy_sdk/webserver/routes/options.py +++ b/questionpy_sdk/webserver/routes/options.py @@ -9,9 +9,8 @@ from aiohttp import web from questionpy import OptionsFormValidationError -from questionpy_common.environment import RequestUser from questionpy_sdk.webserver._form_data import get_nested_form_data, parse_form_data -from questionpy_sdk.webserver.app import SDK_WEBSERVER_APP_KEY, StateFilename, WebServer +from questionpy_sdk.webserver.app import DEFAULT_REQUEST_USER, SDK_WEBSERVER_APP_KEY, StateFilename, WebServer from questionpy_sdk.webserver.context import contextualize if TYPE_CHECKING: @@ -29,7 +28,7 @@ async def render_options(request: web.Request) -> web.Response: worker: Worker async with webserver.worker_pool.get_worker(webserver.package_location, 0, None) as worker: manifest = await worker.get_manifest() - form_definition, form_data = await worker.get_options_form(RequestUser(["de", "en"]), question_state) + form_definition, form_data = await worker.get_options_form(DEFAULT_REQUEST_USER, question_state) context = { "manifest": manifest, @@ -43,7 +42,7 @@ async def _save_updated_form_data(form_data: dict, webserver: "WebServer") -> No old_state = webserver.read_state_file(StateFilename.QUESTION_STATE) worker: Worker async with webserver.worker_pool.get_worker(webserver.package_location, 0, None) as worker: - question = await worker.create_question_from_options(RequestUser(["de", "en"]), old_state, form_data=form_data) + question = await worker.create_question_from_options(DEFAULT_REQUEST_USER, old_state, form_data=form_data) webserver.write_state_file(StateFilename.QUESTION_STATE, question.question_state) diff --git a/tests/e2e/test_templates.py b/tests/e2e/test_templates.py index f0d6432c..bc627694 100644 --- a/tests/e2e/test_templates.py +++ b/tests/e2e/test_templates.py @@ -11,7 +11,7 @@ from questionpy import Package, QuestionTypeWrapper from questionpy.form import FormModel, checkbox, repeat from questionpy_common.api.qtype import QuestionTypeInterface -from questionpy_common.manifest import Manifest +from questionpy_common.manifest import Bcp47LanguageTag, Manifest from .conftest import _NoopQuestion, use_package @@ -88,7 +88,13 @@ def test_repeat_element_if_present(self, driver: webdriver.Chrome, url: str) -> @use_package( package_1_init, - manifest=Manifest(short_name="my_short_name", version="7.3.1", api_version="9.4", author="Testy McTestface"), + manifest=Manifest( + short_name="my_short_name", + version="7.3.1", + api_version="9.4", + author="Testy McTestface", + languages=[Bcp47LanguageTag("en")], + ), ) def test_page_contains_correct_manifest_information(self, driver: webdriver.Chrome, url: str) -> None: driver.get(url) diff --git a/tests/questionpy/wrappers/conftest.py b/tests/questionpy/wrappers/conftest.py index 7c6b5574..ae9eefda 100644 --- a/tests/questionpy/wrappers/conftest.py +++ b/tests/questionpy/wrappers/conftest.py @@ -9,8 +9,9 @@ from questionpy import Attempt, BaseAttemptState, BaseQuestionState, Question from questionpy.form import FormModel, text_input -from questionpy_common.environment import Environment, RequestUser, set_qpy_environment +from questionpy_common.environment import Environment, set_qpy_environment from questionpy_common.manifest import Manifest, PackageFile +from questionpy_sdk.webserver.app import DEFAULT_REQUEST_USER from questionpy_server.worker.runtime.manager import EnvironmentImpl from questionpy_server.worker.runtime.package import ImportablePackage @@ -44,7 +45,7 @@ def environment(package: ImportablePackage) -> Generator[Environment, None, None env = EnvironmentImpl( type="test", limits=None, - request_user=RequestUser(["en"]), + request_user=DEFAULT_REQUEST_USER, main_package=package, packages={}, _on_request_callbacks=[], diff --git a/tests/questionpy_sdk/commands/repo/conftest.py b/tests/questionpy_sdk/commands/repo/conftest.py index a0025520..ca62ca58 100644 --- a/tests/questionpy_sdk/commands/repo/conftest.py +++ b/tests/questionpy_sdk/commands/repo/conftest.py @@ -8,6 +8,7 @@ from click.testing import CliRunner from yaml import safe_dump +from questionpy_common.manifest import Bcp47LanguageTag from questionpy_sdk.commands.package import package from questionpy_sdk.constants import PACKAGE_CONFIG_FILENAME from questionpy_sdk.models import PackageConfig @@ -40,7 +41,12 @@ def create_package( path to the package and the config """ config = PackageConfig( - short_name=short_name, namespace=namespace, version=version, api_version="0.1", author="pytest" + short_name=short_name, + namespace=namespace, + version=version, + api_version="0.1", + author="pytest", + languages=[Bcp47LanguageTag("en")], ) runner = CliRunner() diff --git a/tests/questionpy_sdk/commands/test_package.py b/tests/questionpy_sdk/commands/test_package.py index b5b54479..9e6ba8f2 100644 --- a/tests/questionpy_sdk/commands/test_package.py +++ b/tests/questionpy_sdk/commands/test_package.py @@ -13,6 +13,7 @@ from click.testing import CliRunner from questionpy_common.constants import DIST_DIR, MANIFEST_FILENAME +from questionpy_common.manifest import Bcp47LanguageTag from questionpy_sdk.commands.package import package from questionpy_sdk.constants import PACKAGE_CONFIG_FILENAME from questionpy_sdk.models import PackageConfig @@ -22,7 +23,9 @@ def create_config(source: Path) -> PackageConfig: """Creates a config in the given `source` directory.""" - config = PackageConfig(short_name="short_name", author="pytest", api_version="0.1", version="0.1.0") + config = PackageConfig( + short_name="short_name", author="pytest", api_version="0.1", version="0.1.0", languages=[Bcp47LanguageTag("en")] + ) with (source / PACKAGE_CONFIG_FILENAME).open("w") as file: yaml.dump(config.model_dump(exclude={"type"}), file) return config diff --git a/tests/questionpy_sdk/test_models.py b/tests/questionpy_sdk/test_models.py index f5e16415..24748c79 100644 --- a/tests/questionpy_sdk/test_models.py +++ b/tests/questionpy_sdk/test_models.py @@ -2,7 +2,7 @@ # The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. # (c) Technische Universität Berlin, innoCampus -from questionpy_common.manifest import SourceManifest +from questionpy_common.manifest import Bcp47LanguageTag, SourceManifest from questionpy_sdk.models import PackageConfig @@ -13,11 +13,9 @@ def test_package_config_strips_config_fields() -> None: api_version="0.1", author="John Doe", build_hooks={"pre": "npm start"}, + languages=[Bcp47LanguageTag("en")], ) exp = SourceManifest( - short_name="foo", - version="0.0.1", - api_version="0.1", - author="John Doe", + short_name="foo", version="0.0.1", api_version="0.1", author="John Doe", languages=[Bcp47LanguageTag("en")] ) assert dict(config.manifest) == dict(exp) From b78f2874fde59e0a54f4a8adaab234a8362b737e Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Thu, 20 Feb 2025 19:57:19 +0100 Subject: [PATCH 09/25] feat: improve i18n update command --- questionpy_sdk/commands/i18n/_init.py | 20 ++--- questionpy_sdk/commands/i18n/_update.py | 108 ++++++++++++++++-------- questionpy_sdk/package/source.py | 3 + 3 files changed, 86 insertions(+), 45 deletions(-) diff --git a/questionpy_sdk/commands/i18n/_init.py b/questionpy_sdk/commands/i18n/_init.py index 4e63d9cd..2822831c 100644 --- a/questionpy_sdk/commands/i18n/_init.py +++ b/questionpy_sdk/commands/i18n/_init.py @@ -8,18 +8,19 @@ from click import ClickException from questionpy.i18n import domain_of +from questionpy_common.manifest import Bcp47LanguageTag +from questionpy_sdk._i18n_utils import bcp47_to_posix from questionpy_sdk.commands.i18n import extract from questionpy_sdk.package.source import PackageSource -def _init_explicit(ctx: click.Context, pot: Path, localedir: Path, locale: str, *, force: bool) -> None: +def _init_explicit(ctx: click.Context, pot: Path, localedir: Path, locale: Bcp47LanguageTag, *, force: bool) -> None: init_cmd = babel.messages.frontend.InitCatalog() - init_cmd.locale = locale + init_cmd.locale = bcp47_to_posix(locale) init_cmd.input_file = pot init_cmd.output_file = localedir / f"{locale}.po" if init_cmd.output_file.exists() and not force: - # TODO: Change after implementing update. update_cmd = f"{ctx.parent.command_path} update" if ctx.parent else "update" msg = ( f"Output file '{init_cmd.output_file}' already exists. Use '{update_cmd}' to update existing translations " @@ -31,7 +32,7 @@ def _init_explicit(ctx: click.Context, pot: Path, localedir: Path, locale: str, init_cmd.run() -def _init_in_source_dir(ctx: click.Context, package: PackageSource, locale: str, *, force: bool) -> None: +def _init_in_source_dir(ctx: click.Context, package: PackageSource, locale: Bcp47LanguageTag, *, force: bool) -> None: domain = domain_of(package.config) pot_file = package.path / "locale" / f"{domain}.pot" @@ -40,7 +41,6 @@ def _init_in_source_dir(ctx: click.Context, package: PackageSource, locale: str, output_mo_file = package.path / "locale" / f"{locale}.po" if output_mo_file.exists() and not force: - # TODO: Change after implementing update. update_cmd = f"{ctx.parent.command_path} update" if ctx.parent else "update" msg = ( f"Output file '{output_mo_file}' already exists. Use '{update_cmd}' to update existing translations " @@ -51,12 +51,12 @@ def _init_in_source_dir(ctx: click.Context, package: PackageSource, locale: str, _init_explicit(ctx, pot_file, package.path / "locale", locale, force=force) -@click.command() +@click.command @click.argument("pot_or_package", type=click.Path(exists=True, path_type=Path)) @click.argument("locales", nargs=-1) @click.option("--force", "-f", is_flag=True) @click.pass_context -def init(ctx: click.Context, pot_or_package: Path, locales: Collection[str] = (), *, force: bool) -> None: +def init(ctx: click.Context, pot_or_package: Path, locales: Collection[Bcp47LanguageTag] = (), *, force: bool) -> None: if pot_or_package.is_file() and pot_or_package.suffix == ".pot": if not locales: msg = "When initializing from an explicit .pot file, you must specify which locales to initialize." @@ -72,14 +72,14 @@ def init(ctx: click.Context, pot_or_package: Path, locales: Collection[str] = () locales = package.config.languages.copy() if not force: - for already_present_locale, _, _ in package.discover_po_files(): + for _, already_present_locale, _ in package.discover_po_files(): locales.remove(already_present_locale) if not locales: - # TODO: Change after implementing update. update_cmd = f"{ctx.parent.command_path} update" if ctx.parent else "update" msg = ( - f"The package contains no uninitialized locales. Use '{update_cmd} {package.path}' to update them." + f"The package contains no uninitialized locales. Use '{update_cmd} {package.path}' if you wish to " + f"update them." ) raise ClickException(msg) diff --git a/questionpy_sdk/commands/i18n/_update.py b/questionpy_sdk/commands/i18n/_update.py index 51dc27f3..8ccc16c4 100644 --- a/questionpy_sdk/commands/i18n/_update.py +++ b/questionpy_sdk/commands/i18n/_update.py @@ -1,6 +1,7 @@ import itertools import operator -from collections.abc import Iterator +import sys +from collections.abc import Iterable from datetime import datetime, timedelta from pathlib import Path @@ -8,57 +9,94 @@ import babel.messages.pofile import click -from questionpy.i18n import domain_of -from questionpy_sdk.commands.i18n import extract +from questionpy.i18n import GettextDomain +from questionpy_common.manifest import Bcp47LanguageTag +from questionpy_sdk._i18n_utils import bcp47_to_posix from questionpy_sdk.package.source import PackageSource -def _update_domain(ctx: click.Context, package: PackageSource, domain: str, po_files: list[tuple[str, str, Path]], - pot_file: Path | None = None) -> None: - default_pot_path = package.path / "locale" / f"{domain}.pot" - if not pot_file: - pot_file = default_pot_path - - if not pot_file.exists(): - if pot_file.samefile(default_pot_path) and domain == domain_of(package.config): - ctx.invoke(extract, package.path) - else: - msg = f"Template catalog '{pot_file}' not found." - raise click.ClickException(msg) - - with pot_file.open("rb") as pot_fd: +def _update_domain( + po_files: Iterable[tuple[GettextDomain, Bcp47LanguageTag, Path]], + pot_path: Path, +) -> None: + with pot_path.open("rb") as pot_fd: template_catalog = babel.messages.pofile.read_po(pot_fd) if isinstance(template_catalog.creation_date, datetime): now = datetime.now(template_catalog.creation_date.tzinfo) if now - template_catalog.creation_date > timedelta(hours=1): - click.echo("Warning: .pot file is more than 1 hour old.") + click.echo("Warning: .pot file is more than 1 hour old. Is it up-to-date?") - for (locale, _, po_file) in po_files: + for _, lang, path in po_files: cmd = babel.messages.frontend.UpdateCatalog() - cmd.locale = locale - cmd.input_file = pot_file - cmd.output_file = po_file + cmd.locale = bcp47_to_posix(lang) + cmd.input_file = pot_path + cmd.output_file = path cmd.ensure_finalized() cmd.run() -@click.command +@click.command() @click.argument("package_path", type=click.Path(exists=True, file_okay=False, path_type=Path)) -@click.option("--pot", type=click.Path(exists=True, dir_okay=False, path_type=Path)) -@click.option("--domain") +@click.option("-t", "--pot", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option("-d", "--domain", "only_domain") @click.pass_context -def update(ctx: click.Context, package_path: Path, pot: Path | None = None, domain: str | None = None) -> None: +def update( + ctx: click.Context, package_path: Path, pot: Path | None = None, only_domain: GettextDomain | None = None +) -> None: + """Updates .po files from .pot files. + + \b + Examples: + Update all .po files in the package source dir:: + + questionpy-sdk i18n update my-package-source-dir/ + + Update the .po files for a specific domain in the package source dir:: + + questionpy-sdk i18n update --domain foo my-package-source-dir/ + + Update the .po files for a specific domain from a .pot file in a non-standard location:: + + questionpy-sdk i18n update my-package-source-dir/ --domain foo --pot template.pot + """ # noqa: D301 (it's a click feature) package = PackageSource(package_path) - pos_by_domain = {domain: list(pos) for domain, pos in - itertools.groupby(package.discover_po_files(), operator.itemgetter(1))} + pos_by_domain = { + domain: list(pos) for domain, pos in itertools.groupby(package.discover_po_files(), operator.itemgetter(0)) + } + + pots_by_domain = dict(package.discover_pot_files()) + if pot: + if not only_domain: + # Assume the .pot filename still follows our convention of .pot. + only_domain = GettextDomain(pot.stem) + pots_by_domain[only_domain] = pot - if domain: + domains_to_update = ( + { + only_domain, + } + if only_domain + else {*pos_by_domain, *pots_by_domain} + ) + + failed_count = 0 + for domain in domains_to_update: if domain not in pos_by_domain: - msg = f"Package '{package.path}' contains no PO files for domain '{domain}'." - raise click.ClickException(msg) - _update_domain(ctx, package, domain, pos_by_domain[domain], pot) - else: - for po_domain, po_files in pos_by_domain.items(): - _update_domain(ctx, package, po_domain, po_files, pot) + init_cmd = f"{ctx.parent.command_path} init" if ctx.parent else "init" + click.echo(f"No .po files for domain '{domain}' were found. Use '{init_cmd}' to create some.") + failed_count += 1 + elif domain not in pots_by_domain: + extract_cmd = f"{ctx.parent.command_path} extract" if ctx.parent else "extract" + click.echo( + f"No .pot file for domain '{domain}' was found. Use '{extract_cmd}' to create one or pass " + f"'--pot {domain}.pot' if it is in a non-standard location." + ) + failed_count += 1 + else: + _update_domain(pos_by_domain[domain], pots_by_domain[domain]) + + if failed_count == len(domains_to_update): + # All failed, let's consider this an error. + sys.exit(1) diff --git a/questionpy_sdk/package/source.py b/questionpy_sdk/package/source.py index fd9540e5..b1adbb5b 100644 --- a/questionpy_sdk/package/source.py +++ b/questionpy_sdk/package/source.py @@ -81,3 +81,6 @@ def discover_po_files(self) -> Iterator[tuple[GettextDomain, Bcp47LanguageTag, P for po_file in locale_dir.glob("*/*.po"): yield GettextDomain(po_file.stem), Bcp47LanguageTag(po_file.parent.name), po_file + def discover_pot_files(self) -> Iterator[tuple[GettextDomain, Path]]: + for pot_file in (self.path / "locale").glob("*.pot"): + yield GettextDomain(pot_file.stem), pot_file From 0c82e645eb1124b63f187990be72f04df2d20423 Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Fri, 21 Feb 2025 18:23:41 +0100 Subject: [PATCH 10/25] feat(i18n): implement deferred translation --- examples/i18n/python/local/i18n/__init__.py | 5 +- questionpy/i18n.py | 99 ++++++++++++++----- questionpy_sdk/webserver/elements.py | 26 ++--- .../webserver/question_ui/__init__.py | 13 ++- 4 files changed, 97 insertions(+), 46 deletions(-) diff --git a/examples/i18n/python/local/i18n/__init__.py b/examples/i18n/python/local/i18n/__init__.py index 9ff31949..1ae5f5c0 100644 --- a/examples/i18n/python/local/i18n/__init__.py +++ b/examples/i18n/python/local/i18n/__init__.py @@ -9,9 +9,6 @@ class I18NModel(FormModel): - # TODO: Implement deferred translation. - _ = lambda x: x # noqa: E731 - txt: StaticTextElement = static_text( # TRANSLATORS: This comment will be shown in the .pot file. _("Important Notice"), @@ -35,5 +32,7 @@ def general_feedback(self) -> str | None: class I18NQuestion(Question): attempt_class = I18NAttempt + options: I18NModel + init = make_question_type_init(I18NQuestion) diff --git a/questionpy/i18n.py b/questionpy/i18n.py index db4a8696..74e453bf 100644 --- a/questionpy/i18n.py +++ b/questionpy/i18n.py @@ -1,10 +1,11 @@ import logging +from collections import UserString from collections.abc import Callable from contextvars import ContextVar from dataclasses import dataclass from gettext import GNUTranslations, NullTranslations from importlib.resources.abc import Traversable -from typing import NewType, TypeAlias +from typing import Literal, NewType, Protocol, TypeAlias, cast, overload from questionpy_common.environment import ( Environment, @@ -32,6 +33,7 @@ class _RequestState: class _DomainState: untranslated_lang: Bcp47LanguageTag available_mos: dict[Bcp47LanguageTag, Traversable] + logger: logging.LoggerAdapter request_state: _RequestState | None = None @@ -44,15 +46,6 @@ def domain_of(package: SourceManifest | PackageNamespaceAndShortName) -> Gettext return GettextDomain(f"{package.namespace}.{package.short_name}") -def _guess_untranslated_language(package: Package) -> Bcp47LanguageTag: - # We'll assume that the untranslated messages are in the first supported language according to the manifest. - if package.manifest.languages: - return package.manifest.languages[0] - # If the package lists no supported languages in its manifest, we'll assume it's english. - # TODO: An alternative might be "C" or "unknown"? - return Bcp47LanguageTag("en") - - def _build_translations(mos: list[Traversable]) -> NullTranslations: if not mos: return _NULL_TRANSLATIONS @@ -109,9 +102,6 @@ def get_translations_of_package(package: Package) -> NullTranslations: return request_state.translations -_GettextFun: TypeAlias = Callable[[str], str] - - def _get_package_owning_module(module_name: str) -> Package: # TODO: Dedupe when #152 is in dev. try: @@ -138,31 +128,32 @@ def _ensure_initialized(domain: GettextDomain, package: Package, env: Environmen # Already initialized. return domain_state - untranslated_lang = _guess_untranslated_language(package) + domain_logger = logging.LoggerAdapter(_log.getChild(domain), extra={"domain": domain}) + + untranslated_lang = package.manifest.languages[0] available_mos = _get_available_mos(package) if available_mos: - _log.debug( - "For domain '%s', MO files for the following languages were found: %s", - domain, + domain_logger.debug( + "MO files for the following languages were found: %s", ", ".join(available_mos.keys()), ) else: - _log.debug( - "For domain '%s', no MO files were found. Messages will not be translated. We'll assume the " + domain_logger.debug( + "No MO files were found. Messages will not be translated. We'll assume the " "untranslated strings to be in '%s'.", untranslated_lang, ) - domain_state = states_by_domain[domain] = _DomainState(untranslated_lang, available_mos) + domain_state = states_by_domain[domain] = _DomainState(untranslated_lang, available_mos, domain_logger) def initialize_for_request(request_user: RequestUser) -> None: langs_to_use = [lang for lang in request_user.preferred_languages if lang in domain_state.available_mos] if langs_to_use: - _log.debug("Using the following languages for this request: %s", langs_to_use) + domain_logger.debug("Using the following languages for this request: %s", langs_to_use) primary_lang = langs_to_use[0] else: - _log.debug( + domain_logger.debug( "There are no MO files for any of the user's preferred languages. Messages will not be translated " "and we'll assume the untranslated strings to be in '%s'.", domain_state.untranslated_lang, @@ -181,20 +172,78 @@ def initialize_for_request(request_user: RequestUser) -> None: return domain_state -def get_for(module_name: str) -> tuple[_GettextFun, _GettextFun]: +class _DeferredTranslatedMessage(UserString): + def __init__(self, domain_state: _DomainState, msg_id: str) -> None: + super().__init__(msg_id) + self._domain_state = domain_state + + @property + def data(self) -> str: + if self._domain_state.request_state: + return self._domain_state.request_state.translations.gettext(self._msg_id) + + self._domain_state.logger.debug( + "Deferred message '%s' not translated because domain is not initialized for request.", + self._msg_id, + ) + return self._msg_id + + @data.setter + def data(self, value: str) -> None: + self._msg_id = value + + +class _Gettext(Protocol): + @overload + def __call__(self, message: str, *, defer: None = None) -> str | UserString: ... + + @overload + def __call__(self, message: str, *, defer: Literal[True]) -> UserString: ... + + @overload + def __call__(self, message: str, *, defer: Literal[False]) -> str: ... + + def __call__(self, message: str, *, defer: bool | None = None) -> str | UserString: + """Translate the given message. + + Args: + message: Gettext `msgid`. + defer: By default, translation is deferred only when no request is being processed at the time of the + `gettext` call. This parameter can be explicitly set to `True` to force deferral or to `False` to never + defer translations. In the latter case, calling `gettext` before a request is processed + (e.g. during init) will raise an error. + """ + + +_NGettext: TypeAlias = Callable[[str], str] + + +def get_for(module_name: str) -> tuple[_Gettext, _NGettext]: + """Initializes i18n for the package owning the given Python module and returns the gettext-family functions. + + Args: + module_name: The Python `__package__` or `__module__` whose domain should be used. + """ # TODO: Maybe cache this? package = _get_package_owning_module(module_name) domain = domain_of(package.manifest) domain_state = _ensure_initialized(domain, package, get_qpy_environment()) - def gettext(message: str) -> str: + def gettext(message: str, *, defer: bool | None = None) -> str | UserString: + if defer is None: + defer = domain_state.request_state is None + + if defer: + domain_state.logger.debug("Deferring translation of message '%s'.", message) + return _DeferredTranslatedMessage(domain_state, message) + request_state = _require_request_state(domain, domain_state) return request_state.translations.gettext(message) def ngettext(message: str) -> str: return message - return gettext, ngettext + return cast(_Gettext, gettext), ngettext __all__ = ["DEFAULT_CATEGORY", "GettextDomain", "domain_of", "get_for", "get_translations_of_package"] diff --git a/questionpy_sdk/webserver/elements.py b/questionpy_sdk/webserver/elements.py index d0abd894..3012cd83 100644 --- a/questionpy_sdk/webserver/elements.py +++ b/questionpy_sdk/webserver/elements.py @@ -62,11 +62,11 @@ class CxdTextInputElement(TextInputElement, _CxdFormElement): value: str | None = None def contextualize(self, pattern: Pattern[str], replacement: str) -> None: - self.label = sub(pattern, replacement, self.label) + self.label = sub(pattern, replacement, str(self.label)) if self.default: - self.default = sub(pattern, replacement, self.default) + self.default = sub(pattern, replacement, str(self.default)) if self.placeholder: - self.placeholder = sub(pattern, replacement, self.placeholder) + self.placeholder = sub(pattern, replacement, str(self.placeholder)) def add_form_data_value(self, element_form_data: Any) -> None: if element_form_data: @@ -77,11 +77,11 @@ class CxdTextAreaElement(TextAreaElement, _CxdFormElement): value: str | None = None def contextualize(self, pattern: Pattern[str], replacement: str) -> None: - self.label = sub(pattern, replacement, self.label) + self.label = sub(pattern, replacement, str(self.label)) if self.default: - self.default = sub(pattern, replacement, self.default) + self.default = sub(pattern, replacement, str(self.default)) if self.placeholder: - self.placeholder = sub(pattern, replacement, self.placeholder) + self.placeholder = sub(pattern, replacement, str(self.placeholder)) def add_form_data_value(self, element_form_data: Any) -> None: if element_form_data: @@ -90,16 +90,16 @@ def add_form_data_value(self, element_form_data: Any) -> None: class CxdStaticTextElement(StaticTextElement, _CxdFormElement): def contextualize(self, pattern: Pattern[str], replacement: str) -> None: - self.label = sub(pattern, replacement, self.label) - self.text = sub(pattern, replacement, self.text) + self.label = sub(pattern, replacement, str(self.label)) + self.text = sub(pattern, replacement, str(self.text)) class CxdCheckboxElement(CheckboxElement, _CxdFormElement): def contextualize(self, pattern: Pattern[str], replacement: str) -> None: if self.left_label: - self.left_label = sub(pattern, replacement, self.left_label) + self.left_label = sub(pattern, replacement, str(self.left_label)) if self.right_label: - self.right_label = sub(pattern, replacement, self.right_label) + self.right_label = sub(pattern, replacement, str(self.right_label)) def add_form_data_value(self, element_form_data: Any) -> None: if element_form_data: @@ -135,7 +135,7 @@ def add_form_data_value(self, element_form_data: Any) -> None: class CxdOption(Option, _CxdFormElement): def contextualize(self, pattern: Pattern[str], replacement: str) -> None: - self.label = sub(pattern, replacement, self.label) + self.label = sub(pattern, replacement, str(self.label)) class CxdRadioGroupElement(RadioGroupElement, _CxdFormElement): @@ -154,7 +154,7 @@ def __init__(self, **data: Any): self.options = [] def contextualize(self, pattern: Pattern[str], replacement: str) -> None: - self.label = sub(pattern, replacement, self.label) + self.label = sub(pattern, replacement, str(self.label)) for cxd_option in self.cxd_options: cxd_option.contextualize(pattern, replacement) @@ -182,7 +182,7 @@ def __init__(self, **data: Any): self.options = [] def contextualize(self, pattern: Pattern[str], replacement: str) -> None: - self.label = sub(pattern, replacement, self.label) + self.label = sub(pattern, replacement, str(self.label)) for cxd_option in self.cxd_options: cxd_option.contextualize(pattern, replacement) diff --git a/questionpy_sdk/webserver/question_ui/__init__.py b/questionpy_sdk/webserver/question_ui/__init__.py index 5acd59a0..eea6e27f 100644 --- a/questionpy_sdk/webserver/question_ui/__init__.py +++ b/questionpy_sdk/webserver/question_ui/__init__.py @@ -5,7 +5,7 @@ import re from random import Random -from typing import Any +from typing import TYPE_CHECKING, Any import lxml.html import lxml.html.clean @@ -26,6 +26,9 @@ XMLSyntaxError, ) +if TYPE_CHECKING: + from collections import UserString + _XHTML_NAMESPACE: str = "http://www.w3.org/1999/xhtml" _QPY_NAMESPACE: str = "http://questionpy.org/ns/question" @@ -199,7 +202,7 @@ class QuestionUIRenderer: def __init__( self, xml: str, - placeholders: dict[str, str], + placeholders: dict[str, str | UserString], options: QuestionDisplayOptions, seed: int | None = None, attempt: dict | None = None, @@ -290,7 +293,7 @@ def _resolve_placeholders(self) -> None: if clean_option == "plain": # Treat the value as plain text. - _add_text_before(p_instruction, raw_value) + _add_text_before(p_instruction, str(raw_value)) else: # html.clean works on different element classes than etree, so we need to use different parse functions. # Since the HTML elements are subclasses of the etree elements though, we can reuse them without dumping @@ -522,7 +525,7 @@ class QuestionFormulationUIRenderer(QuestionUIRenderer): def __init__( self, xml: str, - placeholders: dict[str, str], + placeholders: dict[str, str | UserString], options: QuestionDisplayOptions, seed: int | None = None, attempt: dict | None = None, @@ -569,7 +572,7 @@ class _RenderErrorCollector: def __init__( self, xml: str, - placeholders: dict[str, str], + placeholders: dict[str, str | UserString], ) -> None: self.errors = RenderErrorCollection() From b4ffe064da5bc0c9a7d441138f01599fd2f3d166 Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Fri, 21 Feb 2025 19:59:26 +0100 Subject: [PATCH 11/25] feat(i18n): implement n-, p- and npgettext as well as d- variants --- questionpy/i18n.py | 196 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 167 insertions(+), 29 deletions(-) diff --git a/questionpy/i18n.py b/questionpy/i18n.py index 74e453bf..00f5cea0 100644 --- a/questionpy/i18n.py +++ b/questionpy/i18n.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from gettext import GNUTranslations, NullTranslations from importlib.resources.abc import Traversable -from typing import Literal, NewType, Protocol, TypeAlias, cast, overload +from typing import Literal, NewType, TypeAlias, overload from questionpy_common.environment import ( Environment, @@ -85,7 +85,13 @@ def _require_request_user() -> RequestUser: return env.request_user -def _require_request_state(domain: GettextDomain, domain_state: _DomainState) -> _RequestState: +def _require_request_state(domain: GettextDomain, domain_state: _DomainState | None = None) -> _RequestState: + if not domain_state: + domain_state = _i18n_state.get({}).get(domain) + if not domain_state: + msg = f"i18n domain '{domain}' was not initialized. Are you sure the corresponding package is loaded?" + raise RuntimeError(msg) + request_user = _require_request_user() if not domain_state.request_state or domain_state.request_state.user != request_user: msg = f"i18n domain '{domain}' was not initialized for the current request." @@ -102,6 +108,14 @@ def get_translations_of_package(package: Package) -> NullTranslations: return request_state.translations +def get_primary_language(package: Package) -> Bcp47LanguageTag: + domain = domain_of(package.manifest) + domain_state = _i18n_state.get({}).get(domain) + if domain_state: + return _require_request_state(domain, domain_state).primary_lang + return package.manifest.languages[0] + + def _get_package_owning_module(module_name: str) -> Package: # TODO: Dedupe when #152 is in dev. try: @@ -173,52 +187,163 @@ def initialize_for_request(request_user: RequestUser) -> None: class _DeferredTranslatedMessage(UserString): - def __init__(self, domain_state: _DomainState, msg_id: str) -> None: - super().__init__(msg_id) + def __init__( + self, domain_state: _DomainState, default_message: str, getter: Callable[[NullTranslations], str] + ) -> None: + super().__init__(default_message) + self._default_message = default_message self._domain_state = domain_state + self._getter = getter @property def data(self) -> str: if self._domain_state.request_state: - return self._domain_state.request_state.translations.gettext(self._msg_id) + return self._getter(self._domain_state.request_state.translations) self._domain_state.logger.debug( "Deferred message '%s' not translated because domain is not initialized for request.", - self._msg_id, + self._default_message, ) - return self._msg_id + return self._default_message @data.setter - def data(self, value: str) -> None: - self._msg_id = value + def data(self, _: str) -> None: + # This is just here because MyPy expects data to be a writable property. + pass + +class _Gettext: + """Translate the given message.""" + + def __init__(self, package: Package, domain: GettextDomain, domain_state: _DomainState) -> None: + self._package = package + self._domain = domain + self._domain_state = domain_state -class _Gettext(Protocol): @overload - def __call__(self, message: str, *, defer: None = None) -> str | UserString: ... + def __call__(self, message: str, /, *, defer: None = None) -> str | UserString: ... @overload - def __call__(self, message: str, *, defer: Literal[True]) -> UserString: ... + def __call__(self, message: str, /, *, defer: Literal[True]) -> UserString: ... @overload - def __call__(self, message: str, *, defer: Literal[False]) -> str: ... + def __call__(self, message: str, /, *, defer: Literal[False]) -> str: ... - def __call__(self, message: str, *, defer: bool | None = None) -> str | UserString: + def __call__(self, message: str, /, *, defer: bool | None = None) -> str | UserString: """Translate the given message. Args: - message: Gettext `msgid`. + message: Gettext `msgid`. This should also be the message in its primary language, usually english. + defer: By default, translation is deferred only when no request is being processed at the time of the + `gettext` call. This parameter can be explicitly set to `True` to force deferral or to `False` to never + defer translations. In the latter case, calling `gettext` before a request is processed + (e.g. during init) will raise an error. + """ + return self._maybe_defer(message, lambda trans: trans.gettext(message), defer=defer) + + @overload + def ngettext(self, singular: str, plural: str, n: int, /, *, defer: None = None) -> str | UserString: ... + + @overload + def ngettext(self, singular: str, plural: str, n: int, /, *, defer: Literal[True]) -> UserString: ... + + @overload + def ngettext(self, singular: str, plural: str, n: int, /, *, defer: Literal[False]) -> str: ... + + def ngettext(self, singular: str, plural: str, n: int, /, *, defer: bool | None = None) -> str | UserString: + """Translate the given message, accounting for plural forms. + + Args: + singular: Message id in its (english) singular form, used if no plural form exists and `n == 1`. + plural: Message id in its (english) plural form, used if no plural form exists and `n >= 2`. + n: This number is passed through the plural formula of the active catalog to determine which form to use. + defer: By default, translation is deferred only when no request is being processed at the time of the + `gettext` call. This parameter can be explicitly set to `True` to force deferral or to `False` to never + defer translations. In the latter case, calling `gettext` before a request is processed + (e.g. during init) will raise an error. + """ + default_message = singular if n == 1 else plural + return self._maybe_defer(default_message, lambda trans: trans.ngettext(singular, plural, n), defer=defer) + + @overload + def pgettext(self, context: str, message: str, /, *, defer: None = None) -> str | UserString: ... + + @overload + def pgettext(self, context: str, message: str, /, *, defer: Literal[True]) -> UserString: ... + + @overload + def pgettext(self, context: str, message: str, /, *, defer: Literal[False]) -> str: ... + + def pgettext(self, context: str, message: str, /, *, defer: bool | None = None) -> str | UserString: + """Translate the given message in the given context. + + The context allows solving ambiguities where the same message may require different translations depending on + the place it's used. See [the GNU gettext documentation](https://www.gnu.org/software/gettext/manual/html_node/Contexts.html). + + Args: + context: The context within which the message should be scoped. This is also extracted as the `msgctxt` + value. + message: Gettext `msgid`. This should also be the message in its primary language, usually english. defer: By default, translation is deferred only when no request is being processed at the time of the `gettext` call. This parameter can be explicitly set to `True` to force deferral or to `False` to never defer translations. In the latter case, calling `gettext` before a request is processed (e.g. during init) will raise an error. """ + return self._maybe_defer(message, lambda trans: trans.pgettext(context, message), defer=defer) + + @overload + def npgettext( + self, context: str, singular: str, plural: str, n: int, /, *, defer: None = None + ) -> str | UserString: ... + @overload + def npgettext(self, context: str, singular: str, plural: str, n: int, /, *, defer: Literal[True]) -> UserString: ... + + @overload + def npgettext(self, context: str, singular: str, plural: str, n: int, /, *, defer: Literal[False]) -> str: ... -_NGettext: TypeAlias = Callable[[str], str] + def npgettext( + self, context: str, singular: str, plural: str, n: int, /, *, defer: bool | None = None + ) -> str | UserString: + """Translate the given message in the given context. + The context allows solving ambiguities where the same message may require different translations depending on + the place it's used. See [the GNU gettext documentation](https://www.gnu.org/software/gettext/manual/html_node/Contexts.html). -def get_for(module_name: str) -> tuple[_Gettext, _NGettext]: + Args: + context: The context within which the message should be scoped. This is also extracted as the `msgctxt` + value. + singular: Message id in its (english) singular form, used if no plural form exists and `n == 1`. + plural: Message id in its (english) plural form, used if no plural form exists and `n >= 2`. + n: This number is passed through the plural formula of the active catalog to determine which form to use. + defer: By default, translation is deferred only when no request is being processed at the time of the + `gettext` call. This parameter can be explicitly set to `True` to force deferral or to `False` to never + defer translations. In the latter case, calling `gettext` before a request is processed + (e.g. during init) will raise an error. + """ + default_message = singular if n == 1 else plural + return self._maybe_defer( + default_message, lambda trans: trans.npgettext(context, singular, plural, n), defer=defer + ) + + def _maybe_defer( + self, default_message: str, getter: Callable[[NullTranslations], str], *, defer: bool | None + ) -> str | UserString: + if defer is None: + defer = self._domain_state.request_state is None + + if defer: + self._domain_state.logger.debug("Deferring translation of message '%s'.", default_message) + return _DeferredTranslatedMessage(self._domain_state, default_message, getter) + + request_state = _require_request_state(self._domain, self._domain_state) + return getter(request_state.translations) + + +_Noop: TypeAlias = Callable[[str], str] + + +def get_for(module_name: str) -> tuple[_Gettext, _Noop]: """Initializes i18n for the package owning the given Python module and returns the gettext-family functions. Args: @@ -229,21 +354,34 @@ def get_for(module_name: str) -> tuple[_Gettext, _NGettext]: domain = domain_of(package.manifest) domain_state = _ensure_initialized(domain, package, get_qpy_environment()) - def gettext(message: str, *, defer: bool | None = None) -> str | UserString: - if defer is None: - defer = domain_state.request_state is None + return _Gettext(package, domain, domain_state), lambda message: message + + +def dgettext(domain: str, message: str, /) -> str: + domain = GettextDomain(domain) + request_state = _require_request_state(domain) + return request_state.translations.gettext(message) - if defer: - domain_state.logger.debug("Deferring translation of message '%s'.", message) - return _DeferredTranslatedMessage(domain_state, message) - request_state = _require_request_state(domain, domain_state) - return request_state.translations.gettext(message) +def dpgettext(domain: str, context: str, message: str, /) -> str: + domain = GettextDomain(domain) + request_state = _require_request_state(domain) + return request_state.translations.pgettext(context, message) - def ngettext(message: str) -> str: - return message - return cast(_Gettext, gettext), ngettext +def dnpgettext(domain: str, context: str, singular: str, plural: str, n: int, /) -> str: + domain = GettextDomain(domain) + request_state = _require_request_state(domain) + return request_state.translations.npgettext(context, singular, plural, n) -__all__ = ["DEFAULT_CATEGORY", "GettextDomain", "domain_of", "get_for", "get_translations_of_package"] +__all__ = [ + "DEFAULT_CATEGORY", + "GettextDomain", + "dgettext", + "dnpgettext", + "domain_of", + "dpgettext", + "get_for", + "get_translations_of_package", +] From 6c15bc10cd1bb272d423bd644ee6c774c3cf4314 Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Mon, 24 Feb 2025 14:56:40 +0100 Subject: [PATCH 12/25] feat(i18n): support TranslatableString in form DSL and attempt ui --- questionpy/_attempt.py | 25 ++-- questionpy/_wrappers/_question.py | 21 +--- questionpy/form/_dsl.py | 184 ++++++++++++++-------------- questionpy/form/_model.py | 8 +- questionpy/i18n.py | 1 + questionpy_sdk/webserver/attempt.py | 8 +- 6 files changed, 121 insertions(+), 126 deletions(-) diff --git a/questionpy/_attempt.py b/questionpy/_attempt.py index 7d96bed6..5de60bb4 100644 --- a/questionpy/_attempt.py +++ b/questionpy/_attempt.py @@ -1,5 +1,6 @@ import json from abc import ABC, abstractmethod +from collections import UserString from collections.abc import Mapping, Sequence from functools import cached_property from typing import TYPE_CHECKING, ClassVar, Protocol @@ -34,8 +35,8 @@ class BaseScoringState(BaseModel): class AttemptUiPart(BaseModel): - content: str - placeholders: dict[str, str] = {} + content: str | UserString + placeholders: dict[str, str | UserString] = {} """Names and values of the `` AttemptUi: - all_placeholders: dict[str, str] = {} + all_placeholders: dict[str, str | UserString] = {} all_css_files: list[str] = [] all_files: dict[str, AttemptFile] = {} for partial_ui in (formulation, general_feedback, specific_feedback, right_answer): @@ -78,7 +79,7 @@ def cache_control(self) -> CacheControl: pass @property - def placeholders(self) -> dict[str, str]: + def placeholders(self) -> dict[str, str | UserString]: pass @property @@ -98,19 +99,19 @@ def variant(self) -> int: pass @property - def formulation(self) -> str: + def formulation(self) -> str | UserString: pass @property - def general_feedback(self) -> str | None: + def general_feedback(self) -> str | UserString | None: pass @property - def specific_feedback(self) -> str | None: + def specific_feedback(self) -> str | UserString | None: pass @property - def right_answer_description(self) -> str | None: + def right_answer_description(self) -> str | UserString | None: pass @@ -168,7 +169,7 @@ def __init__( self.scoring_state = scoring_state self.cache_control = CacheControl.PRIVATE_CACHE - self.placeholders: dict[str, str] = {} + self.placeholders: dict[str, str | UserString] = {} self.css_files: list[str] = [] self._javascript_calls: list[JsModuleCall] = [] """LMS has to call these JS modules/functions.""" @@ -215,15 +216,15 @@ def formulation(self) -> str: pass @property - def general_feedback(self) -> str | None: + def general_feedback(self) -> str | UserString | None: return None @property - def specific_feedback(self) -> str | None: + def specific_feedback(self) -> str | UserString | None: return None @property - def right_answer_description(self) -> str | None: + def right_answer_description(self) -> str | UserString | None: return None def score_response(self, *, try_scoring_with_countback: bool = False, try_giving_hint: bool = False) -> None: diff --git a/questionpy/_wrappers/_question.py b/questionpy/_wrappers/_question.py index 56ee64b8..c2900b41 100644 --- a/questionpy/_wrappers/_question.py +++ b/questionpy/_wrappers/_question.py @@ -5,29 +5,16 @@ from pydantic import JsonValue -from questionpy import Question +from questionpy import Question, i18n from questionpy._attempt import AttemptProtocol, AttemptScoredProtocol from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel, AttemptUi from questionpy_common.api.question import QuestionInterface, QuestionModel from questionpy_common.environment import get_qpy_environment +from questionpy_common.manifest import Bcp47LanguageTag -def _get_output_lang() -> str: - # TODO: Do something more meaningful per default and allow the package to override. - env = get_qpy_environment() - supported_langs = env.main_package.manifest.languages - preferred_langs = env.request_user.preferred_languages if env.request_user else () - supported_and_preferred_langs = [lang for lang in preferred_langs if lang in supported_langs] - if supported_and_preferred_langs: - # Use the most preferred supported language if any. - return supported_and_preferred_langs[0] - - if supported_langs: - # If no preferred language is supported, use any supported one. - return next(iter(supported_langs)) - - # If the package lists no supported languages, fall back to english. - return "en" +def _get_output_lang() -> Bcp47LanguageTag: + return i18n.get_primary_language(get_qpy_environment().main_package) def _export_question(question: Question) -> QuestionModel: diff --git a/questionpy/form/_dsl.py b/questionpy/form/_dsl.py index 2a8d349d..504184cd 100644 --- a/questionpy/form/_dsl.py +++ b/questionpy/form/_dsl.py @@ -1,6 +1,7 @@ # This file is part of the QuestionPy SDK. (https://questionpy.org) # The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. # (c) Technische Universität Berlin, innoCampus +from collections import UserString from typing import Any, Literal, Optional, TypeAlias, TypeVar, cast, overload from pydantic.fields import FieldInfo @@ -44,12 +45,12 @@ def _listify(value: _ZeroOrMoreConditions) -> list[Condition]: @overload def text_input( - label: str, + label: str | UserString, *, required: Literal[False] = False, - default: str | None = None, - placeholder: str | None = None, - help: str | None = None, + default: str | UserString | None = None, + placeholder: str | UserString | None = None, + help: str | UserString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> str | None: @@ -58,12 +59,12 @@ def text_input( @overload def text_input( - label: str, + label: str | UserString, *, required: Literal[True], - default: str | None = None, - placeholder: str | None = None, - help: str | None = None, + default: str | UserString | None = None, + placeholder: str | UserString | None = None, + help: str | UserString | None = None, disable_if: None = None, hide_if: None = None, ) -> str: @@ -72,12 +73,12 @@ def text_input( @overload def text_input( - label: str, + label: str | UserString, *, required: bool = False, - default: str | None = None, - placeholder: str | None = None, - help: str | None = None, + default: str | UserString | None = None, + placeholder: str | UserString | None = None, + help: str | UserString | None = None, disable_if: _OneOrMoreConditions, hide_if: _ZeroOrMoreConditions = None, ) -> str | None: @@ -86,12 +87,12 @@ def text_input( @overload def text_input( - label: str, + label: str | UserString, *, required: bool = False, - default: str | None = None, - placeholder: str | None = None, - help: str | None = None, + default: str | UserString | None = None, + placeholder: str | UserString | None = None, + help: str | UserString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _OneOrMoreConditions, ) -> str | None: @@ -99,12 +100,12 @@ def text_input( def text_input( - label: str, + label: str | UserString, *, required: bool = False, - default: str | None = None, - placeholder: str | None = None, - help: str | None = None, + default: str | UserString | None = None, + placeholder: str | UserString | None = None, + help: str | UserString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> Any: @@ -143,12 +144,12 @@ def text_input( @overload def text_area( - label: str, + label: str | UserString, *, required: Literal[False] = False, - default: str | None = None, - placeholder: str | None = None, - help: str | None = None, + default: str | UserString | None = None, + placeholder: str | UserString | None = None, + help: str | UserString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> str | None: @@ -157,12 +158,12 @@ def text_area( @overload def text_area( - label: str, + label: str | UserString, *, required: Literal[True], - default: str | None = None, - placeholder: str | None = None, - help: str | None = None, + default: str | UserString | None = None, + placeholder: str | UserString | None = None, + help: str | UserString | None = None, disable_if: None = None, hide_if: None = None, ) -> str: @@ -171,12 +172,12 @@ def text_area( @overload def text_area( - label: str, + label: str | UserString, *, required: bool = False, - default: str | None = None, - placeholder: str | None = None, - help: str | None = None, + default: str | UserString | None = None, + placeholder: str | UserString | None = None, + help: str | UserString | None = None, disable_if: _OneOrMoreConditions, hide_if: _ZeroOrMoreConditions = None, ) -> str | None: @@ -185,12 +186,12 @@ def text_area( @overload def text_area( - label: str, + label: str | UserString, *, required: bool = False, - default: str | None = None, - placeholder: str | None = None, - help: str | None = None, + default: str | UserString | None = None, + placeholder: str | UserString | None = None, + help: str | UserString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _OneOrMoreConditions, ) -> str | None: @@ -198,12 +199,12 @@ def text_area( def text_area( - label: str, + label: str | UserString, *, required: bool = False, - default: str | None = None, - placeholder: str | None = None, - help: str | None = None, + default: str | UserString | None = None, + placeholder: str | UserString | None = None, + help: str | UserString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> Any: @@ -241,10 +242,10 @@ def text_area( def static_text( - label: str, - text: str, + label: str | UserString, + text: str | UserString, *, - help: str | None = None, + help: str | UserString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> StaticTextElement: @@ -272,12 +273,12 @@ def static_text( @overload def checkbox( - left_label: str | None = None, - right_label: str | None = None, + left_label: str | UserString | None = None, + right_label: str | UserString | None = None, *, required: Literal[True], selected: bool = False, - help: str | None = None, + help: str | UserString | None = None, disable_if: None = None, hide_if: None = None, ) -> Literal[True]: @@ -286,12 +287,12 @@ def checkbox( @overload def checkbox( - left_label: str | None = None, - right_label: str | None = None, + left_label: str | UserString | None = None, + right_label: str | UserString | None = None, *, required: Literal[False] = False, selected: bool = False, - help: str | None = None, + help: str | UserString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> bool: @@ -300,12 +301,12 @@ def checkbox( @overload def checkbox( - left_label: str | None = None, - right_label: str | None = None, + left_label: str | UserString | None = None, + right_label: str | UserString | None = None, *, required: bool = False, selected: bool = False, - help: str | None = None, + help: str | UserString | None = None, disable_if: _OneOrMoreConditions, hide_if: _ZeroOrMoreConditions = None, ) -> bool: @@ -314,12 +315,12 @@ def checkbox( @overload def checkbox( - left_label: str | None = None, - right_label: str | None = None, + left_label: str | UserString | None = None, + right_label: str | UserString | None = None, *, required: bool = False, selected: bool = False, - help: str | None = None, + help: str | UserString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _OneOrMoreConditions, ) -> bool: @@ -327,12 +328,12 @@ def checkbox( def checkbox( - left_label: str | None = None, - right_label: str | None = None, + left_label: str | UserString | None = None, + right_label: str | UserString | None = None, *, required: bool = False, selected: bool = False, - help: str | None = None, + help: str | UserString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> Any: @@ -368,11 +369,11 @@ def checkbox( @overload def radio_group( - label: str, + label: str | UserString, enum: type[_E], *, required: Literal[False] = False, - help: str | None = None, + help: str | UserString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> _E | None: @@ -381,11 +382,11 @@ def radio_group( @overload def radio_group( - label: str, + label: str | UserString, enum: type[_E], *, required: Literal[True], - help: str | None = None, + help: str | UserString | None = None, disable_if: _OneOrMoreConditions, hide_if: _ZeroOrMoreConditions = None, ) -> _E | None: @@ -394,11 +395,11 @@ def radio_group( @overload def radio_group( - label: str, + label: str | UserString, enum: type[_E], *, required: Literal[True], - help: str | None = None, + help: str | UserString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _OneOrMoreConditions, ) -> _E | None: @@ -407,11 +408,11 @@ def radio_group( @overload def radio_group( - label: str, + label: str | UserString, enum: type[_E], *, required: Literal[True], - help: str | None = None, + help: str | UserString | None = None, disable_if: None = None, hide_if: None = None, ) -> _E: @@ -419,11 +420,11 @@ def radio_group( def radio_group( - label: str, + label: str | UserString, enum: type[_E], *, required: bool = False, - help: str | None = None, + help: str | UserString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> Any: @@ -459,12 +460,12 @@ def radio_group( @overload def select( - label: str, + label: str | UserString, enum: type[_E], *, required: Literal[False] = False, multiple: Literal[False] = False, - help: str | None = None, + help: str | UserString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> _E | None: @@ -473,12 +474,12 @@ def select( @overload def select( - label: str, + label: str | UserString, enum: type[_E], *, required: Literal[True], multiple: Literal[False] = False, - help: str | None = None, + help: str | UserString | None = None, disable_if: _OneOrMoreConditions, hide_if: _ZeroOrMoreConditions = None, ) -> _E | None: @@ -487,12 +488,12 @@ def select( @overload def select( - label: str, + label: str | UserString, enum: type[_E], *, required: Literal[True], multiple: Literal[False] = False, - help: str | None = None, + help: str | UserString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _OneOrMoreConditions, ) -> _E | None: @@ -501,12 +502,12 @@ def select( @overload def select( - label: str, + label: str | UserString, enum: type[_E], *, required: Literal[True], multiple: Literal[False] = False, - help: str | None = None, + help: str | UserString | None = None, disable_if: None = None, hide_if: None = None, ) -> _E: @@ -515,12 +516,12 @@ def select( @overload def select( - label: str, + label: str | UserString, enum: type[_E], *, required: bool = False, multiple: Literal[True], - help: str | None = None, + help: str | UserString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> set[_E]: @@ -528,12 +529,12 @@ def select( def select( - label: str, + label: str | UserString, enum: type[_E], *, required: bool = False, multiple: bool = False, - help: str | None = None, + help: str | UserString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> Any: @@ -581,7 +582,7 @@ def select( ) -def option(label: str, *, selected: bool = False, value: str | None = None) -> _OptionInfo: +def option(label: str | UserString, *, selected: bool = False, value: str | UserString | None = None) -> _OptionInfo: """Adds an option to an [`OptionEnum`][questionpy.form.OptionEnum]. Args: @@ -622,7 +623,7 @@ def hidden(value: _S, *, disable_if: _ZeroOrMoreConditions = None, hide_if: _Zer """Adds a hidden element with a fixed value. Args: - value (str): Fixed value. + value (str | UserString): Fixed value. disable_if (Condition | list[Condition] | None): Disable this element if some condition(s) match. hide_if (Condition | list[Condition] | None): Hide this element if some condition(s) match. @@ -641,7 +642,7 @@ def hidden(value: _S, *, disable_if: _ZeroOrMoreConditions = None, hide_if: _Zer ) -def section(header: str, model: type[_F]) -> _F: +def section(header: str | UserString, model: type[_F]) -> _F: """Adds a form section that can be expanded and collapsed. Args: @@ -671,10 +672,10 @@ def section(header: str, model: type[_F]) -> _F: def group( - label: str, + label: str | UserString, model: type[_F], *, - help: str | None = None, + help: str | UserString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> _F: @@ -729,7 +730,12 @@ def group( def repeat( - model: type[_F], *, initial: int = 1, minimum: int = 1, increment: int = 1, button_label: str | None = None + model: type[_F], + *, + initial: int = 1, + minimum: int = 1, + increment: int = 1, + button_label: str | UserString | None = None, ) -> list[_F]: """Repeats a sub-model, allowing the user to add new repetitions with the click of a button. @@ -830,7 +836,7 @@ def is_not_checked(name: str) -> IsNotChecked: return IsNotChecked(name=name) -def equals(name: str, *, value: str | int | bool) -> Equals: +def equals(name: str, *, value: str | UserString | int | bool) -> Equals: """Condition on the value of another field being equal to some static value. Many elements can be hidden or disabled client-side by passing conditions to `hide_if` or `disable_if`. See the @@ -854,7 +860,7 @@ def equals(name: str, *, value: str | int | bool) -> Equals: return Equals(name=name, value=value) -def does_not_equal(name: str, *, value: str | int | bool) -> DoesNotEqual: +def does_not_equal(name: str, *, value: str | UserString | int | bool) -> DoesNotEqual: """Condition on the value of another field *not* being equal to some static value. Many elements can be hidden or disabled client-side by passing conditions to `hide_if` or `disable_if`. See the @@ -882,7 +888,7 @@ def does_not_equal(name: str, *, value: str | int | bool) -> DoesNotEqual: return DoesNotEqual(name=name, value=value) -def is_in(name: str, values: list[str | int | bool]) -> In: +def is_in(name: str, values: list[str | UserString | int | bool]) -> In: """Condition on the value of another field being one of a number of static values. Many elements can be hidden or disabled client-side by passing conditions to `hide_if` or `disable_if`. See the diff --git a/questionpy/form/_model.py b/questionpy/form/_model.py index d9641b9e..c7f7d558 100644 --- a/questionpy/form/_model.py +++ b/questionpy/form/_model.py @@ -1,7 +1,7 @@ # This file is part of the QuestionPy SDK. (https://questionpy.org) # The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. # (c) Technische Universität Berlin, innoCampus - +from collections import UserString from collections.abc import Callable from dataclasses import dataclass from enum import Enum @@ -18,9 +18,9 @@ @dataclass class _OptionInfo: - label: str + label: str | UserString selected: bool - value: str | None = None + value: str | UserString | None = None """If None, set to the name by `OptionEnum.__init__` because __set_name__ doesn't get called for enum members.""" @@ -84,7 +84,7 @@ class _StaticElementInfo: @dataclass class _SectionInfo: - header: str + header: str | UserString model: type["FormModel"] diff --git a/questionpy/i18n.py b/questionpy/i18n.py index 00f5cea0..289fb7dd 100644 --- a/questionpy/i18n.py +++ b/questionpy/i18n.py @@ -383,5 +383,6 @@ def dnpgettext(domain: str, context: str, singular: str, plural: str, n: int, /) "domain_of", "dpgettext", "get_for", + "get_primary_language", "get_translations_of_package", ] diff --git a/questionpy_sdk/webserver/attempt.py b/questionpy_sdk/webserver/attempt.py index e550de86..8584800c 100644 --- a/questionpy_sdk/webserver/attempt.py +++ b/questionpy_sdk/webserver/attempt.py @@ -40,7 +40,7 @@ def get_attempt_render_context( ) -> _AttemptRenderContext: renderer_args = (attempt.ui.placeholders, display_options, seed, last_attempt_data) - html, errors = QuestionFormulationUIRenderer(attempt.ui.formulation, *renderer_args).render() + html, errors = QuestionFormulationUIRenderer(str(attempt.ui.formulation), *renderer_args).render() context: _AttemptRenderContext = { "attempt_status": ( @@ -64,17 +64,17 @@ def get_attempt_render_context( if errors: context["render_errors"]["Formulation"] = errors if display_options.general_feedback and attempt.ui.general_feedback: - html, errors = QuestionUIRenderer(attempt.ui.general_feedback, *renderer_args).render() + html, errors = QuestionUIRenderer(str(attempt.ui.general_feedback), *renderer_args).render() context["general_feedback"] = html if errors: context["render_errors"]["General Feedback"] = errors if display_options.specific_feedback and attempt.ui.specific_feedback: - html, errors = QuestionUIRenderer(attempt.ui.specific_feedback, *renderer_args).render() + html, errors = QuestionUIRenderer(str(attempt.ui.specific_feedback), *renderer_args).render() context["specific_feedback"] = html if errors: context["render_errors"]["Specific Feedback"] = errors if display_options.right_answer and attempt.ui.right_answer: - html, errors = QuestionUIRenderer(attempt.ui.right_answer, *renderer_args).render() + html, errors = QuestionUIRenderer(str(attempt.ui.right_answer), *renderer_args).render() context["right_answer"] = html if errors: context["render_errors"]["Right Answer"] = errors From bfd5370dc4daef217975c764becb181cdaf18661 Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Mon, 24 Feb 2025 15:33:23 +0100 Subject: [PATCH 13/25] chore: remove unused AttemptUiPart and _merge_uis --- questionpy/__init__.py | 2 -- questionpy/_attempt.py | 40 +--------------------------------------- 2 files changed, 1 insertion(+), 41 deletions(-) diff --git a/questionpy/__init__.py b/questionpy/__init__.py index 44126a31..d847bac2 100644 --- a/questionpy/__init__.py +++ b/questionpy/__init__.py @@ -40,7 +40,6 @@ from . import i18n from ._attempt import ( Attempt, - AttemptUiPart, BaseAttemptState, BaseScoringState, InvalidResponseError, @@ -58,7 +57,6 @@ "AttemptScoredModel", "AttemptStartedModel", "AttemptUi", - "AttemptUiPart", "BaseAttemptState", "BaseQuestionState", "BaseScoringState", diff --git a/questionpy/_attempt.py b/questionpy/_attempt.py index 5de60bb4..534ede92 100644 --- a/questionpy/_attempt.py +++ b/questionpy/_attempt.py @@ -1,7 +1,7 @@ import json from abc import ABC, abstractmethod from collections import UserString -from collections.abc import Mapping, Sequence +from collections.abc import Mapping from functools import cached_property from typing import TYPE_CHECKING, ClassVar, Protocol @@ -10,7 +10,6 @@ from questionpy_common.api.attempt import ( AttemptFile, - AttemptUi, CacheControl, DisplayRole, FeedbackType, @@ -34,43 +33,6 @@ class BaseScoringState(BaseModel): pass -class AttemptUiPart(BaseModel): - content: str | UserString - placeholders: dict[str, str | UserString] = {} - """Names and values of the `` AttemptUi: - all_placeholders: dict[str, str | UserString] = {} - all_css_files: list[str] = [] - all_files: dict[str, AttemptFile] = {} - for partial_ui in (formulation, general_feedback, specific_feedback, right_answer): - if not partial_ui: - continue - all_placeholders.update(partial_ui.placeholders) - all_css_files.extend(partial_ui.css_files) - all_files.update(partial_ui.files) - - return AttemptUi( - formulation=formulation and formulation.content, - general_feedback=general_feedback and general_feedback.content, - specific_feedback=specific_feedback and specific_feedback.content, - right_answer=right_answer and right_answer.content, - placeholders=all_placeholders, - css_files=all_css_files, - files=all_files, - cache_control=cache_control, - ) - - class AttemptProtocol(Protocol): """Defines the properties and methods an attempt must always contain.""" From 9fae66fcedd53722f6375dba4ef71504a28a8fa1 Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Mon, 24 Feb 2025 17:24:14 +0100 Subject: [PATCH 14/25] feat: support calling .format() on deferred strings --- questionpy/_attempt.py | 20 +- questionpy/form/_dsl.py | 182 +++++++++--------- questionpy/form/_model.py | 8 +- questionpy/i18n.py | 80 +++++--- .../webserver/question_ui/__init__.py | 8 +- 5 files changed, 161 insertions(+), 137 deletions(-) diff --git a/questionpy/_attempt.py b/questionpy/_attempt.py index 534ede92..b768d044 100644 --- a/questionpy/_attempt.py +++ b/questionpy/_attempt.py @@ -1,6 +1,5 @@ import json from abc import ABC, abstractmethod -from collections import UserString from collections.abc import Mapping from functools import cached_property from typing import TYPE_CHECKING, ClassVar, Protocol @@ -8,6 +7,7 @@ import jinja2 from pydantic import BaseModel, JsonValue +from questionpy_common import TranslatableString from questionpy_common.api.attempt import ( AttemptFile, CacheControl, @@ -41,7 +41,7 @@ def cache_control(self) -> CacheControl: pass @property - def placeholders(self) -> dict[str, str | UserString]: + def placeholders(self) -> dict[str, str | TranslatableString]: pass @property @@ -61,19 +61,19 @@ def variant(self) -> int: pass @property - def formulation(self) -> str | UserString: + def formulation(self) -> str | TranslatableString: pass @property - def general_feedback(self) -> str | UserString | None: + def general_feedback(self) -> str | TranslatableString | None: pass @property - def specific_feedback(self) -> str | UserString | None: + def specific_feedback(self) -> str | TranslatableString | None: pass @property - def right_answer_description(self) -> str | UserString | None: + def right_answer_description(self) -> str | TranslatableString | None: pass @@ -131,7 +131,7 @@ def __init__( self.scoring_state = scoring_state self.cache_control = CacheControl.PRIVATE_CACHE - self.placeholders: dict[str, str | UserString] = {} + self.placeholders: dict[str, str | TranslatableString] = {} self.css_files: list[str] = [] self._javascript_calls: list[JsModuleCall] = [] """LMS has to call these JS modules/functions.""" @@ -178,15 +178,15 @@ def formulation(self) -> str: pass @property - def general_feedback(self) -> str | UserString | None: + def general_feedback(self) -> str | TranslatableString | None: return None @property - def specific_feedback(self) -> str | UserString | None: + def specific_feedback(self) -> str | TranslatableString | None: return None @property - def right_answer_description(self) -> str | UserString | None: + def right_answer_description(self) -> str | TranslatableString | None: return None def score_response(self, *, try_scoring_with_countback: bool = False, try_giving_hint: bool = False) -> None: diff --git a/questionpy/form/_dsl.py b/questionpy/form/_dsl.py index 504184cd..23aacb92 100644 --- a/questionpy/form/_dsl.py +++ b/questionpy/form/_dsl.py @@ -1,12 +1,12 @@ # This file is part of the QuestionPy SDK. (https://questionpy.org) # The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. # (c) Technische Universität Berlin, innoCampus -from collections import UserString from typing import Any, Literal, Optional, TypeAlias, TypeVar, cast, overload from pydantic.fields import FieldInfo from pydantic_core import PydanticUndefined +from questionpy_common import TranslatableString from questionpy_common.conditions import Condition, DoesNotEqual, Equals, In, IsChecked, IsNotChecked from questionpy_common.elements import ( CheckboxElement, @@ -45,12 +45,12 @@ def _listify(value: _ZeroOrMoreConditions) -> list[Condition]: @overload def text_input( - label: str | UserString, + label: str | TranslatableString, *, required: Literal[False] = False, - default: str | UserString | None = None, - placeholder: str | UserString | None = None, - help: str | UserString | None = None, + default: str | TranslatableString | None = None, + placeholder: str | TranslatableString | None = None, + help: str | TranslatableString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> str | None: @@ -59,12 +59,12 @@ def text_input( @overload def text_input( - label: str | UserString, + label: str | TranslatableString, *, required: Literal[True], - default: str | UserString | None = None, - placeholder: str | UserString | None = None, - help: str | UserString | None = None, + default: str | TranslatableString | None = None, + placeholder: str | TranslatableString | None = None, + help: str | TranslatableString | None = None, disable_if: None = None, hide_if: None = None, ) -> str: @@ -73,12 +73,12 @@ def text_input( @overload def text_input( - label: str | UserString, + label: str | TranslatableString, *, required: bool = False, - default: str | UserString | None = None, - placeholder: str | UserString | None = None, - help: str | UserString | None = None, + default: str | TranslatableString | None = None, + placeholder: str | TranslatableString | None = None, + help: str | TranslatableString | None = None, disable_if: _OneOrMoreConditions, hide_if: _ZeroOrMoreConditions = None, ) -> str | None: @@ -87,12 +87,12 @@ def text_input( @overload def text_input( - label: str | UserString, + label: str | TranslatableString, *, required: bool = False, - default: str | UserString | None = None, - placeholder: str | UserString | None = None, - help: str | UserString | None = None, + default: str | TranslatableString | None = None, + placeholder: str | TranslatableString | None = None, + help: str | TranslatableString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _OneOrMoreConditions, ) -> str | None: @@ -100,12 +100,12 @@ def text_input( def text_input( - label: str | UserString, + label: str | TranslatableString, *, required: bool = False, - default: str | UserString | None = None, - placeholder: str | UserString | None = None, - help: str | UserString | None = None, + default: str | TranslatableString | None = None, + placeholder: str | TranslatableString | None = None, + help: str | TranslatableString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> Any: @@ -144,12 +144,12 @@ def text_input( @overload def text_area( - label: str | UserString, + label: str | TranslatableString, *, required: Literal[False] = False, - default: str | UserString | None = None, - placeholder: str | UserString | None = None, - help: str | UserString | None = None, + default: str | TranslatableString | None = None, + placeholder: str | TranslatableString | None = None, + help: str | TranslatableString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> str | None: @@ -158,12 +158,12 @@ def text_area( @overload def text_area( - label: str | UserString, + label: str | TranslatableString, *, required: Literal[True], - default: str | UserString | None = None, - placeholder: str | UserString | None = None, - help: str | UserString | None = None, + default: str | TranslatableString | None = None, + placeholder: str | TranslatableString | None = None, + help: str | TranslatableString | None = None, disable_if: None = None, hide_if: None = None, ) -> str: @@ -172,12 +172,12 @@ def text_area( @overload def text_area( - label: str | UserString, + label: str | TranslatableString, *, required: bool = False, - default: str | UserString | None = None, - placeholder: str | UserString | None = None, - help: str | UserString | None = None, + default: str | TranslatableString | None = None, + placeholder: str | TranslatableString | None = None, + help: str | TranslatableString | None = None, disable_if: _OneOrMoreConditions, hide_if: _ZeroOrMoreConditions = None, ) -> str | None: @@ -186,12 +186,12 @@ def text_area( @overload def text_area( - label: str | UserString, + label: str | TranslatableString, *, required: bool = False, - default: str | UserString | None = None, - placeholder: str | UserString | None = None, - help: str | UserString | None = None, + default: str | TranslatableString | None = None, + placeholder: str | TranslatableString | None = None, + help: str | TranslatableString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _OneOrMoreConditions, ) -> str | None: @@ -199,12 +199,12 @@ def text_area( def text_area( - label: str | UserString, + label: str | TranslatableString, *, required: bool = False, - default: str | UserString | None = None, - placeholder: str | UserString | None = None, - help: str | UserString | None = None, + default: str | TranslatableString | None = None, + placeholder: str | TranslatableString | None = None, + help: str | TranslatableString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> Any: @@ -242,10 +242,10 @@ def text_area( def static_text( - label: str | UserString, - text: str | UserString, + label: str | TranslatableString, + text: str | TranslatableString, *, - help: str | UserString | None = None, + help: str | TranslatableString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> StaticTextElement: @@ -273,12 +273,12 @@ def static_text( @overload def checkbox( - left_label: str | UserString | None = None, - right_label: str | UserString | None = None, + left_label: str | TranslatableString | None = None, + right_label: str | TranslatableString | None = None, *, required: Literal[True], selected: bool = False, - help: str | UserString | None = None, + help: str | TranslatableString | None = None, disable_if: None = None, hide_if: None = None, ) -> Literal[True]: @@ -287,12 +287,12 @@ def checkbox( @overload def checkbox( - left_label: str | UserString | None = None, - right_label: str | UserString | None = None, + left_label: str | TranslatableString | None = None, + right_label: str | TranslatableString | None = None, *, required: Literal[False] = False, selected: bool = False, - help: str | UserString | None = None, + help: str | TranslatableString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> bool: @@ -301,12 +301,12 @@ def checkbox( @overload def checkbox( - left_label: str | UserString | None = None, - right_label: str | UserString | None = None, + left_label: str | TranslatableString | None = None, + right_label: str | TranslatableString | None = None, *, required: bool = False, selected: bool = False, - help: str | UserString | None = None, + help: str | TranslatableString | None = None, disable_if: _OneOrMoreConditions, hide_if: _ZeroOrMoreConditions = None, ) -> bool: @@ -315,12 +315,12 @@ def checkbox( @overload def checkbox( - left_label: str | UserString | None = None, - right_label: str | UserString | None = None, + left_label: str | TranslatableString | None = None, + right_label: str | TranslatableString | None = None, *, required: bool = False, selected: bool = False, - help: str | UserString | None = None, + help: str | TranslatableString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _OneOrMoreConditions, ) -> bool: @@ -328,12 +328,12 @@ def checkbox( def checkbox( - left_label: str | UserString | None = None, - right_label: str | UserString | None = None, + left_label: str | TranslatableString | None = None, + right_label: str | TranslatableString | None = None, *, required: bool = False, selected: bool = False, - help: str | UserString | None = None, + help: str | TranslatableString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> Any: @@ -369,11 +369,11 @@ def checkbox( @overload def radio_group( - label: str | UserString, + label: str | TranslatableString, enum: type[_E], *, required: Literal[False] = False, - help: str | UserString | None = None, + help: str | TranslatableString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> _E | None: @@ -382,11 +382,11 @@ def radio_group( @overload def radio_group( - label: str | UserString, + label: str | TranslatableString, enum: type[_E], *, required: Literal[True], - help: str | UserString | None = None, + help: str | TranslatableString | None = None, disable_if: _OneOrMoreConditions, hide_if: _ZeroOrMoreConditions = None, ) -> _E | None: @@ -395,11 +395,11 @@ def radio_group( @overload def radio_group( - label: str | UserString, + label: str | TranslatableString, enum: type[_E], *, required: Literal[True], - help: str | UserString | None = None, + help: str | TranslatableString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _OneOrMoreConditions, ) -> _E | None: @@ -408,11 +408,11 @@ def radio_group( @overload def radio_group( - label: str | UserString, + label: str | TranslatableString, enum: type[_E], *, required: Literal[True], - help: str | UserString | None = None, + help: str | TranslatableString | None = None, disable_if: None = None, hide_if: None = None, ) -> _E: @@ -420,11 +420,11 @@ def radio_group( def radio_group( - label: str | UserString, + label: str | TranslatableString, enum: type[_E], *, required: bool = False, - help: str | UserString | None = None, + help: str | TranslatableString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> Any: @@ -460,12 +460,12 @@ def radio_group( @overload def select( - label: str | UserString, + label: str | TranslatableString, enum: type[_E], *, required: Literal[False] = False, multiple: Literal[False] = False, - help: str | UserString | None = None, + help: str | TranslatableString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> _E | None: @@ -474,12 +474,12 @@ def select( @overload def select( - label: str | UserString, + label: str | TranslatableString, enum: type[_E], *, required: Literal[True], multiple: Literal[False] = False, - help: str | UserString | None = None, + help: str | TranslatableString | None = None, disable_if: _OneOrMoreConditions, hide_if: _ZeroOrMoreConditions = None, ) -> _E | None: @@ -488,12 +488,12 @@ def select( @overload def select( - label: str | UserString, + label: str | TranslatableString, enum: type[_E], *, required: Literal[True], multiple: Literal[False] = False, - help: str | UserString | None = None, + help: str | TranslatableString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _OneOrMoreConditions, ) -> _E | None: @@ -502,12 +502,12 @@ def select( @overload def select( - label: str | UserString, + label: str | TranslatableString, enum: type[_E], *, required: Literal[True], multiple: Literal[False] = False, - help: str | UserString | None = None, + help: str | TranslatableString | None = None, disable_if: None = None, hide_if: None = None, ) -> _E: @@ -516,12 +516,12 @@ def select( @overload def select( - label: str | UserString, + label: str | TranslatableString, enum: type[_E], *, required: bool = False, multiple: Literal[True], - help: str | UserString | None = None, + help: str | TranslatableString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> set[_E]: @@ -529,12 +529,12 @@ def select( def select( - label: str | UserString, + label: str | TranslatableString, enum: type[_E], *, required: bool = False, multiple: bool = False, - help: str | UserString | None = None, + help: str | TranslatableString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> Any: @@ -582,7 +582,9 @@ def select( ) -def option(label: str | UserString, *, selected: bool = False, value: str | UserString | None = None) -> _OptionInfo: +def option( + label: str | TranslatableString, *, selected: bool = False, value: str | TranslatableString | None = None +) -> _OptionInfo: """Adds an option to an [`OptionEnum`][questionpy.form.OptionEnum]. Args: @@ -623,7 +625,7 @@ def hidden(value: _S, *, disable_if: _ZeroOrMoreConditions = None, hide_if: _Zer """Adds a hidden element with a fixed value. Args: - value (str | UserString): Fixed value. + value (str | TranslatableString): Fixed value. disable_if (Condition | list[Condition] | None): Disable this element if some condition(s) match. hide_if (Condition | list[Condition] | None): Hide this element if some condition(s) match. @@ -642,7 +644,7 @@ def hidden(value: _S, *, disable_if: _ZeroOrMoreConditions = None, hide_if: _Zer ) -def section(header: str | UserString, model: type[_F]) -> _F: +def section(header: str | TranslatableString, model: type[_F]) -> _F: """Adds a form section that can be expanded and collapsed. Args: @@ -672,10 +674,10 @@ def section(header: str | UserString, model: type[_F]) -> _F: def group( - label: str | UserString, + label: str | TranslatableString, model: type[_F], *, - help: str | UserString | None = None, + help: str | TranslatableString | None = None, disable_if: _ZeroOrMoreConditions = None, hide_if: _ZeroOrMoreConditions = None, ) -> _F: @@ -735,7 +737,7 @@ def repeat( initial: int = 1, minimum: int = 1, increment: int = 1, - button_label: str | UserString | None = None, + button_label: str | TranslatableString | None = None, ) -> list[_F]: """Repeats a sub-model, allowing the user to add new repetitions with the click of a button. @@ -836,7 +838,7 @@ def is_not_checked(name: str) -> IsNotChecked: return IsNotChecked(name=name) -def equals(name: str, *, value: str | UserString | int | bool) -> Equals: +def equals(name: str, *, value: str | TranslatableString | int | bool) -> Equals: """Condition on the value of another field being equal to some static value. Many elements can be hidden or disabled client-side by passing conditions to `hide_if` or `disable_if`. See the @@ -860,7 +862,7 @@ def equals(name: str, *, value: str | UserString | int | bool) -> Equals: return Equals(name=name, value=value) -def does_not_equal(name: str, *, value: str | UserString | int | bool) -> DoesNotEqual: +def does_not_equal(name: str, *, value: str | TranslatableString | int | bool) -> DoesNotEqual: """Condition on the value of another field *not* being equal to some static value. Many elements can be hidden or disabled client-side by passing conditions to `hide_if` or `disable_if`. See the @@ -888,7 +890,7 @@ def does_not_equal(name: str, *, value: str | UserString | int | bool) -> DoesNo return DoesNotEqual(name=name, value=value) -def is_in(name: str, values: list[str | UserString | int | bool]) -> In: +def is_in(name: str, values: list[str | TranslatableString | int | bool]) -> In: """Condition on the value of another field being one of a number of static values. Many elements can be hidden or disabled client-side by passing conditions to `hide_if` or `disable_if`. See the diff --git a/questionpy/form/_model.py b/questionpy/form/_model.py index c7f7d558..ee6fd217 100644 --- a/questionpy/form/_model.py +++ b/questionpy/form/_model.py @@ -1,7 +1,6 @@ # This file is part of the QuestionPy SDK. (https://questionpy.org) # The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. # (c) Technische Universität Berlin, innoCampus -from collections import UserString from collections.abc import Callable from dataclasses import dataclass from enum import Enum @@ -13,14 +12,15 @@ from pydantic.fields import FieldInfo from pydantic_core import CoreSchema, core_schema +from questionpy_common import TranslatableString from questionpy_common.elements import FormElement, FormSection, OptionsFormDefinition @dataclass class _OptionInfo: - label: str | UserString + label: str | TranslatableString selected: bool - value: str | UserString | None = None + value: str | TranslatableString | None = None """If None, set to the name by `OptionEnum.__init__` because __set_name__ doesn't get called for enum members.""" @@ -84,7 +84,7 @@ class _StaticElementInfo: @dataclass class _SectionInfo: - header: str | UserString + header: str | TranslatableString model: type["FormModel"] diff --git a/questionpy/i18n.py b/questionpy/i18n.py index 289fb7dd..b728920f 100644 --- a/questionpy/i18n.py +++ b/questionpy/i18n.py @@ -1,12 +1,12 @@ import logging -from collections import UserString -from collections.abc import Callable +from collections.abc import Callable, Iterable, Mapping from contextvars import ContextVar from dataclasses import dataclass from gettext import GNUTranslations, NullTranslations from importlib.resources.abc import Traversable from typing import Literal, NewType, TypeAlias, overload +from questionpy_common import TranslatableString from questionpy_common.environment import ( Environment, Package, @@ -186,30 +186,50 @@ def initialize_for_request(request_user: RequestUser) -> None: return domain_state -class _DeferredTranslatedMessage(UserString): +class _DeferredTranslatedMessage(TranslatableString): def __init__( - self, domain_state: _DomainState, default_message: str, getter: Callable[[NullTranslations], str] + self, + domain_state: _DomainState, + default_message: str, + getter: Callable[[NullTranslations], str], + transformations_on_result: Iterable[Callable[[str], str]] = (), ) -> None: - super().__init__(default_message) - self._default_message = default_message self._domain_state = domain_state + self._default_message = default_message self._getter = getter - @property - def data(self) -> str: + self._transformations_on_result = transformations_on_result + + def __str__(self) -> str: if self._domain_state.request_state: - return self._getter(self._domain_state.request_state.translations) + result = self._getter(self._domain_state.request_state.translations) + else: + self._domain_state.logger.warning( + "Deferred message '%s' not translated because domain is not initialized for request.", + self._default_message, + ) + result = self._default_message - self._domain_state.logger.debug( - "Deferred message '%s' not translated because domain is not initialized for request.", + for transformation in self._transformations_on_result: + result = transformation(result) + + return result + + def format(self, *args: object, **kwargs: object) -> TranslatableString: + return _DeferredTranslatedMessage( + self._domain_state, self._default_message, + self._getter, + (*self._transformations_on_result, (lambda s: s.format(*args, **kwargs))), ) - return self._default_message - @data.setter - def data(self, _: str) -> None: - # This is just here because MyPy expects data to be a writable property. - pass + def format_map(self, mapping: Mapping[str, object]) -> TranslatableString: + return _DeferredTranslatedMessage( + self._domain_state, + self._default_message, + self._getter, + (*self._transformations_on_result, (lambda s: s.format_map(mapping))), + ) class _Gettext: @@ -221,15 +241,15 @@ def __init__(self, package: Package, domain: GettextDomain, domain_state: _Domai self._domain_state = domain_state @overload - def __call__(self, message: str, /, *, defer: None = None) -> str | UserString: ... + def __call__(self, message: str, /, *, defer: None = None) -> str | TranslatableString: ... @overload - def __call__(self, message: str, /, *, defer: Literal[True]) -> UserString: ... + def __call__(self, message: str, /, *, defer: Literal[True]) -> TranslatableString: ... @overload def __call__(self, message: str, /, *, defer: Literal[False]) -> str: ... - def __call__(self, message: str, /, *, defer: bool | None = None) -> str | UserString: + def __call__(self, message: str, /, *, defer: bool | None = None) -> str | TranslatableString: """Translate the given message. Args: @@ -242,15 +262,15 @@ def __call__(self, message: str, /, *, defer: bool | None = None) -> str | UserS return self._maybe_defer(message, lambda trans: trans.gettext(message), defer=defer) @overload - def ngettext(self, singular: str, plural: str, n: int, /, *, defer: None = None) -> str | UserString: ... + def ngettext(self, singular: str, plural: str, n: int, /, *, defer: None = None) -> str | TranslatableString: ... @overload - def ngettext(self, singular: str, plural: str, n: int, /, *, defer: Literal[True]) -> UserString: ... + def ngettext(self, singular: str, plural: str, n: int, /, *, defer: Literal[True]) -> TranslatableString: ... @overload def ngettext(self, singular: str, plural: str, n: int, /, *, defer: Literal[False]) -> str: ... - def ngettext(self, singular: str, plural: str, n: int, /, *, defer: bool | None = None) -> str | UserString: + def ngettext(self, singular: str, plural: str, n: int, /, *, defer: bool | None = None) -> str | TranslatableString: """Translate the given message, accounting for plural forms. Args: @@ -266,15 +286,15 @@ def ngettext(self, singular: str, plural: str, n: int, /, *, defer: bool | None return self._maybe_defer(default_message, lambda trans: trans.ngettext(singular, plural, n), defer=defer) @overload - def pgettext(self, context: str, message: str, /, *, defer: None = None) -> str | UserString: ... + def pgettext(self, context: str, message: str, /, *, defer: None = None) -> str | TranslatableString: ... @overload - def pgettext(self, context: str, message: str, /, *, defer: Literal[True]) -> UserString: ... + def pgettext(self, context: str, message: str, /, *, defer: Literal[True]) -> TranslatableString: ... @overload def pgettext(self, context: str, message: str, /, *, defer: Literal[False]) -> str: ... - def pgettext(self, context: str, message: str, /, *, defer: bool | None = None) -> str | UserString: + def pgettext(self, context: str, message: str, /, *, defer: bool | None = None) -> str | TranslatableString: """Translate the given message in the given context. The context allows solving ambiguities where the same message may require different translations depending on @@ -294,17 +314,19 @@ def pgettext(self, context: str, message: str, /, *, defer: bool | None = None) @overload def npgettext( self, context: str, singular: str, plural: str, n: int, /, *, defer: None = None - ) -> str | UserString: ... + ) -> str | TranslatableString: ... @overload - def npgettext(self, context: str, singular: str, plural: str, n: int, /, *, defer: Literal[True]) -> UserString: ... + def npgettext( + self, context: str, singular: str, plural: str, n: int, /, *, defer: Literal[True] + ) -> TranslatableString: ... @overload def npgettext(self, context: str, singular: str, plural: str, n: int, /, *, defer: Literal[False]) -> str: ... def npgettext( self, context: str, singular: str, plural: str, n: int, /, *, defer: bool | None = None - ) -> str | UserString: + ) -> str | TranslatableString: """Translate the given message in the given context. The context allows solving ambiguities where the same message may require different translations depending on @@ -328,7 +350,7 @@ def npgettext( def _maybe_defer( self, default_message: str, getter: Callable[[NullTranslations], str], *, defer: bool | None - ) -> str | UserString: + ) -> str | TranslatableString: if defer is None: defer = self._domain_state.request_state is None diff --git a/questionpy_sdk/webserver/question_ui/__init__.py b/questionpy_sdk/webserver/question_ui/__init__.py index eea6e27f..7f9ab3e3 100644 --- a/questionpy_sdk/webserver/question_ui/__init__.py +++ b/questionpy_sdk/webserver/question_ui/__init__.py @@ -27,7 +27,7 @@ ) if TYPE_CHECKING: - from collections import UserString + from questionpy_common import TranslatableString _XHTML_NAMESPACE: str = "http://www.w3.org/1999/xhtml" _QPY_NAMESPACE: str = "http://questionpy.org/ns/question" @@ -202,7 +202,7 @@ class QuestionUIRenderer: def __init__( self, xml: str, - placeholders: dict[str, str | UserString], + placeholders: dict[str, str | TranslatableString], options: QuestionDisplayOptions, seed: int | None = None, attempt: dict | None = None, @@ -525,7 +525,7 @@ class QuestionFormulationUIRenderer(QuestionUIRenderer): def __init__( self, xml: str, - placeholders: dict[str, str | UserString], + placeholders: dict[str, str | TranslatableString], options: QuestionDisplayOptions, seed: int | None = None, attempt: dict | None = None, @@ -572,7 +572,7 @@ class _RenderErrorCollector: def __init__( self, xml: str, - placeholders: dict[str, str | UserString], + placeholders: dict[str, str | TranslatableString], ) -> None: self.errors = RenderErrorCollection() From 209a4aa6183ea5b077b656d043538e90ec2f5a37 Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Mon, 24 Feb 2025 17:39:21 +0100 Subject: [PATCH 15/25] docs: make i18n example more exhaustive --- examples/i18n/locale/de.po | 139 ++++++++++++++++--- examples/i18n/locale/en.po | 125 +++++++++++++++-- examples/i18n/locale/local.i18n.pot | 121 ++++++++++++++-- examples/i18n/python/local/i18n/__init__.py | 43 +++++- examples/i18n/templates/formulation.xhtml.j2 | 14 +- questionpy/i18n.py | 1 - 6 files changed, 398 insertions(+), 45 deletions(-) diff --git a/examples/i18n/locale/de.po b/examples/i18n/locale/de.po index 22736089..c61d4734 100644 --- a/examples/i18n/locale/de.po +++ b/examples/i18n/locale/de.po @@ -8,36 +8,139 @@ msgid "" msgstr "" "Project-Id-Version: @local/i18n 0.1.0\n" "Report-Msgid-Bugs-To: Jane Doe \n" -"POT-Creation-Date: 2025-02-19 14:57+0100\n" -"PO-Revision-Date: 2025-01-27 14:07+0100\n" +"POT-Creation-Date: 2025-02-24 15:42+0100\n" +"PO-Revision-Date: 2025-02-24 15:56+0100\n" "Last-Translator: \n" -"Language: de\n" "Language-Team: de \n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Language: de\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.16.0\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Generated-By: Babel 2.17.0\n" +"X-Generator: Poedit 3.5\n" + +#: python/local/i18n/__init__.py:14 +msgid "Status of the file" +msgstr "Status der Datei" + +#: python/local/i18n/__init__.py:14 +msgctxt "File" +msgid "Opened" +msgstr "Geöffnet" + +#: python/local/i18n/__init__.py:16 +msgid "Status of the amusement park" +msgstr "Status des Freizeitparks" + +#: python/local/i18n/__init__.py:16 +msgctxt "Amusement Park" +msgid "Opened" +msgstr "Eröffnet" + +#: python/local/i18n/__init__.py:20 +msgid "npgettext combines pluralization support and contextualization." +msgstr "npgettext erlaubt sowohl Pluralisierung als auch Kontextualisierung." + +#: python/local/i18n/__init__.py:21 +#, python-brace-format +msgctxt "Home Depot" +msgid "There is a light." +msgid_plural "There are {} lights!" +msgstr[0] "Da ist eine Lampe." +msgstr[1] "Da sind {} Lampen!" + +#: python/local/i18n/__init__.py:23 +#, python-brace-format +msgctxt "Picard" +msgid "There is a light." +msgid_plural "There are {} lights!" +msgstr[0] "Da ist ein Licht." +msgstr[1] "Da sind {} Lichter!" + +#: python/local/i18n/__init__.py:27 +msgid "First Option" +msgstr "Erste Option" + +#: python/local/i18n/__init__.py:28 +msgid "Second Option" +msgstr "Zweite Option" + +#: python/local/i18n/__init__.py:29 +msgid "Third Option" +msgstr "Dritte Option" #. This comment will be shown in the .pot file. -#: python/local/i18n/__init__.py:17 -msgid "Important Notice" -msgstr "Wichtige Mitteilung" +#: python/local/i18n/__init__.py:35 +msgid "This is a localized FormModel." +msgstr "Das ist ein lokalisiertes FormModel." -#: python/local/i18n/__init__.py:18 +#: python/local/i18n/__init__.py:36 msgid "" -"If you or a loved one has been diagnosed with mesothelioma, you may be " -"entitled to financial compensation." +"The FormModel is defined before any initialization, so message translation " +"needs to be deferred!" msgstr "" -"Falls Sie oder eine Ihnen nahestehende Person eine Mesotheliom-Diagnose " -"erhalten haben, könnten Sie für finanzielle Entschädigung in Frage " -"kommen." +"Das FormModel wird vor jeder Initialisierung definiert, deshalb müssen " +"Übersetzungen später geschehen!" + +#: python/local/i18n/__init__.py:40 +msgid "This is a text_input, and its label is localized!" +msgstr "Das ist ein text_input, und sein Label ist lokalisiert!" + +#: python/local/i18n/__init__.py:42 +msgid "Even this placeholder is localized!" +msgstr "Selbst dieser Platzhalter ist lokalisiert!" + +#: python/local/i18n/__init__.py:43 +msgid "Now you can get help in your language!" +msgstr "Jetzt kannst du in deiner Sprache Hilfe erhalten!" + +#: python/local/i18n/__init__.py:47 +#, python-brace-format +msgid "Choose exactly one option." +msgid_plural "Choose exactly {} options." +msgstr[0] "Wähle genau eine Option." +msgstr[1] "Wähle genau {} Optionen." -#: python/local/i18n/__init__.py:32 -msgid "It's all wrong!" -msgstr "Es ist alles falsch!" +#: python/local/i18n/__init__.py:50 +msgid "This section shows contextualized messages." +msgstr "Dieser Abschnitt zeigt kontextualisierte Übersetzungen." + +#: python/local/i18n/__init__.py:63 +msgid "Programmatic translation!" +msgstr "Programmatische Übersetzung!" #. This comment will be shown in the .pot file. #: templates/formulation.xhtml.j2:4 msgid "This string should be localized!" -msgstr "Dieser String sollte lokalisiert sein!" +msgstr "Diese Zeichenkette sollte lokalisiert sein!" + +#: templates/formulation.xhtml.j2:5 +msgid "One Thing" +msgid_plural "More Things" +msgstr[0] "Eine Sache" +msgstr[1] "Mehrere Sachen" + +#: templates/formulation.xhtml.j2:7 +msgctxt "Context 1" +msgid "My Message" +msgstr "Meine Nachricht in Kontext 1" + +#: templates/formulation.xhtml.j2:7 +msgctxt "Context 2" +msgid "My Message" +msgstr "Meine Nachricht in Kontext 2" + +#: templates/formulation.xhtml.j2:10 templates/formulation.xhtml.j2:11 +msgctxt "Context 1" +msgid "One Thing" +msgid_plural "More Things" +msgstr[0] "Eine Sache in Kontext 1" +msgstr[1] "Mehrere Sachen in Kontext 1" + +#: templates/formulation.xhtml.j2:12 templates/formulation.xhtml.j2:13 +msgctxt "Context 2" +msgid "One Thing" +msgid_plural "More Things" +msgstr[0] "Eine Sache in Kontext 2" +msgstr[1] "Mehrere Sachen in Kontext 2" diff --git a/examples/i18n/locale/en.po b/examples/i18n/locale/en.po index 64634075..bdfcf1aa 100644 --- a/examples/i18n/locale/en.po +++ b/examples/i18n/locale/en.po @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: @local/i18n 0.1.0\n" "Report-Msgid-Bugs-To: Jane Doe \n" -"POT-Creation-Date: 2025-02-19 14:57+0100\n" -"PO-Revision-Date: 2025-01-27 14:01+0100\n" +"POT-Creation-Date: 2025-02-24 15:42+0100\n" +"PO-Revision-Date: 2025-02-24 15:34+0100\n" "Last-Translator: FULL NAME \n" "Language: en\n" "Language-Team: en \n" @@ -17,24 +17,129 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.16.0\n" +"Generated-By: Babel 2.17.0\n" + +#: python/local/i18n/__init__.py:14 +msgid "Status of the file" +msgstr "" + +#: python/local/i18n/__init__.py:14 +msgctxt "File" +msgid "Opened" +msgstr "" + +#: python/local/i18n/__init__.py:16 +msgid "Status of the amusement park" +msgstr "" + +#: python/local/i18n/__init__.py:16 +msgctxt "Amusement Park" +msgid "Opened" +msgstr "" + +#: python/local/i18n/__init__.py:20 +msgid "npgettext combines pluralization support and contextualization." +msgstr "" + +#: python/local/i18n/__init__.py:21 +#, python-brace-format +msgctxt "Home Depot" +msgid "There is a light." +msgid_plural "There are {} lights!" +msgstr[0] "" +msgstr[1] "" + +#: python/local/i18n/__init__.py:23 +#, python-brace-format +msgctxt "Picard" +msgid "There is a light." +msgid_plural "There are {} lights!" +msgstr[0] "" +msgstr[1] "" + +#: python/local/i18n/__init__.py:27 +msgid "First Option" +msgstr "" + +#: python/local/i18n/__init__.py:28 +msgid "Second Option" +msgstr "" + +#: python/local/i18n/__init__.py:29 +msgid "Third Option" +msgstr "" #. This comment will be shown in the .pot file. -#: python/local/i18n/__init__.py:17 -msgid "Important Notice" +#: python/local/i18n/__init__.py:35 +msgid "This is a localized FormModel." msgstr "" -#: python/local/i18n/__init__.py:18 +#: python/local/i18n/__init__.py:36 msgid "" -"If you or a loved one has been diagnosed with mesothelioma, you may be " -"entitled to financial compensation." +"The FormModel is defined before any initialization, so message " +"translation needs to be deferred!" +msgstr "" + +#: python/local/i18n/__init__.py:40 +msgid "This is a text_input, and its label is localized!" +msgstr "" + +#: python/local/i18n/__init__.py:42 +msgid "Even this placeholder is localized!" msgstr "" -#: python/local/i18n/__init__.py:32 -msgid "It's all wrong!" +#: python/local/i18n/__init__.py:43 +msgid "Now you can get help in your language!" +msgstr "" + +#: python/local/i18n/__init__.py:47 +#, python-brace-format +msgid "Choose exactly one option." +msgid_plural "Choose exactly {} options." +msgstr[0] "" +msgstr[1] "" + +#: python/local/i18n/__init__.py:50 +msgid "This section shows contextualized messages." +msgstr "" + +#: python/local/i18n/__init__.py:63 +msgid "Programmatic translation!" msgstr "" #. This comment will be shown in the .pot file. #: templates/formulation.xhtml.j2:4 msgid "This string should be localized!" msgstr "" + +#: templates/formulation.xhtml.j2:5 +msgid "One Thing" +msgid_plural "More Things" +msgstr[0] "" +msgstr[1] "" + +#: templates/formulation.xhtml.j2:7 +msgctxt "Context 1" +msgid "My Message" +msgstr "" + +#: templates/formulation.xhtml.j2:7 +msgctxt "Context 2" +msgid "My Message" +msgstr "" + +#: templates/formulation.xhtml.j2:10 templates/formulation.xhtml.j2:11 +#, fuzzy +msgctxt "Context 1" +msgid "One Thing" +msgid_plural "More Things" +msgstr[0] "" +msgstr[1] "" + +#: templates/formulation.xhtml.j2:12 templates/formulation.xhtml.j2:13 +#, fuzzy +msgctxt "Context 2" +msgid "One Thing" +msgid_plural "More Things" +msgstr[0] "" +msgstr[1] "" diff --git a/examples/i18n/locale/local.i18n.pot b/examples/i18n/locale/local.i18n.pot index d8215a7e..ffe69b2e 100644 --- a/examples/i18n/locale/local.i18n.pot +++ b/examples/i18n/locale/local.i18n.pot @@ -9,28 +9,101 @@ msgid "" msgstr "" "Project-Id-Version: @local/i18n 0.1.0\n" "Report-Msgid-Bugs-To: Jane Doe \n" -"POT-Creation-Date: 2025-02-19 14:57+0100\n" +"POT-Creation-Date: 2025-02-24 15:42+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.16.0\n" +"Generated-By: Babel 2.17.0\n" + +#: python/local/i18n/__init__.py:14 +msgid "Status of the file" +msgstr "" + +#: python/local/i18n/__init__.py:14 +msgctxt "File" +msgid "Opened" +msgstr "" + +#: python/local/i18n/__init__.py:16 +msgid "Status of the amusement park" +msgstr "" + +#: python/local/i18n/__init__.py:16 +msgctxt "Amusement Park" +msgid "Opened" +msgstr "" + +#: python/local/i18n/__init__.py:20 +msgid "npgettext combines pluralization support and contextualization." +msgstr "" + +#: python/local/i18n/__init__.py:21 +#, python-brace-format +msgctxt "Home Depot" +msgid "There is a light." +msgid_plural "There are {} lights!" +msgstr[0] "" +msgstr[1] "" + +#: python/local/i18n/__init__.py:23 +#, python-brace-format +msgctxt "Picard" +msgid "There is a light." +msgid_plural "There are {} lights!" +msgstr[0] "" +msgstr[1] "" + +#: python/local/i18n/__init__.py:27 +msgid "First Option" +msgstr "" + +#: python/local/i18n/__init__.py:28 +msgid "Second Option" +msgstr "" + +#: python/local/i18n/__init__.py:29 +msgid "Third Option" +msgstr "" #. This comment will be shown in the .pot file. -#: python/local/i18n/__init__.py:17 -msgid "Important Notice" +#: python/local/i18n/__init__.py:35 +msgid "This is a localized FormModel." msgstr "" -#: python/local/i18n/__init__.py:18 +#: python/local/i18n/__init__.py:36 msgid "" -"If you or a loved one has been diagnosed with mesothelioma, you may be " -"entitled to financial compensation." +"The FormModel is defined before any initialization, so message " +"translation needs to be deferred!" +msgstr "" + +#: python/local/i18n/__init__.py:40 +msgid "This is a text_input, and its label is localized!" +msgstr "" + +#: python/local/i18n/__init__.py:42 +msgid "Even this placeholder is localized!" msgstr "" -#: python/local/i18n/__init__.py:32 -msgid "It's all wrong!" +#: python/local/i18n/__init__.py:43 +msgid "Now you can get help in your language!" +msgstr "" + +#: python/local/i18n/__init__.py:47 +#, python-brace-format +msgid "Choose exactly one option." +msgid_plural "Choose exactly {} options." +msgstr[0] "" +msgstr[1] "" + +#: python/local/i18n/__init__.py:50 +msgid "This section shows contextualized messages." +msgstr "" + +#: python/local/i18n/__init__.py:63 +msgid "Programmatic translation!" msgstr "" #. This comment will be shown in the .pot file. @@ -38,3 +111,33 @@ msgstr "" msgid "This string should be localized!" msgstr "" +#: templates/formulation.xhtml.j2:5 +msgid "One Thing" +msgid_plural "More Things" +msgstr[0] "" +msgstr[1] "" + +#: templates/formulation.xhtml.j2:7 +msgctxt "Context 1" +msgid "My Message" +msgstr "" + +#: templates/formulation.xhtml.j2:7 +msgctxt "Context 2" +msgid "My Message" +msgstr "" + +#: templates/formulation.xhtml.j2:10 templates/formulation.xhtml.j2:11 +msgctxt "Context 1" +msgid "One Thing" +msgid_plural "More Things" +msgstr[0] "" +msgstr[1] "" + +#: templates/formulation.xhtml.j2:12 templates/formulation.xhtml.j2:13 +msgctxt "Context 2" +msgid "One Thing" +msgid_plural "More Things" +msgstr[0] "" +msgstr[1] "" + diff --git a/examples/i18n/python/local/i18n/__init__.py b/examples/i18n/python/local/i18n/__init__.py index 1ae5f5c0..309166f1 100644 --- a/examples/i18n/python/local/i18n/__init__.py +++ b/examples/i18n/python/local/i18n/__init__.py @@ -2,19 +2,52 @@ # The QuestionPy SDK is free software released under terms of the MIT license. See LICENSE.md. # (c) Technische Universität Berlin, innoCampus from questionpy import Attempt, NeedsManualScoringError, Question, i18n, make_question_type_init -from questionpy.form import FormModel, static_text +from questionpy.form import FormModel, OptionEnum, checkbox, option, radio_group, section, static_text, text_input +from questionpy_common import TranslatableString from questionpy_common.elements import StaticTextElement _, _N = i18n.get_for(__package__) +class ContextSection(FormModel): + file_status: StaticTextElement = static_text(_("Status of the file"), _.pgettext("File", "Opened")) + amusement_park_status: StaticTextElement = static_text( + _("Status of the amusement park"), _.pgettext("Amusement Park", "Opened") + ) + + home_depot: bool = checkbox( + left_label=_("npgettext combines pluralization support and contextualization."), + right_label=_.npgettext("Home Depot", "There is a light.", "There are {} lights!", 74).format(74), + ) + picard: bool = checkbox(right_label=_.npgettext("Picard", "There is a light.", "There are {} lights!", 4).format(4)) + + +class MyOptions(OptionEnum): + FIRST = option(_("First Option")) + SECOND = option(_("Second Option")) + THIRD = option(_("Third Option")) + + class I18NModel(FormModel): txt: StaticTextElement = static_text( # TRANSLATORS: This comment will be shown in the .pot file. - _("Important Notice"), - _("If you or a loved one has been diagnosed with mesothelioma, you may be entitled to financial compensation."), + _("This is a localized FormModel."), + _("The FormModel is defined before any initialization, so message translation needs to be deferred!"), + ) + + input: str = text_input( + _("This is a text_input, and its label is localized!"), + required=True, + placeholder=_("Even this placeholder is localized!"), + help=_("Now you can get help in your language!"), ) + radio_group: MyOptions = radio_group( + _.ngettext("Choose exactly one option.", "Choose exactly {} options.", 1).format(1), MyOptions, required=True + ) + + context: ContextSection = section(_("This section shows contextualized messages."), ContextSection) + class I18NAttempt(Attempt): def _compute_score(self) -> float: @@ -25,8 +58,8 @@ def formulation(self) -> str: return self.jinja2.get_template("formulation.xhtml.j2").render() @property - def general_feedback(self) -> str | None: - return "
" + _("It's all wrong!") + "
" + def general_feedback(self) -> str | TranslatableString | None: + return "
" + _("Programmatic translation!", defer=False) + "
" class I18NQuestion(Question): diff --git a/examples/i18n/templates/formulation.xhtml.j2 b/examples/i18n/templates/formulation.xhtml.j2 index 262fb24d..fda323aa 100644 --- a/examples/i18n/templates/formulation.xhtml.j2 +++ b/examples/i18n/templates/formulation.xhtml.j2 @@ -1,5 +1,15 @@
+> {# TRANSLATORS: This comment will be shown in the .pot file. #} -

{{ _("This string should be localized!") }}

+

gettext: {{ _("This string should be localized!") }}

+

ngettext: {{ ngettext("One Thing", "More Things", 1) }}, {{ ngettext("One Thing", "More Things", 99) }} +

+

pgettext: {{ pgettext("Context 1", "My Message") }}, {{ pgettext("Context 2", "My Message") }}

+

npgettext

+
    +
  • {{ npgettext("Context 1", "One Thing", "More Things", 1) }}
  • +
  • {{ npgettext("Context 1", "One Thing", "More Things", 2) }}
  • +
  • {{ npgettext("Context 2", "One Thing", "More Things", 1) }}
  • +
  • {{ npgettext("Context 2", "One Thing", "More Things", 2) }}
  • +
diff --git a/questionpy/i18n.py b/questionpy/i18n.py index b728920f..266a4562 100644 --- a/questionpy/i18n.py +++ b/questionpy/i18n.py @@ -371,7 +371,6 @@ def get_for(module_name: str) -> tuple[_Gettext, _Noop]: Args: module_name: The Python `__package__` or `__module__` whose domain should be used. """ - # TODO: Maybe cache this? package = _get_package_owning_module(module_name) domain = domain_of(package.manifest) domain_state = _ensure_initialized(domain, package, get_qpy_environment()) From 65e6a173d84366a35e88ce803636d775fc6070a0 Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Tue, 25 Feb 2025 12:50:49 +0100 Subject: [PATCH 16/25] test: ignore examples during collection --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e4ecf72b..c720c4ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ extend-ignore-names = ["mcs", "test_*"] allow-dunder-method-names = ["__get_pydantic_core_schema__"] [tool.pytest.ini_options] -addopts = "--doctest-modules" +addopts = "--doctest-modules --ignore examples" # https://github.com/pytest-dev/pytest-asyncio#auto-mode asyncio_mode = "auto" asyncio_default_fixture_loop_scope="function" From 1871129172b1f4acdaabe59145a56d4283fb4382 Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Tue, 25 Feb 2025 12:56:39 +0100 Subject: [PATCH 17/25] chore: silence S603 and S607 in builder --- questionpy_sdk/package/builder.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/questionpy_sdk/package/builder.py b/questionpy_sdk/package/builder.py index fecf58a1..c2636ca8 100644 --- a/questionpy_sdk/package/builder.py +++ b/questionpy_sdk/package/builder.py @@ -98,7 +98,11 @@ def _install_requirements(self) -> None: # pip doesn't offer a public API, so we have to resort to subprocess (pypa/pip#3121) try: with TemporaryDirectory(prefix=f"qpy_{config.short_name}") as tempdir: - subprocess.run(["pip", "install", "--target", tempdir, *pip_args], check=True, capture_output=True) + subprocess.run( # noqa: S603 # Not really applicable here. + ["pip", "install", "--target", tempdir, *pip_args], # noqa: S607 + check=True, + capture_output=True, + ) self._write_glob(Path(tempdir), "**/*", Path(DIST_DIR) / "dependencies" / "site-packages") except subprocess.CalledProcessError as exc: msg = f"Failed to install requirements: {exc.stderr.decode()}" From 675a44394ebee80ddf7d5fdec598b77d91a87a54 Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Tue, 25 Feb 2025 17:26:05 +0100 Subject: [PATCH 18/25] docs(i18n): add i18n to mkdocs and improve docstrings --- docs/i18n.md | 1 + mkdocs.yml | 1 + poetry.lock | 6 +- pyproject.toml | 2 +- questionpy/_attempt.py | 1 + questionpy/_ui.py | 6 +- questionpy/i18n.py | 461 ++++++++++++++++++++++------------------- 7 files changed, 263 insertions(+), 215 deletions(-) create mode 100644 docs/i18n.md diff --git a/docs/i18n.md b/docs/i18n.md new file mode 100644 index 00000000..d712b80c --- /dev/null +++ b/docs/i18n.md @@ -0,0 +1 @@ +::: questionpy.i18n diff --git a/mkdocs.yml b/mkdocs.yml index 7be476a5..d2f77a04 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -6,3 +6,4 @@ nav: - form: - form/index.md - form/validation.md + - i18n: i18n.md diff --git a/poetry.lock b/poetry.lock index 26b761a6..6ad3a2b2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1572,8 +1572,8 @@ watchdog = "^6.0.0" [package.source] type = "git" url = "https://github.com/questionpy-org/questionpy-server.git" -reference = "81bf74d80757a218d7fdb4f8c6ed2fb57f45897a" -resolved_reference = "81bf74d80757a218d7fdb4f8c6ed2fb57f45897a" +reference = "95df79c5eee9a8e2313f8e3e1da018df25fbd6ad" +resolved_reference = "95df79c5eee9a8e2313f8e3e1da018df25fbd6ad" [[package]] name = "ruff" @@ -1928,4 +1928,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "f22824f37329a40607ba1eb6d37e7221e838f9ad6b1f31e97294d26a3d09676e" +content-hash = "29b096e58046c6591d144110b607ae355ef4599e2790792360aef24a8ce51444" diff --git a/pyproject.toml b/pyproject.toml index c720c4ba..db00d02a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ python = "^3.11" aiohttp = "^3.9.3" pydantic = "^2.6.4" PyYAML = "^6.0.1" -questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "81bf74d80757a218d7fdb4f8c6ed2fb57f45897a" } +questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "95df79c5eee9a8e2313f8e3e1da018df25fbd6ad" } jinja2 = "^3.1.3" aiohttp-jinja2 = "^1.6" lxml = {version = "~5.3.0", extras = ["html_clean"]} diff --git a/questionpy/_attempt.py b/questionpy/_attempt.py index b768d044..683c66d3 100644 --- a/questionpy/_attempt.py +++ b/questionpy/_attempt.py @@ -222,6 +222,7 @@ def _compute_final_score(self) -> float: @cached_property def jinja2(self) -> jinja2.Environment: + """A sensibly configured Jinja2 environment. See [`questionpy.create_jinja2_environment`][] for details.""" return create_jinja2_environment(self, self.question) @property diff --git a/questionpy/_ui.py b/questionpy/_ui.py index a3ae5455..9c177cc2 100644 --- a/questionpy/_ui.py +++ b/questionpy/_ui.py @@ -65,6 +65,7 @@ def create_jinja2_environment(attempt: "Attempt", question: "Question") -> jinja - Package templates are accessible under the prefix ``@//``. - The prefix is optional when accessing templates of the current package. - The QPy environment, attempt, question and question type are available as globals. + - The i18n extension is installed and configured to use the relevant package's translations. """ qpy_env = get_qpy_environment() @@ -93,8 +94,7 @@ def create_jinja2_environment(attempt: "Attempt", question: "Question") -> jinja }) translations = i18n.get_translations_of_package(package) - if translations: - env.add_extension("jinja2.ext.i18n") - env.install_gettext_translations(translations, newstyle=True) # type: ignore[attr-defined] + env.add_extension("jinja2.ext.i18n") + env.install_gettext_translations(translations, newstyle=True) # type: ignore[attr-defined] return env diff --git a/questionpy/i18n.py b/questionpy/i18n.py index 266a4562..b68a2e86 100644 --- a/questionpy/i18n.py +++ b/questionpy/i18n.py @@ -1,10 +1,35 @@ +"""Internationalization (I18N) support for QuestionPy packages. + +This module contains the state and utilities required for translation at runtime. See the CLI documentation on how to +manage translations in a package. + +In most cases, a package wishing to translate messages outside of Jinja templates must call +[`get_for(__package__)`][questionpy.i18n.get_for]. When using the Jinja environment provided by +[`questionpy.Attempt.jinja2`][], [Jinja's i18n extension](https://jinja.palletsprojects.com/en/stable/extensions/#i18n-extension) +is automatically initialized to use the owning package's translations. + +Example: + ```py + _, _N = i18n.get_for(__package__) + print(_("I'm translated!")) + print(_.ngettext("One thing", "{} things", 2).format(2)) + ``` + +Deferred Translation: + Since QuestionPy workers (and by extension, QuestionPy packages) are loaded and initialized to potentially handle + multiple requests, the languages preferred by the user are not known yet when the Python code making up the package + is imported. For this reason, translation may be deferred by [`gettext`][questionpy.i18n.Gettext]. The returned + object will transparently translate the message when it is needed. For now, the only other operations available on + deferred messages are [`format`][str.format] and [`format_map`][str.format_map]. +""" + import logging from collections.abc import Callable, Iterable, Mapping from contextvars import ContextVar from dataclasses import dataclass from gettext import GNUTranslations, NullTranslations from importlib.resources.abc import Traversable -from typing import Literal, NewType, TypeAlias, overload +from typing import Literal, NewType, overload from questionpy_common import TranslatableString from questionpy_common.environment import ( @@ -16,8 +41,20 @@ ) from questionpy_common.manifest import Bcp47LanguageTag, SourceManifest +__all__ = [ + "DEFAULT_CATEGORY", + "GettextDomain", + "TranslatableString", + "dgettext", + "dnpgettext", + "domain_of", + "dpgettext", + "get_for", + "get_primary_language", + "get_translations_of_package", +] + DEFAULT_CATEGORY = "LC_MESSAGES" -_NULL_TRANSLATIONS = NullTranslations() GettextDomain = NewType("GettextDomain", str) @@ -37,15 +74,221 @@ class _DomainState: request_state: _RequestState | None = None -_i18n_state: ContextVar[dict[GettextDomain, _DomainState]] = ContextVar("_i18n_state") +class Gettext: + def __init__(self, package: Package, domain: GettextDomain, domain_state: _DomainState) -> None: + self._package = package + self._domain = domain + self._domain_state = domain_state -_log = logging.getLogger(__name__) + @overload + def __call__(self, message: str, /, *, defer: None = None) -> str | TranslatableString: ... + + @overload + def __call__(self, message: str, /, *, defer: Literal[True]) -> TranslatableString: ... + + @overload + def __call__(self, message: str, /, *, defer: Literal[False]) -> str: ... + + def __call__(self, message: str, /, *, defer: bool | None = None) -> str | TranslatableString: + """Translate the given message. + + Args: + message: Gettext `msgid`. This should also be the message in its primary language, usually english. + defer: By default, translation is deferred only when no request is being processed at the time of the + `gettext` call. This parameter can be explicitly set to `True` to force deferral or to `False` to never + defer translations. In the latter case, calling `gettext` before a request is processed + (e.g. during init) will raise an error. + + Returns: + (str): If available: The translated message. + (questionpy_common.TranslatableString): A deferred [questionpy.TranslatableString][] + """ + return self._maybe_defer(message, lambda trans: trans.gettext(message), defer=defer) + + @overload + def ngettext(self, singular: str, plural: str, n: int, /, *, defer: None = None) -> str | TranslatableString: ... + + @overload + def ngettext(self, singular: str, plural: str, n: int, /, *, defer: Literal[True]) -> TranslatableString: ... + + @overload + def ngettext(self, singular: str, plural: str, n: int, /, *, defer: Literal[False]) -> str: ... + + def ngettext(self, singular: str, plural: str, n: int, /, *, defer: bool | None = None) -> str | TranslatableString: + """Translate the given message, accounting for plural forms. + + Args: + singular: Message id in its (english) singular form, used if no plural form exists and `n == 1`. + plural: Message id in its (english) plural form, used if no plural form exists and `n >= 2`. + n: This number is passed through the plural formula of the active catalog to determine which form to use. + defer: By default, translation is deferred only when no request is being processed at the time of the + `gettext` call. This parameter can be explicitly set to `True` to force deferral or to `False` to never + defer translations. In the latter case, calling `gettext` before a request is processed + (e.g. during init) will raise an error. + """ + default_message = singular if n == 1 else plural + return self._maybe_defer(default_message, lambda trans: trans.ngettext(singular, plural, n), defer=defer) + + @overload + def pgettext(self, context: str, message: str, /, *, defer: None = None) -> str | TranslatableString: ... + + @overload + def pgettext(self, context: str, message: str, /, *, defer: Literal[True]) -> TranslatableString: ... + + @overload + def pgettext(self, context: str, message: str, /, *, defer: Literal[False]) -> str: ... + + def pgettext(self, context: str, message: str, /, *, defer: bool | None = None) -> str | TranslatableString: + """Translate the given message in the given context. + + The context allows solving ambiguities where the same message may require different translations depending on + the place it's used. See [the GNU gettext documentation](https://www.gnu.org/software/gettext/manual/html_node/Contexts.html). + + Args: + context: The context within which the message should be scoped. This is also extracted as the `msgctxt` + value. + message: Gettext `msgid`. This should also be the message in its primary language, usually english. + defer: By default, translation is deferred only when no request is being processed at the time of the + `gettext` call. This parameter can be explicitly set to `True` to force deferral or to `False` to never + defer translations. In the latter case, calling `gettext` before a request is processed + (e.g. during init) will raise an error. + """ + return self._maybe_defer(message, lambda trans: trans.pgettext(context, message), defer=defer) + + @overload + def npgettext( + self, context: str, singular: str, plural: str, n: int, /, *, defer: None = None + ) -> str | TranslatableString: ... + + @overload + def npgettext( + self, context: str, singular: str, plural: str, n: int, /, *, defer: Literal[True] + ) -> TranslatableString: ... + + @overload + def npgettext(self, context: str, singular: str, plural: str, n: int, /, *, defer: Literal[False]) -> str: ... + + def npgettext( + self, context: str, singular: str, plural: str, n: int, /, *, defer: bool | None = None + ) -> str | TranslatableString: + """Translate the given message in the given context, accounting for plural forms. + + The context allows solving ambiguities where the same message may require different translations depending on + the place it's used. See [the GNU gettext documentation](https://www.gnu.org/software/gettext/manual/html_node/Contexts.html). + + Args: + context: The context within which the message should be scoped. This is also extracted as the `msgctxt` + value. + singular: Message id in its (english) singular form, used if no plural form exists and `n == 1`. + plural: Message id in its (english) plural form, used if no plural form exists and `n >= 2`. + n: This number is passed through the plural formula of the active catalog to determine which form to use. + defer: By default, translation is deferred only when no request is being processed at the time of the + `gettext` call. This parameter can be explicitly set to `True` to force deferral or to `False` to never + defer translations. In the latter case, calling `gettext` before a request is processed + (e.g. during init) will raise an error. + """ + default_message = singular if n == 1 else plural + return self._maybe_defer( + default_message, lambda trans: trans.npgettext(context, singular, plural, n), defer=defer + ) + + def _maybe_defer( + self, default_message: str, getter: Callable[[NullTranslations], str], *, defer: bool | None + ) -> str | TranslatableString: + if defer is None: + defer = self._domain_state.request_state is None + + if defer: + self._domain_state.logger.debug("Deferring translation of message '%s'.", default_message) + return _DeferredTranslatedMessage(self._domain_state, default_message, getter) + + request_state = _require_request_state(self._domain, self._domain_state) + return getter(request_state.translations) def domain_of(package: SourceManifest | PackageNamespaceAndShortName) -> GettextDomain: + """Get the canonical gettext domain used when translating messages in given package.""" return GettextDomain(f"{package.namespace}.{package.short_name}") +def get_translations_of_package(package: Package) -> NullTranslations: + """Get the [gettext.NullTranslations][] instance currently used for translations.""" + domain = domain_of(package.manifest) + domain_state = _ensure_initialized(domain, package, get_qpy_environment()) + request_state = _require_request_state(domain, domain_state) + return request_state.translations + + +def get_primary_language(package: Package) -> Bcp47LanguageTag: + """Get the language that is currently first used when translating strings. This may change between requests.""" + domain = domain_of(package.manifest) + domain_state = _i18n_state.get({}).get(domain) + if domain_state: + return _require_request_state(domain, domain_state).primary_lang + return package.manifest.languages[0] + + +def get_for(module_name: str) -> tuple[Gettext, Callable[[str], str]]: + """Initializes i18n for the package owning the given Python module and returns the gettext-family functions. + + Args: + module_name: The Python `__package__` or `__module__` whose domain should be used. + + Returns: + _: (usually assigned to `_`) the main translation function. Compatible with the standard library's + [gettext.gettext][]. The other gettext-family functions are available as methods on `gettext`: `.ngettext`, + `.pgettext` and `.npgettext`. + _N: A function which just returns the passed-in message untranslated. Useful for marking a message as + translatable without translating it at that time. + + Example: + ```py + _, _N = i18n.get_for(__package__) + print(_("I'm translated!")) + print(_.ngettext("One thing", "{} things", 2).format(2)) + ``` + """ + package = _get_package_owning_module(module_name) + domain = domain_of(package.manifest) + domain_state = _ensure_initialized(domain, package, get_qpy_environment()) + + return Gettext(package, domain, domain_state), lambda message: message + + +def dgettext(domain: str, message: str, /) -> str: + """Translate a message in a manually specified domain. The domain must already have been initialized.""" + domain = GettextDomain(domain) + request_state = _require_request_state(domain) + return request_state.translations.gettext(message) + + +def dpgettext(domain: str, context: str, message: str, /) -> str: + """Translate a message in the given context in a manually specified domain. + + The domain must already have been initialized. + """ + domain = GettextDomain(domain) + request_state = _require_request_state(domain) + return request_state.translations.pgettext(context, message) + + +def dnpgettext(domain: str, context: str, singular: str, plural: str, n: int, /) -> str: + """Translate a message in the given context in a manually specified domain, accounting for plural forms. + + The domain must already have been initialized. + """ + domain = GettextDomain(domain) + request_state = _require_request_state(domain) + return request_state.translations.npgettext(context, singular, plural, n) + + +_i18n_state: ContextVar[dict[GettextDomain, _DomainState]] = ContextVar("_i18n_state") + +_log = logging.getLogger(__name__) + +_NULL_TRANSLATIONS = NullTranslations() + + def _build_translations(mos: list[Traversable]) -> NullTranslations: if not mos: return _NULL_TRANSLATIONS @@ -76,15 +319,6 @@ def _get_available_mos(package: Package) -> dict[Bcp47LanguageTag, Traversable]: return result -def _require_request_user() -> RequestUser: - env = get_qpy_environment() - if not env.request_user: - msg = "No request is currently being processed." - raise RuntimeError(msg) - - return env.request_user - - def _require_request_state(domain: GettextDomain, domain_state: _DomainState | None = None) -> _RequestState: if not domain_state: domain_state = _i18n_state.get({}).get(domain) @@ -92,30 +326,18 @@ def _require_request_state(domain: GettextDomain, domain_state: _DomainState | N msg = f"i18n domain '{domain}' was not initialized. Are you sure the corresponding package is loaded?" raise RuntimeError(msg) - request_user = _require_request_user() - if not domain_state.request_state or domain_state.request_state.user != request_user: + env = get_qpy_environment() + if not env.request_user: + msg = "No request is currently being processed." + raise RuntimeError(msg) + + if not domain_state.request_state or domain_state.request_state.user != env.request_user: msg = f"i18n domain '{domain}' was not initialized for the current request." raise RuntimeError(msg) return domain_state.request_state -def get_translations_of_package(package: Package) -> NullTranslations: - """Get the current i18n state of the worker.""" - domain = domain_of(package.manifest) - domain_state = _ensure_initialized(domain, package, get_qpy_environment()) - request_state = _require_request_state(domain, domain_state) - return request_state.translations - - -def get_primary_language(package: Package) -> Bcp47LanguageTag: - domain = domain_of(package.manifest) - domain_state = _i18n_state.get({}).get(domain) - if domain_state: - return _require_request_state(domain, domain_state).primary_lang - return package.manifest.languages[0] - - def _get_package_owning_module(module_name: str) -> Package: # TODO: Dedupe when #152 is in dev. try: @@ -230,180 +452,3 @@ def format_map(self, mapping: Mapping[str, object]) -> TranslatableString: self._getter, (*self._transformations_on_result, (lambda s: s.format_map(mapping))), ) - - -class _Gettext: - """Translate the given message.""" - - def __init__(self, package: Package, domain: GettextDomain, domain_state: _DomainState) -> None: - self._package = package - self._domain = domain - self._domain_state = domain_state - - @overload - def __call__(self, message: str, /, *, defer: None = None) -> str | TranslatableString: ... - - @overload - def __call__(self, message: str, /, *, defer: Literal[True]) -> TranslatableString: ... - - @overload - def __call__(self, message: str, /, *, defer: Literal[False]) -> str: ... - - def __call__(self, message: str, /, *, defer: bool | None = None) -> str | TranslatableString: - """Translate the given message. - - Args: - message: Gettext `msgid`. This should also be the message in its primary language, usually english. - defer: By default, translation is deferred only when no request is being processed at the time of the - `gettext` call. This parameter can be explicitly set to `True` to force deferral or to `False` to never - defer translations. In the latter case, calling `gettext` before a request is processed - (e.g. during init) will raise an error. - """ - return self._maybe_defer(message, lambda trans: trans.gettext(message), defer=defer) - - @overload - def ngettext(self, singular: str, plural: str, n: int, /, *, defer: None = None) -> str | TranslatableString: ... - - @overload - def ngettext(self, singular: str, plural: str, n: int, /, *, defer: Literal[True]) -> TranslatableString: ... - - @overload - def ngettext(self, singular: str, plural: str, n: int, /, *, defer: Literal[False]) -> str: ... - - def ngettext(self, singular: str, plural: str, n: int, /, *, defer: bool | None = None) -> str | TranslatableString: - """Translate the given message, accounting for plural forms. - - Args: - singular: Message id in its (english) singular form, used if no plural form exists and `n == 1`. - plural: Message id in its (english) plural form, used if no plural form exists and `n >= 2`. - n: This number is passed through the plural formula of the active catalog to determine which form to use. - defer: By default, translation is deferred only when no request is being processed at the time of the - `gettext` call. This parameter can be explicitly set to `True` to force deferral or to `False` to never - defer translations. In the latter case, calling `gettext` before a request is processed - (e.g. during init) will raise an error. - """ - default_message = singular if n == 1 else plural - return self._maybe_defer(default_message, lambda trans: trans.ngettext(singular, plural, n), defer=defer) - - @overload - def pgettext(self, context: str, message: str, /, *, defer: None = None) -> str | TranslatableString: ... - - @overload - def pgettext(self, context: str, message: str, /, *, defer: Literal[True]) -> TranslatableString: ... - - @overload - def pgettext(self, context: str, message: str, /, *, defer: Literal[False]) -> str: ... - - def pgettext(self, context: str, message: str, /, *, defer: bool | None = None) -> str | TranslatableString: - """Translate the given message in the given context. - - The context allows solving ambiguities where the same message may require different translations depending on - the place it's used. See [the GNU gettext documentation](https://www.gnu.org/software/gettext/manual/html_node/Contexts.html). - - Args: - context: The context within which the message should be scoped. This is also extracted as the `msgctxt` - value. - message: Gettext `msgid`. This should also be the message in its primary language, usually english. - defer: By default, translation is deferred only when no request is being processed at the time of the - `gettext` call. This parameter can be explicitly set to `True` to force deferral or to `False` to never - defer translations. In the latter case, calling `gettext` before a request is processed - (e.g. during init) will raise an error. - """ - return self._maybe_defer(message, lambda trans: trans.pgettext(context, message), defer=defer) - - @overload - def npgettext( - self, context: str, singular: str, plural: str, n: int, /, *, defer: None = None - ) -> str | TranslatableString: ... - - @overload - def npgettext( - self, context: str, singular: str, plural: str, n: int, /, *, defer: Literal[True] - ) -> TranslatableString: ... - - @overload - def npgettext(self, context: str, singular: str, plural: str, n: int, /, *, defer: Literal[False]) -> str: ... - - def npgettext( - self, context: str, singular: str, plural: str, n: int, /, *, defer: bool | None = None - ) -> str | TranslatableString: - """Translate the given message in the given context. - - The context allows solving ambiguities where the same message may require different translations depending on - the place it's used. See [the GNU gettext documentation](https://www.gnu.org/software/gettext/manual/html_node/Contexts.html). - - Args: - context: The context within which the message should be scoped. This is also extracted as the `msgctxt` - value. - singular: Message id in its (english) singular form, used if no plural form exists and `n == 1`. - plural: Message id in its (english) plural form, used if no plural form exists and `n >= 2`. - n: This number is passed through the plural formula of the active catalog to determine which form to use. - defer: By default, translation is deferred only when no request is being processed at the time of the - `gettext` call. This parameter can be explicitly set to `True` to force deferral or to `False` to never - defer translations. In the latter case, calling `gettext` before a request is processed - (e.g. during init) will raise an error. - """ - default_message = singular if n == 1 else plural - return self._maybe_defer( - default_message, lambda trans: trans.npgettext(context, singular, plural, n), defer=defer - ) - - def _maybe_defer( - self, default_message: str, getter: Callable[[NullTranslations], str], *, defer: bool | None - ) -> str | TranslatableString: - if defer is None: - defer = self._domain_state.request_state is None - - if defer: - self._domain_state.logger.debug("Deferring translation of message '%s'.", default_message) - return _DeferredTranslatedMessage(self._domain_state, default_message, getter) - - request_state = _require_request_state(self._domain, self._domain_state) - return getter(request_state.translations) - - -_Noop: TypeAlias = Callable[[str], str] - - -def get_for(module_name: str) -> tuple[_Gettext, _Noop]: - """Initializes i18n for the package owning the given Python module and returns the gettext-family functions. - - Args: - module_name: The Python `__package__` or `__module__` whose domain should be used. - """ - package = _get_package_owning_module(module_name) - domain = domain_of(package.manifest) - domain_state = _ensure_initialized(domain, package, get_qpy_environment()) - - return _Gettext(package, domain, domain_state), lambda message: message - - -def dgettext(domain: str, message: str, /) -> str: - domain = GettextDomain(domain) - request_state = _require_request_state(domain) - return request_state.translations.gettext(message) - - -def dpgettext(domain: str, context: str, message: str, /) -> str: - domain = GettextDomain(domain) - request_state = _require_request_state(domain) - return request_state.translations.pgettext(context, message) - - -def dnpgettext(domain: str, context: str, singular: str, plural: str, n: int, /) -> str: - domain = GettextDomain(domain) - request_state = _require_request_state(domain) - return request_state.translations.npgettext(context, singular, plural, n) - - -__all__ = [ - "DEFAULT_CATEGORY", - "GettextDomain", - "dgettext", - "dnpgettext", - "domain_of", - "dpgettext", - "get_for", - "get_primary_language", - "get_translations_of_package", -] From 932e143ade6f7e6b65a704bd7a02cfb993b3c9ed Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Tue, 25 Feb 2025 18:00:24 +0100 Subject: [PATCH 19/25] refactor(i18n): default loglevel to INFO --- questionpy/i18n.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/questionpy/i18n.py b/questionpy/i18n.py index b68a2e86..80366872 100644 --- a/questionpy/i18n.py +++ b/questionpy/i18n.py @@ -285,6 +285,8 @@ def dnpgettext(domain: str, context: str, singular: str, plural: str, n: int, /) _i18n_state: ContextVar[dict[GettextDomain, _DomainState]] = ContextVar("_i18n_state") _log = logging.getLogger(__name__) +if _log.level == logging.NOTSET: + _log.setLevel(logging.INFO) _NULL_TRANSLATIONS = NullTranslations() From 4832459c7e8dcefda22ad8200eb0596fcc6af23b Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Tue, 25 Feb 2025 18:11:28 +0100 Subject: [PATCH 20/25] refactor(i18n): dedupe _get_package_owning_module now that #152 is in dev --- questionpy/_util.py | 15 ++++++++++----- questionpy/i18n.py | 19 +++---------------- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/questionpy/_util.py b/questionpy/_util.py index c0197ed5..8e1bcc3c 100644 --- a/questionpy/_util.py +++ b/questionpy/_util.py @@ -87,16 +87,21 @@ def get_mro_type_hint(klass: type, attr_name: str, bound: _TypeT) -> _TypeT: return hint -def get_package_by_attempt(attempt: "Attempt") -> Package: - """Returns the package in which the attempt was defined.""" +def get_package_by_python_module(module_name: str) -> Package: + """Returns the package which the given Python module is a part of.""" try: - namespace, short_name, *_ = attempt.__module__.split(".", maxsplit=2) + namespace, short_name, *_ = module_name.split(".", maxsplit=2) env = get_qpy_environment() key = PackageNamespaceAndShortName(namespace=namespace, short_name=short_name) return env.packages[key] except (KeyError, ValueError) as e: msg = ( - "Current package namespace and shortname could not be determined from '__module__' attribute. Please do " - "not modify the '__module__' attribute." + f"Current package namespace and short name could not be determined from module name '{module_name}'. " + f"Please do not modify the '__module__' or '__package__' attributes." ) raise ValueError(msg) from e + + +def get_package_by_attempt(attempt: "Attempt") -> Package: + """Returns the package in which the attempt was defined.""" + return get_package_by_python_module(attempt.__module__) diff --git a/questionpy/i18n.py b/questionpy/i18n.py index 80366872..ea1d73fb 100644 --- a/questionpy/i18n.py +++ b/questionpy/i18n.py @@ -54,6 +54,8 @@ "get_translations_of_package", ] +from questionpy._util import get_package_by_python_module + DEFAULT_CATEGORY = "LC_MESSAGES" GettextDomain = NewType("GettextDomain", str) @@ -248,7 +250,7 @@ def get_for(module_name: str) -> tuple[Gettext, Callable[[str], str]]: print(_.ngettext("One thing", "{} things", 2).format(2)) ``` """ - package = _get_package_owning_module(module_name) + package = get_package_by_python_module(module_name) domain = domain_of(package.manifest) domain_state = _ensure_initialized(domain, package, get_qpy_environment()) @@ -340,21 +342,6 @@ def _require_request_state(domain: GettextDomain, domain_state: _DomainState | N return domain_state.request_state -def _get_package_owning_module(module_name: str) -> Package: - # TODO: Dedupe when #152 is in dev. - try: - namespace, short_name, *_ = module_name.split(".", maxsplit=2) - env = get_qpy_environment() - key = PackageNamespaceAndShortName(namespace=namespace, short_name=short_name) - return env.packages[key] - except (KeyError, ValueError) as e: - msg = ( - "Current package namespace and shortname could not be determined from '__module__' attribute. Please do " - "not modify the '__module__' attribute." - ) - raise ValueError(msg) from e - - def _ensure_initialized(domain: GettextDomain, package: Package, env: Environment) -> _DomainState: states_by_domain = _i18n_state.get(None) if states_by_domain is None: From 37cba5f6319f4e6efd3485d2ff9826aef8ae3794 Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Mon, 3 Mar 2025 15:40:05 +0100 Subject: [PATCH 21/25] docs: add and improve i18n cli help --- questionpy_sdk/commands/i18n/__init__.py | 2 +- questionpy_sdk/commands/i18n/_extract.py | 26 +++++++++++++++--------- questionpy_sdk/commands/i18n/_init.py | 18 +++++++++++++++- questionpy_sdk/commands/i18n/_update.py | 25 +++++++++++++++-------- 4 files changed, 51 insertions(+), 20 deletions(-) diff --git a/questionpy_sdk/commands/i18n/__init__.py b/questionpy_sdk/commands/i18n/__init__.py index 8921bc4b..3b5ae674 100644 --- a/questionpy_sdk/commands/i18n/__init__.py +++ b/questionpy_sdk/commands/i18n/__init__.py @@ -7,7 +7,7 @@ @click.group() def i18n() -> None: - pass + """Manage translations for a package.""" i18n.add_command(extract) diff --git a/questionpy_sdk/commands/i18n/_extract.py b/questionpy_sdk/commands/i18n/_extract.py index 9509c561..ed79c1a4 100644 --- a/questionpy_sdk/commands/i18n/_extract.py +++ b/questionpy_sdk/commands/i18n/_extract.py @@ -12,27 +12,33 @@ @click.command -@click.argument("source", type=click.Path(exists=True, file_okay=False, path_type=Path)) -@click.option("-o", "--output", type=click.Path(dir_okay=False, path_type=Path)) -def extract(source: Path, output: Path | None = None) -> None: +@click.argument("package", type=click.Path(exists=True, file_okay=False, path_type=Path)) +@click.option( + "-o", + "--output", + type=click.Path(dir_okay=False, path_type=Path), + show_default="/locale/..pot", +) +def extract(package: Path, output: Path | None = None) -> None: + """Extract translatable strings from a package source directory to a catalog template (.pot) file.""" # TODO: Support extraction from already-zipped-up packages. - package = PackageSource(source) - domain = domain_of(package.config) + package_source = PackageSource(package) + domain = domain_of(package_source.config) if not output: - output = source / "locale" / f"{domain}.pot" + output = package / "locale" / f"{domain}.pot" output.parent.mkdir(parents=True, exist_ok=True) catalog = babel.messages.Catalog( domain=domain, - project=package.config.identifier, - version=package.config.version, - msgid_bugs_address=package.config.author, + project=package_source.config.identifier, + version=package_source.config.version, + msgid_bugs_address=package_source.config.author, ) for filename, lineno, message, comments, context in extract_from_dir( - source, _BABEL_MAPPING, comment_tags=("TRANSLATORS:",), strip_comment_tags=True + package, _BABEL_MAPPING, comment_tags=("TRANSLATORS:",), strip_comment_tags=True ): catalog.add(message, None, [(filename, lineno)], auto_comments=comments, context=context) diff --git a/questionpy_sdk/commands/i18n/_init.py b/questionpy_sdk/commands/i18n/_init.py index 2822831c..8263c049 100644 --- a/questionpy_sdk/commands/i18n/_init.py +++ b/questionpy_sdk/commands/i18n/_init.py @@ -54,9 +54,25 @@ def _init_in_source_dir(ctx: click.Context, package: PackageSource, locale: Bcp4 @click.command @click.argument("pot_or_package", type=click.Path(exists=True, path_type=Path)) @click.argument("locales", nargs=-1) -@click.option("--force", "-f", is_flag=True) +@click.option( + "--force", + "-f", + is_flag=True, + help="Instead of ignoring or failing languages whose catalogs already exists, this flag will cause them to be " + "overwritten. YOU WILL LOSE ALL TRANSLATIONS IN THOSE FILES. To update a catalog, use the `update` command.", +) @click.pass_context def init(ctx: click.Context, pot_or_package: Path, locales: Collection[Bcp47LanguageTag] = (), *, force: bool) -> None: + """Create new catalogs (.po files) from a template (.pot file). + + POT_OR_PACKAGE can be a package source directory, in which case the template catalog (.pot file) file will be + expected at the default path `/locale/..pot`, or POT_OR_PACKAGE can specify + an explicit template catalog to use. + + LOCALES can be used to specify the languages (in BCP 47 format) for which to create catalogs. When POT_OR_PACKAGE is + a package source directory, LOCALES can be omitted to use all uninitialized the languages listed in the package + config's `languages` field. + """ if pot_or_package.is_file() and pot_or_package.suffix == ".pot": if not locales: msg = "When initializing from an explicit .pot file, you must specify which locales to initialize." diff --git a/questionpy_sdk/commands/i18n/_update.py b/questionpy_sdk/commands/i18n/_update.py index 8ccc16c4..d3d6f343 100644 --- a/questionpy_sdk/commands/i18n/_update.py +++ b/questionpy_sdk/commands/i18n/_update.py @@ -38,14 +38,22 @@ def _update_domain( @click.command() -@click.argument("package_path", type=click.Path(exists=True, file_okay=False, path_type=Path)) -@click.option("-t", "--pot", type=click.Path(exists=True, dir_okay=False, path_type=Path)) -@click.option("-d", "--domain", "only_domain") +@click.argument("package", type=click.Path(exists=True, file_okay=False, path_type=Path)) +@click.option( + "-t", + "--pot", + type=click.Path(exists=True, dir_okay=False, path_type=Path), + help="Use a different catalog template.", + show_default="/locale/..pot", +) +@click.option( + "-d", "--domain", "only_domain", help="Only update catalogs for the given domain instead of all catalogs." +) @click.pass_context def update( - ctx: click.Context, package_path: Path, pot: Path | None = None, only_domain: GettextDomain | None = None + ctx: click.Context, package: Path, pot: Path | None = None, only_domain: GettextDomain | None = None ) -> None: - """Updates .po files from .pot files. + """Update catalogs (.po files) from updated catalog templates (.pot files). \b Examples: @@ -61,12 +69,13 @@ def update( questionpy-sdk i18n update my-package-source-dir/ --domain foo --pot template.pot """ # noqa: D301 (it's a click feature) - package = PackageSource(package_path) + package_source = PackageSource(package) pos_by_domain = { - domain: list(pos) for domain, pos in itertools.groupby(package.discover_po_files(), operator.itemgetter(0)) + domain: list(pos) + for domain, pos in itertools.groupby(package_source.discover_po_files(), operator.itemgetter(0)) } - pots_by_domain = dict(package.discover_pot_files()) + pots_by_domain = dict(package_source.discover_pot_files()) if pot: if not only_domain: # Assume the .pot filename still follows our convention of .pot. From 98d73a2e40c7acfd0b0f559344e36efc874d21e8 Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Tue, 4 Mar 2025 15:16:10 +0100 Subject: [PATCH 22/25] refactor: translate ui parts in _export_attempt --- poetry.lock | 6 +++--- pyproject.toml | 2 +- questionpy/_wrappers/_question.py | 17 ++++++++++++----- questionpy_sdk/webserver/attempt.py | 2 +- .../webserver/question_ui/__init__.py | 13 +++++-------- 5 files changed, 22 insertions(+), 18 deletions(-) diff --git a/poetry.lock b/poetry.lock index 6ad3a2b2..3f4933e4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1572,8 +1572,8 @@ watchdog = "^6.0.0" [package.source] type = "git" url = "https://github.com/questionpy-org/questionpy-server.git" -reference = "95df79c5eee9a8e2313f8e3e1da018df25fbd6ad" -resolved_reference = "95df79c5eee9a8e2313f8e3e1da018df25fbd6ad" +reference = "2c8e0ae7d6e0387db64c86edc439cc39ca8da71a" +resolved_reference = "2c8e0ae7d6e0387db64c86edc439cc39ca8da71a" [[package]] name = "ruff" @@ -1928,4 +1928,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "29b096e58046c6591d144110b607ae355ef4599e2790792360aef24a8ce51444" +content-hash = "918252a8e8f9aea0341433fec7f9abb786cd498dc11e138a19ccf6fc14f852b1" diff --git a/pyproject.toml b/pyproject.toml index db00d02a..9d10133c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ python = "^3.11" aiohttp = "^3.9.3" pydantic = "^2.6.4" PyYAML = "^6.0.1" -questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "95df79c5eee9a8e2313f8e3e1da018df25fbd6ad" } +questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "2c8e0ae7d6e0387db64c86edc439cc39ca8da71a" } jinja2 = "^3.1.3" aiohttp-jinja2 = "^1.6" lxml = {version = "~5.3.0", extras = ["html_clean"]} diff --git a/questionpy/_wrappers/_question.py b/questionpy/_wrappers/_question.py index c2900b41..6b33de8a 100644 --- a/questionpy/_wrappers/_question.py +++ b/questionpy/_wrappers/_question.py @@ -7,6 +7,7 @@ from questionpy import Question, i18n from questionpy._attempt import AttemptProtocol, AttemptScoredProtocol +from questionpy_common import TranslatableString from questionpy_common.api.attempt import AttemptModel, AttemptScoredModel, AttemptStartedModel, AttemptUi from questionpy_common.api.question import QuestionInterface, QuestionModel from questionpy_common.environment import get_qpy_environment @@ -17,6 +18,12 @@ def _get_output_lang() -> Bcp47LanguageTag: return i18n.get_primary_language(get_qpy_environment().main_package) +def _str_or_none(string: str | TranslatableString | None) -> str | None: + if string is None: + return None + return str(string) + + def _export_question(question: Question) -> QuestionModel: return QuestionModel( lang=_get_output_lang(), @@ -36,11 +43,11 @@ def _export_attempt(attempt: AttemptProtocol) -> dict: "lang": _get_output_lang(), "variant": attempt.variant, "ui": AttemptUi( - formulation=attempt.formulation, - general_feedback=attempt.general_feedback, - specific_feedback=attempt.specific_feedback, - right_answer=attempt.right_answer_description, - placeholders=attempt.placeholders, + formulation=str(attempt.formulation), + general_feedback=_str_or_none(attempt.general_feedback), + specific_feedback=_str_or_none(attempt.specific_feedback), + right_answer=_str_or_none(attempt.right_answer_description), + placeholders={key: str(value) for key, value in attempt.placeholders.items()}, css_files=attempt.css_files, javascript_calls=attempt.javascript_calls, files=attempt.files, diff --git a/questionpy_sdk/webserver/attempt.py b/questionpy_sdk/webserver/attempt.py index 8584800c..bd308bae 100644 --- a/questionpy_sdk/webserver/attempt.py +++ b/questionpy_sdk/webserver/attempt.py @@ -40,7 +40,7 @@ def get_attempt_render_context( ) -> _AttemptRenderContext: renderer_args = (attempt.ui.placeholders, display_options, seed, last_attempt_data) - html, errors = QuestionFormulationUIRenderer(str(attempt.ui.formulation), *renderer_args).render() + html, errors = QuestionFormulationUIRenderer(attempt.ui.formulation, *renderer_args).render() context: _AttemptRenderContext = { "attempt_status": ( diff --git a/questionpy_sdk/webserver/question_ui/__init__.py b/questionpy_sdk/webserver/question_ui/__init__.py index 7f9ab3e3..5acd59a0 100644 --- a/questionpy_sdk/webserver/question_ui/__init__.py +++ b/questionpy_sdk/webserver/question_ui/__init__.py @@ -5,7 +5,7 @@ import re from random import Random -from typing import TYPE_CHECKING, Any +from typing import Any import lxml.html import lxml.html.clean @@ -26,9 +26,6 @@ XMLSyntaxError, ) -if TYPE_CHECKING: - from questionpy_common import TranslatableString - _XHTML_NAMESPACE: str = "http://www.w3.org/1999/xhtml" _QPY_NAMESPACE: str = "http://questionpy.org/ns/question" @@ -202,7 +199,7 @@ class QuestionUIRenderer: def __init__( self, xml: str, - placeholders: dict[str, str | TranslatableString], + placeholders: dict[str, str], options: QuestionDisplayOptions, seed: int | None = None, attempt: dict | None = None, @@ -293,7 +290,7 @@ def _resolve_placeholders(self) -> None: if clean_option == "plain": # Treat the value as plain text. - _add_text_before(p_instruction, str(raw_value)) + _add_text_before(p_instruction, raw_value) else: # html.clean works on different element classes than etree, so we need to use different parse functions. # Since the HTML elements are subclasses of the etree elements though, we can reuse them without dumping @@ -525,7 +522,7 @@ class QuestionFormulationUIRenderer(QuestionUIRenderer): def __init__( self, xml: str, - placeholders: dict[str, str | TranslatableString], + placeholders: dict[str, str], options: QuestionDisplayOptions, seed: int | None = None, attempt: dict | None = None, @@ -572,7 +569,7 @@ class _RenderErrorCollector: def __init__( self, xml: str, - placeholders: dict[str, str | TranslatableString], + placeholders: dict[str, str], ) -> None: self.errors = RenderErrorCollection() From 12cb709593bf007b7a8be319e45e5a1da6b9c9a2 Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Tue, 4 Mar 2025 15:55:06 +0100 Subject: [PATCH 23/25] refactor: disallow TranslatableStrings in form element and condition values --- poetry.lock | 6 +++--- pyproject.toml | 2 +- questionpy/form/_dsl.py | 12 +++++------- questionpy/form/_model.py | 2 +- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/poetry.lock b/poetry.lock index 3f4933e4..a827ed78 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1572,8 +1572,8 @@ watchdog = "^6.0.0" [package.source] type = "git" url = "https://github.com/questionpy-org/questionpy-server.git" -reference = "2c8e0ae7d6e0387db64c86edc439cc39ca8da71a" -resolved_reference = "2c8e0ae7d6e0387db64c86edc439cc39ca8da71a" +reference = "e76d8e6b24ebac872e94e19928c0785f4a84608b" +resolved_reference = "e76d8e6b24ebac872e94e19928c0785f4a84608b" [[package]] name = "ruff" @@ -1928,4 +1928,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "918252a8e8f9aea0341433fec7f9abb786cd498dc11e138a19ccf6fc14f852b1" +content-hash = "169d3054d1c659d7ab8f93f8e47190064b9ad58e55758d9585b738418fe7bb2a" diff --git a/pyproject.toml b/pyproject.toml index 9d10133c..19e49f0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ python = "^3.11" aiohttp = "^3.9.3" pydantic = "^2.6.4" PyYAML = "^6.0.1" -questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "2c8e0ae7d6e0387db64c86edc439cc39ca8da71a" } +questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "e76d8e6b24ebac872e94e19928c0785f4a84608b" } jinja2 = "^3.1.3" aiohttp-jinja2 = "^1.6" lxml = {version = "~5.3.0", extras = ["html_clean"]} diff --git a/questionpy/form/_dsl.py b/questionpy/form/_dsl.py index 23aacb92..5cbc948f 100644 --- a/questionpy/form/_dsl.py +++ b/questionpy/form/_dsl.py @@ -582,9 +582,7 @@ def select( ) -def option( - label: str | TranslatableString, *, selected: bool = False, value: str | TranslatableString | None = None -) -> _OptionInfo: +def option(label: str | TranslatableString, *, selected: bool = False, value: str | None = None) -> _OptionInfo: """Adds an option to an [`OptionEnum`][questionpy.form.OptionEnum]. Args: @@ -625,7 +623,7 @@ def hidden(value: _S, *, disable_if: _ZeroOrMoreConditions = None, hide_if: _Zer """Adds a hidden element with a fixed value. Args: - value (str | TranslatableString): Fixed value. + value (str): Fixed value. disable_if (Condition | list[Condition] | None): Disable this element if some condition(s) match. hide_if (Condition | list[Condition] | None): Hide this element if some condition(s) match. @@ -838,7 +836,7 @@ def is_not_checked(name: str) -> IsNotChecked: return IsNotChecked(name=name) -def equals(name: str, *, value: str | TranslatableString | int | bool) -> Equals: +def equals(name: str, *, value: str | int | bool) -> Equals: """Condition on the value of another field being equal to some static value. Many elements can be hidden or disabled client-side by passing conditions to `hide_if` or `disable_if`. See the @@ -862,7 +860,7 @@ def equals(name: str, *, value: str | TranslatableString | int | bool) -> Equals return Equals(name=name, value=value) -def does_not_equal(name: str, *, value: str | TranslatableString | int | bool) -> DoesNotEqual: +def does_not_equal(name: str, *, value: str | int | bool) -> DoesNotEqual: """Condition on the value of another field *not* being equal to some static value. Many elements can be hidden or disabled client-side by passing conditions to `hide_if` or `disable_if`. See the @@ -890,7 +888,7 @@ def does_not_equal(name: str, *, value: str | TranslatableString | int | bool) - return DoesNotEqual(name=name, value=value) -def is_in(name: str, values: list[str | TranslatableString | int | bool]) -> In: +def is_in(name: str, values: list[str | int | bool]) -> In: """Condition on the value of another field being one of a number of static values. Many elements can be hidden or disabled client-side by passing conditions to `hide_if` or `disable_if`. See the diff --git a/questionpy/form/_model.py b/questionpy/form/_model.py index ee6fd217..522e56b0 100644 --- a/questionpy/form/_model.py +++ b/questionpy/form/_model.py @@ -20,7 +20,7 @@ class _OptionInfo: label: str | TranslatableString selected: bool - value: str | TranslatableString | None = None + value: str | None = None """If None, set to the name by `OptionEnum.__init__` because __set_name__ doesn't get called for enum members.""" From 409bfe5796840ebecae3ad32786c451b1337cbea Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Tue, 4 Mar 2025 17:44:57 +0100 Subject: [PATCH 24/25] refactor: rethink i18n function names --- examples/i18n/locale/de.po | 53 +++++++++--------- examples/i18n/locale/en.po | 57 ++++++++++---------- examples/i18n/locale/local.i18n.pot | 57 ++++++++++---------- examples/i18n/python/local/i18n/__init__.py | 40 ++++++++------ examples/i18n/templates/formulation.xhtml.j2 | 13 +++-- questionpy/_ui.py | 22 ++++++++ questionpy/i18n.py | 52 +++++++++++++----- questionpy_sdk/commands/i18n/_extract.py | 27 +++++++++- 8 files changed, 202 insertions(+), 119 deletions(-) diff --git a/examples/i18n/locale/de.po b/examples/i18n/locale/de.po index c61d4734..4fa23e06 100644 --- a/examples/i18n/locale/de.po +++ b/examples/i18n/locale/de.po @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: @local/i18n 0.1.0\n" "Report-Msgid-Bugs-To: Jane Doe \n" -"POT-Creation-Date: 2025-02-24 15:42+0100\n" -"PO-Revision-Date: 2025-02-24 15:56+0100\n" +"POT-Creation-Date: 2025-03-04 17:05+0100\n" +"PO-Revision-Date: 2025-03-04 17:40+0100\n" "Last-Translator: \n" "Language-Team: de \n" "Language: de\n" @@ -20,29 +20,35 @@ msgstr "" "Generated-By: Babel 2.17.0\n" "X-Generator: Poedit 3.5\n" -#: python/local/i18n/__init__.py:14 +#: python/local/i18n/__init__.py:11 +msgid "I'm marked using a no-op function and later dynamically translated." +msgstr "" +"Ich bin mit einer no-op-Funktion markiert und werde später dynamisch " +"übersetzt." + +#: python/local/i18n/__init__.py:15 msgid "Status of the file" msgstr "Status der Datei" -#: python/local/i18n/__init__.py:14 +#: python/local/i18n/__init__.py:15 msgctxt "File" msgid "Opened" msgstr "Geöffnet" -#: python/local/i18n/__init__.py:16 +#: python/local/i18n/__init__.py:17 msgid "Status of the amusement park" msgstr "Status des Freizeitparks" -#: python/local/i18n/__init__.py:16 +#: python/local/i18n/__init__.py:17 msgctxt "Amusement Park" msgid "Opened" msgstr "Eröffnet" -#: python/local/i18n/__init__.py:20 +#: python/local/i18n/__init__.py:21 msgid "npgettext combines pluralization support and contextualization." msgstr "npgettext erlaubt sowohl Pluralisierung als auch Kontextualisierung." -#: python/local/i18n/__init__.py:21 +#: python/local/i18n/__init__.py:22 #, python-brace-format msgctxt "Home Depot" msgid "There is a light." @@ -50,7 +56,7 @@ msgid_plural "There are {} lights!" msgstr[0] "Da ist eine Lampe." msgstr[1] "Da sind {} Lampen!" -#: python/local/i18n/__init__.py:23 +#: python/local/i18n/__init__.py:24 #, python-brace-format msgctxt "Picard" msgid "There is a light." @@ -58,24 +64,24 @@ msgid_plural "There are {} lights!" msgstr[0] "Da ist ein Licht." msgstr[1] "Da sind {} Lichter!" -#: python/local/i18n/__init__.py:27 +#: python/local/i18n/__init__.py:28 msgid "First Option" msgstr "Erste Option" -#: python/local/i18n/__init__.py:28 +#: python/local/i18n/__init__.py:29 msgid "Second Option" msgstr "Zweite Option" -#: python/local/i18n/__init__.py:29 +#: python/local/i18n/__init__.py:30 msgid "Third Option" msgstr "Dritte Option" #. This comment will be shown in the .pot file. -#: python/local/i18n/__init__.py:35 +#: python/local/i18n/__init__.py:36 msgid "This is a localized FormModel." msgstr "Das ist ein lokalisiertes FormModel." -#: python/local/i18n/__init__.py:36 +#: python/local/i18n/__init__.py:37 msgid "" "The FormModel is defined before any initialization, so message translation " "needs to be deferred!" @@ -83,38 +89,34 @@ msgstr "" "Das FormModel wird vor jeder Initialisierung definiert, deshalb müssen " "Übersetzungen später geschehen!" -#: python/local/i18n/__init__.py:40 +#: python/local/i18n/__init__.py:41 msgid "This is a text_input, and its label is localized!" msgstr "Das ist ein text_input, und sein Label ist lokalisiert!" -#: python/local/i18n/__init__.py:42 +#: python/local/i18n/__init__.py:43 msgid "Even this placeholder is localized!" msgstr "Selbst dieser Platzhalter ist lokalisiert!" -#: python/local/i18n/__init__.py:43 +#: python/local/i18n/__init__.py:44 msgid "Now you can get help in your language!" msgstr "Jetzt kannst du in deiner Sprache Hilfe erhalten!" -#: python/local/i18n/__init__.py:47 +#: python/local/i18n/__init__.py:48 #, python-brace-format msgid "Choose exactly one option." msgid_plural "Choose exactly {} options." msgstr[0] "Wähle genau eine Option." msgstr[1] "Wähle genau {} Optionen." -#: python/local/i18n/__init__.py:50 +#: python/local/i18n/__init__.py:51 msgid "This section shows contextualized messages." msgstr "Dieser Abschnitt zeigt kontextualisierte Übersetzungen." -#: python/local/i18n/__init__.py:63 +#: python/local/i18n/__init__.py:64 msgid "Programmatic translation!" msgstr "Programmatische Übersetzung!" #. This comment will be shown in the .pot file. -#: templates/formulation.xhtml.j2:4 -msgid "This string should be localized!" -msgstr "Diese Zeichenkette sollte lokalisiert sein!" - #: templates/formulation.xhtml.j2:5 msgid "One Thing" msgid_plural "More Things" @@ -144,3 +146,6 @@ msgid "One Thing" msgid_plural "More Things" msgstr[0] "Eine Sache in Kontext 2" msgstr[1] "Mehrere Sachen in Kontext 2" + +#~ msgid "This string should be localized!" +#~ msgstr "Diese Zeichenkette sollte lokalisiert sein!" diff --git a/examples/i18n/locale/en.po b/examples/i18n/locale/en.po index bdfcf1aa..65c1116f 100644 --- a/examples/i18n/locale/en.po +++ b/examples/i18n/locale/en.po @@ -8,40 +8,45 @@ msgid "" msgstr "" "Project-Id-Version: @local/i18n 0.1.0\n" "Report-Msgid-Bugs-To: Jane Doe \n" -"POT-Creation-Date: 2025-02-24 15:42+0100\n" -"PO-Revision-Date: 2025-02-24 15:34+0100\n" -"Last-Translator: FULL NAME \n" -"Language: en\n" +"POT-Creation-Date: 2025-03-04 17:05+0100\n" +"PO-Revision-Date: 2025-03-04 17:15+0100\n" +"Last-Translator: \n" "Language-Team: en \n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"Language: en\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Generated-By: Babel 2.17.0\n" +"X-Generator: Poedit 3.5\n" -#: python/local/i18n/__init__.py:14 +#: python/local/i18n/__init__.py:11 +msgid "I'm marked using a no-op function and later dynamically translated." +msgstr "" + +#: python/local/i18n/__init__.py:15 msgid "Status of the file" msgstr "" -#: python/local/i18n/__init__.py:14 +#: python/local/i18n/__init__.py:15 msgctxt "File" msgid "Opened" msgstr "" -#: python/local/i18n/__init__.py:16 +#: python/local/i18n/__init__.py:17 msgid "Status of the amusement park" msgstr "" -#: python/local/i18n/__init__.py:16 +#: python/local/i18n/__init__.py:17 msgctxt "Amusement Park" msgid "Opened" msgstr "" -#: python/local/i18n/__init__.py:20 +#: python/local/i18n/__init__.py:21 msgid "npgettext combines pluralization support and contextualization." msgstr "" -#: python/local/i18n/__init__.py:21 +#: python/local/i18n/__init__.py:22 #, python-brace-format msgctxt "Home Depot" msgid "There is a light." @@ -49,7 +54,7 @@ msgid_plural "There are {} lights!" msgstr[0] "" msgstr[1] "" -#: python/local/i18n/__init__.py:23 +#: python/local/i18n/__init__.py:24 #, python-brace-format msgctxt "Picard" msgid "There is a light." @@ -57,61 +62,57 @@ msgid_plural "There are {} lights!" msgstr[0] "" msgstr[1] "" -#: python/local/i18n/__init__.py:27 +#: python/local/i18n/__init__.py:28 msgid "First Option" msgstr "" -#: python/local/i18n/__init__.py:28 +#: python/local/i18n/__init__.py:29 msgid "Second Option" msgstr "" -#: python/local/i18n/__init__.py:29 +#: python/local/i18n/__init__.py:30 msgid "Third Option" msgstr "" #. This comment will be shown in the .pot file. -#: python/local/i18n/__init__.py:35 +#: python/local/i18n/__init__.py:36 msgid "This is a localized FormModel." msgstr "" -#: python/local/i18n/__init__.py:36 +#: python/local/i18n/__init__.py:37 msgid "" "The FormModel is defined before any initialization, so message " "translation needs to be deferred!" msgstr "" -#: python/local/i18n/__init__.py:40 +#: python/local/i18n/__init__.py:41 msgid "This is a text_input, and its label is localized!" msgstr "" -#: python/local/i18n/__init__.py:42 +#: python/local/i18n/__init__.py:43 msgid "Even this placeholder is localized!" msgstr "" -#: python/local/i18n/__init__.py:43 +#: python/local/i18n/__init__.py:44 msgid "Now you can get help in your language!" msgstr "" -#: python/local/i18n/__init__.py:47 +#: python/local/i18n/__init__.py:48 #, python-brace-format msgid "Choose exactly one option." msgid_plural "Choose exactly {} options." msgstr[0] "" msgstr[1] "" -#: python/local/i18n/__init__.py:50 +#: python/local/i18n/__init__.py:51 msgid "This section shows contextualized messages." msgstr "" -#: python/local/i18n/__init__.py:63 +#: python/local/i18n/__init__.py:64 msgid "Programmatic translation!" msgstr "" #. This comment will be shown in the .pot file. -#: templates/formulation.xhtml.j2:4 -msgid "This string should be localized!" -msgstr "" - #: templates/formulation.xhtml.j2:5 msgid "One Thing" msgid_plural "More Things" @@ -129,7 +130,6 @@ msgid "My Message" msgstr "" #: templates/formulation.xhtml.j2:10 templates/formulation.xhtml.j2:11 -#, fuzzy msgctxt "Context 1" msgid "One Thing" msgid_plural "More Things" @@ -137,7 +137,6 @@ msgstr[0] "" msgstr[1] "" #: templates/formulation.xhtml.j2:12 templates/formulation.xhtml.j2:13 -#, fuzzy msgctxt "Context 2" msgid "One Thing" msgid_plural "More Things" diff --git a/examples/i18n/locale/local.i18n.pot b/examples/i18n/locale/local.i18n.pot index ffe69b2e..ae0bd229 100644 --- a/examples/i18n/locale/local.i18n.pot +++ b/examples/i18n/locale/local.i18n.pot @@ -9,7 +9,7 @@ msgid "" msgstr "" "Project-Id-Version: @local/i18n 0.1.0\n" "Report-Msgid-Bugs-To: Jane Doe \n" -"POT-Creation-Date: 2025-02-24 15:42+0100\n" +"POT-Creation-Date: 2025-03-04 17:37+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,29 +18,33 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.17.0\n" -#: python/local/i18n/__init__.py:14 +#: python/local/i18n/__init__.py:11 +msgid "I'm marked using a no-op function and later dynamically translated." +msgstr "" + +#: python/local/i18n/__init__.py:15 msgid "Status of the file" msgstr "" -#: python/local/i18n/__init__.py:14 +#: python/local/i18n/__init__.py:15 msgctxt "File" msgid "Opened" msgstr "" -#: python/local/i18n/__init__.py:16 +#: python/local/i18n/__init__.py:17 msgid "Status of the amusement park" msgstr "" -#: python/local/i18n/__init__.py:16 +#: python/local/i18n/__init__.py:17 msgctxt "Amusement Park" msgid "Opened" msgstr "" -#: python/local/i18n/__init__.py:20 +#: python/local/i18n/__init__.py:21 msgid "npgettext combines pluralization support and contextualization." msgstr "" -#: python/local/i18n/__init__.py:21 +#: python/local/i18n/__init__.py:22 #, python-brace-format msgctxt "Home Depot" msgid "There is a light." @@ -48,7 +52,7 @@ msgid_plural "There are {} lights!" msgstr[0] "" msgstr[1] "" -#: python/local/i18n/__init__.py:23 +#: python/local/i18n/__init__.py:24 #, python-brace-format msgctxt "Picard" msgid "There is a light." @@ -56,85 +60,80 @@ msgid_plural "There are {} lights!" msgstr[0] "" msgstr[1] "" -#: python/local/i18n/__init__.py:27 +#: python/local/i18n/__init__.py:28 msgid "First Option" msgstr "" -#: python/local/i18n/__init__.py:28 +#: python/local/i18n/__init__.py:29 msgid "Second Option" msgstr "" -#: python/local/i18n/__init__.py:29 +#: python/local/i18n/__init__.py:30 msgid "Third Option" msgstr "" #. This comment will be shown in the .pot file. -#: python/local/i18n/__init__.py:35 +#: python/local/i18n/__init__.py:36 msgid "This is a localized FormModel." msgstr "" -#: python/local/i18n/__init__.py:36 +#: python/local/i18n/__init__.py:37 msgid "" "The FormModel is defined before any initialization, so message " "translation needs to be deferred!" msgstr "" -#: python/local/i18n/__init__.py:40 +#: python/local/i18n/__init__.py:41 msgid "This is a text_input, and its label is localized!" msgstr "" -#: python/local/i18n/__init__.py:42 +#: python/local/i18n/__init__.py:43 msgid "Even this placeholder is localized!" msgstr "" -#: python/local/i18n/__init__.py:43 +#: python/local/i18n/__init__.py:44 msgid "Now you can get help in your language!" msgstr "" -#: python/local/i18n/__init__.py:47 +#: python/local/i18n/__init__.py:48 #, python-brace-format msgid "Choose exactly one option." msgid_plural "Choose exactly {} options." msgstr[0] "" msgstr[1] "" -#: python/local/i18n/__init__.py:50 +#: python/local/i18n/__init__.py:51 msgid "This section shows contextualized messages." msgstr "" -#: python/local/i18n/__init__.py:63 +#: python/local/i18n/__init__.py:64 msgid "Programmatic translation!" msgstr "" #. This comment will be shown in the .pot file. -#: templates/formulation.xhtml.j2:4 +#: templates/formulation.xhtml.j2:3 msgid "This string should be localized!" msgstr "" -#: templates/formulation.xhtml.j2:5 +#: templates/formulation.xhtml.j2:4 msgid "One Thing" msgid_plural "More Things" msgstr[0] "" msgstr[1] "" -#: templates/formulation.xhtml.j2:7 +#: templates/formulation.xhtml.j2:6 msgctxt "Context 1" msgid "My Message" msgstr "" -#: templates/formulation.xhtml.j2:7 -msgctxt "Context 2" -msgid "My Message" -msgstr "" - -#: templates/formulation.xhtml.j2:10 templates/formulation.xhtml.j2:11 +#: templates/formulation.xhtml.j2:9 msgctxt "Context 1" msgid "One Thing" msgid_plural "More Things" msgstr[0] "" msgstr[1] "" -#: templates/formulation.xhtml.j2:12 templates/formulation.xhtml.j2:13 +#: templates/formulation.xhtml.j2:12 msgctxt "Context 2" msgid "One Thing" msgid_plural "More Things" diff --git a/examples/i18n/python/local/i18n/__init__.py b/examples/i18n/python/local/i18n/__init__.py index 309166f1..84894380 100644 --- a/examples/i18n/python/local/i18n/__init__.py +++ b/examples/i18n/python/local/i18n/__init__.py @@ -6,47 +6,49 @@ from questionpy_common import TranslatableString from questionpy_common.elements import StaticTextElement -_, _N = i18n.get_for(__package__) +__ = i18n.get_for(__package__) + +dynamic_message = __.gettext_noop("I'm marked using a no-op function and later dynamically translated.") class ContextSection(FormModel): - file_status: StaticTextElement = static_text(_("Status of the file"), _.pgettext("File", "Opened")) + file_status: StaticTextElement = static_text(__("Status of the file"), __.pgettext("File", "Opened")) amusement_park_status: StaticTextElement = static_text( - _("Status of the amusement park"), _.pgettext("Amusement Park", "Opened") + __("Status of the amusement park"), __.pgettext("Amusement Park", "Opened") ) home_depot: bool = checkbox( - left_label=_("npgettext combines pluralization support and contextualization."), - right_label=_.npgettext("Home Depot", "There is a light.", "There are {} lights!", 74).format(74), + left_label=__("npgettext combines pluralization support and contextualization."), + right_label=__.npgettext("Home Depot", "There is a light.", "There are {} lights!", 74).format(74), ) - picard: bool = checkbox(right_label=_.npgettext("Picard", "There is a light.", "There are {} lights!", 4).format(4)) + picard: bool = checkbox(right_label=__.np("Picard", "There is a light.", "There are {} lights!", 4).format(4)) class MyOptions(OptionEnum): - FIRST = option(_("First Option")) - SECOND = option(_("Second Option")) - THIRD = option(_("Third Option")) + FIRST = option(__("First Option")) + SECOND = option(__("Second Option")) + THIRD = option(__("Third Option")) class I18NModel(FormModel): txt: StaticTextElement = static_text( # TRANSLATORS: This comment will be shown in the .pot file. - _("This is a localized FormModel."), - _("The FormModel is defined before any initialization, so message translation needs to be deferred!"), + __("This is a localized FormModel."), + __("The FormModel is defined before any initialization, so message translation needs to be deferred!"), ) input: str = text_input( - _("This is a text_input, and its label is localized!"), + __("This is a text_input, and its label is localized!"), required=True, - placeholder=_("Even this placeholder is localized!"), - help=_("Now you can get help in your language!"), + placeholder=__("Even this placeholder is localized!"), + help=__("Now you can get help in your language!"), ) radio_group: MyOptions = radio_group( - _.ngettext("Choose exactly one option.", "Choose exactly {} options.", 1).format(1), MyOptions, required=True + __.ngettext("Choose exactly one option.", "Choose exactly {} options.", 1).format(1), MyOptions, required=True ) - context: ContextSection = section(_("This section shows contextualized messages."), ContextSection) + context: ContextSection = section(__("This section shows contextualized messages."), ContextSection) class I18NAttempt(Attempt): @@ -59,7 +61,11 @@ def formulation(self) -> str: @property def general_feedback(self) -> str | TranslatableString | None: - return "
" + _("Programmatic translation!", defer=False) + "
" + return "
" + __("Programmatic translation!", defer=False) + "
" + + @property + def specific_feedback(self) -> str | TranslatableString | None: + return "
" + __(dynamic_message, defer=False) + "
" class I18NQuestion(Question): diff --git a/examples/i18n/templates/formulation.xhtml.j2 b/examples/i18n/templates/formulation.xhtml.j2 index fda323aa..6281a1ad 100644 --- a/examples/i18n/templates/formulation.xhtml.j2 +++ b/examples/i18n/templates/formulation.xhtml.j2 @@ -1,15 +1,14 @@ -
+
{# TRANSLATORS: This comment will be shown in the .pot file. #} -

gettext: {{ _("This string should be localized!") }}

-

ngettext: {{ ngettext("One Thing", "More Things", 1) }}, {{ ngettext("One Thing", "More Things", 99) }} +

gettext: {{ __("This string should be localized!") }}

+

ngettext: {{ ngettext("One Thing", "More Things", 1) }}, {{ __.n("One Thing", "More Things", 99) }}

-

pgettext: {{ pgettext("Context 1", "My Message") }}, {{ pgettext("Context 2", "My Message") }}

+

pgettext: {{ pgettext("Context 1", "My Message") }}, {{ __.p("Context 2", "My Message") }}

npgettext

  • {{ npgettext("Context 1", "One Thing", "More Things", 1) }}
  • -
  • {{ npgettext("Context 1", "One Thing", "More Things", 2) }}
  • -
  • {{ npgettext("Context 2", "One Thing", "More Things", 1) }}
  • +
  • {{ __.npgettext("Context 1", "One Thing", "More Things", 2) }}
  • +
  • {{ __.np("Context 2", "One Thing", "More Things", 1) }}
  • {{ npgettext("Context 2", "One Thing", "More Things", 2) }}
diff --git a/questionpy/_ui.py b/questionpy/_ui.py index 9c177cc2..a45a9170 100644 --- a/questionpy/_ui.py +++ b/questionpy/_ui.py @@ -59,6 +59,27 @@ def _get_loader(package: Package) -> jinja2.BaseLoader | None: return _TraversableTemplateLoader(templates_directory) +def _jinja_call_proxy(name: str) -> staticmethod: + # Inspired by jinja2.ext._gettext_alias. + @jinja2.pass_context + def function(__context: jinja2.runtime.Context, /, *args: object, **kwargs: object) -> object: + return __context.call(__context.resolve(name), *args, **kwargs) + + return staticmethod(function) + + +class _Jinja2Gettext: + """Provides an interface compatible with both our preferred Python convention and the Jinja extension's defaults. + + We proxy to Jinja's functions instead of our own because Jinja has built-in formatting. + """ + + __call__ = gettext = _jinja_call_proxy("gettext") + n = ngettext = _jinja_call_proxy("ngettext") + p = pgettext = _jinja_call_proxy("pgettext") + np = npgettext = _jinja_call_proxy("npgettext") + + def create_jinja2_environment(attempt: "Attempt", question: "Question") -> jinja2.Environment: """Creates a Jinja2 environment with sensible default configuration. @@ -96,5 +117,6 @@ def create_jinja2_environment(attempt: "Attempt", question: "Question") -> jinja translations = i18n.get_translations_of_package(package) env.add_extension("jinja2.ext.i18n") env.install_gettext_translations(translations, newstyle=True) # type: ignore[attr-defined] + env.globals["__"] = _Jinja2Gettext() return env diff --git a/questionpy/i18n.py b/questionpy/i18n.py index ea1d73fb..b38b3789 100644 --- a/questionpy/i18n.py +++ b/questionpy/i18n.py @@ -76,7 +76,9 @@ class _DomainState: request_state: _RequestState | None = None -class Gettext: +class Gettext: # noqa: PLR0904 (ruff seems to count each overload separately) + """Container for gettext-family functions. Usually called `__`. See [i18n.get_for][questionpy.i18n.get_for].""" + def __init__(self, package: Package, domain: GettextDomain, domain_state: _DomainState) -> None: self._package = package self._domain = domain @@ -194,6 +196,35 @@ def npgettext( default_message, lambda trans: trans.npgettext(context, singular, plural, n), defer=defer ) + gettext = __call__ + """Alias of [`__(message)`][questionpy.i18n.Gettext.__call__].""" + n = ngettext + """Alias of [`__.ngettext`][questionpy.i18n.Gettext.ngettext].""" + p = pgettext + """Alias of [`__.pgettext`][questionpy.i18n.Gettext.pgettext].""" + np = npgettext + """Alias of [`__.npgettext`][questionpy.i18n.Gettext.npgettext].""" + + @staticmethod + def gettext_noop(message: str, /) -> str: + """Mark a regular message for translation.""" + return message + + @staticmethod + def ngettext_noop(singular: str, plural: str, n: int, /) -> tuple[str, str, int]: + """Mark a pluralizable message for translation.""" + return singular, plural, n + + @staticmethod + def pgettext_noop(context: str, message: str, /) -> tuple[str, str]: + """Mark a contextualized message for translation.""" + return context, message + + @staticmethod + def npgettext_noop(context: str, singular: str, plural: str, n: int) -> tuple[str, str, str, int]: + """Mark a both pluralizable and contextualized message for translation.""" + return context, singular, plural, n + def _maybe_defer( self, default_message: str, getter: Callable[[NullTranslations], str], *, defer: bool | None ) -> str | TranslatableString: @@ -230,31 +261,28 @@ def get_primary_language(package: Package) -> Bcp47LanguageTag: return package.manifest.languages[0] -def get_for(module_name: str) -> tuple[Gettext, Callable[[str], str]]: +def get_for(module_name: str) -> Gettext: """Initializes i18n for the package owning the given Python module and returns the gettext-family functions. Args: module_name: The Python `__package__` or `__module__` whose domain should be used. - Returns: - _: (usually assigned to `_`) the main translation function. Compatible with the standard library's - [gettext.gettext][]. The other gettext-family functions are available as methods on `gettext`: `.ngettext`, - `.pgettext` and `.npgettext`. - _N: A function which just returns the passed-in message untranslated. Useful for marking a message as - translatable without translating it at that time. + Returns: The main translation function, usually assigned to `__`. Compatible with the standard library's + [gettext.gettext][]. The other gettext-family functions are available as methods on `gettext`: `.n`, `.p` and + `.np`. Example: ```py - _, _N = i18n.get_for(__package__) - print(_("I'm translated!")) - print(_.ngettext("One thing", "{} things", 2).format(2)) + __ = i18n.get_for(__package__) + print(__("I'm translated!")) + print(__.n("One thing", "{} things", 2).format(2)) ``` """ package = get_package_by_python_module(module_name) domain = domain_of(package.manifest) domain_state = _ensure_initialized(domain, package, get_qpy_environment()) - return Gettext(package, domain, domain_state), lambda message: message + return Gettext(package, domain, domain_state) def dgettext(domain: str, message: str, /) -> str: diff --git a/questionpy_sdk/commands/i18n/_extract.py b/questionpy_sdk/commands/i18n/_extract.py index ed79c1a4..3ea57293 100644 --- a/questionpy_sdk/commands/i18n/_extract.py +++ b/questionpy_sdk/commands/i18n/_extract.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Literal import babel.messages import babel.messages.pofile @@ -9,6 +10,30 @@ from questionpy_sdk.package.source import PackageSource _BABEL_MAPPING = [("python/**.py", "python"), ("templates/**.j2", "jinja2")] +# See Babel's DEFAULT_KEYWORDS for comparison. +_BABEL_KEYWORDS: dict[str, tuple[int | tuple[int, Literal["c"]], ...]] = { + # Full names + "gettext": (1,), + "ngettext": (1, 2), + "pgettext": ((1, "c"), 2), + "npgettext": ((1, "c"), 2, 3), + "dgettext": (2,), + "dngettext": (2, 3), + "dpgettext": ((2, "c"), 3), + "dnpgettext": ((2, "c"), 3, 4), + # Short names encouraged by us + "__": (1,), + "n": (1, 2), + "p": ((1, "c"), 2), + "np": ((1, "c"), 2, 3), + # No-op markers + "gettext_noop": (1,), + "ngettext_noop": (1, 2), + "pgettext_noop": ((1, "c"), 2), + "npgettext_noop": ((1, "c"), 2, 3), + # We don't encourage '_' in Python, but it's the default in Jinja's i18n extension so people may be used to it. + "_": (1,), +} @click.command @@ -38,7 +63,7 @@ def extract(package: Path, output: Path | None = None) -> None: ) for filename, lineno, message, comments, context in extract_from_dir( - package, _BABEL_MAPPING, comment_tags=("TRANSLATORS:",), strip_comment_tags=True + package, _BABEL_MAPPING, comment_tags=("TRANSLATORS:",), strip_comment_tags=True, keywords=_BABEL_KEYWORDS ): catalog.add(message, None, [(filename, lineno)], auto_comments=comments, context=context) From c2e75e10ea29831f862d6fb6f500d3323edca366 Mon Sep 17 00:00:00 2001 From: Maximilian Haye Date: Thu, 6 Mar 2025 16:11:05 +0100 Subject: [PATCH 25/25] chore: increment version number --- poetry.lock | 8 ++++---- pyproject.toml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index a827ed78..24456a90 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1551,7 +1551,7 @@ files = [ [[package]] name = "questionpy-server" -version = "0.4.0" +version = "0.5.0" description = "QuestionPy application server" optional = false python-versions = "^3.11" @@ -1572,8 +1572,8 @@ watchdog = "^6.0.0" [package.source] type = "git" url = "https://github.com/questionpy-org/questionpy-server.git" -reference = "e76d8e6b24ebac872e94e19928c0785f4a84608b" -resolved_reference = "e76d8e6b24ebac872e94e19928c0785f4a84608b" +reference = "3996fad176aed749709d0fec7a21bd17f94148ce" +resolved_reference = "3996fad176aed749709d0fec7a21bd17f94148ce" [[package]] name = "ruff" @@ -1928,4 +1928,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "169d3054d1c659d7ab8f93f8e47190064b9ad58e55758d9585b738418fe7bb2a" +content-hash = "e6e02911b087225cf7e10d8172384a47fedbe2695e180d002a2ce9569d2d7945" diff --git a/pyproject.toml b/pyproject.toml index 19e49f0a..e295c171 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ description = "Library and toolset for the development of QuestionPy packages" authors = ["innoCampus "] license = "MIT" homepage = "https://questionpy.org" -version = "0.3.0" +version = "0.4.0" packages = [ { include = "questionpy" }, { include = "questionpy_sdk" } @@ -28,7 +28,7 @@ python = "^3.11" aiohttp = "^3.9.3" pydantic = "^2.6.4" PyYAML = "^6.0.1" -questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "e76d8e6b24ebac872e94e19928c0785f4a84608b" } +questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "3996fad176aed749709d0fec7a21bd17f94148ce" } jinja2 = "^3.1.3" aiohttp-jinja2 = "^1.6" lxml = {version = "~5.3.0", extras = ["html_clean"]}