diff --git a/lib/tzlocal/CHANGES.txt b/lib/tzlocal/CHANGES.txt index 057855a2..98798fe1 100644 --- a/lib/tzlocal/CHANGES.txt +++ b/lib/tzlocal/CHANGES.txt @@ -1,6 +1,47 @@ Changes ======= +2.0.0 (2019-07-23) +------------------ + +- No differences since 2.0.0b3 + +Major differences since 1.5.1 +............................. + +- When no time zone configuration can be find, tzlocal now return UTC. + This is a major difference from 1.x, where an exception would be raised. + This change is because Docker images often have no configuration at all, + and the unix utilities will then default to UTC, so we follow that. + +- If tzlocal on Unix finds a timezone name in a /etc config file, then + tzlocal now verifies that the timezone it fouds has the same offset as + the local computer is configured with. If it doesn't, something is + configured incorrectly. (Victor Torres, regebro) + +- Get timezone via Termux `getprop` wrapper on Android. It's not officially + supported because we can't test it, but at least we make an effort. + (Jean Jordaan) + +Minor differences and bug fixes +............................... + +- Skip comment lines when parsing /etc/timezone. (Edward Betts) + +- Don't load timezone from current directory. (Gabriel Corona) + +- Now verifies that the config files actually contain something before + reading them. (Zackary Welch, regebro) + +- Got rid of a BytesWarning (Mickaƫl Schoentgen) + +- Now handles if config file paths exists, but are directories. + +- Moved tests out from distributions + +- Support wheels + + 1.5.1 (2017-12-01) ------------------ @@ -38,9 +79,6 @@ Changes ------------------ - Ensure closing of subprocess on OS X (ayalash) -DOING: Implementing feedback on the unsubscribe button -DOING: Investigating remaining issues with DOCX export -BLOCKERS: None - Removed unused imports (jwilk) diff --git a/lib/tzlocal/tests.py b/lib/tzlocal/tests.py deleted file mode 100644 index ace7ffe6..00000000 --- a/lib/tzlocal/tests.py +++ /dev/null @@ -1,134 +0,0 @@ -import mock -import os -import pytz -import sys -import tzlocal.unix -import unittest - -from datetime import datetime - - -class TzLocalTests(unittest.TestCase): - def setUp(self): - if 'TZ' in os.environ: - del os.environ['TZ'] - - self.path = os.path.split(__file__)[0] - - def test_env(self): - tz_harare = tzlocal.unix._tz_from_env(':Africa/Harare') - self.assertEqual(tz_harare.zone, 'Africa/Harare') - - # Some Unices allow this as well, so we must allow it: - tz_harare = tzlocal.unix._tz_from_env('Africa/Harare') - self.assertEqual(tz_harare.zone, 'Africa/Harare') - - tz_local = tzlocal.unix._tz_from_env(':' + os.path.join(self.path, 'test_data', 'Harare')) - self.assertEqual(tz_local.zone, 'local') - # Make sure the local timezone is the same as the Harare one above. - # We test this with a past date, so that we don't run into future changes - # of the Harare timezone. - dt = datetime(2012, 1, 1, 5) - self.assertEqual(tz_harare.localize(dt), tz_local.localize(dt)) - - # Non-zoneinfo timezones are not supported in the TZ environment. - self.assertRaises(pytz.UnknownTimeZoneError, tzlocal.unix._tz_from_env, 'GMT+03:00') - - # Test the _try function - os.environ['TZ'] = 'Africa/Harare' - tz_harare = tzlocal.unix._try_tz_from_env() - self.assertEqual(tz_harare.zone, 'Africa/Harare') - # With a zone that doesn't exist - os.environ['TZ'] = 'Just Nonsense' - tz_harare = tzlocal.unix._try_tz_from_env() - self.assertIsNone(tz_harare) - - - def test_timezone(self): - # Most versions of Ubuntu - - tz = tzlocal.unix._get_localzone(_root=os.path.join(self.path, 'test_data', 'timezone')) - self.assertEqual(tz.zone, 'Africa/Harare') - - def test_zone_setting(self): - # A ZONE setting in /etc/sysconfig/clock, f ex CentOS - - tz = tzlocal.unix._get_localzone(_root=os.path.join(self.path, 'test_data', 'zone_setting')) - self.assertEqual(tz.zone, 'Africa/Harare') - - def test_timezone_setting(self): - # A ZONE setting in /etc/conf.d/clock, f ex Gentoo - - tz = tzlocal.unix._get_localzone(_root=os.path.join(self.path, 'test_data', 'timezone_setting')) - self.assertEqual(tz.zone, 'Africa/Harare') - - def test_symlink_localtime(self): - # A ZONE setting in the target path of a symbolic linked localtime, f ex systemd distributions - - tz = tzlocal.unix._get_localzone(_root=os.path.join(self.path, 'test_data', 'symlink_localtime')) - self.assertEqual(tz.zone, 'Africa/Harare') - - def test_vardbzoneinfo_setting(self): - # A ZONE setting in /etc/conf.d/clock, f ex Gentoo - - tz = tzlocal.unix._get_localzone(_root=os.path.join(self.path, 'test_data', 'vardbzoneinfo')) - self.assertEqual(tz.zone, 'Africa/Harare') - - def test_only_localtime(self): - tz = tzlocal.unix._get_localzone(_root=os.path.join(self.path, 'test_data', 'localtime')) - self.assertEqual(tz.zone, 'local') - dt = datetime(2012, 1, 1, 5) - self.assertEqual(pytz.timezone('Africa/Harare').localize(dt), tz.localize(dt)) - - def test_get_reload(self): - os.environ['TZ'] = 'Africa/Harare' - tz_harare = tzlocal.unix.get_localzone() - self.assertEqual(tz_harare.zone, 'Africa/Harare') - # Changing the TZ makes no difference, because it's cached - os.environ['TZ'] = 'Africa/Johannesburg' - tz_harare = tzlocal.unix.get_localzone() - self.assertEqual(tz_harare.zone, 'Africa/Harare') - # So we reload it - tz_harare = tzlocal.unix.reload_localzone() - self.assertEqual(tz_harare.zone, 'Africa/Johannesburg') - - def test_fail(self): - with self.assertRaises(pytz.exceptions.UnknownTimeZoneError): - tz = tzlocal.unix._get_localzone(_root=os.path.join(self.path, 'test_data')) - -if sys.platform == 'win32': - - import tzlocal.win32 - class TzWin32Tests(unittest.TestCase): - - def test_win32(self): - tzlocal.win32.get_localzone() - -else: - - class TzWin32Tests(unittest.TestCase): - - def test_win32_on_unix(self): - # Yes, winreg is all mocked out, but this test means we at least - # catch syntax errors, etc. - winreg = mock.MagicMock() - winreg.OpenKey = mock.MagicMock() - winreg.OpenKey.close = mock.MagicMock() - winreg.QueryInfoKey = mock.MagicMock(return_value=(1, 1)) - winreg.EnumValue = mock.MagicMock( - return_value=('TimeZoneKeyName','Belarus Standard Time')) - winreg.EnumKey = mock.Mock(return_value='Bahia Standard Time') - sys.modules['winreg'] = winreg - import tzlocal.win32 - tz = tzlocal.win32.get_localzone() - self.assertEqual(tz.zone, 'Europe/Minsk') - - tzlocal.win32.valuestodict = mock.Mock(return_value={ - 'StandardName': 'Mocked Standard Time', - 'Std': 'Mocked Standard Time', - }) - tz = tzlocal.win32.reload_localzone() - self.assertEqual(tz.zone, 'America/Bahia') - -if __name__ == '__main__': - unittest.main() diff --git a/lib/tzlocal/unix.py b/lib/tzlocal/unix.py index 9f7a706f..388273c2 100644 --- a/lib/tzlocal/unix.py +++ b/lib/tzlocal/unix.py @@ -1,15 +1,19 @@ import os -import re import pytz +import re +import warnings + +from tzlocal import utils _cache_tz = None + def _tz_from_env(tzenv): if tzenv[0] == ':': tzenv = tzenv[1:] # TZ specifies a file - if os.path.exists(tzenv): + if os.path.isabs(tzenv) and os.path.exists(tzenv): with open(tzenv, 'rb') as tzfile: return pytz.tzfile.build_tzinfo('local', tzfile) @@ -48,56 +52,85 @@ def _get_localzone(_root='/'): if tzenv: return tzenv + # Are we under Termux on Android? + if os.path.exists('/system/bin/getprop'): + import subprocess + androidtz = subprocess.check_output(['getprop', 'persist.sys.timezone']).strip().decode() + return pytz.timezone(androidtz) + # Now look for distribution specific configuration files # that contain the timezone name. for configfile in ('etc/timezone', 'var/db/zoneinfo'): tzpath = os.path.join(_root, configfile) - if os.path.exists(tzpath): + try: with open(tzpath, 'rb') as tzfile: data = tzfile.read() # Issue #3 was that /etc/timezone was a zoneinfo file. # That's a misconfiguration, but we need to handle it gracefully: - if data[:5] == 'TZif2': + if data[:5] == b'TZif2': continue etctz = data.strip().decode() - # Get rid of host definitions and comments: - if ' ' in etctz: - etctz, dummy = etctz.split(' ', 1) - if '#' in etctz: - etctz, dummy = etctz.split('#', 1) - return pytz.timezone(etctz.replace(' ', '_')) + if not etctz: + # Empty file, skip + continue + for etctz in data.decode().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 + tz = pytz.timezone(etctz.replace(' ', '_')) + if _root == '/': + # We are using a file in etc to name the timezone. + # Verify that the timezone specified there is actually used: + utils.assert_tz_offset(tz) + return tz + + except IOError: + # File doesn't exist or is a directory + 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('\s*ZONE\s*=\s*\"') - timezone_re = re.compile('\s*TIMEZONE\s*=\s*\"') + 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) - if not os.path.exists(tzpath): + 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 + tz = pytz.timezone(etctz.replace(' ', '_')) + if _root == '/': + # We are using a file in etc to name the timezone. + # Verify that the timezone specified there is actually used: + utils.assert_tz_offset(tz) + return tz + + except IOError: + # File doesn't exist or is a directory continue - 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 - return pytz.timezone(etctz.replace(' ', '_')) # systemd distributions use symlinks that include the zone name, # see manpage of localtime(5) and timedatectl(1) @@ -122,15 +155,18 @@ def _get_localzone(_root='/'): with open(tzpath, 'rb') as tzfile: return pytz.tzfile.build_tzinfo('local', tzfile) - raise pytz.UnknownTimeZoneError('Can not find any timezone configuration') + warnings.warn('Can not find any timezone configuration, defaulting to UTC.') + return pytz.utc def get_localzone(): """Get the computers configured local timezone, if any.""" global _cache_tz if _cache_tz is None: _cache_tz = _get_localzone() + return _cache_tz + def reload_localzone(): """Reload the cached localzone. You need to call this if the timezone has changed.""" global _cache_tz diff --git a/lib/tzlocal/utils.py b/lib/tzlocal/utils.py new file mode 100644 index 00000000..bd9d663e --- /dev/null +++ b/lib/tzlocal/utils.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +import datetime + + +def get_system_offset(): + """Get system's timezone offset using built-in library time. + + For the Timezone constants (altzone, daylight, timezone, and tzname), the + value is determined by the timezone rules in effect at module load time or + the last time tzset() is called and may be incorrect for times in the past. + + To keep compatibility with Windows, we're always importing time module here. + """ + import time + if time.daylight and time.localtime().tm_isdst > 0: + return -time.altzone + else: + return -time.timezone + + +def get_tz_offset(tz): + """Get timezone's offset using built-in function datetime.utcoffset().""" + return int(datetime.datetime.now(tz).utcoffset().total_seconds()) + + +def assert_tz_offset(tz): + """Assert that system's timezone offset equals to the timezone offset found. + + If they don't match, we probably have a misconfiguration, for example, an + incorrect timezone set in /etc/timezone file in systemd distributions.""" + tz_offset = get_tz_offset(tz) + system_offset = get_system_offset() + if tz_offset != system_offset: + msg = ('Timezone offset does not match system offset: {0} != {1}. ' + 'Please, check your config files.').format( + tz_offset, system_offset + ) + raise ValueError(msg) diff --git a/lib/tzlocal/win32.py b/lib/tzlocal/win32.py index 86dd99fc..fcc42a23 100644 --- a/lib/tzlocal/win32.py +++ b/lib/tzlocal/win32.py @@ -3,11 +3,14 @@ try: except ImportError: import winreg -from tzlocal.windows_tz import win_tz import pytz +from tzlocal.windows_tz import win_tz +from tzlocal import utils + _cache_tz = None + def valuestodict(key): """Convert a registry key's values to a dictionary.""" dict = {} @@ -17,6 +20,7 @@ def valuestodict(key): dict[data[0]] = data[1] return dict + def get_localzone_name(): # Windows is special. It has unique time zone names (in several # meanings of the word) available, but unfortunately, they can be @@ -81,15 +85,20 @@ def get_localzone_name(): return timezone + def get_localzone(): """Returns the zoneinfo-based tzinfo object that matches the Windows-configured timezone.""" global _cache_tz if _cache_tz is None: _cache_tz = pytz.timezone(get_localzone_name()) + + utils.assert_tz_offset(_cache_tz) return _cache_tz + def reload_localzone(): """Reload the cached localzone. You need to call this if the timezone has changed.""" global _cache_tz _cache_tz = pytz.timezone(get_localzone_name()) + utils.assert_tz_offset(_cache_tz) return _cache_tz diff --git a/lib/tzlocal/windows_tz.py b/lib/tzlocal/windows_tz.py index 123980b8..3d691c85 100644 --- a/lib/tzlocal/windows_tz.py +++ b/lib/tzlocal/windows_tz.py @@ -99,10 +99,12 @@ win_tz = {'AUS Central Standard Time': 'Australia/Darwin', 'Saint Pierre Standard Time': 'America/Miquelon', 'Sakhalin Standard Time': 'Asia/Sakhalin', 'Samoa Standard Time': 'Pacific/Apia', + 'Sao Tome Standard Time': 'Africa/Sao_Tome', 'Saratov Standard Time': 'Europe/Saratov', 'Singapore Standard Time': 'Asia/Singapore', 'South Africa Standard Time': 'Africa/Johannesburg', 'Sri Lanka Standard Time': 'Asia/Colombo', + 'Sudan Standard Time': 'Africa/Khartoum', 'Syria Standard Time': 'Asia/Damascus', 'Taipei Standard Time': 'Asia/Taipei', 'Tasmania Standard Time': 'Australia/Hobart', @@ -164,7 +166,7 @@ tz_win = {'Africa/Abidjan': 'Greenwich Standard Time', 'Africa/Johannesburg': 'South Africa Standard Time', 'Africa/Juba': 'E. Africa Standard Time', 'Africa/Kampala': 'E. Africa Standard Time', - 'Africa/Khartoum': 'E. Africa Standard Time', + 'Africa/Khartoum': 'Sudan Standard Time', 'Africa/Kigali': 'South Africa Standard Time', 'Africa/Kinshasa': 'W. Central Africa Standard Time', 'Africa/Lagos': 'W. Central Africa Standard Time', @@ -185,7 +187,7 @@ tz_win = {'Africa/Abidjan': 'Greenwich Standard Time', 'Africa/Nouakchott': 'Greenwich Standard Time', 'Africa/Ouagadougou': 'Greenwich Standard Time', 'Africa/Porto-Novo': 'W. Central Africa Standard Time', - 'Africa/Sao_Tome': 'Greenwich Standard Time', + 'Africa/Sao_Tome': 'Sao Tome Standard Time', 'Africa/Timbuktu': 'Greenwich Standard Time', 'Africa/Tripoli': 'Libya Standard Time', 'Africa/Tunis': 'W. Central Africa Standard Time', @@ -285,7 +287,7 @@ tz_win = {'Africa/Abidjan': 'Greenwich Standard Time', 'America/Mendoza': 'Argentina Standard Time', 'America/Menominee': 'Central Standard Time', 'America/Merida': 'Central Standard Time (Mexico)', - 'America/Metlakatla': 'Alaskan Standard Time', + 'America/Metlakatla': 'Pacific Standard Time', 'America/Mexico_City': 'Central Standard Time (Mexico)', 'America/Miquelon': 'Saint Pierre Standard Time', 'America/Moncton': 'Atlantic Standard Time', @@ -345,7 +347,7 @@ tz_win = {'Africa/Abidjan': 'Greenwich Standard Time', 'America/Winnipeg': 'Central Standard Time', 'America/Yakutat': 'Alaskan Standard Time', 'America/Yellowknife': 'Mountain Standard Time', - 'Antarctica/Casey': 'Central Pacific Standard Time', + 'Antarctica/Casey': 'W. Australia Standard Time', 'Antarctica/Davis': 'SE Asia Standard Time', 'Antarctica/DumontDUrville': 'West Pacific Standard Time', 'Antarctica/Macquarie': 'Central Pacific Standard Time', @@ -386,7 +388,7 @@ tz_win = {'Africa/Abidjan': 'Greenwich Standard Time', 'Asia/Dili': 'Tokyo Standard Time', 'Asia/Dubai': 'Arabian Standard Time', 'Asia/Dushanbe': 'West Asia Standard Time', - 'Asia/Famagusta': 'Turkey Standard Time', + 'Asia/Famagusta': 'GTB Standard Time', 'Asia/Gaza': 'West Bank Standard Time', 'Asia/Harbin': 'China Standard Time', 'Asia/Hebron': 'West Bank Standard Time', @@ -421,7 +423,8 @@ tz_win = {'Africa/Abidjan': 'Greenwich Standard Time', 'Asia/Pontianak': 'SE Asia Standard Time', 'Asia/Pyongyang': 'North Korea Standard Time', 'Asia/Qatar': 'Arab Standard Time', - 'Asia/Qyzylorda': 'Central Asia Standard Time', + 'Asia/Qostanay': 'Central Asia Standard Time', + 'Asia/Qyzylorda': 'West Asia Standard Time', 'Asia/Rangoon': 'Myanmar Standard Time', 'Asia/Riyadh': 'Arab Standard Time', 'Asia/Saigon': 'SE Asia Standard Time', @@ -530,6 +533,7 @@ tz_win = {'Africa/Abidjan': 'Greenwich Standard Time', 'Etc/GMT-7': 'SE Asia Standard Time', 'Etc/GMT-8': 'Singapore Standard Time', 'Etc/GMT-9': 'Tokyo Standard Time', + 'Etc/UCT': 'UTC', 'Etc/UTC': 'UTC', 'Europe/Amsterdam': 'W. Europe Standard Time', 'Europe/Andorra': 'W. Europe Standard Time', @@ -673,6 +677,7 @@ tz_win = {'Africa/Abidjan': 'Greenwich Standard Time', 'ROK': 'Korea Standard Time', 'Singapore': 'Singapore Standard Time', 'Turkey': 'Turkey Standard Time', + 'UCT': 'UTC', 'US/Alaska': 'Alaskan Standard Time', 'US/Aleutian': 'Aleutian Standard Time', 'US/Arizona': 'US Mountain Standard Time',