Guide 2: File Viewer (quickcat)
This guide builds quickcat, a minimal cat-like tool that prints one or more files to
standard output. It introduces global options, the built-in file_path validator, and
repeatable options.
You will learn how to:
- define global options on the
Application - use the built-in
file_pathvalidator - accept repeatable options with
multiple=True - combine multiple input paths in the handler
The full example
Save the following file as quickcat.py:
from __future__ import annotations
from pathlib import Path
from quickli import Application, Argument, Option, file_path
app = Application(
name="quickcat",
description="A tiny cat-like CLI built with quickli.",
global_options=[
Option("verbose", short_name="v", is_flag=True, help_text="Enable verbose output."),
],
)
@app.entrypoint(
help_text="Print one or more text files to stdout.",
arguments=[
Argument("path", help_text="Primary file path.", validators=[file_path()]),
],
options=[
Option("encoding", short_name="e", default="utf-8", help_text="Text encoding."),
Option("number", short_name="n", help_text="Print line numbers.", is_flag=True),
Option(
"include",
short_name="i",
multiple=True,
validators=[file_path()],
help_text="Additional file paths to print after the primary file.",
),
],
)
def show(
path: Path,
encoding: str = "utf-8",
number: bool = False,
include: list[Path] | None = None,
verbose: bool = False,
) -> str:
input_paths = [path, *(include or [])]
rendered_chunks: list[str] = []
for input_path in input_paths:
text = input_path.read_text(encoding=encoding)
lines = text.splitlines()
if number:
lines = [f"{index:>4} {line}" for index, line in enumerate(lines, start=1)]
if verbose:
rendered_chunks.append(f"==> {input_path} <==")
rendered_chunks.append("\n".join(lines))
return "\n".join(chunk for chunk in rendered_chunks if chunk)
if __name__ == "__main__":
print(app.run())
Run it
# Print a single file
python quickcat.py README.md
# Print with line numbers
python quickcat.py README.md --number
# Print multiple files (verbose shows filenames)
python quickcat.py README.md -i CONTRIBUTING.md --verbose
# Change text encoding
python quickcat.py data.txt --encoding latin-1
Line-by-line explanation
Global options
app = Application(
name="quickcat",
description="A tiny cat-like CLI built with quickli.",
global_options=[
Option("verbose", short_name="v", is_flag=True, help_text="Enable verbose output."),
],
)
Global options are defined at the Application level. They can appear before or after
the command name on the command line. Every handler that wants to use a global option must
declare the matching parameter.
The file_path validator
Argument("path", help_text="Primary file path.", validators=[file_path()]),
file_path() returns a validator function that checks whether the given value points to
an existing, readable file. If the path does not exist, the command fails with a clear
error message before the handler is called.
Validators are called after conversion, so the handler receives a Path object, not a
raw string.
Repeatable options
Option(
"include",
short_name="i",
multiple=True,
validators=[file_path()],
help_text="Additional file paths to print after the primary file.",
),
multiple=True tells quickli to accumulate every occurrence of --include into a list.
You can pass the option several times:
python quickcat.py main.py -i utils.py -i helpers.py
The handler receives include as list[Path] | None. When the option is not passed at
all, include is None.
Automatic type annotation
The handler signature uses Path as the type for path:
def show(
path: Path,
encoding: str = "utf-8",
number: bool = False,
include: list[Path] | None = None,
verbose: bool = False,
) -> str:
quickli uses the type annotations together with the registered converters and validators to pass ready-to-use values to the handler.
Building the output
input_paths = [path, *(include or [])]
rendered_chunks: list[str] = []
for input_path in input_paths:
text = input_path.read_text(encoding=encoding)
lines = text.splitlines()
if number:
lines = [f"{index:>4} {line}" for index, line in enumerate(lines, start=1)]
if verbose:
rendered_chunks.append(f"==> {input_path} <==")
rendered_chunks.append("\n".join(lines))
return "\n".join(chunk for chunk in rendered_chunks if chunk)
The handler collects chunks for each file and joins them at the end. Empty strings (produced by empty files) are filtered out.
What to try next
- Add a
--max-linesoption usingconverter=intto limit output length. - Read Guide 3: Directory Listing for optional arguments and directory validation.
