thunc 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- thunc-0.1.0/.github/workflows/ci.yml +23 -0
- thunc-0.1.0/.github/workflows/publish.yml +36 -0
- thunc-0.1.0/.gitignore +9 -0
- thunc-0.1.0/LICENSE +21 -0
- thunc-0.1.0/PKG-INFO +159 -0
- thunc-0.1.0/README.md +133 -0
- thunc-0.1.0/examples/dynamic_prompts.py +63 -0
- thunc-0.1.0/examples/hello.py +6 -0
- thunc-0.1.0/examples/log_triage.py +60 -0
- thunc-0.1.0/examples/support_inbox.py +63 -0
- thunc-0.1.0/live_tests/conftest.py +25 -0
- thunc-0.1.0/live_tests/test_bool_decision.py +22 -0
- thunc-0.1.0/live_tests/test_dict_output.py +35 -0
- thunc-0.1.0/live_tests/test_hello.py +13 -0
- thunc-0.1.0/pyproject.toml +48 -0
- thunc-0.1.0/tests/conftest.py +33 -0
- thunc-0.1.0/tests/test_backends.py +76 -0
- thunc-0.1.0/tests/test_calls.py +186 -0
- thunc-0.1.0/tests/test_schema.py +59 -0
- thunc-0.1.0/thunc/__init__.py +22 -0
- thunc-0.1.0/thunc/backends.py +134 -0
- thunc-0.1.0/thunc/config.py +56 -0
- thunc-0.1.0/thunc/core.py +173 -0
- thunc-0.1.0/thunc/decorator.py +107 -0
- thunc-0.1.0/thunc/errors.py +2 -0
- thunc-0.1.0/thunc/py.typed +0 -0
- thunc-0.1.0/thunc/schema.py +133 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python-version }}
|
|
19
|
+
- run: pip install -e ".[anthropic,dev]"
|
|
20
|
+
- run: pytest # offline tests only; live_tests need a model and are not run in CI
|
|
21
|
+
- run: ruff check .
|
|
22
|
+
- run: ruff format --check .
|
|
23
|
+
- run: mypy --strict thunc
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Runs when you publish a GitHub release (e.g. tag v0.1.0). Uses PyPI Trusted Publishing:
|
|
4
|
+
# no API token is stored anywhere; PyPI trusts this workflow in this repo.
|
|
5
|
+
|
|
6
|
+
on:
|
|
7
|
+
release:
|
|
8
|
+
types: [published]
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
build:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.13"
|
|
18
|
+
- run: pip install build
|
|
19
|
+
- run: python -m build
|
|
20
|
+
- uses: actions/upload-artifact@v4
|
|
21
|
+
with:
|
|
22
|
+
name: dist
|
|
23
|
+
path: dist/
|
|
24
|
+
|
|
25
|
+
publish:
|
|
26
|
+
needs: build
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
environment: pypi
|
|
29
|
+
permissions:
|
|
30
|
+
id-token: write # lets PyPI verify this workflow; required for Trusted Publishing
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/download-artifact@v4
|
|
33
|
+
with:
|
|
34
|
+
name: dist
|
|
35
|
+
path: dist/
|
|
36
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
thunc-0.1.0/.gitignore
ADDED
thunc-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hussein Eltarras
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
thunc-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: thunc
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: think + function: call an LLM like a typed Python function.
|
|
5
|
+
Project-URL: Repository, https://github.com/Eltarras/thunc
|
|
6
|
+
Project-URL: Issues, https://github.com/Eltarras/thunc/issues
|
|
7
|
+
Author: Hussein Eltarras
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai,claude,llm,prompt,structured-output,typed
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Provides-Extra: anthropic
|
|
19
|
+
Requires-Dist: anthropic; extra == 'anthropic'
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: basedpyright; extra == 'dev'
|
|
22
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# thunc
|
|
28
|
+
|
|
29
|
+
[](https://github.com/Eltarras/thunc/actions/workflows/ci.yml)
|
|
30
|
+
|
|
31
|
+
**think + function.** Call an LLM like a typed Python function.
|
|
32
|
+
|
|
33
|
+
> **Status: beta (v0.1).** Expect bugs; the API may change. Feedback and issues are welcome.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import thunc
|
|
37
|
+
|
|
38
|
+
thunc.configure(backend="claude-code")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@thunc.function
|
|
42
|
+
def urgency(ticket: str) -> int:
|
|
43
|
+
"""Rate how urgent this ticket is, from 1 (can wait) to 5 (customer is blocked)."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
urgency("I was charged twice!") # -> 4, a checked int
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The answer is parsed into the declared type. If it doesn't fit, the model is asked again, and
|
|
51
|
+
after that `thunc.ThuncError` is raised. The library uses the standard library only and needs
|
|
52
|
+
Python 3.10+.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install thunc # standard library only
|
|
58
|
+
pip install "thunc[anthropic]" # adds the Claude API backend
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Try it
|
|
62
|
+
|
|
63
|
+
Clone the repo and run the examples from its root. No install is needed; the examples run through
|
|
64
|
+
your local [Claude Code](https://claude.com/claude-code) login:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
git clone https://github.com/Eltarras/thunc && cd thunc
|
|
68
|
+
python3 -m examples.hello
|
|
69
|
+
python3 -m examples.support_inbox
|
|
70
|
+
THUNC_BACKEND=codex python3 -m examples.log_triage
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Two ways to write a prompt
|
|
74
|
+
|
|
75
|
+
| | When | |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `@thunc.function` | The prompt is fixed and should read like code | The docstring is the prompt, the parameters are the inputs, the return annotation is the type |
|
|
78
|
+
| `thunc.call(...)` | The prompt is built in code (from config, in a loop, loaded from a file) | `thunc.call(f"Translate into {lang}.", {"text": note})` |
|
|
79
|
+
|
|
80
|
+
`@thunc.function(instructions=some_string)` combines the two: a typed, reusable function whose
|
|
81
|
+
prompt is generated.
|
|
82
|
+
|
|
83
|
+
**Keep user data out of the instructions.** Your own text can go in the instructions string.
|
|
84
|
+
Anything from users, files or the web goes in the inputs:
|
|
85
|
+
|
|
86
|
+
- `@thunc.function` does this automatically.
|
|
87
|
+
- With `thunc.call` it's up to you. In a live test, a hostile email pasted in with an f-string
|
|
88
|
+
tricked the model 3 out of 3 times. Passed as an input, it failed 3 out of 3 times.
|
|
89
|
+
|
|
90
|
+
## API
|
|
91
|
+
|
|
92
|
+
| | |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `@thunc.function` | Turns a signature + docstring into an AI-backed function. Options: `instructions=`, `ensure=`, `retries=`, `backend=`, `model=`. The body must be empty (`...`); real code raises `TypeError`. `async def` works |
|
|
95
|
+
| `thunc.call(instructions, inputs=None, *, returns=str, ensure=None, retries=2, backend=None, model=None)` | One prompt. Inputs are sent separately from the instructions |
|
|
96
|
+
| `thunc.map(func, items, workers=8)` | Runs calls in parallel, keeping the input order. Each call takes 4–8s, so this is the main speed lever |
|
|
97
|
+
| `thunc.configure(backend=, api_key=, model=, timeout=, trace=)` | Process-wide settings. `trace="calls.jsonl"` logs every call |
|
|
98
|
+
| `thunc.ThuncError` | Raised when no valid answer arrives after the retries |
|
|
99
|
+
|
|
100
|
+
**Return types:** `str`, `bool`, `int`, `float`, `Literal[...]`, `list[T]`, `dict[str, T]`,
|
|
101
|
+
`T | None`, and dataclasses (built into real instances).
|
|
102
|
+
|
|
103
|
+
**`ensure=`** adds your own check, for example `ensure=lambda n: 1 <= n <= 5`. A failed check is
|
|
104
|
+
sent back to the model and retried.
|
|
105
|
+
|
|
106
|
+
**Backends:**
|
|
107
|
+
- `anthropic` is the Claude API: `configure(api_key=...)` or `ANTHROPIC_API_KEY`, plus
|
|
108
|
+
`pip install anthropic`.
|
|
109
|
+
- `claude-code` and `codex` call your local CLI login, and are meant for cheap testing.
|
|
110
|
+
|
|
111
|
+
The backend can also be set with `THUNC_BACKEND`.
|
|
112
|
+
|
|
113
|
+
**Type checking:** signatures and return types are visible to mypy and Pyright. mypy reports
|
|
114
|
+
empty bodies; turn that off with `disable_error_code = ["empty-body"]`.
|
|
115
|
+
|
|
116
|
+
## Examples
|
|
117
|
+
|
|
118
|
+
| | |
|
|
119
|
+
|---|---|
|
|
120
|
+
| [hello.py](https://github.com/Eltarras/thunc/blob/main/examples/hello.py) | The smallest call |
|
|
121
|
+
| [support_inbox.py](https://github.com/Eltarras/thunc/blob/main/examples/support_inbox.py) | Docstring functions returning a `Literal`, an `int` with `ensure=`, a dataclass, and a reply; tickets processed in parallel |
|
|
122
|
+
| [dynamic_prompts.py](https://github.com/Eltarras/thunc/blob/main/examples/dynamic_prompts.py) | Prompts built from a style guide with `thunc.call`, and a grading function generated from a rubric |
|
|
123
|
+
| [log_triage.py](https://github.com/Eltarras/thunc/blob/main/examples/log_triage.py) | Plain Python and AI functions mixed, with tracing |
|
|
124
|
+
|
|
125
|
+
## Code
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
thunc/
|
|
129
|
+
__init__.py public API
|
|
130
|
+
decorator.py @thunc.function
|
|
131
|
+
core.py thunc.call, thunc.map, tracing
|
|
132
|
+
schema.py return types: describe, parse, validate
|
|
133
|
+
config.py settings and backend selection
|
|
134
|
+
backends.py anthropic, claude-code, codex
|
|
135
|
+
errors.py ThuncError
|
|
136
|
+
tests/ offline: a fake backend, never a real model
|
|
137
|
+
live_tests/ against a real model: hello, a yes/no decision, messy text to a dict
|
|
138
|
+
examples/
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Limitations
|
|
142
|
+
|
|
143
|
+
- **There's no caching and no record/replay yet**, so repeated calls cost again.
|
|
144
|
+
- **The API-key backend hasn't been run live yet.** It's only checked against the SDK's types.
|
|
145
|
+
- **`Literal` results from `thunc.call` are typed as `Any`.** `@thunc.function` has no such gap.
|
|
146
|
+
- **Docstrings disappear under `python -OO`.** Use `instructions=` there.
|
|
147
|
+
|
|
148
|
+
## Development
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
python3 -m venv .venv && .venv/bin/pip install -e ".[anthropic,dev]"
|
|
152
|
+
.venv/bin/pytest # offline tests (these run in CI)
|
|
153
|
+
.venv/bin/pytest live_tests # real model calls through your Claude Code login; costs quota
|
|
154
|
+
.venv/bin/ruff check . && .venv/bin/mypy --strict thunc
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## License
|
|
158
|
+
|
|
159
|
+
[MIT](https://github.com/Eltarras/thunc/blob/main/LICENSE)
|
thunc-0.1.0/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# thunc
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Eltarras/thunc/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
**think + function.** Call an LLM like a typed Python function.
|
|
6
|
+
|
|
7
|
+
> **Status: beta (v0.1).** Expect bugs; the API may change. Feedback and issues are welcome.
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
import thunc
|
|
11
|
+
|
|
12
|
+
thunc.configure(backend="claude-code")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@thunc.function
|
|
16
|
+
def urgency(ticket: str) -> int:
|
|
17
|
+
"""Rate how urgent this ticket is, from 1 (can wait) to 5 (customer is blocked)."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
urgency("I was charged twice!") # -> 4, a checked int
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The answer is parsed into the declared type. If it doesn't fit, the model is asked again, and
|
|
25
|
+
after that `thunc.ThuncError` is raised. The library uses the standard library only and needs
|
|
26
|
+
Python 3.10+.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install thunc # standard library only
|
|
32
|
+
pip install "thunc[anthropic]" # adds the Claude API backend
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Try it
|
|
36
|
+
|
|
37
|
+
Clone the repo and run the examples from its root. No install is needed; the examples run through
|
|
38
|
+
your local [Claude Code](https://claude.com/claude-code) login:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
git clone https://github.com/Eltarras/thunc && cd thunc
|
|
42
|
+
python3 -m examples.hello
|
|
43
|
+
python3 -m examples.support_inbox
|
|
44
|
+
THUNC_BACKEND=codex python3 -m examples.log_triage
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Two ways to write a prompt
|
|
48
|
+
|
|
49
|
+
| | When | |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `@thunc.function` | The prompt is fixed and should read like code | The docstring is the prompt, the parameters are the inputs, the return annotation is the type |
|
|
52
|
+
| `thunc.call(...)` | The prompt is built in code (from config, in a loop, loaded from a file) | `thunc.call(f"Translate into {lang}.", {"text": note})` |
|
|
53
|
+
|
|
54
|
+
`@thunc.function(instructions=some_string)` combines the two: a typed, reusable function whose
|
|
55
|
+
prompt is generated.
|
|
56
|
+
|
|
57
|
+
**Keep user data out of the instructions.** Your own text can go in the instructions string.
|
|
58
|
+
Anything from users, files or the web goes in the inputs:
|
|
59
|
+
|
|
60
|
+
- `@thunc.function` does this automatically.
|
|
61
|
+
- With `thunc.call` it's up to you. In a live test, a hostile email pasted in with an f-string
|
|
62
|
+
tricked the model 3 out of 3 times. Passed as an input, it failed 3 out of 3 times.
|
|
63
|
+
|
|
64
|
+
## API
|
|
65
|
+
|
|
66
|
+
| | |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `@thunc.function` | Turns a signature + docstring into an AI-backed function. Options: `instructions=`, `ensure=`, `retries=`, `backend=`, `model=`. The body must be empty (`...`); real code raises `TypeError`. `async def` works |
|
|
69
|
+
| `thunc.call(instructions, inputs=None, *, returns=str, ensure=None, retries=2, backend=None, model=None)` | One prompt. Inputs are sent separately from the instructions |
|
|
70
|
+
| `thunc.map(func, items, workers=8)` | Runs calls in parallel, keeping the input order. Each call takes 4–8s, so this is the main speed lever |
|
|
71
|
+
| `thunc.configure(backend=, api_key=, model=, timeout=, trace=)` | Process-wide settings. `trace="calls.jsonl"` logs every call |
|
|
72
|
+
| `thunc.ThuncError` | Raised when no valid answer arrives after the retries |
|
|
73
|
+
|
|
74
|
+
**Return types:** `str`, `bool`, `int`, `float`, `Literal[...]`, `list[T]`, `dict[str, T]`,
|
|
75
|
+
`T | None`, and dataclasses (built into real instances).
|
|
76
|
+
|
|
77
|
+
**`ensure=`** adds your own check, for example `ensure=lambda n: 1 <= n <= 5`. A failed check is
|
|
78
|
+
sent back to the model and retried.
|
|
79
|
+
|
|
80
|
+
**Backends:**
|
|
81
|
+
- `anthropic` is the Claude API: `configure(api_key=...)` or `ANTHROPIC_API_KEY`, plus
|
|
82
|
+
`pip install anthropic`.
|
|
83
|
+
- `claude-code` and `codex` call your local CLI login, and are meant for cheap testing.
|
|
84
|
+
|
|
85
|
+
The backend can also be set with `THUNC_BACKEND`.
|
|
86
|
+
|
|
87
|
+
**Type checking:** signatures and return types are visible to mypy and Pyright. mypy reports
|
|
88
|
+
empty bodies; turn that off with `disable_error_code = ["empty-body"]`.
|
|
89
|
+
|
|
90
|
+
## Examples
|
|
91
|
+
|
|
92
|
+
| | |
|
|
93
|
+
|---|---|
|
|
94
|
+
| [hello.py](https://github.com/Eltarras/thunc/blob/main/examples/hello.py) | The smallest call |
|
|
95
|
+
| [support_inbox.py](https://github.com/Eltarras/thunc/blob/main/examples/support_inbox.py) | Docstring functions returning a `Literal`, an `int` with `ensure=`, a dataclass, and a reply; tickets processed in parallel |
|
|
96
|
+
| [dynamic_prompts.py](https://github.com/Eltarras/thunc/blob/main/examples/dynamic_prompts.py) | Prompts built from a style guide with `thunc.call`, and a grading function generated from a rubric |
|
|
97
|
+
| [log_triage.py](https://github.com/Eltarras/thunc/blob/main/examples/log_triage.py) | Plain Python and AI functions mixed, with tracing |
|
|
98
|
+
|
|
99
|
+
## Code
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
thunc/
|
|
103
|
+
__init__.py public API
|
|
104
|
+
decorator.py @thunc.function
|
|
105
|
+
core.py thunc.call, thunc.map, tracing
|
|
106
|
+
schema.py return types: describe, parse, validate
|
|
107
|
+
config.py settings and backend selection
|
|
108
|
+
backends.py anthropic, claude-code, codex
|
|
109
|
+
errors.py ThuncError
|
|
110
|
+
tests/ offline: a fake backend, never a real model
|
|
111
|
+
live_tests/ against a real model: hello, a yes/no decision, messy text to a dict
|
|
112
|
+
examples/
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Limitations
|
|
116
|
+
|
|
117
|
+
- **There's no caching and no record/replay yet**, so repeated calls cost again.
|
|
118
|
+
- **The API-key backend hasn't been run live yet.** It's only checked against the SDK's types.
|
|
119
|
+
- **`Literal` results from `thunc.call` are typed as `Any`.** `@thunc.function` has no such gap.
|
|
120
|
+
- **Docstrings disappear under `python -OO`.** Use `instructions=` there.
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
python3 -m venv .venv && .venv/bin/pip install -e ".[anthropic,dev]"
|
|
126
|
+
.venv/bin/pytest # offline tests (these run in CI)
|
|
127
|
+
.venv/bin/pytest live_tests # real model calls through your Claude Code login; costs quota
|
|
128
|
+
.venv/bin/ruff check . && .venv/bin/mypy --strict thunc
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
[MIT](https://github.com/Eltarras/thunc/blob/main/LICENSE)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""String prompts: instructions built in code from data.
|
|
2
|
+
|
|
3
|
+
Rule of thumb: your own text (config, rubric) may go in the instructions; user content goes in
|
|
4
|
+
the inputs, never into the instructions through an f-string.
|
|
5
|
+
|
|
6
|
+
Run from the repo root: python3 -m examples.dynamic_prompts
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
import thunc
|
|
13
|
+
|
|
14
|
+
thunc.configure(backend=os.environ.get("THUNC_BACKEND", "claude-code"))
|
|
15
|
+
|
|
16
|
+
# 1. thunc.call: one prompt per target language, assembled from a style guide.
|
|
17
|
+
|
|
18
|
+
STYLE_GUIDES = {
|
|
19
|
+
"Dutch": "Use informal 'je', not 'u'. Keep product names in English.",
|
|
20
|
+
"German": "Use formal 'Sie'. Keep product names in English.",
|
|
21
|
+
"French": "Use 'vous'. Put a non-breaking space before ! and ?.",
|
|
22
|
+
}
|
|
23
|
+
release_note = "New: export your dashboards to PDF! Find it under Share → Export."
|
|
24
|
+
|
|
25
|
+
for language, rules in STYLE_GUIDES.items():
|
|
26
|
+
translated = thunc.call(
|
|
27
|
+
f"Translate the release note into {language}. Style rules: {rules}", {"release_note": release_note}
|
|
28
|
+
)
|
|
29
|
+
print(f"{language:7} {translated}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# 2. @thunc.function(instructions=...): a typed, reusable function whose prompt comes from data.
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Grade:
|
|
37
|
+
score: int
|
|
38
|
+
feedback: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
QUESTION = "Why does HTTPS protect against eavesdropping?"
|
|
42
|
+
CRITERIA = [
|
|
43
|
+
"mentions encryption of the traffic",
|
|
44
|
+
"mentions that the server's identity is verified with a certificate",
|
|
45
|
+
"is at most three sentences",
|
|
46
|
+
]
|
|
47
|
+
rubric = "\n".join(f"- {c}" for c in CRITERIA)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@thunc.function(
|
|
51
|
+
instructions=f"Grade the student's answer to: {QUESTION}\nAward one point per criterion met:\n{rubric}\n"
|
|
52
|
+
"Give one sentence of feedback naming what is missing, if anything.",
|
|
53
|
+
ensure=lambda g: 0 <= g.score <= len(CRITERIA),
|
|
54
|
+
)
|
|
55
|
+
def grade(answer: str) -> Grade: ...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
answers = [
|
|
59
|
+
"Because the data is encrypted so nobody in between can read it.",
|
|
60
|
+
"TLS encrypts traffic, and the certificate proves you're talking to the real server.",
|
|
61
|
+
]
|
|
62
|
+
for answer, result in zip(answers, thunc.map(grade, answers), strict=True):
|
|
63
|
+
print(f"\n{result.score}/{len(CRITERIA)} {answer}\n {result.feedback}")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Plain Python and AI functions together: triage the errors in a service log, with tracing on.
|
|
2
|
+
|
|
3
|
+
Run from the repo root: python3 -m examples.log_triage
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import tempfile
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
import thunc
|
|
14
|
+
|
|
15
|
+
trace_file = os.path.join(tempfile.gettempdir(), "thunc_log_triage.jsonl")
|
|
16
|
+
if os.path.exists(trace_file):
|
|
17
|
+
os.remove(trace_file)
|
|
18
|
+
thunc.configure(backend=os.environ.get("THUNC_BACKEND", "claude-code"), trace=trace_file)
|
|
19
|
+
|
|
20
|
+
LOG = """\
|
|
21
|
+
2026-10-03 09:12:01 INFO api request GET /health 200
|
|
22
|
+
2026-10-03 09:12:04 ERROR api psycopg.OperationalError: connection to server at "10.0.3.7", port 5432 failed: timeout
|
|
23
|
+
2026-10-03 09:12:09 ERROR api psycopg.OperationalError: connection to server at "10.0.3.7", port 5432 failed: timeout
|
|
24
|
+
2026-10-03 09:13:30 ERROR worker KeyError: 'customer_id' in invoices/render.py line 88
|
|
25
|
+
2026-10-03 09:15:47 ERROR api ssl.SSLCertVerificationError: certificate has expired (hostname='payments.example.com')
|
|
26
|
+
2026-10-03 09:16:12 ERROR worker KeyError: 'customer_id' in invoices/render.py line 88
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@thunc.function
|
|
31
|
+
def owner(error: str) -> Literal["database", "application-code", "certificates", "network", "unknown"]:
|
|
32
|
+
"""Which area most likely needs to act on this production error?"""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@thunc.function
|
|
37
|
+
def first_step(error: str, owner: str) -> str:
|
|
38
|
+
"""Suggest the single most useful first debugging step for this error, in one sentence."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Plain Python does the cheap part: find and count the unique errors.
|
|
43
|
+
errors = Counter(m[1] for m in re.finditer(r"ERROR \w+\s+(.*)", LOG))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def analyse(error: str) -> tuple[str, str]:
|
|
47
|
+
area = owner(error)
|
|
48
|
+
return area, first_step(error, area)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
for error, (area, step) in zip(errors, thunc.map(analyse, list(errors)), strict=True):
|
|
52
|
+
print(f"{errors[error]}x [{area}] {error[:70]}\n → {step}")
|
|
53
|
+
|
|
54
|
+
# The trace has one JSON line per call: inputs, raw answers, attempts, timing.
|
|
55
|
+
calls = [json.loads(line) for line in open(trace_file, encoding="utf-8")]
|
|
56
|
+
retried = sum(c["attempts"] > 1 for c in calls)
|
|
57
|
+
print(
|
|
58
|
+
f"\n{len(calls)} calls traced to {trace_file}; {retried} needed a retry; "
|
|
59
|
+
f"slowest {max(c['seconds'] for c in calls):.1f}s"
|
|
60
|
+
)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Docstring prompts: triage a support inbox.
|
|
2
|
+
|
|
3
|
+
Run from the repo root: python3 -m examples.support_inbox
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
import thunc
|
|
11
|
+
|
|
12
|
+
thunc.configure(backend=os.environ.get("THUNC_BACKEND", "claude-code"))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Order:
|
|
17
|
+
order_id: str
|
|
18
|
+
product: str | None = None
|
|
19
|
+
amount_eur: float | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@thunc.function
|
|
23
|
+
def category(ticket: str) -> Literal["bug", "billing", "feature-request", "other"]:
|
|
24
|
+
"""Classify this customer support ticket."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@thunc.function(ensure=lambda n: 1 <= n <= 5)
|
|
29
|
+
def urgency(ticket: str) -> int:
|
|
30
|
+
"""Rate how urgent this ticket is, from 1 (can wait a week) to 5 (customer is blocked right now)."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@thunc.function
|
|
35
|
+
def find_order(ticket: str) -> Order | None:
|
|
36
|
+
"""Extract the order this ticket is about, or null if it doesn't mention one."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@thunc.function
|
|
41
|
+
def draft_reply(ticket: str, tone: str = "friendly and concise") -> str:
|
|
42
|
+
"""Write a short first reply to this ticket in the given tone. Never promise a refund."""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
tickets = [
|
|
47
|
+
"I was charged twice for order #A-1042 (Pro plan, €49). Please fix this today!",
|
|
48
|
+
"It would be great if the dashboard had a dark mode.",
|
|
49
|
+
"Export to CSV crashes with 'unexpected token' since this morning's update.",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def triage(ticket: str) -> tuple[str, int, Order | None]:
|
|
54
|
+
return category(ticket), urgency(ticket), find_order(ticket)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# The tickets are triaged in parallel; each ticket's three calls run in sequence.
|
|
58
|
+
for ticket, (kind, level, order) in zip(tickets, thunc.map(triage, tickets), strict=True):
|
|
59
|
+
print(f"[{kind:15}] urgency {level} {ticket}")
|
|
60
|
+
if order:
|
|
61
|
+
print(f"{'':19}order: {order}")
|
|
62
|
+
|
|
63
|
+
print("\nDraft reply to the first ticket:\n" + draft_reply(tickets[0]))
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Live tests call a real model, so they cost quota and answers can vary. Plain `pytest` skips them.
|
|
2
|
+
|
|
3
|
+
pytest live_tests # through your local Claude Code login
|
|
4
|
+
THUNC_BACKEND=codex pytest live_tests
|
|
5
|
+
THUNC_BACKEND=anthropic pytest live_tests # needs ANTHROPIC_API_KEY and `pip install anthropic`
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
import thunc
|
|
14
|
+
|
|
15
|
+
BACKEND = os.environ.get("THUNC_BACKEND", "claude-code")
|
|
16
|
+
CLI = {"claude-code": "claude", "codex": "codex"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture(autouse=True, scope="session")
|
|
20
|
+
def live_backend():
|
|
21
|
+
if BACKEND in CLI and shutil.which(CLI[BACKEND]) is None:
|
|
22
|
+
pytest.skip(f"the `{CLI[BACKEND]}` CLI is not installed")
|
|
23
|
+
if BACKEND == "anthropic" and not os.environ.get("ANTHROPIC_API_KEY"):
|
|
24
|
+
pytest.skip("ANTHROPIC_API_KEY is not set")
|
|
25
|
+
thunc.configure(backend=BACKEND)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import thunc
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@thunc.function
|
|
5
|
+
def bool_decision() -> bool:
|
|
6
|
+
"""is 1 =3?"""
|
|
7
|
+
...
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@thunc.function
|
|
11
|
+
def is_true(statement: str) -> bool:
|
|
12
|
+
"""Is this statement true?"""
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_false_statement():
|
|
17
|
+
assert bool_decision() is False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_true_statement():
|
|
21
|
+
# Without a true case, a model that always answers False would pass.
|
|
22
|
+
assert is_true("2 + 2 = 4") is True
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Raw, messy text in; a dict out. The log mixes decimal commas (42,50), "€134,-" for 134.00,
|
|
2
|
+
a "2x ... each" multiplication, and several date and currency spellings."""
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
import thunc
|
|
7
|
+
|
|
8
|
+
RAW_EXPENSES = """\
|
|
9
|
+
03/09 - Uber airport -> hotel €42,50 (Sara)
|
|
10
|
+
lunch @ Bistro Amsterdam: 18.90 EUR - Tom
|
|
11
|
+
2026-09-03 coffee x3 7.20
|
|
12
|
+
Sept 4th, train tickets AMS-RTM 2x €16.80 each
|
|
13
|
+
hotel 2 nights 289.00 eur paid by company card
|
|
14
|
+
4/9 dinner w/ client €134,-
|
|
15
|
+
taxi back 38 euro (receipt lost)
|
|
16
|
+
coffee 2,40
|
|
17
|
+
Train RTM-AMS €33.60 total
|
|
18
|
+
04-09 snacks for the booth: €12.35
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# transport: 42.50 + 2 x 16.80 + 38.00 + 33.60
|
|
22
|
+
# food: 18.90 + 7.20 + 134.00 + 2.40 + 12.35
|
|
23
|
+
# lodging: 289.00
|
|
24
|
+
EXPECTED = {"transport": 147.70, "food": 174.85, "lodging": 289.00}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@thunc.function(ensure=lambda d: set(d) == {"transport", "food", "lodging"})
|
|
28
|
+
def totals_by_category(expenses: str) -> dict[str, float]:
|
|
29
|
+
"""Total these trip expenses per category: transport, food and lodging. All amounts are in euros."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_totals_by_category():
|
|
34
|
+
totals = totals_by_category(RAW_EXPENSES)
|
|
35
|
+
assert totals == pytest.approx(EXPECTED, abs=0.01)
|