Inżynieria oprogramowania

Jak stworzyć własny linter w Pythonie?

Cześć!

Na początek muszę przyznać się do czegoś, mianowicie… wiosna oderwała mnie od komputera. To sprawiło, że mniej nowych rzeczy pojawia się zarówno na blogu jak i w social mediach. Musicie mi to jakoś wybaczyć. Na szczęście „mniej” nie znaczy „w ogóle”, więc dziś zapraszam na artykuł w którym zbudujemy własny linter.

Czym jest linter?

Linter to aplikacja do statycznej analizy kodu. Historycznie słowo „lint” odnosiło się do programu pomagającego wykryć błędy, analizującego kod napisany w C pod kątem podejrzanych lub nieprzenośnych instrukcji. Nazwa jak i przeznaczenie się nie zmieniły, a sama idea rozprzestrzeniła się praktycznie na wszystkie języki.

Coraz częściej lintery bywają wbudowywane w IDE i na bieżąco pokazują nam ewentualne problemy z naszym kodem. Mimo to, samodzielne programy tego typu nadal mają się bardzo dobrze i często możemy spotkać się z nimi w procesach CI/CD – są one dla programistów jednym z narzędzi, które stoją na straży jakości tworzonego kodu.

Linter nie jest jednak magicznym rozwiązaniem pozwalającym utrzymywać kod w dobrej kondycji. Nie można oczywiście zaprzeczyć, że pomaga wyłapywać błędy czy utrzymywać standardy kodowania, ale trzeba pamiętać, że jest to nadal statyczna analiza kodu i nie zastąpi ona w żadnym stopniu doświadczenia programistów.

Lintery w Pythonie

W ekosystemie Pythona najpopularniejszym linterem jest oczywiście PyLint. Pozwala on, jak większość tego typu programów, na konfigurację zarówno dodającą nowe funkcjonalności jak i ustawienia ograniczające lub zmieniające domyślne reguły. Poza PyLintem jest jeszcze Flake8, a także narzędzia, które mogą nasz kod automatycznie poprawić jak np. Black.

Reguły lintera to zbiór zasad pod kątem których linter sprawdza nasz kod. Dla przykładu:

  • ilość znaków w każdej linii,
  • długość nazw zmiennych,
  • długość nazw funkcji,
  • wielkość znaków,
  • odstępy między kolejnymi blokami kodu,
  • ilość argumentów do funkcji,
  • długość funkcji,
  • ilość słów „return” w funkcji,
  • itp..

Czekaj, stop!

Podoba Ci się to co tworzę? Jeśli tak to zapraszam Cię do zapisania się na newsletter:

Aby potwierdzić swoją subskrypcję, odbierz pocztę i kliknij w link potwierdzający:) jeśli maila nie ma to poczekaj chwile i/lub sprawdź folder spam/inne/oferty itp :)

Aby potwierdzić swoją subskrypcję, odbierz pocztę i kliknij w link potwierdzający:) jeśli maila nie ma to poczekaj chwile i/lub sprawdź folder spam/inne/oferty itp :)
a w ramach prezentu otrzymasz całkowicie za darmo, dwa dokumenty PDF „6 (nie zawsze oczywistych) błędów popełnianych podczas nauki programowania” który jest jednym z efektów ponad siedmioletniej pracy i obserwacji rozwoju niejednego programisty oraz „Wstęp do testowania w Pythonie”, będący wprowadzeniem do biblioteki PyTest.
Jeśli to Cię interesuje to zapraszam również na swoje social media.

Jak i do ewentualnego postawienia mi kawy :)

Jak działa linter?

Wspomniany wcześniej przeze mnie PyLint używa pod spodem pakietu ast, który dostarcza narzędzia pozwalające na budowę abstrakcyjnego drzewa składniowego (ang. abstract syntax tree). Takie drzewo jest wynikiem analizy składniowej robionej wg konkretnej gramatyki. Więcej informacji o gramatykach, jak i o samym podejściu do tworzenia języków programowania, możesz przeczytać w tym artykule. Każdy węzeł wewnętrzny takiego drzewa reprezentuje pewną konstrukcję języka, a jego synowie znaczące składowe tej konstrukcji. Zobaczmy to na przykładzie!

Uruchamiając taki kod:

1
2
3
4
5
6
7
8
9
import ast

code = """
test = 20
"""


tree = ast.parse(code)

print(ast.dump(tree, indent=4))

dostaniemy:

Module(
    body=[
        Assign(
            targets=[
                Name(id='test', ctx=Store())],
            value=Constant(value=20))],
    type_ignores=[])

To, na co patrzymy, to właściwie już nasze drzewo składniowe. Określa ono elementy z których składa się kod, który analizowaliśmy. Idąc od góry napotykamy na moduł. Moduł składa się z body, czyli ciała modułu. Nasze body składa się z komponentu Assign, czyli przypisania. Atrybut targets wskazuje na cel i j jest nim komponent Name o id „test” czyli po prostu nasza zmienna. Atrybut value wskazuje co zostanie przypisane czyli stała „20”. Proste, prawda? Tak mogłoby to wyglądać, gdybyś chcieli zrobić graficzną reprezentację takiego drzewa:

To zobaczmy coś bardziej skomplikowanego:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import ast

code = """
number = 20

if number > 10:
  print(number)
else:
  print(number - 10)
"""


tree = ast.parse(code)

print(ast.dump(tree, indent=4))

i jego ast:

Module(
    body=[
        Assign(
            targets=[
                Name(id='number', ctx=Store())],
            value=Constant(value=20)),
        If(
            test=Compare(
                left=Name(id='number', ctx=Load()),
                ops=[
                    Gt()],
                comparators=[
                    Constant(value=10)]),
            body=[
                Expr(
                    value=Call(
                        func=Name(id='print', ctx=Load()),
                        args=[
                            Name(id='number', ctx=Load())],
                        keywords=[]))],
            orelse=[
                Expr(
                    value=Call(
                        func=Name(id='print', ctx=Load()),
                        args=[
                            BinOp(
                                left=Name(id='number', ctx=Load()),
                                op=Sub(),
                                right=Constant(value=10))],
                        keywords=[]))])],
    type_ignores=[])

Początek jest prosty. Przypisujemy stałą 20 do zmiennej number. Potem znajdziemy komponent IF, który zawiera test, gdzie lewym operandem jest zmienna number. Następnie w atrybucie „ops” mamy wskazany operator, czyli w tym przypadku Gt() czyli „grater than” i atrybut comparators, czyli elementy do których porównujemy – w tym przypadku do stałej 10. Dalej w naszym drzewie jest ciało (to pod IFem) w którym mamy wyrażenie (Expr) z wartością wywołania funkcji (Call) z atrybutem func, wskazującym na nazwę funkcji i args z argumentami. Następnie jest komponent orelse, realizujący else’a w którym mamy bliźniaczo podobne wywołanie funkcji co wcześniej, z tą różnicą, że tu jako argument nie mamy stałej, ale komponent BinOp, czyli operator binarny zdefiniowany atrybutem „op” jako Sub(), określający subtraction, czyli odejmowanie.

Atrybut ctx określa, co ma się zadziać z daną nazwą, czy ma zastać zapisana (np przy przypisaniu) czy załadowana (np. przy porównywaniu). Może jeszcze być usunięta, np. przy usuwaniu.

Myślę, że budowa tych drzew jest dość intuicyjna.

Napiszmy własny linter!

Jak się pewnie domyślasz, podobnie jak autorzy PyLinta, tak i my użyjemy pakietu ast do zbudowania naszego własnego lintera. Zanim jednak zaczniemy go tworzyć, pokażę w jaki sposób możemy np. wypisać wszystkie nazwy zmiennych użyte w przypisaniach w kodzie analizowanego programu.

W skrócie, trzeba stworzyć klasę dziedziczącą po ast.NodeVisitor i przeciążyć odpowiednią metodę. Kod mógłby wyglądać tak:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import ast
from typing import List

code = """
number = 20
second_number = number + 10
"""


tree = ast.parse(code)


class Visitor(ast.NodeVisitor):
  def visit_Assign(self, node: ast.Assign):
      targets: List[ast.Name] = node.targets
      print([x.id for x in targets])


Visitor().visit(tree)

wypisze on nam:

['number']
['second_number']

Jak widać, metody, które powinieneś przeciążyć mają dość sugestywne nazwy, trudno jest je pomylić. Jeśli nie jest się pewnym, można zerknąć do dokumentacji, gdzie każda z metod (a jest ich trochę) jest opisana.

A teraz coś trudniejszego! Napiszmy program, który wypisze ile przypisań jest w każdej zdefiniowanej funkcji. Kod mógłby wyglądać tak:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import ast
from typing import List

code = """
def func():
  first_var = 1
  second_var = 2
  third_var = 3
  return first_var + third_var - second_var
 
def func2():
  first_var = 1
  first_var = 2
  return first_var

def func3():
  one, two, three, four = 1, 2, 3, 4
  five, six = 5, 6

  return first_var
"""


tree = ast.parse(code)


class Visitor(ast.NodeVisitor):
  def visit_FunctionDef(self, node: ast.FunctionDef):
      statements: List[ast.stmt] = node.body
      assignments = [x for x in statements if isinstance(x, ast.Assign)]
      print(f"Func '{node.name}' has {len(assignments)} assignments.")


Visitor().visit(tree)

A wynikiem byłoby:

Func 'func' has 3 assignments.
Func 'func2' has 2 assignments.
Func 'func3' has 2 assignments.

Zauważ, że mimo, że w func3 stworzyliśmy aż 6 zmiennych, to przypisania są dwa. Jest to jak najbardziej poprawne.

Teraz już naprawdę piszemy własny linter

Żeby to się udało, potrzebujemy nieco abstrakcji. Stwórzmy pakiet o nazwie linter z modułami jak poniżej:

W pliku __init__.py modułu registry stworzymy klasę przechowującą reguły lintera. Może ona wyglądać tak:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from collections import defaultdict
from typing import List, Dict

from linter.rules.abstract import Rule


class Registry:
  def __init__(self):
      self._checkers: Dict[str, List[Rule]] = defaultdict(lambda: [])

  def add_checker(self, node_name: str, _class: Rule) -> None:
      self._checkers[node_name].append(_class)

  def get_checkers(self, node_name: str) -> List[Rule]:
      return self._checkers[node_name]


registry = Registry()

Czyli bez udziwnień – zbieramy klasy typu Rule w słowniku. Już w tym miejscu dajemy możliwość wielu reguł dla jednego typu komponentu.

Plik __init__.py dla rules.abstract zawierać będzie klasę abstrakcyjną (i logger, który raczej nie powinien dla kodu produkcyjnego się znajdować w tym miejscu):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import abc
import ast
import logging


class ContextFilter(logging.Filter):

  def __init__(self, lint_code: str, name: str = ''):
      super().__init__(name)
      self.lint_code = lint_code

  def filter(self, record):
      record.lint_code = self.lint_code
      return True


class Rule:
  NODE: ast.stmt
  LINT_CODE: str = ''

  def __init__(self):
      self.log = logging.getLogger()
      custom_filter = ContextFilter(self.LINT_CODE)
      self.log.addFilter(custom_filter)
      logging.basicConfig(format='%(asctime)-15s %(levelname)-8s %(lint_code)-5s: %(message)s')

  @abc.abstractmethod
  def run(self, node: ast.stmt):
      pass

Każda klasa będąca regułą lintera będzie musiała dziedziczyć po tej klasie, a więc implementować metodę run. Dodatkowo, każda klasa będzie definiowała pola NODE i LINT_CODE. Ten pierwszy to po prostu komponent z pakietu ast, którego reguła będzie dotyczyć. Jak wiele linterów, tak i nasz będzie posiadał kody reguł i za to właśnie odpowiadać będzie to drugie pole. Jest to prosty sposób określający z której reguły leci konkretny komunikat.

Został nam jeszcze plik „główny” czyli __init__.py dla samego pakietu linter. Może on wyglądać tak:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import ast
from typing import List, Any

from linter.registry import registry
from linter.rules.abstract import Rule


class Visitor(ast.NodeVisitor):
  def generic_visit(self, node: ast.stmt) -> Any:
      checkers: List[Rule.__class__] = registry.get_checkers(type(node))
      for checker in checkers:
          checker().run(node)

      super().generic_visit(node)


class Linter:
  def __init__(self):
      self.visitor: Visitor = Visitor()

  def add_rule(self, rule: Rule.__class__):
      if not hasattr(rule, 'NODE'):
          raise RuntimeError("Checker should have NODE attr")

      registry.add_checker(rule.NODE, rule)

  def run_linter(self, code: str):
      self.visitor.visit(ast.parse(code))

Użyłem w Visitorze metody generic_visit która jest odpalana po prostu dla każdego komponentu drzewa. To pozwoliło mi nie definiować każdej metody, a po prostu, w określeniu o typ komponentu, pobrać odpowiednie klasy reguł i je wykonać.

Klasa Linter to taki publiczny interfejs dla naszego kodziku.

Użyjmy naszego lintera

Stwórzmy klasę reguły, która będzie podnosić „alarm” jeśli znajdzie w przypisaniu zmienną, której nazwa jest krótsza niż 5 znaków:

1
2
3
4
5
6
7
8
9
class VariableNamesLengthRule(abstract.Rule):
  NODE: ast.stmt = ast.Assign
  LINT_CODE = 'Py123'

  def run(self, node: ast.Assign):
      names: List[ast.Name] = node.targets
      for name in names:
          if len(name.id) < 5:
              self.log.warning("Name %r is to short! Please be more verbose. ", name.id)

I uruchommy nasz linter:

1
2
3
4
5
6
7
8
code = """
test = 5
test2 = test + 4
"""


linter: Linter = Linter()
linter.add_rule(VariableNamesLengthRule)
linter.run_linter(code)

który poprawnie podniesie nam alarm:

2022-05-10 21:25:04,897 WARNING Py123: Name 'test’ is to short! Please be more verbose.

Oczywiście nasz linter to bardzo prosty kodzik i żeby nadawał się do profesjonalnych zastosowań, to trzeba by było spędzić przy nim jeszcze dużo czasu. Niemniej jednak, pokazuje on jak wykorzystać pakiet ast do analizy składni. Jestem prawie pewien, że już czujesz jaki potencjał w tym drzemie!

Wrzuciłem ten kod do repozytorium, tutaj link.

Zmierzając do końca

O tym, że ten wpis powstawał dość długo napisałem już na początku. W tym czasie kilkukrotnie zmieniał swój zakres. Pierwsza wersja była podobna do tego co właśnie przeczytałaś/eś, ale rozwiązanie było trochę bardziej magiczne, oparte o samorejestrujące się reguły (zaimplementowane metaklasą). Jednak im dłużej na ten kod patrzyłem, tym bardziej dochodziłem do wniosku, że jest on zbyt skomplikowany i zaciemni odbiór głównego tematu – czyli drzew abstrakcyjnych i pakietu ast. Uprościłem to rozwiązanie, ale rozszerzyłem zakres o modyfikację komponentów, bo pakiet ast, poza budowaniem drzewa i podróżowaniem po nim, pozwala też na jego modyfikację. Tu znów napotkałem problem: statystyki wskazują, że długie artykuły rzadko są czytane. Więc, żeby uniknąć sytuacji, w której z powodu długości tekstu moja praca pójdzie na marne, ponownie zawęziłem zakres.

Podobne dylematy mam praktycznie przy każdym artykule. I zawsze jest to samo balansowanie – przekazać na tyle dużo, żeby treść była wartościowa i na tyle mało, żeby dało się to pojąć. Wydaje mi się, że idzie mi nieźle, a jak Ty sądzisz?

Cieszę się, że udało mi się dokończyć ten artykuł. Mam nadzieję, że był on dla Ciebie wartościowy. Daj znać w komentarzu co sądzisz o pakiecie ast i możliwościach jakie on daje. Może jednak chcielibyście przeczytać więcej o tym zagadnieniu?

Dzięki za wizytę,
Mateusz Mazurek
Mateusz M.

Pokaż komentarze

  • Trochę to skomplikowane dla nowicjusza. Wrócę to za jakiś czas.

Ostatnie wpisy

Podsumowanie: maj, czerwiec, lipiec i sierpień 2024

Oj daaawnoo mnie tu nie było. Ale wakacje to był czas dużej liczby intensywnych wyjazdów i tak naprawdę, dopiero jakoś… Read More

2 miesiące ago

Podsumowanie: kwiecień 2024

Cześć! Zapraszam na krótkie podsumowanie kwietnia. Wyjazd do Niemiec A dokładniej pod granicę z Francją. Chrześnica miała pierwszą komunię. Po… Read More

6 miesięcy ago

Podsumowanie: luty i marzec 2024

Ostatnio tygodnie były tak bardzo wypełnione, że nie udało mi się napisać nawet krótkiego podsumowanie. Więc dziś zbiorczo podsumuję luty… Read More

7 miesięcy ago

Podsumowanie: styczeń 2024

Zapraszam na krótkie podsumowanie miesiąca. Książki W styczniu przeczytałem "Homo Deus: Historia jutra". Książka łudząco podoba do wcześniejszej książki tego… Read More

9 miesięcy ago

Podsumowanie roku 2023

Cześć! Zapraszam na podsumowanie roku 2023. Książki Zacznijmy od książek. W tym roku cel 35 książek nie został osiągnięty. Niemniej… Read More

10 miesięcy ago

Podsumowanie: grudzień 2023

Zapraszam na krótkie podsumowanie miesiąca. Książki W grudniu skończyłem czytać Mein Kampf. Nudna książka. Ciekawsze fragmenty można by było streścić… Read More

11 miesięcy ago