Repository: astropy/astropy

Issue:
Path handling in FITS broke benchmark code
@neutrinoceros , I vaguely remember you did some Path support on this recently. Are you able to assist with debugging? Thanks!

I see this when I apply `benchmark` label on a PR that did not touch FITS code (#17689). Example log: https://github.com/astropy/astropy/actions/runs/13077182006/job/36492129500

Example traceback (one of several):

```
2025-01-31T17:19:04.4902399Z [61.15%] ···· Traceback (most recent call last):
2025-01-31T17:19:04.4912535Z                 File "/home/runner/work/astropy/astropy/env/2bc11cad084fa5bfe9c66b8a2af28d97/lib/python3.11/site-packages/asv_runner/server.py", line 179, in _run_server
2025-01-31T17:19:04.4913752Z                   _run(run_args)
2025-01-31T17:19:04.4914813Z                 File "/home/runner/work/astropy/astropy/env/2bc11cad084fa5bfe9c66b8a2af28d97/lib/python3.11/site-packages/asv_runner/run.py", line 65, in _run
2025-01-31T17:19:04.4915841Z                   skip = benchmark.do_setup()
2025-01-31T17:19:04.4916258Z                          ^^^^^^^^^^^^^^^^^^^^
2025-01-31T17:19:04.4917388Z                 File "/home/runner/work/astropy/astropy/env/2bc11cad084fa5bfe9c66b8a2af28d97/lib/python3.11/site-packages/asv_runner/benchmarks/time.py", line 80, in do_setup
2025-01-31T17:19:04.4918552Z                   result = Benchmark.do_setup(self)
2025-01-31T17:19:04.4918954Z                            ^^^^^^^^^^^^^^^^^^^^^^^^
2025-01-31T17:19:04.4920446Z                 File "/home/runner/work/astropy/astropy/env/2bc11cad084fa5bfe9c66b8a2af28d97/lib/python3.11/site-packages/asv_runner/benchmarks/_base.py", line 632, in do_setup
2025-01-31T17:19:04.4922410Z                   setup(*self._current_params)
2025-01-31T17:19:04.4923198Z                 File "/home/runner/work/astropy/astropy/astropy-benchmarks/benchmarks/io_fits.py", line 26, in setup
2025-01-31T17:19:04.4923966Z                   t.write(temp, format="fits")
2025-01-31T17:19:04.4925112Z                 File "/home/runner/work/astropy/astropy/env/2bc11cad084fa5bfe9c66b8a2af28d97/lib/python3.11/site-packages/astropy/table/connect.py", line 130, in __call__
2025-01-31T17:19:04.4926320Z                   self.registry.write(instance, *args, **kwargs)
2025-01-31T17:19:04.4927578Z                 File "/home/runner/work/astropy/astropy/env/2bc11cad084fa5bfe9c66b8a2af28d97/lib/python3.11/site-packages/astropy/io/registry/core.py", line 386, in write
2025-01-31T17:19:04.4928737Z                   return writer(data, *args, **kwargs)
2025-01-31T17:19:04.4929148Z                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
2025-01-31T17:19:04.4930385Z                 File "/home/runner/work/astropy/astropy/env/2bc11cad084fa5bfe9c66b8a2af28d97/lib/python3.11/site-packages/astropy/io/fits/connect.py", line 451, in write_table_fits
2025-01-31T17:19:04.4931742Z                   if os.path.exists(output):
2025-01-31T17:19:04.4932137Z                      ^^^^^^^^^^^^^^^^^^^^^^
2025-01-31T17:19:04.4932640Z                 File "<frozen genericpath>", line 19, in exists
2025-01-31T17:19:04.4933381Z               TypeError: stat: path should be string, bytes, os.PathLike or integer, not _TemporaryFileWrapper
2025-01-31T17:19:04.4934061Z               asv: benchmark failed (exit status 1)
```

### Blocks

* https://github.com/astropy/astropy-benchmarks/pull/129

Discussion:
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: benchmark failing doesn't mean much because I think it still pulls in the broken code in `main` for relative comparison? 🤔 

neutrinoceros: I'm seeing two different errors on benchmarks, the first of which is addressed already in the present PR (`TypeError: stat: path should be string, bytes, os.PathLike or integer, not _TemporaryFileWrapper`), but the second one seems new and unrelated (`astropy.modeling.fitting.NonFiniteValueError: Objective function has encountered a non-finite value, this will cause the fit to fail!`).
Perhaps more importantly, my test is now failing on windows, so I'll try address this first.

neutrinoceros: Ok it should work on all plateforms now, with a minor defect that a temporary file is leaked on Python 3.11 . I added a comment to explain what's going on, and that extra layer of complexity can be removed once we drop 3.11 .
saimn: Can we use a `io.BytesIO` buffer instead ? No need for an actual file.

Patch:
diff --git a/astropy/io/fits/connect.py b/astropy/io/fits/connect.py
index ae315c9d06..0f318ff699 100644
--- a/astropy/io/fits/connect.py
+++ b/astropy/io/fits/connect.py
@@ -435,7 +435,7 @@ def write_table_fits(input, output, overwrite=False, append=False):
     ----------
     input : Table
         The table to write out.
-    output : str or os.PathLike[str]
+    output : str or os.PathLike[str] or file-like
         The filename to write the table to.
     overwrite : bool
         Whether to overwrite any existing file without warning.
@@ -448,7 +448,7 @@ def write_table_fits(input, output, overwrite=False, append=False):
     table_hdu = table_to_hdu(input, character_as_bytes=True)
 
     # Check if output file already exists
-    if os.path.exists(output):
+    if isinstance(output, (str, os.PathLike)) and os.path.exists(output):
         if overwrite:
             os.remove(output)
         elif not append:


Test patch:
diff --git a/astropy/io/fits/tests/test_connect.py b/astropy/io/fits/tests/test_connect.py
index 05322fe837..c8b16a608f 100644
--- a/astropy/io/fits/tests/test_connect.py
+++ b/astropy/io/fits/tests/test_connect.py
@@ -1,6 +1,7 @@
 import contextlib
 import gc
 import warnings
+from io import BytesIO
 from pathlib import Path
 
 import numpy as np
@@ -74,6 +75,15 @@ class TestSingleTable:
             t1.write(Path(filename), format="fits", overwrite=True)
         t1.write(Path(tmp_path / filename), format="fits", overwrite=True)
 
+    def test_write_to_fileobj(self):
+        # regression test for https://github.com/astropy/astropy/issues/17703
+        t = Table(self.data)
+        buff = BytesIO()
+        t.write(buff, format="fits")
+        buff.seek(0)
+        t2 = Table.read(buff)
+        assert equal_data(t2, t)
+
     def test_simple(self, tmp_path):
         filename = tmp_path / "test_simple.fts"
         t1 = Table(self.data)