A lightweight, extensible, and reusable CLI framework for Python projects
This framework allows you to dynamically register commands as verb-noun pairs (e.g., finj trade add) by scanning the Python modules under the project directory named by $PROJECT_DIR. It uses argparse subparsers for automatic command wiring and argument parsing.
- ✅ Reusable: Use the same
SubParsley.pyacross multiple projects (e.g.,finj). - ✅ Dynamic Module Loading: Recursively loads modules from
$PROJECT_DIR, skipping test files. - ✅ Automatic Command Wiring: Commands are auto-registered based on Python modules and methods.
- ✅ Self-Documenting: Auto-generates help messages from docstrings.
- ✅ Extensible: Add new commands by simply adding methods to modules.
SubParsley/
└── SubParsley.py # Shared CLI dispatcher
<project_name>_project/ # this whole directory is $PROJECT_DIR
├── <project_name> # Wrapper script (e.g., finj)
├── trade.py # Example module — dispatchers live at the ROOT
├── portfolio.py
└── views/ # Subpackages are recursed into if they
├── __init__.py # contain an __init__.py
└── ...
Place SubParsley.py in a shared location (e.g., ~/shared/SubParsley.py or a Git submodule).
No additional dependencies are required — it uses only Python's built-in argparse and pathlib.
Create a wrapper script (e.g., finj) in your project's root directory.
This script passes the project name as the first argument to SubParsley.py.
#!/bin/bash
export PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PROJECT_NAME="finj"
export PROJECT_DESC="Offline Financial Journal"
python3 /path/to/shared/SubParsley.py "$@"Make it executable:
chmod +x finjAdd Python modules at the root of your project directory (e.g., trade.py, portfolio.py). Subdirectories containing an __init__.py are recursed into.
Each module defines commands (methods) for its noun (module name).
def add(symbol: str, quantity: int, price: float = 0.0):
"""
# @desc: Add a new trade to the system.
# @arg: symbol The stock symbol (e.g., AAPL).
# @arg: quantity Number of shares to add.
# @arg: price Price per share (default: 0.0).
"""
print(f"Adding trade: {symbol} (qty: {quantity}, price: {price})")
def close(trade_id: str, force: bool = False):
"""
# @desc: Close an open trade by ID.
# @arg: trade_id ID of the trade to close.
# @arg: force Force close (default: False).
"""
if force:
print(f"Forcibly closing trade: {trade_id}")
else:
print(f"Closing trade: {trade_id}")def sync():
"""
# @desc: Sync the portfolio with the server.
"""
print("Syncing portfolio...")
def list():
"""
# @desc: List all assets in the portfolio.
"""
print("Listing portfolio assets...")Use the following annotations in your method docstrings to customize the CLI:
| Annotation | Purpose | Example |
|---|---|---|
# @ns: |
Command namespace | # @ns: portfolio |
# @desc: |
Command description | # @desc: Add a new trade to the system. |
# @arg: |
Argument help text | # @arg: symbol The stock symbol (e.g., AAPL). |
# !@desc: |
Suppress the method from the CLI entirely | # !@desc: (on its own line, anywhere in the docstring) |
A tag is matched as a substring anywhere in the line — not just at the start
— so a docstring paragraph that happens to mention @desc: in prose is parsed
as an annotation too. Keep tag lines on their own line.
The leading # shown above is a convention, not a requirement: @ns:,
@desc: and @arg: are recognized with or without it, since the parser only
checks for the substring.
These are load-bearing facts about how SubParsley turns a function signature into a CLI. They are not obvious from the annotation table above, and a consumer that assumes otherwise will design something that breaks.
-
Type annotations drive real argparse behavior, not just help text.
intandfloatparameters get an argparsetype=converter, soshares: floatmeans a non-numeric--sharesvalue is rejected before your function ever runs.boolparameters becomeargparse.BooleanOptionalAction, giving both--flagand--no-flagregardless of the parameter's default. A consumer that re-validates or re-converts these itself is doing redundant work — the value SubParsley hands your function is already the right type. -
There are still no positional arguments. Every parameter is a
--flag. -
A parameter's
@arg:line may carry a bracketed spec in first position:@arg: portfolio [-p] Portfolio name @arg: action [-a choices=BUY|SELL] Trade action @arg: tags [repeat] Repeatable; collects into a list @arg: files [nargs=+] One or more valueschoicesmatching is case-insensitive and yields the declared spelling.repeatgivesaction="append"with a fresh[]default per parser. An unknown modifier warns rather than being silently ignored. -
Enum-annotated parameters convert to members, with the accepted values shown in--help. Users type the value (BUY), not the member name. -
Short flags are derived from parameter names, and order matters.
generate_unique_short_nametakes the first letter of each word in the parameter name (spec_path→sp), and on a collision walks progressively longer prefixes of the next parameter that collided. Collisions are resolved per verb, not across the whole CLI, but within one verb the declaration order of your parameters decides which one keeps the short form — reordering a signature can silently change-sfrom meaningsectorto meaningstatus. -
A missing or malformed argument now exits 1 with
Error: ..., the same convention every other failure in a SubParsley-based CLI uses — not argparse's own usage banner and exit code 2. This is implemented byDyingArgumentParser, which raisesArgumentErrorfromerror()instead of callingsys.exit()directly;main()catches it alongside every other exception. -
A colon in
@desc:/@arg:/@ns:text is now legal. Tag text used to be taken asline.split(":")[1], so@desc: Render a note: fill placeholderssilently lost everything after the second colon. It is nowline.split(":", 1), so a colon anywhere in the description or an argument's help text survives. -
Modules are auto-discovered, and the discovery rule is easy to trip on.
load_modules_recursiveimports every*.pydirectly in the project directory, then recurses into a subdirectory only if it contains an__init__.pyand its name does not start with_. A new subpackage with no__init__.pyregisters nothing, silently. A module that raises on import is skipped with aWarning: skipping module '<name>': <Type>: <message>on stderr, and the CLI continues without it. SetSUBPARSLEY_DEBUG=1to re-raise instead of skipping. -
Test modules are never imported.
*_test*.py,tests.py,conftest.pyandsetup.pyare skipped by default, in both file and directory position. They contribute no verbs, and importing them on every CLI invocation was pure startup cost — 45% of kolobar's.pyfiles, plusunittestitself. Add more patterns with$PROJECT_IGNORE. -
Only the project root goes on
sys.path. Modules are imported by their dotted package name. Two modules with the same base name at different depths (schema.py,models/schema.py) therefore both load correctly and neither shadows the other. -
Errors go to stderr and exit 1.
Error: ...is written to stderr, soVALUE=$(yourcli something)captures data only, and2>/dev/nullactually silences complaints. The prefix and the exit code are unchanged from earlier versions; only the stream differs. -
-his reserved. A parameter whose initials would producehfalls through to a longer prefix instead (hostbecomes-ho), because claiming-hraises at parser-construction time and would take down the whole CLI. A parameter literally namedhgets no short flag at all. Any other short-flag conflict degrades the same way, with a warning. -
A
%in tag text is safe. Help strings are%-escaped before argparse sees them, so@desc: adds up to 100%renders rather than raisingunsupported format character. A side effect is that argparse's own%(default)sinterpolation is not available in docstring text.
List Available Commands
./finj --helpOutput:
usage: SubParsley.py finj [-h] {trade,portfolio} ...
positional arguments:
{trade,portfolio} Nouns
trade Commands for trade
portfolio Commands for portfolio
optional arguments:
-h, --help show this help message and exit
List Verbs for a Noun
./finj trade --helpOutput:
usage: SubParsley.py finj trade [-h] {add,close} ...
positional arguments:
{add,close} Verbs
add Add a new trade to the system.
close Close an open trade by ID.
optional arguments:
-h, --help show this help message and exit
Run a Command
./finj trade add --symbol AAPL --quantity 10 --price 150.25Output:
Adding trade: AAPL (qty: 10, price: 150.25)
Run a Command with Flags
./finj trade close --trade-id 12345 --forceOutput:
Forcibly closing trade: 12345
def process():
"""
# @desc: Process all messages in the inbox.
"""
print("Processing inbox...")Now ./finj inbox process will work automatically.
def update(trade_id: str, quantity: int):
"""
# @desc: Update the quantity of a trade.
# @arg: trade_id ID of the trade to update.
# @arg: quantity New quantity.
"""
print(f"Updating trade {trade_id} to quantity: {quantity}")Now ./finj trade update --trade-id 12345 --quantity 20 will work.
__version__ in SubParsley.py is the single source of truth. SemVer here is
over the generated CLI surface, not the Python API — renaming or rebinding a
flag that a consumer's docstrings already produce is a breaking change, even
though no function signature moved. That is counterintuitive and worth stating
plainly.
A consumer declares what it needs and SubParsley enforces it, so every consumer gets the check for free and none reimplements version parsing:
export PROJECT_REQUIRES_SUBPARSLEY=">=0.3,<0.4"Only >=X.Y[.Z] with an optional ,<A.B[.C] is understood; anything else is
refused rather than ignored, because a silently-skipped clause means a consumer
believes it is protected when it is not. Deliberately no packaging dependency
— being one stdlib-only file you can copy into a project is the point.
--version reports both versions and the resolved SubParsley.py path, which
is the question you actually have when a sibling-directory default and an
installable package can both be present.
The wrapper script configures SubParsley entirely through the environment; there are no framework-level command-line flags.
| Variable | Meaning |
|---|---|
PROJECT_DIR |
Directory to scan for dispatcher modules. Defaults to SubParsley's own directory. |
PROJECT_NAME |
Program name shown in usage. |
PROJECT_DESC |
One-line description shown in --help. |
PROJECT_IGNORE |
Extra comma/semicolon-separated fnmatch patterns to skip, added to the defaults. |
PROJECT_IGNORE_DEFAULTS |
Set to 0 to drop the built-in ignore patterns. Rarely what you want. |
PROJECT_PACKAGE |
Import dispatchers from this installed package instead of scanning a directory. Takes precedence over PROJECT_DIR, and touches no sys.path. |
PROJECT_VERSION |
The consumer's own version, shown by --version. |
PROJECT_REQUIRES_SUBPARSLEY |
e.g. >=0.3,<0.4. Checked before the CLI is built. |
PROJECT_SHORT_FLAGS |
auto (default) derives short flags; explicit emits only those declared as [-x]. |
SUBPARSLEY_DEBUG |
Print tracebacks and re-raise import failures. 0/false/no count as off. |
-
Error: Project directory not found: Ensure
$PROJECT_DIRpoints at an existing directory. -
A command you just added does not appear: its module probably failed to import. SubParsley prints
Warning: skipping module '<name>': ...to stderr and carries on. SetSUBPARSLEY_DEBUG=1to get the traceback instead. -
Error: Unknown command: Check that the module and method names are correct and that the module is inside
$PROJECT_DIR(and, if nested, that every parent directory has an__init__.py). -
Error: Missing arguments: Ensure required arguments are provided. Use
--helpto see the expected arguments for a command. -
A bad value exits with
Error: ...and status 1 (not argparse's own usage banner and exit code 2): this is deliberate, so scripts driving a SubParsley-based CLI can check$?the same way for every kind of failure.
This project is open-source and available under the MIT License.