Nette Command-Line

Píšete v PHP skript, který se spouští z příkazové řádky? Ať jde o malého pomocníka na jedno odpoledne, nebo o nástroj s celou řadou příkazů, tahle knihovna za vás udělá všechnu tu otravnou práci okolo:

  • Přečte volby a argumenty a zkontroluje jejich hodnoty
  • Sestaví nápovědu a srozumitelné chybové hlášky
  • Zvládne i nástroje s více příkazy
  • Vypisuje barevně, ale v rouře a v logu barvy sama vypne
  • Nemá žádné závislosti

Instalace

composer require nette/command-line

Vyžaduje PHP verze 8.2 a podporuje PHP až do verze 8.6.

První skript

Nejlíp se to ukáže na příkladu. Dejme tomu, že máte hromadu obrázků a chcete je převést do jiného formátu. Napíšeme si na to skript convert.php, který se bude volat třeba takhle:

php convert.php photo.jpg logo.png --format png
php convert.php photo.jpg --dry-run

Příkazová řádka se skládá ze tří druhů položek. Argumenty jsou prosté hodnoty bez pomlčky, tady jména souborů. Volba má jméno a hodnotu: --format png říká, do jakého formátu převádět, a dá se zkrátit na -f png. Příznak je jen vypínač bez hodnoty: --dry-run (zkráceně -n) způsobí, že skript jen vypíše, co by udělal, ale na žádný soubor nesáhne.

Kolik kódu je potřeba, aby tohle všechno fungovalo? Překvapivě málo. Samotný převod napíšete jako obyčejnou metodu a atributy u jejích parametrů prozradíte, jak se jednotlivé hodnoty zadávají na příkazové řádce:

use Nette\CommandLine\Attributes as Cli;
use Nette\CommandLine\Command;
use Nette\CommandLine\Parser;

final class ImageConverter
{
	#[Cli\Command('Converts images to another format')]
	public function convert(
		#[Cli\Argument('Images to convert', repeatable: true)]
		array $files,
		#[Cli\Option('Target format', alias: '-f', enum: ['jpg', 'png', 'webp'])]
		string $format = 'webp',
		#[Cli\Flag('Only show what would be done', alias: '-n')]
		bool $dryRun = false,
	): int
	{
		// převede každý soubor do $format, při $dryRun jen vypíše, co by udělal
		return 0;
	}
}

Všimněte si, že nikde nesaháte na $argv a nic neparsujete. Z parametru $format se stane volba --format, z $dryRun příznak --dry-run a výchozí hodnota parametru platí, když uživatel volbu vynechá. Texty v atributech popisují jednotlivé položky a objeví se v nápovědě. repeatable: true říká, že souborů může být víc, a metoda je proto dostane jako pole. enum vyjmenovává povolené hodnoty, takže --format gif skript odmítne dřív, než se metoda vůbec zavolá.

Zbývá metodu spustit. Pod třídu do stejného souboru convert.php připíšete:

require __DIR__ . '/vendor/autoload.php';

$converter = new ImageConverter;
$program = Command::from($converter->convert(...));

$parser = new Parser;
$args = $parser->parse($program);
exit($args->run());

Zápis $converter->convert(...) metodu nevolá, ale předává ji dál. Command::from() z ní vyčte, co skript přijímá. Parser pak přečte příkazovou řádku a zkontroluje hodnoty. Nakonec run() zavolá vaši metodu se správnými hodnotami. Co metoda vrátí, to se stane návratovým kódem skriptu. Podle něj shell, CI nebo jiný skript poznají, jak to dopadlo: 0 znamená úspěch, cokoli jiného problém.

A protože lidé u příkazové řádky mají své zvyky, poradí si skript se všemi obvyklými zápisy: --format png, --format=png i -f png znamenají totéž, jednopísmenné volby jde slepit dohromady (-nf png je -n -f png) a volby smějí stát kdekoli mezi soubory.

Nápověda a chyby

Každý slušný skript má mít nápovědu a srozumitelně hlásit chyby. Obojí přidáte pár řádky, spouštěcí část convert.php stačí rozšířit takhle (dva nové řádky use patří nahoru k ostatním):

use Nette\CommandLine\HelpRenderer;
use Nette\CommandLine\UsageException;

$converter = new ImageConverter;
$program = Command::from($converter->convert(...));
$program->addFlag('--help', 'Print this help', alias: '-h', standalone: true);

$parser = new Parser;
$renderer = new HelpRenderer;

try {
	$args = $parser->parse($program);
	if ($args->get('--help')) {
		$renderer->render($args->command);
		exit(0);
	}
	exit($args->run());

} catch (UsageException $e) {
	$renderer->renderError($e);
	exit(2);
}

Příznak --help je samostatný (standalone), takže zafunguje, i když uživatel nezadal povinné soubory. Parser v takovém případě nic dalšího nekontroluje. HelpRenderer sestaví nápovědu z popisů v atributech a vypíše ji pro $args->command, tedy pro příkaz, který uživatel zvolil; u skriptu bez příkazů je to celý program. Zkuste teď spustit php convert.php --help:

Nápověda skriptu v terminálu

Nemuseli jste ji psát, a přesto je úplná, zarovnaná a barevná. A protože vzniká ze stejné metody, která dělá práci, nikdy nebude tvrdit něco jiného, než co skript opravdu umí.

O chyby se stará blok catch. Když se uživatel přepíše nebo zadá nesmysl, parser vyhodí UsageException a renderError() z ní místo stack trace udělá srozumitelnou odpověď:

$ php convert.php --fromat png photo.jpg
Error: Unknown option `--fromat`. Did you mean `--format`?

$ php convert.php --format gif photo.jpg
Error: Option `--format`: expects `jpg`, `png` or `webp`, `gif` given.

Pod hláškou se rovnou vypíše nápověda, takže uživatel nemusí nic dohledávat, a skript skončí návratovým kódem 2, kterým se špatně zadaná příkazová řádka obvykle hlásí. Vaše metoda se v takovém případě vůbec nezavolá, takže nikdy nepoběží s nesmyslnými hodnotami.

Tenhle spouštěcí blok je v každém skriptu stejný, takže si ho jednou napíšete a dál už jen měníte, co se do $program přidá.

Bez atributů

Atributy jsou nejpohodlnější cesta: každá volba je popsaná přímo u parametru, který ji přijímá, hodnoty dostane metoda rovnou do svých parametrů a nemáte žádnou zvláštní definici, kterou by bylo potřeba udržovat v souladu s kódem. Pod nimi ale leží obyčejné API, které můžete použít i přímo. Hodí se pro rychlý skript bez třídy nebo tehdy, když volby skládáte až za běhu:

use Nette\CommandLine\Command;
use Nette\CommandLine\Parser;

$command = new Command('convert');
$command->addOption('--format', 'Target format', alias: '-f', enum: ['jpg', 'png', 'webp']);
$command->addArgument('files', 'Images to convert', repeatable: true);

$parser = new Parser;
$args = $parser->parse($command);
$args->get('--format');  // 'png'
$args->get('files');     // ['photo.jpg', 'logo.png']

Nápovědu a chyby obsloužíte úplně stejně jako výše. Všechno, co volby a argumenty umějí, najdete na stránce Argumenty, volby a příkazy.

Více příkazů

Skripty mají ve zvyku růst. Dnes obrázky převádí, zítra by je měl umět i zmenšovat. Takové nástroje se obvykle ovládají příkazy, podobně jako composer install a composer update:

php images.php convert photo.jpg --format png
php images.php resize photo.jpg --width 800
php images.php --quiet convert photo.jpg

Nic nemusíte přepisovat. Každý příkaz je jedna metoda s atributem #[Cli\Command], stejná jako convert() výše, a celou třídu takových metod přidáte jedním voláním. Volby, které platí pro všechny příkazy, jako --quiet v posledním řádku, přidáte k celému programu:

$tools = new ImageTools;  // obsahuje metody convert() a resize()
$program = new Command('images');
$program->addFlag('--quiet', 'Print nothing but errors', alias: '-q');
$program->addFlag('--help', 'Print this help', alias: '-h', standalone: true);
$program->addCommandsFrom($tools);

Zbytek skriptu, tedy parser, nápověda a chyby, je stejný jako v convert.php. Protože render($args->command) vypíše nápovědu k vybranému příkazu, dostane každý příkaz vlastní nápovědu (php images.php convert --help). Příkazy jde i vnořovat, takže třeba php images.php cache clear je příkaz clear uvnitř příkazu cache. Víc v sekci Příkazy.

Výstup do terminálu

Skript ale jen nečte, také píše. K tomu slouží třída Console, která se řídí tím, kam výstup míří. V terminálu píše barevně, a když výstup přesměrujete do souboru nebo skript běží v CI, barvy sama vynechá, takže log zůstane čistý. Podobně s průběhem práce: v terminálu se ukazuje na řádku, který se průběžně přepisuje, a do logu se vůbec nedostane.

Konzoli vytvoříte jednou a předáte ji třídě, která ji potřebuje, třeba v konstruktoru:

final class ImageConverter
{
	public function __construct(
		private Console $console,
	) {
	}

	#[Cli\Command('Converts images to another format')]
	public function convert(
		#[Cli\Argument('Images to convert', repeatable: true)]
		array $files,
	): int
	{
		foreach ($files as $file) {
			$this->console->setStatus("Converting $file");  // řádek s průběhem
			// ...
			$this->console->writeLine("$file converted", 'green');
		}
		return 0;
	}
}

$console = new Console;
$converter = new ImageConverter($console);

Text se navíc vypíše přesně tak, jak je. Některé knihovny v textu hledají značky pro barvy, takže XML nebo vstup od uživatele musíte nejdřív escapovat, jinak se rozbije. Tady nic takového nehrozí. Víc na stránce Výstup do terminálu.

Testování

Na závěr dobrá zpráva pro všechny, kdo píšou testy: skript otestujete, aniž byste ho spouštěli z příkazové řádky. Parseru předáte jednotlivá slova příkazové řádky, jako by je napsal uživatel, a ověříte, co z nich vyčetl, nebo jakou chybu nahlásil. Špatně zadanou řádku parser hlásí výjimkou ParseException, což je druh UsageException, kterou jsme zachytávali výše. Příklad používá Nette Tester:

$parser = new Parser;

Assert::exception(
	fn() => $parser->parse($program, ['--format', 'gif', 'photo.jpg']),
	ParseException::class,
	'Option `--format`: expects `jpg`, `png` or `webp`, `gif` given.',
);

$args = $parser->parse($program, ['photo.jpg', '--dry-run']);
Assert::same(0, $args->run());

Víc v sekci Testování.

Přechod z verze 1.x

Verze 2.0 je nové API bez vrstvy zpětné kompatibility: v 1.x držel jediný Parser definici, zpracovával řádku i vypisoval text nápovědy, který dostal, zatímco ve 2.0 je definicí Command, Parser proti ní čte řádku a HelpRenderer z ní vykresluje nápovědu.

Plugin Nette pro Claude Code a Codex obsahuje návod, podle kterého váš kódovací agent kód z 1.x věrně přepíše na 2.0 a nahlásí každé místo, kde se 2.0 chová jinak.

verze: 2.x 1.x