CLI jako narzędzie zarządzania projektem i automatyzacji zadań deweloperskich

Marzec 30, 2025 | #python , #bash , #makefiie , #cli

000000
001000
110100
001101

CLI, czyli Command Line Interface (Interfejs Linii Poleceń), to sposób interakcji z komputerem lub oprogramowaniem, za pośrednictwem linii komend wpisywanych w terminalu. Każdy - a przynajmniej większość - kto miał okazję trochę dłużej pracować z konsolą dostrzega zalety interfejsu tekstowego. Dlatego też, pomimo rozbudowanych i atrakcyjnych wizualnie GUI, zarówno systemy operacyjne, jak i poszczególne programy wciąż wspierają, a nawet rozbudowują CLI.

Tworząc oprogramowanie, bardzo często deweloperzy posługują się całą masą różnych komend, żeby zrealizować różnorakie zadania w projekcie w tym m.in.:

  • inicjalizacja projektu, aplikacji lub modułu (ang. scaffolding),
  • zarządzanie wersjami z użyciem systemów kontroli wersji np. Git,
  • utworzenie środowiska projektowego i instalacja zależności,
  • kontrola jakości kodu (lint), formatowanie kodu i sprawdzanie bezpieczeństwa i uruchamianie testów jednostkowych,
  • kompilacja i budowa aplikacji w tym: minifikacja, bundling czy transpilacja,
  • komunikacja z bazami danych, tworzenie migracji itd.

Można wymieniać jeszcze długo. Nawet w średniej wielkości projektach tych poleceń, które są regularnie używane, jest cała masa, są często rozbudowane, miewają nieintuicyjną składnię, a od czasu do czasu wymagają podania danych wrażliwych, np. hasła do bazy danych. Dość powodów aby pomyśleć nad rozwiązaniem, które ułatwi pracę z projektem.

Makefile

Makefile to specjalny plik tekstowy o nazwie dosłownie Makefile, który zawiera zestaw reguł określających zwykle jak skompilować program, albo zautomatyzować jakieś zadania takie jak testowanie, instalacja, czyszczenie plików tymczasowych itd. Używany jest przede wszystkim razem z narzędziem Make, które odczytuje te reguły i uruchamia zdefiniowane instrukcje.

Przykładowy plik Makefile do zarządzania projektem może wyglądać tak:

# Define that the following targets are not files
.PHONY: help app-start app-exec app-run app-logs \
        code-format code-check code-test code-all \
		db-cli

# Define working directory
WORKING_DIR := $(shell dirname $(realpath $(lastword $(MAKEFILE_LIST))))

# Load environment variables from .env file (if there are variables set in the format VAR=value)
-include $(WORKING_DIR)/.env

# Abbreviation for docker compose
CMD_DC := docker compose

# Macro for running a command with arguments
#   - get arguments from MAKECMDGOALS except the current target ($@)
#   - if no arguments, set the default value passed as $(2)
#   - run command passed as $(1) and collected arguments
define RUN_WITH_ARGS
	@args="$(filter-out $@,$(MAKECMDGOALS))"; \
	if [ -z "$$args" ]; then \
	    args="$(2)"; \
	fi; \
	echo "Running: $(1) $$args"; \
	$(1) $$args
endef


help:
	@echo "Available targets:"
	@echo "  make app-start               - Start the project using the command 'docker compose up'."
	@echo "  make app-logs ARGS='<args>'  - Displays logs from Docker containers "
	@echo "                                 using the command 'docker compose logs -f <args>'."
	@echo "  make app-run ARGS='<args>'   - Starts the Docker container and executes a command in it "
	@echo "                                 using the command 'docker compose run --rm <args>'. "
	@echo "                                 Default args are 'fastapi sh'."
	@echo "  make app-exec ARGS='<args>'  - Execute a command in a running Docker container "
	@echo "                                 using the command 'docker compose exec -it <args>'. "
	@echo "                                 Default args are 'fastapi sh'."
	@echo ""
	@echo "  make code-format             - Formats code"
	@echo "  make code-check              - Lints code"
	@echo "  make code-test ARGS='<args>' - Runs unit tests using the command 'pytest <args>'."
	@echo "  make code-all                - Formats, lints, and tests code all in one go"
	@echo ""
	@echo "  make db-cli                  - Connect to the Postgres database using psql."
	@echo ""


# Targets for the "app" section
app-start:
	$(CMD_DC) up

app-logs:
	$(CMD_DC) logs -f $(ARGS)

app-run:
	$(CMD_DC) run --rm $(or $(ARGS),fastapi sh)

app-exec:
	$(call RUN_WITH_ARGS,$(CMD_DC) exec -it,fastapi sh)


# Targets for the "code" section
code-format:
	$(CMD_DC) run --rm fastapi ruff format

code-check:
	$(CMD_DC) run --rm fastapi ruff check --fix

code-test:
	$(CMD_DC) run --rm fastapi pytest $(ARGS)

code-all:
	$(CMD_DC) run --rm fastapi /bin/sh -c "ruff format && ruff check --fix && pytest"

# Targets for the "db" section
db-cli:
	$(CMD_DC) exec -it postgres psql -U $(POSTGRES_USER) $(POSTGRES_DB)


# Catch-all – will catch all undefined targets
%:
	@:

Wywoływanie komend Make

W Makefile zdefiniowane są cele, które następnie można wywołać. Wywołanie komendy Make powinno nastąpić z katalogu, w którym znajduje się plik Makefile.

make app-start
# docker compose up

Uruchamia ono polecenie docker compose up. W tym wypadku korzyść jest niewielka, ale w przypadku polecenia ...

make code-all
# docker compose run --rm fastapi /bin/sh -c "ruff format && ruff check --fix && pytest"

... pod spodem uruchamiana jest złożona instrukcja, którą najpewniej nie chciałbyś wklepywać ręcznie za każdym razem.

Instrukcje z parametrami wywoływane są w następujący sposób.

make app-logs ARGS=fastapi
# docker compose logs -f fastapi

Wartości domyślne parametrów w skryptach Make

Nie ma w tym wypadku podanej domyślnej wartości dla zmiennej ARGS. Poza tym takie przekazywanie parametrów nie jest to zbyt wygodne. Lepiej byłoby móc zrobić coś takiego make app-logs fastapi.

Pierwszemu problemowi można zaradzić definiując cel (czytaj instrukcję) w następujący sposób:

app-run:
	$(CMD_DC) run --rm $(or $(ARGS),fastapi sh)

Dzięki temu można wywołać cel z domyślnymi parametrami,

make app-run
# docker compose run --rm fastapi sh

albo podać inne parametry.

make app-run ARGS=db sh
# docker compose run db bash

Jeszcze lepszym rozwiązaniem będącym remedium na oba wspomniane niedogodności jest makro.

# Macro for running a command with arguments
#   - get arguments from MAKECMDGOALS except the current target ($@)
#   - if no arguments, set the default value passed as $(2)
#   - run command passed as $(1) and collected arguments
define RUN_WITH_ARGS
	@args="$(filter-out $@,$(MAKECMDGOALS))"; \
	if [ -z "$$args" ]; then \
	    args="$(2)"; \
	fi; \
	echo "Running: $(1) $$args"; \
	$(1) $$args
endef

app-exec:
	$(call RUN_WITH_ARGS,$(CMD_DC) exec -it,fastapi sh)

# Catch-all – will catch all undefined targets
%:
	@:

Makro jest uniwersalne i pozwala na uruchomienie dowolnej instrukcji i ustawienie domyślnej wartości dla argumentów

app-exec:
	$(call RUN_WITH_ARGS,$(CMD_DC) exec -it,fastapi sh)

To, co jest w nim jednak najprzyjemniejsze, to sposób w jaki od tej pory możemy wywołać powyższy cel.

make app-exec fastapi ls -lah

Uwaga! fastapi ls -lah to z punktu widzenia języka Make osobne cele, którym brakuje definicji. Dlatego też na samym końcu dodana jest pułapka pozwalająca przechwycić niezdefiniowane cele.

%:
	@:

Dostęp do zmiennych środowiskowych w Makefile

Ostatnią rzeczą zaimplementowaną w przytoczonym powyżej pliku Makefile jest ładowanie zmiennych środowiskowych z pliku .env i użycie ich w instrukcji.

Przykładowy plik .env wygląda następująco:

POSTGRES_USER=pg_user
POSTGRES_DB=pg_db

Użycie też nie jest zanadto skomplikowane.

# Define working directory
WORKING_DIR := $(shell dirname $(realpath $(lastword $(MAKEFILE_LIST))))

# Load environment variables from .env file (if there are variables set in the format VAR=value)
-include $(WORKING_DIR)/.env

db-cli:
	$(CMD_DC) exec -it postgres psql -U $(POSTGRES_USER) $(POSTGRES_DB)

Na początku rozpoznawane jest położenie pliku Makefile. Założeniem jest, że plik .env jest położony w tym samym katalogu. Następnie plik ze zmiennymi jest includowany.

Instrukcja db-cli obrazuje w jaki sposób odwołać się do tych zmiennych

make db-cli
# docker compose exec -it postgres psql -U pg_user pg_db

Make nie należy do super przyjaznych technologii, więc jeśli nie ma potrzeby używania go, nie ma sensu się go uczyć - tym bardziej, że spokojnie można go zastąpić innymi znanymi technologiami.

Bash

Zgadzam się - Bash też nie jest najbardziej intuicyjnym językiem programowania, ale używany jest zdecydowanie częściej niż Make, więc warto się z nim zaprzyjaźnić, a przynajmniej oswoić.

Poniższy skrypt z pliku ./cli (można dodać rozszerzenie .sh ale to zawsze kilka literek więcej do wklepania) robi to co ten wyżej z tym, że wszystko jest tu na "gotowo" plus mały bonus. Przyjrzyjmy się mu:

#!/usr/bin/env bash  
  
#~ A script to manage the project  
#~  
#~ It contains shortcuts to run common commands used in the project.  
#~  
#~ Usage: ./cli [options]  
#~  
  
WORKING_DIR="$(dirname ${0})"  
  
  
load_env() { 
  local ENV_FILE="${1?Missing environment file}"  
  local ENVS_ARRAY VAR_DECLARATION  
  mapfile ENVS_ARRAY < <(  
    grep --invert-match '^#' "${ENV_FILE}" | grep --invert-match '^\s*$'  
  ) # Uses grep to remove commented and blank lines  
  for VAR_DECLARATION in "${ENVS_ARRAY[@]}"; do  
    { # try  
      export "${VAR_DECLARATION//[$'\r\n']}" # The substitution removes the line breaks  
    } || { # catch  
       echo "Cannot load '${VAR_DECLARATION}'"  
    }  
  done
}  
  
  
display_help() {  
    # Creating help text by reading comments from the source file.  
    # Displays all lines that start with #~, regardless of CRLF
	local src="$0"  
    sed -n 's/^\(\r*[[:space:]]*\)#~/\1/p' "$src"  
}  
  
cmd_dc () {  
    local cmd=(docker compose "$@")  
    echo "Running: ${cmd[*]}"  
    eval "${cmd[@]}"  
}  
  
load_env "${WORKING_DIR}/.env"  
  
case "$1" in  
    #~ Application:  
    app)  
        case $2 in  
            #~ app start - starts the project in detached mode and displays logs  
            start)  
                cmd_dc up 
                ;;  
            #~ app exec [<container> [<command>]] - executes a command in the running container  
            #~     - default container is fastapi and default command is sh
            exec)  
                cmd_dc exec -it "${3:-fastapi sh}" "${@:4}"  
                ;;  
            #~ app run [<container> [<command>]] - runs a command in the selected container  
            #~     - default container is fastapi and default command is sh
            run)  
                cmd_dc run --rm "${3:-fastapi sh}" "${@:4}"  
                ;;  
            #~ app logs - shows Docker Compose logs and follows them  
            logs)  
                cmd_dc logs -f "${@:3}"  
                ;;  
            #~  
            *)  
                display_help  
                ;;  
        esac
        ;;
    #~ Code quality:  
    code)  
        case $2 in  
            #~ code format - formats the code  
            format)  
                cmd_dc run --rm fastapi ruff format  
                ;;  
            #~ code check - lints the code  
            check)  
                cmd_dc run --rm fastapi ruff check --fix  
                ;;  
            #~ code test - runs unit tests  
            test)  
                cmd_dc run --rm fastapi pytest "${@:3}"  
                ;; 
            #~ code all - runs 'format', 'check', and 'test' commands together  
            all)  
                cmd_dc run --rm fastapi /bin/sh -c "\"ruff format && ruff check --fix && pytest\""  
                ;;  
            #~  
            *)  
                display_help  
                ;;  
        esac
		;;
	#~ Database:
    db)
        case $2 in
            #~ db cli - connects to PostgreSQL database using psql
            cli)
                cmd_dc exec -it postgres psql -U "${POSTGRES_USER}" "${POSTGRES_DB}"
                ;;
        esac
		;;
    #~ --help | -h Displays this help text.
	--help|-h)  
        display_help  
        ;;  
    #~  
    *)  
        display_help  
        ;;  
esac

Wywołanie skryptu Bash z parametrami

W skrypcie powłoki nie potrzebujemy odpowiednika makra RUN_WITH_ARGS ponieważ pożądany sposób obsługi ekstra argumentów jest realizowany wprost z użyciem składni języka Bash. Parametry pozycyjne $1, $2 itd. dają dostęp do kolejnych argumentów przekazywanych do skryptu.

Wywołanie instrukcji

./cli app start
# docker compose up

powoduje kolejno wejście do instrukcji case i dopasowanie parametru app przekazanego za pośrednictwem $1 do wzorca app), następnie wchodzi do kolejnego case gdzie parametr start ($2) zostaje dopasowany do wzorca start).

case "$1" in
    app)  
        case $2 in
            start)  
                cmd_dc up 
                ;;

Ustawianie wartości domyślnych parametrów w Bash

Wartość domyślną parametru pozycyjnego w Bash realizuje się za pomocą składni ${3:-fastapi sh} (${<nr parametru>:-<wartość domyślna>}) dzięki czemu można wywołać pewne instrukcje bez parametrów (z parametrami domyślnymi) ...

./cli app run
# docker compose run --rm fastapi sh

lub też z niestandardowymi parametrami:

./cli app run fastapi ls -lah
# docker compose run --rm fastapi ls -lah

Funkcja cmd_dc jest odpowiednikiem skrótu CMD_DC := docker compose z Makefile, z tym że dodatkowo wypisuje wykonywaną instrukcję dzięki czemu zawsze wiadomo co się wywołało.

Ładowanie zmiennych środowiskowych w Bash

Funkcja load_env jest zdecydowanie bardziej rozbudowana niż prosty import z Makefile. W tym wypadku posiłkowałem się gotowym rozwiązaniem znalezionym w gist na GitHub-ie. Niemniej, warto zauważyć, że analogicznie do wcześniej zaprezentowanego skryptu, także tutaj chcąc dostać się do CLI bazy danych nie musimy za każdym razem podawać parametrów połączania, gdyż skrypt pobiera je sobie z pliku .env.

./cli db cli
# docker compose exec -it postgres psql -U pg_user pg_db

Automatyczne tworzenie treści pomocy w skrypcie Bash na podstawie komentarzy

Wspomnianym bonusem jest funkcja display_help, której zadaniem jest wyszukanie w pliku ./cli komentarzy zaczynających się od symbolu #~ poprzedzonego co najwyżej białymi znakami (ewentualnie znakami powrotu karetki) i wyświetlenie ich w formie pomocy w przypadku wywołania skryptu bez parametrów, wywołania z nierozpoznanymi parametrami lub też z parametrami wskazującymi wprost na tekst pomocy.

./cli -h
# A script to manage the project
#
# It contains shortcuts to run common commands used in the project.
#
# Usage: ./cli [options]
#
#     Application:
#             app start - starts project in detached mode and display logs
#             app exec [<container> [<command>]] - execute command in running selected container
#                 - default container is fastapi and default command is sh
#             app run [<container> [<command>]] - run command in selected container
#                 - default container is fastapi and default command is sh
#             app logs - shows docker compose logs and follow
#            
#     Code quality:
#             code format - formats code
#             code check - lint code
#             code test - runs unit tests
#             code all - runs 'format', 'check', and 'test' commands together
#
#     Database:
#             db cli - connect to PostgreSQL database using psql
#            
#     Other:
#         cmd <command> - runs command in the fastapi container eg `cmd hash_pass mypass`
#    
#     --help | -h Displays this help text.

Myślę, że dzięki temu łatwiej jest utrzymać spójność tekstu pomocy z oferowanymi przez skrypt funkcjonalnościami.

Na koniec ten sam skrypt zrealizowany w Pythonie, bo nie każdy musi lubić Bash-a.

Python

Python jest uniwersalnym językiem programowania i ma wiele zastosowań. Znając Pythona nie potrzebujesz niczego więcej. Jeśli nie lubisz języków powłoki to stworzenie prostego CLI równie dobrze możesz zrealizować w technologii, którą już znasz.

#!/usr/bin/env python3
import os
import sys
import subprocess
import logging
import inspect

# Custom log formatter that colors error messages in red.
class CustomFormatter(logging.Formatter):
    red = "\x1b[31;20m"
    reset = "\x1b[0m"

    def format(self, record):
        msg = super().format(record)
        if record.levelno == logging.ERROR:
            msg = f"{self.red}{msg}{self.reset}"
        return msg

# Logger configuration
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.INFO)
formatter = CustomFormatter("[%(levelname)s] %(message)s")
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)

def _load_env_file(env_file):
    """
    Reads environment variables from the given .env file and returns them as a dictionary.
    Skips comments and empty lines.
    """
    env_vars = {}
    try:
        with open(env_file, "r") as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith("#"):
                    continue
                try:
                    key, value = line.split("=", 1)
                    env_vars[key] = value
                except Exception as e:
                    logger.error(f"Cannot load '{line}': {e}")
                    raise
    except FileNotFoundError:
        logger.error(f"File {env_file} not found.")
        raise
    return env_vars

def _display_help():
    """
    Displays a help message with available commands and their descriptions.
    """
    # Get all available commands by inspecting functions
    command_groups = {}
    command_docs = {}
    
    # Get all functions from the module
    for name, func in inspect.getmembers(sys.modules[__name__], inspect.isfunction):
        # Skip functions that start with _ as they are helpers
        if name.startswith('_') or name == 'display_help' or name == 'main':
            continue
            
        parts = name.split('_', 1)
        if len(parts) == 2:
            group, cmd = parts
            if group not in command_groups:
                command_groups[group] = []
            command_groups[group].append(cmd)
            
            # Store function docstring (first line only) for help text
            doc = inspect.getdoc(func)
            if doc:
                # Get the first line of the docstring
                first_line = doc.split('\n')[0].strip()
                command_docs[name] = first_line
            else:
                command_docs[name] = "No description available"
    
    # Build help text
    help_text = "Usage: ./cli.py <group> <command> [args]\n"
    help_text += "Available groups and commands:\n"
    
    for group in sorted(command_groups.keys()):
        help_text += f"  {group}:\n"
        for cmd in sorted(command_groups[group]):
            func_name = f"{group}_{cmd}"
            description = command_docs.get(func_name, "No description available")
            help_text += f"    {cmd:<10} - {description}\n"
        help_text += "\n"
    
    logger.info(help_text)
    sys.exit(1)

def _cmd_dc(args):
    """
    Runs a Docker Compose command with the given arguments.
    """
    cmd = ["docker", "compose"] + args
    logger.info("Running: " + " ".join(cmd))
    try:
        subprocess.run(cmd, check=True)
    except subprocess.CalledProcessError as e:
        logger.error(f"Error executing command: {e}")
        raise

def _try_execute_command(func_name, args, env_vars):
    """
    Checks if a function exists and calls it if it does.
    Returns True if the function was found and executed, False otherwise.
    """
    func = globals().get(func_name)
    if func and callable(func) and not func_name.startswith('_'):
        func(args, env_vars=env_vars)
        return True
    return False


# --- Definitions for the "app" group ---
def app_start(args, **kwargs):
    """Starts the application in detached mode and displays logs."""
    _cmd_dc(["up"] + args)


def app_exec(args, **kwargs):
    """
    Executes a command in a running container.
    Default container is 'fastapi' and default command is 'sh'.
    """
    container = args[0] if args else "fastapi"
    cmd_args = args[1:] if len(args) > 1 else ["sh"]
    _cmd_dc(["exec", "-it", container] + cmd_args)

def app_run(args, **kwargs):
    """
    Runs a command in a new container.
    Default container is 'fastapi' and default command is 'sh'.
    """
    container = args[0] if args else "fastapi"
    cmd_args = args[1:] if len(args) > 1 else ["sh"]
    _cmd_dc(["run", "--rm", container] + cmd_args)

def app_logs(args, **kwargs):
    """Displays Docker Compose logs with optional arguments."""
    _cmd_dc(["logs", "-f"] + args)

# --- Definitions for the "code" group ---
def code_format(args, **kwargs):
    """Formats the code."""
    _cmd_dc(["run", "--rm", "fastapi", "ruff", "format"])

def code_check(args, **kwargs):
    """Checks the code with a linter and fixes issues."""
    _cmd_dc(["run", "--rm", "fastapi", "ruff", "check", "--fix"])

def code_test(args, **kwargs):
    """Runs unit tests."""
    _cmd_dc(["run", "--rm", "fastapi", "pytest"] + args)

def code_all(args, **kwargs):
    """Runs format, check, and test together."""
    _cmd_dc(["run", "--rm", "fastapi", "/bin/sh", "-c", "ruff format && ruff check --fix && pytest"])

# --- Definitions for the "db" group ---
def db_cli(args, **kwargs):
    """
    Connects to the PostgreSQL database using psql.
    Uses the POSTGRES_USER and POSTGRES_DB variables from the .env file.
    """
    # Get env_vars from kwargs
    env_vars = kwargs.get('env_vars')
    
    # Load env_vars if not provided
    if env_vars is None:
        working_dir = os.path.dirname(os.path.abspath(__file__))
        env_vars = _load_env_file(os.path.join(working_dir, ".env"))
        
    user = env_vars.get("POSTGRES_USER")
    db_name = env_vars.get("POSTGRES_DB")
    if not user or not db_name:
        logger.error("Required variables POSTGRES_USER or POSTGRES_DB are missing in the .env file")
        raise Exception("Invalid environment settings.")
    _cmd_dc(["exec", "-it", "postgres", "psql", "-U", user, db_name])


def main():
    try:
        working_dir = os.path.dirname(os.path.abspath(__file__))
        env_vars = _load_env_file(os.path.join(working_dir, ".env"))

        if len(sys.argv) < 2 or sys.argv[1] in ("--help", "-h"):
            _display_help()

        # Try to match the first two arguments to a function name
        if len(sys.argv) >= 3:
            func_name = f"{sys.argv[1]}_{sys.argv[2]}"
            if _try_execute_command(func_name, sys.argv[3:], env_vars):
                return
        
        # Try to match just the first argument
        if len(sys.argv) >= 2:
            func_name = sys.argv[1]
            if _try_execute_command(func_name, sys.argv[2:], env_vars):
                return
                
        # No matching function found
        _display_help()
        
    except Exception as e:
        logger.error(f"An error occurred: {e}", exc_info=True)
        sys.exit(1)

if __name__ == "__main__":
    main()

Działanie skryptu do zarządzania projektem w wersji Python jest analogiczne do tego w wersji Bash, ale implementacja z oczywistych powodów różni się zasadniczo. W Pythonie zostało zrealizowane dopasowywanie parametrów do nazw zdefiniowanych funkcji. Także tekst pomocy realizowany jest na podstawie nazw funkcji i ich docstringów. Dzięki zastosowaniu kwargs-ów, do każdej funkcji są przekazywane zmienne środowiskowe. W zaprezentowanym przykładzie korzysta z nich jedynie funkcja db_cli, ale możliwe jest również ich użycie w innych miejscach w razie potrzeby. Generyczny routing i automatyczne generowanie pomocy oraz ujednolicony interfejs funkcji poleceń sprawiają, że dodanie kolejnej komendy jest banalnie proste i sprowadza się do definicji nowej funkcji.

Podsumowanie

Niezależnie od wybranej technologii, stworzenie prostego skryptu do zarządzania projektem może przynieść istotne korzyści w postaci:

  1. Uproszczenia złożonych i długich komend, co oszczędza czas i zmniejsza ryzyko popełnienia błędu.
  2. Standaryzacji pracy całego zespołu - każdy korzysta z tych samych komend i może łatwo zapoznać się z ich listą, choćby wyświetlając pomoc.
  3. Automatyzacji często powtarzanych zadań, takich jak formatowanie kodu, uruchamianie testów itp.
  4. Zabezpieczenia danych wrażliwych poprzez wczytywanie ich z plików konfiguracyjnych zamiast ręcznego wpisywania.

Akceptuję Ta strona zapisuje niewielkie pliki tekstowe, nazywane ciasteczkami (ang. cookies) na Twoim urządzeniu w celu lepszego dostosowania treści oraz dla celów statystycznych. Możesz wyłączyć możliwość ich zapisu, zmieniając ustawienia Twojej przeglądarki. Korzystanie z tej strony bez zmiany ustawień oznacza zgodę na przechowywanie cookies w Twoim urządzeniu.