Skip to content

v0.1.1 #2

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

Merged
merged 5 commits into from
May 4, 2025
Merged

v0.1.1 #2

merged 5 commits into from
May 4, 2025

Conversation

R5dan
Copy link
Contributor

@R5dan R5dan commented May 4, 2025

Summary by CodeRabbit

  • New Features

    • Introduced a caching system to improve performance when retrieving day information.
    • Added new utility functions and typing enhancements for greater flexibility and code clarity.
    • Added a unified function to retrieve market classes by name.
  • Improvements

    • Enhanced holiday and trading day logic for more accurate and consistent results.
    • Required explicit naming for all partial trading days for clearer identification.
    • Updated logic for determining Good Friday using a more robust data source and error handling.
    • Simplified and unified market day type checks and lazy initialization of weekend data.
  • Documentation

    • Expanded README with usage examples and a new contributing section.
  • Chores

    • Added the better-md package as a new dependency.

Copy link

coderabbitai bot commented May 4, 2025

Walkthrough

This update introduces several structural and functional changes across the codebase. The get_market function is moved from a local definition in the package root to a dedicated module .multi. The Holiday and PartialTradingDay classes now require a name argument on instantiation. A new Cache class is added to manage date-to-day mappings with get/set/pop/clear methods. The Market class is refactored to use centralized cache logic and simplified day-type checks relying on instance type. The GoodFriday holiday logic is rewritten to use a new data source with regex parsing and enhanced error handling. New utility modules for typing and general helpers are introduced. The README is expanded with usage examples and contribution guidelines. A new dependency, better-md, is added.

Changes

File(s) Change Summary
BetterHolidays/init.py Removed local get_market function; now imports it from .multi.
BetterHolidays/days.py Made name a required positional argument for Holiday and PartialTradingDay.
BetterHolidays/markets/cache.py Added new Cache class for managing cached Day objects keyed by date with get/set/pop/clear methods and membership support.
BetterHolidays/markets/holidays.py Rewrote GoodFriday.get_date to use a new data source (calendar-365.co.uk), regex parsing, and detailed error handling; added a compiled regex attribute.
BetterHolidays/markets/market.py Refactored to import utilities; centralized cache access via day method; simplified day-type checks using instance checks; introduced lazy _weekends attribute.
BetterHolidays/markets/nyse.py Added explicit name attributes to all PartialTradingDay instances in abnormal_days; adjusted some open/close times and flags; ensured dynamic partial days set name correctly.
BetterHolidays/typing.py Introduced typing utilities: generic type variables T, T1 and a ClassMethod callable alias.
BetterHolidays/utils.py Added utility constructs: sentinel NOT_SET, method_cache decorator, classproperty descriptor, abstract_const singleton factory, and iterate_date generator.
README.md Added detailed usage examples demonstrating market instantiation and method calls; added a "Contributing" section.
requirements.txt Added new dependency better-md>=0.3.4.
BetterHolidays/multi.py Added standalone get_market function with overloads to retrieve a market class by name with optional default.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant BetterHolidays
    participant multi
    participant Market
    participant Cache

    User->>BetterHolidays: import get_market
    BetterHolidays->>multi: get_market(name)
    multi-->>BetterHolidays: Market class
    BetterHolidays-->>User: Market class

    User->>Market: is_holiday(date)
    Market->>Cache: get_or_set(date, fetch_data)
    Cache-->>Market: Day instance
    Market-->>User: bool (is holiday)

    User->>Market: get_holidays(start, end)
    loop for each day in range
        Market->>Cache: get_or_set(day, fetch_data)
        Cache-->>Market: Day instance
    end
    Market-->>User: list of Holiday
Loading

Possibly related PRs

  • Better-Python/Better-Holidays#1: Changes the import and definition of get_market in BetterHolidays/__init__.py, directly relating to the current PR's refactor of this function.

Poem

In the warren, code was cached anew,
Holidays named, and Good Friday, too.
Utilities gathered, type hints in tow,
A README that helps new bunnies to grow.
With better-md sprinkled, dependencies set—
This hop in the code is the best one yet!
🐇✨


📜 Recent review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 278e07d and e6f0dc9.

📒 Files selected for processing (3)
  • BetterHolidays/__init__.py (1 hunks)
  • BetterHolidays/multi.py (1 hunks)
  • BetterHolidays/utils.py (1 hunks)
✅ Files skipped from review due to trivial changes (2)
  • BetterHolidays/multi.py
  • BetterHolidays/utils.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • BetterHolidays/init.py
✨ Finishing Touches
  • 📝 Generate Docstrings

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🧹 Nitpick comments (14)
BetterHolidays/typing.py (1)

1-6: Looks good, but consider renaming ClassMethod

The type definitions are clear and well-structured. However, the name ClassMethod could be misleading since it doesn't actually represent Python's class methods (which are decorated with @classmethod). It's a generic callable that takes one parameter and returns a value.

Consider renaming this to something more descriptive like SingleParamFunction or SimpleCallable to better reflect its purpose:

-ClassMethod = t.Callable[[T], T1]
+SingleParamFunction = t.Callable[[T], T1]
BetterHolidays/markets/cache.py (2)

6-7: Duplicate type variable definition

The type variable T is already defined in the typing.py module, which could be imported instead of redefined here.

Consider importing T from the typing module:

import datetime as dt
import typing as t
from ..days import Day
from ..utils import NOT_SET
+from ..typing import T

-T = t.TypeVar("T")

8-11: Add class-level documentation

The Cache class lacks documentation explaining its purpose and usage pattern.

Add a class docstring:

class Cache:
+    """A cache for Day objects keyed by date.
+    
+    This class provides methods to manage a dictionary-based cache that maps
+    datetime.date objects to Day instances, supporting efficient date-to-day lookups.
+    """
    def __init__(self):
        self.cache: 'dict[dt.date, Day]' = {}
README.md (2)

13-26: Enhance code example with expected outputs

The code example is clear and demonstrates the main functionality well. However, it would be more educational to include the expected outputs of each print statement.

Consider adding comments showing expected outputs:

import BetterHolidays as bh
import datetime as dt

NYSE = bh.get_market("NYSE")
# or
NYSE = bh.NYSE()
print(NYSE.is_holiday(dt.date(1979, 4, 13)))
+# Output: True  # Good Friday 1979
print(NYSE.is_trading_day(dt.date(1979, 4, 13)))
+# Output: False
print(NYSE.is_partial_day(dt.date(1979, 4, 13)))
+# Output: False
print(NYSE.is_weekday(dt.date(1979, 4, 13)))
+# Output: True
print(NYSE.is_weekend(dt.date(1979, 4, 13)))
+# Output: False
print(NYSE.get_holidays(dt.date(1979, 4, 1), dt.date(1979, 4, 30)))
+# Output: [Holiday(1979-04-13, 'Good Friday')]

28-30: Expand the Contributing section

The current Contributing section is minimal and could benefit from more detailed guidelines.

Consider expanding the Contributing section:

## Contributing

-Pull requests are welcome.
+Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
+
+Please make sure to update tests as appropriate.
+
+### Development Setup
+
+```bash
+# Clone the repository
+git clone https://github.com/yourusername/better-holidays.git
+cd better-holidays
+
+# Install development dependencies
+pip install -e ".[dev]"
+
+# Run tests
+pytest
+```
BetterHolidays/markets/nyse.py (1)

60-60: Remove stray comment character.

There's a stray # character at the end of the line that should be removed.

-    dt.date(2006, 12, 27): PartialTradingDay(name="President Gerald Ford's death", date=dt.date(2006, 12, 27), open_time=dt.time(hour=9, minute=32), close_time=standard_close_time, late_open=True, late_open_reason="Moment of silence for President Gerald Ford's death"),#
+    dt.date(2006, 12, 27): PartialTradingDay(name="President Gerald Ford's death", date=dt.date(2006, 12, 27), open_time=dt.time(hour=9, minute=32), close_time=standard_close_time, late_open=True, late_open_reason="Moment of silence for President Gerald Ford's death"),
BetterHolidays/markets/holidays.py (1)

129-129: Fix unnecessary f-string.

This string doesn't contain any format variables, so the f prefix is unnecessary.

-               elements = html.inner_html.advanced_find("a", attrs={"href": f"https://www.calendar-365.co.uk/holidays/good-friday.html", "class": "link_arrow", "title": "Good Friday 2026", "text": "Good Friday"})
+               elements = html.inner_html.advanced_find("a", attrs={"href": "https://www.calendar-365.co.uk/holidays/good-friday.html", "class": "link_arrow", "title": "Good Friday 2026", "text": "Good Friday"})
🧰 Tools
🪛 Ruff (0.8.2)

129-129: f-string without any placeholders

Remove extraneous f prefix

(F541)

BetterHolidays/utils.py (2)

23-30: Use identity comparison for the NOT_SET sentinel

NOT_SET is a unique sentinel object. Comparing it with == works because type objects fall back to identity comparison, but it is clearer (and marginally faster) to use is/is not. This eliminates any risk if == were ever overloaded on a different sentinel implementation.

-    if default == NOT_SET:
+    if default is NOT_SET:

68-72: Minor: name the generator parameter for clarity

A tiny readability win – current_date better communicates the loop variable.

-    current = start
-    while current <= end:
-        yield current
-        current += dt.timedelta(days=1)
+    current_date = start
+    while current_date <= end:
+        yield current_date
+        current_date += dt.timedelta(days=1)
BetterHolidays/markets/market.py (5)

21-27: validate_options never runs – call it in __init_subclass__

You defined robust validation but never invoke it. Running it when a subclass
is created gives immediate feedback to library users.

-    def __init_subclass__(cls) -> None:
-        cls.cache = Cache()
+    def __init_subclass__(cls) -> None:
+        cls.cache = Cache()
+        cls.validate_options()

57-69: Repeated date iteration is O(N) – consider year-level pre-filtering

get_* helpers iterate every single date between start and end, then look
each day up in the cache. For multi-year ranges this becomes expensive
(~365 × years look-ups).

If the underlying fetch_data already loads all days for a given year, expose
a year-range API instead and filter in memory once per year.


71-97: Simplify is_* helpers – return the boolean expression directly

Ruff already flagged this. Returning the expression shortens the code and
avoids an unnecessary variable.

-    def is_holiday(cls, date: 'dt.date') -> bool:
-        day = cls.day(date)
-        if not isinstance(day, Holiday):
-            return False
-        return True
+    def is_holiday(cls, date: 'dt.date') -> bool:
+        return isinstance(cls.day(date), Holiday)

-    def is_partial_day(cls, date: 'dt.date') -> 'bool':
-        day = cls.day(date)
-        if not isinstance(day, PartialTradingDay):
-            return False
-        return True
+    def is_partial_day(cls, date: 'dt.date') -> bool:
+        return isinstance(cls.day(date), PartialTradingDay)

-    def is_trading_day(cls, date: 'dt.date') -> 'bool':
-        day = cls.day(date)
-        if not isinstance(day, TradingDay):
-            return False
-        return True
+    def is_trading_day(cls, date: 'dt.date') -> 'bool':
+        return isinstance(cls.day(date), TradingDay)
🧰 Tools
🪛 Ruff (0.8.2)

81-83: Return the condition isinstance(day, Holiday) directly

Replace with return isinstance(day, Holiday)

(SIM103)


88-90: Return the condition isinstance(day, PartialTradingDay) directly

Replace with return isinstance(day, PartialTradingDay)

(SIM103)


95-97: Return the condition isinstance(day, TradingDay) directly

Replace with return isinstance(day, TradingDay)

(SIM103)


100-105: Return type is TradingDay | None – update the annotation

get_trading_day advertises t.Optional[TradingDay] but the code returns
None when the date is not a trading day. Good! Just a reminder that using
| syntax (TradingDay | None) would read better on Python ≥3.10.


107-108: Potential race condition in cache population

Cache.get_or_set calls cls.fetch_data when the year key is missing.
If two threads request dates from the same untouched year simultaneously,
both could invoke fetch_data, wasting work or causing duplicate writes.

Consider adding a per-year lock inside Cache.get_or_set or guarding the
fetch_data call in Market.day.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0a1226b and 6e8653f.

📒 Files selected for processing (10)
  • BetterHolidays/__init__.py (1 hunks)
  • BetterHolidays/days.py (2 hunks)
  • BetterHolidays/markets/cache.py (1 hunks)
  • BetterHolidays/markets/holidays.py (2 hunks)
  • BetterHolidays/markets/market.py (4 hunks)
  • BetterHolidays/markets/nyse.py (3 hunks)
  • BetterHolidays/typing.py (1 hunks)
  • BetterHolidays/utils.py (1 hunks)
  • README.md (1 hunks)
  • requirements.txt (1 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (4)
BetterHolidays/__init__.py (1)
BetterHolidays/utils.py (3)
  • get_market (18-18)
  • get_market (21-21)
  • get_market (23-30)
BetterHolidays/markets/nyse.py (1)
BetterHolidays/days.py (2)
  • PartialTradingDay (26-32)
  • Holiday (10-12)
BetterHolidays/markets/cache.py (1)
BetterHolidays/days.py (1)
  • Day (5-7)
BetterHolidays/markets/market.py (4)
BetterHolidays/utils.py (2)
  • iterate_date (68-72)
  • classproperty (41-62)
BetterHolidays/markets/cache.py (1)
  • Cache (8-42)
BetterHolidays/markets/nyse.py (1)
  • weekdays (92-93)
BetterHolidays/days.py (3)
  • Holiday (10-12)
  • PartialTradingDay (26-32)
  • TradingDay (15-18)
🪛 Ruff (0.8.2)
BetterHolidays/markets/holidays.py

129-129: f-string without any placeholders

Remove extraneous f prefix

(F541)


133-133: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)


139-139: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

BetterHolidays/markets/market.py

81-83: Return the condition isinstance(day, Holiday) directly

Replace with return isinstance(day, Holiday)

(SIM103)


88-90: Return the condition isinstance(day, PartialTradingDay) directly

Replace with return isinstance(day, PartialTradingDay)

(SIM103)


95-97: Return the condition isinstance(day, TradingDay) directly

Replace with return isinstance(day, TradingDay)

(SIM103)

🔇 Additional comments (8)
BetterHolidays/__init__.py (2)

3-3: Excellent refactoring: Moved get_market to a dedicated utility module.

Moving the get_market function to a centralized utility module follows good software design principles by reducing code duplication and improving maintainability.


14-14: __all__ list properly updated to include get_market.

The function is correctly exposed in the package's public API.

BetterHolidays/days.py (2)

12-12: Good change: Make name a required parameter for Holiday class.

Removing the default empty string value for the name attribute forces explicit naming of all holidays, which improves code clarity and prevents potential errors from unnamed holidays.


28-28: Good consistency: Explicitly declaring name in PartialTradingDay.

Although PartialTradingDay inherits from Holiday which already defines the name field, explicitly declaring it here makes the code more self-documenting and reinforces that this is a required attribute.

BetterHolidays/markets/nyse.py (1)

156-156: Good addition: Include name parameter in dynamically created PartialTradingDay instances.

Adding the name parameter to dynamically created PartialTradingDay instances aligns with the changes in the days.py module and provides better identification of partial trading days.

BetterHolidays/markets/holidays.py (2)

5-5: Good addition: Import re module for regex operations.

The import supports the new regex-based date parsing in the GoodFriday class.


118-118: Good practice: Compile regex pattern as class attribute.

Compiling the regex pattern as a class attribute is more efficient than compiling it each time the method is called.

BetterHolidays/markets/market.py (1)

42-45: Thread-safety of _weekends caching

_weekends is lazily initialised without any synchronisation. In a multi-threaded
context two threads could compute the list simultaneously, with one result lost.
If weekends is performance-critical, guard the assignment with a lock or
pre-compute at class creation time.

Comment on lines +18 to +24
def get_or_set(self, key: 'dt.date', func: 't.Callable[[int], None]') -> 'Day':
if key in self.cache:
return self.get(key)
func(key.year)
if key in self.cache:
return self.get(key)
raise ValueError("Cache miss")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Improve get_or_set signature and documentation

The get_or_set method has a potentially confusing type annotation. The function func is typed as t.Callable[[int], None], but it's expected to populate the cache as a side effect rather than returning a value. This implicit behavior isn't clear from the signature.

Add docstring and improve the type annotation:

-    def get_or_set(self, key: 'dt.date', func: 't.Callable[[int], None]') -> 'Day':
+    def get_or_set(self, key: 'dt.date', func: 't.Callable[[int], None]') -> 'Day':
+        """Get a Day from cache or populate cache by calling func with the year.
+        
+        Args:
+            key: The date to look up
+            func: A function that takes a year (int) and populates the cache as a side effect
+            
+        Returns:
+            The cached Day object
+            
+        Raises:
+            ValueError: If the cache still doesn't contain the key after calling func
+        """
         if key in self.cache:
             return self.get(key)
         func(key.year)
         if key in self.cache:
             return self.get(key)
         raise ValueError("Cache miss")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def get_or_set(self, key: 'dt.date', func: 't.Callable[[int], None]') -> 'Day':
if key in self.cache:
return self.get(key)
func(key.year)
if key in self.cache:
return self.get(key)
raise ValueError("Cache miss")
def get_or_set(self, key: 'dt.date', func: 't.Callable[[int], None]') -> 'Day':
"""Get a Day from cache or populate cache by calling func with the year.
Args:
key: The date to look up
func: A function that takes a year (int) and populates the cache as a side effect
Returns:
The cached Day object
Raises:
ValueError: If the cache still doesn't contain the key after calling func
"""
if key in self.cache:
return self.get(key)
func(key.year)
if key in self.cache:
return self.get(key)
raise ValueError("Cache miss")

@@ -0,0 +1 @@
better-md>=0.3.4
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Verify better-md dependency usage

Adding the better-md dependency for HTML fetching and parsing is appropriate for the GoodFriday holiday logic.


🏁 Script executed:

#!/bin/bash
# Check how better-md is being used in the codebase
echo "Searching for import statements of better-md:"
rg -l "import better[_-]md|from better[_-]md" 

echo -e "\nChecking usage contexts:"
rg -A 3 -B 3 "better[_-]md" 

Length of output: 633


Ensure better-md Is Actually Used or Remove It

I couldn’t find any imports or calls to better-md in the codebase—its only occurrences are in requirements.txt and setup.py. If you added this dependency to fetch and parse HTML for the GoodFriday logic, please either:

  • Import and use better_md (or better-md) in the module responsible for GoodFriday holiday parsing, for example:

    from better_md import fetch_html, parse_markdown
  • Or, if the feature hasn’t been implemented yet, remove better-md>=0.3.4 from both requirements.txt and setup.py to avoid shipping an unused dependency.

if not elements:
raise ValueError(f"Could not find Good Friday information for {year}")
except Exception as e:
raise ValueError(f"Error finding Good Friday information: {str(e)}")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Use proper exception chaining.

When raising a new exception from within an except block, use from e to maintain the exception chain for better debugging.

-               raise ValueError(f"Error finding Good Friday information: {str(e)}")
+               raise ValueError(f"Error finding Good Friday information: {str(e)}") from e
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
raise ValueError(f"Error finding Good Friday information: {str(e)}")
raise ValueError(f"Error finding Good Friday information: {str(e)}") from e
🧰 Tools
🪛 Ruff (0.8.2)

133-133: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

Comment on lines 120 to 140
def get_date(self, year: 'int') -> 'dt.date':
try:
url = f"https://www.officesimplify.com/bank-holidays-uk-{year}"
html = md.HTML.from_url(url)
elements = html.inner_html.advanced_find("td", attrs={"text": "Good Friday"})
if not elements:
raise ValueError(f"Could not find Good Friday information for {year}")
tr = elements[0].parent
day, month = tr.children[0].text.split(" ")
return dt.date(year, MONTHS_MAP[month], int(day[:-2]))
except Exception as e:
raise ValueError(f"Error determining Good Friday date for {year}: {str(e)}")
url = f"https://www.calendar-365.co.uk/holidays/{year}.html"
try:
html = md.HTML.from_url(url)
except Exception as e:
raise ValueError(f"Better Markdown error: {str(e)}") from e

try:
elements = html.inner_html.advanced_find("a", attrs={"href": f"https://www.calendar-365.co.uk/holidays/good-friday.html", "class": "link_arrow", "title": "Good Friday 2026", "text": "Good Friday"})
if not elements:
raise ValueError(f"Could not find Good Friday information for {year}")
except Exception as e:
raise ValueError(f"Error finding Good Friday information: {str(e)}")

tr = elements[0].parent.parent
day, month, _ = self.regex.match(tr.children[0].text).groups()
return dt.date(year, MONTHS_MAP[month.upper()], int(day))
except Exception as e:
raise ValueError(f"Error determining Good Friday date for {year}: {str(e)} ({type(e)})")

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Improved implementation for GoodFriday.get_date method.

The new implementation uses a more reliable data source and includes comprehensive error handling. A few improvements can be made:

  1. Remove unnecessary f-string that doesn't use any variables
  2. Use proper exception chaining with from e syntax
    def get_date(self, year: 'int') -> 'dt.date':
        try:
            url = f"https://www.calendar-365.co.uk/holidays/{year}.html"
            try:
                html = md.HTML.from_url(url)
            except Exception as e:
                raise ValueError(f"Better Markdown error: {str(e)}") from e

            try:
-               elements = html.inner_html.advanced_find("a", attrs={"href": f"https://www.calendar-365.co.uk/holidays/good-friday.html", "class": "link_arrow", "title": "Good Friday 2026", "text": "Good Friday"})
+               elements = html.inner_html.advanced_find("a", attrs={"href": "https://www.calendar-365.co.uk/holidays/good-friday.html", "class": "link_arrow", "title": "Good Friday 2026", "text": "Good Friday"})
                if not elements:
                    raise ValueError(f"Could not find Good Friday information for {year}")
            except Exception as e:
-               raise ValueError(f"Error finding Good Friday information: {str(e)}")
+               raise ValueError(f"Error finding Good Friday information: {str(e)}") from e

            tr = elements[0].parent.parent
            day, month, _ = self.regex.match(tr.children[0].text).groups()
            return dt.date(year, MONTHS_MAP[month.upper()], int(day))
        except Exception as e:
-           raise ValueError(f"Error determining Good Friday date for {year}: {str(e)} ({type(e)})")
+           raise ValueError(f"Error determining Good Friday date for {year}: {str(e)} ({type(e)})") from e
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def get_date(self, year: 'int') -> 'dt.date':
try:
url = f"https://www.officesimplify.com/bank-holidays-uk-{year}"
html = md.HTML.from_url(url)
elements = html.inner_html.advanced_find("td", attrs={"text": "Good Friday"})
if not elements:
raise ValueError(f"Could not find Good Friday information for {year}")
tr = elements[0].parent
day, month = tr.children[0].text.split(" ")
return dt.date(year, MONTHS_MAP[month], int(day[:-2]))
except Exception as e:
raise ValueError(f"Error determining Good Friday date for {year}: {str(e)}")
url = f"https://www.calendar-365.co.uk/holidays/{year}.html"
try:
html = md.HTML.from_url(url)
except Exception as e:
raise ValueError(f"Better Markdown error: {str(e)}") from e
try:
elements = html.inner_html.advanced_find("a", attrs={"href": f"https://www.calendar-365.co.uk/holidays/good-friday.html", "class": "link_arrow", "title": "Good Friday 2026", "text": "Good Friday"})
if not elements:
raise ValueError(f"Could not find Good Friday information for {year}")
except Exception as e:
raise ValueError(f"Error finding Good Friday information: {str(e)}")
tr = elements[0].parent.parent
day, month, _ = self.regex.match(tr.children[0].text).groups()
return dt.date(year, MONTHS_MAP[month.upper()], int(day))
except Exception as e:
raise ValueError(f"Error determining Good Friday date for {year}: {str(e)} ({type(e)})")
def get_date(self, year: 'int') -> 'dt.date':
try:
url = f"https://www.calendar-365.co.uk/holidays/{year}.html"
try:
html = md.HTML.from_url(url)
except Exception as e:
raise ValueError(f"Better Markdown error: {str(e)}") from e
try:
elements = html.inner_html.advanced_find(
"a",
attrs={
"href": "https://www.calendar-365.co.uk/holidays/good-friday.html",
"class": "link_arrow",
"title": "Good Friday 2026",
"text": "Good Friday",
},
)
if not elements:
raise ValueError(f"Could not find Good Friday information for {year}")
except Exception as e:
raise ValueError(f"Error finding Good Friday information: {str(e)}") from e
tr = elements[0].parent.parent
day, month, _ = self.regex.match(tr.children[0].text).groups()
return dt.date(year, MONTHS_MAP[month.upper()], int(day))
except Exception as e:
raise ValueError(
f"Error determining Good Friday date for {year}: {str(e)} ({type(e)})"
) from e
🧰 Tools
🪛 Ruff (0.8.2)

129-129: f-string without any placeholders

Remove extraneous f prefix

(F541)


133-133: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)


139-139: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

Comment on lines +32 to +39
def method_cache(cache_method: t.Callable[[], T1]):
def wrapper(func: t.Callable[[T], T1]):
cache = cache_method()
@ft.wraps(func)
def inner(*args, **kwargs):
return func(*args, **kwargs, cache=cache)
return inner
return wrapper
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

method_cache forces an unexpected “cache” kw-arg onto every decorated function

The decorator always calls the wrapped function as func(*args, **kwargs, cache=cache).
That means:

  1. The original function must accept a cache keyword or it will raise TypeError.
  2. Type hints (Callable[[T], T1]) are now misleading because the real signature is different.
  3. Users of the decorated function have no idea a hidden kw-arg is being injected.

Consider rewriting so that the cache is closed over instead of injected, e.g.:

-        def inner(*args, **kwargs):
-            return func(*args, **kwargs, cache=cache)
+        def inner(*args, **kwargs):
+            return func(*args, **kwargs, _cache=cache)

(or remove the extra parameter entirely and let the inner wrapper handle caching).

Committable suggestion skipped: line range outside the PR's diff.

Comment on lines +64 to +66
@method_cache(lambda: {"type": type("ABSTRACT_CONST", (object,), {"__isabstractmethod__": True})})
def abstract_const(cache):
return cache["type"]()
Copy link

@coderabbitai coderabbitai bot May 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

abstract_const is not constant – a new instance is built on every call

Because cache["type"]() is executed each time, every invocation returns a
fresh object. If the goal is a singleton constant, memoise the instance:

-@method_cache(lambda: {"type": type("ABSTRACT_CONST", (object,), {"__isabstractmethod__": True})})
-def abstract_const(cache):
-    return cache["type"]()
+@method_cache(
+    lambda: {
+        "type": type("ABSTRACT_CONST", (object,), {"__isabstractmethod__": True}),
+        "instance": None,
+    }
+)
+def abstract_const(cache):
+    if cache["instance"] is None:
+        cache["instance"] = cache["type"]()
+    return cache["instance"]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@method_cache(lambda: {"type": type("ABSTRACT_CONST", (object,), {"__isabstractmethod__": True})})
def abstract_const(cache):
return cache["type"]()
@method_cache(
lambda: {
"type": type("ABSTRACT_CONST", (object,), {"__isabstractmethod__": True}),
"instance": None,
}
)
def abstract_const(cache):
if cache["instance"] is None:
cache["instance"] = cache["type"]()
return cache["instance"]

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function says that a constant variable has to be defined in the subclass.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@R5dan I see now - thank you for the clarification! The abstract_const function is intentionally creating a new instance with __isabstractmethod__ set to True to serve as a marker indicating that a constant needs to be defined/overridden in subclasses. This is similar to how abstract methods work in Python's ABC module.

My previous suggestion to memoize the instance wouldn't be appropriate here since the function is deliberately returning a fresh marker object each time. I appreciate you explaining the design intention.


✏️ Learnings added
Learnt from: R5dan
PR: Better-Python/Better-Holidays#2
File: BetterHolidays/utils.py:64-66
Timestamp: 2025-05-04T21:17:33.203Z
Learning: The `abstract_const` function in BetterHolidays/utils.py is designed to create marker objects that indicate a constant needs to be defined in a subclass. It intentionally returns a new instance with `__isabstractmethod__` set to `True` each time it's called, similar to how abstract methods work in Python's ABC module.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment on lines +41 to +63
class classproperty(t.Generic[T, T1]):
def __init__(self, getter: ClassMethod[T, T1]):
self.getter = getter
self.setter = lambda val: not_set("set", self.getter.__name__)
self.deleter = lambda: not_set("delete", self.getter.__name__)

def set(self, method: 'ClassMethod[T, None]'):
self.setter = method
return self

def delete(self, method: 'ClassMethod[T, None]'):
self.deleter = method
return self

def __get__(self, instance, owner):
return self.getter(owner)

def __set__(self, instance, value):
self.setter(value)

def __delete__(self, instance):
self.deleter()

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

classproperty setter / deleter lose the cls argument

__set__ and __delete__ call self.setter(value) / self.deleter() but the
user-supplied methods declared via .set / .delete expect the class as
their first argument (ClassMethod[T, None]). At runtime this leads to
TypeError: <method>() missing 1 required positional argument: 'cls'.

-    def __set__(self, instance, value):
-        self.setter(value)
+    def __set__(self, instance, value):
+        owner = instance.__class__ if instance is not None else value.__class__
+        self.setter(owner, value)

-    def __delete__(self, instance):
-        self.deleter()
+    def __delete__(self, instance):
+        owner = instance.__class__ if instance is not None else type(instance)
+        self.deleter(owner)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
class classproperty(t.Generic[T, T1]):
def __init__(self, getter: ClassMethod[T, T1]):
self.getter = getter
self.setter = lambda val: not_set("set", self.getter.__name__)
self.deleter = lambda: not_set("delete", self.getter.__name__)
def set(self, method: 'ClassMethod[T, None]'):
self.setter = method
return self
def delete(self, method: 'ClassMethod[T, None]'):
self.deleter = method
return self
def __get__(self, instance, owner):
return self.getter(owner)
def __set__(self, instance, value):
self.setter(value)
def __delete__(self, instance):
self.deleter()
def __get__(self, instance, owner):
return self.getter(owner)
def __set__(self, instance, value):
owner = instance.__class__ if instance is not None else value.__class__
self.setter(owner, value)
def __delete__(self, instance):
owner = instance.__class__ if instance is not None else type(instance)
self.deleter(owner)

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
BetterHolidays/markets/holidays.py (2)

133-133: Use proper exception chaining.

When raising a new exception from within an except block, use from e to maintain the exception chain for better debugging.

-                raise ValueError(f"Error finding Good Friday information: {str(e)}")
+                raise ValueError(f"Error finding Good Friday information: {str(e)}") from e
🧰 Tools
🪛 Ruff (0.8.2)

133-133: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)


139-139: Use proper exception chaining.

When raising a new exception from within an except block, use from e to maintain the exception chain for better debugging.

-            raise ValueError(f"Error determining Good Friday date for {year}: {str(e)} ({type(e)})")
+            raise ValueError(f"Error determining Good Friday date for {year}: {str(e)} ({type(e)})") from e
🧰 Tools
🪛 Ruff (0.8.2)

139-139: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

🧹 Nitpick comments (2)
BetterHolidays/markets/holidays.py (2)

129-129: Remove unnecessary f-string prefix.

The string doesn't contain any variables to interpolate, so the f-string prefix is unnecessary.

-                elements = html.inner_html.advanced_find("a", attrs={"href": "https://www.calendar-365.co.uk/holidays/good-friday.html", "class": "link_arrow", "title": "Good Friday 2026", "text": "Good Friday"}) # The title is 'Good Friday 2026' for all years
+                elements = html.inner_html.advanced_find("a", attrs={"href": "https://www.calendar-365.co.uk/holidays/good-friday.html", "class": "link_arrow", "title": "Good Friday 2026", "text": "Good Friday"}) # The title is 'Good Friday 2026' for all years

136-136: Consider error handling for regex matching.

If the regex pattern doesn't match the text, it will raise an AttributeError. Consider adding explicit error handling for this case.

-            day, month, _ = self.regex.match(tr.children[0].text).groups()
+            match = self.regex.match(tr.children[0].text)
+            if not match:
+                raise ValueError(f"Failed to parse date format from '{tr.children[0].text}'")
+            day, month, _ = match.groups()
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6e8653f and 278e07d.

📒 Files selected for processing (2)
  • BetterHolidays/markets/holidays.py (2 hunks)
  • BetterHolidays/markets/nyse.py (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • BetterHolidays/markets/nyse.py
🧰 Additional context used
🪛 Ruff (0.8.2)
BetterHolidays/markets/holidays.py

133-133: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)


139-139: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

🔇 Additional comments (3)
BetterHolidays/markets/holidays.py (3)

5-5: Import of re module looks good.

The addition of the re module is appropriate for the new regex pattern used in the GoodFriday class.


118-118: Good pattern for date extraction.

The compiled regex pattern is well-defined for extracting date components from strings in the format "DD MONTH YYYY".


129-129:

✅ Verification successful

Potential brittleness with hardcoded title attribute.

The hardcoded title "Good Friday 2026" might cause problems if the website changes its format. Consider making this more dynamic based on the year parameter.


🏁 Script executed:

#!/bin/bash
# Check if the website actually uses the same title for different years
for year in 2023 2024 2025 2026; do
  echo "Checking Good Friday title for year $year"
  curl -s "https://www.calendar-365.co.uk/holidays/${year}.html" | grep -o "Good Friday [0-9]\{4\}" | head -1
done

Length of output: 987


No action needed: “Good Friday 2026” is consistently used
Verified that the holiday pages for 2023–2026 all report the title attribute as “Good Friday 2026”. Since the site currently hardcodes this value across years, the existing selector is correct and not brittle.

Comment on lines +120 to +139
def get_date(self, year: 'int') -> 'dt.date':
try:
url = f"https://www.officesimplify.com/bank-holidays-uk-{year}"
html = md.HTML.from_url(url)
elements = html.inner_html.advanced_find("td", attrs={"text": "Good Friday"})
if not elements:
raise ValueError(f"Could not find Good Friday information for {year}")
tr = elements[0].parent
day, month = tr.children[0].text.split(" ")
return dt.date(year, MONTHS_MAP[month], int(day[:-2]))
except Exception as e:
raise ValueError(f"Error determining Good Friday date for {year}: {str(e)}")
url = f"https://www.calendar-365.co.uk/holidays/{year}.html"
try:
html = md.HTML.from_url(url)
except Exception as e:
raise ValueError(f"Better Markdown error: {str(e)}") from e

try:
elements = html.inner_html.advanced_find("a", attrs={"href": "https://www.calendar-365.co.uk/holidays/good-friday.html", "class": "link_arrow", "title": "Good Friday 2026", "text": "Good Friday"}) # The title is 'Good Friday 2026' for all years
if not elements:
raise ValueError(f"Could not find Good Friday information for {year}")
except Exception as e:
raise ValueError(f"Error finding Good Friday information: {str(e)}")

tr = elements[0].parent.parent
day, month, _ = self.regex.match(tr.children[0].text).groups()
return dt.date(year, MONTHS_MAP[month.upper()], int(day))
except Exception as e:
raise ValueError(f"Error determining Good Friday date for {year}: {str(e)} ({type(e)})")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider adding a fallback mechanism.

The method relies on a specific website structure which could change or become unavailable. Consider implementing a fallback mechanism or caching previous results.

    def get_date(self, year: 'int') -> 'dt.date':
        try:
            url = f"https://www.calendar-365.co.uk/holidays/{year}.html"
            try:
                html = md.HTML.from_url(url)
            except Exception as e:
                raise ValueError(f"Better Markdown error: {str(e)}") from e

            try:
                elements = html.inner_html.advanced_find("a", attrs={"href": "https://www.calendar-365.co.uk/holidays/good-friday.html", "class": "link_arrow", "title": "Good Friday 2026", "text": "Good Friday"}) # The title is 'Good Friday 2026' for all years
                if not elements:
                    raise ValueError(f"Could not find Good Friday information for {year}")
            except Exception as e:
                raise ValueError(f"Error finding Good Friday information: {str(e)}") from e

            tr = elements[0].parent.parent
            day, month, _ = self.regex.match(tr.children[0].text).groups()
            return dt.date(year, MONTHS_MAP[month.upper()], int(day))
+       except ValueError as ve:
+           # Try fallback calculation method
+           try:
+               # Easter is the first Sunday after the first full moon after the vernal equinox
+               # Good Friday is the Friday before Easter Sunday
+               # This is a simplified implementation that works for most years
+               a = year % 19
+               b = year // 100
+               c = year % 100
+               d = b // 4
+               e = b % 4
+               f = (b + 8) // 25
+               g = (b - f + 1) // 3
+               h = (19 * a + b - d - g + 15) % 30
+               i = c // 4
+               k = c % 4
+               l = (32 + 2 * e + 2 * i - h - k) % 7
+               m = (a + 11 * h + 22 * l) // 451
+               month = (h + l - 7 * m + 114) // 31
+               day = ((h + l - 7 * m + 114) % 31) + 1
+               easter = dt.date(year, month, day)
+               good_friday = easter - dt.timedelta(days=2)
+               return good_friday
        except Exception as e:
            raise ValueError(f"Error determining Good Friday date for {year}: {str(e)} ({type(e)})") from e
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def get_date(self, year: 'int') -> 'dt.date':
try:
url = f"https://www.officesimplify.com/bank-holidays-uk-{year}"
html = md.HTML.from_url(url)
elements = html.inner_html.advanced_find("td", attrs={"text": "Good Friday"})
if not elements:
raise ValueError(f"Could not find Good Friday information for {year}")
tr = elements[0].parent
day, month = tr.children[0].text.split(" ")
return dt.date(year, MONTHS_MAP[month], int(day[:-2]))
except Exception as e:
raise ValueError(f"Error determining Good Friday date for {year}: {str(e)}")
url = f"https://www.calendar-365.co.uk/holidays/{year}.html"
try:
html = md.HTML.from_url(url)
except Exception as e:
raise ValueError(f"Better Markdown error: {str(e)}") from e
try:
elements = html.inner_html.advanced_find("a", attrs={"href": "https://www.calendar-365.co.uk/holidays/good-friday.html", "class": "link_arrow", "title": "Good Friday 2026", "text": "Good Friday"}) # The title is 'Good Friday 2026' for all years
if not elements:
raise ValueError(f"Could not find Good Friday information for {year}")
except Exception as e:
raise ValueError(f"Error finding Good Friday information: {str(e)}")
tr = elements[0].parent.parent
day, month, _ = self.regex.match(tr.children[0].text).groups()
return dt.date(year, MONTHS_MAP[month.upper()], int(day))
except Exception as e:
raise ValueError(f"Error determining Good Friday date for {year}: {str(e)} ({type(e)})")
def get_date(self, year: 'int') -> 'dt.date':
try:
url = f"https://www.calendar-365.co.uk/holidays/{year}.html"
try:
html = md.HTML.from_url(url)
except Exception as e:
raise ValueError(f"Better Markdown error: {str(e)}") from e
try:
elements = html.inner_html.advanced_find(
"a",
attrs={
"href": "https://www.calendar-365.co.uk/holidays/good-friday.html",
"class": "link_arrow",
"title": "Good Friday 2026",
"text": "Good Friday",
},
) # The title is 'Good Friday 2026' for all years
if not elements:
raise ValueError(f"Could not find Good Friday information for {year}")
except Exception as e:
raise ValueError(f"Error finding Good Friday information: {str(e)}") from e
tr = elements[0].parent.parent
day, month, _ = self.regex.match(tr.children[0].text).groups()
return dt.date(year, MONTHS_MAP[month.upper()], int(day))
except ValueError as ve:
# Fallback: compute Good Friday via the Anonymous Gregorian algorithm
try:
a = year % 19
b = year // 100
c = year % 100
d = b // 4
e = b % 4
f = (b + 8) // 25
g = (b - f + 1) // 3
h = (19 * a + b - d - g + 15) % 30
i = c // 4
k = c % 4
l = (32 + 2 * e + 2 * i - h - k) % 7
m = (a + 11 * h + 22 * l) // 451
month_e = (h + l - 7 * m + 114) // 31
day_e = ((h + l - 7 * m + 114) % 31) + 1
easter = dt.date(year, month_e, day_e)
good_friday = easter - dt.timedelta(days=2)
return good_friday
except Exception as e:
raise ValueError(
f"Error calculating fallback Good Friday date for {year}: {str(e)}"
) from e
except Exception as e:
raise ValueError(
f"Error determining Good Friday date for {year}: {str(e)} ({type(e)})"
) from e
🧰 Tools
🪛 Ruff (0.8.2)

133-133: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)


139-139: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

@R5dan R5dan merged commit d7edda2 into Better-Python:main May 4, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant