Typer-based CLI framework with two entry shapes:
ServiceApp— service framework. Subclass it, getrun,version,config-check, andgenerate-artefactssubcommands for free, plus standard flags wired into the config cascade, logger setup, and metrics auto-init.- Raw Typer — for one-off tools and utilities. The
scalo.climodule re-exportsTyper,Argument,Optionplus a library of pre-built standard options and output helpers.
Use ServiceApp for long-running services; use raw Typer for everything
else. Ships in the base package — Typer is a core dependency.
from scalo.cli import (
Typer, Argument, Option,
ServiceApp, VersionInfo,
)from scalo.cli import ServiceApp, VersionInfo
class MyService(ServiceApp):
name = "my-service"
env_prefix = "MY_SVC"
def version_info(self) -> VersionInfo:
return VersionInfo(self.name, "1.0.0")
def run_service(self, config) -> None:
# config is the loaded Dynaconf settings object
...
if __name__ == "__main__":
MyService().cli()That gives you:
my-service run # start the service
my-service version # print version
my-service config-check # validate config and exit
my-service generate-artefacts # write Dockerfile + ArgoCD manifest
my-service --helpfrom pathlib import Path
from scalo.cli import Typer, Argument, Option
app = Typer(help="My tool")
@app.command()
def process(
file: Path = Argument(..., help="Input file"),
verbose: bool = Option(False, "--verbose", "-v"),
):
"""Process a file."""
...
if __name__ == "__main__":
app()ServiceApp is an abstract base class. Subclasses provide name,
env_prefix, version_info(), and either run_service() (sync) or
run_service_async() (async). The framework: builds the Typer app,
initialises the logger from --log-level/--log-format/--verbose,
loads configuration via the 7-layer cascade with the app's
env_prefix, auto-initialises AppMetrics if scalo[metrics]
is installed (exposed at --metrics-addr), and dispatches to whichever
of run_service / run_service_async the subclass overrode.
class MyService(ServiceApp):
name = "my-service"
env_prefix = "MY_SVC"
def version_info(self):
return VersionInfo(self.name, "1.0.0", commit="abc123")
def run_service(self, config): # sync — config is Dynaconf settings
...
# async def run_service_async(self, config): ... # async alternative| Subcommand | What it does |
|---|---|
run (default) |
Initialise logger + config + metrics, then call run_service / run_service_async. |
version |
Print the VersionInfo and exit. |
config-check |
Validate the cascade loads cleanly, print a key-value summary to stderr, exit. |
generate-artefacts |
Write deployment-contract.json, container-manifest.json, Dockerfile.runtime, argocd-application.yaml to the output dir (default ci/). Requires ServiceApp.deployment_contract() to be overridden. |
Standard flags accepted by run and config-check:
| Flag | Env var | Default | Purpose |
|---|---|---|---|
--config, -c |
CLI_CONFIG |
— | Path to an extra config file added to the cascade |
--log-level, -l |
LOG_LEVEL |
info |
Log level |
--log-format |
LOG_FORMAT |
config, else auto |
json, text or auto (JSON unless stderr is a TTY); see LOGGING.md |
--metrics-addr |
METRICS_ADDR |
0.0.0.0:9090 |
Bind address for /metrics |
--verbose, -v |
— | False | Force DEBUG |
--quiet, -q |
— | False | Suppress non-error output |
--verbose and --quiet are mutually exclusive — passing both exits
with code 1.
Override register_commands to add your own:
def register_commands(self, app):
@app.command()
def migrate(target: str = "latest"):
"""Run database migrations."""
...Custom commands sit alongside run, version, etc. on the same Typer
app.
Override deployment_contract() to return a
scalo.deployment.DeploymentContract. my-service generate-artefacts -o ci/ then writes deployment-contract.json,
container-manifest.json, Dockerfile.runtime, and
argocd-application.yaml. Requires scalo[deployment]. The
default None return prints a warning and emits nothing — services
that don't ship as containers can leave it unset.
The cli package re-exports Typer plus a small library of utilities:
from scalo.cli import Typer, Argument, Option
from scalo.cli.options import VERBOSE_OPTION, CONFIG_OPTION, DRY_RUN_OPTION
from scalo.cli.output import (
print_success, print_error, print_warning, print_info,
print_table, print_json,
)
from scalo.cli.version import version_optionPre-configured Option(...) values for any command signature:
| Name | Flag | Type | Env var |
|---|---|---|---|
VERBOSE_OPTION |
--verbose, -v |
bool |
CLI_VERBOSE |
QUIET_OPTION |
--quiet, -q |
bool |
CLI_QUIET |
DEBUG_OPTION |
--debug, -d |
bool |
CLI_DEBUG |
LOG_LEVEL_OPTION |
--log-level, -l |
str |
LOG_LEVEL |
LOG_FILE_OPTION |
--log-file |
str | None |
LOG_FILE |
CONFIG_OPTION |
--config, -c |
str | None |
CLI_CONFIG |
ENV_OPTION |
--env, -e |
str ("dev") |
ENVIRONMENT |
DRY_RUN_OPTION |
--dry-run |
bool |
CLI_DRY_RUN |
FORCE_OPTION |
--force, -f |
bool |
CLI_FORCE |
YES_OPTION |
--yes, -y |
bool |
CLI_YES |
OUTPUT_OPTION |
--output, -o |
str ("table") |
CLI_OUTPUT_FORMAT |
CONFIG_OPTION is typed str | None (not Path) — convert at the
call site if you need a Path.
@app.command()
def deploy(verbose: bool = VERBOSE_OPTION,
config: str | None = CONFIG_OPTION,
dry_run: bool = DRY_RUN_OPTION):
cfg_path = Path(config) if config else Nonepath_option(...) and enum_option(...) helpers build options with
per-call validation (e.g. exists=True, dir_okay=False, enum
choices).
from scalo.cli.output import (
print_success, print_error, print_warning, print_info,
print_table, print_json,
)
print_success("Deployment complete")
print_error("DB unreachable")
print_warning("Skipping signed-image check (dev mode)")
print_info("Processing 100 records")
print_table([{"name": "a", "status": "ok"}, {"name": "b", "status": "fail"}],
title="Workers")
print_json({"host": "localhost", "port": 5432})Built on rich — automatic colour when stdout is a TTY, plain text
in pipes and CI.
from scalo.cli.version import version_option
@app.callback()
def main(
version: bool = version_option("myapp", app_name="My Application"),
):
"""My App"""
...Adds --version to the root command. Reads the installed package
version automatically.
Typer ships CliRunner from Click:
from typer.testing import CliRunner
from my_tool import app
result = CliRunner().invoke(app, ["process", "input.txt", "--verbose"])
assert result.exit_code == 0For ServiceApp, invoke via service.cli(["version"]) — pass args to
skip sys.argv.
[project.scripts]
my-service = "my_package.cli:MyService.cli" # ServiceApp
my-tool = "my_package.cli:app" # raw Typerpip install (or uv tool install) puts the command on the user's
PATH.
| Building | Use |
|---|---|
| Long-running service (Kafka consumer, FastAPI, scheduled worker) | ServiceApp |
| Operator tool / data-pipeline runner / one-off CLI | Raw Typer + standard options |
| Quick script bound to one function | Raw app.command() |
Custom subcommands on top of ServiceApp defaults |
register_commands |