Skip to content

Skip self-pairs in forbidden contracts so wildcards can include the source#361

Merged
seddonym merged 3 commits into
seddonym:mainfrom
Spenhouet:forbidden-skip-self-pair
Jun 23, 2026
Merged

Skip self-pairs in forbidden contracts so wildcards can include the source#361
seddonym merged 3 commits into
seddonym:mainfrom
Spenhouet:forbidden-skip-self-pair

Conversation

@Spenhouet

Copy link
Copy Markdown
Contributor

Closes #360.

Problem

A forbidden contract is the natural way to enforce that a package is a leaf, i.e. it may import only itself (and external libraries) and none of its sibling packages, while siblings may still import it. The obvious config is:

[[tool.importlinter.contracts]]
type = "forbidden"
source_modules = ["mypackage.core"]
forbidden_modules = ["mypackage.*"]

But mypackage.* also matches mypackage.core itself, and the check fails with:

Modules have shared descendants.

because the mypackage.core -> mypackage.core pair is passed to find_shortest_chains. ignore_imports does not help: it removes edges from the graph, not the module from the expanded forbidden set, so the self-pair is still evaluated. The only workarounds today are enumerating every sibling by hand (and keeping the list in sync as packages are added) or writing a custom contract type.

Change

In ForbiddenContract.check, skip any (source, forbidden) pair that refers to the same module, or, when treated as packages, where one contains the other. A module cannot be meaningfully forbidden from importing itself, so skipping is the correct semantics rather than an error. This lets a wildcard such as mypackage.* be used as a forbidden module even when a source module is one of the modules it matches, and newly added sibling packages are covered automatically.

The guard runs before both the direct and indirect (allow_indirect_imports) code paths, so neither raises nor reports intra-package imports for the skipped pair.

Tests

Added unit tests in tests/unit/contracts/test_forbidden.py:

  • source module equal to a forbidden module is skipped (kept), for as_packages True and False
  • a wildcard that matches the source module skips the self-pair and keeps an otherwise-clean source
  • a wildcard that matches the source module still detects real imports to sibling packages

Docs

  • Documented the behavior on the forbidden contract page, with the leaf-package example.
  • Added a release note and an authors entry.

Per CONTRIBUTING, this follows up the filed issue #360; happy to adjust the approach (e.g. an opt-in flag, or an exclusion syntax in module expressions) if you'd prefer a different direction.

Sebastian Penhouet and others added 2 commits June 17, 2026 14:04
…ource

A forbidden contract with a wildcard forbidden module that also matches one of
its source modules (e.g. source_modules = mypackage.core, forbidden_modules =
mypackage.*) previously failed with "Modules have shared descendants", because
the source/forbidden pair referring to the same package was passed to
find_shortest_chains.

Skip any pair where the source and forbidden module are the same module, or
(when treated as packages) one contains the other. A module cannot be forbidden
from importing itself, so skipping is the correct semantics, and it lets a
wildcard cover all sibling packages, including ones added later, without listing
them or excluding the source by hand.

Closes seddonym#360

@seddonym seddonym left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your submission, and apologies it has taken a while to get to it.

I agree this is has been a problem and I agree with the way you've solved it - nice work.

The implementation is basically there, I've just requested a couple of small tweaks, and have pushed some alternative documentation. The main thing to add is test coverage for the as_packages code. Let me know if you have any concerns or questions!

Comment thread src/importlinter/contracts/forbidden.py Outdated
"""
if source_module == forbidden_module:
return True
if self.as_packages:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we move this method to be a function that has as_packages passed in? Makes it more functional - it's entirely dependent on its inputs.

Comment thread src/importlinter/contracts/forbidden.py Outdated
"""
if source_module == forbidden_module:
return True
if self.as_packages:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I comment out this if block, the tests still pass. Could we add test coverage for this bit?

Comment thread docs/contract_types/forbidden.md Outdated

External packages may also be forbidden.

If a source module and a forbidden module refer to the same module (or, when treated as packages,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a module cannot be forbidden from importing itself

I think this is a potentially confusing way to put it - in this context, a module can be a subpackage, and imports do exist within subpackages. For example, importlinter.domain.fields imports importlinter.domain.imports, but we might still want to make importlinter.domain a leaf subpackage.

I've pushed up a commit that rewrites the docs in a way that I hope is clearer, let me know what you think.

Comment thread docs/release_notes.md Outdated

* Improve error message when root package is a single-file module.
* Alert users with all unmatched ignored imports in the same run.
* Forbidden contracts: skip any source/forbidden pair that refers to the same module (or, as

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest replacing this whole thing with just Allow overlapping modules in forbidden contracts. Users can consult the docs for more info.

Comment thread src/importlinter/contracts/forbidden.py Outdated
Forbidden contracts check that one set of modules are not imported by another set of modules.
Indirect imports will also be checked.

Any pair where the source and forbidden module are the same (or, when treated as packages, one

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should replace this with whatever ends up in the docs.

Comment thread src/importlinter/contracts/forbidden.py Outdated
output.verbose_print(
verbose,
f"Skipping {source_module} -> {forbidden_module} "
"(a module cannot be forbidden from importing itself).",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a module cannot be forbidden from importing itself

This is misleading - let's just log "Skipping overlapping modules {source_module} and {forbidden_module}."

- Make `_modules_overlap` a module-level function taking `as_packages` as an
  argument, rather than a method reading it from the instance.
- Reword the verbose log to "Skipping overlapping modules {a} and {b}.".
- Align the contract docstring with the documentation and drop the misleading
  "a module cannot be forbidden from importing itself" phrasing.
- Shorten the release note to "Allow overlapping modules in forbidden contracts.".
- Add test coverage for the as_packages branch: overlapping (containing)
  modules are skipped when as_packages is True and still checked when False.
@Spenhouet

Copy link
Copy Markdown
Contributor Author

Thanks for the review and the docs rewrite. Pushed f40ce3c addressing all points:

  • Functional helper: _modules_overlap is now a module-level function taking as_packages as an argument, instead of a method reading it from the instance.
  • as_packages coverage: added test_overlapping_modules_treated_as_packages_are_skipped (parametrized both containment directions, as_packages=True) and test_overlapping_modules_not_treated_as_packages_are_still_checked (as_packages=False). Confirmed: neutering the if as_packages: branch now fails the first test with grimp's "Modules have shared descendants".
  • Log wording: changed to Skipping overlapping modules {source_module} and {forbidden_module}..
  • Docstring: reworded to match your docs and dropped the misleading "a module cannot be forbidden from importing itself" phrasing.
  • Release note: shortened to Allow overlapping modules in forbidden contracts..
  • Docs page: kept your version as-is.

The behavior matches your docs: with as_packages=True, overlapping pairs (same module, or one containing the other) are skipped; with as_packages=False, only the exact-same module is skipped.

@seddonym
seddonym self-requested a review June 23, 2026 07:24
@seddonym
seddonym merged commit 5c174fc into seddonym:main Jun 23, 2026
6 checks passed
@seddonym

Copy link
Copy Markdown
Owner

That was quick. Thanks - great contribution!

@seddonym

Copy link
Copy Markdown
Owner

This is now available in the latest release, 2.12. Enjoy!

@Spenhouet

Copy link
Copy Markdown
Contributor Author

Thanks for the fast merge and release!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Express "this package may only import from itself" without listing every other module

2 participants