Quickstart¶
In this tutorial you build notes, a small CLI that stores notes in a JSON file. Along the way you
use every core part of a manifest: commands, params, helper functions, groups, aliases, shell
commands and tests. It takes about 15 minutes.
You need cliffy installed in an active virtual environment. See Installation.
The finished manifest is at the end of this page.
1. A manifest with one command¶
Create a file called notes.yaml:
name: notes
version: 0.1.0
help: Keep short notes in a JSON file
commands:
add:
help: Add a note
params:
- text: str!
run: |
print(f"Added {text}")
tests:
- add "buy milk": assert result.output == "Added buy milk\n"
nameis what you will type to run the CLI.versionis shown bynotes --version.- Each key under
commandsis a command.runholds its Python code. text: str!declares a parameter calledtextof typestr. The!makes it required. Parameters are ordinary variables insiderun.testsis optional. You will come back to it in step 7.
Run the command without installing anything:
cli run generates the CLI, runs it once and throws it away. To see the Python it generated, use
cli render:
def add(text: str = typer.Argument(...)):
print(f"Added {text}")
cli.command("add", help="Add a note",)(add)
Your run script became the body of a Typer command function.
2. Store the notes¶
The CLI needs to read and write a file. Add imports, vars and functions between help and
commands:
imports: |
import json
import os
from collections import Counter
from pathlib import Path
vars:
default_file: notes.json
functions:
- |
def notes_path() -> Path:
return Path(os.environ.get("NOTES_FILE", "{{ default_file }}"))
- |
def load_notes() -> list[dict]:
path = notes_path()
return json.loads(path.read_text()) if path.exists() else []
- |
def save_notes(notes: list[dict]) -> None:
notes_path().write_text(json.dumps(notes, indent=2))
importsis copied to the top of the generated module.functionsis a list of helper functions that every command can call.varsdefines manifest variables.{{ default_file }}is replaced withnotes.jsonwhen the manifest is loaded. More in Vars and Jinja.
3. Describe parameters once with types¶
text: str! is short, but it has no help text. The types section lets you name a full
Typer parameter definition and reuse it. Add this
after functions:
types:
Text: str = typer.Argument(..., help="Text of the note")
Tag: str = typer.Option("general", "--tag", "-t", help="Tag to file the note under")
Now replace the add command so that it uses those types and saves the note:
commands:
add:
help: Add a note
params:
- text: Text
- tag: Tag
run: |
notes = load_notes()
notes.append({"text": text, "tag": tag})
save_notes(notes)
print(f"Added note {len(notes)} [{tag}]")
Try it:
$ cli run notes.yaml add "buy milk"
Added note 1 [general]
$ cli run notes.yaml -- add "book flights" --tag travel
Added note 2 [travel]
The -- separator
When the arguments for your CLI include options such as --tag, put -- after the manifest
path. Without it, cli run tries to interpret --tag as one of its own options.
4. Add options and an alias¶
Add a second command under commands:
list|ls:
help: List notes, optionally filtered by tag
params:
- --tag|-t: str = ""
run: |
for number, note in enumerate(load_notes(), start=1):
if not tag or note["tag"] == tag:
print(f"{number}. [{note['tag']}] {note['text']}")
- The key
list|lscreates the commandlistand the aliasls. --tag|-t: str = ""is an option (the name starts with--) with the short form-tand an empty string as its default.
$ cli run notes.yaml list
1. [general] buy milk
2. [travel] book flights
$ cli run notes.yaml -- ls -t travel
2. [travel] book flights
5. Exit codes and groups¶
Commands can stop with an error code by raising typer.Exit. Add remove:
remove|rm:
help: Remove a note by its number
params:
- number: int!
run: |
notes = load_notes()
if not 1 <= number <= len(notes):
print(f"No note {number}")
raise typer.Exit(code=1)
notes.pop(number - 1)
save_notes(notes)
print(f"Removed note {number}")
A dot in a command key creates a group. These two commands are run as notes tags list and
notes tags rename. The entry tags, which has help and no run, sets the help text of the group:
tags:
help: Work with tags
tags.list:
help: Count notes per tag
run: |
for tag, count in sorted(Counter(note["tag"] for note in load_notes()).items()):
print(f"{tag} ({count})")
tags.rename:
help: Rename a tag on every note
params:
- old: str!
- new: str!
run: |
notes = load_notes()
for note in notes:
if note["tag"] == old:
note["tag"] = new
save_notes(notes)
print(f"Renamed {old} to {new}")
$ cli run notes.yaml tags list
general (1)
travel (1)
$ cli run notes.yaml tags rename general home
Renamed general to home
$ cli run notes.yaml remove 7
No note 7
6. Call a shell command¶
A line that starts with $ is run as a shell command. f{...} inserts the value of a Python
expression into it:
backup:
help: Copy the notes file next to itself
run: |
$ cp f{notes_path()} f{notes_path()}.bak
print(f"Backed up to {notes_path()}.bak")
More in Shell commands.
7. Test it¶
Replace the tests section at the end of the file:
tests:
- $ rm -f notes.json notes.json.bak
- add "buy milk" --tag home: assert result.output == "Added note 1 [home]\n"
- add "ship release": assert "Added note 2 [general]" in result.output
- list: |
assert "1. [home] buy milk" in result.output
assert "2. [general] ship release" in result.output
- ls -t home: assert result_text == "1. [home] buy milk"
- tags rename general work: assert result.exit_code == 0
- tags list: assert "work (1)" in result.output
- backup: assert "Backed up" in result.output
- remove 9: assert result.exit_code == 1
- rm 1: assert "Removed note 1" in result.output
- $ rm -f notes.json notes.json.bak
- An entry that starts with
$is a shell step. Here it removes leftover files before and after the run. - Every other entry maps a command line to Python assertions.
resultis the outcome of the command, withresult.outputandresult.exit_code.result_textis the output with surrounding whitespace removed.
More in Testing manifests.
8. Load it as a real command¶
So far every call went through cli run. Load the manifest to get a notes command:
$ cli load notes.yaml
✨ Generated notes CLI v0.1.0 ✨
$ notes -h
$ notes add --help
Usage: notes add [OPTIONS] {text}
Add a note
╭─ Arguments ────────────────────────────────────────────────────────────────╮
│ * text <str> Text of the note [required] │
╰────────────────────────────────────────────────────────────────────────────╯
╭─ Options ──────────────────────────────────────────────────────────────────╮
│ --tag -t <str> Tag to file the note under [default: general] │
│ --help -h Show this message and exit. │
╰────────────────────────────────────────────────────────────────────────────╯
notes stays available in this environment until you run cli remove notes. After you edit the
manifest, run cli update notes to regenerate it, or let cli dev do that
on every save.
9. Ship it as one file¶
$ cli build notes.yaml -o dist
+ notes built 📦
dist/notes (single file, needs Python >=3.10, 1.8 MB)
run it with: ./dist/notes
$ ./dist/notes --version
notes, 0.1.0
dist/notes is a self-contained zipapp. It runs on any machine with a compatible Python 3 and does
not need cliffy. With --target script you get a single Python script instead, and with
--target native a compiled executable that does not even need Python. More in
Building executables.
The finished manifest¶
name: notes
version: 0.1.0
help: Keep short notes in a JSON file
imports: |
import json
import os
from collections import Counter
from pathlib import Path
vars:
default_file: notes.json
functions:
- |
def notes_path() -> Path:
return Path(os.environ.get("NOTES_FILE", "{{ default_file }}"))
- |
def load_notes() -> list[dict]:
path = notes_path()
return json.loads(path.read_text()) if path.exists() else []
- |
def save_notes(notes: list[dict]) -> None:
notes_path().write_text(json.dumps(notes, indent=2))
types:
Text: str = typer.Argument(..., help="Text of the note")
Tag: str = typer.Option("general", "--tag", "-t", help="Tag to file the note under")
commands:
add:
help: Add a note
params:
- text: Text
- tag: Tag
run: |
notes = load_notes()
notes.append({"text": text, "tag": tag})
save_notes(notes)
print(f"Added note {len(notes)} [{tag}]")
list|ls:
help: List notes, optionally filtered by tag
params:
- --tag|-t: str = ""
run: |
for number, note in enumerate(load_notes(), start=1):
if not tag or note["tag"] == tag:
print(f"{number}. [{note['tag']}] {note['text']}")
remove|rm:
help: Remove a note by its number
params:
- number: int!
run: |
notes = load_notes()
if not 1 <= number <= len(notes):
print(f"No note {number}")
raise typer.Exit(code=1)
notes.pop(number - 1)
save_notes(notes)
print(f"Removed note {number}")
tags:
help: Work with tags
tags.list:
help: Count notes per tag
run: |
for tag, count in sorted(Counter(note["tag"] for note in load_notes()).items()):
print(f"{tag} ({count})")
tags.rename:
help: Rename a tag on every note
params:
- old: str!
- new: str!
run: |
notes = load_notes()
for note in notes:
if note["tag"] == old:
note["tag"] = new
save_notes(notes)
print(f"Renamed {old} to {new}")
backup:
help: Copy the notes file next to itself
run: |
$ cp f{notes_path()} f{notes_path()}.bak
print(f"Backed up to {notes_path()}.bak")
tests:
- $ rm -f notes.json notes.json.bak
- add "buy milk" --tag home: assert result.output == "Added note 1 [home]\n"
- add "ship release": assert "Added note 2 [general]" in result.output
- list: |
assert "1. [home] buy milk" in result.output
assert "2. [general] ship release" in result.output
- ls -t home: assert result_text == "1. [home] buy milk"
- tags rename general work: assert result.exit_code == 0
- tags list: assert "work (1)" in result.output
- backup: assert "Backed up" in result.output
- remove 9: assert result.exit_code == 1
- rm 1: assert "Removed note 1" in result.output
- $ rm -f notes.json notes.json.bak
examples:
- command: notes add "buy milk" --tag home
description: Add a note tagged home.
- command: notes ls --tag home
description: Show only the notes tagged home.
The examples section at the end is shown at the end of notes --help and is used by
cli docs.
Next steps¶
- Manifest basics explains how a manifest is put together.
- Params and types covers every way to declare a parameter.
- Command templates and global params shows how to share params between commands.