A tiny, typed, signature-driven CLI runner.
Project description
yeeter
A tiny, typed, signature-driven CLI runner.
PyPI distribution: yeetr
Python import package: yeeter
CLI command: yeet
No decorators. No command classes. No ceremony. Just yeet the function.
Minimal example
Zero-boilerplate: the yeet script
Installing yeeter also installs a yeet script that finds and runs a
function in any Python file.
No if __name__ == "__main__" block, no yeeter.run(...) call — just
the function:
# app.py
def main(thing: int, *, n: float = 0.1) -> None:
print(thing, n)
yeet file.py 5 --n 0.2
The default function name is main. Pass a different one to pick another
top-level function in the same file:
# app.py
def main(...) -> None: ...
def greet(name: str, *, loud: bool = False) -> None: ...
yeet file.py greet world --loud
yeet file.py --help prints the target function's help, not yeet's.
yeet itself only has yeet FILE [FUNC] [args...].
You can still use the explicit yeeter.run(main) form when you prefer —
the yeet script is just sugar on top of it.
Explicit yeeter.run(main)
def main(thing: int, *, n: float = 0.1) -> None:
print(thing, n)
if __name__ == "__main__":
import yeeter
yeeter.run(main)
yeet file.py 5 --n 0.2
Note the bare * in the signature: parameters before it become
positional CLI args, parameters after it become --options. That's
the whole mapping — no decorators, no per-parameter annotations needed.
Hashbang
For tiny scripts, you can make the file itself executable and let yeet
discover main directly from the shebang. The short forms are:
#!yeet
or:
#!uv run yeet
For example:
#!yeet
def main(name: str, *, loud: bool = False) -> None:
print(name.upper() if loud else name)
Then run it directly:
chmod +x greet.py
./greet.py world --loud
If you need a different entry function, keep the shebang simple and call
uv run yeet file.py other_func ... explicitly instead.
Async
async def main(name: str, *, loud: bool = False) -> None:
...
yeet file.py world --loud
If the function is a coroutine, its result is awaited via asyncio.run,
or via uvloop.run when the
optional uvloop extra is installed:
uv add "yeeter[uvloop]"
When uvloop is importable, yeeter uses it transparently — no code
change required. Otherwise it falls back to the stdlib event loop.
Path
from pathlib import Path
def main(path: Path, *, output: Path | None = None) -> None:
...
yeet file.py input.pdf --output out.txt
Literal choices
from typing import Literal
def main(*, format: Literal["json", "csv"] = "json") -> None:
...
yeet file.py --format csv
Arg and Opt metadata
For aliases and help text, use Arg (positional) or Opt (keyword-only)
inside Annotated:
from pathlib import Path
from typing import Annotated
from yeeter import Arg, Opt
def main(
path: Annotated[Path, Arg(help="Input file")],
*,
workers: Annotated[int, Opt(alias="-w", help="Worker count")] = 4,
) -> None:
...
yeet file.py input.pdf -w 8
Arg accepts help, metavar, min, and the path validators below. Opt
accepts alias, aliases, help, metavar, envvar, hidden, and the
path validators below. Mixing them (e.g. Opt on a positional or Arg on a
keyword-only parameter) raises a clear YeeterError.
You can also define aliases once and reuse them:
from pathlib import Path
from typing import Annotated
from yeeter import Arg, Opt
type InputPath = Annotated[Path, Arg(help="Input file")]
type WorkerCount = Annotated[int, Opt(alias="-w", help="Worker count")]
def main(path: InputPath, *, workers: WorkerCount = 4) -> None:
...
Environment variable fallback (Opt(envvar=...))
Opt(envvar="NAME") falls back to an environment variable when the flag is
not provided on the CLI. Precedence: explicit CLI > env var > default.
from typing import Annotated
from yeeter import Opt
def main(*, workers: Annotated[int, Opt(envvar="WORKERS")] = 4) -> None:
...
WORKERS=8 yeet file.py # workers == 8
yeet file.py --workers 16 # workers == 16 (CLI wins)
yeet file.py # workers == 4 (default)
Env-var values are type-coerced just like CLI values. bool accepts
1/0/true/false/yes/no (case-insensitive). list[T] splits on os.pathsep
(: on POSIX, ; on Windows). Literal choices are validated.
Hidden options (Opt(hidden=True))
Hidden options still parse from the CLI but are absent from --help (both
the usage line and the options table):
from typing import Annotated
from yeeter import Opt
def main(*, debug: Annotated[bool, Opt(hidden=True)] = False) -> None:
...
Path validators
Arg and Opt accept exists, file_okay, dir_okay, readable, and
writable for Path parameters. They run at parse time and fail with a
clear error:
from pathlib import Path
from typing import Annotated
from yeeter import Arg
def main(
src: Annotated[Path, Arg(exists=True, dir_okay=False, readable=True)],
dst: Annotated[Path, Arg(writable=True)],
) -> None:
...
Defaults mirror typer: file_okay=True, dir_okay=True, others off.
Setting any path-check on a non-Path parameter raises YeeterError at
parser-build time. Validators also apply to list[Path] and to
*paths: Path.
Variadic positional args (*args)
*args maps to a trailing variadic positional CLI argument. The annotation
on *args is the element type (not list[T]):
from pathlib import Path
def main(dst: Path, *sources: Path) -> None:
...
yeet file.py dst src1 src2 src3
By default *args accepts zero or more values (argparse nargs="*"). Use
Arg(min=1) to require at least one:
from typing import Annotated
from yeeter import Arg
def main(*sources: Annotated[Path, Arg(min=1, help="Source paths")]) -> None:
...
Keyword-only options remain --flags after *args. **kwargs is not
supported.
Why Annotated? Python's type system only permits call expressions
(Opt(...)) inside the metadata slot of Annotated. No other syntax is
accepted by Pyright in strict mode. The Annotated form is verbose but is
the only way to attach per-parameter metadata that fully type-checks.
Rules
- Positional parameters become positional CLI args.
- Keyword-only parameters (after
*) become--options. - Names convert from
snake_casetokebab-casefor CLI flags. flag: bool = Falsebecomes--flag.flag: bool = Truebecomes--no-flag.- Required
boolparameters raise a clear error. T | None/Optional[T]are accepted; treated as their inner type withNoneas default.list[T]becomes a repeated option (--tag a --tag b).
Supported types
str, int, float, bool, pathlib.Path, typing.Literal[...],
T | None, list[T]. Anything else raises a clear YeeterError.
Logging
By default, yeeter.run installs a Rich-based logging handler before
invoking your function, so you get formatted logs with zero boilerplate:
import logging
import yeeter
logger = logging.getLogger("app")
def main(thing: int) -> None:
logger.info("thing = %s", thing)
If your function has a log_level parameter (e.g.
log_level: Literal["debug", "info", "warning", "error"] = "info"), its
value drives the log level. Otherwise, the default is INFO.
Setup is idempotent: if the root logger already has handlers, yeeter does not touch them. To take full control of logging yourself, opt out:
yeeter.run(main, should_setup_logging=False)
Testing
run() accepts an explicit argv for tests:
yeeter.run(main, argv=["5", "--n", "0.2"])
yeeter vs. typer
Typer is a mature, feature-rich CLI
framework and a direct inspiration for yeeter — the Annotated[..., Arg/Opt]
metadata pattern, path validators, and envvar fallback all take cues from
typer. yeeter is a much smaller library aimed at a narrower slice of the
problem. Quick honest comparison so you can pick the right tool:
| Topic | yeeter | typer |
|---|---|---|
| Style | Plain function signature, no decorators | Decorators (@app.command()) or typer.run |
| Zero-boilerplate runner | yeet main.py [func] [args...] script — no if __name__ == "__main__" / yeeter.run(...) block needed |
Always need a typer.run(...) call or a decorated @app.command() entry point |
| Executable shebang | #!yeet or #!uv run yeet can make the script itself executable without extra wrapper code |
No equivalent single-line signature-driven runner; still need a typer.run(...) or app entry point |
| Arg vs. option mapping | Uses Python's * separator: before * = positional args, after * = --options (no per-param annotation needed) |
Decide per parameter via typer.Argument(...) / typer.Option(...) |
| Per-param metadata | Annotated[T, Arg(...)] / Annotated[T, Opt(...)] |
Annotated[T, typer.Argument(...)] / typer.Option(...) |
| Variadic positional args | Native *args: T maps to a trailing variadic positional arg |
Use list[T] with typer.Argument(...) |
| Boolean flags | Default drives the flag: = False -> --flag, = True -> --no-flag |
Pair of flags declared explicitly: --flag / --no-flag |
| Subcommands | Not supported (single command per script) | First-class subcommands, command groups, nested apps |
| Async functions | Native: async def is run via asyncio.run / uvloop.run |
Not built-in; wrap with asyncio.run(...) yourself |
| Shell completion | Not built-in | Built-in (bash/zsh/fish/PowerShell) |
| Help rendering | Rich tables for args and options | Rich-formatted help via rich |
| Type-checker friendliness | Designed to be Pyright-strict clean end-to-end | Some patterns require # type: ignore under strict settings |
| Logging | Rich logging set up by default (opt-out) | Not opinionated about logging |
| Dependencies | rich, rich-argparse (small footprint) |
click, rich, shellingham, typing-extensions |
| Maturity / ecosystem | New and small | Widely adopted, large ecosystem |
| Best for | Single-purpose scripts and tools where the function is the CLI | Multi-command CLIs, distributed apps, anything needing completion |
If you need subcommands or shell completion, use typer. If you want one function = one CLI with minimal ceremony and strict typing, yeeter is designed for that.
Releases
yeeter uses CalVer based on the release date. Versions are published in
PEP 440 canonical form as YYYY.M.D, so a release on 2026-05-21 is
2026.5.21; multiple releases on the same day use .postN, for example
2026.5.21.post1.
Run task release to create the release/{TAG} PR, then merge it.
Then create and push the matching release tag. GitHub Actions validates
the tag, creates the GitHub Release, and a separate workflow deploys
docs.
If you need to bypass the PR flow, run task release-direct. That bumps
the version on main, runs task deps-lock, commits, pushes main,
creates the matching tag, and pushes the tag.
To bump a release version manually, run uv version <version>.
Install from PyPI with:
pip install yeetr
Development
uv sync
uv run ruff check
uv run pyright
uv run pytest
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file yeetr-2026.5.21.post16.tar.gz.
File metadata
- Download URL: yeetr-2026.5.21.post16.tar.gz
- Upload date:
- Size: 16.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
be6e07d1803b9eacfea127612a5e60c2a7cdb8494f9d9f6838f4d4437464f6c5
|
|
| MD5 |
1197057283d157f627aac57be7599463
|
|
| BLAKE2b-256 |
2ada76022f6f5a26cc35ebc42b8dfbd30844b0602f4c709f59cb24b66e77e80d
|
File details
Details for the file yeetr-2026.5.21.post16-py3-none-any.whl.
File metadata
- Download URL: yeetr-2026.5.21.post16-py3-none-any.whl
- Upload date:
- Size: 17.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
34ddb1c5570e598e00867539c750d9ab79c8e6add11cd48a91f659e129e52a2f
|
|
| MD5 |
cbce8c0d7a1ef03df079c08fbab310c8
|
|
| BLAKE2b-256 |
65971eaf4a166ad0551d49931595cacbf79ea02938dd039454f7f791b0c646f9
|