diff --git a/.gitignore b/.gitignore index 0dccebb..e456c8f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,10 @@ build dist *.egg* *.py[co] +.tox +.coverage docs/_* datetime_tz/win32tz_map.py -py2 -py3 +release_venv venv venv3 diff --git a/.travis.yml b/.travis.yml index b1c557a..5f83dcd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,17 @@ language: python python: - - 2.6 - 2.7 - - 3.3 - - 3.4 + - 3.6 + - 3.7 + - 3.8 - nightly +dist: xenial matrix: allow_failures: - python: nightly + sudo: false cache: @@ -19,15 +21,12 @@ cache: install: - pip install --upgrade pip - - pip install pyopenssl ndg-httpsclient pyasn1 - - pip install -r requirements.txt - - python setup.py install - - python run_tests_all_pytz.py --download-only + - pip install --upgrade tox script: - - python run_tests_all_pytz.py + - ./test_multiple_pytz_versions.sh + - tox -e py after_success: - pip install coveralls - - coverage run --source=datetime_tz setup.py test - - coveralls + - coveralls # Coverage generated by tox run diff --git a/README.md b/README.md index 6fd46ad..58b1132 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ This module depends on: For development: * PyLint - Needed for checking for link. - * Genshi - Needed for building windows mapping file. + * defusedxml - Needed for building windows mapping file. [![Build Status](https://travis-ci.org/mithro/python-datetime-tz.png?branch=master)](https://travis-ci.org/mithro/python-datetime-tz) [![Coverage Status](https://coveralls.io/repos/mithro/python-datetime-tz/badge.png)](https://coveralls.io/r/mithro/python-datetime-tz) -[![PyPi Version](https://pypip.in/v/python-datetime-tz/badge.png)](https://crate.io/packages/python-coveralls/) -[![PyPi Downloads](https://pypip.in/d/python-datetime-tz/badge.png)](https://crate.io/packages/python-coveralls/) +[![PyPi Version](https://img.shields.io/pypi/v/python-datetime-tz.svg)](https://pypi.org/project/python-datetime-tz/) +[![PyPi Downloads](https://img.shields.io/pypi/dm/python-datetime-tz.svg)](https://pypi.org/project/python-datetime-tz/) diff --git a/datetime_tz/__init__.py b/datetime_tz/__init__.py index a4387ce..e939a56 100644 --- a/datetime_tz/__init__.py +++ b/datetime_tz/__init__.py @@ -217,7 +217,7 @@ def detect_timezone(): if tz is not None: return tz - # Next we try and use a similiar method to what PHP does. + # Next we try and use a similar method to what PHP does. # We first try to search on time.tzname, time.timezone, time.daylight to # match a pytz zone. warnings.warn("Had to fall back to worst detection method (the 'PHP' " @@ -691,7 +691,7 @@ def smartparse(cls, toparse, tzinfo=None): if isinstance(dt.tzinfo, pytz_abbr.tzabbr): abbr = dt.tzinfo dt = dt.replace(tzinfo=None) - dt = cls(dt, abbr.zone, is_dst=abbr.dst) + dt = cls(dt, abbr.zone, is_dst=abbr.is_dst) dt = cls(dt) @@ -866,7 +866,7 @@ def _wrap_method(name): # Have to give the second argument as method has no __module__ option. @functools.wraps(method, ("__name__", "__doc__"), ()) def wrapper(self, *args, **kw): - r = method(self, *args, **kw) + r = method(self.asdatetime(naive=False), *args, **kw) if isinstance(r, datetime.datetime) and not isinstance(r, type(self)): r = type(self)(r) diff --git a/datetime_tz/detect_windows.py b/datetime_tz/detect_windows.py index 3f708aa..b2fe6db 100644 --- a/datetime_tz/detect_windows.py +++ b/datetime_tz/detect_windows.py @@ -116,7 +116,12 @@ def _detect_timezone_windows(): win32timezone.TimeZoneInfo._get_indexed_time_zone_keys("Std")) win32tz_key_name = win32timezone_to_en.get(win32tz_name, win32tz_name) - territory = locale.getdefaultlocale()[0].split("_", 1)[1] + language_code = locale.getdefaultlocale()[0] + if language_code is None: + # Failure getting the system locale, result may be wrong! + territory = None + else: + territory = language_code.split("_", 1)[1] olson_name = win32tz_map.win32timezones.get((win32tz_key_name, territory), win32tz_map.win32timezones.get(win32tz_key_name, None)) if not olson_name: return None diff --git a/datetime_tz/pytz_abbr.py b/datetime_tz/pytz_abbr.py index c97e460..3470324 100644 --- a/datetime_tz/pytz_abbr.py +++ b/datetime_tz/pytz_abbr.py @@ -47,19 +47,63 @@ import pytz import pytz.tzfile +try: + basestring +except NameError: + # pylint: disable=redefined-builtin + basestring = str + class tzabbr(datetime.tzinfo): """A timezone abbreviation. - *WARNING*: This is not a tzinfo implementation! Trying to use this as tzinfo - object will result in failure. We inherit from datetime.tzinfo so we can get - through the dateutil checks. + *WARNING*: This is a proxy object for an underlying `pytz` object only + intended to allow dateutil to probe the localized datetime for information. + datetime_tz's parsing logic will replace it with the relevant pytz zone + before returning to the end user. """ - pass + + def __init__(self, abbr, name, region, zone, dst): + super(tzabbr, self).__init__() + + self.abbr = abbr + self.name = name + self.region = region + + if isinstance(zone, basestring): + zone = pytz.timezone(zone) + + self.zone = zone + self.is_dst = dst + + def _get_localized(self, dt): + # To make this a fully-functioning pass-through to the underlying pytz + # zone, we would want to use `fold` to set `is_dst`, but since this is + # only a temporary proxy for the zone, we will fix the DST status + return self.zone.localize(dt.replace(tzinfo=None), is_dst=self.is_dst) + + def tzname(self, dt): + return self._get_localized(dt).tzname() + + def utcoffset(self, dt): + return self._get_localized(dt).utcoffset() # pragma: no cover + + def dst(self, dt): + return self._get_localized(dt).dst() # pragma: no cover # A "marker" tzinfo object which is used to signify an unknown timezone. -unknown = datetime.tzinfo() +class _UnknownZone(datetime.tzinfo): + def tzname(self, dt): + return "UNK" + + def dst(self, dt): + return None + + def utcoffset(self, dt): + return datetime.timedelta(0) # pragma: no cover + +unknown = _UnknownZone() regions = {"all": {}, "military": {}} @@ -74,12 +118,7 @@ def tzabbr_register(abbr, name, region, zone, dst): If another abbreviation with the same name has already been registered it new abbreviation will only be registered in region specific dictionary. """ - newabbr = tzabbr() - newabbr.abbr = abbr - newabbr.name = name - newabbr.region = region - newabbr.zone = zone - newabbr.dst = dst + newabbr = tzabbr(abbr, name, region, zone, dst) if abbr not in all: all[abbr] = newabbr diff --git a/datetime_tz/update_win32tz_map.py b/datetime_tz/update_win32tz_map.py index ad479b5..371ec62 100644 --- a/datetime_tz/update_win32tz_map.py +++ b/datetime_tz/update_win32tz_map.py @@ -23,15 +23,7 @@ import hashlib import os -try: - import urllib.request as urllib2 -except ImportError: - import urllib2 - -try: - from io import StringIO -except ImportError: - from StringIO import StringIO +from defusedxml import ElementTree try: # pylint: disable=redefined-builtin @@ -39,14 +31,17 @@ except ImportError: pass -import genshi.input try: from datetime_tz import win32tz_map except ImportError: win32tz_map = None +try: + import urllib.request as urllib2 +except ImportError: + import urllib2 -_CLDR_WINZONES_URL = "http://www.unicode.org/repos/cldr/trunk/common/supplemental/windowsZones.xml" # pylint: disable=line-too-long +_CLDR_WINZONES_URL = "https://github.com/unicode-org/cldr/raw/master/common/supplemental/windowsZones.xml" # pylint: disable=line-too-long def download_cldr_win32tz_map_xml(): @@ -61,35 +56,27 @@ def create_win32tz_map(windows_zones_xml): windows_zones_xml: The CLDR XML mapping. Yields: - (win32_name, olson_name, comment) + For "default" territory (001): (win32_name, olson_name) + Where territory is set: ((win32_name, territory), olson_name) """ - coming_comment = None - win32_name = None - territory = None - parser = genshi.input.XMLParser(StringIO(windows_zones_xml)) - map_zones = {} - zone_comments = {} - - for kind, data, _ in parser: - if kind == genshi.core.START and str(data[0]) == "mapZone": - attrs = data[1] - win32_name, territory, olson_name = ( - attrs.get("other"), attrs.get("territory"), attrs.get("type").split(" ")[0]) - - map_zones[(win32_name, territory)] = olson_name - elif kind == genshi.core.END and str(data) == "mapZone" and win32_name: - if coming_comment: - zone_comments[(win32_name, territory)] = coming_comment - coming_comment = None - win32_name = None - elif kind == genshi.core.COMMENT: - coming_comment = data.strip() - elif kind in (genshi.core.START, genshi.core.END, genshi.core.COMMENT): - coming_comment = None - - for win32_name, territory in sorted(map_zones): - yield (win32_name, territory, map_zones[(win32_name, territory)], - zone_comments.get((win32_name, territory), None)) + parser = ElementTree.fromstring(windows_zones_xml) + map_timezones = parser.find("windowsZones").find("mapTimezones") + + for child in map_timezones: + if child.tag == "mapZone": + win32_name = str(child.attrib.get("other", "")) + territory = str(child.attrib.get("territory", "")) + # Some `type` parameters are have multiple values, separated by spaces + # eg: "America/Denver America/Boise" + olson_name = str(child.attrib.get("type", "")).split(" ")[0] + + if not win32_name or not olson_name: + continue + + if territory == "001" or not territory: + yield (win32_name, olson_name) + else: + yield ((win32_name, territory), olson_name) def update_stored_win32tz_map(): @@ -97,9 +84,6 @@ def update_stored_win32tz_map(): windows_zones_xml = download_cldr_win32tz_map_xml() source_hash = hashlib.md5(windows_zones_xml).hexdigest() - if hasattr(windows_zones_xml, "decode"): - windows_zones_xml = windows_zones_xml.decode("utf-8") - map_zones = create_win32tz_map(windows_zones_xml) map_dir = os.path.dirname(os.path.abspath(__file__)) map_filename = os.path.join(map_dir, "win32tz_map.py") @@ -110,24 +94,16 @@ def update_stored_win32tz_map(): return False map_file = open(map_filename, "w") + map_file.write(( + "'''Map between Windows and Olson timezones taken from {0}\n" + "Generated automatically by {1}'''\n" + "source_hash = {2!r} # md5 sum of xml source data\n" + "win32timezones = {{\n" + ).format(_CLDR_WINZONES_URL, __file__, source_hash)) + + for z in map_zones: + map_file.write(" %r: %r,\n" % z) - comment = "Map between Windows and Olson timezones taken from %s" % ( - _CLDR_WINZONES_URL,) - comment2 = "Generated automatically from datetime_tz.py" - map_file.write("'''%s\n" % comment) - map_file.write("%s'''\n" % comment2) - - map_file.write("source_hash = '%s' # md5 sum of xml source data\n" % ( - source_hash)) - - map_file.write("win32timezones = {\n") - for win32_name, territory, olson_name, comment in map_zones: - if territory == '001': - map_file.write(" %r: %r, # %s\n" % ( - str(win32_name), str(olson_name), comment or "")) - else: - map_file.write(" %r: %r, # %s\n" % ( - (str(win32_name), str(territory)), str(olson_name), comment or "")) map_file.write("}\n") map_file.close() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cc8a7b5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[build-system] +requires = [ + "setuptools>=41.0", + "wheel", + "defusedxml", + "python-dateutil>=2.0", + "pytz >= 2011g", +] +build-backend = "setuptools.build_meta" diff --git a/release.sh b/release.sh index 29c71b7..ff0c182 100755 --- a/release.sh +++ b/release.sh @@ -1,33 +1,15 @@ -#!/bin/bash - -rm -rf py2 -virtualenv-2.7 py2 +#!/bin/bash -e +rm -rf release_env +rm -rf dist/ +python3 -m virtualenv release_env +source release_env/bin/activate ( - . py2/bin/activate - pip install --upgrade pip - pip install --upgrade setuptools - pip install -r requirements.txt - pip install wheel - python setup.py clean - python setup.py sdist - python setup.py bdist_wheel - python setup.py sdist upload - python setup.py bdist_wheel upload - #PS1="py2setup # " bash --norc -) +pip install -U pip +pip install -U twine +pip install -U pep517 +# Run the build +python -m pep517.build . -b -s -rm -rf py3 -virtualenv-3.4 py3 -( - . py3/bin/activate - pip install --upgrade pip - pip install --upgrade setuptools - pip install wheel - pip install -r requirements.txt - python setup.py clean - python setup.py sdist - python setup.py bdist_wheel - python setup.py sdist upload - python setup.py bdist_wheel upload - #PS1="py3setup # " bash --norc +# Upload the files with twine +twine upload dist/* ) diff --git a/requirements.txt b/requirements.txt index cb8df59..63e0490 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -Genshi +defusedxml python-dateutil -pytz>=2007g +pytz>=2011g diff --git a/run_tests_all_pytz.py b/run_tests_all_pytz.py deleted file mode 100755 index 6b26ac3..0000000 --- a/run_tests_all_pytz.py +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# vim: set ts=2 sw=2 et sts=2 ai: -# -# Copyright 2015 Google Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -# pylint doesn't understand the from __future__ import which must be the first -# line of the file. -# pylint: disable=missing-docstring,pointless-string-statement -# pylint: disable=g-statement-before-imports - -from __future__ import print_function - -"""Run the tests against every pytz version available.""" - -__author__ = "tansell@google.com (Tim Ansell)" - -import glob -import os -import os.path -import subprocess -import sys - -try: - # pylint: disable=g-import-not-at-top - from urllib.request import urlopen -except ImportError: - # pylint: disable=g-import-not-at-top - from urllib import urlopen - -try: - # pylint: disable=g-import-not-at-top - import simplejson -except ImportError: - # pylint: disable=g-import-not-at-top - import json as simplejson - - -if not hasattr(sys, "real_prefix"): - print("""\ -This script should only be run inside a virtualenv because it is going to -modify the install version of things. -""") - sys.exit(1) - -CACHE_DIR = os.path.expanduser(os.path.join("~", ".cache", "pypi")) -if not os.path.exists(CACHE_DIR): - os.makedirs(CACHE_DIR) -print("Using a download cache directory of", repr(CACHE_DIR)) - -# Get the pytz versions from pypi -pypi_data_raw = urlopen( - "https://pypi.python.org/pypi/pytz/json").read().decode("utf-8") -pypi_data = simplejson.loads(pypi_data_raw) - - -# Hack to work around https://github.com/pypa/pip/issues/2902 -def mangle_release(rel): - if rel < "2007g": - return None - - # pytz is only supported on Python 3 from version 2011b. - if sys.version[:3] >= "3.0" and rel < "2011g": - return None - - if rel.endswith("r"): - return rel[:-1]+".post0" - return rel - -releases = pypi_data["releases"] -# Download the pytz versions into the cache. -for release in sorted(releases): - # These lines shouldn't be needed but pypi always runs setup.py even when - # downloading. - filename = "*pytz-"+release+"*" - if glob.glob(os.path.join(CACHE_DIR, filename)): - print("Not downloading release", release, "(already downloaded).") - continue - - mangled = mangle_release(release) - if not mangled: - continue - - print("Downloading pytz release", release) - print("="*75) - subprocess.check_call("""\ -pip install \ - --pre \ - --no-binary all \ - --download %s \ - pytz==%s -""" % (CACHE_DIR, mangled), shell=True) - print("-"*75) - -if "--download-only" in sys.argv: - sys.exit(0) - -success = [] -failures = [] -for release in sorted(releases): - mangled = mangle_release(release) - if not mangled: - print("Skipping release", release, "as it is isn't supported") - continue - - print() - print("Running tests with pytz release", release) - print("="*75) - print("Installing...") - subprocess.check_call("""\ -pip install \ - --pre \ - --no-binary all \ - --no-index \ - --find-links=file://%s \ - pytz==%s -""" % (CACHE_DIR, mangled), shell=True) - print("-"*75) - print("Running tests...") - t = subprocess.Popen("python setup.py test", shell=True) - if t.wait() != 0: - failures.append(release) - else: - success.append(release) - print("="*75) - -print("Tests passed on pytz versions:") -print(success) -print() -print("Tests failed on pytz versions:") -print(failures) -print("="*75) - -if failures: - sys.exit(1) diff --git a/setup.py b/setup.py index 44f572d..5816dd6 100644 --- a/setup.py +++ b/setup.py @@ -18,13 +18,13 @@ # import os +import sys + +from setuptools import setup +from setuptools.command import sdist, install -try: - from setuptools import setup - from setuptools.command import sdist, install -except ImportError: - from distutils.core import setup - from distutils.command import sdist, install +# Required in order to import datetime_tz in PEP 517 builds +sys.path.append(os.path.dirname(__file__)) class update_sdist(sdist.sdist): def run(self): @@ -40,11 +40,10 @@ def run(self): update_win32tz_map.update_stored_win32tz_map() install.install.run(self) -import sys data = dict( name='python-datetime-tz', - version='0.5.3', + version='0.5.4', author='Tim Ansell', author_email='mithro@mithis.com', url='http://github.com/mithro/python-datetime-tz', @@ -60,22 +59,15 @@ def run(self): "Topic :: Software Development :: Internationalization", ], packages=['datetime_tz'], - install_requires=[], - setup_requires=['Genshi'], + install_requires=[ + "defusedxml", + "python-dateutil >= 2.0", + "pytz >= 2011g", + ], py_modules=['datetime_tz','datetime_tz.pytz_abbr'], test_suite='tests', cmdclass={'sdist': update_sdist, "install": update_install}, ) -deps = [] -if sys.version[:3] < '3.0': - deps += ['pytz >= 2007g'] - deps += ['python-dateutil >= 1.4'] -else: - deps += ['pytz >= 2011g'] - deps += ['python-dateutil >= 2.0'] - -data['install_requires'] += deps -data['setup_requires'] += deps setup(**data) diff --git a/test_multiple_pytz_versions.sh b/test_multiple_pytz_versions.sh new file mode 100755 index 0000000..80e9c91 --- /dev/null +++ b/test_multiple_pytz_versions.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -e + +versions=( 2011g 2014.1 2016.1 2016.6.1 2017.2 2018.3 2018.9 ) +for version in ${versions[@]}; do + PYTZ_VERSION="==$version" tox -e py +done + + diff --git a/tests.py b/tests.py index 3d72682..b790bfd 100644 --- a/tests.py +++ b/tests.py @@ -254,30 +254,31 @@ def os_path_exists_fake(filename, os_path_exists=os.path.exists): self.mocked("os.path.exists", os_path_exists_fake) # Check that when /etc/timezone is a valid input - def timezone_valid_fake(filename, mode="r", open=open): + real_open = builtins.open + def timezone_valid_fake(filename, *args, **kw): if filename == "/etc/timezone": return StringIO("Australia/Sydney") - return open(filename, mode) + return real_open(filename, *args, **kw) self.mocked("builtins.open", timezone_valid_fake) tzinfo = datetime_tz._detect_timezone_etc_timezone() self.assertTimezoneEqual(tzinfo, pytz.timezone("Australia/Sydney")) # Check that when /etc/timezone is invalid timezone - def timezone_invalid_fake(filename, mode="r", open=open): + def timezone_invalid_fake(filename, *args, **kw): if filename == "/etc/timezone": return StringIO("Invalid-Timezone") - return open(filename, mode) + return real_open(filename, *args, **kw) self.mocked("builtins.open", timezone_invalid_fake) tzinfo = datetime_tz._detect_timezone_etc_timezone() self.assertEqual(None, tzinfo) # Check that when /etc/timezone is random "binary" data - def timezone_binary_fake(filename, mode="r", open=open): + def timezone_binary_fake(filename, *args, **kw): if filename == "/etc/timezone": return StringIO("\0\r\n\t\0\r\r\n\0") - return open(filename, mode) + return real_open(filename, *args, **kw) self.mocked("builtins.open", timezone_binary_fake) tzinfo = datetime_tz._detect_timezone_etc_timezone() @@ -316,7 +317,8 @@ def os_walk_fake(dirname, *args, **kw): return os_walk(dirname, *args, **kw) self.mocked("os.walk", os_walk_fake) - def localtime_valid_fake(filename, mode="r", open=open): + real_open = builtins.open + def localtime_valid_fake(filename, *args, **kw): if filename == "/etc/localtime": filename = os.path.join(os.path.dirname(__file__), localtime_file) @@ -331,7 +333,7 @@ def localtime_valid_fake(filename, mode="r", open=open): ): filename = os.path.join(os.path.dirname(__file__), "test_zonedata_utc") - return open(filename, mode) + return real_open(filename, *args, **kw) self.mocked("builtins.open", localtime_valid_fake) self.assertEqual( @@ -1340,7 +1342,7 @@ def testDefaultTzinfos(self): self.assertTrue("Australia/Sydney" in def_tz.keys()) self.assertTrue(def_tz.has_key("Australia/Sydney")) self.assertRaises(KeyError, def_tz.get, "Made/Up") - self.assertEquals(def_tz.get("Made/Up", None), None) + self.assertEqual(def_tz.get("Made/Up", None), None) class datetime_tz_test_subclass(datetime_tz.datetime_tz): diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..b9d1a9e --- /dev/null +++ b/tox.ini @@ -0,0 +1,18 @@ +[tox] +envlist = py27, + py36, + py37, + py38 +minversion = 3.3.0 +skip_missing_interpreters = true +isolated_build = true + +[testenv] +description = run the unit tests with pytest under {basepython} +commands = pytest {posargs: --cov=datetime_tz} tests.py +deps = + python-dateutil + pytz{env:PYTZ_VERSION:} + defusedxml + pytest + pytest-cov