regebro / tzlocal

A Python module that tries to figure out what your local timezone is
MIT License
185 stars 58 forks source link

Upgrade to 4.0 causes ZoneInfoNotFoundError #121

Closed Andrioden closed 2 years ago

Andrioden commented 2 years ago

Summary

Our project uses dateparses, which again depends on tzlocal. After auto-upgrading our packages, and therunder tzlocal from 3.0 to 4.0 our build started failing. Its either that or related to your dependency pytz-deprecation-shim.

When i pinned our build to tzlocal = 3.0, the problem went away.

Details

Other things:

Our code

def any_to_datetime(s: str) -> datetime:
    # Hack around that '9999-12-31T23:59:59' crashes when parsed
    if s[:4] == "9999":
        d = datetime(9999, 12, 31, 23, 59, 59)
        return ensure_timezoned(d)
    if parsed_datetime := dateparser.parse(s):  # <- Line 18, raises tzlocal.utils.ZoneInfoNotFoundError
        return ensure_timezoned(parsed_datetime)

    raise Exception(f"Could not parse {s=}")

Exception

../api_import/utils/date.py:18: in any_to_datetime
    if parsed_datetime := dateparser.parse(s):
/usr/local/lib/python3.9/site-packages/dateparser/conf.py:92: in wrapper
    return f(*args, **kwargs)
/usr/local/lib/python3.9/site-packages/dateparser/__init__.py:61: in parse
    data = parser.get_date_data(date_string, date_formats)
/usr/local/lib/python3.9/site-packages/dateparser/date.py:428: in get_date_data
    parsed_date = _DateLocaleParser.parse(
/usr/local/lib/python3.9/site-packages/dateparser/date.py:178: in parse
    return instance._parse()
/usr/local/lib/python3.9/site-packages/dateparser/date.py:182: in _parse
    date_data = self._parsers[parser_name]()
/usr/local/lib/python3.9/site-packages/dateparser/date.py:196: in _try_freshness_parser
    return freshness_date_parser.get_date_data(self._get_translated_date(), self._settings)
/usr/local/lib/python3.9/site-packages/dateparser/freshness_date_parser.py:156: in get_date_data
    date, period = self.parse(date_string, settings)
/usr/local/lib/python3.9/site-packages/dateparser/freshness_date_parser.py:93: in parse
    now = datetime.now(self.get_local_tz())
/usr/local/lib/python3.9/site-packages/dateparser/freshness_date_parser.py:41: in get_local_tz
    return get_localzone()
/usr/local/lib/python3.9/site-packages/tzlocal/unix.py:205: in get_localzone
    _cache_tz = _get_localzone()
/usr/local/lib/python3.9/site-packages/tzlocal/unix.py:167: in _get_localzone
    tzname = _get_localzone_name(_root)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
_root = '/'
    def _get_localzone_name(_root="/"):
        """Tries to find the local timezone configuration.

        This method finds the timezone name, if it can, or it returns None.

        The parameter _root makes the function look for files like /etc/localtime
        beneath the _root directory. This is primarily used by the tests.
        In normal usage you call the function without parameters."""

        # First try the ENV setting.
        tzenv = utils._tz_name_from_env()
        if tzenv:
            return tzenv

        # Are we under Termux on Android?
        if os.path.exists(os.path.join(_root, "system/bin/getprop")):
            import subprocess

            androidtz = (
                subprocess.check_output(["getprop", "persist.sys.timezone"])
                .strip()
                .decode()
            )
            return androidtz

        # Now look for distribution specific configuration files
        # that contain the timezone name.

        # Stick all of them in a dict, to compare later.
        found_configs = {}

        for configfile in ("etc/timezone", "var/db/zoneinfo"):
            tzpath = os.path.join(_root, configfile)
            try:
                with open(tzpath, "rt") as tzfile:
                    data = tzfile.read()

                    etctz = data.strip()
                    if not etctz:
                        # Empty file, skip
                        continue
                    for etctz in data.splitlines():
                        # Get rid of host definitions and comments:
                        if " " in etctz:
                            etctz, dummy = etctz.split(" ", 1)
                        if "#" in etctz:
                            etctz, dummy = etctz.split("#", 1)
                        if not etctz:
                            continue

                        found_configs[tzpath] = etctz.replace(" ", "_")

            except (IOError, UnicodeDecodeError):
                # File doesn't exist or is a directory, or it's a binary file.
                continue

        # CentOS has a ZONE setting in /etc/sysconfig/clock,
        # OpenSUSE has a TIMEZONE setting in /etc/sysconfig/clock and
        # Gentoo has a TIMEZONE setting in /etc/conf.d/clock
        # We look through these files for a timezone:

        zone_re = re.compile(r"\s*ZONE\s*=\s*\"")
        timezone_re = re.compile(r"\s*TIMEZONE\s*=\s*\"")
        end_re = re.compile('"')

        for filename in ("etc/sysconfig/clock", "etc/conf.d/clock"):
            tzpath = os.path.join(_root, filename)
            try:
                with open(tzpath, "rt") as tzfile:
                    data = tzfile.readlines()

                for line in data:
                    # Look for the ZONE= setting.
                    match = zone_re.match(line)
                    if match is None:
                        # No ZONE= setting. Look for the TIMEZONE= setting.
                        match = timezone_re.match(line)
                    if match is not None:
                        # Some setting existed
                        line = line[match.end():]
                        etctz = line[: end_re.search(line).start()]

                        # We found a timezone
                        found_configs[tzpath] = etctz.replace(" ", "_")

            except (IOError, UnicodeDecodeError):
                # UnicodeDecode handles when clock is symlink to /etc/localtime
                continue

        # systemd distributions use symlinks that include the zone name,
        # see manpage of localtime(5) and timedatectl(1)
        tzpath = os.path.join(_root, "etc/localtime")
        if os.path.exists(tzpath) and os.path.islink(tzpath):
            etctz = tzpath = os.path.realpath(tzpath)
            start = etctz.find("/") + 1
            while start != 0:
                etctz = etctz[start:]
                try:
                    pds.timezone(etctz)
                    found_configs[tzpath] = etctz.replace(" ", "_")
                except pds.UnknownTimeZoneError:
                    pass
                start = etctz.find("/") + 1

        if len(found_configs) > 0:
            # We found some explicit config of some sort!
            if len(found_configs) > 1:
                # Uh-oh, multiple configs. See if they match:
                unique_tzs = set()
                for tzname in found_configs.values():
                    # Get rid of any Etc's
                    tzname = tzname.replace("Etc/", "")
                    # In practice these are the same:
                    tzname = tzname.replace("UTC", "GMT")
                    # Let's handle these synonyms as well. Many systems have tons
                    # of synonyms, including country names and "Zulu" and other
                    # nonsense. Those will be seen as different ones. Let's stick
                    # to the official zoneinfo Continent/City names.
                    if tzname in ["GMT0", "GMT+0", "GMT-0"]:
                        tzname = "GMT"
                    unique_tzs.add(tzname)

                if len(unique_tzs) != 1:
                    message = "Multiple conflicting time zone configurations found:\n"
                    for key, value in found_configs.items():
                        message += f"{key}: {value}\n"
                    message += "Fix the configuration, or set the time zone in a TZ environment variable.\n"
>                   raise utils.ZoneInfoNotFoundError(message)
E                   tzlocal.utils.ZoneInfoNotFoundError: 'Multiple conflicting time zone configurations found:\n/etc/timezone: Etc/UTC\n/usr/share/zoneinfo/UCT: UCT\nFix the configuration, or set the time zone in a TZ environment variable.\n'
/usr/local/lib/python3.9/site-packages/tzlocal/unix.py:146: ZoneInfoNotFoundError
regebro commented 2 years ago

This is because you have two configurations that sort of conflict (but only sort of), you both have an /etc/localtime that is a symlink to /usr/share/zoneinfo/UCT, but you also have an /etc/timezone with a configuration, that says "UTC".

Now, of course, UCT and UTC is the same, but I forgot about UCT, and the code I have looking for synonyms isn't including that one. I'll fix that. Also /usr/share/zoneinfo/UCT is usually a link to /usr/share/zoneinfo/Etc/UTC, which also would have avoided this specific issue, but apparently this isn't the case on your system.

Andrioden commented 2 years ago

Hmm, I dont complete overview of our projects code, env and build atm, but I want to reply quickly: If so I am going to guess this conflict lies in the public docker image python:3.9.6-buster which makes me think tzlocal is better off solving it, which it sounds like you are!

Andrioden commented 2 years ago

I am ready to upgrade our reference, and test this if you push any updates tonight.

regebro commented 2 years ago

I released a 4.0.1b1 with this fix, see if that fixes it.

Andrioden commented 2 years ago

Works!

Any chance you release as well? So I can remove the pinned dependency. : >

regebro commented 2 years ago

I'll wait a bit to see if anything else pops up.

wlad commented 2 years ago

I started to see my CI pipelines failing. Luckily Google brought me here (how cool is that :laughing: ). A library I'm using (RESTInstance) is depending on tzlocal

16:12:26.026 | FAIL | ZoneInfoNotFoundError: 'Multiple conflicting time zone configurations found:
\n/etc/timezone: Etc/UTC\n/usr/share/zoneinfo/UCT: UCT\nFix the configuration, 
or set the time zone in a TZ environment variable.\n' |

Traceback (most recent call last):
  File "/home/circleci/.local/lib/python3.8/site-packages/REST/keywords.py", line 394, in get
    return self._request(endpoint, request, validate)["response"]
  File "/home/circleci/.local/lib/python3.8/site-packages/REST/keywords.py", line 1358, in _request
    get_localzone()
  File "/home/circleci/.local/lib/python3.8/site-packages/tzlocal/unix.py", line 205, in get_localzone
    _cache_tz = _get_localzone()
  File "/home/circleci/.local/lib/python3.8/site-packages/tzlocal/unix.py", line 167, in _get_localzone
    tzname = _get_localzone_name(_root)
  File "/home/circleci/.local/lib/python3.8/site-packages/tzlocal/unix.py", line 146, in _get_localzone_name
    raise utils.ZoneInfoNotFoundError(message)

I'll try updating to tzlocal 4.0.1b1

wlad commented 2 years ago

tzlocal 4.0.1b1 fixed my issue.

regebro commented 2 years ago

Cool. I had no idea UCT was such a popular spelling. :-)

regebro commented 2 years ago

OK, I released 4.0.1.

regebro commented 2 years ago

@Andrioden & @wlad I think what you had was actually the same issue as this: https://github.com/regebro/tzlocal/issues/122

Turns out plenty of Linux distributions does not link aliases like UCT to the correct file: UTC, instead they link them backwards. I do not know why.

I released a 4.1b1 that I think will fix this, it would be cool if one of you could test it.