CLI jako narzędzie zarządzania projektem i automatyzacji zadań deweloperskich
Marzec 30, 2025 | #python , #bash , #makefiie , #cli
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:
- Uproszczenia złożonych i długich komend, co oszczędza czas i zmniejsza ryzyko popełnienia błędu.
- 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.
- Automatyzacji często powtarzanych zadań, takich jak formatowanie kodu, uruchamianie testów itp.
- Zabezpieczenia danych wrażliwych poprzez wczytywanie ich z plików konfiguracyjnych zamiast ręcznego wpisywania.