Basic linter¶
In this introductory tutorial you will learn how to:
- create a basic linter using
lintkit
- create a few basic rules verifying
pyproject.toml
⧉
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 loadtoml
file and save it as a state underdata
- Access to
loader
created attributes should always be performed bylintkit.loader.Loader.getitem
lintkit.rule.Node
specifies we are interested innode
/content of the data (as opposed to the raw contents of the file or allTOML
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 oflintkit.settings.name
andcode
, in our case"MYLINTER1"
. Many linters have their own codes (or group of codes), for exampleruff
⧉.- One has to define
values
(yielding values to check),message
(message to display in case of rule violation), andcheck
(what does it mean to check thevalue
, actual rule) lintkit.Value
is a transparent proxy object as defined bywrapt
⧉ (lintkit.Value[str]
should be treated as plainstr
). This transparent proxy carries important information (like comment associated with the line) which is used bylintkit
pipelines
Note
Why values
method yields? Linter creators can return multiple values, which, in turn, will be check
ed 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:
while this one would:
Making the rules reusable¶
You may have noticed, that the following would also pass the linter check:
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:
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:
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
:
- Configuring linter via
loadfig
⧉ (or other tool); continuation of this tutorial - Advanced linter for Python code
- File linters