Repository: pandas-dev/pandas
Pull request #61604 (2025-06-07 22:27:57)

WEB: Replace os.path with pathlib.Path in pandas_web.py
Replaces `os.path` with `pathlib.Path` in `pandas_web.py`, as suggested by @datapythonista in [61578](https://github.com/pandas-dev/pandas/pull/61578). No functional changes, verified site generation remains correct

- [x] Ran pre-commit check

iabhi4: Thanks for the review! updated to make sure all paths are created as `Path` early and used consistently. Removed redundant casts too.
iabhi4: Addressed all the latest comments, switched to `Path.rglob`, cleaned up `base_url`, and ensured consistent `Path` usage. Let me know if anything else looks off, I also retested on local after the changes

Patch:
diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst
index 03a3867083..5160d2ea8b 100644
--- a/doc/source/whatsnew/v3.0.0.rst
+++ b/doc/source/whatsnew/v3.0.0.rst
@@ -64,6 +64,7 @@ Other enhancements
 - :meth:`Series.nlargest` uses a 'stable' sort internally and will preserve original ordering.
 - :class:`ArrowDtype` now supports ``pyarrow.JsonType`` (:issue:`60958`)
 - :class:`DataFrameGroupBy` and :class:`SeriesGroupBy` methods ``sum``, ``mean``, ``median``, ``prod``, ``min``, ``max``, ``std``, ``var`` and ``sem`` now accept ``skipna`` parameter (:issue:`15675`)
+- :class:`Holiday` has gained the constructor argument and field ``exclude_dates`` to exclude specific datetimes from a custom holiday calendar (:issue:`54382`)
 - :class:`Rolling` and :class:`Expanding` now support ``nunique`` (:issue:`26958`)
 - :class:`Rolling` and :class:`Expanding` now support aggregations ``first`` and ``last`` (:issue:`33155`)
 - :func:`read_parquet` accepts ``to_pandas_kwargs`` which are forwarded to :meth:`pyarrow.Table.to_pandas` which enables passing additional keywords to customize the conversion to pandas, such as ``maps_as_pydicts`` to read the Parquet map data type as python dictionaries (:issue:`56842`)
diff --git a/pandas/tseries/holiday.py b/pandas/tseries/holiday.py
index 2d195fbbc4..8ad2541d2e 100644
--- a/pandas/tseries/holiday.py
+++ b/pandas/tseries/holiday.py
@@ -169,6 +169,7 @@ class Holiday:
         start_date=None,
         end_date=None,
         days_of_week: tuple | None = None,
+        exclude_dates: DatetimeIndex | None = None,
     ) -> None:
         """
         Parameters
@@ -193,6 +194,8 @@ class Holiday:
         days_of_week : tuple of int or dateutil.relativedelta weekday strs, default None
             Provide a tuple of days e.g  (0,1,2,3,) for Monday Through Thursday
             Monday=0,..,Sunday=6
+        exclude_dates : DatetimeIndex or default None
+            Specific dates to exclude e.g. skipping a specific year's holiday
 
         Examples
         --------
@@ -257,6 +260,9 @@ class Holiday:
         self.observance = observance
         assert days_of_week is None or type(days_of_week) == tuple
         self.days_of_week = days_of_week
+        if not (exclude_dates is None or isinstance(exclude_dates, DatetimeIndex)):
+            raise ValueError("exclude_dates must be None or of type DatetimeIndex.")
+        self.exclude_dates = exclude_dates
 
     def __repr__(self) -> str:
         info = ""
@@ -328,6 +334,9 @@ class Holiday:
         holiday_dates = holiday_dates[
             (holiday_dates >= filter_start_date) & (holiday_dates <= filter_end_date)
         ]
+
+        if self.exclude_dates is not None:
+            holiday_dates = holiday_dates.difference(self.exclude_dates)
         if return_name:
             return Series(self.name, index=holiday_dates)
         return holiday_dates
diff --git a/web/pandas_web.py b/web/pandas_web.py
index 34ac374314..eca5317842 100755
--- a/web/pandas_web.py
+++ b/web/pandas_web.py
@@ -100,20 +100,15 @@ class Preprocessors:
         posts = []
         # posts from the file system
         if context["blog"]["posts_path"]:
-            posts_path = os.path.join(
-                context["source_path"], *context["blog"]["posts_path"].split("/")
-            )
-            for fname in os.listdir(posts_path):
-                if fname.startswith("index."):
+            posts_path = context["source_path"] / context["blog"]["posts_path"]
+            for fname in posts_path.iterdir():
+                if fname.name.startswith("index."):
                     continue
-                link = (
-                    f"/{context['blog']['posts_path']}"
-                    f"/{os.path.splitext(fname)[0]}.html"
-                )
+                link = f"/{context['blog']['posts_path']}/{fname.stem}.html"
                 md = markdown.Markdown(
                     extensions=context["main"]["markdown_extensions"]
                 )
-                with open(os.path.join(posts_path, fname), encoding="utf-8") as f:
+                with fname.open(encoding="utf-8") as f:
                     html = md.convert(f.read())
                 title = md.Meta["title"][0]
                 summary = re.sub(tag_expr, "", html)
@@ -386,15 +381,15 @@ def get_callable(obj_as_str: str) -> object:
     return obj
 
 
-def get_context(config_fname: str, **kwargs):
+def get_context(config_fname: pathlib.Path, **kwargs):
     """
     Load the config yaml as the base context, and enrich it with the
     information added by the context preprocessors defined in the file.
     """
-    with open(config_fname, encoding="utf-8") as f:
+    with config_fname.open(encoding="utf-8") as f:
         context = yaml.safe_load(f)
 
-    context["source_path"] = os.path.dirname(config_fname)
+    context["source_path"] = config_fname.parent
     context.update(kwargs)
 
     preprocessors = (
@@ -409,14 +404,13 @@ def get_context(config_fname: str, **kwargs):
     return context
 
 
-def get_source_files(source_path: str) -> typing.Generator[str, None, None]:
+def get_source_files(source_path: pathlib.Path) -> typing.Generator[str, None, None]:
     """
     Generate the list of files present in the source directory.
     """
-    for root, dirs, fnames in os.walk(source_path):
-        root_rel_path = os.path.relpath(root, source_path)
-        for fname in fnames:
-            yield os.path.join(root_rel_path, fname)
+    for path in source_path.rglob("*"):
+        if path.is_file():
+            yield path.relative_to(source_path)
 
 
 def extend_base_template(content: str, base_template: str) -> str:
@@ -432,8 +426,8 @@ def extend_base_template(content: str, base_template: str) -> str:
 
 
 def main(
-    source_path: str,
-    target_path: str,
+    source_path: pathlib.Path,
+    target_path: pathlib.Path,
 ) -> int:
     """
     Copy every file in the source directory to the target directory.
@@ -441,9 +435,10 @@ def main(
     For ``.md`` and ``.html`` files, render them with the context
     before copying them. ``.md`` files are transformed to HTML.
     """
+
     # Sanity check: validate that versions.json is valid JSON
-    versions_path = os.path.join(source_path, "versions.json")
-    with open(versions_path, encoding="utf-8") as f:
+    versions_path = source_path / "versions.json"
+    with versions_path.open(encoding="utf-8") as f:
         try:
             json.load(f)
         except json.JSONDecodeError as e:
@@ -451,7 +446,7 @@ def main(
                 f"Invalid versions.json: {e}. Ensure it is valid JSON."
             ) from e
 
-    config_fname = os.path.join(source_path, "config.yml")
+    config_fname = source_path / "config.yml"
 
     shutil.rmtree(target_path, ignore_errors=True)
     os.makedirs(target_path, exist_ok=True)
@@ -460,23 +455,22 @@ def main(
     context = get_context(config_fname, target_path=target_path)
     sys.stderr.write("Context generated\n")
 
-    templates_path = os.path.join(source_path, context["main"]["templates_path"])
+    templates_path = source_path / context["main"]["templates_path"]
     jinja_env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_path))
 
     for fname in get_source_files(source_path):
-        if os.path.normpath(fname) in context["main"]["ignore"]:
+        if fname.as_posix() in context["main"]["ignore"]:
             continue
-
         sys.stderr.write(f"Processing {fname}\n")
-        dirname = os.path.dirname(fname)
-        os.makedirs(os.path.join(target_path, dirname), exist_ok=True)
+        dirname = fname.parent
+        (target_path / dirname).mkdir(parents=True, exist_ok=True)
 
-        extension = os.path.splitext(fname)[-1]
+        extension = fname.suffix
         if extension in (".html", ".md"):
-            with open(os.path.join(source_path, fname), encoding="utf-8") as f:
+            with (source_path / fname).open(encoding="utf-8") as f:
                 content = f.read()
             if extension == ".md":
-                if "pdeps/" in fname:
+                if len(fname.parts) > 1 and fname.parts[1] == "pdeps":
                     from markdown.extensions.toc import TocExtension
 
                     body = markdown.markdown(
@@ -503,17 +497,13 @@ def main(
                 # Python-Markdown doesn't let us config table attributes by hand
                 body = body.replace("<table>", '<table class="table table-bordered">')
                 content = extend_base_template(body, context["main"]["base_template"])
-            context["base_url"] = "".join(["../"] * os.path.normpath(fname).count("/"))
+            context["base_url"] = "../" * (len(fname.parents) - 1)
             content = jinja_env.from_string(content).render(**context)
-            fname_html = os.path.splitext(fname)[0] + ".html"
-            with open(
-                os.path.join(target_path, fname_html), "w", encoding="utf-8"
-            ) as f:
+            fname_html = fname.with_suffix(".html").name
+            with (target_path / dirname / fname_html).open("w", encoding="utf-8") as f:
                 f.write(content)
         else:
-            shutil.copy(
-                os.path.join(source_path, fname), os.path.join(target_path, dirname)
-            )
+            shutil.copy(source_path / fname, target_path / fname)
 
 
 if __name__ == "__main__":
@@ -525,4 +515,4 @@ if __name__ == "__main__":
         "--target-path", default="build", help="directory where to write the output"
     )
     args = parser.parse_args()
-    sys.exit(main(args.source_path, args.target_path))
+    sys.exit(main(pathlib.Path(args.source_path), pathlib.Path(args.target_path)))


Test patch:
diff --git a/pandas/tests/tseries/holiday/test_holiday.py b/pandas/tests/tseries/holiday/test_holiday.py
index ffe6ff0b51..37e029f31b 100644
--- a/pandas/tests/tseries/holiday/test_holiday.py
+++ b/pandas/tests/tseries/holiday/test_holiday.py
@@ -353,3 +353,104 @@ def test_holidays_with_timezone_specified_but_no_occurences():
     expected_results.index = expected_results.index.as_unit("ns")
 
     tm.assert_equal(test_case, expected_results)
+
+
+def test_holiday_with_exclusion():
+    # GH 54382
+    start = Timestamp("2020-05-01")
+    end = Timestamp("2025-05-31")
+    exclude = DatetimeIndex([Timestamp("2022-05-30")])  # Queen's platinum Jubilee
+
+    queens_jubilee_uk_spring_bank_holiday: Holiday = Holiday(
+        "Queen's Jubilee UK Spring Bank Holiday",
+        month=5,
+        day=31,
+        offset=DateOffset(weekday=MO(-1)),
+        exclude_dates=exclude,
+    )
+
+    result = queens_jubilee_uk_spring_bank_holiday.dates(start, end)
+    expected = DatetimeIndex(
+        [
+            Timestamp("2020-05-25"),
+            Timestamp("2021-05-31"),
+            Timestamp("2023-05-29"),
+            Timestamp("2024-05-27"),
+            Timestamp("2025-05-26"),
+        ],
+        dtype="datetime64[ns]",
+    )
+    tm.assert_index_equal(result, expected)
+
+
+def test_holiday_with_multiple_exclusions():
+    start = Timestamp("2025-01-01")
+    end = Timestamp("2065-12-31")
+    exclude = DatetimeIndex(
+        [
+            Timestamp("2025-01-01"),
+            Timestamp("2042-01-01"),
+            Timestamp("2061-01-01"),
+        ]
+    )  # Yakudoshi new year
+
+    yakudoshi_new_year: Holiday = Holiday(
+        "Yakudoshi New Year", month=1, day=1, exclude_dates=exclude
+    )
+
+    result = yakudoshi_new_year.dates(start, end)
+    expected = DatetimeIndex(
+        [
+            Timestamp("2026-01-01"),
+            Timestamp("2027-01-01"),
+            Timestamp("2028-01-01"),
+            Timestamp("2029-01-01"),
+            Timestamp("2030-01-01"),
+            Timestamp("2031-01-01"),
+            Timestamp("2032-01-01"),
+            Timestamp("2033-01-01"),
+            Timestamp("2034-01-01"),
+            Timestamp("2035-01-01"),
+            Timestamp("2036-01-01"),
+            Timestamp("2037-01-01"),
+            Timestamp("2038-01-01"),
+            Timestamp("2039-01-01"),
+            Timestamp("2040-01-01"),
+            Timestamp("2041-01-01"),
+            Timestamp("2043-01-01"),
+            Timestamp("2044-01-01"),
+            Timestamp("2045-01-01"),
+            Timestamp("2046-01-01"),
+            Timestamp("2047-01-01"),
+            Timestamp("2048-01-01"),
+            Timestamp("2049-01-01"),
+            Timestamp("2050-01-01"),
+            Timestamp("2051-01-01"),
+            Timestamp("2052-01-01"),
+            Timestamp("2053-01-01"),
+            Timestamp("2054-01-01"),
+            Timestamp("2055-01-01"),
+            Timestamp("2056-01-01"),
+            Timestamp("2057-01-01"),
+            Timestamp("2058-01-01"),
+            Timestamp("2059-01-01"),
+            Timestamp("2060-01-01"),
+            Timestamp("2062-01-01"),
+            Timestamp("2063-01-01"),
+            Timestamp("2064-01-01"),
+            Timestamp("2065-01-01"),
+        ],
+        dtype="datetime64[ns]",
+    )
+    tm.assert_index_equal(result, expected)
+
+
+def test_exclude_date_value_error():
+    msg = "exclude_dates must be None or of type DatetimeIndex."
+
+    with pytest.raises(ValueError, match=msg):
+        exclude = [
+            Timestamp("2025-06-10"),
+            Timestamp("2026-06-10"),
+        ]
+        Holiday("National Ice Tea Day", month=6, day=10, exclude_dates=exclude)