Repository: tweag/FawltyDeps

Issue:
Support wildcards in `ignore-unused`.
**Is your feature request related to a problem? Please describe.**

I have a codebase with types stubs and pytest plugins as dev dependencies. When running `fawltydeps` they all appear as false positives (they're not `import`ed directly in our codebase but they're providing pytest fixtures and typing support).

Now, I know I can individually `ignore-unused` in the configuration, but there are a lot of them. So I end up maintaining a rather long list of package names for this tool to ignore (list of `pytest-x` and `types-y`) in addition to the _actual_ dependencies.

**Describe the solution you'd like**

I'm not sure if there's a better way, but something that comes to mind would be to support wildcards:
```yaml
[tool.fawltydeps]
ignore_unused = ["pytest-*", "types-*]
```

```sh
fawltydeps . --ignore-unused="pytest-*" --ignore-unused="types-*"
```

**Describe alternatives you've considered**

This _could maybe_ be automatically handled in the logic of the code, something like how you [handle the `"-stubs"` suffix](https://github.com/tweag/FawltyDeps/blob/499f137177937a06fac11ce6f9b6eb78a422aae3/fawltydeps/packages.py#L87). But I'm not sure that's better.

**Additional context**

Hints from issue comments:
@jherland: Thank you for your request!

I'm not opposed to adding wildcard support for `ignore-unused`, but I'd like to examine the motivation in more detail, first. For one, we certainly want to support type stubs well enough that `ignore-unused` should not be necessary for those.

I started looking at adding `types-*` handling to the same code that handles the `-stubs` suffix, but I quickly discovered that `types-*` packages should _already_ be covered: because we're not matching on the name of the package itself, but rather on the existence of a `FOO-stubs` _import name_ provided by the package (which is then matched against an `import FOO` in your code; as such the type stubs are not _ignored_ by FawltyDeps, but rather _matched_ against your `import`s). This `*-stubs` naming is mandated by https://typing.readthedocs.io/en/latest/spec/distributing.html#stub-only-packages, so I'm wondering why these come out as false positives in your case. A couple of control questions:

1. Are your `types-*` packages following the spec? If they're generally available, feel free to send me a list, and I can check them for you.
2. Are you `import`ing the names that these stubs provide type information for? If you're somehow using type stubs for modules that you're not importing then that would also explain why they initially appear as unused.

Moving on to the `pytest-*` packages: These are not like type stubs, as they're not as directly related to 3rd-party modules/imports in your code. For example, there are plugins like [pytest-celery](https://pypi.org/project/pytest-celery/) that pair up with the [celery](https://pypi.org/project/celery/) package, but [pytest-analyzer](https://pypi.org/project/pytest-analyzer/) has nothing to do with the [analyzer](https://pypi.org/project/analyzer/) package.

I'd rather classify pytest plugins in our "tool" category, for which we already provide a [default list here](https://github.com/tweag/FawltyDeps/blob/499f137177937a06fac11ce6f9b6eb78a422aae3/fawltydeps/settings.py#L104). (of course, as soon as you provide your own `ignore-unused` you override this list...)

Adding `pytest-*` to this list is probably a good idea.

But of course, in order to do so, we must in fact support wildcards,  and I don't see why these should not be generally available to our users, as well. So let's add those. But let's also make sure that you don't need to use them in the first place 😄 @samcunliffe: Yeah, 

> I'm not opposed to adding wildcard support for ignore-unused, but I'd like to examine the motivation in more detail, first. For one, we certainly want to support type stubs well enough that ignore-unused should not be necessary for those.

Sorry, I think my brain jumped to a solution.

---

As for types, I have the following flagged (the code under scrutiny is closed-source 😞 so I can't show you anything more useful)

```
- 'types-PyYAML'
- 'types-Pygments'
- 'types-colorama'
- 'types-pyOpenSSL'
- 'types-requests'
- 'types-setuptools'
- 'types-ujson'
```

I have (correctly) not had, e.g. `types-python-dateutils`, flagged and it _is_ one of my explicit dependencies, I'm directly `import` ing `dateutils`. So the direct import stuff is working. The other types stubs are included as they're needed to stop `mypy` flagging. So... the tool is 'working' in that they're directly unused. Probably I should retract my claim "false positives" here. I just want to suppress a whole bunch.

----

For pytest plugins I have a small list:
```
- 'pyfakefs'
- 'pytest-cov'
- 'pytest-freezer'
- 'pytest-mock'
```

Which, as you say: are all plugins that aren't imported, they provide fixtures (or coverage reporting). For what it's worth we also have `pytest-asyncio` and that's correctly not flagged as we have `import pytest_asyncio` in our codebase.
@jherland: If you're able to test with PR #479, I believe that should solve most of your issue: With that you should indeed be able to configure FawltyDeps exactly as you indicate in the issue description above.@samcunliffe: Yes!

I'm at `0424cc3  HEAD -> jherland/support-ignore-wildcards` running over a project with:

```toml
[project.optional-dependencies]
dev = [
    "httpx",
    "pre-commit",
    "pyfakefs",
    "pytest",
    "pytest-asyncio",
    "pytest-freezer",
    "pytest-cov",
    "pytest-mock",
    'types-PyYAML',
    'types-Pygments',
    'types-colorama',
    'types-pyOpenSSL',
    'types-requests',
    'types-setuptools',
    'types-ujson',
    "faker",
]
```


Running `fawltydeps` with no config just out of the box:
```
These dependencies appear to be unused (i.e. not imported):
- 'ipython'
- 'jupyter'
- 'pyfakefs'
- 'types-PyYAML'
- 'types-Pygments'
- 'types-colorama'
- 'types-pyOpenSSL'
- 'types-requests'
- 'types-setuptools'
- 'types-ujson'
```
(Python plugins matching `pytest-*` are gone).

And if I run `fawltydeps  --ignore-unused="types-*" --ignore-unused="pytest-*" --ignore-unused="pre-commit"` I can also suppress all of the others...
```
These dependencies appear to be unused (i.e. not imported):
- 'ipython'
- 'jupyter'
- 'pyfakefs'
```

(N.b. [`pyfakefs` is a `pytest` plugin](https://pytest-pyfakefs.readthedocs.io/en/latest/) that doesn't follow the naming convention.)

---

> believe that should solve most of your issue

I think it solves all?

Solution patch:
diff --git a/docs/configuration.md b/docs/configuration.md
index d27b98c..e4565c0 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -31,14 +31,16 @@ Here is a complete list of configuration directives we support:
   the current directory, i.e. like `pyenvs = ["."]`.
 - `ignore_undeclared`: A list of specific dependencies to ignore when reporting
   undeclared dependencies, for example: `["some_module", "some_other_module"]`.
-  The default is the empty list: `ignore_undeclared = []`.
+  Simple `*`-wildcards are supported. The default is the empty list:
+  `ignore_undeclared = []`.
 - `ignore_unused`: A list of specific dependencies to ignore when reporting
   unused dependencies, for example: `["black", "mypy", "some_other_module"]`.
-  The default is a list including common development tools. However, you have the
-  flexibility to overwrite this list according to your project's specific requirements.
-  For the complete default list, please see the `DEFAULT_IGNORE_UNUSED`
-  variable in the [`fawltydeps/settings.py`](https://github.com/tweag/FawltyDeps/blob/main/fawltydeps/settings.py) file
-  in the repository.
+  Simple `*`-wildcards are supported, so e.g. `"pytest-*"` can be used to ignore
+  many pytest plugins. If `ignore_unused` is left unconfigured, it defaults to a
+  list of common development tools (see the `DEFAULT_IGNORE_UNUSED` variable in
+  [`fawltydeps/settings.py`](https://github.com/tweag/FawltyDeps/blob/main/fawltydeps/settings.py)
+  for the complete list). Once you specify it, however, your list will _replace_
+  the default list.
 - `deps_parser_choice`: Manually select which format to use for parsing
   declared dependencies. Must be one of `"requirements.txt"`, `"setup.py"`,
   `"setup.cfg"`, `"pyproject.toml"`, `"pixi.toml"`, `"environment.yml"`, or
diff --git a/docs/how_to_guides.md b/docs/how_to_guides.md
index 51c40f8..589a5ba 100644
--- a/docs/how_to_guides.md
+++ b/docs/how_to_guides.md
@@ -36,7 +36,7 @@ in one of these ways:
 How not to display tools like `black` and `pylint` in _unused dependencies_?
 
 By default, all packages declared as dependencies by your project are included
-in the FawltyDeps analysis, even if they only contain tools that were not meant
+in the FawltyDeps analysis. This includes tools that are typically not meant
 to be `import`ed, but rather meant to be run by, say, in a pre-commit hook or a
 CI script. In such cases you may use either:
 
diff --git a/docs/usage.md b/docs/usage.md
index 54078b3..94866fb 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -175,6 +175,12 @@ to customize the ignore list. By providing your own list of dependencies with
 this option, you can effectively overwrite the default list. For example:
 `--ignore-unused black mypy some_other_module`
 
+Both `--ignore-undeclared` and `--ignore-unused` support use of simple
+`*`-wildcards to ignore multiple dependencies, for example
+`--ignore-unused types-*` to ignore type stubs dependencies, or
+`--ignore-unused *pre-commit*` to ignore the pre-commit tool and associated
+addons.
+
 ## Output formats
 
 The default output from FawltyDeps is a summary outlining the relevant
diff --git a/fawltydeps/check.py b/fawltydeps/check.py
index d171581..154b366 100644
--- a/fawltydeps/check.py
+++ b/fawltydeps/check.py
@@ -1,6 +1,7 @@
 """Compare imports and dependencies to determine undeclared and unused deps."""
 
 import logging
+import re
 from collections.abc import Iterable
 from itertools import groupby
 
@@ -16,6 +17,18 @@ from fawltydeps.types import (
 logger = logging.getLogger(__name__)
 
 
+def is_ignored(name: str, ignore_set: set[str]) -> bool:
+    """Return True iff 'name' is in 'ignore_set'."""
+    if name in ignore_set:  # common case
+        return True
+    patterns = [
+        ".*".join(re.escape(fragment) for fragment in word.split("*"))
+        for word in ignore_set
+        if "*" in word
+    ]
+    return any(re.fullmatch(pattern, name) for pattern in patterns)
+
+
 def calculate_undeclared(
     imports: list[ParsedImport],
     resolved_deps: dict[str, Package],
@@ -32,7 +45,8 @@ def calculate_undeclared(
     undeclared = [
         i
         for i in imports
-        if i.name not in declared_names.union(settings.ignore_undeclared)
+        if not is_ignored(i.name, settings.ignore_undeclared)
+        and i.name not in declared_names
     ]
     undeclared.sort(key=lambda i: i.name)  # groupby requires pre-sorting
     return [
@@ -61,7 +75,7 @@ def calculate_unused(
     unused = [
         dep
         for dep in declared_deps
-        if (dep.name not in settings.ignore_unused)
+        if not is_ignored(dep.name, settings.ignore_unused)
         and not resolved_deps[dep.name].is_used(imported_names)
     ]
     unused.sort(key=lambda dep: dep.name)  # groupby requires pre-sorting
diff --git a/fawltydeps/packages.py b/fawltydeps/packages.py
index 4fc7d51..a839af9 100644
--- a/fawltydeps/packages.py
+++ b/fawltydeps/packages.py
@@ -70,27 +70,45 @@ class Package:
         (e.g. typing-extensions), but once installed, it is presented in the
         context of the local environment with a slightly different spelling
         (e.g. typing_extension).
+
+        Exception: Stubs-only packages have a "-stubs" suffix on their import
+        name that is _not_ normalized to "_stubs".
         """
-        return package_name.lower().replace("-", "_")
+        suffix = ""
+        if package_name.endswith("-stubs"):
+            package_name = package_name[:-6]
+            suffix = "-stubs"
+        return package_name.lower().replace("-", "_") + suffix
 
     @cached_property
     def normalized_name(self) -> str:
         """Package name in normalized form."""
         return self.normalize_name(self.package_name)
 
-    def has_type_stubs(self) -> set[str]:
-        """Return a set of import names without type stubs suffix."""
-        provides_stubs_for = [
+    def stubbed_imports(self) -> set[str]:
+        """Return a set of import names without the type stubs suffix.
+
+        For example, a package that has .import_names == {"foo", "bar-stubs"},
+        will return {"bar"} from this method, indicating that it provides type
+        stubs for the "bar" import.
+
+        This allows stub-only packages to be matched against the import names
+        for which they provide type stubs. Stub-only packages are described in
+        https://typing.readthedocs.io/en/latest/spec/distributing.html, and the
+        "-stubs" suffix is mandated here. This makes it relatively safe for us
+        to assume that "FOO-stubs" is indeed directly associated with the "FOO"
+        module.
+        """
+        return {
             import_name[: -len("-stubs")]
             for import_name in self.import_names
             if import_name.endswith("-stubs")
-        ]
-        return set(provides_stubs_for)
+        }
 
     def is_used(self, imported_names: Iterable[str]) -> bool:
         """Return True iff this package is among the given import names."""
         return bool(self.import_names.intersection(imported_names)) or bool(
-            self.has_type_stubs().intersection(imported_names)
+            self.stubbed_imports().intersection(imported_names)
         )
 
 
diff --git a/fawltydeps/settings.py b/fawltydeps/settings.py
index 9e91f63..d813378 100644
--- a/fawltydeps/settings.py
+++ b/fawltydeps/settings.py
@@ -130,6 +130,7 @@ DEFAULT_IGNORE_UNUSED = {
     "nox",
     "pre-commit",
     "pytest",
+    "pytest-*",
     "tox",
     # Building and Packaging Tools
     "twine",


Test patch:
diff --git a/tests/real_projects/peppy.toml b/tests/real_projects/peppy.toml
index 612c164..c720479 100644
--- a/tests/real_projects/peppy.toml
+++ b/tests/real_projects/peppy.toml
@@ -64,8 +64,6 @@ unused_deps = [
     "mock",
     "peppy",  # Declared in ./requirements/requirements-doc.txt
     "pydoc-markdown",
-    "pytest-cov",
-    "pytest-remotedata",
     # "pyyaml",  # found via SysPathResolver due to fawltydeps' own yaml dependency
 ]
 
@@ -100,6 +98,4 @@ unused_deps = [
     "mock",
     "peppy",
     "pydoc-markdown",
-    "pytest-cov",
-    "pytest-remotedata",
 ]
diff --git a/tests/real_projects/requests.toml b/tests/real_projects/requests.toml
index 2e42f7b..bee5a0e 100644
--- a/tests/real_projects/requests.toml
+++ b/tests/real_projects/requests.toml
@@ -69,7 +69,4 @@ unused_deps =  [
     "Sphinx",
     "httpbin",
     "markupsafe",
-    "pytest-cov",
-    "pytest-httpbin",
-    "pytest-mock",
 ]
diff --git a/tests/test_compare_imports_to_dependencies.py b/tests/test_compare_imports_to_dependencies.py
index 8cb042a..788cdd3 100644
--- a/tests/test_compare_imports_to_dependencies.py
+++ b/tests/test_compare_imports_to_dependencies.py
@@ -4,7 +4,7 @@ import logging
 
 import pytest
 
-from fawltydeps.check import calculate_undeclared, calculate_unused
+from fawltydeps.check import calculate_undeclared, calculate_unused, is_ignored
 from fawltydeps.settings import Settings
 
 from .utils import test_vectors
@@ -41,3 +41,66 @@ def test_calculate_unused(vector):
         settings=settings,
     )
     assert actual == vector.expect_unused_deps
+
+
+@pytest.mark.parametrize(
+    ("name", "ignore_set", "expect"),
+    [
+        pytest.param(
+            "baz",
+            {"bar", "baz"},
+            True,
+            id="verbatim match",
+        ),
+        pytest.param(
+            "foo",
+            {"bar", "baz"},
+            False,
+            id="simple non-match",
+        ),
+        pytest.param(
+            "pytest-foo",
+            {"bar", "baz", "pytest"},
+            False,
+            id="no automatic prefix match",
+        ),
+        pytest.param(
+            "pytest-foo",
+            {"bar", "baz", "pytest-*"},
+            True,
+            id="matching wildcard suffix",
+        ),
+        pytest.param(
+            "pytest-foo",
+            {"bar", "baz", "*-foo"},
+            True,
+            id="matching wildcard prefix",
+        ),
+        pytest.param(
+            "pytest-foo",
+            {"*"},
+            True,
+            id="match everything",
+        ),
+        pytest.param(
+            "pytest-foo",
+            {"*test-*"},
+            True,
+            id="matching multi-wildcard",
+        ),
+        pytest.param(
+            "pytestfoo",
+            {"*test-*"},
+            False,
+            id="non-matching multi-wildcard",
+        ),
+        pytest.param(
+            "pytest-foo",
+            {"py?test-*", "p?test-*", "p[xyz]test-*", "{py,foo}test-*"},
+            False,
+            id="do not accept other potential wildcard syntaxes",
+        ),
+    ],
+)
+def test_is_ignored(name, ignore_set, expect):
+    assert is_ignored(name, ignore_set) == expect
diff --git a/tests/test_packages.py b/tests/test_packages.py
index cca80c3..55017c5 100644
--- a/tests/test_packages.py
+++ b/tests/test_packages.py
@@ -40,28 +40,46 @@ def test_package__empty_package__matches_nothing():
     [
         pytest.param(
             "foobar",
-            ["foobar", "and", "other", "names"],
-            ["only", "other", "names", "foo_bar", "Foobar", "FooBar", "FOOBAR"],
+            ["foobar"],
+            ["foo_bar", "Foobar", "FooBar", "FOOBAR"],
             id="simple_lowercase_name__matches_itself_only",
         ),
         pytest.param(
             "FooBar",
-            ["foobar", "and", "other", "names"],
-            ["only", "other", "names", "foo_bar", "Foobar", "FooBar", "FOOBAR"],
+            ["foobar"],
+            ["foo_bar", "Foobar", "FooBar", "FOOBAR"],
             id="mixed_case_name__matches_lowercase_only",
         ),
         pytest.param(
             "typing-extensions",
-            ["typing_extensions", "and", "other", "names"],
+            ["typing_extensions"],
             ["typing-extensions", "typingextensions"],
             id="name_with_hyphen__matches_name_with_underscore_only",
         ),
         pytest.param(
             "Foo-Bar",
-            ["foo_bar", "and", "other", "names"],
+            ["foo_bar"],
             ["foo-bar", "Foobar", "FooBar", "FOOBAR"],
             id="weird_name__matches_normalized_name_only",
         ),
+        pytest.param(
+            "foo-stubs",
+            ["foo"],
+            ["foo_stubs"],
+            id="type-stubs__matches_without_-stubs_suffix",
+        ),
+        pytest.param(
+            "foo-stubs",
+            ["foo-stubs"],
+            ["foo_stubs"],
+            id="type-stubs__preserve_-stubs_suffix_in_normalization",
+        ),
+        pytest.param(
+            "foobar",
+            ["other_module", "another", "foobar"],
+            ["other_module", "another"],
+            id="find_one_match_among_many_nonmatches",
+        ),
     ],
 )
 def test_package__identity_mapping(
@@ -81,58 +99,72 @@ def test_package__identity_mapping(
         pytest.param(
             "foobar",
             {"foobar"},
-            ["foobar", "and", "other", "names"],
-            ["only", "other", "names", "foo_bar", "Foobar", "FooBar", "FOOBAR"],
+            ["foobar"],
+            ["foo_bar", "Foobar", "FooBar", "FOOBAR"],
             id="simple_name_mapped_to_itself__matches_itself_only",
         ),
         pytest.param(
             "FooBar",
             {"FooBar"},
-            ["FooBar", "and", "other", "names"],
-            ["only", "other", "names", "foo_bar", "foobar", "FOOBAR"],
+            ["FooBar"],
+            ["foo_bar", "foobar", "FOOBAR"],
             id="mixed_case_name_mapped_to_itself__matches_exact_spelling_only",
         ),
         pytest.param(
             "typing-extensions",
             {"typing_extensions"},
-            ["typing_extensions", "and", "other", "names"],
+            ["typing_extensions"],
             ["typing-extensions", "typingextensions"],
             id="hyphen_name_mapped_to_underscore_name__matches_only_underscore_name",
         ),
         pytest.param(
             "Foo-Bar",
             {"blorp"},
-            ["blorp", "and", "other", "names"],
+            ["blorp"],
             ["Foo-Bar", "foo-bar", "foobar", "FooBar", "FOOBAR", "Blorp", "BLORP"],
             id="weird_name_mapped_diff_name__matches_diff_name_only",
         ),
         pytest.param(
             "foobar",
             {"foo", "bar", "baz"},
-            ["foo", "and", "other", "names"],
-            ["foobar", "and", "other", "names"],
+            ["foo"],
+            ["foobar"],
             id="name_with_three_imports__matches_first_import",
         ),
         pytest.param(
             "foobar",
             {"foo", "bar", "baz"},
-            ["bar", "and", "other", "names"],
-            ["foobar", "and", "other", "names"],
+            ["bar"],
+            ["foobar"],
             id="name_with_three_imports__matches_second_import",
         ),
         pytest.param(
             "foobar",
             {"foo", "bar", "baz"},
-            ["baz", "and", "other", "names"],
-            ["foobar", "and", "other", "names"],
+            ["baz"],
+            ["foobar"],
             id="name_with_three_imports__matches_third_import",
         ),
         pytest.param(
             "types-requests",
             {"requests-stubs"},
-            ["requests", "and", "other", "names"],
-            ["types_requests", "and", "other", "names"],
-            id="name_with_stubs_suffix__matches_name_without_suffix",
+            ["requests-stubs"],
+            ["requests_stubs"],
+            id="name_with_stubs_suffix__matches_name_with_stubs_suffix",
+        ),
+        pytest.param(
+            "types-requests",
+            {"requests-stubs"},
+            ["requests"],
+            ["types_requests"],
+            id="name_with_stubs_suffix__matches_name_without_stubs_suffix",
+        ),
+        pytest.param(
+            "foobar",
+            {"foo", "bar", "baz"},
+            ["other_module", "another", "bar"],
+            ["other_module", "another", "foobar"],
+            id="find_one_match_among_many_nonmatches",
         ),
     ],
 )
@@ -158,7 +190,7 @@ def test_package__local_env_mapping(
                 """\
                 apache-airflow = ["airflow"]
                 attrs = ["attr", "attrs"]
-            """
+                """
             ],
             None,
             {
@@ -211,6 +243,20 @@ def test_package__local_env_mapping(
             },
             id="well_formated_input_2files_and_config__parses_correctly",
         ),
+        pytest.param(
+            [
+                """\
+                types_requests = ["requests-stubs"]
+                """
+            ],
+            None,
+            {
+                "types_requests": Package(
+                    "types_requests", {"requests-stubs"}, UserDefinedMapping
+                ),
+            },
+            id="stubs_only_package",
+        ),
     ],
 )
 def test_user_defined_mapping__well_formated_input_file__parses_correctly(
diff --git a/tests/utils.py b/tests/utils.py
index ec6c3d4..5b0f227 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -91,7 +91,7 @@ def resolved_factory(*deps: str) -> dict[str, Package]:
         if imports is not None:  # exists in local env
             return Package(dep, imports, SysPathPackageResolver)
         # fall back to identity mapping
-        return Package(dep, {dep}, IdentityMapping)
+        return Package(dep, {Package.normalize_name(dep)}, IdentityMapping)
 
     return {dep: make_package(dep) for dep in deps}
 
@@ -363,4 +363,28 @@ test_vectors = [
             UndeclaredDependency("foo", [Location("<stdin>")], {"foo1", "foo2"}),
         ],
     ),
+    FDTestVector(
+        "ignore_unused_with_wildcard__can_match_multiple_deps",
+        declared_deps=deps_factory("pytest-foo", "pytest-bar", "not-pytest-baz"),
+        ignore_unused=["pytest-*"],
+        expect_resolved_deps=resolved_factory(
+            "pytest-foo", "pytest-bar", "not-pytest-baz"
+        ),
+        expect_unused_deps=unused_factory("not-pytest-baz"),
+    ),
+    FDTestVector(
+        "ignore_undeclare_with_wildcard__can_match_multiple_imports",
+        imports=imports_factory("foo", "bar", "baz"),
+        ignore_undeclared=["*a*"],
+        expect_undeclared_deps=undeclared_factory("foo"),
+    ),
+    FDTestVector(
+        "mixed_deps__report_only_non_ignored_unused_with_wildcard",
+        imports=imports_factory("foo", "bar", "baz"),
+        declared_deps=deps_factory("foo", "foo-stubs", "pytest-bar", "baz"),
+        ignore_unused=["pytest-*"],
+        expect_resolved_deps=resolved_factory("foo", "foo-stubs", "pytest-bar", "baz"),
+        expect_undeclared_deps=undeclared_factory("bar"),
+        expect_unused_deps=[],
+    ),
 ]