Packaging a Data Script as a CLI Tool

Turning a script with values hardcoded at the top into a command a teammate can run with --help, argparse compared to Typer, and where argument parsing ends and configuration loading begins.

A script that starts with REPORT_DATE = "2026-05-01" hardcoded at the top, edited by hand before each run, works fine for the person who wrote it and breaks down the moment anyone else needs to run it — they have to open the file, find the right line, and know what to change it to. A command-line interface turns that edit into an argument.

argparse: no dependency, built into Python#

The standard library's argparse handles this with no extra dependency:

Python
import argparse
def build_parser() -> argparse.ArgumentParser:    parser = argparse.ArgumentParser(description="Export orders for a date range.")    parser.add_argument("--start-date", required=True)    parser.add_argument("--end-date", required=True)    parser.add_argument("--dry-run", action="store_true")    return parser

required=True makes --start-date mandatory — omitting it produces a clear usage error instead of the script silently running with an undefined value. action="store_true" turns --dry-run into a flag with no value to supply, present or absent. argparse also generates --help output from these declarations automatically, so the CLI documents its own options without a separate written reference:

Python
def main():    args = build_parser().parse_args()    run_export(args.start_date, args.end_date, dry_run=args.dry_run)
if __name__ == "__main__":    main()

The __main__ guard exists so the module can be imported safely#

if __name__ == "__main__": runs main() only when the file is executed directly — not when it is imported. This is what makes the export logic testable: a test can import the module and call run_export(...) directly with specific arguments, without triggering argument parsing or the real side effects main() would otherwise cause on import.

Typer: less boilerplate, one more dependency#

Typer (built on Click) derives the same CLI from function type hints instead of explicit add_argument calls:

Python
import typer
def main(start_date: str, end_date: str, dry_run: bool = False):    run_export(start_date, end_date, dry_run=dry_run)
if __name__ == "__main__":    typer.run(main)

The parameter types and defaults are the CLI specification — dry_run: bool = False becomes an optional --dry-run flag with no extra declaration. This scales especially well once a script grows into several subcommands, which Typer supports directly; for one script with a handful of flags, argparse's zero-dependency stdlib approach is often simpler to justify.

CLI arguments are not the same thing as configuration#

Configuration and Secrets Management covers loading a database host or an API key from the environment, once, at startup. CLI arguments answer a different question: not "what does this pipeline connect to," but "what should this specific run do" — which date range, which mode, whether to actually write or just preview. Mixing the two is the source of a specific, recurring mistake:

A credential is not a CLI argument

Passing a password or API key as a command-line flag (--db-password=hunter2) puts it in shell history and in the process list every other process on the machine can read — a real information leak, not a theoretical one. Credentials belong in the configuration layer covered in Configuration and Secrets Management, never as an argument the CLI itself accepts.

Common mistakes#

Business logic living directly under if __name__ == "__main__"#

Code written only inside the __main__ guard cannot be imported and called from a test, or from another script, without going through argument parsing first. Keep the guard thin — parse arguments, then call a plain function — so the actual logic is reachable independent of the CLI layer.

No --help a teammate can discover on their own#

A script whose only documentation is its source code forces every new user to read it before running it. argparse and Typer both generate --help for free from the declarations already in the code — there is little reason to skip it.

Treating every hardcoded value as worth exposing as a flag#

Not every constant needs to become a CLI argument. A value that never changes between runs — an internal timeout, a fixed batch size chosen once and rarely revisited — is often clearer left as a constant in the code than as one more flag nobody ever overrides in practice.

Further reading#

See also#