Skip to content

PGP dates #125

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

Draft
wants to merge 6 commits into
base: develop
Choose a base branch
from
Draft
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
4,650 changes: 4,650 additions & 0 deletions examples/pgp_dates.ipynb

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion src/undate/converters/calendars/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
from undate.converters.calendars.gregorian import GregorianDateConverter
from undate.converters.calendars.hebrew import HebrewDateConverter
from undate.converters.calendars.islamic import IslamicDateConverter
from undate.converters.calendars.seleucid import SeleucidDateConverter

__all__ = ["GregorianDateConverter", "HebrewDateConverter", "IslamicDateConverter"]
__all__ = [
"GregorianDateConverter",
"HebrewDateConverter",
"IslamicDateConverter",
"SeleucidDateConverter",
]
14 changes: 10 additions & 4 deletions src/undate/converters/calendars/hebrew/hebrew.lark
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

// only support day month year format for now
// parser requires numeric day and year to be distinguished based on order
hebrew_date: day month year | month year | year
hebrew_date: weekday? day month comma? year | month year | year

// TODO: handle date ranges?

Expand All @@ -27,10 +27,14 @@ month: month_1
| month_10
| month_11
| month_12
| month_13
| month_13
// months have 29 or 30 days; we do not expect leading zeroes
day: /[1-9]/ | /[12][0-9]/ | /30/

comma: ","
weekday: ("Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday") comma?


// months, in order; from convertdate list
// with variants from Princeton Geniza Project
// support matching with and without accents
Expand All @@ -43,11 +47,13 @@ month_5: "Av"
month_6: "Elul"
// Tishrei or Tishri
month_7: /Tishre?i/
month_8: "Heshvan"
// Heshvan, Ḥeshvan, Marḥeshvan
month_8: /(Mar)?[ḤHḥ]eshvan/
month_9: "Kislev"
// Tevet or Teveth
month_10: /[ṬT]eveth?/
month_11: "Shevat"
// Shevat or Shevaṭ
month_11: /Sheva[tṭ]/
// Adar I or Adar
month_12: /Adar( I)?/
// Adar II or Adar Bet
Expand Down
8 changes: 5 additions & 3 deletions src/undate/converters/calendars/hebrew/transformer.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ class HebrewDateTransformer(Transformer):
"""Transform a Hebrew date parse tree and return an Undate or
UndateInterval."""

calendar = Calendar.HEBREW

def hebrew_date(self, items):
parts = {}
for child in items:
Expand All @@ -22,9 +24,9 @@ def hebrew_date(self, items):
value = int(child.children[0])
parts[str(child.data)] = value

# initialize and return an undate with islamic year, month, day and
# islamic calendar
return HebrewUndate(**parts)
# initialize and return an undate with year, month, day and
# configured calendar (hebrew by default)
return Undate(**parts, calendar=self.calendar)

# year translation is not needed since we want a tree with name year
# this is equivalent to a no-op
Expand Down
9 changes: 7 additions & 2 deletions src/undate/converters/calendars/islamic/islamic.lark
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

// only support day month year format for now
// parser requires numeric day and year to be distinguished based on order
islamic_date: day month year | month year | year
islamic_date: weekday? day month year | month year | year

// TODO: handle date ranges?

Expand All @@ -13,6 +13,7 @@ islamic_date: day month year | month year | year

year: /\d+/


// months
month: month_1
| month_2
Expand All @@ -29,6 +30,10 @@ month: month_1
// months have 29 or 30 days; we do not expect leading zeroes
day: /[1-9]/ | /[12][0-9]/ | /30/


comma: ","
weekday: ("Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday") comma?

// months, in order; from convertdate list
// with variants from Princeton Geniza Project
// support matching with and without accents
Expand All @@ -42,7 +47,7 @@ month_4: /Rab[īi][ʿ'] (ath-Th[āa]n[īi]|II)/
// Jumādā al-ʾAwwal or Jumādā I
month_5: /Jum[āa]d[āa] (al-[ʾ`]Awwal|I)/
// Jumādā ath-Thāniya or Jumādā II
month_6: /Jum[āa]d[āa] (ath-Th[āa]niyah|II)/
month_6: /Jum[āa][dḍ][āa] (ath-Th[āa]niyah|II)/
month_7: "Rajab"
// Shaʿbān
month_8: /Sha[ʿ']b[āa]n/
Expand Down
24 changes: 24 additions & 0 deletions src/undate/converters/calendars/seleucid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from undate.converters.calendars import HebrewDateConverter
from undate.undate import Calendar


class SeleucidDateConverter(HebrewDateConverter):
#: offset for Seleucid calendar: Seleucid year + 3449 = Anno Mundi year
SELEUCID_OFFSET = 3449

#: converter name: Seleucid
name: str = "Seleucid"
calendar_name: str = "Seleucid"

def __init__(self):
super().__init__()
# override hebrew calendar to initialize undates with seleucid
# calendar; this triggers Seleucid calendar to_gregorian method use
self.transformer.calendar = Calendar.SELEUCID

def to_gregorian(self, year: int, month: int, day: int) -> tuple[int, int, int]:
"""Convert a Seleucid date, specified by year, month, and day,
to the Gregorian equivalent date. Uses hebrew calendar conversion
logic with :attr:`SELEUCID_OFFSET`. Returns a tuple of year, month, day.
"""
return super().to_gregorian(year + self.SELEUCID_OFFSET, month, day)
21 changes: 21 additions & 0 deletions src/undate/date.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,27 @@ def day(self) -> Optional[int]:
return int(str(self.astype("datetime64[D]")).split("-")[-1])
return None

@property
def weekday(self) -> Optional[int]:
"""Equivalent to :meth:`datetime.date.weedkay`; returns day of week as an
integer where Monday is 0 and Sunday is 6. Only supported for dates
with date unit in days.
"""
# only return a weekday if date unit is in days
if self.dtype == "datetime64[D]":
# calculate based on difference between current day and week start
# numpy datetime weeks start on thursdays - presumably since
# unix epoch day zero was a thursday...

# implementation inspired in part by https://stackoverflow.com/a/54264187

thursday_week = self.astype("datetime64[W]")
days_from_thursday = (self - thursday_week).astype(int)
# if monday is 0, thursday is 3
return (days_from_thursday + 3) % 7

return None

def __sub__(self, other):
# modify to conditionally return a timedelta object instead of a
# Date object with dtype timedelta64[D] (default behavior)
Expand Down
24 changes: 22 additions & 2 deletions src/undate/undate.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class Calendar(StrEnum):
GREGORIAN = auto()
HEBREW = auto()
ISLAMIC = auto()
SELEUCID = auto()

@staticmethod
def get_converter(calendar):
Expand Down Expand Up @@ -96,7 +97,6 @@ def __init__(
if calendar is not None:
self.set_calendar(calendar)
self.calendar_converter = Calendar.get_converter(self.calendar)

self.calculate_earliest_latest(year, month, day)

if converter is None:
Expand Down Expand Up @@ -192,6 +192,9 @@ def calculate_earliest_latest(self, year, month, day):
)

def set_calendar(self, calendar: Union[str, Calendar]):
"""Find calendar by name if passed as string and set on the object.
Only intended for use at initialization time; use :meth:`as_calendar`
to change calendar."""
if calendar is not None:
# if not passed as a Calendar instance, do a lookup
if not isinstance(calendar, Calendar):
Expand All @@ -202,6 +205,19 @@ def set_calendar(self, calendar: Union[str, Calendar]):
raise ValueError(f"Calendar `{calendar}` is not supported") from err
self.calendar = calendar

def as_calendar(self, calendar: Union[str, Calendar]):
"""Return a new :class:`Undate` object with the same year, month, day, and labels
used to initialize the current object, but with a different calendar. Note that this
does NOT do calendar conversion, but reinterprets current numeric year, month, day values
according to the new calendar."""
return Undate(
year=self.initial_values.get("year"),
month=self.initial_values.get("month"),
day=self.initial_values.get("day"),
label=self.label,
calendar=calendar,
)

def __str__(self) -> str:
# if any portion of the date is partially known, construct
# pseudo ISO8601 format here, since ISO8601 doesn't support unknown digits
Expand Down Expand Up @@ -319,8 +335,12 @@ def __lt__(self, other: object) -> bool:
# (e.g., single date within the same year)
# comparison for those cases is not currently supported
elif other in self or self in other:
# sort by precision, most precise first
by_precision = sorted(
[self, other], key=lambda x: x.precision, reverse=True
)
raise NotImplementedError(
"Can't compare when one date falls within the other"
f"Can't compare when one date ({by_precision[0]}) falls within the other ({by_precision[1]})"
)
# NOTE: unsupported comparisons are supposed to return NotImplemented
# However, doing that in this case results in a confusing TypeError!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ def test_hebrew_undate():
("5362", HebrewUndate(5362), DatePrecision.YEAR),
# add when we support parsing ranges:
# Adar I and Adar II 5453 : (1693 CE)
# support weekdays included in text
("Thursday, 12 Sivan 4795", HebrewUndate(4795, 3, 12), DatePrecision.DAY),
# with or without comma
("Thursday 12 Sivan 4795", HebrewUndate(4795, 3, 12), DatePrecision.DAY),
# huh, current parsing completely ignores whitespace; do we want that?
("Thursday12Sivan4795", HebrewUndate(4795, 3, 12), DatePrecision.DAY),
]


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def test_islamic_undate():
# examples from ISMI data (reformatted to day month year)
# Rabi 1 = month 3
("14 Rabīʿ I 901", IslamicUndate(901, 3, 14), DatePrecision.DAY),
("Rabīʿ I 490", IslamicUndate(490, 3), DatePrecision.MONTH),
("884", IslamicUndate(884), DatePrecision.YEAR),
# Gregorian: UndateInterval(Undate(1479, 4, 3), Undate(1480, 3, 21)),
# add when we support parsing ranges:
Expand Down
22 changes: 22 additions & 0 deletions tests/test_date.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import datetime

import numpy as np
from undate.date import ONE_YEAR, Date, DatePrecision, Timedelta

Expand Down Expand Up @@ -51,6 +53,26 @@ def test_properties_day(self):
assert Date(2010, 5).day is None
assert Date(2021, 6, 15).day == 15

def test_weekday(self):
# thursday
assert Date(2025, 1, 2).weekday == 3
assert Date(2025, 1, 2).weekday == datetime.date(2025, 1, 2).weekday()
# friday
assert Date(2025, 1, 3).weekday == 4
assert Date(2025, 1, 3).weekday == datetime.date(2025, 1, 3).weekday()
# saturday
assert Date(2025, 1, 4).weekday == 5
assert Date(2025, 1, 4).weekday == datetime.date(2025, 1, 4).weekday()
# sunday
assert Date(2025, 1, 5).weekday == 6
assert Date(2025, 1, 5).weekday == datetime.date(2025, 1, 5).weekday()
# monday
assert Date(2025, 1, 6).weekday == 0
assert Date(2025, 1, 6).weekday == datetime.date(2025, 1, 6).weekday()
# tuesday
assert Date(2025, 1, 7).weekday == 1
assert Date(2025, 1, 7).weekday == datetime.date(2025, 1, 7).weekday()

def test_substract(self):
# date - date = timedelta
date_difference = Date(2024, 1, 2) - Date(2024, 1, 1)
Expand Down
10 changes: 8 additions & 2 deletions tests/test_undate.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,11 +298,17 @@ def test_lt_notimplemented(self):
# how to compare mixed precision where dates overlap?
# if the second date falls *within* earliest/latest,
# then it is not clearly less; not implemented?
with pytest.raises(NotImplementedError, match="date falls within the other"):
with pytest.raises(
NotImplementedError,
match="one date \\(2022-05\\) falls within the other \\(2022\\)",
):
assert Undate(2022) < Undate(2022, 5)

# same if we attempt to compare in the other direction
with pytest.raises(NotImplementedError, match="date falls within the other"):
with pytest.raises(
NotImplementedError,
match="one date \\(2022-05\\) falls within the other \\(2022\\)",
):
assert Undate(2022, 5) < Undate(2022)

testdata_contains = [
Expand Down
Loading