seddonym / import-linter

Import Linter allows you to define and enforce rules for the internal and external imports within your Python project.
https://import-linter.readthedocs.io/
BSD 2-Clause "Simplified" License
679 stars 48 forks source link

New contract type: private_modules #159

Open seddonym opened 1 year ago

seddonym commented 1 year ago

A contract which enforces 'private' modules using underscore prefixes.

For example, a module named _foo.py should not be imported directly except by its direct parent package.

holvianssi commented 1 year ago

You can do what you want with a custom contract.

Here's one which allows configuring which submodules can be imported, that is this enforces that only public submodules can be imported.

import re

from importlinter import Contract
from importlinter import ContractCheck
from importlinter import fields
from importlinter import output

class PublicAPIContract(Contract):
    module = fields.StringField()
    exclude = fields.StringField(required=False, default="")
    public_submodules = fields.ListField(fields.ModuleField(), required=False, default=["public"])

    def check(self, graph, verbose):
        output.verbose_print(verbose, f"Checking imports outside public API for {self.module}...")
        submodules = graph.find_descendants(self.module)
        hits = []
        for submodule in submodules:
            if any([ps for ps in self.public_submodules if submodule.startswith(f"{self.module}.{ps}")]):
                continue
            importers = graph.find_modules_that_directly_import(submodule)
            importers = self.exclude_allowed_importers(graph, submodule, importers)
            if importers:
                importers = ", ".join(importers)
                hits.append(f"{submodule} imported by {importers}")
        return ContractCheck(kept=not hits, metadata=hits)

    def exclude_allowed_importers(self, graph, submodule, importers):
        """
        Remove allowed imports from a list of found imports of the module

        An improt is allowed if:
            the module imports itself
            the exclude field is a substring of the importing module
            all importing lines matches regex noqa:.*import-linter
        """
        importers = [i for i in importers if not i.startswith(self.module)]
        if self.exclude:
            importers = [i for i in importers if self.exclude not in i]
        qa_importers = []
        for importer in importers:
            details = graph.get_import_details(importer=importer, imported=submodule)
            if any([d for d in details if not re.search("noqa:.*import-linter", d["line_contents"])]):
                qa_importers.append(importer)
        return qa_importers

    def render_broken_contract(self, check):
        output.print_error(
            f"Import of non-public module of {self.module}",
            bold=True,
        )
        output.new_line()
        for hit in check.metadata:
            output.indent_cursor()
            output.print_error(hit)

And usage (place the class above in import_contracts.py):

[tool.importlinter]
root_package = "myproject"
contract_types = [
    "public_api: import_contracts.PublicAPIContract",
]

[[tool.importlinter.contracts]]
name = "myproject.send_email imported only through public API"
type = "public_api"
module = "myproject.send_email"
exclude = ".tests."
public_submodules = [
    "public",
     "utils",
]

Now, if myproject.othermodule imports myproject.send_email.implementation that's a violation, but if it imports myproject.send_email.utils that is ok.

It should be relatively straightforward to turn this the other way around - that you list the private submodules, and all other imports are fine.

I'd like to have this both ways in, that is you can configure public submodules or private submodules with standard contracts. Bonus points if the module itself can contain the contract configuration. This way you could just open myproject/send_email/import_contract.cfg and see how the module can be used, and then import-linter would enforce the contract.

seddonym commented 1 year ago

Thanks for including!

To clarify, this ticket is about a general contract that would enforce a convention we use in our internal code base where modules prepended with an underscore should only be accessible within that same subpackage. The contract would then cover the whole code base and check this convention is being followed (rather than needing to list specific modules in a contract), something like:

[importlinter:contract:private-modules]
name = Private modules
type = private_modules
packages = mypackage