Metadata-Version: 2.4
Name: twat
Version: 2.7.17
Project-URL: Documentation, https://github.com/twardoch/twat#readme
Project-URL: Issues, https://github.com/twardoch/twat/issues
Project-URL: Source, https://github.com/twardoch/twat
Author-email: Adam Twardoch <adam+github@twardoch.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Requires-Python: >=3.10
Requires-Dist: fire>=0.7.0
Requires-Dist: importlib-metadata>=8.6.1
Requires-Dist: importlib-resources>=6.5.2
Requires-Dist: loguru>=0.7.3
Requires-Dist: pydantic>=2.10.6
Requires-Dist: python-dotenv>=1.0.1
Requires-Dist: rich>=13.9.4
Requires-Dist: tomli>=2.2.1
Requires-Dist: typing-extensions>=4.0.0; python_version < '3.10'
Provides-Extra: all
Requires-Dist: fire>=0.7.0; extra == 'all'
Requires-Dist: importlib-metadata>=8.6.1; extra == 'all'
Requires-Dist: importlib-resources>=6.5.2; extra == 'all'
Requires-Dist: loguru>=0.7.3; extra == 'all'
Requires-Dist: pydantic>=2.10.6; extra == 'all'
Requires-Dist: python-dotenv>=1.0.1; extra == 'all'
Requires-Dist: rich>=13.9.4; extra == 'all'
Requires-Dist: tomli>=2.2.1; extra == 'all'
Requires-Dist: twat-audio[all]>=2.7.7; extra == 'all'
Requires-Dist: twat-cache[all]>=2.6.7; extra == 'all'
Requires-Dist: twat-coding[all]>=2.7.7; extra == 'all'
Requires-Dist: twat-ez[all]>=2.7.7; extra == 'all'
Requires-Dist: twat-font[all]>=2.7.7; extra == 'all'
Requires-Dist: twat-fs[all]>=2.7.7; extra == 'all'
Requires-Dist: twat-genai[all]>=2.7.7; extra == 'all'
Requires-Dist: twat-hatch[all]>=2.7.7; extra == 'all'
Requires-Dist: twat-image[all]>=2.7.7; extra == 'all'
Requires-Dist: twat-labs[all]>=2.7.7; extra == 'all'
Requires-Dist: twat-llm[all]>=2.7.7; extra == 'all'
Requires-Dist: twat-mp[all]>=2.6.4; extra == 'all'
Requires-Dist: twat-os[all]>=2.7.7; extra == 'all'
Requires-Dist: twat-search[all]>=2.7.7; extra == 'all'
Requires-Dist: twat-speech[all]>=2.7.7; extra == 'all'
Requires-Dist: twat-task[all]>=2.7.7; extra == 'all'
Requires-Dist: twat-text[all]>=2.7.7; extra == 'all'
Requires-Dist: twat-video[all]>=2.1.7; extra == 'all'
Requires-Dist: typing-extensions>=4.0.0; (python_version < '3.10') and extra == 'all'
Provides-Extra: audio
Requires-Dist: twat-audio[all]>=2.7.7; extra == 'audio'
Provides-Extra: cache
Requires-Dist: twat-cache[all]>=2.6.7; extra == 'cache'
Provides-Extra: coding
Requires-Dist: twat-coding[all]>=2.7.7; extra == 'coding'
Provides-Extra: dev
Requires-Dist: mypy>=1.0.0; extra == 'dev'
Requires-Dist: pre-commit>=4.0.0; extra == 'dev'
Requires-Dist: ruff>=0.1.0; extra == 'dev'
Provides-Extra: ez
Requires-Dist: twat-ez[all]>=2.7.7; extra == 'ez'
Provides-Extra: font
Requires-Dist: twat-font[all]>=2.7.7; extra == 'font'
Provides-Extra: fs
Requires-Dist: twat-fs[all]>=2.7.7; extra == 'fs'
Provides-Extra: genai
Requires-Dist: twat-genai[all]>=2.7.7; extra == 'genai'
Provides-Extra: hatch
Requires-Dist: twat-hatch[all]>=2.7.7; extra == 'hatch'
Provides-Extra: image
Requires-Dist: twat-image[all]>=2.7.7; extra == 'image'
Provides-Extra: labs
Requires-Dist: twat-labs[all]>=2.7.7; extra == 'labs'
Provides-Extra: llm
Requires-Dist: twat-llm[all]>=2.7.7; extra == 'llm'
Provides-Extra: mp
Requires-Dist: twat-mp[all]>=2.6.4; extra == 'mp'
Provides-Extra: os
Requires-Dist: twat-os[all]>=2.7.7; extra == 'os'
Provides-Extra: search
Requires-Dist: twat-search[all]>=2.7.7; extra == 'search'
Provides-Extra: speech
Requires-Dist: twat-speech[all]>=2.7.7; extra == 'speech'
Provides-Extra: task
Requires-Dist: twat-task[all]>=2.7.7; extra == 'task'
Provides-Extra: test
Requires-Dist: pytest-benchmark[histogram]>=5.1.0; extra == 'test'
Requires-Dist: pytest-cov>=6.0.0; extra == 'test'
Requires-Dist: pytest-xdist>=3.6.1; extra == 'test'
Requires-Dist: pytest>=8.3.4; extra == 'test'
Provides-Extra: text
Requires-Dist: twat-text[all]>=2.7.7; extra == 'text'
Provides-Extra: video
Requires-Dist: twat-video[all]>=2.1.7; extra == 'video'
Description-Content-Type: text/markdown

# twat

**Twardoch's Workstation Automation Toolkit.** A minimal plugin host for Python: install small, focused packages and access them all through one namespace and one CLI.

`twat` does nothing on its own. Install plugins, and they snap into place.

## How it works

Python has a built-in mechanism for packages to register themselves with a group name — called *entry points*. `twat` uses this to build a dynamic namespace: when you access `twat.fs`, it looks up the `twat.plugins` entry point named `fs`, loads the module, and hands it back. No configuration files, no registration steps beyond a normal `pip install`.

```python
import twat

# twat-fs registered itself as a "twat.plugins" entry point named "fs"
files = twat.fs.list_directory(".")

# twat-cache registered as "cache"
@twat.cache.memoize()
def expensive():
    ...
```

The CLI works the same way. `twat fs list /tmp` finds the `fs` plugin, rewrites `sys.argv` so the plugin thinks it was called directly, and calls `twat_fs.main()`.

## Discovering commands

Every plugin ships a Fire-based CLI with two access shapes:

```bash
twat-image gray2alpha input.png output.png   # subcommand form
twat-image-gray2alpha input.png output.png   # dashed form (one script per leaf)
```

Each leaf (and each command group) is registered as a real `console_scripts`
entry point, so typing `twat-<TAB>` in your shell offers dozens of completions
once you have `twat[all]` installed:

```
twat-audio              twat-image-gray2alpha       twat-search-web
twat-audio-normalize    twat-llm-ask                twat-speech-transcribe
twat-fs-upload          twat-os-clipboard           twat-video-probe
... (~90 more)
```

To wire completion of the `twat` dispatcher itself:

```bash
twat --completions zsh  > ~/.zfunc/_twat       # zsh
twat --completions bash > ~/.local/share/bash-completion/completions/twat
twat --completions fish > ~/.config/fish/completions/twat.fish
```

## Install

```bash
pip install twat
```

`twat` is an empty host. Install plugins separately:

```bash
pip install twat-fs twat-cache twat-os
```

## CLI

```bash
twat --help          # show host usage
twat --list          # list installed plugin entry point names
twat <plugin_name> [args...]

# Examples:
twat --list
twat fs list /tmp
twat cache clear
```

`twat --list` reads only plugin entry point metadata, so it does not import
plugin modules just to show what is installed. Dispatch still works by loading
the selected plugin, rewriting `sys.argv` so the plugin sees `twat.<plugin>` as
its executable name, and calling the plugin module's callable `main()`.

## Writing a plugin

No SDK required. A plugin is a normal Python package that declares one entry point.

### 1. Package structure

```
twat-myplugin/
├── src/
│   └── twat_myplugin/
│       ├── __init__.py    # Public API — everything here becomes twat.myplugin.*
│       └── __main__.py    # CLI handler — must expose a main() function
├── pyproject.toml
└── README.md
```

### 2. Register the entry point (`pyproject.toml`)

```toml
[project.entry-points."twat.plugins"]
myplugin = "twat_myplugin"
```

This is the only `twat`-specific line in your entire package.

### 3. Expose your API (`__init__.py`)

```python
from importlib import metadata

try:
    __version__ = metadata.version(__name__)
except metadata.PackageNotFoundError:
    __version__ = "0.0.0-dev"

def do_something():
    return "result"

# The CLI dispatcher calls plugin.main() — expose it here
try:
    from .__main__ import main
except ImportError:
    def main() -> None:
        pass

__all__ = ["do_something", "main", "__version__"]
```

### 4. Handle CLI calls (`__main__.py`)

```python
import sys

def main() -> None:
    # sys.argv[0] is already "twat.myplugin" when called via `twat myplugin`
    print(f"Hello from myplugin! Args: {sys.argv[1:]}")
    sys.exit(0)

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

### Naming conventions

| Thing | Convention | Example |
|-------|-----------|---------|
| pip package | `twat-<name>` with hyphens | `twat-myplugin` |
| entry point name | lowercase, no prefix | `myplugin` |
| Python module | `twat_<name>` with underscores | `twat_myplugin` |

The entry point name becomes the attribute on `twat` and the subcommand in the CLI.

## How plugin loading works

`twat.__getattr__` is called whenever you access an attribute that doesn't exist on the module. It scans the `twat.plugins` entry point group for a matching name, loads the module, registers it in `sys.modules` as `twat.<name>` (so subsequent accesses skip the lookup), and returns it.

If no plugin matches, a `PluginError` is raised with a clear message.

The CLI entry point (`twat.main`) intercepts `sys.argv`, pulls out the plugin name, rewrites `sys.argv[0]` to `twat.<plugin>` and `sys.argv[1:]` to the remaining arguments, then calls the plugin's `main()`. The plugin never knows it was dispatched through `twat`.

## Development

```bash
git clone https://github.com/twardoch/twat
cd twat
uv venv && uv sync
pytest -xvs
```

## License

MIT
