Metadata-Version: 2.5
Name: toolfuncs
Version: 3.0.0
Summary: Typed Python functions as JSON command-line tools that notebooks can load
Project-URL: Repository, https://github.com/nimashoghi/toolfuncs
Project-URL: Issues, https://github.com/nimashoghi/toolfuncs/issues
Author-email: Nima Shoghi <nima@boltz.bio>
Requires-Python: >=3.11
Requires-Dist: cyclopts<6,>=5.3
Requires-Dist: packaging>=24
Description-Content-Type: text/markdown

# toolfuncs

`toolfuncs` is a small library for tools that are typed Python functions. It has two names:

- `toolfuncs.App()` makes a [Cyclopts](https://cyclopts.readthedocs.io/) `App` that prints each command result as compact JSON and lets a list argument take several tokens.
- `toolfuncs.load(path)` runs a tool script in the current Python process, for example in a notebook, and returns its functions and classes.

The function is the interface. Its parameters are the command-line arguments, and its return value is the command-line output.

## Write a tool

A tool is an executable [PEP 723](https://peps.python.org/pep-0723/) script that `uv run --script` starts. Only uv must be on the machine. Pin `toolfuncs` exactly and set `exclude-newer`, so each run gets the same dependencies. uv ignores every upload after `exclude-newer`, so it must be later than the upload of every exactly pinned release. A plain date such as `2026-10-24` includes that whole day in the machine's local time zone; a timestamp such as `2026-10-24T00:00:00Z` excludes the uploads of that day. The wiki's bump sets it to the bump date.

```python
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13"
# dependencies = ["toolfuncs==3.0.0"]
# [tool.uv]
# exclude-newer = "2026-10-24"
# ///
"""Commit attributable wiki changes.

Longer help text."""

import toolfuncs

app = toolfuncs.App()


@app.command
def commit(globs: list[str], *, message: str, dry_run: bool = False) -> dict:
    """Commit the matching files."""
    return {"globs": globs, "message": message, "dry_run": dry_run}


if __name__ == "__main__":
    app()
```

The `exclude-newer` value in this example is the date on which its pins were bumped.

Make the file executable (`chmod +x wiki-commit`) and run it:

```console
$ ./wiki-commit commit --message "Update notes" a.md b.md
{"globs":["a.md","b.md"],"message":"Update notes","dry_run":false}
$ ./wiki-commit commit --message "Update notes" --globs a.md b.md --dry-run | jq .dry_run
true
```

A tool with one command registers it with `@app.default` instead of `@app.command`. Then the arguments follow the tool name directly (`./tool a.md b.md`).

`App` is a factory for `cyclopts.App`. It passes all keyword arguments to `cyclopts.App`, for example `App(help=__doc__)` to show the module docstring at the top of `--help`. A `result_action` replaces the JSON printer, for a tool that sets its own exit status or streams its output. A `default_parameter` is combined with the multi-token list setting. Pass `version=` to set the `--version` output; otherwise Cyclopts reports the version of `toolfuncs`, because `toolfuncs` creates the App. Use Cyclopts directly for everything else, for example `from cyclopts import Parameter`.

The package has no command line of its own. Its `toolfuncs` command only prints that toolfuncs 3 has no command line and exits with status 1. It exists so that an old `toolfuncs setup` dispatcher, which runs `uvx --from toolfuncs toolfuncs` with no version, stops instead of finding itself on `PATH` and calling itself without end.

## Arguments, help and errors

- A list of plain values (`list[str]`, `list[Path]`, `tuple[str, ...]`) takes several tokens, positionally (`commit a.md b.md`) or after its keyword (`--globs a.md b.md`). A keyword list takes all tokens up to the next option or `--`, so put positional arguments before it or after `--`.
- A dict, dataclass or Pydantic model takes one JSON token after its keyword: `--settings '{"retries": 3}'`. Positionally, Cyclopts takes one token for each dataclass field. Cyclopts converts each value by its annotation and reads `Any` as `str`, so give dict values a concrete type or use a model. Cyclopts also accepts dotted keys such as `--settings.retries 3`. Cyclopts 5.3 cannot parse a list of dicts (`list[dict[str, int]]`). The multi-token list setting stops a list of dataclasses or models with more than one required field from parsing; give that parameter `Annotated[list[Item], Parameter(consume_multiple=False)]`, and then it takes one JSON object for each `--items`, or one JSON array.
- Other parameters, including scalar unions such as `str | None`, take one token. Cyclopts converts it by the annotation and does not read it as JSON: for `str | None`, `null` is the string `"null"`, so leave the option out to get `None`. A union with a list, dict or model, such as `list[str] | None`, follows the rule above for that type. A fixed-length tuple such as `tuple[int, str]` takes one token for each element.
- `--help` shows help from the function signatures and docstrings.
- Bad input prints an error to stderr and exits with a non-zero status. An uncaught exception prints its traceback to stderr and exits with status 1.

## Output

`App` prints every result that is not `None` to stdout as compact JSON (`json.dumps` with `separators=(",", ":")` and `allow_nan=False`). A `None` result prints nothing. Output is always JSON; use `jq` to read it in a terminal.

- `dict`, `list`, `tuple`, `str`, `int`, `float`, `bool` and `None` are written as JSON.
- An object with `model_dump`, such as a Pydantic model, is written as `model_dump(mode="json")`.
- A dataclass instance is written as `dataclasses.asdict`.
- A `pathlib` path is written as its string.
- A `datetime.datetime`, `datetime.date` or `datetime.time` is written as its `isoformat()`.

Values of these types can also be nested. Other values raise `TypeError`, and `NaN` or infinity raises `ValueError`.

## Load a tool in Python

```python
import toolfuncs

wiki_sync = toolfuncs.load(".bin/wiki-sync")
wiki_sync.commit(["a.md", "b.md"], message="Update notes")
```

`load` does these steps:

1. It reads the script's PEP 723 block and finds the `dependencies` whose distributions are not installed in the running interpreter. It skips a requirement whose environment marker is false.
2. It installs those requirements, as written, into the running interpreter with `uv pip install --python sys.executable`, with `--exclude-newer` when the script sets `[tool.uv] exclude-newer`. It reads no other `[tool.uv]` settings. If uv is not on `PATH`, it raises `FileNotFoundError`, and if uv fails, it raises `RuntimeError` with uv's error output.
3. It runs the script with `runpy.run_path` under a module name that is not `"__main__"`, so the `if __name__ == "__main__":` block does not run. `__file__` is the absolute script path.
4. It returns a `types.SimpleNamespace` of the functions, classes and other callables (such as decorated functions) whose module is the script. Imported names are not included.

`load` does not replace an installed distribution, because the running process can already use it. When the installed version does not satisfy the script's requirement, `load` gives a `UserWarning` and uses the installed version. `load` does not check the extras or the URL of an installed distribution. uv can still change an installed distribution when a new one needs it.

Unlike `python script`, `load` does not put the script's directory on `sys.path`, so a script that imports a module next to it adds that directory itself. `runpy` removes the script's module from `sys.modules` after the run, so code that looks the module up later does not work after `load`: for example, a Pydantic model with a forward reference, `typing.get_type_hints` on a script class that uses `from __future__ import annotations`, or `pickle`.

A loaded command function returns the same value that the command line prints as JSON.

`load` can change the environment of the running interpreter. Use it only in an environment that you may change, for example a notebook kernel with its own virtual environment. Do not use it with an interpreter that other runs share, such as one that `uvx` keeps in the uv cache: an install there changes that cache entry for every later run.

## Development

```console
uv sync
uv run pytest
uv run ruff format --check .
uv run ruff check .
uv run basedpyright
```

The tests need uv on `PATH`. One test runs the template above with `uv run --script` and needs network access to PyPI, unless the uv cache already holds its dependencies.

See [CHANGELOG.md](CHANGELOG.md) for changes between versions.

## Releasing

The GitHub Release is the release control point. Set `[project].version`, and set the README template's `toolfuncs` pin to the new version and its `exclude-newer` to the release date as a plain date. Merge and push that commit on the release date, then publish a GitHub Release whose tag is `v<version>`. The release workflow verifies that the tag and package version match, builds the wheel and source distribution, and publishes them to PyPI through Trusted Publishing.
