Repository: dask/distributed

Issue:
 Make writing performance reports to remote filesystems easier
Earlier today I wanted to upload a performance report to an S3 bucket but then noticed that `performance_report(...)` doesn't have a `storage_options` parameter. This isn't a huge deal as one can just create a performance report locally and then use `s3fs` to upload that local file to S3. That said it would be nice is `performance_report` could handle `s3://`, etc. protocols. 

Similar to https://github.com/dask/distributed/issues/5659


Discussion:
Hi @jrbourbeau ! I'd like to work on this if it's ok for you. Do you have any suggestions on how to start? The idea would be to add `storage_options` to the `performance_report` class, right? 

@ivojuroro that's great. Yeah that's right. This PR (#5863) did something similar for cluster dumps so may be good inspiration.
@jacobtomlinson Great, I'll check and I'll let you know how it goes. Thanks !
Hi, @ivojuroro are still working on this issue? Would you mind if I take it?

Resolving patch:
diff --git a/distributed/client.py b/distributed/client.py
--- a/distributed/client.py
+++ b/distributed/client.py
@@ -5724,20 +5724,23 @@ class performance_report:
     mode: str, optional
         Mode parameter to pass to :func:`bokeh.io.output.output_file`. Defaults to ``None``.
 
+    storage_options: dict, optional
+         Any additional arguments to :func:`fsspec.open` when writing to a URL.
+
     Examples
     --------
     >>> with performance_report(filename="myfile.html", stacklevel=1):
     ...     x.compute()
-
-    $ python -m http.server
-    $ open myfile.html
     """
 
-    def __init__(self, filename="dask-report.html", stacklevel=1, mode=None):
+    def __init__(
+        self, filename="dask-report.html", stacklevel=1, mode=None, storage_options=None
+    ):
         self.filename = filename
         # stacklevel 0 or less - shows dask internals which likely isn't helpful
         self._stacklevel = stacklevel if stacklevel > 0 else 1
         self.mode = mode
+        self.storage_options = storage_options or {}
 
     async def __aenter__(self):
         self.start = time()
@@ -5747,6 +5750,8 @@ async def __aenter__(self):
         await get_client().get_task_stream(start=0, stop=0)  # ensure plugin
 
     async def __aexit__(self, exc_type, exc_value, traceback, code=None):
+        import fsspec
+
         client = get_client()
         if code is None:
             frames = client._get_computation_code(self._stacklevel + 1, nframes=1)
@@ -5754,7 +5759,9 @@ async def __aexit__(self, exc_type, exc_value, traceback, code=None):
         data = await client.scheduler.performance_report(
             start=self.start, last_count=self.last_count, code=code, mode=self.mode
         )
-        with open(self.filename, "w") as f:
+        with fsspec.open(
+            self.filename, mode="w", compression="infer", **self.storage_options
+        ) as f:
             f.write(data)
 
     def __enter__(self):


Test patch:
diff --git a/distributed/tests/test_client.py b/distributed/tests/test_client.py
--- a/distributed/tests/test_client.py
+++ b/distributed/tests/test_client.py
@@ -6614,8 +6614,9 @@ async def f(dask_worker):
 
 @pytest.mark.slow
 @pytest.mark.skipif(WINDOWS, reason="frequently kills off the whole test suite")
+@pytest.mark.parametrize("local", [True, False])
 @gen_cluster(client=True, nthreads=[("127.0.0.1", 2)] * 2)
-async def test_performance_report(c, s, a, b):
+async def test_performance_report(c, s, a, b, local):
     pytest.importorskip("bokeh")
     da = pytest.importorskip("dask.array")
 
@@ -6628,8 +6629,15 @@ async def f(stacklevel, mode=None):
         """
         x = da.random.random((1000, 1000), chunks=(100, 100))
         with tmpfile(extension="html") as fn:
+            urlpath = fn
+            if not local:
+                pytest.importorskip("fsspec")
+                # Make it look like an fsspec path
+                urlpath = f"file://{fn}"
             async with performance_report(
-                filename=fn, stacklevel=stacklevel, mode=mode
+                filename=urlpath,
+                stacklevel=stacklevel,
+                mode=mode,
             ):
                 await c.compute((x + x.T).sum())