Kontynuujemy temat tworzenia wysokiej jakości oprogramowaniu w kontekście przetwarzania danych. W tym wpisie porównamy jak R i Python radzą sobie ze wspomaganiem użytkownika w pisaniu przejrzystego, dobrego kodu. Temat jest szeroki, skupimy się na 4 zagadnieniach:

  • Jak powinien wyglądać szablon projektu Data Science?
  • Dlaczego konwencje nazewnictwa mają znaczenie i czym linter?
  • Dlaczego ważna jest ujednolicone formatowania kodu?
  • Czym jest CI/CD i jak wykorzystywać go do tworzenia wysokiej jakości oprogramowania?

Python

Zacznijmy od poezji: Zen of Python - PEP20.

Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren’t special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one– and preferably only one –obvious way to do it.
Although that way may not be obvious at first unless you’re Dutch.
Now is better than never.
Although never is often better than right now.
If the implementation is hard to explain, it’s a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea – let’s do more of those!

Powyższy tekst odnosi się do tego jak ,,powinno" wyglądać programowanie w Pythonie. W skrócie: Explicit, Simple, Readable. Można go odnieść także do tego jak powinien wyglądać projekt i jak powinien wyglądać kod.

Za eRem nie stoi podobny wiersz. Ale gdyby było inaczej to, moim zdaniem, wyglądałby mniej więcej tak:

Truth is better than beautiful.
Data is complex.
So is the road to understand it.
The end justifies the means.
There are many ways to lead you there,
Pick the one you like.
Although that way may not be obvious at first unless you’re a statistician.
Everything is a list or a vector.
So hack away until you reach your goal.
And pray that it works in two years.

Dlaczego taki wiersz? Zobaczmy i porównajmy.

Cookie cutter to po angielsku forma do ciasteczek. Oczywiście w przypadku programowania nie mówimy o wypiekach, ale tej nazwy przyjęło się używać dla wzoru, według którego należy tworzyć projekty.

Rzecz jasna nie ma jednego właściwego wzoru, który obowiązuje wszystkich analizujących dane. Problemy i projekty są tak niejednorodne, że fragmenty niezbędne w jednym są zupełnie zbyteczne w drugim. Niemniej jednak warto ujednolicać strukturę projektów z dwóch powodów.

  1. Zachęci nas do utrzymania porządku w kodzie np. do podziału kodu na wiele małych plików.
  2. Osobie nieznającej kodu łatwiej będzie zrozumieć o co w nim chodzi, bo struktura będzie znajoma. Pamiętajmy, że osobą nie znającą kodu staniemy się po około dwóch latach od momentu ostatniego commita. Więc nie mówimy bynajmniej o zarządzaniu wieloosobowymi zespołami.

Popularnym wyborem takiej ,,ciasteczkowej" formy jest Data Science Cookiecutter. Nie trzeba korzystać z niego w całości, ale z mojego doświadczenie wynika, że należy mieć na względzie sugerowaną strukturę plików.

Zresztą sami twórcy zachęcają do pewnej elastyczności pisząc:

We’re not talking about bikeshedding the indentation aesthetics or pedantic formatting standards — ultimately, data science code quality is about correctness and reproducibility.

Projekt zawiera kilka opinii, z którymi bardzo polecam się zapoznać. Między innymi:

  • raw_data nie może się zmieniać. Cała analiza/proces powinien być do odtworzenia wyłącznie przy użyciu funkcji z folderu src/.
  • notebooki służą tylko do eksploracji i komunikacji. Należy zadbać o ich nazwę, żeby łatwo było ustalić ich twórcę i zawartość. Kod powtarzający się w kilku notebookach należy refaktoryzować i umieścić w src/.
  • jakiekolwiek hasła, tokeny czy inne niejawne informacje nie mogą się znaleźć w systemie kontroli wersji.

Mogę powiedzieć z autopsji, że przestrzeganie tych zasad, w dłuższej perspektywie, ułatwia życie.

Statyczna analiza kodu - Linter

Program służący do statycznej analizy kodu (statycznej czyli przed uruchomieniem programu) nazywamy linterem. Nazwa pochodzi z linuxowego narzędzia do analizy kodu w C.

Co robi linter? Wykrywa bugi, błędy programistyczne i stylistyczne, podejrzane konstrukcje.

Beautiful is better than ugly.
...
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
...

Implementacją lintera dla języka Pythona jest pylint.

Jest 5 rodzajów błędów jakie może nam zwrócić pylint:

  • ( C) convention, for programming standard violation
  • ( R) refactor, for bad code smell
  • ( W) warning, for python specific problems
  • ( E) error, for probable bugs in the code
  • ( F) fatal, if an error occurred which prevented pylint from doing further processing.

Pełna lista sprawdzeń i wiadomości od pylinta można sprawdzić komendą pylint --list-msgs. Na potrzeby tego wpisu przyjrzyjmy się kilku wybranym.

Sprawdzenie stylu

  • Zgodność ze standardem PEP8.
  • Czy białe znaki będą kodowane jako Taby czy Spacje?.
  • Linia ma maksymalnie 79 znaków. Czemu?
  • Kiedy używać pustych linii następujących po sobie?
  • Kodowanie plików. Generalnie chcemy UTF-8.
  • Składnia do importowania bibliotek.
  • Spacje wokół operatorów x=1+2 czy x = 1 + 2?
  • Nazewnictwo modułów, funkcji, zmiennych. Czy mogą się zaczynać wielką literą? Jakie znaki specjalne mogą zawierać? Etc.

Błędy, poprawność kodu

Linter jest w stanie namierzyć także konstrukcje, które wyglądają podejrzanie i mogą być źródłem bugów.

  • duplicate-argument-name. Dwa argumenty funkcji o tej samej nazwie.
  • return-outside-function. Keyword return znajdujący się poza funkcją.
  • assert-on-tuple. Asercja na krotce - zwróci True na niepustej nie patrząc na wartości.
  • nonlocal-and-global. Zmienne globalne i nielokalne to osobny temat. Zachęcam do poczytania.

Jak użyć pylinta?

Na Macu i Windowsie instalujemy go przez pip-a.

Krótkie przypomnienie z poprzedniego wpisu

Oczywiście w jeśli tworzymy nowy projekt to przed instalacją tworzymy nowe środowisko wirtualne. Dodajemy pylint requirements.in, kompilujemy, instalujemy biblioteki.

pip3 install -r requirements.txt

Aby użyć pylint wykonujemy komendę:

pylint dir/

Sprawdzeń (linterów) jest bardzo dużo. Być może nie wszystkie są adekwatne do naszego projektu. Jak wyłączyć część sprawdzeń?

Mamy 5 opcji.

  • globalnie na komputerze ~/pylintrc. Nie jest to dobry pomysł ponieważ dotknie to każdego projektu. Nawet długo po tym jak zapomnimy, że ten plik edytowaliśmy.
  • dla całego projektu/folderu w pliku pylintrc.
  • dla całego pliku. Dodajemy np. # pylint: disable=C0103.
  • dla konkretnej linii. Dodajemy np. # pylint: disable=C0103.
  • dla większego bloku kodu. Dodajemy np. # pragma pylint: disable=C0103.

Jak zawsze przyświeca nam Zen Pythona:

Special cases aren't special enough to break the rules.
Although practicality beats purity.

Nie boimy się wyjątków, jeśli podążanie za regułami okazałoby się totalnie niepraktyczne.

Uwaga

Istnieje możliwość integracji pylinta ze środowiskiem programistycznym (IDE) tak, aby na bieżąco sprawdzał poprawność kodu. Więcej informacji tutaj tutaj.

Kod może wyglądać dowolnie o ile jest zgodny z black.

W naszym podążaniu za Pythonowym Zen natrafiamy na zdanie:

Beautiful is better than ugly

Aby nasz kod był ładny używamy narzędzia, które ,,bezkompromisowo" nam kod sformatuje. Narzędzie to nazywa się black, a nazwa jest nawiązaniem do samochodów Forda, które mogły być produkowane w dowolnym kolorze, o ile był to czarny.

Co robi black?

Formatuje kod według określonych reguł, m.in.:

  • spacje przy operatorach.
  • cudzysłowy (podwójne zamiast pojedynczych).
  • puste linie.

Tę operację wykonuje inteligentnie i efektywnie to znaczy omija pliki z .gitignore oraz nie sprawdza plików niezmienionych od ostatniego formatowania.

Aby sprawdzić poprawność formatowania używamy komendy:

black --check src/

Z kolei aby plik sformatować omijamy --check

black src/

Black dla jupyter notebook-ów

Notebooki są zwykle siedliskiem nie tylko błędów, ale i brzydkiego kodu. Jednocześnie zawierają wartościowe pomysły i wobec tego czasem warto się nimi dzielić (w tym też commitować do gita). Dobry sposób korzystania z notebooków daje nam, jak zwykle, Cookie cutter.

Odpowiedzią na potrzebę poprawy czytelności notebooków jest Black nb. Jest to pakiet, który umożliwia użycie black dla wszystkich komórek w notebookach w folderze

black-nb notebooks/

Dodając argument --check możemy jedynie sprawdzić zgodność ze standardami black bez modyfikowania kodu.

Może przed commitowaniem notebooka chcemy wyczyścić output wszystkich komórek? Nic prostszego! Wystarczy komenda:

black-nb --clear-output . notebooks/

Uwaga praktyczna

Jak sprawić, żeby kernel był widoczny dla jupytera? Trzeba zainstalować w nim pakiet ipykernel.

source venv/bin/activate
# pip install ipykernel
python -m ipykernel install --user --name=KERNEL_NAME

Formatowanie kodu sql-owego

W projektach analitycznych/data science mamy nie tylko kod w R/Pythonie, ale też dużo SQLa. Mówi się, że przygotowanie danych zajmuje 90% czasu a modelowanie pozostałe 10%. Oznacza to, że poprawne wyciągnięcie danych z bazy jest newralgicznym i koniecznym elementem.

Sprawdzanie i debugowanie skryptów sql-owych jest, oględnie mówiąc, nieprzyjemne. Stosując się do jasno określonych zasad formatowania kodu możemy sobie nieco ułatwić to zadanie.

W Pythonie użyć można do tego sql-formatter. Jest to biblioteka oryginalnie stworzona w PHP, dostosowana do użycia w Pythonie. Dla niektórych IDE jest ona dostępna jako plugin, co dodatkowo może ułatwić skorzystanie z niej.

R

Przeszliśmy przez kilka narzędzi pythonowych czas na sprawdzenie jak osiągnąć te same cele za pomocą tego, co oferuje R.

Linter czyli lintr

Istnieje eRowy odpowiednik pylinta, który również oferuje statyczną analizę kodu. Zaimplementowanych jest wiele linterów, ich lista dostępna jest tutaj. Co istotne z praktycznego punktu widzenia, RStudio ma specjalne wsparcie do przechodzenia przez wiadomości od lintera. Dzięki temu szybko odnajdziemy błędy w kodzie.

Jak używać lintr-a?

Podobnie jak w pylincie możemy sprawdzić dowolny plik lub folder. Dodatkowo możemy sprawdzić pakiety. Odpowiednikiem lintrc jest ukryty plik .lintr. W pliku README projektu jest skrypt, który pomaga w tworzeniu tego pliku.

Warto pamiętać

Pakiet to większy, spójny kod. Dobrze jest tworzyć pakiety nie tylko jako odpowiednim biblioteki w Pythonie, ale też jako zamknięte analizy.

Formatowanie kodu w R - styler

W R nie ma odpowiednika PEPów. Jeśli chodzi o formatowanie plików, to wiele firm, instytucji ma swoje rekomendacje.

Styler formatuje kod według standardów tidyverse. Możemy go uruchomić na kilka sposobów:

  • Przez addin w RStudio
  • Poprzez eRowe funkcje style_pkg(), style_file() or style_text()
  • Poprzez pakiet usethis
    • Formatowanie plików: usethis::use_tidy_style().
    • Integracja z Githubem, przy Pull request zaczynających się od /style będzie automatycznie zastosowany styler. Dzięki temu zawsze przy mergowaniu do master-a będziemy mieć dobre formatowanie. usethis::use_tidy_github_actions()

Struktura projektu

Przykładowe, wyjściowe struktury różnego typu projektów (pakiet, aplikacja shiny, prezentacja itp.) zawiera RStudio. Można z nich skorzystać, ale z mojego doświadczenia, lepszym pomysłem jest znowuż użycie pakietu usethis.

Jeśli chcecie rozwijać pakiety w eRze to najszybsza i najpewniejsza droga wiedzie właśnie przez usethis. Moim zdaniem jest to lepsze rozwiązanie niż tworzenie i rozwijanie szkieletu pakietu dostępnego w RStudio. Oprócz tego, że można zacząć od polecenia usethis::create_project aby utworzyć nowy pusty projekt, warto korzystać w trakcie z innych komend. Po co nam wiedza gdzie powinny być wpisane zależności? Po prostu korzystamy z usethis::use_package(). Chcemy aby projekt zawierał dane wejściowe? Korzystamy z usethis::use_data_raw(). Do tego nie trzeba pamiętać dokładnie gdzie wstawiać testy, gdzie dane, a gdzie dokumentację.

Poniżej jeszcze kilka przydatnych komend jako zajawka :)

usethis::edit_git_ignore() # otwiera nam plik gitignore, jeśli go nie ma to go stworzy
usethis::use_description() # dodaje szkielet pliku Description
usethis::use_github_* # rodzina funkcji, które dodaje github actions (nie trzeba znać dokładnej składni)
usethis::use_roxygen_md() # dokumentacja funkcji będzie traktowana jako markdown i tak renderowana
usethis::use_data() # zapisuje dane w odpowiednim miejscu, tak żeby były dostępne dla funkcji w pakiecie

Poczytajcie i korzystajcie :)

usethis is a workflow package: it automates repetitive tasks that arise during project setup and development, both for R packages and non-package projects.

CI/CD

Wszystkie poprzednie punkty prowadzą nas do ostatniego tematu dzisiejszego wpisu czyli CI/CD. Jak zauważyliśmy, aby tworzyć dobry kod trzeba stawiać przed sobą wiele wymagań nie tylko dotyczących jego efektywności, ale też takich związanych jego wyglądem. Czy też tym jak się go czyta. W jaki sposób upewnić się, że kod w naszym projekcie będzie, na każdym etapie swojego rozwoju, na pewno zgodny z tymi wszystkimi wytycznymi?

Odpowiedzią jest CI/CD. Jest to skrót od Continuous integration, continuous deployment (ciągła/nieustająca integracja, ciągłe wdrażanie).

Co oznaczają te pojęcia?

W Continuous integration chodzi o częste mergowanie małych zmian do głównej gałęzi projektu. Oczywiście każda taka operacja musi wiązać się z zapewnieniem wysokiej jakości oprogramowania. A więc automatycznego zbudowanie projektu (np paczka eRowa), weryfikacji formatowania kodu czy jego statyczne sprawdzenie. Oczywiście pojęcie jest szersze, ale w tym momencie wystarczy nam taka definicja.

Continuous deployment oznacza automatyczne wypuszczenie nowych wersji oprogramowania. Czyli np. wgranie obrazu dockerowego na serwer. O tym jeszcze sobie opowiemy w kolejnych wpisach.

Jest wiele narzędzie, które można wykorzytać do CI/CD. Jako przykład użyjemy dostępnego za darmo (do pewnych limitów) Github actions.

Wachlarz zadań jakie można wykonać za pomocą Github actions jest bardzo szeroki:

  • Można sprawdzać czy przechodzą testy, czy poprawna jest składnia, czy statyczna kontrola kodu się powiedzie.
  • Można budować obrazy dockerowe.
  • Można uruchomić i wykonać dowolny kod. W szczególności daje to możliwość użycia go jako schedulera, trochę jak Jenkins).

Przykłady Python

Dla black tworzymy plik .github/workflows/black.yml.

name: Black

on: [push, pull_request]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: psf/black@stable

Dla Pylinta jest nieco trudniej. Na Githubie można znaleźć kilka wersji.

Te konfiguracje sprawiają, że na każdej gałęzi na gicie wykonane zostaną sprawdzenie zgodności z pylint i black. W rezultacie zmergowanie Pull Request, będzie możliwe dopiero i tylko gdy zadane standardy będą spełnione. Jest to zgodne z ideą Continuous Integration.

A jak robić CI/CD w R?

Co prawda jest możliwe żeby wszystko tworzyć samemu, ale można należy używać usethis.

Nas interesuje tylko mały wycinek jego funkcjonalności służący do ustawienia CI/CD.

Komendy są intuicyjne. Wystarczy je wykonać i wszystko magicznie działa.

usethis::use_github_action("lint") # dla pakietu
usethis::use_github_action("lint-project") # dla projektu
usethis::use_tidy_github_actions()

W każdym wypadku usethis zadba o to, żeby odpowiednie konfiguracyjne pliki .yaml znalazły się w folderze .github/workflows.

Dla wytrwałych i ciekawych

Poniżej kilka przykładów tworzenia ręcznie Github actions

Podsumowanie

Czy to starcie wygrał Python czy R? Myślę, że mamy remis. W obu językach łatwo jest używać narzędzi, które pomagają pisać wysokiej jakości, ładny kod. Sprawienie aby te narzędzia były częścią procesu tworzenia oprogramowania (CI/CD) jest również łatwo ustawić. W R mamy samograj w postaci pakietu usethis, w Pythonie mamy multum przykładów w sieci i gotowe rozwiązania np. na github actions.