Repository: scverse/scanpy

Issue:
Leiden and louvain params not saved to matching `key_added` key in `uns` dictionary 
### Please make sure these conditions are met

- [X] I have checked that this issue has not already been reported.
- [X] I have confirmed this bug exists on the latest version of scanpy.
- [X] (optional) I have confirmed this bug exists on the main branch of scanpy.

### What happened?

Leiden and Louvain clustering params are not saved to matching `key_added` key in `uns` dictionary but are ovewritten to hardcoded key instead. One use case is that a user may want to run Leiden/Louvain clustering multiple times with different resolutions / parameters. One may specify different keys to store results under. However, if you do so, the metadata for the parameterization of the clustering algorithms are overwritten because the lines below do not respect the user provided `key_added` parameter.

I think the desired behavior is to store data under `adata.uns[key_added]["params"]`

I think I've found the pertinent lines below. Happy to submit a PR if maintainers agree :D.
- https://github.com/scverse/scanpy/blob/91ea0fbb03392795d1506d297d4b4847c646db04/scanpy/tools/_leiden.py#L206
- https://github.com/scverse/scanpy/blob/91ea0fbb03392795d1506d297d4b4847c646db04/scanpy/tools/_louvain.py#L259

### Minimal code sample

```python
sc.tl.leiden(adata, resolution=0.8, key_added="leiden_0.8")
assert "leiden_0.8" not in adata.uns
params = adata.uns["leiden"] 

sc.tl.leiden(adata, resolution=1.2, key_added="leiden_1.2")
assert "leiden_1.2" not in adata.uns
overwritten_params = adata.uns["leiden"] 
assert params == overwritten_params # should fail
```


### Error output

_No response_

### Versions

<details>
Confirmed that params are overwritten in source in main branch. (see permalinks)
</details>



Discussion:

Resolving patch:
diff --git a/scanpy/tools/_leiden.py b/scanpy/tools/_leiden.py
--- a/scanpy/tools/_leiden.py
+++ b/scanpy/tools/_leiden.py
@@ -114,7 +114,7 @@ def leiden(
         Array of dim (number of samples) that stores the subgroup id
         (``'0'``, ``'1'``, ...) for each cell.
 
-    `adata.uns['leiden']['params']` : :class:`dict`
+    `adata.uns['leiden' | key_added]['params']` : :class:`dict`
         A dict with the values for the parameters `resolution`, `random_state`,
         and `n_iterations`.
     """
@@ -202,8 +202,8 @@ def leiden(
         categories=natsorted(map(str, np.unique(groups))),
     )
     # store information on the clustering parameters
-    adata.uns["leiden"] = {}
-    adata.uns["leiden"]["params"] = dict(
+    adata.uns[key_added] = {}
+    adata.uns[key_added]["params"] = dict(
         resolution=resolution,
         random_state=random_state,
         n_iterations=n_iterations,
diff --git a/scanpy/tools/_louvain.py b/scanpy/tools/_louvain.py
--- a/scanpy/tools/_louvain.py
+++ b/scanpy/tools/_louvain.py
@@ -133,7 +133,7 @@ def louvain(
         Array of dim (number of samples) that stores the subgroup id
         (``'0'``, ``'1'``, ...) for each cell.
 
-    `adata.uns['louvain']['params']` : :class:`dict`
+    `adata.uns['louvain' | key_added]['params']` : :class:`dict`
         A dict with the values for the parameters `resolution`, `random_state`,
         and `n_iterations`.
     """
@@ -255,8 +255,8 @@ def louvain(
         values=groups.astype("U"),
         categories=natsorted(map(str, np.unique(groups))),
     )
-    adata.uns["louvain"] = {}
-    adata.uns["louvain"]["params"] = dict(
+    adata.uns[key_added] = {}
+    adata.uns[key_added]["params"] = dict(
         resolution=resolution,
         random_state=random_state,
     )


Test patch:
diff --git a/scanpy/tests/external/test_hashsolo.py b/scanpy/tests/external/test_hashsolo.py
--- a/scanpy/tests/external/test_hashsolo.py
+++ b/scanpy/tests/external/test_hashsolo.py
@@ -32,4 +32,4 @@ def test_cell_demultiplexing():
     )
     negatives = ["Negative"] * 10
     classification = doublets + classes + negatives
-    assert all(test_data.obs["Classification"].astype(str) == classification)
+    assert test_data.obs["Classification"].astype(str).tolist() == classification
diff --git a/scanpy/tests/notebooks/test_paga_paul15_subsampled.py b/scanpy/tests/notebooks/test_paga_paul15_subsampled.py
--- a/scanpy/tests/notebooks/test_paga_paul15_subsampled.py
+++ b/scanpy/tests/notebooks/test_paga_paul15_subsampled.py
@@ -8,10 +8,9 @@
 from pathlib import Path
 
 import numpy as np
+import pytest
 from matplotlib.testing import setup
 
-setup()
-
 import scanpy as sc
 from scanpy.testing._helpers.data import paul15
 from scanpy.testing._pytest.marks import needs
@@ -20,9 +19,11 @@
 ROOT = HERE / "_images_paga_paul15_subsampled"
 
 
+@pytest.mark.skip(reason="Broken, needs fixing")
 @needs.igraph
 @needs.louvain
 def test_paga_paul15_subsampled(image_comparer, plt):
+    setup()
     save_and_compare_images = partial(image_comparer, ROOT, tol=25)
 
     adata = paul15()
@@ -59,16 +60,8 @@ def test_paga_paul15_subsampled(image_comparer, plt):
         [
             [0.0, 0.128553, 0.0, 0.07825, 0.0, 0.0, 0.238741, 0.0, 0.0, 0.657049],
             [
-                0.128553,
-                0.0,
-                0.480676,
-                0.257505,
-                0.533036,
-                0.043871,
-                0.0,
-                0.032903,
-                0.0,
-                0.087743,
+                *[0.128553, 0.0, 0.480676, 0.257505, 0.533036],
+                *[0.043871, 0.0, 0.032903, 0.0, 0.087743],
             ],
         ]
     )
diff --git a/scanpy/tests/test_clustering.py b/scanpy/tests/test_clustering.py
--- a/scanpy/tests/test_clustering.py
+++ b/scanpy/tests/test_clustering.py
@@ -31,9 +31,12 @@ def test_leiden_basic(adata_neighbors, flavor, resolution, n_iterations):
         resolution=resolution,
         n_iterations=n_iterations,
         directed=(flavor == "leidenalg"),
+        key_added="leiden_custom",
+    )
+    assert adata_neighbors.uns["leiden_custom"]["params"]["resolution"] == resolution
+    assert (
+        adata_neighbors.uns["leiden_custom"]["params"]["n_iterations"] == n_iterations
     )
-    assert adata_neighbors.uns["leiden"]["params"]["resolution"] == resolution
-    assert adata_neighbors.uns["leiden"]["params"]["n_iterations"] == n_iterations
 
 
 @needs.leidenalg
@@ -186,6 +189,22 @@ def test_louvain_basic(adata_neighbors):
     sc.tl.louvain(adata_neighbors, flavor="igraph")
 
 
+@needs.louvain
+@pytest.mark.parametrize("random_state", [10, 999])
+@pytest.mark.parametrize("resolution", [0.9, 1.1])
+def test_louvain_custom_key(adata_neighbors, resolution, random_state):
+    sc.tl.louvain(
+        adata_neighbors,
+        key_added="louvain_custom",
+        random_state=random_state,
+        resolution=resolution,
+    )
+    assert (
+        adata_neighbors.uns["louvain_custom"]["params"]["random_state"] == random_state
+    )
+    assert adata_neighbors.uns["louvain_custom"]["params"]["resolution"] == resolution
+
+
 @needs.louvain
 @needs.igraph
 def test_partition_type(adata_neighbors):
@@ -193,3 +212,26 @@ def test_partition_type(adata_neighbors):
 
     sc.tl.louvain(adata_neighbors, partition_type=louvain.RBERVertexPartition)
     sc.tl.louvain(adata_neighbors, partition_type=louvain.SurpriseVertexPartition)
+
+
+@pytest.mark.parametrize(
+    "clustering,default_key,default_res,custom_resolutions",
+    [
+        pytest.param(sc.tl.leiden, "leiden", 0.8, [0.9, 1.1], marks=needs.leidenalg),
+        pytest.param(sc.tl.louvain, "louvain", 0.8, [0.9, 1.1], marks=needs.louvain),
+    ],
+)
+def test_clustering_custom_key(
+    adata_neighbors, clustering, default_key, default_res, custom_resolutions
+):
+    custom_keys = [f"{default_key}_{res}" for res in custom_resolutions]
+
+    # Run clustering with default key, then custom keys
+    clustering(adata_neighbors, resolution=default_res)
+    for key, res in zip(custom_keys, custom_resolutions):
+        clustering(adata_neighbors, resolution=res, key_added=key)
+
+    # ensure that all clustering parameters are added to user provided keys and not overwritten
+    assert adata_neighbors.uns[default_key]["params"]["resolution"] == default_res
+    for key, res in zip(custom_keys, custom_resolutions):
+        assert adata_neighbors.uns[key]["params"]["resolution"] == res