FP2: Local Lint Rules ===================== By default, Fixit expects rules to be referenced by their fully-qualified module and/or class name. For example, ``fixit.rules`` refers to the bundled set if lint rules shipped with Fixit. Third party rules available in the environment can similarly be referenced by their module name, as long as they are installed and importable by Fixit at runtime. These will be collectively referred to as "global rules" here for sake of clarity. However, in many cases, it is also useful for projects using Fixit to build custom lint rules specific to the project being linted. It is beneficial for those rules to be defined within the codebase being linted, without needing to first install those lint rules into the environment before running Fixit. These are defined as "local rules", and will require dedicated behavior from Fixit to discover and import these rules at runtime, as well as special configuration syntax for enabling these local rules in a project. Configuration ------------- When configuring the set of enabled or disabled rules, any local rules must be marked with a single leading period, and referred to using their path relative to the configuration file's directory. For example, a configuration file at ``project/fixit.toml`` could include rules defined in ``project/some/local/rules.py`` with the following: .. code-block:: toml [tool.fixit] enable = [".some.local.rules"] References to local rules should be accepted in either ``enable`` or ``disable`` options, including overrides, to provide the same selection criteria available to global rules: .. code-block:: toml [tool.fixit] enable = [".rules"] [[tool.fixit.overrides]] path = "project1" disable = [".rules:PickyRule"] enable = [".project1.rules"] Implementation -------------- When gathering enabled/disabled rules from configuration, Fixit currently just records the module name (qualname) and does set operations when merging configs/overrides. In order to differentiate and manage local rules correctly, without affecting the behavior of merging/overrides, one possible option is to replace the simple string with a tuple of path and module. For example, where a global rule would be tracked as just ``"fixit.rules"``, local rules from ``foo/bar/fixit.toml`` could be tracked as ``(Path('foo/bar'), ".local.rules"))``, allowing set operations while still tracking their origin, and preventing collisions from different directories. In this implementation, the path object should match the parent directory containing the configuration file that enables or disables these local rules. Options in both a ``pyproject.toml`` and ``fixit.toml`` from the same exact directory would use the same path object, as would any local rules referenced by overrides in those files. --- When discovering and loading rules, the system should attempt to make sure that it is loading rules from the local path, rather than accidentally loading rules from the outside environment. This can be done either by a temporarily restricted path when importing, or with a custom import loader. The former may be a simpler starting point, and can be handled with a temporary override of ``sys.path`` to include the local directory — and nothing else? — and then importing the module as normal, with the leading period removed. This could look something like: .. code-block:: python with temporary_sys_path(parent_path): name = name.lstrip(".") module = importlib.import_module(name) Once loaded, local modules and rules can be handled and traversed the same as for global rules, though the logic for filtering out disabled local rules may require more nuance. Long term, we almost certainly need a custom loader, to prevent potential conflicts between local namespaces. For example, project1 and project2 both referece a local ``.rules`` module, which would clash in ``sys.modules``. Before a final public release, the following considerations must be handled: - Importing multiple local rules with the same local path and/or module name, or names that conflict with system modules. Eg, two different configs specifying ``".local.rules"``, or loading a module named ``".sys"``. - Relative imports from within local rules modules, such also ``from .foo import Bar`` or worse ``from ..foo import Bar``. Limitations ----------- For simplicity of implementation, it makes sense to disallow local rules from outside of the configuration file's parent directory. In other words, ``project/subdir/fixit.toml`` can not reference local rules from ``project/other/rules.py``. Enabling (or disabling) local rules with more than one preceding period, such as ``..rules``, is not supported, and should be rejected during config validation. To apply rules from a different subdirectory to another subdirectory, a configuration located in a common parent can use :ref:`configuration overrides `. For instance, ``project/fixit.toml`` could specify an override for the ``subdir`` path to enable ``".local.rules"``. ---- Also for simplicity of implementation (and explanation to users), it makes sense to disallow filtering of local rules from outside the file (or exact parent directory) that originally enabled them. For example, this would be considered invalid, or at least would not accomplish what the user may expect: .. code-block:: toml # foo/fixit.toml [tool.fixit] enable = [".local.rules"] .. code-block:: toml # foo/bar/fixit.toml [tool.fixit] disable = [".local.rules"] Rather, the expected way to make this work would be with subpath overrides in the parent directory's ``fixit.toml`` file: .. code-block:: toml # foo/fixit.toml [tool.fixit] enable = [".local.rules"] [[tool.fixit.overrides]] path = "bar" disable = [".local.rules"]