Repository: python-babel/babel
Pull request #1108 (2024-08-01 13:06:18)

Initial support for reading mapping configuration as TOML
This PR offers to implement part of https://github.com/python-babel/babel/issues/777.

Namely, following this PR you would be able to run `pybabel extract -F babel.toml` – or even `pybabel extract -F pyproject.toml`, as we already will support `tool.babel` as well as `babel` for the namespace. (Auto-detecting a `pyproject.toml` instead of `setup.cfg` or `babel.cfg` is not implemented in this PR.)

I'm **requesting comments** on the proposed TOML format, which borrows the idea of [mappings-as-lists from Mypy's overrides configuration](https://mypy.readthedocs.io/en/latest/config_file.html#example-pyproject-toml).

```toml
[babel.extractors]
custom = "mypackage.module:myfunc"

# Python source files
[[babel.mappings]]
method = "python"
pattern = "**.py"

# Genshi templates
[[babel.mappings]]
method = "genshi"
pattern = "**/templates/**.html"
include_attrs = ""

[[babel.mappings]]
method = "genshi"
pattern = "**/templates/**.txt"
template_class = "genshi.template:TextTemplate"
encoding = "latin-1"

# Some custom extractor
[[babel.mappings]]
method = "custom"
pattern = "**/custom/*.*"
```


akx: cc @zharktas, @tomasr8, @eemeli – please weigh in if you have ideas :)
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/python-babel/babel/pull/1108?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) Report
Attention: Patch coverage is `70.17544%` with `17 lines` in your changes missing coverage. Please review.
> Project coverage is 89.86%. Comparing base [(`a32f15e`)](https://app.codecov.io/gh/python-babel/babel/commit/a32f15ecec23d5d5049100fd8e65606be7ad12a1?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) to head [(`62ab4dc`)](https://app.codecov.io/gh/python-babel/babel/commit/62ab4dc9e06314c624520b6f0d227b471d4d74cf?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel).

| [Files](https://app.codecov.io/gh/python-babel/babel/pull/1108?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | Patch % | Lines |
|---|---|---|
| [babel/messages/frontend.py](https://app.codecov.io/gh/python-babel/babel/pull/1108?src=pr&el=tree&filepath=babel%2Fmessages%2Ffrontend.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel#diff-YmFiZWwvbWVzc2FnZXMvZnJvbnRlbmQucHk=) | 70.17% | [17 Missing :warning: ](https://app.codecov.io/gh/python-babel/babel/pull/1108?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #1108      +/-   ##
==========================================
- Coverage   91.21%   89.86%   -1.35%     
==========================================
  Files          27       27              
  Lines        4496     4549      +53     
==========================================
- Hits         4101     4088      -13     
- Misses        395      461      +66     
```

| [Flag](https://app.codecov.io/gh/python-babel/babel/pull/1108/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | Coverage Δ | |
|---|---|---|
| [macos-12-3.10](https://app.codecov.io/gh/python-babel/babel/pull/1108/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [macos-12-3.11](https://app.codecov.io/gh/python-babel/babel/pull/1108/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [macos-12-3.12](https://app.codecov.io/gh/python-babel/babel/pull/1108/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [macos-12-3.13-dev](https://app.codecov.io/gh/python-babel/babel/pull/1108/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [macos-12-3.8](https://app.codecov.io/gh/python-babel/babel/pull/1108/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [macos-12-3.9](https://app.codecov.io/gh/python-babel/babel/pull/1108/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [macos-12-pypy3.10](https://app.codecov.io/gh/python-babel/babel/pull/1108/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [ubuntu-22.04-3.10](https://app.codecov.io/gh/python-babel/babel/pull/1108/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `89.77% <70.17%> (-0.24%)` | :arrow_down: |
| [ubuntu-22.04-3.11](https://app.codecov.io/gh/python-babel/babel/pull/1108/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [ubuntu-22.04-3.12](https://app.codecov.io/gh/python-babel/babel/pull/1108/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [ubuntu-22.04-3.13-dev](https://app.codecov.io/gh/python-babel/babel/pull/1108/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [ubuntu-22.04-3.8](https://app.codecov.io/gh/python-babel/babel/pull/1108/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [ubuntu-22.04-3.9](https://app.codecov.io/gh/python-babel/babel/pull/1108/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `89.70% <70.17%> (-0.24%)` | :arrow_down: |
| [ubuntu-22.04-pypy3.10](https://app.codecov.io/gh/python-babel/babel/pull/1108/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [windows-2022-3.10](https://app.codecov.io/gh/python-babel/babel/pull/1108/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [windows-2022-3.11](https://app.codecov.io/gh/python-babel/babel/pull/1108/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [windows-2022-3.12](https://app.codecov.io/gh/python-babel/babel/pull/1108/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [windows-2022-3.13-dev](https://app.codecov.io/gh/python-babel/babel/pull/1108/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [windows-2022-3.8](https://app.codecov.io/gh/python-babel/babel/pull/1108/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [windows-2022-3.9](https://app.codecov.io/gh/python-babel/babel/pull/1108/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |
| [windows-2022-pypy3.10](https://app.codecov.io/gh/python-babel/babel/pull/1108/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `?` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/python-babel/babel/pull/1108?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel).

akx: Oh, and cc @miigotu too of course as the opener of #777 😄 
tomasr8: Looks great! I was just wondering how you can specify ignore paths. Would this work?
```toml
[[babel.mappings]]
method = "ignore"
pattern = "test/*"
```
Can the pattern be a list?
akx: > Looks great! I was just wondering how you can specify ignore paths. 

By not specifying them in a mapping at all... 😅 There's also the command-line `--ignore-dirs` option for wholesale directory ignoring.

But if your imagined use-case is "extract from all HTML files with `jinja2`, but never extract anything from `test*.html`", that would be a separate issue. Perfectly doable with a no-op extractor (that we don't yet have), since the list of mappings is in priority order, but that'd also work for .cfg style mapping files.

> Can the pattern be a list?

Might as well support that too!
akx: > I'm a bit tired so maybe I missed something, but there already exists a noop extractor - `extract_nothing` so for example this cfg currently works as expected:

My bad, it's not you being tired. I had forgotten there's a special `if method == 'ignore':` case in `extract_from_file` that handles that (as well as the `'ignore': extract_nothing` case in `_BUILTIN_EXTRACTORS`)...
akx: > Entirely unsolicited,

Not at all, thank you for the input! Much appreciated.

> but an option might be: ...

I'm not completely sold on that – feels like it becomes too easy to accidentally do `[mappings.python]` if you expect to only have a single mapping for `python`, and you'd then get a (possibly inscrutable) parsing type error.

> I would also drop the babel. prefix for the babel.toml file -- it is needlessly explicit (see e.g. .ruff.toml which drops the full tool.ruff prefix).

That's probably a good idea.
AA-Turner: > I'm not completely sold on that – feels like it becomes too easy to accidentally do `[mappings.python]` if you expect to only have a single mapping for `python`, and you'd then get a (possibly inscrutable) parsing type error.

I think this is a problem regardless. For example [this PR](https://github.com/python/python-docs-theme/pull/195) adds a config file with one entry, ``[jinja2: **.html]``. Someone without knowledge of TOML's quirks might write 

```toml
[mapping]
method = "jinja2"
pattern = "**.html"
```

which looks right, but would instantly fail. Worse would be:

```toml
[babel.mappings]
method = "genshi"
pattern = "**/templates/**.html"
include_attrs = ""

[babel.mappings]
method = "genshi"
pattern = "**/templates/**.txt"
template_class = "genshi.template:TextTemplate"
encoding = "latin-1"
```

This is luckily an [error](https://toml.io/en/v1.0.0#table) in TOML, but still confusing. There should be a clear and early error message if ``isinstance(config['mapping'], dict) is True`` (or in my proposal, ``any(isinstance(k, dict) for k in config['mapping'].keys())``).

I think with the clear error message (that I posit is needed regardless of the option taken), my proposal still has merit. However, it does have drawbacks as you note.

Patch:
diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py
index 65ff661..7a9ce38 100644
--- a/babel/messages/frontend.py
+++ b/babel/messages/frontend.py
@@ -19,10 +19,11 @@ import re
 import shutil
 import sys
 import tempfile
+import warnings
 from collections import OrderedDict
 from configparser import RawConfigParser
 from io import StringIO
-from typing import Iterable
+from typing import BinaryIO, Iterable, Literal
 
 from babel import Locale, localedata
 from babel import __version__ as VERSION
@@ -53,6 +54,12 @@ class SetupError(BaseError):
     pass
 
 
+class ConfigurationError(BaseError):
+    """
+    Raised for errors in configuration files.
+    """
+
+
 def listify_value(arg, split=None):
     """
     Make a list out of an argument.
@@ -534,8 +541,21 @@ class ExtractMessages(CommandMixin):
         mappings = []
 
         if self.mapping_file:
-            with open(self.mapping_file) as fileobj:
-                method_map, options_map = parse_mapping(fileobj)
+            if self.mapping_file.endswith(".toml"):
+                with open(self.mapping_file, "rb") as fileobj:
+                    file_style = (
+                        "pyproject.toml"
+                        if os.path.basename(self.mapping_file) == "pyproject.toml"
+                        else "standalone"
+                    )
+                    method_map, options_map = _parse_mapping_toml(
+                        fileobj,
+                        filename=self.mapping_file,
+                        style=file_style,
+                    )
+            else:
+                with open(self.mapping_file) as fileobj:
+                    method_map, options_map = parse_mapping_cfg(fileobj, filename=self.mapping_file)
             for path in self.input_paths:
                 mappings.append((path, method_map, options_map))
 
@@ -543,7 +563,7 @@ class ExtractMessages(CommandMixin):
             message_extractors = self.distribution.message_extractors
             for path, mapping in message_extractors.items():
                 if isinstance(mapping, str):
-                    method_map, options_map = parse_mapping(StringIO(mapping))
+                    method_map, options_map = parse_mapping_cfg(StringIO(mapping))
                 else:
                     method_map, options_map = [], {}
                     for pattern, method, options in mapping:
@@ -980,53 +1000,19 @@ def main():
 
 
 def parse_mapping(fileobj, filename=None):
-    """Parse an extraction method mapping from a file-like object.
+    warnings.warn(
+        "parse_mapping is deprecated, use parse_mapping_cfg instead",
+        DeprecationWarning,
+        stacklevel=2,
+    )
+    return parse_mapping_cfg(fileobj, filename)
 
-    >>> buf = StringIO('''
-    ... [extractors]
-    ... custom = mypackage.module:myfunc
-    ...
-    ... # Python source files
-    ... [python: **.py]
-    ...
-    ... # Genshi templates
-    ... [genshi: **/templates/**.html]
-    ... include_attrs =
-    ... [genshi: **/templates/**.txt]
-    ... template_class = genshi.template:TextTemplate
-    ... encoding = latin-1
-    ...
-    ... # Some custom extractor
-    ... [custom: **/custom/*.*]
-    ... ''')
-
-    >>> method_map, options_map = parse_mapping(buf)
-    >>> len(method_map)
-    4
-
-    >>> method_map[0]
-    ('**.py', 'python')
-    >>> options_map['**.py']
-    {}
-    >>> method_map[1]
-    ('**/templates/**.html', 'genshi')
-    >>> options_map['**/templates/**.html']['include_attrs']
-    ''
-    >>> method_map[2]
-    ('**/templates/**.txt', 'genshi')
-    >>> options_map['**/templates/**.txt']['template_class']
-    'genshi.template:TextTemplate'
-    >>> options_map['**/templates/**.txt']['encoding']
-    'latin-1'
-
-    >>> method_map[3]
-    ('**/custom/*.*', 'mypackage.module:myfunc')
-    >>> options_map['**/custom/*.*']
-    {}
+
+def parse_mapping_cfg(fileobj, filename=None):
+    """Parse an extraction method mapping from a file-like object.
 
     :param fileobj: a readable file-like object containing the configuration
                     text to parse
-    :see: `extract_from_directory`
     """
     extractors = {}
     method_map = []
@@ -1053,6 +1039,94 @@ def parse_mapping(fileobj, filename=None):
     return method_map, options_map
 
 
+def _parse_config_object(config: dict, *, filename="(unknown)"):
+    extractors = {}
+    method_map = []
+    options_map = {}
+
+    extractors_read = config.get("extractors", {})
+    if not isinstance(extractors_read, dict):
+        raise ConfigurationError(f"{filename}: extractors: Expected a dictionary, got {type(extractors_read)!r}")
+    for method, callable_spec in extractors_read.items():
+        if not isinstance(method, str):
+            # Impossible via TOML, but could happen with a custom object.
+            raise ConfigurationError(f"{filename}: extractors: Extraction method must be a string, got {method!r}")
+        if not isinstance(callable_spec, str):
+            raise ConfigurationError(f"{filename}: extractors: Callable specification must be a string, got {callable_spec!r}")
+        extractors[method] = callable_spec
+
+    if "mapping" in config:
+        raise ConfigurationError(f"{filename}: 'mapping' is not a valid key, did you mean 'mappings'?")
+
+    mappings_read = config.get("mappings", [])
+    if not isinstance(mappings_read, list):
+        raise ConfigurationError(f"{filename}: mappings: Expected a list, got {type(mappings_read)!r}")
+    for idx, entry in enumerate(mappings_read):
+        if not isinstance(entry, dict):
+            raise ConfigurationError(f"{filename}: mappings[{idx}]: Expected a dictionary, got {type(entry)!r}")
+        entry = entry.copy()
+
+        method = entry.pop("method", None)
+        if not isinstance(method, str):
+            raise ConfigurationError(f"{filename}: mappings[{idx}]: 'method' must be a string, got {method!r}")
+        method = extractors.get(method, method)  # Map the extractor name to the callable now
+
+        pattern = entry.pop("pattern", None)
+        if not isinstance(pattern, (list, str)):
+            raise ConfigurationError(f"{filename}: mappings[{idx}]: 'pattern' must be a list or a string, got {pattern!r}")
+        if not isinstance(pattern, list):
+            pattern = [pattern]
+
+        for pat in pattern:
+            if not isinstance(pat, str):
+                raise ConfigurationError(f"{filename}: mappings[{idx}]: 'pattern' elements must be strings, got {pat!r}")
+            method_map.append((pat, method))
+            options_map[pat] = entry
+
+    return method_map, options_map
+
+
+def _parse_mapping_toml(
+    fileobj: BinaryIO,
+    filename: str = "(unknown)",
+    style: Literal["standalone", "pyproject.toml"] = "standalone",
+):
+    """Parse an extraction method mapping from a binary file-like object.
+
+    .. warning: As of this version of Babel, this is a private API subject to changes.
+
+    :param fileobj: a readable binary file-like object containing the configuration TOML to parse
+    :param filename: the name of the file being parsed, for error messages
+    :param style: whether the file is in the style of a `pyproject.toml` file, i.e. whether to look for `tool.babel`.
+    """
+    try:
+        import tomllib
+    except ImportError:
+        try:
+            import tomli as tomllib
+        except ImportError as ie:  # pragma: no cover
+            raise ImportError("tomli or tomllib is required to parse TOML files") from ie
+
+    try:
+        parsed_data = tomllib.load(fileobj)
+    except tomllib.TOMLDecodeError as e:
+        raise ConfigurationError(f"{filename}: Error parsing TOML file: {e}") from e
+
+    if style == "pyproject.toml":
+        try:
+            babel_data = parsed_data["tool"]["babel"]
+        except (TypeError, KeyError) as e:
+            raise ConfigurationError(f"{filename}: No 'tool.babel' section found in file") from e
+    elif style == "standalone":
+        babel_data = parsed_data
+        if "babel" in babel_data:
+            raise ConfigurationError(f"{filename}: 'babel' should not be present in a stand-alone configuration file")
+    else:  # pragma: no cover
+        raise ValueError(f"Unknown TOML style {style!r}")
+
+    return _parse_config_object(babel_data, filename=filename)
+
+
 def _parse_spec(s: str) -> tuple[int | None, tuple[int | tuple[int, str], ...]]:
     inds = []
     number = None
diff --git a/docs/dev.rst b/docs/dev.rst
index 95f20d5..51c1a06 100644
--- a/docs/dev.rst
+++ b/docs/dev.rst
@@ -3,7 +3,7 @@ Babel Development
 
 Babel as a library has a long history that goes back to the Trac project.
 Since then it has evolved into an independently developed project that
-implements data access for the CLDR project.
+implements data access for the `https://cldr.unicode.org Unicode CLDR project`_.
 
 This document tries to explain as best as possible the general rules of
 the project in case you want to help out developing.
@@ -12,18 +12,19 @@ Tracking the CLDR
 -----------------
 
 Generally the goal of the project is to work as closely as possible with
-the CLDR data.  This has in the past caused some frustrating problems
+the `https://cldr.unicode.org/index/charts CLDR data`_.  This has in the
+past caused some frustrating problems
 because the data is entirely out of our hand.  To minimize the frustration
 we generally deal with CLDR updates the following way:
 
-*   bump the CLDR data only with a major release of Babel.
-*   never perform custom bugfixes on the CLDR data.
-*   never work around CLDR bugs within Babel.  If you find a problem in
+*   Bump the CLDR data only with a major release of Babel.
+*   Never perform custom bugfixes on the CLDR data.
+*   Never work around CLDR bugs within Babel. If you find a problem in
     the data, report it upstream.
-*   adjust the parsing of the data as soon as possible, otherwise this
-    will spiral out of control later.  This is especially the case for
+*   Adjust the parsing of the data as soon as possible, otherwise this
+    will spiral out of control later. This is especially the case for
     bigger updates that change pluralization and more.
-*   try not to test against specific CLDR data that is likely to change.
+*   Try not to test against specific CLDR data that is likely to change.
 
 Python Versions
 ---------------
@@ -36,7 +37,7 @@ At the moment the following Python versions should be supported:
 Unicode
 -------
 
-Unicode is a big deal in Babel.  Here is how the rules are set up:
+Unicode is a big deal in Babel. Here is how the rules are set up:
 
 *   internally everything is unicode that makes sense to have as unicode.
 *   Encode / decode at boundaries explicitly.  Never assume an encoding in
diff --git a/scripts/download_import_cldr.py b/scripts/download_import_cldr.py
index 4a9805d..cf670ed 100755
--- a/scripts/download_import_cldr.py
+++ b/scripts/download_import_cldr.py
@@ -79,7 +79,9 @@ def main():
     subprocess.check_call([
         sys.executable,
         os.path.join(scripts_path, 'import_cldr.py'),
-        common_path])
+        common_path,
+        *sys.argv[1:],
+    ])
 
 
 if __name__ == '__main__':
diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py
index ee48144..e8cc031 100755
--- a/scripts/import_cldr.py
+++ b/scripts/import_cldr.py
@@ -417,7 +417,8 @@ def _process_local_datas(sup, srcdir, destdir, force=False, dump_json=False):
         if locale_id in day_period_rules:
             data["day_period_rules"] = day_period_rules[locale_id]
 
-        parse_locale_display_names(data, tree)
+        is_global = ("_" not in locale_id)
+        parse_locale_display_names(data, tree, is_global=is_global)
         parse_list_patterns(data, tree)
         parse_dates(data, tree, sup, regions, territory)
 
@@ -489,43 +490,54 @@ def _should_skip_elem(elem, type=None, dest=None):
     :param dest: Destination dict. May be elided to skip the dict check.
     :return: skip boolean
     """
-    if 'draft' in elem.attrib or 'alt' in elem.attrib:
+    if _is_draft_or_alt(elem):
         if dest is None or type in dest:
             return True
 
 
-def _import_type_text(dest, elem, type=None):
+def _is_draft_or_alt(elem) -> bool:
+    return 'draft' in elem.attrib or 'alt' in elem.attrib
+
+
+def _import_type_text(dest, elem, type=None, *, allow_variant_and_draft_fallback=True) -> None:
     """
     Conditionally import the element's inner text(s) into the `dest` dict.
 
-    The condition being, namely, that the element isn't a draft/alternate version
-    of a pre-existing element.
+    If `allow_variant_and_draft_fallback` is True, then the element may be imported
+    if there otherwise isn't a pre-existing element of the same type.
 
     :param dest: Destination dict
     :param elem: XML element.
     :param type: Override type. (By default, the `type` attr of the element.)
-    :return:
+    :param allow_variant_and_draft_fallback: See above.
+    :return: Nothing.
     """
     if type is None:
         type = elem.attrib['type']
-    if _should_skip_elem(elem, type, dest):
+
+    # Already have this, nothing to do.
+    if type in dest:
+        return
+
+    if not allow_variant_and_draft_fallback and _is_draft_or_alt(elem):
+        # Not allowed to use a draft/alternate here.
         return
     dest[type] = _text(elem)
 
 
-def parse_locale_display_names(data, tree):
+def parse_locale_display_names(data, tree, *, is_global: bool):
     territories = data.setdefault('territories', {})
     for elem in tree.findall('.//territories/territory'):
-        _import_type_text(territories, elem)
+        _import_type_text(territories, elem, allow_variant_and_draft_fallback=is_global)
     languages = data.setdefault('languages', {})
     for elem in tree.findall('.//languages/language'):
-        _import_type_text(languages, elem)
+        _import_type_text(languages, elem, allow_variant_and_draft_fallback=is_global)
     variants = data.setdefault('variants', {})
     for elem in tree.findall('.//variants/variant'):
-        _import_type_text(variants, elem)
+        _import_type_text(variants, elem, allow_variant_and_draft_fallback=is_global)
     scripts = data.setdefault('scripts', {})
     for elem in tree.findall('.//scripts/script'):
-        _import_type_text(scripts, elem)
+        _import_type_text(scripts, elem, allow_variant_and_draft_fallback=is_global)
 
 
 def parse_list_patterns(data, tree):


Test patch:
diff --git a/tests/messages/test_frontend.py b/tests/messages/test_frontend.py
index 45638b3..7a6b08c 100644
--- a/tests/messages/test_frontend.py
+++ b/tests/messages/test_frontend.py
@@ -11,12 +11,14 @@
 # history and logs, available at http://babel.edgewall.org/log/.
 import logging
 import os
+import re
 import shlex
 import shutil
 import sys
 import time
 import unittest
 from datetime import datetime, timedelta
+from functools import partial
 from io import BytesIO, StringIO
 from typing import List
 
@@ -1388,25 +1390,86 @@ msgstr[2] ""
             assert len(catalog) == 4  # Catalog was updated
 
 
-def test_parse_mapping():
-    buf = StringIO(
-        '[extractors]\n'
-        'custom = mypackage.module:myfunc\n'
-        '\n'
-        '# Python source files\n'
-        '[python: **.py]\n'
-        '\n'
-        '# Genshi templates\n'
-        '[genshi: **/templates/**.html]\n'
-        'include_attrs =\n'
-        '[genshi: **/templates/**.txt]\n'
-        'template_class = genshi.template:TextTemplate\n'
-        'encoding = latin-1\n'
-        '\n'
-        '# Some custom extractor\n'
-        '[custom: **/custom/*.*]\n')
-
-    method_map, options_map = frontend.parse_mapping(buf)
+mapping_cfg = """
+[extractors]
+custom = mypackage.module:myfunc
+
+# Python source files
+[python: **.py]
+
+# Genshi templates
+[genshi: **/templates/**.html]
+include_attrs =
+
+[genshi: **/templates/**.txt]
+template_class = genshi.template:TextTemplate
+encoding = latin-1
+
+# Some custom extractor
+[custom: **/custom/*.*]
+"""
+
+mapping_toml = """
+[extractors]
+custom = "mypackage.module:myfunc"
+
+# Python source files
+[[mappings]]
+method = "python"
+pattern = "**.py"
+
+# Genshi templates
+[[mappings]]
+method = "genshi"
+pattern = "**/templates/**.html"
+include_attrs = ""
+
+[[mappings]]
+method = "genshi"
+pattern = "**/templates/**.txt"
+template_class = "genshi.template:TextTemplate"
+encoding = "latin-1"
+
+# Some custom extractor
+[[mappings]]
+method = "custom"
+pattern = "**/custom/*.*"
+"""
+
+
+@pytest.mark.parametrize(
+    ("data", "parser", "preprocess", "is_toml"),
+    [
+        (
+            mapping_cfg,
+            frontend.parse_mapping_cfg,
+            None,
+            False,
+        ),
+        (
+            mapping_toml,
+            frontend._parse_mapping_toml,
+            None,
+            True,
+        ),
+        (
+            mapping_toml,
+            partial(frontend._parse_mapping_toml, style="pyproject.toml"),
+            lambda s: re.sub(r"^(\[+)", r"\1tool.babel.", s, flags=re.MULTILINE),
+            True,
+        ),
+    ],
+    ids=("cfg", "toml", "pyproject-toml"),
+)
+def test_parse_mapping(data: str, parser, preprocess, is_toml):
+    if preprocess:
+        data = preprocess(data)
+    if is_toml:
+        buf = BytesIO(data.encode())
+    else:
+        buf = StringIO(data)
+
+    method_map, options_map = parser(buf)
     assert len(method_map) == 4
 
     assert method_map[0] == ('**.py', 'python')
diff --git a/tests/messages/test_toml_config.py b/tests/messages/test_toml_config.py
new file mode 100644
index 0000000..6a3c157
--- /dev/null
+++ b/tests/messages/test_toml_config.py
@@ -0,0 +1,38 @@
+import pathlib
+from io import BytesIO
+
+import pytest
+
+from babel.messages import frontend
+
+toml_test_cases_path = pathlib.Path(__file__).parent / "toml-test-cases"
+assert toml_test_cases_path.is_dir(), "toml-test-cases directory not found"
+
+
+def test_toml_mapping_multiple_patterns():
+    """
+    Test that patterns may be specified as a list in TOML,
+    and are expanded to multiple entries in the method map.
+    """
+    method_map, options_map = frontend._parse_mapping_toml(BytesIO(b"""
+[[mappings]]
+method = "python"
+pattern = ["xyz/**.py", "foo/**.py"]
+"""))
+    assert len(method_map) == 2
+    assert method_map[0] == ('xyz/**.py', 'python')
+    assert method_map[1] == ('foo/**.py', 'python')
+
+
+@pytest.mark.parametrize("test_case", toml_test_cases_path.glob("bad.*.toml"), ids=lambda p: p.name)
+def test_bad_toml_test_case(test_case: pathlib.Path):
+    """
+    Test that bad TOML files raise a ValueError.
+    """
+    with pytest.raises(frontend.ConfigurationError):
+        with test_case.open("rb") as f:
+            frontend._parse_mapping_toml(
+                f,
+                filename=test_case.name,
+                style="pyproject.toml" if "pyproject" in test_case.name else "standalone",
+            )
diff --git a/tests/messages/toml-test-cases/bad.extractor.toml b/tests/messages/toml-test-cases/bad.extractor.toml
new file mode 100644
index 0000000..9992684
--- /dev/null
+++ b/tests/messages/toml-test-cases/bad.extractor.toml
@@ -0,0 +1,2 @@
+[extractors]
+custom = { module = "mypackage.module", func = "myfunc" }
diff --git a/tests/messages/toml-test-cases/bad.extractors-not-a-dict.toml b/tests/messages/toml-test-cases/bad.extractors-not-a-dict.toml
new file mode 100644
index 0000000..92d7678
--- /dev/null
+++ b/tests/messages/toml-test-cases/bad.extractors-not-a-dict.toml
@@ -0,0 +1,1 @@
+[[extractors]]
diff --git a/tests/messages/toml-test-cases/bad.just-a-mapping.toml b/tests/messages/toml-test-cases/bad.just-a-mapping.toml
new file mode 100644
index 0000000..40006f5
--- /dev/null
+++ b/tests/messages/toml-test-cases/bad.just-a-mapping.toml
@@ -0,0 +1,3 @@
+[mapping]
+method = "jinja2"
+pattern = "**.html"
diff --git a/tests/messages/toml-test-cases/bad.mapping-not-a-dict.toml b/tests/messages/toml-test-cases/bad.mapping-not-a-dict.toml
new file mode 100644
index 0000000..f22367f
--- /dev/null
+++ b/tests/messages/toml-test-cases/bad.mapping-not-a-dict.toml
@@ -0,0 +1,1 @@
+mappings = [8]
diff --git a/tests/messages/toml-test-cases/bad.mappings-not-a-list.toml b/tests/messages/toml-test-cases/bad.mappings-not-a-list.toml
new file mode 100644
index 0000000..f308741
--- /dev/null
+++ b/tests/messages/toml-test-cases/bad.mappings-not-a-list.toml
@@ -0,0 +1,1 @@
+mappings = "python"
diff --git a/tests/messages/toml-test-cases/bad.missing-extraction-method.toml b/tests/messages/toml-test-cases/bad.missing-extraction-method.toml
new file mode 100644
index 0000000..69a2be2
--- /dev/null
+++ b/tests/messages/toml-test-cases/bad.missing-extraction-method.toml
@@ -0,0 +1,2 @@
+[[mappings]]
+pattern = ["xyz/**.py", "foo/**.py"]
diff --git a/tests/messages/toml-test-cases/bad.multiple-mappings-not-a-list.toml b/tests/messages/toml-test-cases/bad.multiple-mappings-not-a-list.toml
new file mode 100644
index 0000000..48c1513
--- /dev/null
+++ b/tests/messages/toml-test-cases/bad.multiple-mappings-not-a-list.toml
@@ -0,0 +1,10 @@
+[mappings]
+method = "genshi"
+pattern = "**/templates/**.html"
+include_attrs = ""
+
+[mappings]
+method = "genshi"
+pattern = "**/templates/**.txt"
+template_class = "genshi.template:TextTemplate"
+encoding = "latin-1"
diff --git a/tests/messages/toml-test-cases/bad.non-string-extraction-method.toml b/tests/messages/toml-test-cases/bad.non-string-extraction-method.toml
new file mode 100644
index 0000000..19c3ece
--- /dev/null
+++ b/tests/messages/toml-test-cases/bad.non-string-extraction-method.toml
@@ -0,0 +1,2 @@
+[[mappings]]
+method = 42
diff --git a/tests/messages/toml-test-cases/bad.pattern-type-2.toml b/tests/messages/toml-test-cases/bad.pattern-type-2.toml
new file mode 100644
index 0000000..d6c1382
--- /dev/null
+++ b/tests/messages/toml-test-cases/bad.pattern-type-2.toml
@@ -0,0 +1,3 @@
+[[mappings]]
+method = "big snake"
+pattern = [42]
diff --git a/tests/messages/toml-test-cases/bad.pattern-type.toml b/tests/messages/toml-test-cases/bad.pattern-type.toml
new file mode 100644
index 0000000..598b722
--- /dev/null
+++ b/tests/messages/toml-test-cases/bad.pattern-type.toml
@@ -0,0 +1,3 @@
+[[mappings]]
+method = "big snake"
+pattern = 2048
diff --git a/tests/messages/toml-test-cases/bad.pyproject-without-tool-babel.toml b/tests/messages/toml-test-cases/bad.pyproject-without-tool-babel.toml
new file mode 100644
index 0000000..598b722
--- /dev/null
+++ b/tests/messages/toml-test-cases/bad.pyproject-without-tool-babel.toml
@@ -0,0 +1,3 @@
+[[mappings]]
+method = "big snake"
+pattern = 2048
diff --git a/tests/messages/toml-test-cases/bad.standalone-with-babel-prefix.toml b/tests/messages/toml-test-cases/bad.standalone-with-babel-prefix.toml
new file mode 100644
index 0000000..cbc1d8d
--- /dev/null
+++ b/tests/messages/toml-test-cases/bad.standalone-with-babel-prefix.toml
@@ -0,0 +1,2 @@
+[babel.extractors]
+custom = "mypackage.module:myfunc"
diff --git a/tests/test_core.py b/tests/test_core.py
index 1bec215..57f1a89 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -348,3 +348,17 @@ def test_issue_814():
     loc = Locale.parse('ca_ES_valencia')
     assert loc.variant == "VALENCIA"
     assert loc.get_display_name() == 'català (Espanya, valencià)'
+
+
+def test_issue_1112():
+    """
+    Test that an alternate spelling of `Türkei` doesn't inadvertently
+    get imported from `de_AT` to replace the parent's non-alternate spelling.
+    """
+    assert (
+        Locale.parse('de').territories['TR'] ==
+        Locale.parse('de_AT').territories['TR'] ==
+        Locale.parse('de_CH').territories['TR'] ==
+        Locale.parse('de_DE').territories['TR'] ==
+        'Türkei'
+    )