Jak trzymać konfigurację skryptów?

Jak trzymać konfigurację skryptów? Żeby było bezpiecznie i wygodnie? Przy prostych zadaniach konfiguracyjne parametry można (ale nie należy!) trzymać inline w kodzie. Ale lepiej w zmiennych (właściwie stałych) zdefiniowanych na początku pliku (i odpowiednio nazwanych, żeby wiedzieć że to stałe związane z konfiguracją). A najlepiej - w pliku zewnętrznym (lub zmiennych systemowych, ale tym się dzisiaj nie zajmiemy).

Photo by Ferenc Almasi on Unsplash

  Dlaczego w pliku zewnętrznym? Z co najmniej dwóch powodów:

  • prosta przenośność - można przenieść skrypt (albo cały ich zestaw, cały projekt) z jednego komputera na drugi i na przykład zmienić tylko ścieżki do plików z danymi. Można zmienić też adres (i inne dane dostępowe) bazy danych i dzięki temu nie zmieniając ani jednego znaku w oprogramowaniu przejść ze środowiska testowego na produkcyjne.
  • bezpieczeństwo - w czasach kiedy kod (nawet firmowy) trzymany jest na GitHubie czy innych tego typu repozytoriach (nawet wewnętrznego BitBucketa to dotyczy) bezpieczeństwo jest ważne i dane dostępowe (loginy, hasła, klucze api itd. itp.) nie powinny być udostępniane. Żeby nie były - nie mogą być w kodzie, muszą być gdzieś poza kodem. W dużym skrócie popularne rozwiązanie wygląda tak, że developerzy nie znają danych dostępowych do produkcji, każdy trzyma lokalnie swoją konfigurację (z np. swoją bazą danych na potrzeby developmentu), a w pliku .gitignore wpisany jest plik konfiguracyjny. To powoduje, że plik z konfiguracją nie trafia do repozytorium.

No dobrze, ale skoro konfiguracja ma być w plikach zewnętrznych to w jakich? Najlepiej takich, które trzymają się jakiegoś standardu. Standardy są właściwie cztery, my zajmiemy się trzema. I to zajmiemy się w R oraz Pythonie.

Konfiguracja .INI

Pierwszy standard to plik .ini. To plik tekstowy (jak wszystkie inne) o przykładowej strukturze jak poniżej:

; to jest komentarz
[modul1]
zmienna_txt = "Jestem \"zmienną\" tekstową z 'ciapkami'"
zmienna_num = 123
zmienna_flo = 456.789

; a to jest komentarz przed drugim modułem
[modul2]
zmienna_arr_txt = ['str1', 'str2', 'str3'] ; komentarz inline
zmienna_arr_num = [123, 456, 789]

Co my tutaj widzimy? Mamy dwie sekcje nazwane modul1 i modul2, które mogą odpowiadać za na przykład różne elementy konfiguracyjne - bazę danych, ścieżki do plików, klucze api do różnych systemów. Takie moduły ułatwiają edycję konfiguracji. W każdym z modułów mamy po dwie zmienne. W pierwszym są to zwykłe, pojedyncze zmienne, a w drugim - listy. Listy mogą być przydatne (na przykład lista adresów mailowych, odbiorców raportów). Dodatkowo mamy możliwość komentowania w pliku - czasem to się przydaje. A jak działa? Jak użyć takiego pliku? Zacznijmy od Pythona. W Pythonie mamy wbudowany pakiet configparser, który bardzo dobrze radzi sobie z czytaniem (oraz zapisywaniem, ale to dzisiaj odpuszczamy) plików .ini. Przykładowy kod, który wczyta i pokaże nam wybrane pozycje z konfiguracji:

import configparser

# na potrzeby ładnego pokazywania obiektów
import pprint

pp = pprint.PrettyPrinter(indent=2)

# zbudowanie obiektu parsera
config = configparser.ConfigParser()

# wczytanie konfiguracji
config.read('config.ini')

Jak wygląda wczytany obiekt?

pp.pprint(config)

## <configparser.ConfigParser object at 0x7fd9654dda20>

Ma to swoją klasę, do elementów wewnątrz można dobrać się poprzez metodę get():

print(config.get('modul1', 'zmienna_txt'))

## "Jestem \"zmienną\" tekstową z 'ciapkami'"

print(config.get('modul1', 'zmienna_num'))

## 123

Ale można sięgnąć po wartość też tak jak (nie powinno się) w standardowych słownikach:

print(config['modul2']['zmienna_arr_txt'])

## ['str1', 'str2', 'str3'] ; komentarz inline

Ups - na końcu został nam komentarz. Cała linia (po znaku równości) traktowana jest jako wartość konkretnej zmiennej. Trzeba o tym pamiętać. A czy da się dostać do jednego elementu na liście?

print(config['modul2']['zmienna_arr_num'][1])

## 1

To nie jest jakiś konkretny element z listy (zobacz wyżej jak zdefiniowana jest zmienna_arr_num)! To jest drugi (indeksowanie od zera) znak w zmiennej zmienna_arr_num potraktowanej jako string. Bo tutaj wszystko jest stringiem:

print(type(config['modul1']['zmienna_num']))

## <class 'str'>


print(type(config['modul1']['zmienna_flo']))

## <class 'str'>

Tak, ta liczba też jest stringiem. Więc trzeba ewentualnie zrobić jawny casting do int lub float jeśli potrzebujecie. Wszystko poza tymi niedogodnościami wygląda ok, prawda? I tak musimy znać ścieżki (jaki moduł, jaka zmienna) poszczególnych parametrów, więc nic dziwnego że odwołujemy się do nich po konkretnych nazwach. Zwróć uwagę na to co otrzymujemy w zmienna_txt z modul1. W konfiguracji wartość zmiennej jest w cudzysłowach, aby było jasne że to ciąg tekstowy… ale te cudzysłowy trafiają do naszej zmiennej! No słabo trochę (z jednej strony, z drugiej ok). No i trzeba odpowiednio to w konfiguracji wyeskejpować. A jak to wygląda w R? Podobnie jak w Pythonie - mamy gotową bibliotekę (już nie wbudowaną, trzeba ją sobie zainstalować przez install.packages("configr")) - configr. Ta z kolei jest na tyle uniwersalna, że posłuży nam przy wszystkich omawianych dzisiaj formatach plików konfiguracyjnych. Przykładowy skrypt wyciągający dane z pliku .ini:

library(configr)

# wczytanie konfiguracji
config <- read.config("config.ini")

print(config)

## $modul1
## $modul1$zmienna_txt
## [1] "\"Jestem \\\"zmienną\\\" tekstową z 'ciapkami'\""
## 
## $modul1$zmienna_num
## [1] "123"
## 
## $modul1$zmienna_flo
## [1] "456.789"
## 
## $modul2
## $modul2$zmienna_arr_txt
## [1] "['str1', 'str2', 'str3'] ; komentarz inline"
## 
## $modul2$zmienna_arr_num
## [1] "[123, 456, 789]"


cat(config$modul1$zmienna_txt)

## "Jestem \"zmienną\" tekstową z 'ciapkami'"


cat(config$modul1$zmienna_num)

## 123


cat(config$modul2$zmienna_arr_txt)

## ['str1', 'str2', 'str3'] ; komentarz inline

Tutaj podobnie jak w Pythonie - komentarz inline nie jest obsługiwany (w sumie .ini nie mają jako takiej sztywnej definicji, a gdyby miały - taki komentarz byłby niedozwolony).

cat(config$modul2$zmienna_arr_num)

## [123, 456, 789]

Uwaga - wynik ponownie jest stringiem a nie listą, co widać tutaj:

cat(class(config$modul2$zmienna_arr_txt))

## character

tutaj:

cat(class(config$modul2$zmienna_arr_num))

## character

oraz tutaj:

cat(config$modul2$zmienna_arr_num[2])

## NA

Z grubsza więc jest tak samo jak w Pythonie. Podsumowując - pliki INI:

  • pozwalają na komentarze - ale tylko jako jedna linia, bez inline
  • każda wartość przypisana do zmiennej jest typu string
  • nie można użyć zagnieżdżeń

  [mailerlite_form form_id=4]

Konfiguracja .JSON

Pliki .ini nie pozwalają na zagnieżdżanie elementów - mamy płaską listę zmiennych (dla wygody upakowaną w moduły, o ile istnieje taka potrzeba). A pliki .json już takie zagnieżdżanie umożliwiają. Po co to? Ano na przykład w jednym pliku możemy trzymać konfigurację wszystkich środowisk (zagnieżdżony obiekt to konfiguracja jednego ze środowisk) i tylko jedną zmienną (też w pliku konfiguracyjnym) decydować którego środowiska używamy. Średnio to bezpieczne rozwiązanie, ale się da. Zbudujmy więc kolejną konfigurację, tym razem już szerszą - taką, która pozwala na wykorzystanie zarówno zagnieżdżenia pojedynczych obiektów jak i na zbudowanie listy obiektów:

{
    "modul1": {
        "zmienna_txt": "Jestem \"zmienną\" tekstową z 'ciapkami'",
        "zmienna_num": 123,
        "zmienna_flo": 456.789
    },
    "modul2": {
        "zmienna_arr_txt": ["str1", "str2", "str3"],
        "zmienna_arr_num": [123, 456, 789]
    },
    "modul3": {
        "obiekt1": {
            "nazwa": "Obiekt Jeden",
            "numer": 1,
            "tablica_num": [1, 1, 1],
            "tablica_str": [
                "obj 1 - string 1",
                "obj 1 - string 2",
                "obj 1 - string 3"
            ]
        },
        "obiekt2": {
            "nazwa": "Obiekt dwa",
            "numer": 2,
            "tablica_num": [2, 2, 2],
            "tablica_str": [
                "obj 2 - string 1",
                "obj 2 - string 2"
            ]
        }
    },
    "modul4": [
        {
            "nazwa": "array of objects 1",
            "wartosc": 1,
            "inna_wartosc": "abc"
        },
        {
            "nazwa": "array of objects 2",
            "wartosc": 2,
            "inna_wartosc": "def"
        },
        {
            "nazwa": "array of objects 3",
            "wartosc": 3,
            "inna_wartosc": "ghi"
        },
        {
            "nazwa": "array of objects 4",
            "wartosc": 4,
            "inna_wartosc": "jkl"
        }
    ]
}

W powyższej konfiguracji modul1 oraz modul2 są żywcem przeniesione z pliku .ini, a kolejne dwa moduły pokazują rzeczy (cuda, o jakich nie śniło się filozofom! ;-) na które format INI nie pozwala. W modul3 mamy dwa obiekty zagnieżdżone pod modułem. Oba obiekty z grubsza są takie same, ale są to dwa odrębne byty. Aby dostać się do nazwy obiektu trzeba przejść po drzewie: moduł -> obiekt -> pole -> wartość. Z kolei w modul4 mamy cztery obiekty zagnieżdżone pod modułem ale w formie listy. Zatem aby dostać się do nazwy konkretnego elementu trzeba przejść ścieżką: moduł -> jeden z elementów listy -> pole -> wartość. Odczytajmy taką konfigurację w Pythonie. Będzie bardzo prosto, o ile potrafisz odczytać pliki JSON.

import json
import pprint

pp = pprint.PrettyPrinter(indent=2)

# wczytujemy plik JSON
with open('config.json', 'r') as fp:
    config = json.load(fp)

# jak wygląda w całości nasz obiekt?
pp.pprint(config)

## { 'modul1': { 'zmienna_flo': 456.789,
##               'zmienna_num': 123,
##               'zmienna_txt': 'Jestem "zmienną" tekstową z \'ciapkami\''},
##   'modul2': { 'zmienna_arr_num': [123, 456, 789],
##               'zmienna_arr_txt': ['str1', 'str2', 'str3']},
##   'modul3': { 'obiekt1': { 'nazwa': 'Obiekt Jeden',
##                            'numer': 1,
##                            'tablica_num': [1, 1, 1],
##                            'tablica_str': [ 'obj 1 - string 1',
##                                             'obj 1 - string 2',
##                                             'obj 1 - string 3']},
##               'obiekt2': { 'nazwa': 'Obiekt dwa',
##                            'numer': 2,
##                            'tablica_num': [2, 2, 2],
##                            'tablica_str': [ 'obj 2 - string 1',
##                                             'obj 2 - string 2']}},
##   'modul4': [ { 'inna_wartosc': 'abc',
##                 'nazwa': 'array of objects 1',
##                 'wartosc': 1},
##               { 'inna_wartosc': 'def',
##                 'nazwa': 'array of objects 2',
##                 'wartosc': 2},
##               { 'inna_wartosc': 'ghi',
##                 'nazwa': 'array of objects 3',
##                 'wartosc': 3},
##               { 'inna_wartosc': 'jkl',
##                 'nazwa': 'array of objects 4',
##                 'wartosc': 4}]}

Wygląda (z grubsza, jeśli pominąć formatowanie) jak nasz źródłowy JSON, prawda? Jak dostać się do wartości kolejnych elementów? Tak jak do elementów słownika (dict) w Pythonie. Tutaj akurat słownik słowników:

print(config['modul1']['zmienna_txt'])

## Jestem "zmienną" tekstową z 'ciapkami'

A jak mamy dodatkowo listę to po prostu używamy indeksu:

print(config['modul3']['obiekt2']['tablica_str'][1])

## obj 2 - string 2

Dla listy obiektów - najpierw indeks na liście, a potem nazwa elementu:

print(config['modul4'][1]['nazwa'])

## array of objects 2

A co z typem? Jeśli liczba nie jest w ciapkach to…

print(type(config['modul1']['zmienna_num']))

## <class 'int'>


print(type(config['modul1']['zmienna_flo']))

## <class 'float'>

…jest tego typu którego być powinna (int lub float). Wszystko zgodnie z intuicją - dokładnie tak, jak można się spodziewać. Czy w R jest tak samo?

library(configr)

config <- read.config("config.json")
print(config)

## $modul1
## $modul1$zmienna_txt
## [1] "Jestem \"zmienną\" tekstową z 'ciapkami'"
## 
## $modul1$zmienna_num
## [1] 123
## 
## $modul1$zmienna_flo
## [1] 456.789
## 
## $modul2
## $modul2$zmienna_arr_txt
## [1] "str1" "str2" "str3"
## 
## $modul2$zmienna_arr_num
## [1] 123 456 789
## 
## $modul3
## $modul3$obiekt1
## $modul3$obiekt1$nazwa
## [1] "Obiekt Jeden"
## 
## $modul3$obiekt1$numer
## [1] 1
## 
## $modul3$obiekt1$tablica_num
## [1] 1 1 1
## 
## $modul3$obiekt1$tablica_str
## [1] "obj 1 - string 1" "obj 1 - string 2" "obj 1 - string 3"
## 
## $modul3$obiekt2
## $modul3$obiekt2$nazwa
## [1] "Obiekt dwa"
## 
## $modul3$obiekt2$numer
## [1] 2
## 
## $modul3$obiekt2$tablica_num
## [1] 2 2 2
## 
## $modul3$obiekt2$tablica_str
## [1] "obj 2 - string 1" "obj 2 - string 2"
## 
## 
## $modul4
##                nazwa wartosc inna_wartosc
## 1 array of objects 1       1          abc
## 2 array of objects 2       2          def
## 3 array of objects 3       3          ghi
## 4 array of objects 4       4          jkl
##

cat(config$modul1$zmienna_txt)

## Jestem "zmienną" tekstową z 'ciapkami'


cat(config$modul3$obiekt2$tablica_str[2])

## obj 2 - string 2

Sięgamy do konkretnego elementu tablicy!

cat(config$modul4$nazwa[2])

## array of objects 2

Uwaga - inna kolejność parametrów niż w pliku konfiguracyjnym: najpierw mamy nazwę pola, a potem numer na liście (indeks) obiektów. Może to wprowadzić zamieszanie. Sprawdźmy jeszcze czy zachowane są typy danych:

cat(class(config$modul1$zmienna_num))

## integer


cat(class(config$modul1$zmienna_flo))

## numeric

Jest tak jak być powinno - liczby całkowite są typu integer, a zmiennoprzecinkowe - numeric. Zwróć też uwagę, że config$modul4 to właściwie gotowy data frame… co ma swoje zalety. W Pythonie z użyciem Pandas też łatwo data frame’a z JSONa zbudować. Podsumowując - pliki JSON:

  • nie pozwalają na komentarze
  • zachowane są typy danych
  • stringi trzeba ubierać w ciapki
  • można użyć zagnieżdżeń i list elementów/obiektów

 

Konfiguracja .YAML

Pliki YAML pozwalają na wszystko to co JSON, ale mają dwa miłe dodatki. Po pierwsze - są chyba nieco bardziej czytelne - nie ma tylu nawiasów. Ale trzeba dbać o wcięcia. Na szczęście większość edytorów potrafi tego pilnować (albo ma wtyczki do tego). Druga zaleta - komentarze. Zobaczmy przykładową konfigurację - identyczną z tą z JSONa:

modul1:
  # tutaj można dodać komentarz
  zmienna_txt: Jestem "zmienną" tekstową z 'ciapkami'
  zmienna_num: 123
  zmienna_flo: 456.789

modul2:
  zmienna_arr_txt: # na tym poziomie też można komentować!
    - "str1"
    - "str2"
    - "str3"
  zmienna_arr_num: [123, 456, 789]

modul3:
  obiekt1:
    nazwa: "Obiekt Jeden"
    numer: 1
    tablica_num: [1, 1, 1]
    tablica_str:
      - "obj 1 - string 1"
      - "obj 1 - string 2" # a nawet na tym!
      - "obj 1 - string 3"
  obiekt2:
    nazwa: "Obiekt dwa"
    numer: 2
    tablica_num: [2, 2, 2]
    tablica_str: ["obj 2 - string 1", "obj 2 - string 2"] # zapisane jako tablica, a nie od kresek

# oczywiście tutaj komentarze też są dozwolone
modul4:
  - nazwa: array of objects 1
    wartosc: 1
    inna_wartosc: abc
  - nazwa: array of objects 2
    wartosc: 2
    inna_wartosc: def
  - nazwa: array of objects 3
    wartosc: 3
    inna_wartosc: ghi
  - nazwa: array of objects 4
    wartosc: 4
    inna_wartosc: jkl

Przeczytajmy to w Pythonie. Potrzebujemy pakietu pyyaml - jeśli go nie masz to zainstaluj via pip lub conda (jeśli używasz Anacondy).

import yaml
import pprint

pp = pprint.PrettyPrinter(indent=2)

with open('config.yaml', 'r') as fp:
    config = yaml.safe_load(fp)

pp.pprint(config)

## { 'modul1': { 'zmienna_flo': 456.789,
##               'zmienna_num': 123,
##               'zmienna_txt': 'Jestem "zmienną" tekstową z \'ciapkami\''},
##   'modul2': { 'zmienna_arr_num': [123, 456, 789],
##               'zmienna_arr_txt': ['str1', 'str2', 'str3']},
##   'modul3': { 'obiekt1': { 'nazwa': 'Obiekt Jeden',
##                            'numer': 1,
##                            'tablica_num': [1, 1, 1],
##                            'tablica_str': [ 'obj 1 - string 1',
##                                             'obj 1 - string 2',
##                                             'obj 1 - string 3']},
##               'obiekt2': { 'nazwa': 'Obiekt dwa',
##                            'numer': 2,
##                            'tablica_num': [2, 2, 2],
##                            'tablica_str': [ 'obj 2 - string 1',
##                                             'obj 2 - string 2']}},
##   'modul4': [ { 'inna_wartosc': 'abc',
##                 'nazwa': 'array of objects 1',
##                 'wartosc': 1},
##               { 'inna_wartosc': 'def',
##                 'nazwa': 'array of objects 2',
##                 'wartosc': 2},
##               { 'inna_wartosc': 'ghi',
##                 'nazwa': 'array of objects 3',
##                 'wartosc': 3},
##               { 'inna_wartosc': 'jkl',
##                 'nazwa': 'array of objects 4',
##                 'wartosc': 4}]}


print(config['modul1']['zmienna_txt'])

## Jestem "zmienną" tekstową z 'ciapkami'

Porównaj wynik z tym jak ciąg został wpisany w konfigurację - jest znak w znak tak samo. To bardzo wygodne.

print(type(config['modul1']['zmienna_num']))

## <class 'int'>


print(type(config['modul1']['zmienna_flo']))

## <class 'float'>

Jak widać klasy danych liczbowych są zachowane. To również wygodne, ale nic szczególnego w porównaniu z JSONem.

print(config['modul3']['obiekt2']['tablica_str'][1])

## obj 2 - string 2


print(config['modul4'][1]['nazwa'])

## array of objects 2

Właściwie nie ma czego komentować - wszystko zachowuje się tak jak powinno. Typy zmiennych są rozpoznawane, stringi nie potrzebują ciapek, listy da się indeksować - nie ważne czy to listy elementów jednego typu czy całych obiektów. A jak czytać to w R? Przy użyciu pakietu configr właściwie ciągle tak samo:

library(configr)

config <- read.config("config.yaml")
print(config)

## $modul1
## $modul1$zmienna_txt
## [1] "Jestem \"zmienną\" tekstową z 'ciapkami'"
## 
## $modul1$zmienna_num
## [1] 123
## 
## $modul1$zmienna_flo
## [1] 456.789
## 
## $modul2
## $modul2$zmienna_arr_txt
## [1] "str1" "str2" "str3"
## 
## $modul2$zmienna_arr_num
## [1] 123 456 789
## 
## $modul3
## $modul3$obiekt1
## $modul3$obiekt1$nazwa
## [1] "Obiekt Jeden"
## 
## $modul3$obiekt1$numer
## [1] 1
## 
## $modul3$obiekt1$tablica_num
## [1] 1 1 1
## 
## $modul3$obiekt1$tablica_str
## [1] "obj 1 - string 1" "obj 1 - string 2" "obj 1 - string 3"
## 
## $modul3$obiekt2
## $modul3$obiekt2$nazwa
## [1] "Obiekt dwa"
## 
## $modul3$obiekt2$numer
## [1] 2
## 
## $modul3$obiekt2$tablica_num
## [1] 2 2 2
## 
## $modul3$obiekt2$tablica_str
## [1] "obj 2 - string 1" "obj 2 - string 2"
## 
## 
## $modul4
## $modul4[[1]]
## $modul4[[1]]$nazwa
## [1] "array of objects 1"
## 
## $modul4[[1]]$wartosc
## [1] 1
## 
## $modul4[[1]]$inna_wartosc
## [1] "abc"
## 
## $modul4[[2]]
## $modul4[[2]]$nazwa
## [1] "array of objects 2"
## 
## $modul4[[2]]$wartosc
## [1] 2
## 
## $modul4[[2]]$inna_wartosc
## [1] "def"
## 
## $modul4[[3]]
## $modul4[[3]]$nazwa
## [1] "array of objects 3"
## 
## $modul4[[3]]$wartosc
## [1] 3
## 
## $modul4[[3]]$inna_wartosc
## [1] "ghi"
## 
## $modul4[[4]]
## $modul4[[4]]$nazwa
## [1] "array of objects 4"
## 
## $modul4[[4]]$wartosc
## [1] 4
## 
## $modul4[[4]]$inna_wartosc
## [1] "jkl"

Jak widzimy zmienia się tylko delikatnie (w porównaniu do JSONa) struktura obiektu stworzonego po wczytaniu YAMLa. Jak sięgać do środka?

cat(config$modul1$zmienna_txt)

## Jestem "zmienną" tekstową z 'ciapkami'


cat(config$modul1$zmienna_num)

## 123


cat(class(config$modul1$zmienna_num))

## integer


cat(class(config$modul1$zmienna_flo))

## numeric


cat(config$modul2$zmienna_arr_txt)

## str1 str2 str3


cat(config$modul2$zmienna_arr_num)

## 123 456 789

Sięganie do elementu tablicy jest intuicyjne:

cat(config$modul3$obiekt2$tablica_str[2])

## obj 2 - string 2

Nawet jeśli to tablica bardziej złożonych obiektów:

cat(config$modul4[[2]]$nazwa)

## array of objects 2

Uwaga - inna kolejność sięgania do parametrów niż w przypadku sięgania do nich wczytanych z JSONa, ale tutaj zgodna z logiką. Trudniej z YAMLa (w porównaniu do JSONa) uzyskać data frame. Ale YAML to format do konfiguracji a nie przekazywana danych. Do danych JSON sprawdza się zdecydowanie lepiej (co widać we wszelakich API). Podsumowując - pliki YAML:

  • pozwalają na komentarze na każdym poziomie
  • zachowane są typy danych
  • stringów mogą być bez ciapek
  • można użyć zagnieżdżeń właściwie na każdym poziomie
  • format daje nam trochę swobody w pisaniu (np. definiowanie tablic) i czyta się podobnie do naturalnego języka

No ideał! ;)

GitHub

Żeby było łatwiej i nie trzeba było kopiować kodu z tego wpisu wszystko wrzuciłem na swojego GitHuba - śmiało forkuj, jeśli potrzebujesz. A jeśli jesteś już tutaj to możesz też wykonać jeden klik i zapisać się na newsletter.