Implementation Guides
These guides walk through the reference examples that ship with the quickli source
package. Each guide explains the code line-by-line so you can follow along, run the
examples yourself, and understand how the framework primitives work together.
How to use these guides
Every guide is self-contained. You can read them in any order, but the sequence below builds knowledge progressively from the smallest possible example to a larger multi-command application.
- Hello World — your first entrypoint, one argument, one flag.
- File viewer (quickcat) — global options, multiple file
arguments, and the
file_pathvalidator. - Directory listing (quickls) — optional arguments,
multiple options, and the
directory_pathvalidator. - Directory creator (quickmkdir) — repeatable options and combining multiple paths in one call.
- File head (quickhead) — numeric conversion with
intand thepositive_numbervalidator. - Multi-command CLI (pyk5l) — multiple commands, global options, and custom validators on a kubectl-like application.
- Configuration — JSON/YAML structures, custom schemas, initialization, and CLI overrides.
- Logging guide — a production-style application using Python logging.
- Packaging guide — console scripts, executable binaries, and containers.
Prerequisites
Install quickli before running any example:
pip install quickli
All examples run on Python 3.12, 3.13, and 3.14.
The guides describe the current source APIs. Until a release containing the new
initialization/help features is published, install the checkout with
python -m pip install -e packages/core from the repository root.
The dependency range in the packaging example is illustrative: choose a published version
that includes the APIs you use, then verify it in a clean environment.
Logging guide
quiCkLI does not install logging handlers or choose a logging policy. Use Python's
standard-library logging module, leaving the framework's stdout/error contract intact.
This example combines persistent settings, a repeatable verbosity flag, and named commands.
1. Define the application
Save this module as mytool.py initially. The packaging guide moves it into a package.
import logging
import sys
from pathlib import Path
from quickli import Application, Config, ConfigField, ConfigSchema, Option
logger = logging.getLogger(__name__)
def valid_log_level(value: str) -> str:
if value not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}:
raise ValueError("Use DEBUG, INFO, WARNING, ERROR, or CRITICAL.")
return value
schema = ConfigSchema(fields=[
ConfigField(
"log_level", default="WARNING",
validators=[valid_log_level], help_text="Default logging threshold.",
),
ConfigField("workspace", default="local"),
])
config = Config(Path.home() / ".mytool" / "settings.yaml", schema=schema)
app = Application(
"mytool",
description="Inspect a configured workspace.",
config=config,
auto_init_config=True,
global_options=[
Option("verbose", short_name="v", is_flag=True, multiple=True),
],
)
@app.command(help_text="Inspect the configured workspace.")
def status(verbose: int = 0) -> dict[str, str]:
data = config.load()
level = "DEBUG" if verbose >= 2 else "INFO" if verbose else data.get("log_level", "WARNING")
logging.getLogger().setLevel(level)
logger.debug("Loaded configuration from %s", config.path)
logger.info("Inspecting workspace %s", data.get("workspace", "local"))
return {"workspace": data.get("workspace", "local"), "status": "ready"}
def main(argv: list[str] | None = None, *, output_format: str = "text") -> int:
logging.basicConfig(
level=logging.WARNING,
format="%(levelname)s %(name)s: %(message)s",
stream=sys.stderr,
)
return app.main(argv, output_format=output_format)
if __name__ == "__main__":
raise SystemExit(main())
2. Initialize and exercise the logger
- Run
python mytool.py initto create~/.mytool/settings.yaml. - Run
python mytool.py status. The default threshold suppresses informational logs. - Run
python mytool.py status -vto see informational messages on stderr. - Run
python mytool.py status -vvto include debug messages. - Edit
log_level: WARNINGtolog_level: INFOto change the persistent default.
Global options reach every selected handler. The built-in init command accepts them
without using them. Custom handlers must accept verbose or **kwargs.
Return results from handlers and let app.main() write stdout. Keep logs on stderr.
For example, python mytool.py status -v > result.txt saves only the result.
For structured output, call main(["status", "-v"], output_format="json") from Python:
stdout contains {"ok": true, "result": {...}}, while logs remain on stderr.
3. Integrate with larger applications
- Create a named logger with
logging.getLogger(__name__)in each module. - Configure handlers once at the executable boundary, not during imports or plugin registration. An embedding application may already own the logging configuration.
basicConfig()does nothing when root handlers already exist. Do not useforce=Truein reusable libraries: it removes the host application's handlers.- Use lazy formatting, such as
logger.info("Processing %s", path), rather than building messages eagerly. - Log diagnostic context, not credentials or entire configuration dictionaries.
- For persistent logs, attach
logging.handlers.RotatingFileHandlerwith a size limit, backup count, and UTF-8 encoding instead of an unbounded file handler. - An
error_handlercallback can inspect aCLIErrorand log it. ReturningNonekeeps normal error rendering and exit codes. Avoid logging and rendering the same message twice unless the diagnostic log has a separate audience.
See Application error handlers and the Python logging cookbook for handler composition.
Packaging guide
Start with a Python package and console script. Build a standalone binary only if users should not install Python; use a container when the deployment environment already runs containers. These are application distribution choices, not features of quiCkLI itself.
1. Create a package with a console script
Move the logging example into this structure:
mytool/
├── pyproject.toml
└── src/
└── mytool/
├── __init__.py
├── __main__.py
└── cli.py
cli.pycontains the complete logging example above.__init__.pycan be empty.__main__.pyenablespython -m mytool:
from mytool.cli import main
raise SystemExit(main())
Configure packaging and the shell command:
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "mytool"
version = "0.1.0"
description = "Inspect a configured workspace."
requires-python = ">=3.12"
dependencies = ["quickli>=0.6,<0.7"]
[project.scripts]
mytool = "mytool.cli:main"
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]
The name mytool is your application's distribution and import name; quickli remains
the framework dependency. Select a dependency range for the release you have tested.
The framework remains alpha; this example range stays within its current minor series.
- Create and activate a virtual environment using
python -m venv .venv. - Install locally with
python -m pip install -e .. - Run
mytool init, thenmytool status -v. - Confirm
python -m mytool statusworks too. - Install the build frontend with
python -m pip install build. - Build a source distribution and wheel with
python -m build. - Install
dist/mytool-0.1.0-py3-none-any.whlinto a clean environment and repeat the checks.
Point [project.scripts] at main, not app.run. Installers create an executable wrapper
that calls the function and propagates its return code. run() returns a handler result;
main() prints results, handles errors, and returns an integer exit status.
2. Build an executable binary
PyInstaller is an optional application build tool, not a required framework dependency.
Build from the activated environment where mytool and its dependencies are installed:
python -m pip install pyinstaller
python -m PyInstaller --clean --onefile --name mytool src/mytool/__main__.py
./dist/mytool init
./dist/mytool status -v
On Windows, run dist\mytool.exe. A directory bundle (--onedir instead of --onefile)
is easier to inspect and starts without extracting a one-file bundle.
PyInstaller is not a cross-compiler. Build and test on each target OS and architecture, and verify the oldest supported Linux runtime when distributing Linux binaries. Test JSON/YAML configuration, error exits, and any dynamically imported plugins in a clean environment without your development installation. Review bundled licenses.
Configuration stays under the user's home directory, not inside the executable bundle. Do not embed writable configuration, credentials, or development files in the binary.
3. Containerize the application
Use the same package and console script. A multi-stage build keeps build tools out of the runtime image, and a non-root user owns the writable configuration directory:
FROM python:3.12-slim AS builder
WORKDIR /build
COPY pyproject.toml ./
COPY src ./src
RUN python -m pip wheel --wheel-dir /wheels .
FROM python:3.12-slim
RUN useradd --create-home --uid 10001 app
COPY /wheels /wheels
RUN python -m pip install --no-cache-dir --no-index --find-links=/wheels mytool \
&& rm -rf /wheels \
&& mkdir -p /home/app/.mytool \
&& chown app:app /home/app/.mytool
ENV HOME=/home/app
USER app
WORKDIR /home/app
ENTRYPOINT ["mytool"]
CMD ["status"]
Build and initialize a persistent configuration volume before running status:
docker build -t mytool:local .
docker volume create mytool-config
docker run --rm -v mytool-config:/home/app/.mytool mytool:local init
docker run --rm -v mytool-config:/home/app/.mytool mytool:local status -v
ENTRYPOINTuses exec form, so signals and exit codes reach the CLI directly.- The named volume preserves initialization across otherwise disposable containers.
- A bind mount must be writable by UID 10001; host permissions are not changed automatically.
- Add
.venv,dist,build,.git, caches, and local secret/config files to.dockerignore. Never pass secrets through DockerfileARG,ENV, or copied files. - For releases, pin base image digests and application dependency versions in your deployment build process, scan the image, and test the actual target architecture.
4. Verify the distributed result
- Confirm initialization creates the intended schema and never resets an existing file.
- Check commands, arguments, value options, repeated flags, and invalid input.
- Verify logs go to stderr and successful results to stdout.
- Check a successful exit code and a failing exit code outside the development environment.
- Run on the Python versions and operating systems you promise to support.
