-
Notifications
You must be signed in to change notification settings - Fork 3
Add grammar & converter for Christian holidays #162
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
Changes from all commits
5fd7505
b31a1ed
0d58c94
5168ba5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| %import common.WS | ||
| %ignore WS | ||
|
|
||
| %import .undate_common.DATE_PUNCTUATION | ||
| %ignore DATE_PUNCTUATION | ||
|
|
||
|
|
||
| holiday_date: movable_feast year | fixed_date year? | ||
|
|
||
| // holidays that shift depending on the year | ||
| movable_feast: EASTER | EASTER_MONDAY | HOLY_SATURDAY | ASCENSION | ||
| | PENTECOST | WHIT_MONDAY | TRINITY | ASH_WEDNESDAY | SHROVE_TUESDAY | ||
|
|
||
| // holidays that are always on the same date | ||
| fixed_date: EPIPHANY | CANDLEMAS | ST_PATRICKS | ALL_FOOLS | ST_CYPRIANS | ||
|
|
||
| year: /\d{4}/ | ||
|
|
||
| // all patterns use case-insensitive regex | ||
|
|
||
| // Fixed-date holidays | ||
| EPIPHANY: /epiphany/i | ||
| CANDLEMAS: /candlemass?/i // recognize with both one and 2 s | ||
| ST_PATRICKS: /st\.?\s*patrick'?s?\s*day/i | ||
| ALL_FOOLS: /(april|all)\s*fools?\s*day/i | ||
| ST_CYPRIANS: /st\.?\s*cyprian'?s?\s*day/i | ||
|
|
||
| // Moveable feasts | ||
| EASTER: /easter/i | ||
| EASTER_MONDAY: /easter\s*monday/i | ||
| HOLY_SATURDAY: /holy\s*saturday/i | ||
| ASCENSION: /ascension\s*day|ascension/i | ||
| PENTECOST: /pentecost/i | ||
| WHIT_MONDAY: /whit\s*monday|whitsun\s*monday/i | ||
| TRINITY: /trinity\s*sunday|trinity/i | ||
| ASH_WEDNESDAY: /ash\s*wednesday/i | ||
| SHROVE_TUESDAY: /shrove\s*tuesday/i | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,166 @@ | ||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||
| Holiday date Converter: parse Christian liturgical dates and convert to Gregorian. | ||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| import datetime | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| from lark import Lark, Transformer, Tree, Token | ||||||||||||||||||||||||||||||
| from lark.exceptions import UnexpectedInput | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| from convertdate import holidays # type: ignore[import-untyped] | ||||||||||||||||||||||||||||||
| from undate import Undate, Calendar | ||||||||||||||||||||||||||||||
| from undate.converters.base import BaseDateConverter, GRAMMAR_FILE_PATH | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| # To add a new holiday: | ||||||||||||||||||||||||||||||
| # 1. Add a name and pattern to holidays.lark grammar file | ||||||||||||||||||||||||||||||
| # 2. Include the in appropriate section (fixed or movable) | ||||||||||||||||||||||||||||||
| # 3. Add an entry to FIXED_HOLIDAYS or MOVABLE_FEASTS; must match grammar terminal name | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| # holidays that fall on the same date every year | ||||||||||||||||||||||||||||||
| # key must match grammar term; value is tuple of numeric month, day | ||||||||||||||||||||||||||||||
| FIXED_HOLIDAYS = { | ||||||||||||||||||||||||||||||
| "EPIPHANY": (1, 6), # January 6 | ||||||||||||||||||||||||||||||
| "CANDLEMAS": (2, 2), # February 2; 40th day & end of epiphany | ||||||||||||||||||||||||||||||
| "ST_PATRICKS": (3, 17), # March 17 | ||||||||||||||||||||||||||||||
| "ALL_FOOLS": (4, 1), # All / April fools day, April 1 | ||||||||||||||||||||||||||||||
| "ST_CYPRIANS": (9, 16), # St. Cyprian's Feast day: September 16 | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Comment on lines
+22
to
+28
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Typo: "CANDLEMASS" should be "CANDLEMAS". The feast is spelled Candlemas (single "s"). This terminal name is likely mirrored in ✏️ Proposed fix FIXED_HOLIDAYS = {
"EPIPHANY": (1, 6), # January 6
- "CANDLEMASS": (2, 2), # February 2; 40th day & end of epiphany
+ "CANDLEMAS": (2, 2), # February 2; 40th day & end of epiphany
"ST_PATRICKS": (3, 17), # March 17📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| # holidays that shift depending on the year; value is days relative to Easter | ||||||||||||||||||||||||||||||
| MOVABLE_FEASTS = { | ||||||||||||||||||||||||||||||
| "EASTER": 0, # Easter, no offset | ||||||||||||||||||||||||||||||
| "HOLY_SATURDAY": -1, # day before Easter | ||||||||||||||||||||||||||||||
| "EASTER_MONDAY": 1, # day after Easter | ||||||||||||||||||||||||||||||
| "ASCENSION": 39, # fortieth day of Easter | ||||||||||||||||||||||||||||||
| "PENTECOST": 49, # 7 weeks after Easter | ||||||||||||||||||||||||||||||
| "WHIT_MONDAY": 50, # Monday after Pentecost | ||||||||||||||||||||||||||||||
| "TRINITY": 56, # first Sunday after Pentecost | ||||||||||||||||||||||||||||||
| "ASH_WEDNESDAY": -46, # Wednesday of the 7th week before Easter | ||||||||||||||||||||||||||||||
| "SHROVE_TUESDAY": -47, # day before Ash Wednesday | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| parser = Lark.open( | ||||||||||||||||||||||||||||||
| str(GRAMMAR_FILE_PATH / "holidays.lark"), rel_to=__file__, start="holiday_date" | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| class HolidayTransformer(Transformer): | ||||||||||||||||||||||||||||||
| calendar = Calendar.GREGORIAN | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def year(self, items): | ||||||||||||||||||||||||||||||
| value = "".join([str(i) for i in items]) | ||||||||||||||||||||||||||||||
| return Token("year", value) | ||||||||||||||||||||||||||||||
| # return Tree(data="year", children=[value]) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def movable_feast(self, items): | ||||||||||||||||||||||||||||||
| # movable feast day can't be calculated without the year, | ||||||||||||||||||||||||||||||
| # so pass through | ||||||||||||||||||||||||||||||
| return items[0] | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def fixed_date(self, items): | ||||||||||||||||||||||||||||||
| item = items[0] | ||||||||||||||||||||||||||||||
| # type is prefixed when included in the combined parser; | ||||||||||||||||||||||||||||||
| # we need the second portion | ||||||||||||||||||||||||||||||
| holiday_name = item.type.split("__")[-1] | ||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||
| month, day = FIXED_HOLIDAYS[holiday_name] | ||||||||||||||||||||||||||||||
| except KeyError: | ||||||||||||||||||||||||||||||
| raise ValueError(f"Unknown fixed holiday {holiday_name}") | ||||||||||||||||||||||||||||||
| return Tree("fixed_date", [Token("month", month), Token("day", day)]) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def holiday_date(self, items): | ||||||||||||||||||||||||||||||
| parts = self._get_date_parts(items) | ||||||||||||||||||||||||||||||
| return Undate(**parts) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def _get_date_parts(self, items) -> dict[str, int | str]: | ||||||||||||||||||||||||||||||
| # recursive method to take parsed tokens and trees and generate | ||||||||||||||||||||||||||||||
| # a dictionary of year, month, day for initializing an undate object | ||||||||||||||||||||||||||||||
| # handles nested tree with month/day (for fixed date holidays) | ||||||||||||||||||||||||||||||
| # and includes movable feast logic, after year is determined. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| parts = {} | ||||||||||||||||||||||||||||||
| date_parts = ["year", "month", "day"] | ||||||||||||||||||||||||||||||
| movable_feast = None | ||||||||||||||||||||||||||||||
| for child in items: | ||||||||||||||||||||||||||||||
| field = value = None | ||||||||||||||||||||||||||||||
| # if this is a token, get type and value | ||||||||||||||||||||||||||||||
| if isinstance(child, Token): | ||||||||||||||||||||||||||||||
| # month/day from fixed date holiday | ||||||||||||||||||||||||||||||
| if child.type in date_parts: | ||||||||||||||||||||||||||||||
| field = child.type | ||||||||||||||||||||||||||||||
| value = child.value | ||||||||||||||||||||||||||||||
| # check for movable feast terminal | ||||||||||||||||||||||||||||||
| elif child.type in MOVABLE_FEASTS: | ||||||||||||||||||||||||||||||
| # collect but don't handle until we know the year | ||||||||||||||||||||||||||||||
| movable_feast = child.type | ||||||||||||||||||||||||||||||
| # handle namespaced token type; happens when called from combined grammar | ||||||||||||||||||||||||||||||
| elif ( | ||||||||||||||||||||||||||||||
| "__" in child.type and child.type.split("__")[-1] in MOVABLE_FEASTS | ||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||
| # collect but don't handle until we know the year | ||||||||||||||||||||||||||||||
| movable_feast = child.type.split("__")[-1] | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| # if a tree, recurse on children to get date parts | ||||||||||||||||||||||||||||||
| if isinstance(child, Tree) and child.children: | ||||||||||||||||||||||||||||||
| parts.update(self._get_date_parts(child.children)) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| # if date fields were found, add to dictionary | ||||||||||||||||||||||||||||||
| if field and value: | ||||||||||||||||||||||||||||||
| # currently all date parts are integer only | ||||||||||||||||||||||||||||||
| parts[str(field)] = int(value) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| # if date is a movable feast, calculate relative to Easter based on the year | ||||||||||||||||||||||||||||||
| if movable_feast is not None: | ||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||
| year = parts["year"] | ||||||||||||||||||||||||||||||
| except KeyError: | ||||||||||||||||||||||||||||||
| raise ValueError("Year is required for movable feasts") | ||||||||||||||||||||||||||||||
| offset = MOVABLE_FEASTS[movable_feast] | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| holiday_date = datetime.date(*holidays.easter(year)) + datetime.timedelta( | ||||||||||||||||||||||||||||||
| days=offset | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| parts.update({"month": holiday_date.month, "day": holiday_date.day}) | ||||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return parts | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| class HolidayDateConverter(BaseDateConverter): | ||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||
| Converter for Christian liturgical dates. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Supports fixed-date holidays (Epiphany, Candlemass, etc.) and | ||||||||||||||||||||||||||||||
| Easter-relative movable feasts (Easter, Ash Wednesday, Pentecost, etc.). | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Example usage:: | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Undate.parse("Easter 1942", "holidays") | ||||||||||||||||||||||||||||||
| Undate.parse("Ash Wednesday 1942", "holidays") | ||||||||||||||||||||||||||||||
| Undate.parse("Epiphany", "holidays") | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Does not support serialization. | ||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| name = "holidays" | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def __init__(self): | ||||||||||||||||||||||||||||||
| self.transformer = HolidayTransformer() | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def parse(self, value: str) -> Undate: | ||||||||||||||||||||||||||||||
| if not value: | ||||||||||||||||||||||||||||||
| raise ValueError("Parsing empty string is not supported") | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||
| parsetree = parser.parse(value) | ||||||||||||||||||||||||||||||
| # transform the parse tree into an undate or undate interval | ||||||||||||||||||||||||||||||
| undate_obj = self.transformer.transform(parsetree) | ||||||||||||||||||||||||||||||
| # set the input holiday text as a label on the undate object | ||||||||||||||||||||||||||||||
| undate_obj.label = value | ||||||||||||||||||||||||||||||
| return undate_obj | ||||||||||||||||||||||||||||||
| except UnexpectedInput as err: | ||||||||||||||||||||||||||||||
| raise ValueError(f"Could not parse '{value}' as a holiday date") from err | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def to_string(self, undate: Undate) -> str: | ||||||||||||||||||||||||||||||
| raise ValueError("Holiday converter does not support serialization") | ||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: dh-tech/undate-python
Length of output: 113
🏁 Script executed:
# Find and examine the holidays.py file fd -t f holidays.pyRepository: dh-tech/undate-python
Length of output: 137
🏁 Script executed:
Repository: dh-tech/undate-python
Length of output: 104
🏁 Script executed:
Repository: dh-tech/undate-python
Length of output: 47
🏁 Script executed:
cat -n src/undate/converters/holidays.py | head -150Repository: dh-tech/undate-python
Length of output: 6947
🏁 Script executed:
Repository: dh-tech/undate-python
Length of output: 188
🏁 Script executed:
Repository: dh-tech/undate-python
Length of output: 79
🏁 Script executed:
Repository: dh-tech/undate-python
Length of output: 279
🏁 Script executed:
Repository: dh-tech/undate-python
Length of output: 1407
Reject
0000before it reaches the movable-feast calculation.The grammar currently allows
year: /\d{4}/to match0000, but the movable-feast resolver at line 127 usesdatetime.date(*holidays.easter(parts["year"])), which only supports years 1–9999. Parsing inputs likeEaster 0000will therefore result in aValueErrorat transformation time instead of a normal parse failure.Grammar fix
📝 Committable suggestion
🤖 Prompt for AI Agents