self-contained-python-scripts
Self-Contained Python Scripts with uv
Create Python scripts that are fully self-contained — no virtualenv setup, no requirements.txt, no pyproject.toml. Just a single .py file that anyone can run with uv run.
Script Structure
Every script follows this structure:
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.14"
# dependencies = [
# "some-package",
# ]
# ///
"""One-line description of what this script does."""
# imports and code here
Rules
- Shebang: Always
#!/usr/bin/env -S uv run --scriptas the first line. - Inline metadata: PEP 723 block immediately after the shebang.
requires-python: Always specify. Default to>= 3.14unless the user specifies otherwise.- Maximum Python version: If a dependency is known to be incompatible with a newer Python version, add an upper bound (e.g.,
requires-python = ">=3.14,<3.15"). - Dependencies: List all third-party imports in the
dependenciesarray.
PEP 723 Inline Script Metadata
The metadata block uses TOML syntax, prefixed with # on every line:
# /// script
# requires-python = ">=3.14"
# dependencies = [
# "requests>=2.31",
# "rich",
# ]
# ///
Syntax Rules
- Opening line: exactly
# /// script - Closing line: exactly
# /// - Every interior line starts with
#followed by a space (if content follows) - Content is TOML with the
#prefix stripped - Only one
scriptblock per file
Supported Fields
| Field | Type | Description |
|---|---|---|
requires-python |
str |
Version specifier (e.g., ">=3.14") |
dependencies |
list[str] |
PEP 508 dependency specifiers |
[tool.*] |
table | Tool-specific config (same as pyproject.toml) |
Examples
Minimal (no dependencies):
# /// script
# requires-python = ">=3.14"
# ///
With pinned versions:
# /// script
# requires-python = ">=3.14"
# dependencies = [
# "httpx>=0.27,<1",
# "rich>=13",
# "pydantic>=2,<3",
# ]
# ///
CLI Design
All scripts MUST have a CLI interface. The choice of CLI framework depends on complexity.
Simple Scripts: argparse
Use argparse for scripts with a flat set of arguments (no subcommands). This is the default unless the user asks for something else.
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.14"
# dependencies = []
# ///
"""Resize images in a directory."""
import argparse
from pathlib import Path
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path, help="Source directory")
parser.add_argument("-o", "--output", type=Path, default=None, help="Output directory (default: source/resized)")
parser.add_argument("-w", "--width", type=int, default=800, help="Target width in pixels (default: 800)")
parser.add_argument("--quality", type=int, default=85, choices=range(1, 101), metavar="1-100", help="JPEG quality (default: 85)")
parser.add_argument("-v", "--verbose", action="store_true", help="Print detailed output")
args = parser.parse_args()
output = args.output or args.source / "resized"
# ... implementation ...
if __name__ == "__main__":
main()
Complex Scripts: Cyclopts
Use Cyclopts for scripts with multiple commands or subcommand groups. Cyclopts uses Python's native type hints for argument parsing — no Argument() / Option() wrappers needed.
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.14"
# dependencies = [
# "cyclopts>=4",
# ]
# ///
"""Project scaffolding tool."""
from pathlib import Path
from typing import Annotated, Literal
from cyclopts import App, Parameter, validators
app = App(name="scaffold", help=__doc__, version="1.0.0")
@app.command
def init(
path: Path = Path("."),
template: Literal["default", "minimal", "full"] = "default",
*,
force: bool = False,
):
"""Initialize a new project.
Parameters
----------
path
Directory to initialize.
template
Project template to use.
force
Overwrite existing files.
"""
print(f"Initializing {template!r} project at {path}")
# ── Subcommand group ──────────────────────────────────────
db = app.command(App(name="db", help="Database operations."))
@db.command
def migrate(*, dry_run: bool = False):
"""Run pending migrations.
Parameters
----------
dry_run
Show SQL without executing.
"""
print(f"{'[DRY RUN] ' if dry_run else ''}Running migrations...")
@db.command
def seed(
count: Annotated[int, Parameter(validator=validators.Number(gte=1))] = 10,
*,
table: str | None = None,
):
"""Seed the database with test data.
Parameters
----------
count
Number of records to insert.
table
Specific table to seed (all if omitted).
"""
target = f"table {table!r}" if table else "all tables"
print(f"Seeding {count} records into {target}")
if __name__ == "__main__":
app()
Cyclopts Key Patterns
from cyclopts import App, Parameter, validators
from typing import Annotated, Literal
app = App(name="tool", help="...", version="1.0.0")
# Default action (no subcommand given)
@app.default
def main(): app.help_print()
# Command (name auto-derived: foo_bar -> foo-bar)
@app.command
def foo_bar(): ...
# Subcommand group
sub = app.command(App(name="sub", help="..."))
@sub.command
def action(): ...
# Positional-only (no --flag generated)
def cmd(src: Path, dst: Path, /): ...
# Keyword-only (must use --flag)
def cmd(*, verbose: bool = False): ...
# Short alias
output: Annotated[str, Parameter(alias="-o")] = "out"
# Validator
port: Annotated[int, Parameter(validator=validators.Number(gte=1, lte=65535))] = 8080
# Choices
fmt: Literal["json", "yaml", "toml"] = "json"
# Entry point
if __name__ == "__main__":
app()
Cyclopts vs Typer: Cyclopts uses Python's
/and*markers for positional vs keyword. NoArgument()/Option()needed. Decorator parentheses are optional:@app.commandworks without().
Rich Output Formatting
Use Rich when the script benefits from formatted terminal output (tables, panels, syntax highlighting, styled text).
# dependencies = ["rich"]
from rich.console import Console
from rich.table import Table
from rich import print # drop-in replacement for print()
console = Console()
console.print("[bold green]Success![/] Operation completed.")
table = Table(title="Results")
table.add_column("Name", style="cyan")
table.add_column("Status", style="green")
table.add_row("item-1", "OK")
console.print(table)
Only add rich as a dependency when formatted output provides clear value. Plain print() is fine for simple scripts.
Progress Bars: alive-progress
Use alive-progress when a script performs a loop over many items or a long-running operation. Do NOT use a progress bar for tasks expected to complete in under 2 seconds.
# dependencies = ["alive-progress"]
from alive_progress import alive_bar
Basic Usage
with alive_bar(len(items), title="Processing") as bar:
for item in items:
process(item)
bar()
Full-Featured Example
with alive_bar(
len(files),
title="Uploading",
unit="files",
dual_line=True,
) as bar:
for f in files:
bar.text = f"-> {f.name}"
upload(f)
bar()
Key Parameters
| Parameter | Type | Default | Use When |
|---|---|---|---|
total |
int | None |
None |
Always specify when the count is known. None = unknown/streaming mode (no ETA). |
title |
str | None |
None |
Always provide a short, descriptive label (shown left of bar). |
unit |
str |
"" |
Items have a natural unit ("files", "rows", "req", "B"). |
scale |
str | None |
None |
Byte-like units: "SI" (1000-based), "IEC" (1024-based, KiB/MiB/GiB). |
dual_line |
bool |
False |
bar.text messages are long and would clutter the bar line. |
spinner |
str | None |
from theme | Override spinner style. Named strings: classic, dots, waves, pulse, etc. |
bar |
str | None |
from theme | Override bar fill style: smooth, classic, blocks, bubbles, etc. |
theme |
str |
"smooth" |
Preset bundle of spinner + bar + unknown style. Options: smooth, classic, scuba, musical. |
manual |
bool |
False |
You know the percentage but not item count. Call bar(0.0-1.0). |
force_tty |
bool | None |
None |
True for PyCharm/Jupyter. False for CI (receipt only). |
receipt_text |
bool |
False |
True to show the last bar.text in the final summary line. |
The bar Handle
with alive_bar(total, title="Work") as bar:
bar() # advance by 1
bar(5) # advance by 5
bar.text = "current status" # situational message (inline or second line with dual_line)
bar.title = "Phase 2" # update the left-side title mid-run
bar.current # read current count
Operating Modes
total |
manual |
Mode | bar() call |
|---|---|---|---|
| provided | False |
Auto (default) | bar() increments by 1 |
None |
False |
Unknown | bar() increments (no ETA, animated) |
| provided | True |
Manual | bar(0.0-1.0) sets percentage |
Iterator Shortcut
from alive_progress import alive_it
for item in alive_it(items, title="Processing"):
process(item)
Common Patterns
# ── Bytes with IEC scaling ─────────────────────────────────
with alive_bar(file_size, unit="B", scale="IEC", title="Download") as bar:
for chunk in stream:
write(chunk)
bar(len(chunk))
# ── Unknown total (streaming) ──────────────────────────────
with alive_bar(title="Reading stream") as bar:
for record in stream:
process(record)
bar()
# ── Manual percentage ──────────────────────────────────────
steps = ["fetch", "transform", "load"]
with alive_bar(manual=True, title="Pipeline") as bar:
for i, step in enumerate(steps):
bar.text = step
run(step)
bar((i + 1) / len(steps))
Gotchas
- No nesting: Do not nest
with alive_bar()blocks. Use sequential bars instead. totalmust beint: Not float. Cast if needed:alive_bar(int(total)).bar()outside thewithblock is silently ignored.print()inside thewithblock works correctly — alive-progress hooks stdout and renders print output above the bar.bar.textis not shown in the final receipt unlessreceipt_text=True.
Interactive Prompts: questionary
Use questionary when the script needs user input beyond simple CLI arguments. ALWAYS provide a CLI argument alternative so the script can run non-interactively (e.g., in CI).
# dependencies = ["questionary"]
import questionary
Pattern: CLI Args with Interactive Fallback
def get_config(args: argparse.Namespace) -> dict:
"""Resolve config from CLI args, falling back to interactive prompts."""
name = args.name or questionary.text("Project name?", default="my-project").ask()
template = args.template or questionary.select(
"Template?",
choices=["default", "minimal", "full"],
).ask()
return {"name": name, "template": template}
Prompt Types
text — free-form input
name = questionary.text("Your name?", default="World").ask()
password — masked input
secret = questionary.password("API key?").ask()
confirm — yes/no
proceed = questionary.confirm("Continue?", default=True).ask()
select — pick one (arrow keys)
choice = questionary.select(
"Environment?",
choices=["development", "staging", "production"],
default="development",
).ask()
checkbox — pick many (Space to toggle)
selected = questionary.checkbox(
"Features to enable?",
choices=["auth", "logging", "metrics", "tracing"],
).ask()
path — file/directory with Tab completion
config = questionary.path("Config file?", default="./config.yaml").ask()
autocomplete — text with suggestions
lang = questionary.autocomplete(
"Language?",
choices=["Python", "Rust", "Go", "TypeScript", "Java"],
).ask()
Choice Objects
from questionary import Choice, Separator
choices = [
Choice("Production", value="prod"),
Choice("Staging", value="staging"),
Separator("--- Dev ---"),
Choice("Local", value="local"),
Choice("Docker", value="docker", disabled="Not available"),
]
Validation
questionary.text(
"Port?",
validate=lambda v: True if v.isdigit() and 1 <= int(v) <= 65535 else "Must be 1-65535",
).ask()
Return Value
All .ask() calls return None if the user cancels with Ctrl-C. Always handle this:
name = questionary.text("Name?").ask()
if name is None:
print("Cancelled.")
raise SystemExit(1)
Fuzzy Selection: iterfzf
Use iterfzf when the user needs to select from a large list with fuzzy search. fzf is bundled in the package — no separate install needed.
# dependencies = ["iterfzf"]
from iterfzf import iterfzf
Basic Usage
# Single selection
choice = iterfzf(["apple", "banana", "cherry", "date"])
# Multi-selection (Tab to toggle)
selected = iterfzf(
["alpha", "beta", "gamma", "delta"],
multi=True,
prompt="Pick items > ",
)
Key Parameters
| Parameter | Default | Description |
|---|---|---|
multi |
False |
True = multi-select with Tab. Returns list[str]. |
prompt |
" >" |
Prompt string shown in fzf UI. |
query |
"" |
Pre-filled search query. |
exact |
False |
True = exact substring match instead of fuzzy. |
case_sensitive |
None |
True/False/None (smart-case). |
preview |
None |
Shell command for preview pane (e.g., "cat {}"). |
header |
None |
Sticky header text below the prompt. |
ansi |
None |
True to render ANSI colors in items. |
Return Types
multi |
User selects | User cancels (Esc) |
|---|---|---|
False |
str |
None |
True |
list[str] |
None |
Ctrl-C raises
KeyboardInterrupt.
Feeding Large / Lazy Data
iterfzf accepts any iterable — items are streamed lazily:
import subprocess
# Stream git log lazily
def git_commits():
proc = subprocess.Popen(["git", "log", "--oneline", "-100"], stdout=subprocess.PIPE, text=True)
yield from (line.strip() for line in proc.stdout)
commit = iterfzf(git_commits(), prompt="Pick commit > ")
Complete Example: Full-Featured Script
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.14"
# dependencies = [
# "httpx>=0.27",
# "rich>=13",
# "alive-progress>=3",
# ]
# ///
"""Fetch and display GitHub repository statistics."""
import argparse
from pathlib import Path
import httpx
from alive_progress import alive_bar
from rich.console import Console
from rich.table import Table
console = Console()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("repos", nargs="+", help="GitHub repos (owner/name)")
parser.add_argument("-o", "--output", type=Path, default=None, help="Save results to JSON file")
parser.add_argument("--token", default=None, help="GitHub API token")
args = parser.parse_args()
results = []
with alive_bar(len(args.repos), title="Fetching repos", unit="repos") as bar:
for repo in args.repos:
bar.text = f"-> {repo}"
resp = httpx.get(
f"https://api.github.com/repos/{repo}",
headers={"Authorization": f"Bearer {args.token}"} if args.token else {},
)
resp.raise_for_status()
results.append(resp.json())
bar()
table = Table(title="Repository Stats")
table.add_column("Repository", style="cyan")
table.add_column("Stars", justify="right", style="yellow")
table.add_column("Forks", justify="right")
table.add_column("Language", style="green")
for r in results:
table.add_row(r["full_name"], str(r["stargazers_count"]), str(r["forks_count"]), r.get("language", "—"))
console.print(table)
if args.output:
import json
args.output.write_text(json.dumps(results, indent=2))
console.print(f"[dim]Saved to {args.output}[/dim]")
if __name__ == "__main__":
main()