Update HolidayBase::get_closest_holiday method#3651
Conversation
When get_closest_holiday needed to look into an adjacent year it called _populate(next_year/previous_year) but never added that year to self.years. This left the object in an inconsistent state: the populated data was in the dict but years did not reflect it. As a result, every subsequent call to get_closest_holiday for the same date range would re-invoke _populate unnecessarily. Add the target year to self.years before calling _populate, matching the behaviour of __keytransform__ (the normal expansion path).
Walkthrough
ChangesClosest holiday year cache update
Estimated code review effort: 2 (Simple) | ~10 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## dev #3651 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 317 317
Lines 19007 19009 +2
Branches 2430 2430
=========================================
+ Hits 19007 19009 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| if direction == "forward" and (next_year := dt.year + 1) not in self.years: | ||
| self.years.add(next_year) | ||
| self._populate(next_year) | ||
| elif direction == "backward" and (previous_year := dt.year - 1) not in self.years: | ||
| self.years.add(previous_year) | ||
| self._populate(previous_year) |
There was a problem hiding this comment.
I think, in general, it would be more accurate here to consider the value of self.expand rather than simply adding an extra year.
eeshsaxena
left a comment
There was a problem hiding this comment.
Nice catch - this is a real inconsistency. I pulled the branch and confirmed the behavior on dev before the fix:
US(years=2024, expand=False)thenget_closest_holiday(date(2024, 12, 31), "forward")leavesself.years == {2024}even though2025's holidays are already in the dict (any(d.year == 2025 for d in us.keys())isTrue). So the object really is in a state where the data andself.yearsdisagree. Same thing in the"backward"direction with2023.
A couple of things I checked that might be useful for the review:
- Completeness: these two call sites look like the only
_populate()calls that load a year which isn't already inself.yearswithout recording it. The other callers (__init__, the re-populate loop afterclear()) iterate overself.years, and__keytransform__already doesself.years.add(...)right before_populate(...). So the fix covers both affected branches. - Ordering: adding the year before calling
_populate()is safe here -_populate()doesn't consultself.years, and this matches the existing__keytransform__pattern (self.years.add(dt.year); self._populate(dt.year)). - One thing I was initially worried about but which turned out fine: without the fix, a repeated
get_closest_holidaycall re-runs_populate()for the adjacent year (since it's still missing fromself.years), and_populatedocuments that it "assumes that no holidays for the year have already been populated". In practice__setitem__dedupes identical names, so it doesn't corrupt the data - but the fix does avoid that redundant re-population, which is a nice side benefit worth keeping in the description.
One small, optional thought (very much defer to the maintainers): the self.years.add(year); self._populate(year) pair now appears in three places, and this bug came from a _populate() call that was missing its paired add. A tiny helper that does both could keep them in sync and make future call sites harder to get wrong - but that's a maintainability nicety beyond the scope of this fix.
Test looks good and exercises both directions. Thanks for tracking this down!
eeshsaxena
left a comment
There was a problem hiding this comment.
Nice catch - this looks correct to me, and it lines up with how the rest of the class manages self.years.
The convincing bit is that the fix mirrors the canonical expand path in __keytransform__:
if self.expand and dt.year not in self.years:
self.years.add(dt.year)
self._populate(dt.year)i.e. the established contract is that the caller adds the year to self.years before calling _populate (_populate itself only generates holidays and never touches self.years). get_closest_holiday was the one place calling _populate without first doing the self.years.add(...), so the object was left inconsistent exactly as described. Adding the year first - in the same order as __keytransform__ - is the right fix.
I applied the patch on top of dev and ran the get_closest_holiday tests: all pass, including the new test_get_closest_holiday_updates_years covering both the forward (+1) and backward (-1) branches. Good that both directions are exercised.
One tiny thing you might optionally note (not a blocker, and it matches existing behaviour): if the adjacent year falls outside [start_year, end_year], _populate early-returns, so the year gets added to self.years with no data - but that's already exactly what the __keytransform__ expand path does too, so this stays consistent with the current contract.
LGTM from me - deferring to the maintainers for the merge decision.
|
Thanks for verifying both directions. On the optional point: the out-of- |
sanmaxdev
left a comment
There was a problem hiding this comment.
Verified the fix locally. All 7 TestClosestHoliday tests pass, including the new regression test.
The inconsistency is real: before this PR, _populate(next_year) was called but the year was never added to self.years, so repeated calls to get_closest_holiday would re-populate the same year every time.
Regarding the expand point: the canonical pattern in __keytransform__ (line 650) gates both self.years.add() and self._populate() on self.expand. However, get_closest_holiday already calls _populate() unconditionally (the pre-existing code did not check self.expand). So the method intentionally crosses year boundaries regardless of expand.
If the maintainer prefers keeping self.years fixed when expand=False, a middle ground would be to guard only the self.years.add() calls on self.expand, accepting redundant _populate() calls for the expand=False case. If get_closest_holiday is expected to work across year boundaries regardless of expand (as the pre-existing code suggests), the current approach of always tracking populated years is the right one.
Yes, I think we missed this point when adding this method, and we need to fix it. If someone sets |
|
Agreed — pushed 33d2910. The adjacent-year population is now gated on |
|
| self._populate(next_year) | ||
| elif direction == "backward" and (previous_year := dt.year - 1) not in self.years: | ||
| self._populate(previous_year) | ||
| # Automatically expand for `expand=True` cases only, like `__keytransform__`. |
There was a problem hiding this comment.
| # Automatically expand for `expand=True` cases only, like `__keytransform__`. |
| # Searching forward should add the next year to self.years so that | ||
| # repeated calls do not re-populate the adjacent year. |
There was a problem hiding this comment.
| # Searching forward should add the next year to self.years so that | |
| # repeated calls do not re-populate the adjacent year. |
| # Searching backward should add the previous year to self.years. | ||
| us2 = US(years=2024) | ||
| self.assertEqual(us2.years, {2024}) | ||
|
|
||
| us2.get_closest_holiday(date(2024, 1, 1), "backward") | ||
| self.assertIn(2023, us2.years) |
There was a problem hiding this comment.
| # Searching backward should add the previous year to self.years. | |
| us2 = US(years=2024) | |
| self.assertEqual(us2.years, {2024}) | |
| us2.get_closest_holiday(date(2024, 1, 1), "backward") | |
| self.assertIn(2023, us2.years) | |
| us_2 = US(years=2024) | |
| self.assertEqual(us_2.years, {2024}) | |
| us_2.get_closest_holiday(date(2024, 1, 1), "backward") | |
| self.assertIn(2023, us_2.years) |
| self.assertIn(2023, us2.years) | ||
|
|
||
| def test_get_closest_holiday_expand_false_keeps_state(self): | ||
| # With expand=False the object must not change state while accessed. |
There was a problem hiding this comment.
| # With expand=False the object must not change state while accessed. |
HolidayBase::get_closest_holiday method



What
get_closest_holidaylooks into the adjacent year when the search would cross a year boundary. It called_populate(next_year)/_populate(previous_year)but never added the year toself.years. This left the object in an inconsistent state:self.yearsreported only the originally loaded years even though the adjacent year's data was already in the dict.get_closest_holidayfor the same date range saw the adjacent year still missing fromself.years, so it called_populateagain unnecessarily.Reproduction
Fix
Add
self.years.add(next_year)/self.years.add(previous_year)before the corresponding_populatecalls, matching the pattern used in__keytransform__(the normal expansion path).Tests
Added
test_get_closest_holiday_updates_yearstoTestClosestHolidayasserting thatself.yearsis updated in both the forward and backward directions.This pull request was prepared with the assistance of AI, under my direction and review.