Using pyproject.toml
Let's create a CLI tool that is configurable via the user's pyproject.toml
. Think tools like Poetry, Ruff, and Codespell.
Base Application
For this example, we'll be creating a simple CLI named compact
that can compress data with either the zip
or the lzma
algorithm.
from cyclopts import App, Parameter
from cyclopts.types import ExistingFile, File
from typing import Annotated, Literal, Optional
import lzma
import zlib
app = App(name="compact", help="Data compression tool.")
@app.command
def compress(src: ExistingFile, dst: Optional[File] = None, *, method: Literal["lzma", "zip"] = "zip"):
"""Compress a file."""
data = src.read_bytes()
if method == "lzma":
out = lzma.compress(data)
elif method == "zip":
out = zlib.compress(data)
else:
raise NotImplementedError
dst = dst or src.with_suffix(src.suffix + "." + method)
dst.write_bytes(out)
if __name__ == "__main__":
app()
This application works as-is, but it may be useful for the caller to set the default method
field from their pyproject.toml
.
More precisely, we want to be able to use the following configuration:
# pyproject.toml
[tool.compact.compress]
method = "lzma"
Customizing Launch
First, we need to hook into the application launch process. In Cyclopts, this is done with the Meta App (an app that launches an app). The most basic meta-app is:
@app.meta.default
def main(*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)]):
app(tokens)
if __name__ == "__main__":
app.meta() # Call app.meta() instead of app()
For our purposes, we'll want to dive into the Cyclopts machinery a little further.
@app.meta.default
def main(*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)]):
command, bound = app.parse_args(tokens)
return command(*bound.args, **bound.kwargs)
command
is the actual python function to-be-executed. bound
is a BoundArguments
object containing all the parsed & converted CLI arguments. It follows that command(*bound.args, **bound.kwargs)
would execute the function with all of our supplied arguments.
Reading in pyproject.toml
We now have an appropriate place to read pyproject.toml
from the current working directory.
Add the following to the beginning of the main
meta-app function:
import tomli # Or you can just use ``toml`` in Python >=3.11
try:
with open("pyproject.toml", "rb") as f:
config = tomli.load(f)["tool"]["compact"] # Reads the [tool.compact] table.
except (FileNotFoundError, KeyError):
config = {}
config
will be empty if pyproject.toml
does not exist, or if it does not contain a [tool.compact]
table.
Note
Many applications search the current working directory for pyproject.toml
, and will fallback to searching parenting directories until a pyproject.toml
is found. Here's a snippet for that:
from pathlib import Path
def find_pyproject() -> Path:
"""Searches current directory, then parenting directories until a pyproject.toml is found."""
for parent in Path("pyproject.toml").absolute().parents:
if (candidate := parent / path.name).exists():
return candidate
raise FileNotFoundError("Cannot find a pyproject.toml")
Getting the command string
We want to dynamically parse what sub-table of the config we need to access based on the command being executed.
The parse_commands()
method returns a bunch of data; the first returned element is a list of strings containing the parsed command names.
command_names, _, _ = app.parse_commands(tokens)
If we invoked our program:
$ python compact.py compress foo.bin
Then the resulting command_names
would be ["compress"]
.
We can now access the config for this specific subcommand:
for command_name in command_names:
config = config.get(command_name, {})
Updating bound arguments
Finally, we need to set these values as defaults to the bound.arguments
dictionary.
We don't want to simply update the dictionary, as that would mean our toml-configured values would overwrite CLI-provided values. Using dict.setdefault()
will only set values for previously non-existent keys.
# Update the bound arguments for unset keys:
for key, value in config.items():
bound.arguments.setdefault(key, value)
Warning
You are responsible to correctly interpreting/coercing data types from non-cli sources to the correct type.
E.g. A value from toml
may be a string, but the function might be expecting a Path
object.
Final Code
Putting it all together, here's the complete copy/pastable example code:
from cyclopts import App, Parameter
from cyclopts.types import ExistingFile, File
from typing import Annotated, Literal, Optional
import tomli
import lzma
import zlib
app = App(name="compact", help="Data compression tool.")
@app.command
def compress(src: ExistingFile, dst: Optional[File] = None, *, method: Literal["lzma", "zip"] = "zip"):
"""Compress a file."""
data = src.read_bytes()
if method == "lzma":
out = lzma.compress(data)
elif method == "zip":
out = zlib.compress(data)
else:
raise NotImplementedError
dst = dst or src.with_suffix(src.suffix + "." + method)
dst.write_bytes(out)
@app.meta.default
def main(*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)]):
try:
with open("pyproject.toml", "rb") as f:
config = tomli.load(f)["tool"]["compact"]
except (FileNotFoundError, KeyError):
config = {}
# The main Cyclopts parsing/conversion
command, bound = app.parse_args(tokens)
# Get the config dictionary for the specified command.
command_names, _, _ = app.parse_commands(tokens)
for command_name in command_names:
config = config.get(command_name, {})
# Update the bound arguments for unset keys:
for key, value in config.items():
bound.arguments.setdefault(key, value)
# Actual function execution.
return command(*bound.args, **bound.kwargs)
if __name__ == "__main__":
app.meta()