Open seddonym opened 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.
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
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.