Repository: astropy/astropy

Issue:
BUG: `astropy.time.update_leap_seconds` doesn't warn for corrupt files passed as `pathlib.Path` objects
In #16060 and #16923, we find that `astropy/time.update_leap_seconds` doesn't behave correctly if passed a corrupt file as `Path` instead of `str`.
This is visible in `astropy/test/test_update_leap_second.py::test_auto_update_corrupt_file`, as applying the following patch
```patch
diff --git a/astropy/time/tests/test_update_leap_seconds.py b/astropy/time/tests/test_update_leap_seconds.py
index a1027a0755..45250147c3 100644
--- a/astropy/time/tests/test_update_leap_seconds.py
+++ b/astropy/time/tests/test_update_leap_seconds.py
@@ -58,7 +58,7 @@ class TestUpdateLeapSeconds:
             update_leap_seconds(["nonsense"])
 
     def test_auto_update_corrupt_file(self, tmp_path):
-        bad_file = str(tmp_path / "no_expiration")
+        bad_file = tmp_path / "no_expiration"
         with open(iers.IERS_LEAP_SECOND_FILE) as fh:
             lines = fh.readlines()
         with open(bad_file, "w") as fh:
```
leads to failing the test:
```
E   Failed: DID NOT WARN. No warnings of type (<class 'astropy.utils.exceptions.AstropyWarning'>,) matching the regex were emitted.
     Regex: ValueError.*did not find expiration
     Emitted warnings: [ AstropyWarning('leap-second auto-update failed due to the following exception: AttributeError("\'PosixPath\' object has no attribute \'decode\'")')].
```
(or in other words: a different warning is emitted first)

The issue doesn't appear to be trivial enough to be fixed directly in #16023 (which is meant as a simple refactor), but it should be done as a follow up.

There should ideally be more than one test exercising passing `Path` arguments to this function.

_Originally posted by @pllim in https://github.com/astropy/astropy/pull/16923#discussion_r1742578137_
            

Discussion:
github-actions[bot]: Thank you for your contribution to Astropy! 🌌 This checklist is meant to remind the package maintainers who will review this pull request of some common things to look for.

  - [ ] Do the proposed changes actually accomplish desired goals?
  - [ ] Do the proposed changes follow the [Astropy coding guidelines](https://docs.astropy.org/en/latest/development/codeguide.html)?
  - [ ] Are tests added/updated as required? If so, do they follow the [Astropy testing guidelines](https://docs.astropy.org/en/latest/development/testguide.html)?
  - [ ] Are docs added/updated as required? If so, do they follow the [Astropy documentation guidelines](https://docs.astropy.org/en/latest/development/docguide.html)?
  - [ ] Is rebase and/or squash necessary? If so, please provide the author with appropriate instructions. Also see instructions for [rebase](https://docs.astropy.org/en/latest/development/development_details.html#rebase-if-necessary) and [squash](https://docs.astropy.org/en/latest/development/development_details.html#squash-if-necessary).
  - [ ] Did the CI pass? If no, are the failures related? If you need to run daily and weekly cron jobs as part of the PR, please apply the "Extra CI" label. Codestyle issues can be fixed by the [bot](https://docs.astropy.org/en/latest/development/development_details.html#pre-commit).
  - [ ] Is a change log needed? If yes, did the change log check pass? If no, add the "no-changelog-entry-needed" label. If this is a manual backport, use the "skip-changelog-checks" label unless special changelog handling is necessary.
  - [ ] Is this a big PR that makes a "What's new?" entry worthwhile and if so, is (1) a "what's new" entry included in this PR and (2) the "whatsnew-needed" label applied?
  - [ ] At the time of adding the milestone, if the milestone set requires a backport to release branch(es), apply the appropriate "backport-X.Y.x" label(s) *before* merge.
github-actions[bot]: 👋 Thank you for your draft pull request! Do you know that you can use `[ci skip]` or `[skip ci]` in your commit messages to skip running continuous integration tests until you are ready?
pllim: This might naturally fix https://github.com/astropy/astropy/issues/16929 also but you should confirm.
neutrinoceros: > This might naturally fix https://github.com/astropy/astropy/issues/16929 also but you should confirm.

just checked, and it doesn't.
neutrinoceros: ... but now it does ! The fix is actually orthogonal to the initial goal of this PR but it indeed felt related enough to be bundled with it. Let me know if you'd like me to split the PR instead.
neutrinoceros: > fine to adjust one of the existing ones to pass in a path instead of a string.

done ! I parametrized the only existing test using `IERS_B.read` directly.

Patch:
diff --git a/astropy/utils/iers/iers.py b/astropy/utils/iers/iers.py
index 42e9e75e4e..970f76dc19 100644
--- a/astropy/utils/iers/iers.py
+++ b/astropy/utils/iers/iers.py
@@ -9,9 +9,12 @@ celestial-to-terrestrial coordinate transformations
 (in `astropy.coordinates`).
 """
 
+from __future__ import annotations
+
 import os
 import re
 from datetime import datetime, timezone
+from typing import TYPE_CHECKING
 from urllib.parse import urlparse
 from warnings import warn
 
@@ -43,6 +46,9 @@ from astropy.utils.data import (
 from astropy.utils.exceptions import AstropyDeprecationWarning, AstropyWarning
 from astropy.utils.state import ScienceState
 
+if TYPE_CHECKING:
+    from typing_extensions import Self
+
 __all__ = [
     "Conf",
     "conf",
@@ -604,15 +610,19 @@ class IERS_A(IERS):
         return table
 
     @classmethod
-    def read(cls, file=None, readme=None):
+    def read(
+        cls,
+        file: str | os.PathLike[str] | None = None,
+        readme: str | os.PathLike[str] | None = None,
+    ) -> Self:
         """Read IERS-A table from a finals2000a.* file provided by USNO.
 
         Parameters
         ----------
-        file : str
+        file : str or os.PathLike[str]
             full path to ascii file holding IERS-A data.
             Defaults to ``iers.IERS_A_FILE``.
-        readme : str
+        readme : str or os.PathLike[str]
             full path to ascii file holding CDS-style readme.
             Defaults to package version, ``iers.IERS_A_README``.
 
@@ -640,8 +650,12 @@ class IERS_A(IERS):
                 )
             else:
                 file = IERS_A_FILE
+        else:
+            file = os.fspath(file)
         if readme is None:
             readme = IERS_A_README
+        else:
+            readme = os.fspath(readme)
 
         iers_a = super().read(file, format="cds", readme=readme)
 
@@ -694,15 +708,20 @@ class IERS_B(IERS):
     iers_table = None
 
     @classmethod
-    def read(cls, file=None, readme=None, data_start=6):
+    def read(
+        cls,
+        file: str | os.PathLike[str] | None = None,
+        readme: str | os.PathLike[str] | None = None,
+        data_start: int = 6,
+    ) -> Self:
         """Read IERS-B table from a eopc04.* file provided by IERS.
 
         Parameters
         ----------
-        file : str
+        file : str or os.PathLike[str]
             full path to ascii file holding IERS-B data.
             Defaults to package version, ``iers.IERS_B_FILE``.
-        readme : str
+        readme : str or os.PathLike[str]
             full path to ascii file holding CDS-style readme.
             Defaults to package version, ``iers.IERS_B_README``.
         data_start : int
@@ -732,9 +751,12 @@ class IERS_B(IERS):
         """
         if file is None:
             file = IERS_B_FILE
+        else:
+            file = os.fspath(file)
         if readme is None:
             readme = IERS_B_README
-
+        else:
+            readme = os.fspath(readme)
         table = super().read(file, format="cds", readme=readme, data_start=data_start)
 
         table.meta["data_path"] = file
@@ -1144,8 +1166,8 @@ class LeapSeconds(QTable):
             # configuration items, which we want to be sure are up to date).
             files = [getattr(conf, f, f) for f in cls._auto_open_files]
 
-        # Remove empty entries.
-        files = [f for f in files if f]
+        # Remove empty entries and normalize Path objects to string
+        files = [os.fspath(f) for f in files if f]
 
         # Our trials start with normal files and remote ones that are
         # already in cache.  The bools here indicate that the cache
diff --git a/docs/changes/utils/16931.feature.rst b/docs/changes/utils/16931.feature.rst
new file mode 100644
index 0000000000..c9c7174e1e
--- /dev/null
+++ b/docs/changes/utils/16931.feature.rst
@@ -0,0 +1,2 @@
+Add support for specifying files as ``pathlib.Path`` objects in ``IERS_A.read``
+and ``IERS_B.read``.


Test patch:
diff --git a/astropy/time/tests/test_update_leap_seconds.py b/astropy/time/tests/test_update_leap_seconds.py
index a1027a0755..45250147c3 100644
--- a/astropy/time/tests/test_update_leap_seconds.py
+++ b/astropy/time/tests/test_update_leap_seconds.py
@@ -58,7 +58,7 @@ class TestUpdateLeapSeconds:
             update_leap_seconds(["nonsense"])
 
     def test_auto_update_corrupt_file(self, tmp_path):
-        bad_file = str(tmp_path / "no_expiration")
+        bad_file = tmp_path / "no_expiration"
         with open(iers.IERS_LEAP_SECOND_FILE) as fh:
             lines = fh.readlines()
         with open(bad_file, "w") as fh:
diff --git a/astropy/utils/iers/tests/test_iers.py b/astropy/utils/iers/tests/test_iers.py
index 4a71364c9d..fd32aaa5da 100644
--- a/astropy/utils/iers/tests/test_iers.py
+++ b/astropy/utils/iers/tests/test_iers.py
@@ -96,7 +96,8 @@ class TestBasic:
         iers.IERS_A.close()
 
 
-def test_IERS_B_old_style_excerpt():
+@pytest.mark.parametrize("path_transform", [os.fspath, Path])
+def test_IERS_B_old_style_excerpt(path_transform):
     """Check that the instructions given in `IERS_B.read` actually work."""
     # If this test is changed, be sure to also adjust the instructions.
     #
@@ -104,8 +105,8 @@ def test_IERS_B_old_style_excerpt():
     # enough time has passed that old-style IERS_B files are simply
     # not around any more, say in 2025.  If so, also remove the excerpt
     # and the ReadMe.eopc04_IAU2000 file.
-    old_style_file = get_pkg_data_filename(
-        os.path.join("data", "iers_b_old_style_excerpt")
+    old_style_file = path_transform(
+        get_pkg_data_filename(os.path.join("data", "iers_b_old_style_excerpt"))
     )
     excerpt = iers.IERS_B.read(
         old_style_file,