diff --git a/bookwyrm/tests/test_sealed_date.py b/bookwyrm/tests/test_sealed_date.py index 0eca8a815..cda1ae0fc 100644 --- a/bookwyrm/tests/test_sealed_date.py +++ b/bookwyrm/tests/test_sealed_date.py @@ -18,16 +18,76 @@ class SealedDateTest(unittest.TestCase): sealed = sealed_date.SealedDate.from_datetime(self.dt) self.assertEqual(self.dt, sealed) self.assertEqual("2023-10-20", sealed.partial_isoformat()) + self.assertTrue(sealed.has_day) + self.assertTrue(sealed.has_month) def test_month_seal(self): sealed = sealed_date.MonthSeal.from_datetime(self.dt) self.assertEqual(self.dt, sealed) self.assertEqual("2023-10", sealed.partial_isoformat()) + self.assertFalse(sealed.has_day) + self.assertTrue(sealed.has_month) def test_year_seal(self): sealed = sealed_date.YearSeal.from_datetime(self.dt) self.assertEqual(self.dt, sealed) self.assertEqual("2023", sealed.partial_isoformat()) + self.assertFalse(sealed.has_day) + self.assertFalse(sealed.has_month) + + def test_parse_year_seal(self): + parsed = sealed_date.from_partial_isoformat("1995") + expected = datetime.date(1995, 1, 1) + self.assertEqual(expected, parsed.date()) + self.assertFalse(parsed.has_day) + self.assertFalse(parsed.has_month) + + def test_parse_year_errors(self): + self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "995") + self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "1995x") + self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "1995-") + + def test_parse_month_seal(self): + expected = datetime.date(1995, 5, 1) + test_cases = [ + ("parse_month", "1995-05"), + ("parse_month_lenient", "1995-5"), + ] + for desc, value in test_cases: + with self.subTest(desc): + parsed = sealed_date.from_partial_isoformat(value) + self.assertEqual(expected, parsed.date()) + self.assertFalse(parsed.has_day) + self.assertTrue(parsed.has_month) + + def test_parse_month_dash_required(self): + self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "20056") + self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "200506") + self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "1995-7-") + + def test_parse_day_seal(self): + expected = datetime.date(1995, 5, 6) + test_cases = [ + ("parse_day", "1995-05-06"), + ("parse_day_lenient1", "1995-5-6"), + ("parse_day_lenient2", "1995-05-6"), + ] + for desc, value in test_cases: + with self.subTest(desc): + parsed = sealed_date.from_partial_isoformat(value) + self.assertEqual(expected, parsed.date()) + self.assertTrue(parsed.has_day) + self.assertTrue(parsed.has_month) + + def test_partial_isoformat_no_time_allowed(self): + self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "2005-06-07 ") + self.assertRaises(ValueError, sealed_date.from_partial_isoformat, "2005-06-07T") + self.assertRaises( + ValueError, sealed_date.from_partial_isoformat, "2005-06-07T00:00:00" + ) + self.assertRaises( + ValueError, sealed_date.from_partial_isoformat, "2005-06-07T00:00:00-03" + ) class SealedDateFormFieldTest(unittest.TestCase): diff --git a/bookwyrm/utils/sealed_date.py b/bookwyrm/utils/sealed_date.py index 931d1b8e0..6055b03cc 100644 --- a/bookwyrm/utils/sealed_date.py +++ b/bookwyrm/utils/sealed_date.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime, timedelta +import re from typing import Any, Optional, Type, TypeVar, cast from django.core.exceptions import ValidationError @@ -11,6 +12,12 @@ from django.forms.widgets import SelectDateWidget from django.utils import timezone +__all__ = [ + "SealedDate", + "from_partial_isoformat", +] + +_partial_re = re.compile(r"(\d{4})(?:-(\d\d?))?(?:-(\d\d?))?$") _westmost_tz = timezone.get_fixed_timezone(timedelta(hours=-12)) Sealed = TypeVar("Sealed", bound="SealedDate") # TODO: use Self in Python >= 3.11 @@ -63,6 +70,22 @@ class YearSeal(SealedDate): return self.strftime("%Y") +def from_partial_isoformat(value: str) -> SealedDate: + match = _partial_re.match(value) + + if not match: + raise ValueError + + year, month, day = [val and int(val) for val in match.groups()] + + if month is None: + return YearSeal.from_date_parts(year, 1, 1) + elif day is None: + return MonthSeal.from_date_parts(year, month, 1) + else: + return SealedDate.from_date_parts(year, month, day) + + class SealedDateFormField(DateField): """date form field with support for SealedDate"""