Repository: python-babel/babel

Issue:
ValueError from failed import of localtime submodule
## Overview Description

When calling routines like `babel.number.get_decimal_symbol`, an internal import of `babel.localtime` fails under certain circumstances with a ValueError, that may easily slip awareness.

## Steps to Reproduce

1. Have a Unix system
2. set TZ=/UTC
3. in Python 3.9+ without `pytz`, run `babel.numbers.parse_decimal("5.2")`

## Actual Results

```
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/lib/python3.10/dist-packages/babel/numbers.py", line 1103, in parse_decimal
    group_symbol = get_group_symbol(locale, numbering_system=numbering_system)
  File "/usr/local/lib/python3.10/dist-packages/babel/numbers.py", line 452, in get_group_symbol
    return _get_number_symbols(locale, numbering_system=numbering_system).get('group', ',')
  File "/usr/local/lib/python3.10/dist-packages/babel/numbers.py", line 335, in _get_number_symbols
    return parsed_locale.number_symbols[numbering_system]
  File "/usr/local/lib/python3.10/dist-packages/babel/core.py", line 640, in number_symbols
    return self._data['number_symbols']
  File "/usr/local/lib/python3.10/dist-packages/babel/core.py", line 439, in _data
    self.__data = localedata.LocaleDataDict(localedata.load(str(self)))
  File "/usr/local/lib/python3.10/dist-packages/babel/localedata.py", line 137, in load
    data = load(parent).copy()
  File "/usr/local/lib/python3.10/dist-packages/babel/localedata.py", line 137, in load
    data = load(parent).copy()
  File "/usr/local/lib/python3.10/dist-packages/babel/localedata.py", line 137, in load
    data = load(parent).copy()
  File "/usr/local/lib/python3.10/dist-packages/babel/localedata.py", line 143, in load
    data = pickle.load(fileobj)
  File "/usr/local/lib/python3.10/dist-packages/babel/dates.py", line 34, in <module>
    from babel import localtime
  File "/usr/local/lib/python3.10/dist-packages/babel/localtime/__init__.py", line 41, in <module>
    LOCALTZ = get_localzone()
  File "/usr/local/lib/python3.10/dist-packages/babel/localtime/__init__.py", line 37, in get_localzone
    return _get_localzone()
  File "/usr/local/lib/python3.10/dist-packages/babel/localtime/_unix.py", line 36, in _get_localzone
    return _tz_from_env(tzenv)
  File "/usr/local/lib/python3.10/dist-packages/babel/localtime/_unix.py", line 21, in _tz_from_env
    return _get_tzinfo_or_raise(tzenv)
  File "/usr/local/lib/python3.10/dist-packages/babel/localtime/_helpers.py", line 29, in _get_tzinfo_or_raise
    tzinfo = _get_tzinfo(tzenv)
  File "/usr/local/lib/python3.10/dist-packages/babel/localtime/_helpers.py", line 21, in _get_tzinfo
    return zoneinfo.ZoneInfo(tzenv)
  File "/usr/lib/python3.10/zoneinfo/_tzpath.py", line 67, in find_tzfile
    _validate_tzfile_path(key)
  File "/usr/lib/python3.10/zoneinfo/_tzpath.py", line 81, in _validate_tzfile_path
    raise ValueError(
ValueError: ZoneInfo keys may not be absolute paths, got: /UTC
```

## Expected Results

`Decimal('5.2')`

## Reproducibility

Works repeatedly with many calls, as long as they rely on `localtime`.

## Additional Information

AFAICT the `babel.localtime` submodule is loaded lazily in the course of processing calls like `babel.number.parse_decimal` or `babel.number.get_decimal_symbol`. On systems where `pytz` is not present, `zoneinfo` is loaded in `babel.localtime._unix.py` via  `babel.localtime.__init__.py`, where also `get_localzone` is called. On systems with `TZ` set (and it not being a local file), this triggers a call to `zoneinfo.ZoneInfo` with the value of `TZ`. This may be an invalid value, which raises an uncaught ValueError. This error is raised again on repeated calls, as the import fails.

While the workaround is pretty easy (install `pytz`), the problem here is awareness:

- I don't think many people check the full env list for potential pitfalls. It may even be out of the user's control - especially on cloud services.
- I can't think of a test that catches this accidentally.
- Many of these calls are specifically wrapped to catch `ValueError` under the assumption that this means that the parsing failed due to the input value.

So this is a potential case of "fails only in production silently".

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/python-babel/babel/pull/1100?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 90.34%. Comparing base [(`2f87363`)](https://app.codecov.io/gh/python-babel/babel/commit/2f87363410f3c904e107e85ca10b9f84902db93f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) to head [(`ff4c01c`)](https://app.codecov.io/gh/python-babel/babel/commit/ff4c01cd90bdcb36f7a6d7ccc413c48257554979?dropdown=coverage&el=desc&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    #1100      +/-   ##
==========================================
- Coverage   91.09%   90.34%   -0.75%     
==========================================
  Files          26       26              
  Lines        4470     4476       +6     
==========================================
- Hits         4072     4044      -28     
- Misses        398      432      +34     
```

| [Flag](https://app.codecov.io/gh/python-babel/babel/pull/1100/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/1100/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/1100/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/1100/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/1100/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/1100/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/1100/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/1100/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `90.05% <66.66%> (+0.12%)` | :arrow_up: |
| [ubuntu-22.04-3.11](https://app.codecov.io/gh/python-babel/babel/pull/1100/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `90.05% <66.66%> (+0.12%)` | :arrow_up: |
| [ubuntu-22.04-3.12](https://app.codecov.io/gh/python-babel/babel/pull/1100/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/1100/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `90.05% <50.00%> (+0.19%)` | :arrow_up: |
| [ubuntu-22.04-3.9](https://app.codecov.io/gh/python-babel/babel/pull/1100/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-babel) | `90.05% <66.66%> (+0.12%)` | :arrow_up: |
| [ubuntu-22.04-pypy3.10](https://app.codecov.io/gh/python-babel/babel/pull/1100/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/1100/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/1100/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/1100/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/1100/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/1100/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/1100/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/1100?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).

matthiashuschle: Looks good to me.

Patch:
diff --git a/babel/localtime/_helpers.py b/babel/localtime/_helpers.py
index f27b315..e7e6705 100644
--- a/babel/localtime/_helpers.py
+++ b/babel/localtime/_helpers.py
@@ -2,7 +2,11 @@ try:
     import pytz
 except ModuleNotFoundError:
     pytz = None
+
+try:
     import zoneinfo
+except ModuleNotFoundError:
+    zoneinfo = None
 
 
 def _get_tzinfo(tzenv: str):
@@ -19,6 +23,16 @@ def _get_tzinfo(tzenv: str):
     else:
         try:
             return zoneinfo.ZoneInfo(tzenv)
+        except ValueError as ve:
+            # This is somewhat hacky, but since _validate_tzfile_path() doesn't
+            # raise a specific error type, we'll need to check the message to be
+            # one we know to be from that function.
+            # If so, we pretend it meant that the TZ didn't exist, for the benefit
+            # of `babel.localtime` catching the `LookupError` raised by
+            # `_get_tzinfo_or_raise()`.
+            # See https://github.com/python-babel/babel/issues/1092
+            if str(ve).startswith("ZoneInfo keys "):
+                return None
         except zoneinfo.ZoneInfoNotFoundError:
             pass
 


Test patch:
diff --git a/tests/test_localtime.py b/tests/test_localtime.py
new file mode 100644
index 0000000..723ffa0
--- /dev/null
+++ b/tests/test_localtime.py
@@ -0,0 +1,29 @@
+import sys
+
+import pytest
+
+from babel.localtime import _helpers, get_localzone
+
+
+@pytest.mark.skipif(
+    sys.platform == "win32",
+    reason="Issue 1092 is not applicable on Windows",
+)
+def test_issue_1092_without_pytz(monkeypatch):
+    pytest.importorskip("zoneinfo", reason="zoneinfo is not available")
+    monkeypatch.setenv("TZ", "/UTC")  # Malformed timezone name.
+    # In case pytz _is_ also installed, we want to pretend it's not, so patch it out...
+    monkeypatch.setattr(_helpers, "pytz", None)
+    with pytest.raises(LookupError):
+        get_localzone()
+
+
+@pytest.mark.skipif(
+    sys.platform == "win32",
+    reason="Issue 1092 is not applicable on Windows",
+)
+def test_issue_1092_with_pytz(monkeypatch):
+    pytest.importorskip("pytz", reason="pytz is not installed")
+    monkeypatch.setenv("TZ", "/UTC")  # Malformed timezone name.
+    with pytest.raises(LookupError):
+        get_localzone()