Repository: freedomofpress/securedrop

Issue:
runtime `i18n` configuration manipulates global state
I guess we already do this for LOCALES, but I think continuing the pattern should be accompanied by a TODO that manipulating/relying on global state is not desirable.

_Originally posted by @legoktm in https://github.com/freedomofpress/securedrop/pull/6406#discussion_r863080227_


Discussion:
(This is not a good first issue yet, I will add some more detail on what the plan is to fix this before it's ready for others to take on)

Resolving patch:
diff --git a/securedrop/i18n.py b/securedrop/i18n.py
--- a/securedrop/i18n.py
+++ b/securedrop/i18n.py
@@ -16,7 +16,7 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 import collections
-from typing import Dict, List, Set
+from typing import Dict, List, OrderedDict, Set
 
 from babel.core import (
     Locale,
@@ -25,7 +25,7 @@
     negotiate_locale,
     parse_locale,
 )
-from flask import Flask, g, request, session
+from flask import Flask, current_app, g, request, session
 from flask_babel import Babel
 from sdconfig import FALLBACK_LOCALE, SDConfig
 
@@ -128,7 +128,7 @@ def parse_locale_set(codes: List[str]) -> Set[Locale]:
     return {Locale.parse(code) for code in codes}
 
 
-def validate_locale_configuration(config: SDConfig, babel: Babel) -> None:
+def validate_locale_configuration(config: SDConfig, babel: Babel) -> Set[Locale]:
     """
     Check that configured locales are available in the filesystem and therefore usable by
     Babel.  Warn about configured locales that are not usable, unless we're left with
@@ -157,16 +157,12 @@ def validate_locale_configuration(config: SDConfig, babel: Babel) -> None:
             f"None of the default locales {defaults} are in the set of usable locales {usable}"
         )
 
-    global USABLE_LOCALES
-    USABLE_LOCALES = usable
+    return usable
 
 
-# TODO(#6420): avoid relying on and manipulating on this global state
-LOCALES = collections.OrderedDict()  # type: collections.OrderedDict[str, RequestLocaleInfo]
-USABLE_LOCALES = set()  # type: Set[Locale]
-
-
-def map_locale_display_names(config: SDConfig) -> None:
+def map_locale_display_names(
+    config: SDConfig, usable_locales: Set[Locale]
+) -> OrderedDict[str, RequestLocaleInfo]:
     """
     Create a map of locale identifiers to names for display.
 
@@ -183,7 +179,7 @@ def map_locale_display_names(config: SDConfig) -> None:
 
     locale_map = collections.OrderedDict()
     for l in sorted(config.SUPPORTED_LOCALES):
-        if Locale.parse(l) not in USABLE_LOCALES:
+        if Locale.parse(l) not in usable_locales:
             continue
 
         locale = RequestLocaleInfo(l)
@@ -193,14 +189,13 @@ def map_locale_display_names(config: SDConfig) -> None:
 
         locale_map[str(locale)] = locale
 
-    global LOCALES
-    LOCALES = locale_map
+    return locale_map
 
 
 def configure(config: SDConfig, app: Flask) -> None:
     babel = configure_babel(config, app)
-    validate_locale_configuration(config, babel)
-    map_locale_display_names(config)
+    usable_locales = validate_locale_configuration(config, babel)
+    app.config["LOCALES"] = map_locale_display_names(config, usable_locales)
 
 
 def get_locale(config: SDConfig) -> str:
@@ -223,7 +218,8 @@ def get_locale(config: SDConfig) -> str:
     preferences.append(config.DEFAULT_LOCALE)
     preferences.append(FALLBACK_LOCALE)
 
-    negotiated = negotiate_locale(preferences, LOCALES.keys())
+    locales = current_app.config["LOCALES"]
+    negotiated = negotiate_locale(preferences, locales.keys())
 
     if not negotiated:
         raise ValueError("No usable locale")
@@ -264,4 +260,4 @@ def set_locale(config: SDConfig) -> None:
     locale = get_locale(config)
     g.localeinfo = RequestLocaleInfo(locale)  # pylint: disable=assigning-non-slot
     session["locale"] = locale
-    g.locales = LOCALES  # pylint: disable=assigning-non-slot
+    g.locales = current_app.config["LOCALES"]  # pylint: disable=assigning-non-slot


Test patch:
diff --git a/securedrop/tests/test_i18n.py b/securedrop/tests/test_i18n.py
--- a/securedrop/tests/test_i18n.py
+++ b/securedrop/tests/test_i18n.py
@@ -296,7 +296,7 @@ def test_i18n():
     ):
         with app.app_context():
             db.create_all()
-        assert list(i18n.LOCALES.keys()) == test_config.SUPPORTED_LOCALES
+        assert list(app.config["LOCALES"].keys()) == test_config.SUPPORTED_LOCALES
         verify_i18n(app)
 
 
@@ -311,7 +311,6 @@ def test_no_usable_fallback_locale():
     test_config = create_config_for_i18n_test(
         default_locale=NEVER_LOCALE, supported_locales=[NEVER_LOCALE]
     )
-    i18n.USABLE_LOCALES = set()
 
     with pytest.raises(ValueError, match="in the set of usable locales"):
         journalist_app_module.create_app(test_config)
diff --git a/securedrop/tests/test_template_filters.py b/securedrop/tests/test_template_filters.py
--- a/securedrop/tests/test_template_filters.py
+++ b/securedrop/tests/test_template_filters.py
@@ -2,7 +2,6 @@
 from datetime import datetime, timedelta
 from pathlib import Path
 
-import i18n
 import i18n_tool
 import journalist_app
 import source_app
@@ -110,6 +109,6 @@ def do_test(create_app):
     with app.app_context():
         db.create_all()
 
-    assert list(i18n.LOCALES.keys()) == test_config.SUPPORTED_LOCALES
+    assert list(app.config["LOCALES"].keys()) == test_config.SUPPORTED_LOCALES
     verify_filesizeformat(app)
     verify_rel_datetime_format(app)