Skip to content

Basic linter

In this introductory tutorial you will learn how to:

Your first rule

Firstly create a file called rules.py with the following content:

import typing

import lintkit

if typing.TYPE_CHECKING:
    from collections.abc import Iterable

# Define the name for our linter
lintkit.settings.name = "MYLINTER"

class NameDefined(lintkit.loader.TOML, lintkit.rule.Node, code=1):
    """Checks if `name` property is defined in `pyproject.toml`"""

    # Should always return `Value` or `None`
    def values(self) -> Iterable[lintkit.Value[str | None]]:
        """Yield `project.name` from `pyproject.toml`.

        Note:
            It might not be available, hence we are checking
            for `None` values.

        """
        # Appropriate `TOML` data will be loaded later
        data = self.getitem("data")

        # Unpack and yield safely project.name field
        project = data.get("project")
        if project is None:
            yield lintkit.Value()
        else:
            name = project.get("name")
            if name is None:
                yield lintkit.Value()
            else:
                yield lintkit.Value.from_toml(name)

    def message(self, _: lintkit.Value[str | None]) -> str:
        # No need to use `lintkit.Value` here
        return "Field 'project.name' was not defined"

    def check(self, value: lintkit.Value[str | None]) -> bool:
        return value is not None

Please note the following elements:

  • lintkit.settings.name attribute specifies the name our linter will have (used when outputting errors or defining in-code ignores).
  • lintkit.loader.TOML will load toml file and save it as a state under data
  • Access to loader created attributes should always be performed by lintkit.loader.Loader.getitem
  • lintkit.rule.Node specifies we are interested in node/content of the data (as opposed to the raw contents of the file or all TOML files). Check out File linters for more information.
  • code=1 specifies numeric value associated with this rule. It will later be displayed as a concatenation of lintkit.settings.name and code, in our case "MYLINTER1". Many linters have their own codes (or group of codes), for example ruff ⧉.
  • One has to define values (yielding values to check), message (message to display in case of rule violation), and check (what does it mean to check the value, actual rule)
  • lintkit.Value is a transparent proxy object as defined by wrapt ⧉ (lintkit.Value[str] should be treated as plain str). This transparent proxy carries important information (like comment associated with the line) which is used by lintkit pipelines

Note

Why values method yields? Linter creators can return multiple values, which, in turn, will be checked one by one. Check out Advanced tutorial for an example.

This rule will enable us to verify whether pyproject.toml contains necessary [project] section with field name, so this file would not raise an error:

[project]

name = "my super linter"

while this one would:

[project]

nnname = "typos happen :("

Making the rules reusable

You may have noticed, that the following would also pass the linter check:

[project]

name = 213

while the name field in Python's pyproject.toml can only be string. We could try to add appropriate verification like so:

...

def check(self, value: lintkit.Value[str | None]) -> bool:
    return value is not None and isinstance(value, str)

but that would have its own downsides:

  • we are verifying two things in one rule
  • it is not extensible (what if we want to check something else)?

Tip

As a rule of thumb, try to make your linters follow "one check, one rule" formula.

Instead, lintkit provides a way to reuse rule elements via inheritance (change original contents of the file to these):

import typing

import lintkit

if typing.TYPE_CHECKING:
    from collections.abc import Iterable

# Define the name for our linter
lintkit.settings.name = "MYLINTER"

class PyProjectNameLoader(lintkit.loader.TOML, lintkit.rule.Node):
    """Safely loads `project.name` property of `pyproject.toml."""

    def values(self) -> Iterable[lintkit.Value[str] | None]:
        """Yield `project.name` from `pyproject.toml`.

        Note:
            It might not be available, hence we are checking
            for `None` values.

        """
        # Appropriate `TOML` data will be loaded earlier
        data = self.getitem("data")

        # Unpack and yield safely project.name field
        project = data.get("project")
        if project is None:
            yield None
        else:
            name = project.get("name")
            if name is None:
                yield None
            else:
                yield lintkit.Value.from_toml(name)

# Concrete definitions of the rules

class NameExists(PyProjectNameLoader, code=1):
    """Checks if `name` property is defined in `pyproject.toml`"""

    def check(self, value: lintkit.Value[str | None]) -> bool:
        return value is not None

    def message(self, _: lintkit.Value[str | None]) -> str:
        return "Field 'project.name' was not defined"

class NameIsString(PyProjectNameLoader, code=2):
    """Checks if `name` property is of type `str`."""

    def check(self, value: lintkit.Value[str | None]) -> bool:
        return isinstance(value, str)

    def message(self, value: lintkit.Value[str | None]) -> str:
        return f"Field 'project.name' is not a string (got {type(value)}"

class NameNoWhitespaces(PyProjectNameLoader, code=3):
    """Checks if `name` is below has no spaces."""

    def check(self, value: lintkit.Value[str | None]) -> bool:
        return isinstance(value, str) and not any(c.isspace() for c in s)

    def message(self, _: lintkit.Value[str | None]) -> str:
        return "Field 'project.name' contains whitespaces"

# Let's add one simple rule for fun
class NameIsShort(PyProjectNameLoader, code=4):
    """Checks if `name` is below `10` characters."""

    def check(self, value: lintkit.Value[str | None]) -> bool:
        return isinstance(value, str) and len(value) < 10

    def message(self, v: lintkit.Value[str | None]) -> str:
        return f"Field 'project.name' is too long ({len(v)} > 10 chars)"

Important

rule is defined when you pass its code. Before all subclasses are considered a reusable elements by [lintkit][] (in our case PyProjectNameLoader).

Running the linter

First, let's define an example pyproject.toml we would like to lint:

[project]

name = "That is a long incorrect project name"

Now you can run linter on pyproject.toml (assuming all files are in the same folder), create a file called run.py:

import sys

import lintkit

import rules

if __name__ == "__main__":
    sys.exit(lintkit.run("pyproject.toml"))

Tip

You can use sys.exit directly with the return code of lintkit.run as it will return True if any rule fails.

Run it:

> python run.py

And you should see the following output (exact file path might differ):

/pyproject.toml:-:- MYLINTER3: Field 'project.name' contains whitespaces
/pyproject.toml:-:- MYLINTER4: Field 'project.name' is too long (38 > 10 chars )

Note

Currently TOML does not support line and column numbers (that's why -) unlike YAML and Python. Check out lintkit.loader if you want to find out more.

Next steps

Check one of the following tutorials to learn more about what you can do with lintkit: