Repository: scverse/scanpy

Issue:
String conversion of `qc_vars` into a `Collection` when a `str` input is provided in `calculate_qc_metrics`
### What kind of feature would you like to request?

Additional function parameters / changed functionality / changed defaults?

### Please describe your wishes

I often make the mistake of providing a single string input to the `qc_vars` argument in `calculate_qc_metrics` which always fails with a `KeyError` because it beings to iterate over the string. This could be a very simple addition to just convert the argument into a `list` if a `str` is provided as input. 

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/scverse/scanpy/pull/2859?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Comparison is base [(`ddeb820`)](https://app.codecov.io/gh/scverse/scanpy/commit/ddeb820620b48752d41d0903df0475c4a60d7b64?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse) 74.56% compared to head [(`bb3a6c1`)](https://app.codecov.io/gh/scverse/scanpy/pull/2859?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse) 72.74%.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #2859      +/-   ##
==========================================
- Coverage   74.56%   72.74%   -1.83%     
==========================================
  Files         115      115              
  Lines       12713    12678      -35     
==========================================
- Hits         9480     9222     -258     
- Misses       3233     3456     +223     
```


| [Files](https://app.codecov.io/gh/scverse/scanpy/pull/2859?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse) | Coverage Δ | |
|---|---|---|
| [scanpy/preprocessing/\_qc.py](https://app.codecov.io/gh/scverse/scanpy/pull/2859?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse#diff-c2NhbnB5L3ByZXByb2Nlc3NpbmcvX3FjLnB5) | `82.14% <100.00%> (+0.21%)` | :arrow_up: |

... and [27 files with indirect coverage changes](https://app.codecov.io/gh/scverse/scanpy/pull/2859/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse)

</details>
noamteyssier: Sounds good - added an entry to the release note and updated the tests to not use the parameterization. 
ivirshup: Thanks! I've moved the release note to 1.10, since this is more of a feature.

Patch:
diff --git a/docs/release-notes/1.10.0.md b/docs/release-notes/1.10.0.md
index 7d48d4fd..1bb50c89 100644
--- a/docs/release-notes/1.10.0.md
+++ b/docs/release-notes/1.10.0.md
@@ -18,6 +18,7 @@
 * {func}`scanpy.tl.rank_genes_groups` no longer warns that it's default was changed from t-test_overestim_var to t-test {pr}`2798` {smaller}`L Heumos`
 * {func}`scanpy.pp.highly_variable_genes` has new flavor `seurat_v3_paper` that is in its implementation consistent with the paper description in Stuart et al 2018. {pr}`2792` {smaller}`E Roellin`
 * {func}`scanpy.pp.highly_variable_genes` supports dask for the default `seurat` and `cell_ranger` flavors {pr}`2809` {smaller}`P Angerer`
+* Auto conversion of strings to collections in `scanpy.pp.calculate_qc_metrics` {pr}`2859` {smaller}`N Teyssier`
 
 ```{rubric} Docs
 ```
diff --git a/scanpy/preprocessing/_qc.py b/scanpy/preprocessing/_qc.py
index 241afb32..4d1123f6 100644
--- a/scanpy/preprocessing/_qc.py
+++ b/scanpy/preprocessing/_qc.py
@@ -233,7 +233,7 @@ def calculate_qc_metrics(
     *,
     expr_type: str = "counts",
     var_type: str = "genes",
-    qc_vars: Collection[str] = (),
+    qc_vars: Collection[str] | str = (),
     percent_top: Collection[int] | None = (50, 100, 200, 500),
     layer: str | None = None,
     use_raw: bool = False,
@@ -308,6 +308,10 @@ def calculate_qc_metrics(
     if issparse(X):
         X.eliminate_zeros()
 
+    # Convert qc_vars to list if str
+    if isinstance(qc_vars, str):
+        qc_vars = [qc_vars]
+
     obs_metrics = describe_obs(
         adata,
         expr_type=expr_type,


Test patch:
diff --git a/scanpy/tests/test_qc_metrics.py b/scanpy/tests/test_qc_metrics.py
index 06a4d0ce..83971fa2 100644
--- a/scanpy/tests/test_qc_metrics.py
+++ b/scanpy/tests/test_qc_metrics.py
@@ -143,6 +143,16 @@ def test_qc_metrics_format(cls):
         assert np.allclose(adata.var[col], adata_dense.var[col])
 
 
+def test_qc_metrics_format_str_qc_vars():
+    adata_dense, init_var = adata_mito()
+    sc.pp.calculate_qc_metrics(adata_dense, qc_vars="mito", inplace=True)
+    adata = AnnData(X=adata_dense.X, var=init_var.copy())
+    sc.pp.calculate_qc_metrics(adata, qc_vars="mito", inplace=True)
+    assert np.allclose(adata.obs, adata_dense.obs)
+    for col in adata.var:  # np.allclose doesn't like mix of types
+        assert np.allclose(adata.var[col], adata_dense.var[col])
+
+
 def test_qc_metrics_percentage():  # In response to #421
     adata_dense, init_var = adata_mito()
     sc.pp.calculate_qc_metrics(adata_dense, percent_top=[])