diff --git a/marshmallow_utils/fields/__init__.py b/marshmallow_utils/fields/__init__.py index b871a77..53cf930 100644 --- a/marshmallow_utils/fields/__init__.py +++ b/marshmallow_utils/fields/__init__.py @@ -13,6 +13,7 @@ from .isodate import ISODateString from .isolanguage import ISOLangString from .links import Link, Links +from .localized_edtfdatestring import LocalizedEDTFDateString from .nestedattr import NestedAttribute from .sanitizedhtml import ALLOWED_HTML_ATTRS, ALLOWED_HTML_TAGS, SanitizedHTML from .sanitizedunicode import SanitizedUnicode @@ -23,6 +24,7 @@ 'ALLOWED_HTML_TAGS', 'EDTFDateString', 'Function', + 'LocalizedEDTFDateString', 'GenFunction', 'GenMethod', 'ISODateString', diff --git a/marshmallow_utils/fields/edtfdatestring.py b/marshmallow_utils/fields/edtfdatestring.py index 7d015f0..b6841ff 100644 --- a/marshmallow_utils/fields/edtfdatestring.py +++ b/marshmallow_utils/fields/edtfdatestring.py @@ -7,8 +7,6 @@ """Extended Date(/Time) Format Level 0 date string field.""" -from datetime import date - from edtf.parser.grammar import ParseException, level0Expression from marshmallow import fields @@ -28,20 +26,11 @@ class EDTFDateString(fields.Str): "invalid": _("Please provide a valid date or interval.") } - def _deserialize(self, value, attr, data, **kwargs): - """Deserialize an EDTF Level 0 formatted date string. - - load()-equivalent operation. - - NOTE: Level 0 allows for an interval. - NOTE: ``level0Expression`` tries hard to parse dates. For example, - ``"2020-01-02garbage"`` will parse to the 2020-01-02 date. - """ + def _parse_date_string(self, datestring): + """Parse input string as EDTF.""" parser = level0Expression("level0") - try: - result = parser.parseString(value) - + result = parser.parseString(datestring) if not result: raise ParseException() @@ -52,10 +41,21 @@ def _deserialize(self, value, attr, data, **kwargs): result = result[0] if result.upper_strict() < result.lower_strict(): raise self.make_error("invalid") - - return ( - super(EDTFDateString, self) - ._deserialize(str(result), attr, data, **kwargs) - ) + return result except ParseException: raise self.make_error("invalid") + + def _deserialize(self, value, attr, data, **kwargs): + """Deserialize an EDTF Level 0 formatted date string. + + load()-equivalent operation. + + NOTE: Level 0 allows for an interval. + NOTE: ``level0Expression`` tries hard to parse dates. For example, + ``"2020-01-02garbage"`` will parse to the 2020-01-02 date. + """ + result = self._parse_date_string(value) + return ( + super(EDTFDateString, self) + ._deserialize(str(result), attr, data, **kwargs) + ) diff --git a/marshmallow_utils/fields/localized_edtfdatestring.py b/marshmallow_utils/fields/localized_edtfdatestring.py new file mode 100644 index 0000000..a999051 --- /dev/null +++ b/marshmallow_utils/fields/localized_edtfdatestring.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2016-2020 CERN. +# +# Marshmallow-Utils is free software; you can redistribute it and/or modify +# it under the terms of the MIT License; see LICENSE file for more details. + +"""Localized Extended Date(/Time) Format Level 0 date string field.""" + +import calendar +from datetime import date, datetime + +from edtf.parser.grammar import Interval, ParseException, level0Expression +from marshmallow import fields + + +def _(x): + return x + +# TODO: move to external package and depend on flask-babelex + + +class LocalizedEDTFFormatter(object): + """Localized Formatter for EDTF dates.""" + + RANGE_SEPARATOR = u' \u2014 ' + AVAILABLE_FORMATS = ['short', 'medium', 'long', 'full'] + + def __init__(self, format=None, locale=None): + """EDTF formatter consrtuctor. + + :params format: One of `AVAILABLE_FORMATS`. + :params locale: Locale value used for localization. + """ + self.format = format or 'medium' + self.locale = locale or 'en' + + def has_day_precision(self, edtfdate): + """Check if EDTF date has `day` precision. + + If so, then the string can be converted to a python date object. + """ + return edtfdate.precision == 'day' + + def to_date(self, edtfdate, strict="lower"): + """Convert EDTF date to python date object. + + :params strict: Convert EDTF date string to `lower` or `upper` strict + in case it is not a `python.datetime` compatible value. + """ + if self.has_day_precision(edtfdate): + return datetime.strptime(edtfdate.isoformat(), "%Y-%m-%d").date() + else: + if strict == 'lower': + edtfdate = edtfdate.lower_strict() + elif strict == 'upper': + edtfdate = edtfdate.upper_strict() + return date.fromtimestamp(calendar.timegm(edtfdate)) + + def format_date(self, edtfdate, strict): + """Format single EDTF date.""" + try: + # TODO: remove when moved to external package + from flask_babelex import format_date + date_obj = self.to_date(edtfdate, strict) + return format_date(date_obj, self.format, self.locale) + except ImportError: + return str(edtfdate) + + def format_range_date(self, range_dates): + """Format range of EDTF dates.""" + # TODO: remove when moved to external package + try: + from flask_babelex import format_date + start_date = self.format_date(range_dates[0], strict="lower") + end_date = self.format_date(range_dates[1], strict="upper") + return self.RANGE_SEPARATOR.join([start_date, end_date]) + except ImportError: + return self.RANGE_SEPARATOR.join(range_dates) + + +class LocalizedEDTFDateString(fields.Str): + """ + Localized Extended Date(/Time) Format Level 0 date string field. + + Made a field for stronger semantics than just a validator. + """ + + default_error_messages = { + "invalid": _("Please provide a valid date or interval.") + } + + def __init__(self, formatter=None, format=None, locale=None, **kwargs): + """Contructor.""" + self.formatter = formatter or LocalizedEDTFFormatter( + format=format, locale=locale) + super(LocalizedEDTFDateString, self).__init__(**kwargs) + + def _parse_date_string(self, datestring): + """Parse input string as EDTF.""" + parser = level0Expression("level0") + try: + result = parser.parseString(datestring) + # check it is chronological if interval + # NOTE: EDTF Date and Interval both have same interface + # and date.lower_strict() <= date.upper_strict() is always + # True for a Date + result = result[0] + if result.upper_strict() < result.lower_strict(): + raise self.make_error("invalid") + return result + except ParseException: + raise self.make_error("invalid") + + def _serialize(self, value, attr, data, **kwargs): + """Serialize a Localized EDTF Level 0 formatted date string. + + dump()-equivalent operation. + + NOTE: Level 0 allows for an interval. + NOTE: ``level0Expression`` tries hard to parse dates. For example, + ``"2020-01-02garbage"`` will parse to the 2020-01-02 date. + """ + result = self._parse_date_string(value) + # Format string + if self.formatter: + if isinstance(result, Interval): + # range value + result = self.formatter.format_range_date( + [result.lower, result.upper]) + else: + result = self.formatter.format_date(result, strict="lower") + return ( + super(LocalizedEDTFDateString, self) + ._serialize(str(result), attr, data, **kwargs) + ) diff --git a/marshmallow_utils/version.py b/marshmallow_utils/version.py index 78bf2c3..57ec8d6 100644 --- a/marshmallow_utils/version.py +++ b/marshmallow_utils/version.py @@ -12,4 +12,4 @@ and parsed by ``setup.py``. """ -__version__ = '0.2.1' +__version__ = '0.2.2'