diff --git a/.github/scripts/dispatch_release/detect.sh b/.github/scripts/dispatch_release/detect.sh index 71d3b525f85..dcecbc70d8d 100755 --- a/.github/scripts/dispatch_release/detect.sh +++ b/.github/scripts/dispatch_release/detect.sh @@ -18,8 +18,9 @@ declare -A MAP=( [reflex_components_sonner]=reflex-components-sonner [reflex_docgen]=reflex-docgen [reflex_hosting_cli]=reflex-hosting-cli + [reflex_i18n]=reflex-i18n ) -ORDER=(hatch_reflex_pyi reflex_base reflex_components_code reflex_components_core reflex_components_dataeditor reflex_components_gridjs reflex_components_lucide reflex_components_markdown reflex_components_moment reflex_components_plotly reflex_components_radix reflex_components_react_player reflex_components_recharts reflex_components_sonner reflex_docgen reflex_hosting_cli) +ORDER=(hatch_reflex_pyi reflex_base reflex_components_code reflex_components_core reflex_components_dataeditor reflex_components_gridjs reflex_components_lucide reflex_components_markdown reflex_components_moment reflex_components_plotly reflex_components_radix reflex_components_react_player reflex_components_recharts reflex_components_sonner reflex_docgen reflex_hosting_cli reflex_i18n) PACKAGES=() for key in "${ORDER[@]}"; do diff --git a/.github/workflows/dispatch_release.yml b/.github/workflows/dispatch_release.yml index a329004d142..0093b316a5e 100644 --- a/.github/workflows/dispatch_release.yml +++ b/.github/workflows/dispatch_release.yml @@ -90,6 +90,10 @@ on: description: "reflex-hosting-cli" type: boolean default: false + reflex_i18n: + description: "reflex-i18n" + type: boolean + default: false permissions: contents: read @@ -123,6 +127,7 @@ jobs: reflex_components_sonner: ${{ inputs.reflex_components_sonner }} reflex_docgen: ${{ inputs.reflex_docgen }} reflex_hosting_cli: ${{ inputs.reflex_hosting_cli }} + reflex_i18n: ${{ inputs.reflex_i18n }} run: bash .github/scripts/dispatch_release/detect.sh plan: diff --git a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py index 3d7a5c8c39f..e290938d4d2 100644 --- a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py +++ b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py @@ -123,6 +123,7 @@ def get_sidebar_items_backend(): client_storage, database, events, + i18n, state, state_structure, utility_methods, @@ -179,6 +180,12 @@ def get_sidebar_items_backend(): client_storage.overview, ], ), + create_item( + "Internationalization", + children=[ + i18n.overview, + ], + ), create_item( "Database", children=[ diff --git a/docs/i18n/overview.md b/docs/i18n/overview.md new file mode 100644 index 00000000000..701628c7218 --- /dev/null +++ b/docs/i18n/overview.md @@ -0,0 +1,274 @@ +```python exec +import reflex as rx +``` + +# Internationalization (i18n) + +Reflex supports translating your app into multiple languages through the +[`reflex-i18n`](https://pypi.org/project/reflex-i18n/) package. It covers the +two kinds of text in an app: + +- **Static content** — literal strings in your components (labels, buttons, + headings), translated on the client with `rx.t(...)`. +- **Dynamic content** — strings produced by your state (messages, formatted + values), translated on the server with `gettext`. + +Only the visitor's active language is ever sent to the browser, so adding more +locales does not bloat what each user downloads. + +## Installation + +```bash +pip install reflex-i18n +``` + +Then enable it by adding the `I18nPlugin` to your `rxconfig.py`, listing the +locales you support: + +```python +import reflex as rx +from reflex_i18n import I18nPlugin + +config = rx.Config( + app_name="myapp", + plugins=[ + I18nPlugin(locales=["en", "de", "fr"], default_locale="en"), + ], +) +``` + +`default_locale` is the language your source strings are written in. Translations +live in `.po` files under `locales/` (configurable with `catalog_dir=`). + +## Static content with `rx.t` + +Wrap literal component strings in `rx.t`. The text you pass is the message in +your default locale; at runtime it is looked up in the active locale's catalog, +falling back to the original text when a translation is missing. + +```python +def index(): + return rx.vstack( + rx.heading(rx.t("Welcome")), + rx.button(rx.t("Sign in")), + ) +``` + +### Interpolating values + +Use `{name}` placeholders and pass the values as keyword arguments. Values may +be plain data **or state vars** — they interpolate on the client, so they stay +reactive: + +```python +class State(rx.State): + name: str = "Ada" + + +def greeting(): + return rx.text(rx.t("Hello, {name}!", name=State.name)) +``` + +### Plurals + +Pass a `plural` form and a `count`. The correct form is chosen using the active +locale's plural rules, and `count` is also available as the `{count}` +placeholder: + +```python +class CartState(rx.State): + items: int = 1 + + +def cart_label(): + return rx.text(rx.t("{count} item", plural="{count} items", count=CartState.items)) +``` + +### Disambiguating with context + +When the same source text needs different translations in different places, +give it a `context`: + +```python +rx.t("Open", context="verb") # "to open something" +rx.t("Open", context="status") # "currently open" +``` + +## Dynamic content with `gettext` + +For text generated in your state, import `gettext` (conventionally aliased `_`) +and call it wherever you build the string. It translates into the current +visitor's locale. + +The best place is a **computed var**: it re-runs and re-sends the translated +string automatically when the locale changes. + +```python +from reflex_i18n import gettext as _ + + +class DashboardState(rx.State): + unread: int = 0 + + @rx.var + def status(self) -> str: + return _("You have {n} new messages").format(n=self.unread) +``` + +You can also translate at the moment you produce a one-off message, such as in +an event handler: + +```python +from reflex_i18n import gettext as _, ngettext + + +class OrderState(rx.State): + message: str = "" + + @rx.event + def checkout(self): + self.message = _("Order confirmed") + + @rx.event + def summarize(self, n: int): + self.message = ngettext("{n} order", "{n} orders", n).format(n=n) +``` + +`ngettext(singular, plural, n)` handles plurals and `pgettext(context, message)` +handles context, mirroring `rx.t`. + +Note: translate at render time (in a computed var) or at the moment you emit a +message. A translated string stored in a plain var earlier will not +re-translate on its own when the locale changes. + +## Formatting numbers and dates + +Numbers, currencies, percentages and dates are formatted differently per locale +(`1,234.5` vs `1.234,5`, `7/18/2026` vs `18.07.2026`). There are two ways to +format, mirroring static vs dynamic content. + +**In components (client-side).** `rx.i18n.number` / `currency` / `percent` / +`date` / `time` / `datetime` format a value in the browser using `Intl`, and +reformat instantly when the locale changes: + +```python +def price_row(): + return rx.hstack( + rx.text(rx.i18n.number(State.quantity, max_fraction_digits=2)), + rx.text(rx.i18n.currency(State.total, "EUR")), + rx.text(rx.i18n.percent(State.tax_rate, max_fraction_digits=1)), + rx.text(rx.i18n.date(State.created, length="long")), + ) +``` + +Curated options — `min_fraction_digits`, `max_fraction_digits`, `grouping`, +`compact`, and `length` (`"short"`/`"medium"`/`"long"`/`"full"`) — cover the +common cases; pass `options={...}` for raw +[`Intl`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat) +options. + +**In state (server-side).** Use the `format_*` helpers, e.g. when building a +translated string. Like `gettext`, a computed var using one reformats when the +locale changes: + +```python +from reflex_i18n import gettext as _, format_currency + + +class CartState(rx.State): + total: float = 0.0 + + @rx.var + def summary(self) -> str: + return _("Total: {amount}").format(amount=format_currency(self.total, "EUR")) +``` + +`rx.i18n.locale` exposes the active locale as a var, e.g. to drive +`rx.moment(State.created, locale=rx.i18n.locale)`. + +Two caveats: + +- **Naive datetimes client-side** are shown in the visitor's local time zone (a + datetime with no offset has no absolute meaning). For a fixed zone, use a + timezone-aware datetime, pass `options={"timeZone": "..."}`, or format + server-side with `format_datetime`. (Plain dates and times are unambiguous and + render correctly.) +- Python's `f"{value:,}"` formatting on a numeric var stays `en-US` by design; + use `rx.i18n.number` for locale-aware output. + +## Detecting and switching the locale + +On a visitor's first load, the locale is negotiated from their browser's +`Accept-Language`, falling back to `default_locale`. Once they pick a language +it is remembered in a cookie. + +Switch languages with `rx.i18n.set_locale`, which updates both static and +dynamic content: + +```python +def language_switcher(): + return rx.hstack( + rx.button("English", on_click=rx.i18n.set_locale("en")), + rx.button("Deutsch", on_click=rx.i18n.set_locale("de")), + ) +``` + +Static (`rx.t`) content updates instantly; dynamic (state) content updates on +the next server round-trip. + +## Translation catalogs + +Translations are standard gettext `.po` files, one per locale, under `locales/`: + +```text +locales/ + en.po + de.po + fr.po +``` + +A catalog entry pairs the source text (`msgid`) with its translation +(`msgstr`): + +```po +msgid "Welcome" +msgstr "Willkommen" + +msgid "{count} item" +msgid_plural "{count} items" +msgstr[0] "{count} Artikel" +msgstr[1] "{count} Artikel" +``` + +You don't write these by hand — the CLI generates and maintains them. + +## The `reflex i18n` CLI + +Once the plugin is configured, three commands manage your catalogs: + +```bash +# Scan the app for rx.t and gettext calls and update every locale's .po file +# (new messages added, translations preserved, removed ones marked obsolete). +reflex i18n extract + +# Create a fresh catalog for a new locale. +reflex i18n init es + +# Fail (non-zero exit) if any locale has untranslated or fuzzy messages. +# Useful as a CI check. +reflex i18n check +``` + +A typical workflow: run `reflex i18n extract` after adding or changing strings, +fill in the `msgstr` values in each locale's `.po` file, and add +`reflex i18n check` to CI to catch missing translations. + +## How it works + +- `rx.t` compiles to a client-side lookup. At build time Reflex generates one + small JavaScript catalog per locale, and the browser loads **only the active + language** on demand. +- `gettext` runs on the server against the locale resolved for the current + client, so no translation data for other languages is sent to the browser. +- Missing translations always fall back to the source text, so an incomplete + catalog degrades gracefully rather than showing blank strings. diff --git a/news/6796.bugfix.md b/news/6796.bugfix.md new file mode 100644 index 00000000000..826d6463462 --- /dev/null +++ b/news/6796.bugfix.md @@ -0,0 +1 @@ +Hot reload no longer crashes in `_mark_dirty_computed_vars` when a computed var on a surviving state depends on a state defined in the reloaded module: dependency edges pointing at reloaded states are now purged from every remaining state instead of being left to dangle. diff --git a/news/6796.feature.md b/news/6796.feature.md new file mode 100644 index 00000000000..c134b4e010b --- /dev/null +++ b/news/6796.feature.md @@ -0,0 +1 @@ +Added internationalization support through the new `reflex-i18n` package, exposed under `rx.i18n`: translate static component content with `rx.t` and dynamic state content server-side with `rx.i18n.gettext`/`ngettext`/`pgettext`, and configure locales with `rx.i18n.I18nPlugin` in `rx.Config(plugins=[...])`. Installed packages can now also contribute `reflex` CLI subcommands by declaring a `reflex.cli` entry point (e.g. `reflex-i18n` adds `reflex i18n`); a plugin failing to load its command no longer breaks the whole CLI. diff --git a/packages/reflex-base/news/6796.feature.md b/packages/reflex-base/news/6796.feature.md new file mode 100644 index 00000000000..f7a749368a1 --- /dev/null +++ b/packages/reflex-base/news/6796.feature.md @@ -0,0 +1 @@ +Added two extension points (used by `reflex-i18n`, available to any package): `register_event_scope_provider` wraps each event's handler execution and delta resolution in an ambient context that is re-derived after the handler runs (e.g. the active locale), which plain middleware cannot do; and `register_implicit_dependency` marks functions whose use inside a computed var getter implies a dependency on a var the getter never reads directly (e.g. a gettext helper reading the active locale from a contextvar). Both are no-ops with negligible cost when nothing is registered. diff --git a/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py b/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py index f361b2f3969..8ba129c2db7 100644 --- a/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py +++ b/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py @@ -17,6 +17,7 @@ from reflex.utils import console, types from reflex_base.event.context import EventContext from reflex_base.event.processor.event_processor import EventProcessor, EventQueueEntry +from reflex_base.event.processor.scope import event_scope from reflex_base.registry import RegisteredEventHandler from reflex_base.utils.format import format_event_handler @@ -207,8 +208,12 @@ async def chain_updates( if root_state is not None: # Emit deltas first, so any frontend events are processed with the latest state. + # Enter the event scope again here (after the handler ran): a provider + # may derive its context from state the handler just changed (e.g. the + # locale), and computed vars are recomputed during delta resolution. try: - delta = await root_state._get_resolved_delta() + async with event_scope(root_state): + delta = await root_state._get_resolved_delta() if delta: await ctx.emit_delta(delta) finally: @@ -374,25 +379,30 @@ async def _execute_event( substate = await state.get_state(event.state_cls) root_state = state._get_root_state() - if needs_to_rehydrate: - await self._rehydrate(root_state) - - # Process non-background events while holding the lock. - if not registered_handler.handler.is_background: - await process_event( - handler=registered_handler.handler, - payload=event.payload, - state=substate, - root_state=root_state, - ) - return + # Enter any per-event ambient context (e.g. i18n locale) around + # handler execution. A no-op unless a feature registered a scope + # provider, so apps without one pay effectively nothing. + async with event_scope(root_state): + if needs_to_rehydrate: + await self._rehydrate(root_state) + + # Process non-background events while holding the lock. + if not registered_handler.handler.is_background: + await process_event( + handler=registered_handler.handler, + payload=event.payload, + state=substate, + root_state=root_state, + ) + return # Otherwise drop the state lock and start processing the background task with a proxy state. - await process_event( - handler=registered_handler.handler, - state=StateProxy(substate), - payload=event.payload, - root_state=root_state, - ) + async with event_scope(root_state): + await process_event( + handler=registered_handler.handler, + state=StateProxy(substate), + payload=event.payload, + root_state=root_state, + ) async def _handle_backend_exception( self, ex: Exception, ev_ctx: EventContext | None = None diff --git a/packages/reflex-base/src/reflex_base/event/processor/scope.py b/packages/reflex-base/src/reflex_base/event/processor/scope.py new file mode 100644 index 00000000000..eee9a50aee2 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/event/processor/scope.py @@ -0,0 +1,82 @@ +"""Per-event ambient context providers (e.g. i18n locale). + +A provider yields a context manager entered around both handler execution and +delta resolution; it is called fresh at each phase, so state a handler changed +is re-read. Middleware can't do this (separate pre/post calls). No registered +providers means a ``nullcontext`` fast path. +""" + +from __future__ import annotations + +import contextlib +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from reflex.state import BaseState + +# A provider takes the root state and returns (async) a synchronous context +# manager to enter for the current event phase. +EventScopeProvider = Callable[ + ["BaseState"], Awaitable[contextlib.AbstractContextManager[None]] +] + +_providers: list[EventScopeProvider] = [] + + +def register_event_scope_provider(provider: EventScopeProvider) -> None: + """Register a per-event ambient-context provider. + + Args: + provider: Async callable from root state to a context manager. + """ + _providers.append(provider) + + +def has_event_scope_providers() -> bool: + """Whether any event-scope providers are registered. + + Returns: + True if at least one provider is registered. + """ + return bool(_providers) + + +class _EventScope: + """Async context manager entering every registered provider's scope.""" + + __slots__ = ("_root_state", "_stack") + + def __init__(self, root_state: BaseState): + self._root_state = root_state + self._stack = contextlib.ExitStack() + + async def __aenter__(self) -> None: + # ``async with`` only calls ``__aexit__`` if ``__aenter__`` returns, so + # a provider raising mid-loop would otherwise leak the contexts already + # entered (e.g. an earlier provider's contextvar token). + try: + for provider in _providers: + self._stack.enter_context(await provider(self._root_state)) + except BaseException: + self._stack.close() + raise + + async def __aexit__(self, *exc_info: object) -> None: + self._stack.close() + + +def event_scope( + root_state: BaseState, +) -> contextlib.AbstractAsyncContextManager[None]: + """Ambient-context scope for processing an event. + + Args: + root_state: The client's root state instance. + + Returns: + A scope entering every provider, or a no-op when none are registered. + """ + if not _providers: + return contextlib.nullcontext() + return _EventScope(root_state) diff --git a/packages/reflex-base/src/reflex_base/vars/dep_tracking.py b/packages/reflex-base/src/reflex_base/vars/dep_tracking.py index 7f2a9fd5c7a..cd52054e215 100644 --- a/packages/reflex-base/src/reflex_base/vars/dep_tracking.py +++ b/packages/reflex-base/src/reflex_base/vars/dep_tracking.py @@ -9,6 +9,7 @@ import importlib import inspect import sys +from collections.abc import Callable, Iterable from types import CellType, CodeType, FunctionType, ModuleType from typing import TYPE_CHECKING, Any, ClassVar, cast @@ -22,6 +23,26 @@ CellEmpty = object() +# Functions whose mere use inside a computed var getter implies a dependency on +# a var, even though the getter never reads that var directly (e.g. a gettext +# helper that reads the active locale from a contextvar). Maps the function to a +# provider returning the implied dependency Var, or None when inactive. +_implicit_dependency_providers: dict[object, Callable[[], Var | None]] = {} + + +def register_implicit_dependency( + funcs: Iterable[object], provider: Callable[[], Var | None] +) -> None: + """Register functions that imply a computed-var dependency when referenced. + + Args: + funcs: The functions to detect (matched by object identity). + provider: Returns the implied dependency Var, or None when the + dependency is not currently applicable. + """ + for func in funcs: + _implicit_dependency_providers[func] = provider + def get_cell_value(cell: CellType) -> Any: """Get the value of a cell object. @@ -216,6 +237,28 @@ def load_attr_or_method(self, instruction: dis.Instruction) -> None: instruction.argval ) + def _add_implicit_dependency(self, obj: object) -> None: + """Record an implied dependency if ``obj`` is a registered function. + + Args: + obj: The object a load instruction resolved to (may be anything). + """ + if not _implicit_dependency_providers: + return + try: + provider = _implicit_dependency_providers.get(obj) + except TypeError: + return # unhashable objects can't be registered functions + if provider is None: + return + dep_var = provider() + if dep_var is None: + return + var_data = dep_var._get_all_var_data() + if var_data is None or not var_data.state: + return + self.dependencies.setdefault(var_data.state, set()).add(var_data.field_name) + def _get_globals(self) -> dict[str, Any]: """Get the globals of the function. @@ -454,6 +497,23 @@ def _populate_dependencies(self) -> None: tracked_locals=self.tracked_locals, ) ) + elif ( + instruction.opname == "LOAD_GLOBAL" + and self.scan_status == ScanStatus.SCANNING + ): + # A referenced global may be a function that implies a + # dependency (e.g. a gettext helper reading the active locale). + self._add_implicit_dependency( + self._get_globals().get(instruction.argval) + ) + elif ( + instruction.opname == "LOAD_DEREF" + and self.scan_status == ScanStatus.SCANNING + ): + # Same as above for a closure-captured function reference. + self._add_implicit_dependency( + self._get_closure().get(instruction.argval) + ) elif instruction.opname == "IMPORT_NAME" and instruction.argval is not None: self.scan_status = ScanStatus.GETTING_IMPORT self._last_import_name = instruction.argval diff --git a/packages/reflex-i18n/README.md b/packages/reflex-i18n/README.md new file mode 100644 index 00000000000..e48a4fbf561 --- /dev/null +++ b/packages/reflex-i18n/README.md @@ -0,0 +1,39 @@ +# reflex-i18n + +Internationalization (i18n) for [Reflex](https://reflex.dev) apps. + +- `rx.t(...)` — translate static component strings, resolved client-side from + per-locale catalogs (only the active locale is shipped to the client). +- `gettext` / `ngettext` / `pgettext` — translate dynamic (state) content + server-side; translated computed vars retranslate automatically on a locale + switch. +- `I18nPlugin` — configure locales and wire compilation. + +```python +import reflex as rx +from reflex_i18n import I18nPlugin, t, gettext as _ + + +class State(rx.State): + @rx.var + def greeting(self) -> str: + return _("Hello") + + +def index(): + return rx.text(t("Welcome")) + + +app = rx.App() +app.add_page(index) +``` + +```python +# rxconfig.py +config = rx.Config( + app_name="myapp", + plugins=[I18nPlugin(locales=["en", "de"], default_locale="en")], +) +``` + +Translations live in `locales/{locale}.po`. diff --git a/packages/reflex-i18n/news/6796.feature.md b/packages/reflex-i18n/news/6796.feature.md new file mode 100644 index 00000000000..d64d82d3adc --- /dev/null +++ b/packages/reflex-i18n/news/6796.feature.md @@ -0,0 +1 @@ +New `reflex-i18n` package providing internationalization for Reflex apps: `rx.t` for translating static component text, server-side `gettext`/`ngettext`/`pgettext` for dynamic state content, an `I18nPlugin`/`I18nConfig` to configure available locales, an `I18nState` whose active locale is backed by a cookie, and a `reflex i18n` CLI for extracting message catalogs and compiling `.po`/`.mo` files via Babel. diff --git a/packages/reflex-i18n/pyproject.toml b/packages/reflex-i18n/pyproject.toml new file mode 100644 index 00000000000..7ecc4f16ff9 --- /dev/null +++ b/packages/reflex-i18n/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "reflex-i18n" +dynamic = ["version"] +description = "Internationalization (i18n) for Reflex apps." +license.text = "Apache-2.0" +readme = "README.md" +authors = [{ name = "Reflex", email = "maintainers@reflex.dev" }] +maintainers = [{ name = "Reflex", email = "maintainers@reflex.dev" }] +requires-python = ">=3.10" +dependencies = [ + # TODO: Bump to the released versions before publishing. + "reflex-base >= 0.9.7.dev0", + "reflex >= 0.9.7.dev0", + "babel >= 2.14.0,<3.0", +] + +[project.entry-points."reflex.cli"] +i18n = "reflex_i18n.cli:i18n_cli" + +[tool.hatch.version] +source = "uv-dynamic-versioning" + +[tool.uv-dynamic-versioning] +pattern-prefix = "reflex-i18n-" +fallback-version = "0.0.0dev0" + +[tool.hatch.build] +artifacts = ["_web/**"] + +[build-system] +requires = ["hatchling", "uv-dynamic-versioning"] +build-backend = "hatchling.build" diff --git a/packages/reflex-i18n/src/reflex_i18n/__init__.py b/packages/reflex-i18n/src/reflex_i18n/__init__.py new file mode 100644 index 00000000000..b1680b3cfe7 --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/__init__.py @@ -0,0 +1,101 @@ +"""Internationalization (i18n) for Reflex apps. + +Static (component) content is translated with :func:`t`; dynamic (state) +content is translated server-side with :func:`gettext` / :func:`ngettext` / +:func:`pgettext`. Configure locales with :class:`I18nPlugin` in +``rx.Config(plugins=[...])``. +""" + +from typing import TYPE_CHECKING, Any + +from .config import LOCALE_COOKIE_NAME, I18nConfig + +# date/datetime/time use the alias form and are omitted from __all__ so +# `from reflex_i18n import *` cannot shadow the stdlib names; they remain +# available as rx.i18n.date / .time / .datetime (prefer attribute access). +from .format import currency, number, percent +from .format import date as date +from .format import datetime as datetime +from .format import time as time +from .plugin import I18nPlugin +from .runtime import ( + format_currency, + format_date, + format_datetime, + format_decimal, + format_number, + format_percent, + format_time, + gettext, + ngettext, + pgettext, +) +from .vars import t + +if TYPE_CHECKING: + from reflex_base.vars.sequence import StringVar + + from .state import I18nState, set_locale + + # The active locale as a client-side var; resolved lazily (see below). + locale: StringVar + +# gettext alias, the conventional shorthand for marking translatable strings. +_ = gettext + +# "date", "datetime" and "time" are intentionally omitted (see the import +# above): reachable as rx.i18n.date/.time/.datetime but excluded from +# `import *` so they cannot shadow the stdlib names. +__all__ = [ + "LOCALE_COOKIE_NAME", + "I18nConfig", + "I18nPlugin", + "I18nState", + "currency", + "format_currency", + "format_date", + "format_datetime", + "format_decimal", + "format_number", + "format_percent", + "format_time", + "gettext", + "locale", + "ngettext", + "number", + "percent", + "pgettext", + "set_locale", + "t", +] + +# Importing ``.state`` registers ``I18nState`` as a substate (a global side +# effect). Defer it so merely importing this package (e.g. the reflex CLI +# loading the ``reflex i18n`` entry point) does not attach i18n state to apps +# that never use i18n; opting in via ``I18nPlugin`` or accessing these names +# does. +_LAZY_STATE_ATTRS = frozenset({"I18nState", "set_locale"}) + + +def __getattr__(name: str) -> Any: + """Lazily resolve state-registering attributes. + + Args: + name: The attribute name. + + Returns: + The resolved attribute. + + Raises: + AttributeError: If the attribute is not part of the public API. + """ + if name in _LAZY_STATE_ATTRS: + from . import state + + return getattr(state, name) + if name == "locale": + from .format import _locale_var + + return _locale_var() + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) diff --git a/packages/reflex-i18n/src/reflex_i18n/_web/i18n.js b/packages/reflex-i18n/src/reflex_i18n/_web/i18n.js new file mode 100644 index 00000000000..0e67438e87d --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/_web/i18n.js @@ -0,0 +1,251 @@ +import { + createContext, + createElement, + useCallback, + useContext, + useEffect, + useState, +} from "react"; + +import { cookieName, defaultLocale, loaders, locales } from "$/i18n/index.js"; + +// gettext msgctxt separator; catalog keys are "context\u0004msgid". +const CONTEXT_SEPARATOR = "\u0004"; + +// Language subtags written right-to-left. +const RTL_LANGUAGES = new Set([ + "ar", + "arc", + "ckb", + "dv", + "fa", + "he", + "ks", + "ps", + "sd", + "ug", + "ur", + "yi", +]); + +const stripContext = (key) => { + const index = key.indexOf(CONTEXT_SEPARATOR); + return index === -1 ? key : key.slice(index + 1); +}; + +const interpolate = (message, params) => + message.replace(/\{(\w+)\}/g, (match, name) => + params && name in params ? String(params[name]) : match, + ); + +const readCookie = (name) => { + const match = document.cookie + .split("; ") + .find((row) => row.startsWith(name + "=")); + return match ? decodeURIComponent(match.split("=")[1]) : undefined; +}; + +const writeCookie = (name, value) => { + const secure = location.protocol === "https:" ? "; secure" : ""; + document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=31536000; samesite=lax${secure}`; +}; + +// Match a requested locale list against the supported locales: exact tag +// first, then primary-language prefix (e.g. "de-AT" -> "de"). +const negotiate = (requested) => { + for (const tag of requested) { + if (locales.includes(tag)) { + return tag; + } + const language = tag.split("-")[0]; + const match = locales.find( + (supported) => supported.split("-")[0] === language, + ); + if (match !== undefined) { + return match; + } + } + return undefined; +}; + +const initialLocale = () => { + const fromCookie = readCookie(cookieName); + if (fromCookie && locales.includes(fromCookie)) { + return fromCookie; + } + return ( + negotiate(navigator.languages ?? [navigator.language]) ?? defaultLocale + ); +}; + +export const I18nContext = createContext({ + locale: defaultLocale, + catalog: undefined, + setLocale: () => {}, +}); + +// The mounted provider's setter, so a Reflex event (run_script) can switch +// the locale without threading context through the calling component. +let _switchLocale = null; + +export function switchLocale(locale) { + if (_switchLocale) { + _switchLocale(locale); + } +} + +export function I18nProvider({ children }) { + // Start from the default locale so the server/first render is + // deterministic and never touches document/navigator; the cookie- and + // browser-based locale is resolved client-side in the effect below. + const [locale, setLocaleState] = useState(defaultLocale); + const [catalog, setCatalog] = useState(undefined); + + useEffect(() => { + setLocaleState(initialLocale()); + }, []); + + useEffect(() => { + let cancelled = false; + loaders[locale]() + .then((module_) => { + if (!cancelled) { + setCatalog(module_); + } + }) + .catch((error) => { + // A failed chunk load (e.g. a stale hashed chunk after a redeploy) + // leaves the previous catalog in place; text falls back to the source + // msgids. Surface it instead of an unhandled rejection. + console.error( + `Failed to load i18n catalog for locale "${locale}".`, + error, + ); + }); + const root = document.documentElement; + root.lang = locale; + root.dir = RTL_LANGUAGES.has(locale.split("-")[0]) ? "rtl" : "ltr"; + return () => { + cancelled = true; + }; + }, [locale]); + + const setLocale = useCallback((nextLocale) => { + if (!locales.includes(nextLocale)) { + console.error( + `Invalid locale "${nextLocale}". Supported locales: ${locales.join(", ")}.`, + ); + return; + } + // The cookie is the source of truth for a chosen locale; only an + // explicit choice writes it, so browser-preference changes keep + // applying for users who never picked a language. + writeCookie(cookieName, nextLocale); + setLocaleState(nextLocale); + }, []); + + useEffect(() => { + _switchLocale = setLocale; + return () => { + _switchLocale = null; + }; + }, [setLocale]); + + return createElement( + I18nContext.Provider, + { value: { locale, catalog, setLocale } }, + children, + ); +} + +// Cached Intl formatter instances, keyed by locale + serialized options, so a +// list of many formatted values reuses one formatter per (locale, options). +const _numberFormatters = new Map(); +const _dateFormatters = new Map(); + +// In normal use the key set is small (locales x compile-time option sets), but +// runtime-varying options (a Var in `options=`) could grow it, so cap the cache +// and evict the oldest entry (Map preserves insertion order). +const FORMATTER_CACHE_LIMIT = 100; + +const getFormatter = (cache, Ctor, locale, options) => { + const key = locale + " " + JSON.stringify(options ?? {}); + let formatter = cache.get(key); + if (formatter === undefined) { + formatter = new Ctor(locale, options); + if (cache.size >= FORMATTER_CACHE_LIMIT) { + cache.delete(cache.keys().next().value); + } + cache.set(key, formatter); + } + return formatter; +}; + +// Turn a value into a Date, normalizing Python's str(date/datetime/time): +// trim microseconds to milliseconds; parse a date-only value as local midnight +// (not UTC, which would shift the day in negative offsets); anchor a bare time +// to the epoch date so Intl can format it (new Date("14:30:00") is invalid). +const toDate = (value) => { + if (value instanceof Date) return value; + if (typeof value === "number") return new Date(value); + const s = String(value).replace(/(\.\d{3})\d+/, "$1"); + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return new Date(`${s}T00:00:00`); + if (/^\d{2}:\d{2}/.test(s)) return new Date(`1970-01-01T${s}`); + return new Date(s.replace(" ", "T")); +}; + +export function useFormat() { + const { locale } = useContext(I18nContext); + const formatNumber = useCallback( + (value, options) => + getFormatter( + _numberFormatters, + Intl.NumberFormat, + locale, + options, + ).format(value), + [locale], + ); + const formatDate = useCallback( + (value, options) => + getFormatter( + _dateFormatters, + Intl.DateTimeFormat, + locale, + options, + ).format(toDate(value)), + [locale], + ); + return [formatNumber, formatDate]; +} + +export function useLocale() { + return useContext(I18nContext).locale; +} + +export function useTranslation() { + const { catalog } = useContext(I18nContext); + + const t_ = useCallback( + (key, params) => { + const message = catalog?.messages[key] ?? stripContext(key); + return interpolate(message, params); + }, + [catalog], + ); + + const tp_ = useCallback( + (key, pluralMessage, count, params) => { + const entry = catalog?.messages[key]; + const message = Array.isArray(entry) + ? (entry[catalog.plural(count)] ?? entry[entry.length - 1]) + : count === 1 + ? stripContext(key) + : pluralMessage; + return interpolate(message, params); + }, + [catalog], + ); + + return [t_, tp_]; +} diff --git a/packages/reflex-i18n/src/reflex_i18n/catalog.py b/packages/reflex-i18n/src/reflex_i18n/catalog.py new file mode 100644 index 00000000000..40d7c30a0ca --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/catalog.py @@ -0,0 +1,155 @@ +"""Compile ``.po`` catalogs into the per-locale JS modules served to clients.""" + +from __future__ import annotations + +import json +import re +from collections.abc import Sequence +from pathlib import Path +from typing import TYPE_CHECKING + +from reflex_base.utils import console + +from .config import LOCALE_COOKIE_NAME, I18nConfig +from .registry import MessageKey + +if TYPE_CHECKING: + from babel.messages.catalog import Catalog + +# C plural expressions from the Plural-Forms header are (almost) valid JS; +# whitelist their tokens before embedding one in generated code. +_PLURAL_EXPR_ALLOWED = re.compile(r"^[ n0-9()%?:!<>=&|+*/-]+$") +_FALLBACK_PLURAL_EXPR = "n != 1" + +_GENERATED_HEADER = "// Generated by Reflex. Do not edit.\n" + + +def read_po_catalog(path: Path) -> Catalog: + """Parse a ``.po`` catalog file. + + Args: + path: The path of the ``.po`` file. + + Returns: + The parsed catalog. + """ + from babel.messages.pofile import read_po + + with path.open("r", encoding="utf-8") as po_file: + return read_po(po_file) + + +def _plural_expr_js(catalog: Catalog | None, locale: str) -> str: + """Get the catalog's plural expression as a safe JS expression. + + Args: + catalog: The parsed catalog, if one exists for the locale. + locale: The locale being compiled, for warnings. + + Returns: + The validated plural expression over the variable ``n``. + """ + if catalog is None: + return _FALLBACK_PLURAL_EXPR + expr = catalog.plural_expr + if not _PLURAL_EXPR_ALLOWED.match(expr): + console.warn( + f"Ignoring invalid Plural-Forms expression {expr!r} for locale " + f"{locale!r}; falling back to {_FALLBACK_PLURAL_EXPR!r}." + ) + return _FALLBACK_PLURAL_EXPR + return expr + + +def compile_catalog_module( + catalog: Catalog | None, + used_messages: Sequence[MessageKey], + locale: str, + *, + is_default_locale: bool, +) -> str: + """Render the JS catalog module for one locale (used messages only). + + Args: + catalog: The parsed ``.po`` catalog, or None if the locale has none. + used_messages: All messages collected from ``rx.t`` calls. + locale: The locale being compiled, for warnings. + is_default_locale: If True, missing translations are not warned about. + + Returns: + The JS module source code. + """ + entries: list[str] = [] + missing: list[MessageKey] = [] + for key in used_messages: + translation = _lookup_translation(catalog, key) + if translation is None: + missing.append(key) + continue + entries.append(f" {json.dumps(key.catalog_key)}: {json.dumps(translation)},") + if missing and not is_default_locale: + missing_list = "\n".join(f" {key.message!r}" for key in missing[:10]) + more = f"\n ... and {len(missing) - 10} more" if len(missing) > 10 else "" + console.warn( + f"{len(missing)} translation(s) missing for locale {locale!r} " + f"(falling back to the default locale):\n{missing_list}{more}" + ) + messages_body = "\n".join(entries) + return ( + f"{_GENERATED_HEADER}" + f"export const plural = (n) => Number({_plural_expr_js(catalog, locale)});\n" + f"export const messages = {{\n{messages_body}\n}};\n" + ) + + +def _lookup_translation( + catalog: Catalog | None, key: MessageKey +) -> str | list[str] | None: + """Find the translation for a message in a catalog. + + Args: + catalog: The parsed catalog, or None. + key: The message to look up. + + Returns: + The translated string, a list of plural forms, or None if the message + is untranslated (including partially translated plurals). + """ + if catalog is None: + return None + message = catalog.get(key.msgid, key.context) + if message is None: + return None + strings = message.string + if key.plural is None: + return strings or None if isinstance(strings, str) else None + if isinstance(strings, str): + strings = (strings,) + if not strings or not all(strings): + return None + return list(strings) + + +def compile_index_module(config: I18nConfig) -> str: + """Render the JS module describing the app's locales and catalog loaders. + + The static ``import()`` map lets the bundler code-split one chunk per + locale, so clients only ever download the active language. + + Args: + config: The app's i18n configuration. + + Returns: + The JS module source code. + """ + loaders = "\n".join( + f" {json.dumps(locale)}: () => import({json.dumps(f'$/i18n/{locale}.js')})," + for locale in config.locales + ) + return ( + f"{_GENERATED_HEADER}" + f"export const locales = {json.dumps(list(config.locales))};\n" + f"export const defaultLocale = {json.dumps(config.default_locale)};\n" + f"export const cookieName = {json.dumps(LOCALE_COOKIE_NAME)};\n" + f"export const loaders = {{\n{loaders}\n}};\n" + ) diff --git a/packages/reflex-i18n/src/reflex_i18n/cli.py b/packages/reflex-i18n/src/reflex_i18n/cli.py new file mode 100644 index 00000000000..c241ae70864 --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/cli.py @@ -0,0 +1,273 @@ +"""The ``reflex i18n`` command group: extract, init, and check catalogs. + +Attached to the ``reflex`` CLI via the ``reflex.cli`` entry point. +""" + +from __future__ import annotations + +import dataclasses +from pathlib import Path +from typing import TYPE_CHECKING + +import click +from reflex_base.config import get_config +from reflex_base.utils import console + +from .plugin import I18nPlugin +from .registry import MessageKey, collected_messages + +if TYPE_CHECKING: + from babel.messages.catalog import Catalog + +# Call names extracted for server-side (dynamic) translation. Matched +# syntactically by Babel, so the conventional ``gettext as _`` alias works. +_GETTEXT_KEYWORDS = { + "_": None, + "gettext": None, + "ngettext": (1, 2), + "pgettext": ((1, "c"), 2), +} + +_POT_FILENAME = "messages.pot" + + +@dataclasses.dataclass +class LocaleStats: + """Per-locale translation completeness, for reporting and ``check``.""" + + locale: str + missing: int = 0 + fuzzy: int = 0 + obsolete: int = 0 + + @property + def incomplete(self) -> bool: + """Whether the locale has untranslated or fuzzy messages. + + Returns: + True if any message is missing or fuzzy. + """ + return bool(self.missing or self.fuzzy) + + +def _resolve_plugin() -> I18nPlugin: + """Find the active I18nPlugin in the loaded config. + + Returns: + The configured plugin. + + Raises: + click.ClickException: If no I18nPlugin is configured. + """ + plugin = next((p for p in get_config().plugins if isinstance(p, I18nPlugin)), None) + if plugin is None: + msg = ( + "No I18nPlugin configured. Add I18nPlugin(locales=[...]) to " + "rx.Config(plugins=[...]) in rxconfig.py." + ) + raise click.ClickException(msg) + return plugin + + +def _extract_template() -> tuple[I18nPlugin, Catalog, Path]: + """Dry-compile the app and extract every message into a template catalog. + + Returns: + The plugin, the extracted template, and the catalog directory. + """ + from reflex.utils import prerequisites + + prerequisites.get_compiled_app(dry_run=True, use_rich=False) + plugin = _resolve_plugin() + template = extract_catalog(_app_source_dir(), collected_messages()) + return plugin, template, Path.cwd() / plugin.catalog_dir + + +def extract_catalog(app_dir: Path, used_messages: tuple[MessageKey, ...]) -> Catalog: + """Build a message-template catalog from both translation sources. + + Args: + app_dir: The app source directory to scan for gettext calls. + used_messages: Static ``rx.t`` messages from the compile registry. + + Returns: + A catalog holding every extracted message (untranslated template). + """ + from babel.messages.catalog import Catalog + from babel.messages.extract import extract_from_dir + + catalog = Catalog() + + # Dynamic content: gettext-family calls in the app source (with locations). + for filename, lineno, message, _comments, context in extract_from_dir( + app_dir, keywords=_GETTEXT_KEYWORDS + ): + catalog.add( + message, + locations=[(str(Path(app_dir.name) / filename), lineno)], + context=context, + ) + + # Static content: rx.t messages collected during compilation. + for key in used_messages: + catalog.add(key.msgid, context=key.context) + + return catalog + + +def _read_or_new_catalog(po_path: Path, locale: str) -> Catalog: + """Read an existing ``.po`` file or create an empty catalog for a locale. + + Args: + po_path: The path of the ``.po`` file. + locale: The locale identifier. + + Returns: + The loaded or newly created catalog. + """ + from babel.messages.catalog import Catalog + from babel.messages.pofile import read_po + + if po_path.exists(): + with po_path.open("rb") as f: + return read_po(f, locale=locale) + return Catalog(locale=locale) + + +def _write_catalog(catalog: Catalog, path: Path) -> None: + """Write a catalog to a ``.po``/``.pot`` file. + + Args: + catalog: The catalog to write. + path: The destination path. + """ + from babel.messages.pofile import write_po + + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as f: + write_po(f, catalog, omit_header=False) + + +def merge_into_locale(template: Catalog, po_path: Path, locale: str) -> LocaleStats: + """Merge the template into a locale catalog, preserving translations. + + Args: + template: The freshly extracted message template. + po_path: The locale's ``.po`` file path. + locale: The locale identifier. + + Returns: + Stats describing the merged catalog. + """ + catalog = _read_or_new_catalog(po_path, locale) + catalog.update(template) + _write_catalog(catalog, po_path) + return _catalog_stats(catalog, locale) + + +def _catalog_stats(catalog: Catalog, locale: str) -> LocaleStats: + """Count untranslated, fuzzy, and obsolete messages in a catalog. + + Args: + catalog: The catalog to inspect. + locale: The locale identifier. + + Returns: + The computed stats. + """ + stats = LocaleStats(locale=locale, obsolete=len(catalog.obsolete)) + for message in catalog: + if not message.id: # the header entry + continue + if "fuzzy" in message.flags: + stats.fuzzy += 1 + elif not message.string or ( + isinstance(message.string, (list, tuple)) and not all(message.string) + ): + stats.missing += 1 + return stats + + +def _report(stats: LocaleStats) -> None: + """Print a one-line summary for a locale. + + Args: + stats: The locale stats to report. + """ + detail = f"{stats.missing} missing, {stats.fuzzy} fuzzy, {stats.obsolete} obsolete" + if stats.incomplete: + console.warn(f" {stats.locale}: {detail}") + else: + console.success(f" {stats.locale}: complete ({stats.obsolete} obsolete)") + + +def _app_source_dir() -> Path: + """The app's Python source directory. + + Returns: + The directory scanned for gettext calls. + """ + return Path.cwd() / get_config().app_name + + +@click.group() +def i18n_cli(): + """Manage translation catalogs for the app.""" + + +@i18n_cli.command(name="extract") +def extract_command(): + """Extract messages and update every locale's ``.po`` catalog.""" + plugin, template, catalog_dir = _extract_template() + + _write_catalog(template, catalog_dir / _POT_FILENAME) + console.info(f"Extracted {len(template)} messages.") + for locale in plugin.locales: + _report(merge_into_locale(template, catalog_dir / f"{locale}.po", locale)) + console.success("Catalogs updated.") + + +@i18n_cli.command(name="init") +@click.argument("locale") +def init_command(locale: str): + """Create a new ``.po`` catalog for LOCALE. + + Args: + locale: The locale to initialize (e.g. ``de``). + + Raises: + ClickException: If the catalog already exists. + """ + plugin, template, catalog_dir = _extract_template() + po_path = catalog_dir / f"{locale}.po" + if po_path.exists(): + msg = f"Catalog already exists: {po_path}. Use `reflex i18n extract`." + raise click.ClickException(msg) + + stats = merge_into_locale(template, po_path, locale) + console.success(f"Created {po_path} with {stats.missing} messages to translate.") + if locale not in plugin.locales: + console.info( + f"Add {locale!r} to I18nPlugin(locales=[...]) in rxconfig.py to ship it." + ) + + +@i18n_cli.command(name="check") +def check_command(): + """Fail if any non-default locale has untranslated or fuzzy messages.""" + plugin, template, catalog_dir = _extract_template() + + incomplete = False + for locale in plugin.locales: + if locale == plugin.default_locale: + continue + catalog = _read_or_new_catalog(catalog_dir / f"{locale}.po", locale) + catalog.update(template) + stats = _catalog_stats(catalog, locale) + _report(stats) + incomplete = incomplete or stats.incomplete + + if incomplete: + msg = "Some locales have untranslated or fuzzy messages." + raise click.ClickException(msg) + console.success("All locales are complete.") diff --git a/packages/reflex-i18n/src/reflex_i18n/component.py b/packages/reflex-i18n/src/reflex_i18n/component.py new file mode 100644 index 00000000000..750652efdb2 --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/component.py @@ -0,0 +1,18 @@ +"""The app-wrap provider component backing client-side translations.""" + +from __future__ import annotations + +from reflex_base.components.component import Component + + +class I18nProvider(Component): + """Provides the active locale and message catalog via React context. + + Implemented in the static web template ``utils/i18n.js``; pulled into the + app shell automatically (via ``VarData.app_wraps``) whenever ``rx.t`` is + used. + """ + + library = "$/utils/i18n" + + tag = "I18nProvider" diff --git a/packages/reflex-i18n/src/reflex_i18n/config.py b/packages/reflex-i18n/src/reflex_i18n/config.py new file mode 100644 index 00000000000..1f856d6945c --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/config.py @@ -0,0 +1,101 @@ +"""App-level i18n configuration.""" + +from __future__ import annotations + +import dataclasses +from collections.abc import Sequence +from pathlib import Path + +# Cookie persisting the user's chosen locale; read by both the client +# runtime and the server-side locale resolution. +LOCALE_COOKIE_NAME = "reflex_locale" + + +@dataclasses.dataclass(frozen=True) +class I18nConfig: + """Internationalization configuration held by :class:`I18nPlugin`.""" + + # Locales the app supports, e.g. ("en", "de"). Order is preserved for + # Accept-Language negotiation ties. + locales: tuple[str, ...] + + # The locale the source-text msgids are written in. + default_locale: str = "en" + + # Directory (relative to the app root) containing {locale}.po catalogs. + catalog_dir: str = "locales" + + def __init__( + self, + locales: Sequence[str], + default_locale: str = "en", + catalog_dir: str = "locales", + ): + """Initialize and validate the i18n configuration. + + Args: + locales: Locales the app supports, e.g. ``["en", "de"]``. + default_locale: The locale the source-text msgids are written in. + catalog_dir: Directory (relative to the app root) containing + ``{locale}.po`` catalogs. + + Raises: + ValueError: If no locales are given or the default locale is not + among them. + """ + locales_tuple = tuple(locales) + if not locales_tuple: + msg = "I18nConfig.locales must contain at least one locale." + raise ValueError(msg) + if default_locale not in locales_tuple: + msg = ( + f"I18nConfig.default_locale {default_locale!r} must be one of " + f"the configured locales {locales_tuple!r}." + ) + raise ValueError(msg) + object.__setattr__(self, "locales", locales_tuple) + object.__setattr__(self, "default_locale", default_locale) + object.__setattr__(self, "catalog_dir", catalog_dir) + + +_active_config: I18nConfig | None = None + +# Absolute catalog directory, captured when the app is constructed (cwd is the +# app root then). Used at compile and request time so catalog loading does not +# depend on the process cwd, which is not guaranteed to be the app root later. +_active_catalog_dir: Path | None = None + + +def set_active_i18n_config(config: I18nConfig | None) -> None: + """Set the i18n configuration of the running app. + + Called by ``rx.App`` so server-side translation helpers can resolve + locales without a reference to the app instance. Must be called while the + current working directory is the app root (it is during app construction). + + Args: + config: The configuration to activate, or None to deactivate. + """ + global _active_config, _active_catalog_dir + _active_config = config + _active_catalog_dir = ( + (Path.cwd() / config.catalog_dir).resolve() if config is not None else None + ) + + +def get_active_i18n_config() -> I18nConfig | None: + """Get the i18n configuration of the running app. + + Returns: + The active configuration, or None if the app has no i18n config. + """ + return _active_config + + +def get_active_catalog_dir() -> Path | None: + """Get the absolute catalog directory of the running app. + + Returns: + The absolute catalog directory, or None if i18n is not configured. + """ + return _active_catalog_dir diff --git a/packages/reflex-i18n/src/reflex_i18n/format.py b/packages/reflex-i18n/src/reflex_i18n/format.py new file mode 100644 index 00000000000..2402aa5cb68 --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/format.py @@ -0,0 +1,335 @@ +"""Client-side, locale-aware number and date formatting vars. + +``rx.i18n.number`` / ``rx.i18n.currency`` / ``rx.i18n.date`` (and friends) +format a value in the active locale using the browser's ``Intl`` API, +reactively reformatting when the locale changes. To format inside state +(server-side), use the ``format_*`` helpers in :mod:`reflex_i18n.runtime`. +""" + +from __future__ import annotations + +import functools +from typing import TYPE_CHECKING, Any + +from reflex_base.utils.imports import ImportVar +from reflex_base.vars.base import LiteralVar, Var, VarData +from reflex_base.vars.function import FunctionVar +from reflex_base.vars.sequence import StringVar + +from .component import I18nProvider +from .config import get_active_i18n_config +from .vars import _PROVIDER_PRIORITY + +if TYPE_CHECKING: + # Aliased so the `date`/`time`/`datetime` functions below don't shadow it. + import datetime as _datetime + + +def _require_config() -> None: + """Ensure the i18n plugin is configured. + + Raises: + RuntimeError: If no I18nPlugin is configured. + """ + if get_active_i18n_config() is None: + msg = ( + "rx.i18n formatting requires the i18n plugin. Add " + "I18nPlugin(locales=[...]) to rx.Config(plugins=[...])." + ) + raise RuntimeError(msg) + + +@functools.cache +def _format_var_data() -> VarData: + """VarData injecting the ``useFormat`` hook and the provider. + + Returns: + The shared VarData for number/date formatting vars. + """ + return VarData( + imports={"$/utils/i18n": [ImportVar(tag="useFormat")]}, + hooks={"const [ fmtNumber, fmtDate ] = useFormat()": None}, + app_wraps=((_PROVIDER_PRIORITY, I18nProvider.create()),), + ) + + +@functools.cache +def _locale_var_data() -> VarData: + """VarData injecting the ``useLocale`` hook and the provider. + + Returns: + The shared VarData for the active-locale var. + """ + return VarData( + imports={"$/utils/i18n": [ImportVar(tag="useLocale")]}, + hooks={"const i18nLocale = useLocale()": None}, + app_wraps=((_PROVIDER_PRIORITY, I18nProvider.create()),), + ) + + +def _number_options( + *, + style: str | None = None, + currency: str | None = None, + min_fraction_digits: int | None = None, + max_fraction_digits: int | None = None, + grouping: bool | None = None, + compact: bool = False, + options: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build ``Intl.NumberFormat`` options from curated kwargs. + + Args: + style: The Intl number style (``decimal``/``currency``/``percent``). + currency: The ISO 4217 currency code (for ``style="currency"``). + min_fraction_digits: Minimum fraction digits. + max_fraction_digits: Maximum fraction digits. + grouping: Whether to show the grouping (thousands) separator. + compact: Whether to use compact notation (e.g. ``1.2M``). + options: Raw ``Intl.NumberFormat`` options, merged last. + + Returns: + The Intl options object. + """ + opts: dict[str, Any] = {} + if style is not None: + opts["style"] = style + if currency is not None: + opts["currency"] = currency + if min_fraction_digits is not None: + opts["minimumFractionDigits"] = min_fraction_digits + if max_fraction_digits is not None: + opts["maximumFractionDigits"] = max_fraction_digits + if grouping is not None: + opts["useGrouping"] = grouping + if compact: + opts["notation"] = "compact" + if options: + opts.update(options) + return opts + + +def _call(fn_name: str, value: Any, options: dict[str, Any]) -> StringVar: + """Call a client formatter hook function with a value and options. + + Args: + fn_name: The hook function (``fmtNumber`` or ``fmtDate``). + value: The value to format (may be a Var). + options: The Intl options object. + + Returns: + A StringVar resolving to the formatted value. + """ + var_data = _format_var_data() + formatter = Var(_js_expr=fn_name, _var_data=var_data).to(FunctionVar) + return formatter.call(value, LiteralVar.create(options)).to(str) + + +def number( + value: Var[Any] | int | float, + *, + min_fraction_digits: int | None = None, + max_fraction_digits: int | None = None, + grouping: bool | None = None, + compact: bool = False, + options: dict[str, Any] | None = None, +) -> StringVar: + """Format a number in the active locale. + + Args: + value: The number to format. + min_fraction_digits: Minimum fraction digits. + max_fraction_digits: Maximum fraction digits. + grouping: Whether to show the grouping separator. + compact: Whether to use compact notation (e.g. ``1.2M``). + options: Raw ``Intl.NumberFormat`` options, merged last. + + Returns: + A StringVar resolving to the localized number. + """ + _require_config() + return _call( + "fmtNumber", + value, + _number_options( + min_fraction_digits=min_fraction_digits, + max_fraction_digits=max_fraction_digits, + grouping=grouping, + compact=compact, + options=options, + ), + ) + + +def currency( + value: Var[Any] | int | float, + currency: str, + *, + min_fraction_digits: int | None = None, + max_fraction_digits: int | None = None, + grouping: bool | None = None, + compact: bool = False, + options: dict[str, Any] | None = None, +) -> StringVar: + """Format a currency amount in the active locale. + + Args: + value: The amount to format. + currency: The ISO 4217 currency code (e.g. ``"EUR"``). + min_fraction_digits: Minimum fraction digits. + max_fraction_digits: Maximum fraction digits. + grouping: Whether to show the grouping separator. + compact: Whether to use compact notation. + options: Raw ``Intl.NumberFormat`` options, merged last. + + Returns: + A StringVar resolving to the localized currency amount. + """ + _require_config() + return _call( + "fmtNumber", + value, + _number_options( + style="currency", + currency=currency, + min_fraction_digits=min_fraction_digits, + max_fraction_digits=max_fraction_digits, + grouping=grouping, + compact=compact, + options=options, + ), + ) + + +def percent( + value: Var[Any] | int | float, + *, + min_fraction_digits: int | None = None, + max_fraction_digits: int | None = None, + grouping: bool | None = None, + options: dict[str, Any] | None = None, +) -> StringVar: + """Format a ratio as a percentage in the active locale (``0.15`` -> ``15%``). + + Args: + value: The ratio to format (``1`` == 100%). + min_fraction_digits: Minimum fraction digits. + max_fraction_digits: Maximum fraction digits. + grouping: Whether to show the grouping separator. + options: Raw ``Intl.NumberFormat`` options, merged last. + + Returns: + A StringVar resolving to the localized percentage. + """ + _require_config() + return _call( + "fmtNumber", + value, + _number_options( + style="percent", + min_fraction_digits=min_fraction_digits, + max_fraction_digits=max_fraction_digits, + grouping=grouping, + options=options, + ), + ) + + +def _date_options( + *, + date_style: str | None = None, + time_style: str | None = None, + options: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build ``Intl.DateTimeFormat`` options from curated kwargs. + + Args: + date_style: The Intl ``dateStyle`` (``short``/``medium``/``long``/``full``). + time_style: The Intl ``timeStyle``. + options: Raw ``Intl.DateTimeFormat`` options, merged last. + + Returns: + The Intl options object. + """ + opts: dict[str, Any] = {} + if date_style is not None: + opts["dateStyle"] = date_style + if time_style is not None: + opts["timeStyle"] = time_style + if options: + opts.update(options) + return opts + + +def date( + value: Var[Any] | _datetime.date | str, + *, + length: str = "medium", + options: dict[str, Any] | None = None, +) -> StringVar: + """Format a date in the active locale. + + Args: + value: The date to format (a Var or ISO string). + length: The date length (``short``/``medium``/``long``/``full``). + options: Raw ``Intl.DateTimeFormat`` options, merged last. + + Returns: + A StringVar resolving to the localized date. + """ + _require_config() + return _call("fmtDate", value, _date_options(date_style=length, options=options)) + + +def time( + value: Var[Any] | _datetime.time | str, + *, + length: str = "medium", + options: dict[str, Any] | None = None, +) -> StringVar: + """Format a time in the active locale. + + Args: + value: The time to format (a Var or ISO string). + length: The time length (``short``/``medium``/``long``/``full``). + options: Raw ``Intl.DateTimeFormat`` options, merged last. + + Returns: + A StringVar resolving to the localized time. + """ + _require_config() + return _call("fmtDate", value, _date_options(time_style=length, options=options)) + + +def datetime( + value: Var[Any] | _datetime.datetime | str, + *, + length: str = "medium", + options: dict[str, Any] | None = None, +) -> StringVar: + """Format a date and time in the active locale. + + Args: + value: The datetime to format (a Var or ISO string). + length: The length (``short``/``medium``/``long``/``full``). + options: Raw ``Intl.DateTimeFormat`` options, merged last. + + Returns: + A StringVar resolving to the localized date and time. + """ + _require_config() + return _call( + "fmtDate", + value, + _date_options(date_style=length, time_style=length, options=options), + ) + + +@functools.cache +def _locale_var() -> StringVar: + """Build the active-locale var (cached singleton). + + Returns: + A StringVar resolving to the active locale code. + """ + return Var(_js_expr="i18nLocale", _var_data=_locale_var_data()).to(str) diff --git a/packages/reflex-i18n/src/reflex_i18n/plugin.py b/packages/reflex-i18n/src/reflex_i18n/plugin.py new file mode 100644 index 00000000000..693cbc0d90d --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/plugin.py @@ -0,0 +1,108 @@ +"""The Reflex plugin that wires i18n into an app's compilation.""" + +from __future__ import annotations + +import dataclasses +from collections.abc import Sequence +from pathlib import Path + +from reflex_base.plugins.base import CommonContext, Plugin, PreCompileContext +from reflex_base.utils import console +from typing_extensions import Unpack + +from .catalog import compile_catalog_module, compile_index_module, read_po_catalog +from .config import I18nConfig, set_active_i18n_config +from .registry import collected_messages + +# The compiled catalog modules are written under ``.web/i18n``. +_I18N_WEB_DIR = "i18n" +# The client runtime is written to ``.web/utils/i18n.js`` (the ``$/utils/i18n`` +# import specifier used by the provider and translation vars). +_RUNTIME_WEB_PATH = "utils/i18n.js" +_RUNTIME_SOURCE = Path(__file__).parent / "_web" / "i18n.js" + + +@dataclasses.dataclass +class I18nPlugin(Plugin): + """Enables ``rx.t`` translations and compiles per-locale catalogs. + + Add to ``rx.Config(plugins=[I18nPlugin(locales=[...])])``. + """ + + locales: Sequence[str] + default_locale: str = "en" + catalog_dir: str = "locales" + + def _config(self) -> I18nConfig: + """Build the i18n configuration from the plugin's fields. + + Returns: + The validated configuration. + """ + return I18nConfig( + locales=self.locales, + default_locale=self.default_locale, + catalog_dir=self.catalog_dir, + ) + + def __post_init__(self): + """Activate the i18n config and register framework state.""" + set_active_i18n_config(self._config()) + # Registers I18nState (substate) and the event-scope locale provider. + # Imported here rather than at module top so an app opting in triggers + # it, but the package's other import paths (e.g. the reflex CLI loading + # the i18n entry point) don't register state. + from . import state # noqa: F401 + + def get_static_assets( + self, **context: Unpack[CommonContext] + ) -> Sequence[tuple[Path, str | bytes]]: + """Ship the client i18n runtime. + + Args: + context: The plugin context (unused). + + Returns: + The client runtime written to ``.web/utils/i18n.js``. + """ + return [(Path(_RUNTIME_WEB_PATH), _RUNTIME_SOURCE.read_text(encoding="utf-8"))] + + def pre_compile(self, **context: Unpack[PreCompileContext]) -> None: + """Register the catalog-emission task. + + Args: + context: The pre-compile context. + """ + context["add_save_task"](self._compile_catalogs) + + def _compile_catalogs(self) -> list[tuple[str, str]]: + """Compile per-locale catalog modules from the app's ``.po`` files. + + Returns: + Pairs of (``.web``-relative path, module code). + """ + config = self._config() + used_messages = collected_messages() + catalog_dir = Path.cwd() / self.catalog_dir + + results: list[tuple[str, str]] = [ + (f"{_I18N_WEB_DIR}/index.js", compile_index_module(config)) + ] + for locale in config.locales: + po_path = catalog_dir / f"{locale}.po" + catalog = read_po_catalog(po_path) if po_path.exists() else None + if catalog is None and locale != config.default_locale: + console.warn( + f"No translation catalog found for locale {locale!r} " + f"(expected {po_path})." + ) + results.append(( + f"{_I18N_WEB_DIR}/{locale}.js", + compile_catalog_module( + catalog, + used_messages, + locale, + is_default_locale=locale == config.default_locale, + ), + )) + return results diff --git a/packages/reflex-i18n/src/reflex_i18n/registry.py b/packages/reflex-i18n/src/reflex_i18n/registry.py new file mode 100644 index 00000000000..54bcff5efe6 --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/registry.py @@ -0,0 +1,74 @@ +"""Compile-time registry of messages used via :func:`reflex_i18n.vars.t`. + +Every ``rx.t`` call records its message here. After all pages have been +evaluated, the registry is the complete set of static messages the app uses; +the compiler emits per-locale catalog modules containing only these entries +(tree-shaking) and the extraction tooling turns them into ``.pot`` entries. + +The registry is intentionally never reset between compiles: components built +at module import time register before the compiler runs, and a fresh process +(any prod compile) starts empty anyway. In dev hot-reload this can retain +stale entries, which only makes dev catalogs slightly larger. +""" + +from __future__ import annotations + +from typing import NamedTuple + +# gettext's msgctxt separator, used to build unique catalog keys. +CONTEXT_SEPARATOR = "\x04" + + +class MessageKey(NamedTuple): + """A translatable message collected from an ``rx.t`` call.""" + + message: str + plural: str | None = None + context: str | None = None + + @property + def catalog_key(self) -> str: + """The key identifying this message in a compiled catalog module. + + Returns: + The msgid, prefixed with the gettext context convention if a + context is set. + """ + if self.context: + return f"{self.context}{CONTEXT_SEPARATOR}{self.message}" + return self.message + + @property + def msgid(self) -> str | tuple[str, str]: + """The babel catalog msgid: a string, or (singular, plural) tuple. + + Returns: + The plural pair if this is a plural message, else the message. + """ + return self.message if self.plural is None else (self.message, self.plural) + + +_collected: dict[MessageKey, None] = {} + + +def register(key: MessageKey) -> None: + """Record a message used by the app. + + Args: + key: The message to record. + """ + _collected[key] = None + + +def collected_messages() -> tuple[MessageKey, ...]: + """Get all messages registered so far, in first-use order. + + Returns: + The registered messages. + """ + return tuple(_collected) + + +def clear_messages() -> None: + """Clear the registry. Only intended for tests.""" + _collected.clear() diff --git a/packages/reflex-i18n/src/reflex_i18n/runtime.py b/packages/reflex-i18n/src/reflex_i18n/runtime.py new file mode 100644 index 00000000000..057c3784d6a --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/runtime.py @@ -0,0 +1,485 @@ +"""Server-side translation of dynamic (state) content via gettext. + +Event handlers and computed vars call :func:`gettext` (aliased ``_``) to +translate strings in the active locale. The active locale is held in a +contextvar set per client while events are processed; translating in a +computed var means the string is retranslated and re-pushed as a delta +whenever the locale changes. +""" + +from __future__ import annotations + +import copy +import gettext as _gettext_module +from collections.abc import Iterator, Sequence +from contextlib import contextmanager +from contextvars import ContextVar +from pathlib import Path +from typing import TYPE_CHECKING + +from reflex_base.vars.dep_tracking import register_implicit_dependency + +from .config import get_active_catalog_dir, get_active_i18n_config + +if TYPE_CHECKING: + import datetime + import decimal + + from reflex_base.vars.base import Var + +# The locale in effect for the current client while an event is processed. +_active_locale: ContextVar[str | None] = ContextVar( + "reflex_active_locale", default=None +) + +# Lazily-built gettext translators, keyed by locale. +_translations: dict[str, _gettext_module.NullTranslations] = {} + + +@contextmanager +def use_locale(locale: str | None) -> Iterator[None]: + """Set the active locale for the duration of the context. + + Args: + locale: The locale to activate, or None to leave translations + untranslated. + + Yields: + None + """ + token = _active_locale.set(locale) + try: + yield + finally: + _active_locale.reset(token) + + +def get_locale() -> str | None: + """Get the locale active for the current client. + + Returns: + The active locale, or None if none is set. + """ + return _active_locale.get() + + +def _catalog_path(locale: str) -> Path | None: + """Find the ``.po`` catalog for a locale. + + Args: + locale: The locale to find a catalog for. + + Returns: + The path of the catalog file, or None if the app has no i18n config + or the file does not exist. + """ + catalog_dir = get_active_catalog_dir() + if catalog_dir is None: + return None + path = catalog_dir / f"{locale}.po" + return path if path.exists() else None + + +def _get_translations(locale: str) -> _gettext_module.NullTranslations: + """Get (and cache) the gettext translator for a locale. + + Args: + locale: The locale to translate into. + + Returns: + A gettext translator; a null (pass-through) translator if no catalog + exists for the locale. + """ + cached = _translations.get(locale) + if cached is not None: + return cached + + path = _catalog_path(locale) + if path is None: + translations: _gettext_module.NullTranslations = ( + _gettext_module.NullTranslations() + ) + else: + import io + + from babel.messages.mofile import write_mo + + from .catalog import read_po_catalog + + catalog = read_po_catalog(path) + buffer = io.BytesIO() + write_mo(buffer, catalog) + buffer.seek(0) + translations = _gettext_module.GNUTranslations(buffer) + + _translations[locale] = translations + return translations + + +def clear_translations_cache() -> None: + """Drop cached translators. Intended for tests and hot reload.""" + _translations.clear() + + +def gettext(message: str) -> str: + """Translate a message into the active locale. + + Args: + message: The source-locale message. + + Returns: + The translated message, or the source message if untranslated or no + locale is active. + """ + locale = _active_locale.get() + if locale is None: + return message + return _get_translations(locale).gettext(message) + + +def ngettext(singular: str, plural: str, n: int) -> str: + """Translate a message with plural forms into the active locale. + + Args: + singular: The source-locale singular message. + plural: The source-locale plural message. + n: The quantity selecting the form. + + Returns: + The translated message for ``n``, or the appropriate source message + if untranslated or no locale is active. + """ + locale = _active_locale.get() + if locale is None: + return singular if n == 1 else plural + return _get_translations(locale).ngettext(singular, plural, n) + + +def pgettext(context: str, message: str) -> str: + """Translate a context-qualified message into the active locale. + + Args: + context: The gettext message context (msgctxt). + message: The source-locale message. + + Returns: + The translated message, or the source message if untranslated or no + locale is active. + """ + locale = _active_locale.get() + if locale is None: + return message + return _get_translations(locale).pgettext(context, message) + + +def negotiate_locale( + accept_language: str, supported: Sequence[str], default: str +) -> str: + """Choose the best supported locale for an Accept-Language header. + + Args: + accept_language: The raw ``Accept-Language`` header value. + supported: The locales the app supports. + default: The locale to return if none match. + + Returns: + The best matching supported locale, or ``default``. + """ + supported_set = set(supported) + by_language: dict[str, str] = {} + for locale in supported: + by_language.setdefault(locale.split("-")[0], locale) + + ranked: list[tuple[float, int, str]] = [] + for index, entry in enumerate(accept_language.split(",")): + tag, _, params = entry.strip().partition(";") + tag = tag.strip().replace("_", "-") + if not tag or tag == "*": + continue + quality = 1.0 + if params.strip().startswith("q="): + try: + quality = float(params.strip()[2:]) + except ValueError: + quality = 0.0 + if quality <= 0.0: + # q=0 means "not acceptable" (RFC 7231 §5.3.1); exclude it. + continue + # Sort by quality descending, then by original order for ties. + ranked.append((-quality, index, tag)) + + for _, _, tag in sorted(ranked): + if tag in supported_set: + return tag + language = tag.split("-")[0] + if language in by_language: + return by_language[language] + return default + + +def _format_locale() -> str: + """The locale to format in: active locale, else the default, else ``en``. + + Returns: + The locale code for server-side formatting. + """ + locale = _active_locale.get() + if locale is not None: + return locale + config = get_active_i18n_config() + return config.default_locale if config is not None else "en" + + +def _babel_locale() -> str: + """The active format locale, normalized for Babel. + + Returns: + The locale with ``-`` replaced by ``_`` (Babel rejects ``en-US``). + """ + return _format_locale().replace("-", "_") + + +def _apply_number( + kind: str, + number: float | decimal.Decimal, + *, + min_fraction_digits: int | None, + max_fraction_digits: int | None, + grouping: bool, + currency: str | None = None, +) -> str: + """Format a number with the locale's own pattern. + + Using the locale's ``NumberPattern`` keeps locale-specific affixes and + grouping (the ``%`` spacing, currency symbol position, non-Western grouping) + that a hand-built pattern would lose; the requested fraction digits are + applied by overriding the pattern's ``frac_prec``. + + Args: + kind: ``"decimal"``, ``"percent"`` or ``"currency"``. + number: The number to format. + min_fraction_digits: Minimum fraction digits, or None for the default. + max_fraction_digits: Maximum fraction digits, or None for the default. + grouping: Whether to show the grouping separator. + currency: The ISO 4217 currency code (for ``kind="currency"``). + + Returns: + The localized number. + """ + from babel import Locale + + locale = Locale.parse(_babel_locale()) + if kind == "currency": + pattern = locale.currency_formats["standard"] + elif kind == "percent": + pattern = locale.percent_formats[None] + else: + pattern = locale.decimal_formats[None] + + # Default: use the pattern's own fraction digits (currency_digits for + # currency). If digits are requested, override frac_prec on a copy so the + # shared locale pattern is untouched, and drop currency_digits so the + # override takes effect. + currency_digits = True + if min_fraction_digits is not None or max_fraction_digits is not None: + low = min_fraction_digits if min_fraction_digits is not None else 0 + high = ( + max_fraction_digits + if max_fraction_digits is not None + else max(low, pattern.frac_prec[1]) + ) + pattern = copy.copy(pattern) + pattern.frac_prec = (low, high) + currency_digits = False + + return pattern.apply( + number, + locale, + currency=currency, + currency_digits=currency_digits, + group_separator=grouping, + ) + + +def format_number( + number: float | decimal.Decimal, + *, + min_fraction_digits: int | None = None, + max_fraction_digits: int | None = None, + grouping: bool = True, + compact: bool = False, +) -> str: + """Format a number in the active locale (server-side). + + Args: + number: The number to format. + min_fraction_digits: Minimum fraction digits. + max_fraction_digits: Maximum fraction digits. + grouping: Whether to show the grouping separator. + compact: Whether to use compact notation (e.g. ``1.2M``); honors + ``max_fraction_digits`` only. + + Returns: + The localized number. + """ + if compact: + from babel import numbers + + return numbers.format_compact_decimal( + float(number), + locale=_babel_locale(), + fraction_digits=max_fraction_digits or 0, + ) + return _apply_number( + "decimal", + number, + min_fraction_digits=min_fraction_digits, + max_fraction_digits=max_fraction_digits, + grouping=grouping, + ) + + +# Babel's canonical name; ``format_number`` is the friendlier alias. +format_decimal = format_number + + +def format_currency( + number: float | decimal.Decimal, + currency: str, + *, + min_fraction_digits: int | None = None, + max_fraction_digits: int | None = None, + grouping: bool = True, +) -> str: + """Format a currency amount in the active locale (server-side). + + Args: + number: The amount to format. + currency: The ISO 4217 currency code (e.g. ``"EUR"``). + min_fraction_digits: Minimum fraction digits (default: the currency's). + max_fraction_digits: Maximum fraction digits (default: the currency's). + grouping: Whether to show the grouping separator. + + Returns: + The localized currency amount. + """ + return _apply_number( + "currency", + number, + min_fraction_digits=min_fraction_digits, + max_fraction_digits=max_fraction_digits, + grouping=grouping, + currency=currency, + ) + + +def format_percent( + number: float | decimal.Decimal, + *, + min_fraction_digits: int | None = None, + max_fraction_digits: int | None = None, + grouping: bool = True, +) -> str: + """Format a ratio as a percentage in the active locale (``0.15`` -> ``15%``). + + Args: + number: The ratio to format (``1`` == 100%). + min_fraction_digits: Minimum fraction digits. + max_fraction_digits: Maximum fraction digits. + grouping: Whether to show the grouping separator. + + Returns: + The localized percentage. + """ + return _apply_number( + "percent", + number, + min_fraction_digits=min_fraction_digits, + max_fraction_digits=max_fraction_digits, + grouping=grouping, + ) + + +def format_date(value: datetime.date, *, length: str = "medium") -> str: + """Format a date in the active locale (server-side). + + Args: + value: The ``date``/``datetime`` to format. + length: The date length (``short``/``medium``/``long``/``full``). + + Returns: + The localized date. + """ + from babel import dates + + return dates.format_date(value, format=length, locale=_babel_locale()) + + +def format_time( + value: datetime.time | datetime.datetime, *, length: str = "medium" +) -> str: + """Format a time in the active locale (server-side). + + Args: + value: The ``time``/``datetime`` to format. + length: The time length (``short``/``medium``/``long``/``full``). + + Returns: + The localized time. + """ + from babel import dates + + return dates.format_time(value, format=length, locale=_babel_locale()) + + +def format_datetime(value: datetime.datetime, *, length: str = "medium") -> str: + """Format a date and time in the active locale (server-side). + + Args: + value: The ``datetime`` to format. + length: The length (``short``/``medium``/``long``/``full``). + + Returns: + The localized date and time. + """ + from babel import dates + + return dates.format_datetime(value, format=length, locale=_babel_locale()) + + +def _locale_dependency() -> Var | None: + """The var a gettext-family call implies a dependency on. + + Returns: + The active-locale var backing dynamic retranslation. + """ + # Lazy import: registering this provider must not itself import .state (and + # register I18nState); state is only needed once a real gettext computed + # var invokes this during dependency scanning. + from .state import I18nState + + return I18nState.locale + + +# Registered here rather than in :mod:`.state` so a computed var that calls +# gettext (or a ``format_*`` helper) gains its dependency on the active locale +# as soon as the app imports it — before (and independently of) I18nPlugin +# construction, which in a backend worker can happen after the getter's +# dependencies are scanned and cached. Registering only populates a dict; the +# provider imports state lazily. ``format_decimal`` is ``format_number``, so +# the tuple dedupes it. +register_implicit_dependency( + ( + gettext, + ngettext, + pgettext, + format_number, + format_decimal, + format_currency, + format_percent, + format_date, + format_time, + format_datetime, + ), + _locale_dependency, +) diff --git a/packages/reflex-i18n/src/reflex_i18n/state.py b/packages/reflex-i18n/src/reflex_i18n/state.py new file mode 100644 index 00000000000..11a035e1c97 --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/state.py @@ -0,0 +1,148 @@ +"""Framework state backing localization. + +Registered as a substate of the root state when this module is imported (which +the :class:`~reflex_i18n.plugin.I18nPlugin` triggers). Holds the client's +chosen locale in a cookie and exposes the resolved locale for server-side +translation of dynamic content. +""" + +from __future__ import annotations + +import contextlib + +from reflex_base.event.processor.scope import register_event_scope_provider +from reflex_base.utils.imports import ImportVar +from reflex_base.vars.base import Var, VarData, computed_var +from reflex_base.vars.function import FunctionVar + +from reflex.event import EventType, event, run_script +from reflex.istate.storage import Cookie +from reflex.state import BaseState, State + +from .config import LOCALE_COOKIE_NAME, get_active_i18n_config +from .runtime import negotiate_locale, use_locale + + +def _resolve_locale(locale_cookie: str, accept_language: str) -> str: + """Resolve a locale: chosen cookie, then Accept-Language, then default. + + Args: + locale_cookie: The user's chosen locale, or empty if none. + accept_language: The raw ``Accept-Language`` header value. + + Returns: + The resolved locale. + """ + config = get_active_i18n_config() + if config is None: + return locale_cookie or "en" + if locale_cookie and locale_cookie in config.locales: + return locale_cookie + return negotiate_locale(accept_language, config.locales, config.default_locale) + + +class I18nState(State): + """Substate holding the active locale for the current client.""" + + # The locale explicitly chosen by the user, persisted client-side. Empty + # until the user picks one, so Accept-Language keeps driving the default. + locale_cookie: str = Cookie("", name=LOCALE_COOKIE_NAME) + + @classmethod + def is_user_defined(cls) -> bool: + """Whether this is a user-authored state. + + Returns: + False: this is a framework state, so it is excluded from telemetry + and auto-setter generation. + """ + return False + + @computed_var(cache=True, backend=True, auto_deps=False, deps=["locale_cookie"]) + def locale(self) -> str: + """The locale in effect for this client (server-side). + + Returns: + The resolved locale. + """ + # Depends only on locale_cookie (not router): accept-language is read + # for the initial value but is fixed per session, and depending on + # router would dirty this substate on every navigation. + return _resolve_locale(self.locale_cookie, self.router.headers.accept_language) + + @event + def set_locale(self, locale: str) -> None: + """Set the client's chosen locale. + + Args: + locale: The locale to switch to. + + Raises: + ValueError: If the locale is not configured. + """ + config = get_active_i18n_config() + if config is not None and locale not in config.locales: + msg = ( + f"Locale {locale!r} is not configured. Configured locales: " + f"{list(config.locales)}." + ) + raise ValueError(msg) + self.locale_cookie = locale + + +def set_locale(locale: str | Var[str]) -> EventType: + """Switch the app's locale (static content instantly, dynamic on next delta). + + Args: + locale: The locale to switch to. + + Returns: + The client-side and server-side switch events. + """ + switch_client = run_script( + Var( + _js_expr="switchLocale", + _var_data=VarData( + imports={"$/utils/i18n": [ImportVar(tag="switchLocale")]} + ), + ) + .to(FunctionVar) + .call(locale) + ) + return [switch_client, I18nState.set_locale(locale)] + + +async def _locale_scope( + root_state: BaseState, +) -> contextlib.AbstractContextManager[None]: + """Per-event locale context, or a no-op when this app has no i18n plugin. + + Args: + root_state: The client's root state instance. + + Returns: + A context manager activating the client's locale. + """ + from reflex_base.config import get_config + + from .plugin import I18nPlugin + + # Gate on the CURRENT app's plugins rather than the module-global active + # config: the provider is registered process-wide once i18n is imported, + # but an app not using i18n (possible when several apps share a process) + # must be a no-op so it never touches I18nState. + if not any(isinstance(plugin, I18nPlugin) for plugin in get_config().plugins): + return contextlib.nullcontext() + i18n_state = await root_state.get_state(I18nState) + locale = _resolve_locale( + i18n_state.locale_cookie, i18n_state.router.headers.accept_language + ) + return use_locale(locale) + + +register_event_scope_provider(_locale_scope) + +# The implicit dependency of gettext-family calls on I18nState.locale (so +# translated computed vars recompute when the locale changes) is registered in +# .runtime, next to those functions, so it takes effect as soon as the app +# imports gettext — independently of when this module is imported. diff --git a/packages/reflex-i18n/src/reflex_i18n/vars.py b/packages/reflex-i18n/src/reflex_i18n/vars.py new file mode 100644 index 00000000000..67ed7553113 --- /dev/null +++ b/packages/reflex-i18n/src/reflex_i18n/vars.py @@ -0,0 +1,106 @@ +"""The ``rx.t`` translation var for static component content.""" + +from __future__ import annotations + +import functools +from typing import Any + +from reflex_base.utils.imports import ImportVar +from reflex_base.vars.base import LiteralVar, Var, VarData +from reflex_base.vars.function import FunctionVar +from reflex_base.vars.sequence import StringVar + +from .component import I18nProvider +from .config import get_active_i18n_config +from .registry import MessageKey, register + +# Outside the ErrorBoundary (55) so error fallback UI can translate too. +_PROVIDER_PRIORITY = 58 + + +@functools.cache +def _i18n_var_data() -> VarData: + """VarData shared by all translation vars. + + Injects the ``useTranslation`` hook into any component using a + translation and drags the ``I18nProvider`` into the app shell. + + Returns: + The shared VarData. + """ + return VarData( + imports={"$/utils/i18n": [ImportVar(tag="useTranslation")]}, + hooks={"const [ t_, tp_ ] = useTranslation()": None}, + app_wraps=((_PROVIDER_PRIORITY, I18nProvider.create()),), + ) + + +def t( + message: str, + /, + *, + plural: str | None = None, + count: int | Var[int] | None = None, + context: str | None = None, + **params: Any, +) -> StringVar: + """Translate a static message via the client-side locale catalog. + + The message text is the msgid in the default locale; translations are + looked up at runtime from the active locale's catalog, falling back to + the message itself. ``{name}``-style placeholders are interpolated + client-side from ``params``, so params may be state Vars. + + Args: + message: The message in the default locale. Must be a literal string + so it can be extracted into catalogs at compile time. + plural: The plural form of the message in the default locale. + count: The quantity selecting the plural form. Required with + ``plural``, and implicitly available as the ``{count}`` + placeholder. + context: Optional gettext msgctxt to disambiguate identical messages. + **params: Values for ``{name}`` placeholders in the message. + + Returns: + A StringVar resolving to the translated, interpolated message. + + Raises: + TypeError: If message or plural is not a literal string. + ValueError: If only one of ``plural`` and ``count`` is given. + RuntimeError: If no I18nPlugin is configured. + """ + if get_active_i18n_config() is None: + msg = ( + "rx.t() requires the i18n plugin. Add " + "I18nPlugin(locales=[...]) to rx.Config(plugins=[...])." + ) + raise RuntimeError(msg) + if isinstance(message, Var) or not isinstance(message, str): + msg = ( + "rx.t() requires a literal string message so it can be extracted " + "into translation catalogs at compile time. To translate dynamic " + "content, translate it server-side in state instead " + "(see reflex.i18n)." + ) + raise TypeError(msg) + if plural is not None and (isinstance(plural, Var) or not isinstance(plural, str)): + msg = "rx.t() requires a literal string for plural." + raise TypeError(msg) + if (plural is None) != (count is None): + msg = "rx.t() requires count and plural to be passed together." + raise ValueError(msg) + + key = MessageKey(message=message, plural=plural, context=context) + register(key) + + if plural is not None: + params.setdefault("count", count) + params_var = LiteralVar.create(params) + var_data = _i18n_var_data() + + if plural is None: + translate = Var(_js_expr="t_", _var_data=var_data).to(FunctionVar) + return translate.call(key.catalog_key, params_var).to(str) + + translate_plural = Var(_js_expr="tp_", _var_data=var_data).to(FunctionVar) + return translate_plural.call(key.catalog_key, plural, count, params_var).to(str) diff --git a/pyi_hashes.json b/pyi_hashes.json index 481c3e8ef8b..0b234bde4a6 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -118,7 +118,7 @@ "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "99ebcfc07868061bdc3c2010d85a153f", "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "4f6c26f8c76543cc41e2b9dc400ece8a", "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "58521fcd1b514804f534d97624e82c9a", - "reflex/__init__.pyi": "56385a4f0d9431eb0056dbc5553a58f9", + "reflex/__init__.pyi": "631944887ada406c22b93e2fb3b78e15", "reflex/components/__init__.pyi": "9facd05a776d0641432696bbf8e34388", "reflex/experimental/memo.pyi": "36e5d5f97eb64e94c0974e909e7e2952" } diff --git a/pyproject.toml b/pyproject.toml index 5db2a059ccd..c3e60fc60ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,7 @@ reflex = "reflex.reflex:cli" dev = [ "alembic", "asynctest", + "reflex-i18n", "codespell", "darglint", "dill", @@ -395,6 +396,7 @@ reflex-components-recharts = false reflex-components-sonner = false reflex-enterprise = false reflex-hosting-cli = false +reflex-i18n = false # Security fix (CVE-2026-54283 form() DoS) shipped in starlette 1.3.1; exempt so the # pinned floor resolves before it ages out of the exclude-newer window. starlette = false @@ -414,6 +416,7 @@ reflex-components-radix.workspace = true reflex-components-react-player.workspace = true reflex-components-recharts.workspace = true reflex-components-sonner.workspace = true +reflex-i18n.workspace = true reflex-docgen.workspace = true reflex-hosting-cli.workspace = true reflex-components-internal.workspace = true diff --git a/reflex/__init__.py b/reflex/__init__.py index 6b23b495d3e..0b55d8e1dcf 100644 --- a/reflex/__init__.py +++ b/reflex/__init__.py @@ -214,6 +214,16 @@ "upload_files_chunk", "window_alert", ], + "i18n": [ + "I18nConfig", + "I18nPlugin", + "I18nState", + "gettext", + "ngettext", + "pgettext", + "set_locale", + "t", + ], "istate.storage": [ "Cookie", "LocalStorage", @@ -251,6 +261,7 @@ "vars", "config", "compiler", + "i18n", "plugins", } _SUBMOD_ATTRS: lazy_loader.SubmodAttrsType = _MAPPING diff --git a/reflex/i18n.py b/reflex/i18n.py new file mode 100644 index 00000000000..a3aa2702fee --- /dev/null +++ b/reflex/i18n.py @@ -0,0 +1,71 @@ +"""Public internationalization API (provided by the ``reflex-i18n`` package). + +Static component content is translated with ``rx.t``; dynamic (state) content +is translated server-side with ``gettext``/``ngettext``/``pgettext`` (aliased +``_``). Configure locales with ``I18nPlugin`` in ``rx.Config(plugins=[...])``. + +Requires ``reflex-i18n`` to be installed (``pip install reflex-i18n``). +""" + +try: + from reflex_i18n import ( + I18nConfig, + I18nPlugin, + I18nState, + currency, + format_currency, + format_date, + format_datetime, + format_decimal, + format_number, + format_percent, + format_time, + gettext, + locale, + ngettext, + number, + percent, + pgettext, + set_locale, + t, + ) + + # Accessed as rx.i18n.date / .time / .datetime. Kept out of __all__ (below) + # so `from reflex.i18n import *` cannot shadow the stdlib names; the alias + # form marks these as intentional re-exports. Prefer attribute access. + from reflex_i18n import date as date + from reflex_i18n import datetime as datetime + from reflex_i18n import time as time +except ImportError as e: # pragma: no cover + msg = ( + "The `reflex-i18n` package is required for i18n features (rx.t, " + 'rx.i18n). Install it with `pip install "reflex-i18n"`.' + ) + raise ImportError(msg) from e + +_ = gettext + +# "date", "datetime" and "time" are intentionally omitted: they are available +# as rx.i18n.date/.time/.datetime but excluded from `import *` so they cannot +# shadow the stdlib names. +__all__ = [ + "I18nConfig", + "I18nPlugin", + "I18nState", + "currency", + "format_currency", + "format_date", + "format_datetime", + "format_decimal", + "format_number", + "format_percent", + "format_time", + "gettext", + "locale", + "ngettext", + "number", + "percent", + "pgettext", + "set_locale", + "t", +] diff --git a/reflex/reflex.py b/reflex/reflex.py index 3c340fccea2..7ab8417fd83 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -966,5 +966,37 @@ def _convert_reflex_loglevel_to_reflex_cli_loglevel( cli.add_command(script_cli, name="script") cli.add_command(custom_components_cli, name="component") + +def _add_plugin_cli_commands() -> None: + """Attach command groups contributed by installed packages. + + Any package can add a subcommand by declaring a ``reflex.cli`` entry point + resolving to a ``click.Command`` (e.g. ``reflex-i18n`` provides ``i18n``). + A failure to load one plugin's command must not break the whole CLI. + """ + from importlib.metadata import entry_points + + for entry_point in entry_points(group="reflex.cli"): + try: + command = entry_point.load() + except Exception as exc: + console.warn( + f"Failed to load CLI command {entry_point.name!r} from " + f"{entry_point.value!r}: {exc}" + ) + continue + # Guard against a resolved object that is not a Click command, which + # would otherwise make cli.add_command raise and abort the whole CLI. + if not isinstance(command, click.Command): + console.warn( + f"CLI command {entry_point.name!r} from {entry_point.value!r} " + f"is not a click.Command; skipping." + ) + continue + cli.add_command(command, name=entry_point.name) + + +_add_plugin_cli_commands() + if __name__ == "__main__": cli() diff --git a/reflex/state.py b/reflex/state.py index a488cab97e7..1c566c064f4 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -2654,6 +2654,29 @@ def reload_state_module( state: Recursive argument for the state class to reload. """ + removed: set[str] = set() + _reload_state_module(module, state, removed) + # Drop dependency edges that point at reloaded states from every surviving + # state. A state removed here re-registers its edges when its module is + # re-imported; without this, an edge stored on a state that is NOT in the + # reloaded module (e.g. a package-level state a computed var depends on) + # would dangle and crash _mark_dirty_computed_vars on the next app. + if removed: + _purge_dependencies_on_states(removed, state.get_root_state()) + + +def _reload_state_module( + module: str, + state: type[BaseState], + removed: set[str], +) -> None: + """Recursively reset rx.State subclasses defined in ``module``. + + Args: + module: The module to reload. + state: The state class to reload. + removed: Accumulates the full names of states removed during reload. + """ from reflex_base.registry import RegistrationContext # Reset the _app_ref of OnLoadInternalState to avoid stale references. @@ -2670,11 +2693,32 @@ def reload_state_module( reg_ctx = RegistrationContext.get() substates = reg_ctx.get_substates(state) for subclass in tuple(substates): - reload_state_module(module=module, state=subclass) + _reload_state_module(module, subclass, removed) if subclass.__module__ == module and module is not None: + removed.add(subclass.get_full_name()) all_base_state_classes.pop(subclass.get_full_name(), None) substates.remove(subclass) state._always_dirty_substates.discard(subclass.get_name()) state._var_dependencies = {} state._init_var_dependency_dicts() state.get_class_substate.cache_clear() + + +def _purge_dependencies_on_states( + removed: set[str], + root: type[BaseState], +) -> None: + """Remove dependency edges pointing at any of ``removed`` from all states. + + Args: + removed: Full names of states that were reloaded/removed. + root: The root state used to resolve remaining state classes. + """ + for full_name in tuple(all_base_state_classes): + try: + cls = root.get_class_substate(full_name) + except ValueError: + continue + cls._potentially_dirty_states.difference_update(removed) + for deps in cls._var_dependencies.values(): + deps.difference_update({edge for edge in deps if edge[0] in removed}) diff --git a/tests/integration/tests_playwright/test_i18n.py b/tests/integration/tests_playwright/test_i18n.py new file mode 100644 index 00000000000..fe0b2b36874 --- /dev/null +++ b/tests/integration/tests_playwright/test_i18n.py @@ -0,0 +1,322 @@ +"""Integration tests for i18n: static (rx.t) and dynamic (gettext) content.""" + +from collections.abc import Generator + +import pytest +from playwright.sync_api import Page, expect + +from reflex.testing import AppHarness + + +def I18nApp(): + """App exercising static and dynamic translation.""" + import datetime + from pathlib import Path + + import reflex as rx + from reflex.i18n import format_number + from reflex.i18n import gettext as _ + + po_header = ( + 'msgid ""\n' + 'msgstr ""\n' + '"Content-Type: text/plain; charset=UTF-8\\n"\n' + '"Plural-Forms: nplurals=2; plural=(n != 1);\\n"\n\n' + ) + de_po = po_header + ( + 'msgid "Hello"\n' + 'msgstr "Hallo"\n\n' + 'msgid "{count} item"\n' + 'msgid_plural "{count} items"\n' + 'msgstr[0] "{count} Artikel"\n' + 'msgstr[1] "{count} Artikel"\n\n' + 'msgid "Welcome"\n' + 'msgstr "Willkommen"\n' + ) + + # Written at compile time (cwd is the app root here) so the compiler and + # backend both find it. + locales = Path("locales") + locales.mkdir(exist_ok=True) + (locales / "de.po").write_text(de_po, encoding="utf-8") + (locales / "en.po").write_text(po_header, encoding="utf-8") + + class PageState(rx.State): + count: int = 1 + amount: float = 1234.5 + # A date-only and a time-only value: client-side formatting must parse + # these without a UTC day-shift or an "Invalid Date". + day: datetime.date = datetime.date(2026, 7, 18) + meeting: datetime.time = datetime.time(14, 30, 0) + # Set at emit time by an event handler; translated server-side into + # the active locale via gettext. + message: str = "" + + @rx.event + def increment(self): + self.count += 1 + + @rx.event + def greet(self): + self.message = _("Welcome") + + @rx.var + def computed_greeting(self) -> str: + # No explicit locale reference: auto-detection injects the + # dependency on the active locale so this recomputes on switch. + return _("Welcome") + + @rx.var + def server_number(self) -> str: + # Server-side Babel formatting; auto-reformats on locale switch. + return format_number(self.amount, min_fraction_digits=2) + + @rx.memo + def memo_inside() -> rx.Component: + # rx.t call site INSIDE a memoized component. + return rx.text(rx.t("Hello"), id="memo-inside") + + @rx.memo + def memo_prop(label: rx.Var[str]) -> rx.Component: + # A translated string computed outside and passed IN as a prop, which + # React.memo gates on by identity. + return rx.text(label, id="memo-prop") + + def index(): + return rx.box( + rx.input( + value=PageState.router.session.client_token, + read_only=True, + id="token", + ), + rx.text(rx.t("Hello"), id="static-hello"), + rx.text( + rx.t("{count} item", plural="{count} items", count=PageState.count), + id="static-count", + ), + rx.text(PageState.message, id="dynamic-message"), + rx.text(PageState.computed_greeting, id="computed-greeting"), + rx.text( + rx.i18n.number(PageState.amount, min_fraction_digits=2), + id="client-number", + ), + rx.text(PageState.server_number, id="server-number"), + rx.text(rx.i18n.date(PageState.day, length="medium"), id="client-date"), + rx.text(rx.i18n.time(PageState.meeting, length="short"), id="client-time"), + memo_inside(), + memo_prop(label=rx.t("Hello")), + rx.button("inc", on_click=PageState.increment, id="inc"), + rx.button("greet", on_click=PageState.greet, id="greet"), + rx.button("de", on_click=rx.i18n.set_locale("de"), id="to-de"), + rx.button("en", on_click=rx.i18n.set_locale("en"), id="to-en"), + ) + + app = rx.App() + app.add_page(index) + + # Set after App() because its __post_init__ reloads the config. + rx.config.get_config().plugins = [ + rx.i18n.I18nPlugin(locales=["en", "de"], default_locale="en") + ] + + +@pytest.fixture +def browser_context_args(browser_context_args: dict) -> dict: + """Pin the browser locale and time zone for deterministic i18n output. + + The time zone is a negative offset (America/New_York) so a date-only value + parsed as UTC midnight would visibly shift to the previous day, catching + regressions in client-side date normalization. + + Args: + browser_context_args: The default pytest-playwright context args. + + Returns: + Context args with locale en-US and a fixed time zone. + """ + return { + **browser_context_args, + "locale": "en-US", + "timezone_id": "America/New_York", + } + + +@pytest.fixture(scope="module") +def i18n_app( + tmp_path_factory: pytest.TempPathFactory, +) -> Generator[AppHarness, None, None]: + """Create a harness for the i18n app. + + Args: + tmp_path_factory: Pytest fixture for creating temporary directories. + + Yields: + Running AppHarness for the test app. + """ + with AppHarness.create( + root=tmp_path_factory.mktemp("i18n_app"), + app_source=I18nApp, + ) as harness: + yield harness + + +def test_static_translation_switches_locale(i18n_app: AppHarness, page: Page): + """Static rx.t content switches locale instantly and persists via cookie. + + Args: + i18n_app: Running harness for the i18n app. + page: Playwright page. + """ + assert i18n_app.frontend_url is not None + page.goto(i18n_app.frontend_url) + expect(page.locator("#token")).not_to_have_value("") + + # Default locale (en): source text is shown. + expect(page.locator("#static-hello")).to_have_text("Hello") + expect(page.locator("#static-count")).to_have_text("1 item") + + # Switch to German: static content updates without a reload. + page.click("#to-de") + expect(page.locator("#static-hello")).to_have_text("Hallo") + expect(page.locator("#static-count")).to_have_text("1 Artikel") + + # Plural boundary: 2 items still uses the (identical) German plural form. + page.click("#inc") + expect(page.locator("#static-count")).to_have_text("2 Artikel") + + # The chosen locale is persisted as a cookie. + cookies = {c.get("name"): c.get("value") for c in page.context.cookies()} + assert cookies.get("reflex_locale") == "de" + + # After reload the German locale is restored from the cookie. + page.reload() + expect(page.locator("#static-hello")).to_have_text("Hallo") + + # Switch back to English. + page.click("#to-en") + expect(page.locator("#static-hello")).to_have_text("Hello") + expect(page.locator("#static-count")).to_have_text("2 items") + + +def test_dynamic_translation_uses_active_locale(i18n_app: AppHarness, page: Page): + """Server-side gettext translates into the client's active locale. + + Args: + i18n_app: Running harness for the i18n app. + page: Playwright page. + """ + assert i18n_app.frontend_url is not None + page.goto(i18n_app.frontend_url) + expect(page.locator("#token")).not_to_have_value("") + + # In the default locale the handler emits the source text. + page.click("#greet") + expect(page.locator("#dynamic-message")).to_have_text("Welcome") + + # After switching to German, the same handler translates server-side. + page.click("#to-de") + page.click("#greet") + expect(page.locator("#dynamic-message")).to_have_text("Willkommen") + + +def test_computed_var_retranslates_on_switch(i18n_app: AppHarness, page: Page): + """A computed var calling gettext retranslates reactively on locale switch. + + This exercises auto-detected locale dependency injection: the getter never + references the locale, yet switching locale recomputes and re-pushes it. + + Args: + i18n_app: Running harness for the i18n app. + page: Playwright page. + """ + assert i18n_app.frontend_url is not None + page.goto(i18n_app.frontend_url) + expect(page.locator("#token")).not_to_have_value("") + + # Rendered in the default locale on first load. + expect(page.locator("#computed-greeting")).to_have_text("Welcome") + + # Switching locale alone (no other interaction) retranslates the computed + # var via the auto-injected dependency on the active locale. + page.click("#to-de") + expect(page.locator("#computed-greeting")).to_have_text("Willkommen") + + page.click("#to-en") + expect(page.locator("#computed-greeting")).to_have_text("Welcome") + + +def test_memo_component_translations_switch_locale(i18n_app: AppHarness, page: Page): + """rx.t updates inside rx.memo components and when passed in as a prop. + + React.memo re-renders on a consumed context change, and the parent recomputes + a translated prop on switch, so neither case freezes the source text. + + Args: + i18n_app: Running harness for the i18n app. + page: Playwright page. + """ + assert i18n_app.frontend_url is not None + page.goto(i18n_app.frontend_url) + expect(page.locator("#token")).not_to_have_value("") + + # Both the in-memo call site and the memoized prop start at the source text. + expect(page.locator("#memo-inside")).to_have_text("Hello") + expect(page.locator("#memo-prop")).to_have_text("Hello") + + # A locale switch retranslates both without a reload. + page.click("#to-de") + expect(page.locator("#memo-inside")).to_have_text("Hallo") + expect(page.locator("#memo-prop")).to_have_text("Hallo") + + page.click("#to-en") + expect(page.locator("#memo-inside")).to_have_text("Hello") + expect(page.locator("#memo-prop")).to_have_text("Hello") + + +def test_number_formatting_switches_locale(i18n_app: AppHarness, page: Page): + """Client (Intl) and server (Babel) number formatting follow the locale. + + Args: + i18n_app: Running harness for the i18n app. + page: Playwright page. + """ + assert i18n_app.frontend_url is not None + page.goto(i18n_app.frontend_url) + expect(page.locator("#token")).not_to_have_value("") + + # Default locale (en): 1,234.50 for both client- and server-formatted. + expect(page.locator("#client-number")).to_have_text("1,234.50") + expect(page.locator("#server-number")).to_have_text("1,234.50") + + # German grouping/decimal separators, on both surfaces. + page.click("#to-de") + expect(page.locator("#client-number")).to_have_text("1.234,50") + expect(page.locator("#server-number")).to_have_text("1.234,50") + + page.click("#to-en") + expect(page.locator("#client-number")).to_have_text("1,234.50") + + +def test_client_date_and_time_formatting(i18n_app: AppHarness, page: Page): + """Client-side date/time formatting parses Python values correctly. + + Under a negative-offset time zone, a date-only value must not shift to the + previous day, and a time-only value must not render "Invalid Date". + + Args: + i18n_app: Running harness for the i18n app. + page: Playwright page. + """ + assert i18n_app.frontend_url is not None + page.goto(i18n_app.frontend_url) + expect(page.locator("#token")).not_to_have_value("") + + # Date-only value: stays on the 18th (not the 17th) despite UTC-4. + expect(page.locator("#client-date")).to_have_text("Jul 18, 2026") + # Time-only value: formats as a time, never "Invalid Date". + expect(page.locator("#client-time")).to_have_text("2:30 PM") + + # German locale reformats both without a day-shift. + page.click("#to-de") + expect(page.locator("#client-date")).to_have_text("18.07.2026") + expect(page.locator("#client-time")).to_have_text("14:30") diff --git a/tests/units/reflex_base/event/processor/test_scope.py b/tests/units/reflex_base/event/processor/test_scope.py new file mode 100644 index 00000000000..adc0acbc5a3 --- /dev/null +++ b/tests/units/reflex_base/event/processor/test_scope.py @@ -0,0 +1,83 @@ +"""Tests for the per-event ambient-context scope hook.""" + +import contextlib +from typing import TYPE_CHECKING, cast + +import pytest +from reflex_base.event.processor import scope + +if TYPE_CHECKING: + from reflex.state import BaseState + +# The scope hook never touches the root state when providers ignore it, so a +# dummy stands in for a real BaseState in these tests. +_ROOT = cast("BaseState", object()) + + +@pytest.fixture(autouse=True) +def clean_providers(): + """Isolate the global provider registry per test. + + Yields: + None + """ + saved = list(scope._providers) + scope._providers.clear() + yield + scope._providers[:] = saved + + +def test_no_providers_is_nullcontext(): + assert isinstance(scope.event_scope(_ROOT), contextlib.nullcontext) + assert not scope.has_event_scope_providers() + + +@pytest.mark.asyncio +async def test_provider_scope_entered_and_exited(): + events: list[str] = [] + + @contextlib.contextmanager + def cm(): + events.append("enter") + try: + yield + finally: + events.append("exit") + + async def provider(_root): # noqa: RUF029 (async required by provider protocol) + return cm() + + scope.register_event_scope_provider(provider) + async with scope.event_scope(_ROOT): + events.append("body") + + assert events == ["enter", "body", "exit"] + + +@pytest.mark.asyncio +async def test_earlier_scope_cleaned_up_when_later_provider_raises(): + exited: list[str] = [] + + @contextlib.contextmanager + def tracking_cm(): + try: + yield + finally: + exited.append("first") + + async def first(_root): # noqa: RUF029 (async required by provider protocol) + return tracking_cm() + + async def second(_root): # noqa: RUF029 (async required by provider protocol) + msg = "boom" + raise RuntimeError(msg) + + scope.register_event_scope_provider(first) + scope.register_event_scope_provider(second) + + with pytest.raises(RuntimeError, match="boom"): + async with scope.event_scope(_ROOT): + pass + + # The first provider's context must be exited even though the second raised. + assert exited == ["first"] diff --git a/tests/units/reflex_i18n/__init__.py b/tests/units/reflex_i18n/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/units/reflex_i18n/test_catalog.py b/tests/units/reflex_i18n/test_catalog.py new file mode 100644 index 00000000000..41c6f8843a3 --- /dev/null +++ b/tests/units/reflex_i18n/test_catalog.py @@ -0,0 +1,85 @@ +"""Tests for compiling .po catalogs into per-locale JS modules.""" + +import dataclasses + +from babel.messages.catalog import Catalog +from reflex_i18n.catalog import compile_catalog_module, compile_index_module +from reflex_i18n.config import I18nConfig +from reflex_i18n.registry import MessageKey + + +def _catalog(locale: str = "de") -> Catalog: + catalog = Catalog(locale=locale) + catalog.add("Hello {name}!", "Hallo {name}!") + catalog.add("Open", "Öffnen", context="menu") + catalog.add( + ("{count} item", "{count} items"), + ("{count} Artikel", "{count} Artikel"), + ) + return catalog + + +def test_compile_index_module(): + config = I18nConfig(locales=["en", "de"], default_locale="en") + module = compile_index_module(config) + assert 'export const locales = ["en", "de"];' in module + assert 'export const defaultLocale = "en";' in module + assert 'export const cookieName = "reflex_locale";' in module + assert '"de": () => import("$/i18n/de.js"),' in module + + +def test_compile_catalog_includes_used_messages(): + used = [MessageKey("Hello {name}!"), MessageKey("Open", context="menu")] + module = compile_catalog_module(_catalog(), used, "de", is_default_locale=False) + assert '"Hello {name}!": "Hallo {name}!"' in module + assert '"menu\\u0004Open": "\\u00d6ffnen"' in module + assert "export const plural = (n) => Number((n != 1));" in module + + +def test_compile_catalog_tree_shakes_unused(): + # "Open" is in the catalog but not used; it must not be emitted. + used = [MessageKey("Hello {name}!")] + module = compile_catalog_module(_catalog(), used, "de", is_default_locale=False) + assert "Open" not in module + assert "Hallo" in module + + +def test_compile_catalog_plural_entry_is_array(): + used = [MessageKey("{count} item", "{count} items")] + module = compile_catalog_module(_catalog(), used, "de", is_default_locale=False) + assert '"{count} item": ["{count} Artikel", "{count} Artikel"]' in module + + +def test_compile_catalog_omits_untranslated(): + used = [MessageKey("Untranslated")] + module = compile_catalog_module(_catalog(), used, "de", is_default_locale=False) + assert "Untranslated" not in module + assert "export const messages = {\n\n};" in module + + +def test_compile_catalog_no_catalog_falls_back(): + used = [MessageKey("Hello")] + module = compile_catalog_module(None, used, "en", is_default_locale=True) + assert "export const plural = (n) => Number(n != 1);" in module + assert "Hello" not in module + + +def test_compile_catalog_polish_plural_expr(): + catalog = Catalog(locale="pl") + module = compile_catalog_module(catalog, [], "pl", is_default_locale=False) + # Polish has a 3-form plural; the C expression is whitelisted and kept. + assert "n%10>=2" in module + + +@dataclasses.dataclass +class _FakeCatalog: + """Minimal catalog stand-in for exercising plural-expr validation.""" + + plural_expr: str + + +def test_compile_catalog_rejects_malicious_plural_expr(): + catalog = _FakeCatalog("alert('xss')") + module = compile_catalog_module(catalog, [], "de", is_default_locale=False) # pyright: ignore[reportArgumentType] + assert "alert" not in module + assert "export const plural = (n) => Number(n != 1);" in module diff --git a/tests/units/reflex_i18n/test_cli.py b/tests/units/reflex_i18n/test_cli.py new file mode 100644 index 00000000000..c0ddb325f00 --- /dev/null +++ b/tests/units/reflex_i18n/test_cli.py @@ -0,0 +1,127 @@ +"""Tests for the reflex i18n CLI helpers and command discovery.""" + +from pathlib import Path + +import pytest +from babel.messages.pofile import read_po, write_po +from reflex_i18n.cli import ( + LocaleStats, + _catalog_stats, + check_command, + extract_catalog, + extract_command, + i18n_cli, + init_command, + merge_into_locale, +) +from reflex_i18n.registry import MessageKey + + +def _write_app_source(app_dir: Path) -> None: + app_dir.mkdir(parents=True, exist_ok=True) + (app_dir / "app.py").write_text( + "from reflex_i18n import gettext as _\n" + 'x = _("Server hello")\n' + 'y = ngettext("{n} file", "{n} files", 2)\n', + encoding="utf-8", + ) + + +def test_extract_catalog_unions_both_sources(tmp_path: Path): + app_dir = tmp_path / "myapp" + _write_app_source(app_dir) + used = ( + MessageKey("Static hello"), + MessageKey("{count} item", plural="{count} items"), + MessageKey("Open", context="menu"), + ) + + catalog = extract_catalog(app_dir, used) + ids = [m.id for m in catalog if m.id] + + # From gettext source extraction. + assert "Server hello" in ids + assert ("{n} file", "{n} files") in ids + # From the rx.t registry. + assert "Static hello" in ids + assert ("{count} item", "{count} items") in ids + # Context-qualified message is keyed by (id, context). + assert catalog.get("Open", context="menu") is not None + + +def test_extract_catalog_records_gettext_locations(tmp_path: Path): + app_dir = tmp_path / "myapp" + _write_app_source(app_dir) + + catalog = extract_catalog(app_dir, ()) + message = catalog.get("Server hello") + assert message is not None + assert message.locations # source location captured for gettext calls + + +def test_merge_preserves_existing_translation(tmp_path: Path): + app_dir = tmp_path / "myapp" + _write_app_source(app_dir) + po_path = tmp_path / "locales" / "de.po" + + # First extraction seeds the catalog untranslated. + template = extract_catalog(app_dir, (MessageKey("Static hello"),)) + merge_into_locale(template, po_path, "de") + + # Translate one message, then re-merge. + with po_path.open("rb") as f: + catalog = read_po(f) + message = catalog.get("Static hello") + assert message is not None + message.string = "Statisch hallo" + with po_path.open("wb") as f: + write_po(f, catalog) + + stats = merge_into_locale(template, po_path, "de") + with po_path.open("rb") as f: + merged = read_po(f) + # Translation survived the re-merge. + merged_message = merged.get("Static hello") + assert merged_message is not None + assert merged_message.string == "Statisch hallo" + # "Server hello" and the plural remain untranslated. + assert stats.missing == 2 + + +def test_catalog_stats_counts_missing_and_obsolete(tmp_path: Path): + app_dir = tmp_path / "myapp" + _write_app_source(app_dir) + template = extract_catalog(app_dir, ()) + + po_path = tmp_path / "de.po" + merge_into_locale(template, po_path, "de") + with po_path.open("rb") as f: + catalog = read_po(f, locale="de") + + stats = _catalog_stats(catalog, "de") + assert stats.missing == 2 # both extracted messages untranslated + assert stats.locale == "de" + + +def test_locale_stats_incomplete(): + assert LocaleStats("de", missing=1).incomplete + assert LocaleStats("de", fuzzy=1).incomplete + assert not LocaleStats("de", obsolete=3).incomplete + + +def test_cli_group_registered(): + # The command group and its subcommands are wired up. + assert set(i18n_cli.commands) == {"extract", "init", "check"} + + +def test_cli_group_discovered_by_reflex(): + from reflex.reflex import cli + + assert "i18n" in cli.commands + + +@pytest.mark.parametrize("command", [extract_command, init_command, check_command]) +def test_commands_are_click_commands(command): + import click + + assert isinstance(command, click.Command) diff --git a/tests/units/reflex_i18n/test_config.py b/tests/units/reflex_i18n/test_config.py new file mode 100644 index 00000000000..8777e85610a --- /dev/null +++ b/tests/units/reflex_i18n/test_config.py @@ -0,0 +1,35 @@ +"""Tests for the i18n configuration.""" + +import pytest +from reflex_i18n import I18nConfig +from reflex_i18n.config import get_active_i18n_config, set_active_i18n_config + + +def test_config_defaults(): + config = I18nConfig(locales=["en", "de"]) + assert config.locales == ("en", "de") + assert config.default_locale == "en" + assert config.catalog_dir == "locales" + + +def test_config_accepts_any_sequence(): + config = I18nConfig(locales=("de",), default_locale="de") + assert config.locales == ("de",) + + +def test_config_requires_locales(): + with pytest.raises(ValueError, match="at least one locale"): + I18nConfig(locales=[]) + + +def test_config_default_locale_must_be_supported(): + with pytest.raises(ValueError, match="must be one of"): + I18nConfig(locales=["en", "de"], default_locale="fr") + + +def test_active_config_roundtrip(): + config = I18nConfig(locales=["en"]) + set_active_i18n_config(config) + assert get_active_i18n_config() is config + set_active_i18n_config(None) + assert get_active_i18n_config() is None diff --git a/tests/units/reflex_i18n/test_format.py b/tests/units/reflex_i18n/test_format.py new file mode 100644 index 00000000000..7f4d2556711 --- /dev/null +++ b/tests/units/reflex_i18n/test_format.py @@ -0,0 +1,123 @@ +"""Tests for the client-side rx.i18n formatting vars.""" + +import pytest +from reflex_base.vars.base import Var +from reflex_i18n import I18nConfig, currency, date, datetime, number, percent, time +from reflex_i18n.component import I18nProvider +from reflex_i18n.config import set_active_i18n_config +from reflex_i18n.format import _locale_var + + +@pytest.fixture(autouse=True) +def active_config(): + """Activate an i18n config for the formatting vars. + + Yields: + None + """ + set_active_i18n_config(I18nConfig(locales=["en", "de"], default_locale="en")) + yield + set_active_i18n_config(None) + + +def _num() -> Var: + return Var(_js_expr="state.value", _var_type=float) + + +def test_number_emits_fmt_number_call(): + var = number(_num()) + assert str(var) == "(fmtNumber(state.value, ({ })))" + assert var._var_type is str + + +def test_number_curated_kwargs_map_to_intl_options(): + js = str(number(_num(), min_fraction_digits=1, max_fraction_digits=2)) + assert '["minimumFractionDigits"] : 1' in js + assert '["maximumFractionDigits"] : 2' in js + + +def test_number_grouping_and_compact(): + js = str(number(_num(), grouping=False, compact=True)) + assert '["useGrouping"] : false' in js + assert '["notation"] : "compact"' in js + + +def test_number_options_escape_hatch(): + js = str(number(_num(), options={"notation": "compact"})) + assert '["notation"] : "compact"' in js + + +def test_currency_sets_style_and_currency(): + js = str(currency(_num(), "EUR")) + assert '["style"] : "currency"' in js + assert '["currency"] : "EUR"' in js + + +def test_percent_sets_style(): + js = str(percent(_num(), min_fraction_digits=1)) + assert '["style"] : "percent"' in js + assert '["minimumFractionDigits"] : 1' in js + + +def test_date_uses_date_style(): + js = str(date(Var(_js_expr="state.day"), length="long")) + assert js.startswith("(fmtDate(state.day") + assert '["dateStyle"] : "long"' in js + + +def test_time_uses_time_style(): + js = str(time(Var(_js_expr="state.t"), length="short")) + assert '["timeStyle"] : "short"' in js + + +def test_datetime_uses_both_styles(): + js = str(datetime(Var(_js_expr="state.dt"))) + assert '["dateStyle"] : "medium"' in js + assert '["timeStyle"] : "medium"' in js + + +def test_format_var_data_injects_hook_and_provider(): + var_data = number(_num())._get_all_var_data() + assert var_data is not None + assert "const [ fmtNumber, fmtDate ] = useFormat()" in var_data.hooks + assert any(lib == "$/utils/i18n" for lib, _ in var_data.imports) + priority, provider = var_data.app_wraps[0] + assert priority == 58 + assert isinstance(provider, I18nProvider) + + +def test_number_and_date_share_one_hook(): + combined = Var.create([number(_num()), date(Var(_js_expr="state.day"))]) + var_data = combined._get_all_var_data() + assert var_data is not None + hooks = [h for h in var_data.hooks if "useFormat" in h] + assert hooks == ["const [ fmtNumber, fmtDate ] = useFormat()"] + + +def test_locale_var_reads_hook(): + var = _locale_var() + assert str(var) == "i18nLocale" + var_data = var._get_all_var_data() + assert var_data is not None + assert "const i18nLocale = useLocale()" in var_data.hooks + + +def test_shadowing_names_excluded_from_star_export(): + # date/time/datetime are reachable as attributes (rx.i18n.date) but kept + # out of __all__ so `import *` cannot shadow the stdlib names. + import reflex_i18n + + for name in ("date", "time", "datetime"): + assert name not in reflex_i18n.__all__ + assert callable(getattr(reflex_i18n, name)) + # Non-shadowing formatters remain star-exported. + for name in ("number", "currency", "percent"): + assert name in reflex_i18n.__all__ + + +def test_formatting_without_plugin_raises(): + set_active_i18n_config(None) + with pytest.raises(RuntimeError, match="requires the i18n plugin"): + number(_num()) + with pytest.raises(RuntimeError, match="requires the i18n plugin"): + date(Var(_js_expr="state.day")) diff --git a/tests/units/reflex_i18n/test_implicit_deps.py b/tests/units/reflex_i18n/test_implicit_deps.py new file mode 100644 index 00000000000..c98f02aba3e --- /dev/null +++ b/tests/units/reflex_i18n/test_implicit_deps.py @@ -0,0 +1,137 @@ +"""Tests for implicit computed-var dependencies (dep_tracking registry).""" + +import pytest +from reflex_base.vars.dep_tracking import ( + _implicit_dependency_providers, + register_implicit_dependency, +) + + +@pytest.fixture(autouse=True) +def clean_registry(): + """Isolate the implicit-dependency registry and framework dep edges. + + Defining a state whose computed var depends on ``I18nState.locale`` + registers a permanent cross-state edge on the shared ``I18nState`` class. + Snapshot and restore those edges so throwaway test states do not pollute + later tests. + + Yields: + None + """ + from reflex_i18n.state import I18nState + + saved = dict(_implicit_dependency_providers) + _implicit_dependency_providers.clear() + saved_deps = {k: set(v) for k, v in I18nState._var_dependencies.items()} + saved_dirty = set(I18nState._potentially_dirty_states) + yield + _implicit_dependency_providers.clear() + _implicit_dependency_providers.update(saved) + I18nState._var_dependencies = saved_deps + I18nState._potentially_dirty_states = saved_dirty + + +def test_gettext_computed_var_gains_locale_dependency(): + from reflex_i18n import gettext as _ + from reflex_i18n.state import I18nState + + import reflex as rx + + register_implicit_dependency((_,), lambda: I18nState.locale) + i18n_name = I18nState.get_full_name() + + class TranslatedState(rx.State): + name: str = "" + + @rx.var + def greeting(self) -> str: + return _("Hello") + + @rx.var + def greeting_named(self) -> str: + return _("Hi ") + self.name + + greeting_deps = TranslatedState.computed_vars["greeting"]._deps( + objclass=TranslatedState + ) + assert greeting_deps.get(i18n_name) == {"locale"} + + named_deps = TranslatedState.computed_vars["greeting_named"]._deps( + objclass=TranslatedState + ) + assert named_deps.get(i18n_name) == {"locale"} + assert "name" in named_deps[TranslatedState.get_full_name()] + + +def test_format_computed_var_gains_locale_dependency(): + from reflex_i18n import format_number + from reflex_i18n.state import I18nState + + import reflex as rx + + register_implicit_dependency((format_number,), lambda: I18nState.locale) + i18n_name = I18nState.get_full_name() + + class PriceState(rx.State): + amount: float = 0.0 + + @rx.var + def label(self) -> str: + return format_number(self.amount) + + deps = PriceState.computed_vars["label"]._deps(objclass=PriceState) + assert deps.get(i18n_name) == {"locale"} + assert "amount" in deps[PriceState.get_full_name()] + + +def test_no_registration_no_extra_dependency(): + import reflex as rx + + class PlainState(rx.State): + n: int = 0 + + @rx.var + def doubled(self) -> int: + return self.n * 2 + + deps = PlainState.computed_vars["doubled"]._deps(objclass=PlainState) + assert deps == {PlainState.get_full_name(): {"n"}} + + +def test_provider_returning_none_adds_nothing(): + import reflex as rx + + def marker() -> str: + return "x" + + register_implicit_dependency((marker,), lambda: None) + + class UsesMarker(rx.State): + @rx.var + def value(self) -> str: + return marker() + + deps = UsesMarker.computed_vars["value"]._deps(objclass=UsesMarker) + assert deps == {} + + +def test_helper_method_recursion_detects_dependency(): + from reflex_i18n import gettext as _ + from reflex_i18n.state import I18nState + + import reflex as rx + + register_implicit_dependency((_,), lambda: I18nState.locale) + i18n_name = I18nState.get_full_name() + + class HelperState(rx.State): + @rx.var + def label(self) -> str: + return self._translated() + + def _translated(self) -> str: + return _("Save") + + deps = HelperState.computed_vars["label"]._deps(objclass=HelperState) + assert deps.get(i18n_name) == {"locale"} diff --git a/tests/units/reflex_i18n/test_package_import.py b/tests/units/reflex_i18n/test_package_import.py new file mode 100644 index 00000000000..0dae9a742c1 --- /dev/null +++ b/tests/units/reflex_i18n/test_package_import.py @@ -0,0 +1,90 @@ +"""Tests for the package's import-time side effects. + +State registration is a process-global, irreversible side effect, so these run +in a fresh interpreter via a subprocess rather than the shared test process +(where importing ``reflex_i18n.state`` elsewhere would already have registered +``I18nState``). +""" + +import subprocess +import sys +import textwrap + + +def _run_snippet(body: str) -> None: + """Run a Python snippet in a clean interpreter, asserting it exits 0. + + Args: + body: The snippet source (dedented before running). + """ + result = subprocess.run( + [sys.executable, "-c", textwrap.dedent(body)], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + + +def test_importing_package_does_not_register_state(): + """Importing the package must not register I18nState until an app opts in.""" + # Regression: the reflex CLI imports every reflex.cli entry point at + # startup, which imports reflex_i18n; that must not attach I18nState (and + # its locale cookie) to apps that never use i18n. + _run_snippet(""" + import reflex_i18n.cli # the reflex.cli entry point target + import reflex_i18n + from reflex.state import all_base_state_classes + + def i18n_registered() -> bool: + return any("i18n" in name.lower() for name in all_base_state_classes) + + assert not i18n_registered(), sorted(all_base_state_classes) + + # Opting in (as an app's rxconfig does) registers the state. + reflex_i18n.I18nPlugin(locales=["en"]) + assert i18n_registered(), sorted(all_base_state_classes) + """) + + +def test_state_attributes_accessible_and_register_on_access(): + """Accessing I18nState/set_locale resolves them and registers state.""" + _run_snippet(""" + import reflex_i18n + from reflex.state import all_base_state_classes + + assert not any("i18n" in n.lower() for n in all_base_state_classes) + + assert reflex_i18n.I18nState.get_full_name() + assert callable(reflex_i18n.set_locale) + assert any("i18n" in n.lower() for n in all_base_state_classes) + + try: + reflex_i18n.does_not_exist + except AttributeError: + pass + else: + raise AssertionError("expected AttributeError for unknown attribute") + """) + + +def test_gettext_computed_var_gets_locale_edge_without_plugin_or_state_import(): + """Importing gettext alone must wire the retranslation dependency.""" + # Regression: the implicit-dep provider lives in .runtime (next to gettext), + # not .state, so a computed var scanned before I18nPlugin is constructed (as + # in a backend worker) still gains its edge to I18nState.locale. + _run_snippet(""" + # Mirror a backend worker importing the app module: no I18nPlugin + # construction, no explicit reflex_i18n.state import. + import reflex as rx + from reflex_i18n import gettext as _ + + class GreetState(rx.State): + @rx.var + def greeting(self) -> str: + return _("Hello") + + from reflex_i18n.state import I18nState + deps = GreetState.computed_vars["greeting"]._deps(objclass=GreetState) + assert deps.get(I18nState.get_full_name()) == {"locale"}, deps + """) diff --git a/tests/units/reflex_i18n/test_plugin.py b/tests/units/reflex_i18n/test_plugin.py new file mode 100644 index 00000000000..d821f486242 --- /dev/null +++ b/tests/units/reflex_i18n/test_plugin.py @@ -0,0 +1,78 @@ +"""Tests for the I18nPlugin: config activation and catalog emission.""" + +from pathlib import Path + +import pytest +from reflex_i18n import I18nPlugin, t +from reflex_i18n.config import set_active_i18n_config +from reflex_i18n.registry import clear_messages + + +@pytest.fixture(autouse=True) +def clean_registry(): + """Isolate the message registry and global config per test. + + Yields: + None + """ + clear_messages() + set_active_i18n_config(None) + yield + clear_messages() + set_active_i18n_config(None) + + +def _write_po(catalog_dir: Path, locale: str, body: str) -> None: + catalog_dir.mkdir(parents=True, exist_ok=True) + header = ( + 'msgid ""\nmsgstr ""\n' + '"Content-Type: text/plain; charset=UTF-8\\n"\n' + '"Plural-Forms: nplurals=2; plural=(n != 1);\\n"\n\n' + ) + (catalog_dir / f"{locale}.po").write_text(header + body, encoding="utf-8") + + +def test_plugin_activates_config(): + I18nPlugin(locales=["en", "de"], default_locale="en") + # rx.t requires an active config; constructing the plugin provides it. + var = t("Hello") + assert 't_("Hello"' in str(var) + + +def test_t_without_plugin_raises(): + with pytest.raises(RuntimeError, match="requires the i18n plugin"): + t("Hello") + + +def test_plugin_ships_client_runtime(): + plugin = I18nPlugin(locales=["en"]) + assets = dict(plugin.get_static_assets()) + runtime = assets[Path("utils/i18n.js")] + assert isinstance(runtime, str) + assert "useTranslation" in runtime + + +def test_plugin_emits_index_and_locale_modules(tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write_po(tmp_path / "locales", "de", 'msgid "Hello"\nmsgstr "Hallo"\n') + + plugin = I18nPlugin(locales=["en", "de"], default_locale="en") + t("Hello") + + outputs = dict(plugin._compile_catalogs()) + assert set(outputs) == {"i18n/index.js", "i18n/en.js", "i18n/de.js"} + assert '"de": () => import("$/i18n/de.js")' in outputs["i18n/index.js"] + assert '"Hello": "Hallo"' in outputs["i18n/de.js"] + # The default (source) locale needs no translation entry. + assert '"Hello"' not in outputs["i18n/en.js"] + + +def test_plugin_missing_catalog_file_still_emits_module(tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + plugin = I18nPlugin(locales=["en", "de"], default_locale="en") + t("Hello") + + outputs = dict(plugin._compile_catalogs()) + # de.js exists but is empty (falls back to source text at runtime). + assert "i18n/de.js" in outputs + assert "export const messages" in outputs["i18n/de.js"] diff --git a/tests/units/reflex_i18n/test_runtime.py b/tests/units/reflex_i18n/test_runtime.py new file mode 100644 index 00000000000..bff85dce5a2 --- /dev/null +++ b/tests/units/reflex_i18n/test_runtime.py @@ -0,0 +1,297 @@ +"""Tests for server-side gettext translation of dynamic content.""" + +import datetime +from pathlib import Path + +import pytest +from reflex_i18n.config import I18nConfig, set_active_i18n_config +from reflex_i18n.runtime import ( + clear_translations_cache, + format_currency, + format_date, + format_datetime, + format_decimal, + format_number, + format_percent, + format_time, + gettext, + negotiate_locale, + ngettext, + pgettext, + use_locale, +) + + +@pytest.fixture(autouse=True) +def clean_state(): + """Reset the translation cache and active config per test. + + Yields: + None + """ + clear_translations_cache() + set_active_i18n_config(None) + yield + clear_translations_cache() + set_active_i18n_config(None) + + +def _write_po(catalog_dir: Path, locale: str, body: str) -> None: + catalog_dir.mkdir(parents=True, exist_ok=True) + header = ( + 'msgid ""\nmsgstr ""\n' + '"Content-Type: text/plain; charset=UTF-8\\n"\n' + '"Plural-Forms: nplurals=2; plural=(n != 1);\\n"\n\n' + ) + (catalog_dir / f"{locale}.po").write_text(header + body, encoding="utf-8") + + +def test_gettext_without_locale_returns_source(): + assert gettext("Hello") == "Hello" + + +def test_gettext_translates_active_locale(tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write_po(tmp_path / "locales", "de", 'msgid "Hello"\nmsgstr "Hallo"\n') + set_active_i18n_config(I18nConfig(locales=["en", "de"], default_locale="en")) + + with use_locale("de"): + assert gettext("Hello") == "Hallo" + assert gettext("Hello") == "Hello" + + +def test_gettext_missing_translation_falls_back(tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write_po(tmp_path / "locales", "de", 'msgid "Hello"\nmsgstr "Hallo"\n') + set_active_i18n_config(I18nConfig(locales=["en", "de"], default_locale="en")) + + with use_locale("de"): + assert gettext("Goodbye") == "Goodbye" + + +def test_ngettext_plural(tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write_po( + tmp_path / "locales", + "de", + 'msgid "{n} item"\nmsgid_plural "{n} items"\n' + 'msgstr[0] "{n} Artikel"\nmsgstr[1] "{n} Artikel"\n', + ) + set_active_i18n_config(I18nConfig(locales=["en", "de"], default_locale="en")) + + with use_locale("de"): + assert ngettext("{n} item", "{n} items", 1) == "{n} Artikel" + assert ngettext("{n} item", "{n} items", 3) == "{n} Artikel" + + +def test_ngettext_without_locale(): + assert ngettext("one", "many", 1) == "one" + assert ngettext("one", "many", 2) == "many" + + +def test_pgettext_context(tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + _write_po( + tmp_path / "locales", + "de", + 'msgctxt "menu"\nmsgid "Open"\nmsgstr "Öffnen"\n', + ) + set_active_i18n_config(I18nConfig(locales=["en", "de"], default_locale="en")) + + with use_locale("de"): + assert pgettext("menu", "Open") == "Öffnen" + + +def test_negotiate_locale_exact_match(): + assert negotiate_locale("de-DE,de;q=0.9", ["en", "de"], "en") == "de" + + +def test_negotiate_locale_language_prefix(): + assert negotiate_locale("de-AT", ["en", "de"], "en") == "de" + + +def test_negotiate_locale_quality_order(): + assert negotiate_locale("fr;q=0.2,de;q=0.8", ["en", "de", "fr"], "en") == "de" + + +def test_negotiate_locale_falls_back_to_default(): + assert negotiate_locale("ja,ko", ["en", "de"], "en") == "en" + + +def test_negotiate_locale_ignores_wildcard(): + assert negotiate_locale("*", ["en", "de"], "en") == "en" + + +def test_negotiate_locale_excludes_q0(): + # q=0 means "not acceptable" (RFC 7231): de must not be matched even though + # it is supported, and negotiation falls back to the default. + assert negotiate_locale("de;q=0", ["en", "de"], "en") == "en" + # A q=0 locale is excluded even when it is the only supported candidate. + assert negotiate_locale("de;q=0,fr;q=0.9", ["en", "de"], "en") == "en" + + +def test_format_number_localized(): + with use_locale("de"): + assert format_number(1234567.891, max_fraction_digits=2) == "1.234.567,89" + with use_locale("en"): + assert format_number(1234567.891, max_fraction_digits=2) == "1,234,567.89" + + +def test_format_number_grouping_and_fraction_bounds(): + with use_locale("en"): + assert format_number(1234.5, grouping=False) == "1234.5" + assert format_number(1, min_fraction_digits=2) == "1.00" + + +def test_format_number_compact(): + with use_locale("en"): + assert format_number(1200000, compact=True) == "1M" + assert format_number(1200000, compact=True, max_fraction_digits=1) == "1.2M" + + +def test_format_decimal_is_format_number(): + assert format_decimal is format_number + + +def test_format_currency_localized(): + with use_locale("de"): + assert format_currency(1234.5, "EUR") == "1.234,50\xa0€" + with use_locale("en"): + assert format_currency(1234.5, "EUR") == "€1,234.50" + + +def test_format_percent_localized(): + # Locale default digits; de keeps its non-breaking space before "%". + with use_locale("de"): + assert format_percent(0.5) == "50\xa0%" + with use_locale("en"): + assert format_percent(0.5) == "50%" + + +def test_format_percent_fraction_digits_preserve_affix(): + # Fraction digits apply without losing the locale's "%" spacing. + with use_locale("de"): + assert format_percent(0.1234, max_fraction_digits=1) == "12,3\xa0%" + with use_locale("en"): + assert format_percent(0.1234, max_fraction_digits=1) == "12.3%" + + +def test_format_currency_fraction_digits(): + with use_locale("de"): + assert format_currency(1234.5, "EUR", max_fraction_digits=1) == "1.234,5\xa0€" + assert format_currency(1234.5, "EUR", min_fraction_digits=3) == "1.234,500\xa0€" + with use_locale("en"): + assert format_currency(1234.5, "USD", min_fraction_digits=3) == "$1,234.500" + + +def test_format_number_preserves_native_grouping(): + # Indian grouping (lakh/crore) is preserved, not forced to Western groups. + with use_locale("en-IN"): + assert format_number(1234567) == "12,34,567" + + +def test_format_hyphenated_locale(): + # Babel needs 'en_US'; the helper normalizes '-' so BCP-47 tags work. + with use_locale("en-US"): + assert format_number(1234.5, max_fraction_digits=1) == "1,234.5" + + +def test_format_date_localized(): + day = datetime.date(2026, 3, 18) + with use_locale("de"): + assert format_date(day, length="long") == "18. März 2026" + with use_locale("en"): + assert format_date(day, length="long") == "March 18, 2026" + + +def test_format_time_localized(): + moment = datetime.datetime(2026, 7, 18, 14, 30) + with use_locale("de"): + assert format_time(moment, length="short") == "14:30" + + +def test_format_datetime_localized(): + moment = datetime.datetime(2026, 7, 18, 14, 30) + with use_locale("de"): + assert format_datetime(moment, length="short") == "18.07.26, 14:30" + + +def test_format_falls_back_to_default_locale_without_active(): + set_active_i18n_config(I18nConfig(locales=["de", "en"], default_locale="de")) + # No active locale in context -> uses the configured default (de). + assert format_number(1234.5, max_fraction_digits=1) == "1.234,5" + + +def test_format_falls_back_to_en_without_config(): + # No active locale and no config -> plain en-US formatting. + assert format_number(1234.5, max_fraction_digits=1) == "1,234.5" + + +def test_format_functions_registered_as_locale_dependencies(): + # Importing the runtime registers the format helpers so a computed var + # using one reformats when the locale changes (like gettext). + from reflex_base.vars.dep_tracking import _implicit_dependency_providers + + for fn in ( + format_number, + format_currency, + format_percent, + format_date, + format_time, + format_datetime, + ): + assert fn in _implicit_dependency_providers + + +async def test_format_computed_var_reformats_on_locale_change(): + """A format_* computed var recomputes and re-emits in the new locale. + + Exercises the full automatic path without a browser: the real module-level + registration wires the locale dependency, and changing the locale cookie + dirties the computed var so it is recomputed (in the new locale) and lands + in the delta. + """ + from reflex_i18n.state import I18nState + + import reflex as rx + from reflex.state import State as RootState + + set_active_i18n_config(I18nConfig(locales=["en", "de"], default_locale="en")) + # Defining a state that depends on I18nState.locale registers a permanent + # cross-state edge on the shared I18nState class; snapshot and restore it. + saved_deps = {k: set(v) for k, v in I18nState._var_dependencies.items()} + saved_dirty = set(I18nState._potentially_dirty_states) + try: + + class LocalePriceState(rx.State): + amount: float = 1234.5 + + @rx.var + def price(self) -> str: + return format_number(self.amount, max_fraction_digits=1) + + # The real (non-mocked) registration wired the locale dependency. + deps = LocalePriceState.computed_vars["price"]._deps(objclass=LocalePriceState) + assert deps.get(I18nState.get_full_name()) == {"locale"} + + root = RootState(_reflex_internal_init=True) # pyright: ignore[reportCallIssue] + i18n = await root.get_state(I18nState) + price_state = await root.get_state(LocalePriceState) + + # Cached in the default locale (en), then dirties are cleared. + assert price_state.price == "1,234.5" + root._clean() + + # Switching the locale dirties the computed var; it recomputes in the + # active locale and is re-emitted in the delta. + i18n.locale_cookie = "de" + with use_locale("de"): + delta = await root._get_resolved_delta() + + values = [v for substate in delta.values() for v in substate.values()] + assert "1.234,5" in values + assert "1,234.5" not in values + finally: + I18nState._var_dependencies = saved_deps + I18nState._potentially_dirty_states = saved_dirty + set_active_i18n_config(None) diff --git a/tests/units/reflex_i18n/test_state.py b/tests/units/reflex_i18n/test_state.py new file mode 100644 index 00000000000..4ef3780cb48 --- /dev/null +++ b/tests/units/reflex_i18n/test_state.py @@ -0,0 +1,76 @@ +"""Tests for the I18nState and set_locale event helper.""" + +import contextlib +from typing import TYPE_CHECKING, cast + +import pytest +from reflex_i18n import I18nConfig +from reflex_i18n.config import set_active_i18n_config +from reflex_i18n.state import I18nState, _locale_scope, _resolve_locale, set_locale + +if TYPE_CHECKING: + from reflex.state import BaseState + + +@pytest.fixture(autouse=True) +def i18n_config(): + """Activate an i18n config for the duration of the test. + + Yields: + The active config. + """ + config = I18nConfig(locales=["en", "de", "fr"], default_locale="en") + set_active_i18n_config(config) + yield config + set_active_i18n_config(None) + + +def test_resolve_locale_prefers_cookie(): + assert _resolve_locale("de", "fr-FR,fr;q=0.9") == "de" + + +def test_resolve_locale_ignores_invalid_cookie(): + assert _resolve_locale("es", "fr-FR,fr;q=0.9") == "fr" + + +def test_resolve_locale_uses_accept_language(): + assert _resolve_locale("", "de-DE,de;q=0.9") == "de" + + +def test_resolve_locale_falls_back_to_default(): + assert _resolve_locale("", "ja,ko") == "en" + + +def test_resolve_locale_without_config(): + set_active_i18n_config(None) + assert _resolve_locale("", "de") == "en" + assert _resolve_locale("de", "") == "de" + + +def test_set_locale_returns_client_and_server_events(): + events = set_locale("de") + assert isinstance(events, list) + assert len(events) == 2 + # The first switches the client (run_script), the second updates state. + assert "switchLocale" in repr(events[0]) + assert "set_locale" in repr(events[1]) + + +def test_set_locale_handler_validates_locale(): + state = I18nState() + with pytest.raises(ValueError, match="not configured"): + state.set_locale("es") + + +def test_set_locale_handler_sets_cookie(): + state = I18nState() + state.set_locale("de") + assert state.locale_cookie == "de" + + +@pytest.mark.asyncio +async def test_locale_scope_noop_without_config(): + set_active_i18n_config(None) + + scope = await _locale_scope(cast("BaseState", object())) + assert isinstance(scope, contextlib.nullcontext) diff --git a/tests/units/reflex_i18n/test_vars.py b/tests/units/reflex_i18n/test_vars.py new file mode 100644 index 00000000000..c2ddc182f49 --- /dev/null +++ b/tests/units/reflex_i18n/test_vars.py @@ -0,0 +1,113 @@ +"""Tests for the rx.t translation var.""" + +import pytest +from reflex_base.vars.base import LiteralVar, Var +from reflex_i18n import I18nConfig, t +from reflex_i18n.component import I18nProvider +from reflex_i18n.config import set_active_i18n_config +from reflex_i18n.registry import MessageKey, clear_messages, collected_messages + + +@pytest.fixture(autouse=True) +def clean_registry(): + """Isolate the message registry and activate a config for ``t``. + + Yields: + None + """ + clear_messages() + set_active_i18n_config(I18nConfig(locales=["en", "de"], default_locale="en")) + yield + clear_messages() + set_active_i18n_config(None) + + +def test_t_simple_message(): + var = t("Hello") + assert str(var) == '(t_("Hello", ({ })))' + assert var._var_type is str + + +def test_t_interpolated_params(): + name_var = Var(_js_expr="state.name", _var_type=str) + var = t("Hello {name}!", name=name_var) + assert 't_("Hello {name}!"' in str(var) + assert "state.name" in str(var) + + +def test_t_var_data(): + var = t("Hello") + var_data = var._get_all_var_data() + assert var_data is not None + assert "const [ t_, tp_ ] = useTranslation()" in var_data.hooks + assert any(lib == "$/utils/i18n" for lib, _ in var_data.imports) + assert len(var_data.app_wraps) == 1 + priority, provider = var_data.app_wraps[0] + assert priority == 58 + assert isinstance(provider, I18nProvider) + + +def test_t_context_key(): + var = t("Open", context="menu") + assert '"menu\\u0004Open"' in str(var) + + +def test_t_plural(): + count_var = Var(_js_expr="state.count", _var_type=int) + var = t("{count} item", plural="{count} items", count=count_var) + js = str(var) + assert js.startswith('(tp_("{count} item", "{count} items", state.count') + # count is implicitly available for interpolation. + assert '["count"] : state.count' in js + + +def test_t_plural_explicit_count_param_wins(): + var = t("{count} item", plural="{count} items", count=2, count_label="two") + assert '["count"] : 2' in str(var) + + +def test_t_registers_messages(): + t("Hello") + t("{count} item", plural="{count} items", count=1) + t("Open", context="menu") + assert collected_messages() == ( + MessageKey("Hello", None, None), + MessageKey("{count} item", "{count} items", None), + MessageKey("Open", None, "menu"), + ) + + +def test_t_deduplicates_messages(): + t("Hello") + t("Hello") + assert collected_messages() == (MessageKey("Hello", None, None),) + + +def test_t_in_fstring_keeps_var_data(): + var = LiteralVar.create(f"{t('Hello')} world") + var_data = var._get_all_var_data() + assert var_data is not None + assert "const [ t_, tp_ ] = useTranslation()" in var_data.hooks + assert len(var_data.app_wraps) == 1 + + +def test_t_rejects_non_literal_message(): + state_var = Var(_js_expr="state.msg", _var_type=str) + with pytest.raises(TypeError, match="literal string"): + t(state_var) # pyright: ignore[reportArgumentType] + + +def test_t_rejects_non_literal_plural(): + state_var = Var(_js_expr="state.msg", _var_type=str) + with pytest.raises(TypeError, match="literal string"): + t("{count} item", plural=state_var, count=1) # pyright: ignore[reportArgumentType] + + +def test_t_plural_requires_count(): + with pytest.raises(ValueError, match="count and plural"): + t("{count} item", plural="{count} items") + + +def test_t_count_requires_plural(): + with pytest.raises(ValueError, match="count and plural"): + t("{count} item", count=1) diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 112aa1e8d43..b636bf70f46 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -56,6 +56,7 @@ OnLoadInternalState, RouterData, State, + reload_state_module, ) from reflex.testing import chdir from reflex.utils import prerequisites @@ -3712,6 +3713,54 @@ def bar(self) -> str: assert C1._get_potentially_dirty_states() == set() +def test_reload_scrubs_cross_state_dependency_edges(): + """Reloading a module drops dependency edges pointing at its states. + + Regression: an edge from an app-module state to a var on a state defined + *outside* that module (e.g. a package-level state) must be cleaned on + reload, or it dangles and crashes ``_mark_dirty_computed_vars`` on the + next app. + """ + + # Stands in for a long-lived package-level state (e.g. the i18n locale + # state): defined in this test module, so reloading the app module below + # leaves it in place. + class PkgState(RxState): + value: str = "" + + # A state in a separate "app" module whose computed var depends on + # PkgState.value, so the edge is stored on PkgState. + def _computed(self) -> str: + return "" + + app_module = "_reload_regression_fake_app" + AppState = type( + "AppState", + (RxState,), + { + "__module__": app_module, + "app_cv": computed_var(deps=[PkgState.value])(_computed), + }, + ) + app_name = AppState.get_full_name() + + assert app_name in PkgState._potentially_dirty_states + assert any( + edge[0] == app_name + for edges in PkgState._var_dependencies.values() + for edge in edges + ) + + reload_state_module(module=app_module) + + assert app_name not in PkgState._potentially_dirty_states + assert not any( + edge[0] == app_name + for edges in PkgState._var_dependencies.values() + for edge in edges + ) + + @pytest.mark.asyncio async def test_router_var_dep(state_manager: StateManager, token: str) -> None: """Test that router var dependencies are correctly tracked. diff --git a/uv.lock b/uv.lock index e6efb6377a0..2d84b765313 100644 --- a/uv.lock +++ b/uv.lock @@ -27,6 +27,7 @@ hatch-reflex-pyi = false reflex-components-gridjs = false starlette = false reflex = false +reflex-i18n = false reflex-enterprise = false reflex-components-code = false reflex-components-recharts = false @@ -61,6 +62,7 @@ members = [ "reflex-docs-app", "reflex-docs-bundle", "reflex-hosting-cli", + "reflex-i18n", "reflex-integrations-docs", "reflex-site-shared", ] @@ -311,6 +313,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -3670,6 +3681,7 @@ dev = [ { name = "pytest-split" }, { name = "python-dotenv" }, { name = "reflex-docgen" }, + { name = "reflex-i18n" }, { name = "reflex-site-shared" }, { name = "ruff" }, { name = "selenium" }, @@ -3747,6 +3759,7 @@ dev = [ { name = "pytest-split" }, { name = "python-dotenv" }, { name = "reflex-docgen", editable = "packages/reflex-docgen" }, + { name = "reflex-i18n", editable = "packages/reflex-i18n" }, { name = "reflex-site-shared", editable = "packages/reflex-site-shared" }, { name = "ruff" }, { name = "selenium" }, @@ -4092,6 +4105,22 @@ requires-dist = [ { name = "rich", specifier = ">=13,<15" }, ] +[[package]] +name = "reflex-i18n" +source = { editable = "packages/reflex-i18n" } +dependencies = [ + { name = "babel" }, + { name = "reflex" }, + { name = "reflex-base" }, +] + +[package.metadata] +requires-dist = [ + { name = "babel", specifier = ">=2.14.0,<3.0" }, + { name = "reflex", editable = "." }, + { name = "reflex-base", editable = "packages/reflex-base" }, +] + [[package]] name = "reflex-integrations-docs" source = { editable = "packages/integrations-docs" }