Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New contract type: private_modules #159

Open
seddonym opened this issue Feb 3, 2023 · 2 comments
Open

New contract type: private_modules #159

seddonym opened this issue Feb 3, 2023 · 2 comments

Comments

@seddonym
Copy link
Owner

seddonym commented Feb 3, 2023

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
Copy link

holvianssi commented Aug 2, 2023

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
Copy link
Owner Author

seddonym commented Aug 7, 2023

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

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

No branches or pull requests

2 participants