├── requirements.txt ├── test_requirements.txt ├── .travis.yml ├── .gitignore ├── README.rst ├── tox.ini ├── LICENSE ├── tests └── test_parsing.py ├── setup.py └── lib └── iso8601 └── __init__.py /requirements.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test_requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6" 4 | # command to install dependencies 5 | install: pip install tox 6 | # command to run tests 7 | script: tox 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | examples/db.sqlite3 3 | venv 4 | table.css.map 5 | .idea 6 | .cache 7 | .DS_Store 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Packages 13 | *.egg 14 | *.egg-info 15 | dist 16 | build 17 | eggs 18 | parts 19 | sdist 20 | develop-eggs 21 | .installed.cfg 22 | 23 | # Installer logs 24 | pip-log.txt 25 | 26 | # Unit test / coverage reports 27 | .coverage 28 | .tox 29 | testreport.xml 30 | htmlcov 31 | 32 | # Translations 33 | *.mo 34 | 35 | # Mr Developer 36 | .mr.developer.cfg 37 | .project 38 | .pydevproject 39 | 40 | # Complexity 41 | output/*.html 42 | output/*/index.html 43 | 44 | # Sphinx 45 | docs/_build 46 | docs/tri*.rst 47 | 48 | # tests 49 | results/ 50 | .pytest_cache 51 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://travis-ci.org/boxed/iso8601.svg?branch=master 2 | :target: https://travis-ci.org/boxed/iso8601 3 | 4 | ISO8601 5 | ======= 6 | 7 | Implementation of (most of) the ISO 8601 specification. Just call `iso8601.parse()` with your string in any format and it will be auto detected. 8 | 9 | Supports 10 | ----------------- 11 | - Naive times 12 | - Timezone information (specified as offsets or as Z for 0 offset) 13 | - Year 14 | - Year-month 15 | - Year-month-date 16 | - Year-week 17 | - Year-week-weekday 18 | - Year-ordinal day 19 | - Hour 20 | - Hour-minute 21 | - Hour-minute 22 | - Hour-minute-second 23 | - Hour-minute-second-microsecond 24 | - All combinations of the three "families" above! 25 | 26 | Not supported formats 27 | --------------------- 28 | - Time durations 29 | - Time intervals 30 | - Repeating intervals 31 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py36 3 | 4 | [testenv] 5 | commands = {envpython} -m pytest {posargs} 6 | deps = 7 | -rtest_requirements.txt 8 | usedevelop = True 9 | 10 | [testenv:coverage] 11 | basepython = python2.7 12 | usedevelop = True 13 | commands = 14 | {envpython} -m pytest --cov iso8601 --cov-config .coveragerc {posargs} 15 | {envpython} -m coverage report -m 16 | {envpython} -m coverage html 17 | deps = 18 | coverage 19 | pytest-cov 20 | -rtest_requirements.txt 21 | 22 | [testenv:lint] 23 | basepython = python2.7 24 | usedevelop = True 25 | commands = 26 | {envpython} -m flake8 lib/iso8601 tests setup.py {posargs} 27 | deps = 28 | flake8 29 | 30 | [testenv:venv] 31 | envdir = venv 32 | usedevelop = True 33 | basepython = python3.6 34 | commands = {posargs:python --version} 35 | deps = 36 | -rrequirements.txt 37 | whitelist_externals = 38 | make 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2013 Anders Hovmöller. http://kodare.net 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/test_parsing.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from iso8601 import parse, parse_time, TimeZone 3 | from datetime import date, datetime, time, timedelta 4 | 5 | 6 | @pytest.mark.parametrize( 7 | 'function, data, expected', [ 8 | (parse, '2012', date(2012, 1, 1)), 9 | (parse, '2012-05-03', date(2012, 5, 3)), 10 | (parse, '20120503', date(2012, 5, 3)), 11 | (parse, '2012-05', date(2012, 5, 1)), 12 | 13 | # week numbers 14 | (parse, '2012-W05', date(2012, 1, 30)), 15 | (parse, '2012W05', date(2012, 1, 30)), 16 | (parse, '2012-W05-5', date(2012, 2, 3)), 17 | (parse, '2012W055', date(2012, 2, 3)), 18 | 19 | # ordinal days 20 | (parse, '2012-007', date(2012, 1, 7)), 21 | (parse, '2012007', date(2012, 1, 7)), 22 | 23 | # times 24 | (parse, '00:00', time(0, 0)), 25 | (parse, '12:04:23', time(12, 4, 23)), 26 | (parse, '120423', time(12, 4, 23)), 27 | (parse, '12:04', time(12, 4, 0)), 28 | (parse, '1204', date(1204, 1, 1)), 29 | (parse_time, '1204', time(12, 4, 0)), 30 | (parse, '12', time(12, 0, 0)), 31 | (parse, '02', time(2, 0, 0)), 32 | (parse, '12:04:23.450686', time(12, 4, 23, 450686)), 33 | (parse, '12:04:23.45', time(12, 4, 23, 450000)), 34 | 35 | # combined 36 | (parse, '2008-09-03T20:56:35.450686', datetime(2008, 9, 3, 20, 56, 35, 450686)), 37 | (parse, '2008-09-03T20:56:35.450686Z', datetime(2008, 9, 3, 20, 56, 35, 450686, TimeZone(timedelta()))), 38 | (parse, '2008-09-03T20:56:35.45Z', datetime(2008, 9, 3, 20, 56, 35, 450000, TimeZone(timedelta()))), 39 | (parse, '2008-09-03T20:56:35.450686+01', datetime(2008, 9, 3, 20, 56, 35, 450686, TimeZone(timedelta(minutes=60)))), 40 | (parse, '2008-09-03T20:56:35.45+01', datetime(2008, 9, 3, 20, 56, 35, 450000, TimeZone(timedelta(minutes=60)))), 41 | (parse, '2008-09-03T20:56:35.450686+0100', datetime(2008, 9, 3, 20, 56, 35, 450686, TimeZone(timedelta(minutes=60)))), 42 | (parse, '2008-09-03T20:56:35.450686+01:30', datetime(2008, 9, 3, 20, 56, 35, 450686, TimeZone(timedelta(minutes=60 + 30)))), 43 | (parse, '2008-09-03T20:56:35.450686-01:30', datetime(2008, 9, 3, 20, 56, 35, 450686, TimeZone(timedelta(minutes=-(60 + 30))))), 44 | (parse, '2013-03-28T02:30:24+00:00', datetime(2013, 3, 28, 2, 30, 24, tzinfo=TimeZone(timedelta(minutes=0)))), 45 | ]) 46 | def test(function, data, expected): 47 | actual = function(data) 48 | assert type(actual) == type(expected) 49 | assert actual == expected 50 | 51 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import re 5 | from setuptools import setup, find_packages, Command 6 | from io import open 7 | 8 | readme = open('README.rst', encoding='utf8').read() 9 | 10 | 11 | def read_reqs(name): 12 | with open(os.path.join(os.path.dirname(__file__), name), encoding='utf8') as f: 13 | return [line for line in f.read().split('\n') if line and not line.strip().startswith('#')] 14 | 15 | 16 | def read_version(): 17 | with open(os.path.join('lib', 'iso8601', '__init__.py'), encoding='utf8') as f: 18 | m = re.search(r'''__version__\s*=\s*['"]([^'"]*)['"]''', f.read()) 19 | if m: 20 | return m.group(1) 21 | raise ValueError("couldn't find version") 22 | 23 | 24 | class Tag(Command): 25 | user_options = [] 26 | 27 | def initialize_options(self): 28 | pass 29 | 30 | def finalize_options(self): 31 | pass 32 | 33 | def run(self): 34 | from subprocess import call 35 | version = read_version() 36 | errno = call(['git', 'tag', '--annotate', version, '--message', 'Version %s' % version]) 37 | if errno == 0: 38 | print("Added tag for version %s" % version) 39 | raise SystemExit(errno) 40 | 41 | 42 | class ReleaseCheck(Command): 43 | user_options = [] 44 | 45 | def initialize_options(self): 46 | pass 47 | 48 | def finalize_options(self): 49 | pass 50 | 51 | def run(self): 52 | from subprocess import check_output 53 | tag = check_output(['git', 'describe', '--all', '--exact-match', 'HEAD']).strip() 54 | version = read_version() 55 | if tag != version: 56 | print('Missing %s tag on release' % version) 57 | raise SystemExit(1) 58 | 59 | current_branch = check_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD']).strip() 60 | if current_branch != 'master': 61 | print('Only release from master') 62 | raise SystemExit(1) 63 | 64 | print("Ok to distribute files") 65 | 66 | 67 | # NB: _don't_ add namespace_packages to setup(), it'll break 68 | # everything using imp.find_module 69 | setup( 70 | name='iso8601', 71 | version=read_version(), 72 | description='iso8601', 73 | long_description=readme, 74 | author='Anders Hovmöller', 75 | author_email='boxed@killingar.net', 76 | url='https://github.com/boxe/iso8601', 77 | packages=find_packages('lib'), 78 | package_dir={'': 'lib'}, 79 | include_package_data=True, 80 | install_requires=read_reqs('requirements.txt'), 81 | license="BSD", 82 | zip_safe=False, 83 | keywords='iso8601', 84 | classifiers=[ 85 | 'Development Status :: 5 - Production/Stable', 86 | 'Intended Audience :: Developers', 87 | 'License :: OSI Approved :: BSD License', 88 | 'Natural Language :: English', 89 | "Programming Language :: Python :: 2", 90 | 'Programming Language :: Python :: 2.7', 91 | 'Programming Language :: Python :: 3', 92 | 'Programming Language :: Python :: 3.6', 93 | ], 94 | test_suite='tests', 95 | cmdclass={'tag': Tag, 96 | 'release_check': ReleaseCheck}, 97 | ) 98 | -------------------------------------------------------------------------------- /lib/iso8601/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/local/bin/python 2 | # coding: utf8 3 | 4 | # Based on http://en.wikipedia.org/wiki/ISO_8601 5 | # NOTE: this library only supports date in the range 1 AD - 9999 AD due to limitations in pythons datetime module 6 | 7 | # NOTE: autodetect between YYYY and hhmm is impossible, YYYY is preferred 8 | # TODO: durations 9 | # TODO: time intervals 10 | # TODO: repeating intervals 11 | 12 | from datetime import date, datetime, time, timedelta, tzinfo 13 | import re 14 | 15 | 16 | __version__ = '1.0.0' 17 | 18 | 19 | class TimeZone(tzinfo): 20 | def __init__(self, delta): 21 | super(TimeZone, self).__init__() 22 | self.offset = delta 23 | 24 | def utcoffset(self, dt): 25 | return self.offset 26 | 27 | def tzname(self, dt): 28 | return str(self.offset) 29 | 30 | def dst(self, dt): 31 | return self.offset 32 | 33 | def __repr__(self): 34 | return '%s' % self.offset 35 | 36 | 37 | def parse(s): 38 | timezone = None 39 | if s.endswith('Z'): 40 | timezone = TimeZone(timedelta()) 41 | s = s[:-1] 42 | length = len(s) 43 | if 'T' in s: 44 | date_part, time_part = s.split('T') 45 | return datetime.combine(parse_date(date_part), parse_time(time_part, timezone)) 46 | if ':' in s or length in {6, 2}: 47 | return parse_time(s, timezone) 48 | return parse_date(s) 49 | 50 | 51 | def parse_timezone(s): 52 | assert s[0] in {'+', '-'} 53 | sign, timezone = s[0], s[1:] 54 | timezone = parse_time(timezone) 55 | minutes = timezone.hour*60 + timezone.minute 56 | if sign == '-': 57 | minutes = -minutes 58 | return TimeZone(timedelta(minutes=minutes)) 59 | 60 | 61 | def parse_date(s): 62 | # calendar dates 63 | # YYYY-MM-DD 64 | # YYYYMMDD 65 | result = None 66 | m = re.match(r'^(?P\d{4})-?(?P\d{2})-?(?P\d{2})', s) 67 | if m: 68 | s = s[m.end():] 69 | result = date(int(m.groupdict()['year']), int(m.groupdict()['month']), int(m.groupdict()['day'])) 70 | 71 | # week dates 72 | if result is None: 73 | if 'W' in s: 74 | formats = [ 75 | # suffix for parsing, format, description 76 | ('', '%Y-W%W-%w', 'YYYY-Www-D'), 77 | ('', '%YW%W%w', 'YYYYWwwD'), 78 | ('1', '%Y-W%W%w', 'YYYY-Www'), # week only 79 | ('1', '%YW%W%w', 'YYYYWww'), # week only 80 | ] 81 | for suffix, week_format, description in formats: 82 | if len(description) == len(s): 83 | try: 84 | result = datetime.strptime(s[:len(description)] + suffix, week_format).date() 85 | s = s[:len(description)] 86 | break 87 | except ValueError: 88 | pass 89 | 90 | # ordinal dates 91 | description = 'YYYY-DDD' 92 | if result is None and len(s) >= len(description): 93 | try: 94 | result = datetime.strptime(s[:len(description)], '%Y-%j').date() 95 | s = s[:len(description)] 96 | except ValueError: 97 | pass 98 | 99 | description = 'YYYYDDD' 100 | if result is None and len(s) >= len(description): 101 | try: 102 | result = datetime.strptime(s[:len(description)], '%Y%j').date() 103 | s = s[:len(description)] 104 | except ValueError: 105 | pass 106 | 107 | # YYYY-MM # month only 108 | if result is None: 109 | m = re.match(r'^(?P\d{4})-?(?P\d{2})', s) 110 | if m: 111 | s = s[m.end():] 112 | result = date(int(m.groupdict()['year']), int(m.groupdict()['month']), 1) 113 | 114 | if result is None: 115 | description = 'YYYY' 116 | result = datetime.strptime(s[:len(description)], '%Y').date() 117 | s = s[:len(description)] 118 | 119 | return result 120 | 121 | 122 | def parse_time(in_s, timezone=None): 123 | result = None 124 | s = in_s 125 | 126 | def check_result(): 127 | if m: 128 | hour = int(m.groupdict()['hour']) 129 | minute = m.groupdict().get('minute', 0) 130 | second = m.groupdict().get('second', 0) 131 | micros = m.groupdict().get('micros') or '.0' 132 | if micros: 133 | assert micros[0] == '.' 134 | micros = micros[1:].ljust(6, '0') 135 | return s[m.end():], time(hour, int(minute), int(second), int(micros), timezone) 136 | return s, result 137 | 138 | # hh:mm:ss 139 | # hhmmss 140 | if result is None: 141 | m = re.match(r'^(?P\d{2}):?(?P\d{2}):?(?P\d{2})(?P\.\d{1,6})?', s) 142 | s, result = check_result() 143 | 144 | # hh:mm 145 | # hhmm 146 | if result is None: 147 | m = re.match(r'^(?P\d{2}):?(?P\d{2})(?P\.\d{1,6})?', s) 148 | s, result = check_result() 149 | 150 | # hh 151 | if result is None: 152 | m = re.match(r'^(?P\d{2})(?P\.\d{1,6})?', s) 153 | s, result = check_result() 154 | 155 | if result is not None: 156 | if s: 157 | result = result.replace(tzinfo=parse_timezone(s)) 158 | return result 159 | 160 | raise Exception('Could not parse "%s" as a time' % in_s) 161 | --------------------------------------------------------------------------------