Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for ISO week dates #139

Merged
merged 3 commits into from
Nov 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
- [Version 2.0.0](#version-200)
- [Breaking changes](#breaking-changes)
- [Other Changes](#other-changes)
- [v1.x.x -> 2.0.0 Migration guide](#v1xx---200-migration-guide)
- [v1.x.x -\> 2.0.0 Migration guide](#v1xx---200-migration-guide)
- [ValueError instead of None](#valueerror-instead-of-none)
- [Tightened ISO 8601 conformance](#tightened-iso-8601-conformance)
- [`parse_datetime_unaware` has been renamed](#parse_datetime_unaware-has-been-renamed)
Expand All @@ -25,6 +25,7 @@
* Removed improper ability to call `FixedOffset`'s `dst`, `tzname` and `utcoffset` without arguments
* Fixed: `datetime.tzname` returns a `str` in Python 2.7, not a `unicode`
* Change `METH_VARARGS` to `METH_O`, enhancing performance. ([#130](https://github.com/closeio/ciso8601/pull/130))
* Added support for ISO week dates, ([#139](https://github.com/closeio/ciso8601/pull/139))

# 2.x.x

Expand Down
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
include LICENSE
include README.rst
include CHANGELOG.md
include isocalendar.h
include timezone.h
40 changes: 17 additions & 23 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@ ciso8601
Since it's written as a C module, it is much faster than other Python libraries.
Tested with cPython 2.7, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10, 3.11.

**Note:** ciso8601 doesn't support the entirety of the ISO 8601 spec, `only a popular subset`_.
.. |datetime.fromisoformat| replace:: ``datetime.fromisoformat``
.. _datetime.fromisoformat: https://docs.python.org/3/library/datetime.html#datetime.datetime.fromisoformat
Comment on lines +19 to +20
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a reStructured text hack to allow links on inline code blocks.


**Note:** ciso8601 doesn't support the entirety of the ISO 8601 spec; but supports `a superset`_ of what is supported by Python itself (|datetime.fromisoformat|_).

.. _ISO 8601: https://en.wikipedia.org/wiki/ISO_8601
.. _RFC 3339: https://tools.ietf.org/html/rfc3339

.. _`only a popular subset`: https://github.com/closeio/ciso8601#supported-subset-of-iso-8601
.. _`a superset`: https://github.com/closeio/ciso8601#supported-subset-of-iso-8601

(Interested in working on projects like this? `Close`_ is looking for `great engineers`_ to join our team)

Expand Down Expand Up @@ -200,7 +203,8 @@ Tested on Linux 5.15.49-linuxkit using the following modules:

.. </include:benchmark_module_versions.rst>

**Note:** ciso8601 doesn't support the entirety of the ISO 8601 spec, `only a popular subset`_.
**Note:** ciso8601 doesn't support the entirety of the ISO 8601 spec; but supports `a superset`_ of what is supported by Python itself (|datetime.fromisoformat|_).


For full benchmarking details (or to run the benchmark yourself), see `benchmarking/README.rst`_

Expand All @@ -209,7 +213,7 @@ For full benchmarking details (or to run the benchmark yourself), see `benchmark
Supported subset of ISO 8601
----------------------------

``ciso8601`` only supports the most common subset of ISO 8601.
``ciso8601`` only supports a subset of ISO 8601, but supports a superset of what is supported by Python itself (|datetime.fromisoformat|_), and supports the entirety of the `RFC 3339`_ specification.

Date formats
^^^^^^^^^^^^
Expand All @@ -222,32 +226,22 @@ The following date formats are supported:
============================= ============== ==================
Format Example Supported
============================= ============== ==================
``YYYY-MM-DD`` ``2018-04-29`` ✅
``YYYY-MM`` ``2018-04`` ✅
``YYYYMMDD`` ``20180429`` ✅
``YYYY-MM-DD`` (extended) ``2018-04-29`` ✅
``YYYY-MM`` (extended) ``2018-04`` ✅
``YYYYMMDD`` (basic) ``20180429`` ✅
``YYYY-Www-D`` (week date) ``2009-W01-1`` ✅
``YYYY-Www`` (week date) ``2009-W01`` ✅
``YYYYWwwD`` (week date) ``2009W011`` ✅
``YYYYWww`` (week date) ``2009W01`` ✅
``YYYY-DDD`` (ordinal date) ``1981-095`` ❌
``YYYYDDD`` (ordinal date) ``1981095`` ❌
``--MM-DD`` (omitted year) ``--04-29`` ❌
``--MMDD`` (omitted year) ``--0429`` ❌
``±YYYYY-MM`` (>4 digit year) ``+10000-04`` ❌
``+YYYY-MM`` (leading +) ``+2018-04`` ❌
``-YYYY-MM`` (negative -) ``-2018-04`` ❌
============================= ============== ==================

Week dates or ordinal dates are not currently supported.

.. table::
:widths: auto

============================= ============== ==================
Format Example Supported
============================= ============== ==================
``YYYY-Www`` (week date) ``2009-W01`` ❌
``YYYYWww`` (week date) ``2009W01`` ❌
``YYYY-Www-D`` (week date) ``2009-W01-1`` ❌
``YYYYWwwD`` (week date) ``2009-W01-1`` ❌
``YYYY-DDD`` (ordinal date) ``1981-095`` ❌
``YYYYDDD`` (ordinal date) ``1981095`` ❌
============================= ============== ==================

Time formats
^^^^^^^^^^^^

Expand Down
63 changes: 52 additions & 11 deletions generate_test_timestamps.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime
import pytz
import sys

from collections import namedtuple

Expand All @@ -22,6 +23,8 @@ def __merge_dicts(*dict_args):
"year": NumberField(4, 4, 1, 9999),
"month": NumberField(2, 2, 1, 12),
"day": NumberField(2, 2, 1, 31),
"iso_week": NumberField(2, 2, 1, 53),
"iso_day": NumberField(1, 1, 1, 7),
"hour": NumberField(2, 2, 0, 24), # 24 = special midnight value
"minute": NumberField(2, 2, 0, 59),
"second": NumberField(2, 2, 0, 60), # 60 = Leap second
Expand All @@ -39,7 +42,7 @@ def __merge_dicts(*dict_args):
}


def __generate_valid_formats(year=2014, month=2, day=3, hour=1, minute=23, second=45, microsecond=123456, tzhour=4, tzminute=30):
def __generate_valid_formats(year=2014, month=2, day=3, iso_week=6, iso_day=1, hour=1, minute=23, second=45, microsecond=123456, tzhour=4, tzminute=30):
# Given a set of values, generates the 400+ different combinations of those values within a valid ISO 8601 string.
# Returns a Python format string, the fields in the format string, and the corresponding parameters you could pass to the datetime constructor
# These can be used by generate_valid_timestamp_and_datetime and generate_invalid_timestamp_and_datetime to produce test cases
Expand All @@ -53,6 +56,16 @@ def __generate_valid_formats(year=2014, month=2, day=3, hour=1, minute=23, secon
("{year}-{month}-{day}", set(["year", "month", "day"]), {"year": year, "month": month, "day": day}),
]

valid_basic_week_date_formats = [
("{year}W{iso_week}", set(["year", "iso_week"]), {"year": year, "iso_week": iso_week, "iso_day": 1}),
("{year}W{iso_week}{iso_day}", set(["year", "iso_week", "iso_day"]), {"year": year, "iso_week": iso_week, "iso_day": iso_day})
]

valid_extended_week_date_formats = [
("{year}-W{iso_week}", set(["year", "iso_week"]), {"year": year, "iso_week": iso_week, "iso_day": 1}),
("{year}-W{iso_week}-{iso_day}", set(["year", "iso_week", "iso_day"]), {"year": year, "iso_week": iso_week, "iso_day": iso_day})
]

valid_date_and_time_separators = [None, "T", "t", " "]

valid_basic_time_formats = [
Expand Down Expand Up @@ -85,8 +98,28 @@ def __generate_valid_formats(year=2014, month=2, day=3, hour=1, minute=23, secon
("+{tzhour}:{tzminute}", set(["tzhour", "tzminute"]), {"tzinfo": pytz.FixedOffset(1 * ((tzhour * 60) + tzminute))})
]

for valid_calendar_date_formats, valid_time_formats in [(valid_basic_calendar_date_formats, valid_basic_time_formats), (valid_extended_calendar_date_formats, valid_extended_time_formats)]:
format_date_time_combinations = [
(valid_basic_calendar_date_formats, valid_basic_time_formats),
(valid_extended_calendar_date_formats, valid_extended_time_formats),
]

if (sys.version_info.major, sys.version_info.minor) >= (3, 8):
# We rely on datetime.datetime.fromisocalendar
# to generate the expected values, but that was added in Python 3.8
format_date_time_combinations += [
(valid_basic_week_date_formats, valid_basic_time_formats),
(valid_extended_week_date_formats, valid_extended_time_formats)
]

for valid_calendar_date_formats, valid_time_formats in format_date_time_combinations:
for calendar_format, calendar_fields, calendar_params in valid_calendar_date_formats:

if "iso_week" in calendar_fields:
dt = datetime.datetime.fromisocalendar(calendar_params["year"], calendar_params["iso_week"], calendar_params["iso_day"])
calendar_params = __merge_dicts(calendar_params, { "month": dt.month, "day": dt.day })
del(calendar_params["iso_week"])
del(calendar_params["iso_day"])

for date_and_time_separator in valid_date_and_time_separators:
if date_and_time_separator is None:
full_format = calendar_format
Expand Down Expand Up @@ -117,7 +150,7 @@ def __pad_params(**kwargs):
return {key: PADDED_NUMBER_FIELD_FORMATS[key].format(**{key: value}) if key in PADDED_NUMBER_FIELD_FORMATS else value for key, value in kwargs.items()}


def generate_valid_timestamp_and_datetime(year=2014, month=2, day=3, hour=1, minute=23, second=45, microsecond=123456, tzhour=4, tzminute=30):
def generate_valid_timestamp_and_datetime(year=2014, month=2, day=3, iso_week=6, iso_day=1, hour=1, minute=23, second=45, microsecond=123456, tzhour=4, tzminute=30):
# Given a set of values, generates the 400+ different combinations of those values within a valid ISO 8601 string, and the corresponding datetime
# This can be used to generate test cases of valid ISO 8601 timestamps.

Expand All @@ -128,6 +161,8 @@ def generate_valid_timestamp_and_datetime(year=2014, month=2, day=3, hour=1, min
"year": year,
"month": month,
"day": day,
"iso_week": iso_week,
"iso_day": iso_day,
"hour": hour,
"minute": minute,
"second": second,
Expand All @@ -142,7 +177,7 @@ def generate_valid_timestamp_and_datetime(year=2014, month=2, day=3, hour=1, min
yield (timestamp, datetime.datetime(**datetime_params))


def generate_invalid_timestamp(year=2014, month=2, day=3, hour=1, minute=23, second=45, microsecond=123456, tzhour=4, tzminute=30):
def generate_invalid_timestamp(year=2014, month=2, day=3, iso_week=6, iso_day=1, hour=1, minute=23, second=45, microsecond=123456, tzhour=4, tzminute=30):
# At the very least, each field can be invalid in the following ways:
# - Have too few characters
# - Have too many characters
Expand All @@ -169,6 +204,8 @@ def generate_invalid_timestamp(year=2014, month=2, day=3, hour=1, minute=23, sec
"year": year,
"month": month,
"day": day,
"iso_week": iso_week,
"iso_day": iso_day,
"hour": hour,
"minute": minute,
"second": second,
Expand All @@ -184,43 +221,47 @@ def generate_invalid_timestamp(year=2014, month=2, day=3, hour=1, minute=23, sec
if field is not None:
# Too few characters
for length in range(1, field.min_width):
if timestamp_format.startswith("{year}W{iso_week}{iso_day}") and field_name == "iso_week":
# If you reduce the iso_week field to 1 character, then the iso_day will make it into
# a valid "{year}W{iso_week}" timestamp
continue
str_value = str(__pad_params(**{field_name: kwargs[field_name]})[field_name])[0:length]
mangled_kwargs[field_name] = "{{:0>{length}}}".format(length=length).format(str_value)
timestamp = timestamp_format.format(**mangled_kwargs)
yield timestamp
yield (timestamp, "{0} has too few characters".format(field_name))

# Too many characters
if field.max_width is not None:
mangled_kwargs[field_name] = "{{:0>{length}}}".format(length=field.max_width + 1).format(kwargs[field_name])
timestamp = timestamp_format.format(**mangled_kwargs)
yield timestamp
yield (timestamp, "{0} has too many characters".format(field_name))

# Too small of value
if (field.min_value - 1) >= 0:
mangled_kwargs[field_name] = __pad_params(**{field_name: field.min_value - 1})[field_name]
timestamp = timestamp_format.format(**mangled_kwargs)
yield timestamp
yield (timestamp, "{0} has too small value".format(field_name))

# Too large of value
if field.max_value is not None:
mangled_kwargs[field_name] = __pad_params(**{field_name: field.max_value + 1})[field_name]
timestamp = timestamp_format.format(**mangled_kwargs)
yield timestamp
yield (timestamp, "{0} has too large value".format(field_name))

# Invalid characters
max_invalid_characters = field.max_width if field.max_width is not None else 1
# ex. 2014 -> a, aa, aaa
for length in range(1, max_invalid_characters):
mangled_kwargs[field_name] = "a" * length
timestamp = timestamp_format.format(**mangled_kwargs)
yield timestamp
yield (timestamp, "{0} has invalid characters".format(field_name))
# ex. 2014 -> aaaa, 2aaa, 20aa, 201a
for length in range(0, max_invalid_characters):
str_value = str(__pad_params(**{field_name: kwargs[field_name]})[field_name])[0:length]
mangled_kwargs[field_name] = "{{:a<{length}}}".format(length=max_invalid_characters).format(str_value)
timestamp = timestamp_format.format(**mangled_kwargs)
yield timestamp
yield (timestamp, "{0} has invalid characters".format(field_name))

# Trailing characters
timestamp = timestamp_format.format(**__pad_params(**kwargs)) + "EXTRA"
yield timestamp
yield (timestamp, "{0} has extra characters".format(field_name))
Loading