callables 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.
- callables-0.1.0/PKG-INFO +86 -0
- callables-0.1.0/README.md +71 -0
- callables-0.1.0/callables/GUIDE.md +236 -0
- callables-0.1.0/callables/__init__.py +6 -0
- callables-0.1.0/callables/__main__.py +5 -0
- callables-0.1.0/callables/checks.py +110 -0
- callables-0.1.0/callables/cli.py +335 -0
- callables-0.1.0/callables/commands.py +121 -0
- callables-0.1.0/callables/decorator.py +234 -0
- callables-0.1.0/callables/files.py +134 -0
- callables-0.1.0/callables/init.py +121 -0
- callables-0.1.0/callables/manifest.py +75 -0
- callables-0.1.0/callables/project.py +54 -0
- callables-0.1.0/callables/py.typed +0 -0
- callables-0.1.0/callables/runtime.py +77 -0
- callables-0.1.0/callables/schema.py +77 -0
- callables-0.1.0/callables.egg-info/PKG-INFO +86 -0
- callables-0.1.0/callables.egg-info/SOURCES.txt +31 -0
- callables-0.1.0/callables.egg-info/dependency_links.txt +1 -0
- callables-0.1.0/callables.egg-info/entry_points.txt +4 -0
- callables-0.1.0/callables.egg-info/requires.txt +4 -0
- callables-0.1.0/callables.egg-info/top_level.txt +2 -0
- callables-0.1.0/pyproject.toml +40 -0
- callables-0.1.0/setup.cfg +4 -0
- callables-0.1.0/shipfn/__init__.py +10 -0
- callables-0.1.0/shipfn/__main__.py +5 -0
- callables-0.1.0/tests/test_agent_tools.py +159 -0
- callables-0.1.0/tests/test_command.py +86 -0
- callables-0.1.0/tests/test_files.py +85 -0
- callables-0.1.0/tests/test_groups.py +138 -0
- callables-0.1.0/tests/test_next.py +132 -0
- callables-0.1.0/tests/test_project.py +60 -0
- callables-0.1.0/tests/test_sdk.py +142 -0
callables-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: callables
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Decorate Python functions and push them to Callables: forms, schedules and run history for your team.
|
|
5
|
+
Project-URL: Homepage, https://callables.app
|
|
6
|
+
Project-URL: Source, https://github.com/callables-app/callables-python
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Typing :: Typed
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: pydantic>=2.5
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# Callables Python SDK
|
|
17
|
+
|
|
18
|
+
Turn Python functions into forms, schedules and run history your whole team can use. Decorate a function with `@deploy`, push the repo, and people run it from the Callables web app — no Python needed on their end.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install callables
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
This installs the `call` command. Code written for the SDK's old name, `shipfn`, keeps working: `from shipfn import deploy` and the `shipfn` command are aliases for this package.
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from typing import Literal
|
|
28
|
+
from callables import deploy
|
|
29
|
+
|
|
30
|
+
@deploy(title="Weekly report", secrets=["SALES_API_TOKEN"], schedule="0 9 * * MON")
|
|
31
|
+
def weekly_report(region: Literal["NA", "EMEA", "APAC"] = "NA", days: int = 7) -> list[dict]:
|
|
32
|
+
"""Deals closed in the last N days."""
|
|
33
|
+
...
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- **Inputs** come from the type hints: `str`, `int`, `float`, `bool`, `date`, `Literal[...]`/`Enum` (dropdowns), `list[...]`, optional (`X | None`) and Pydantic models. Defaults become prefilled values.
|
|
37
|
+
- **Results** are shown by return type: `str` as Markdown, `list[dict]` (or a DataFrame) as a table with CSV download, `dict`/Pydantic model as a key-value list, `None` as "done".
|
|
38
|
+
- **Files in**: a `File` parameter becomes an upload field. Your function gets a `File` with `.path`, `.name`, `.read_text()`, `.read_bytes()` and `.open()`. Limit the picker with `Annotated[File, accept(".csv", ".xlsx")]`.
|
|
39
|
+
- **Files out**: return a `File` (or a `list[File]`) and people get download buttons. To hand back files alongside another result, call `save_file(path_or_bytes, name=...)`. Files are kept for 24 hours by default.
|
|
40
|
+
- **Secrets** you list are injected as environment variables at run time (`os.environ["SALES_API_TOKEN"]`); set their values in the Callables console.
|
|
41
|
+
- **Schedules** are cron expressions, in UTC.
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from typing import Annotated
|
|
45
|
+
from callables import deploy, File, accept, save_file
|
|
46
|
+
|
|
47
|
+
@deploy(title="Clean up a sales sheet")
|
|
48
|
+
def clean(sheet: Annotated[File, accept(".csv")]) -> File:
|
|
49
|
+
rows = sheet.read_text().splitlines()
|
|
50
|
+
save_file(f"{len(rows)} rows".encode(), name="summary.txt") # an extra download
|
|
51
|
+
out = "/tmp/clean.csv"
|
|
52
|
+
...
|
|
53
|
+
return File(out, name=f"clean-{sheet.name}")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Try a function on your machine before pushing:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
call check # problems a deploy would hit, with fixes (exit 1 on errors)
|
|
60
|
+
call weekly_report --region EMEA --days 30 # every function is also a CLI command
|
|
61
|
+
call local weekly_report -i region=EMEA -i days=30
|
|
62
|
+
call local clean -i sheet=@sales.csv # @path passes a file; outputs land in ./callables-output/
|
|
63
|
+
call manifest # what the platform will see
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Coding agents
|
|
67
|
+
|
|
68
|
+
The SDK carries its own guide for Claude Code, Cursor, Codex and other agents:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
call init # an example automation, .claude/skills/callables/SKILL.md and an AGENTS.md section
|
|
72
|
+
call docs # print the full guide
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`call check --json` and `call local ... --json` print one JSON object on stdout, so an agent can check and run functions and read the result. Re-run `call init` after upgrading the SDK to refresh the instructions. Your own files and the rest of AGENTS.md are left alone.
|
|
76
|
+
|
|
77
|
+
On the platform, builds use the server's own copy of this package, so you don't need to add `callables` to your `requirements.txt` (it's fine if you do).
|
|
78
|
+
|
|
79
|
+
## Developing the SDK
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
|
|
83
|
+
.venv/bin/pytest
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The platform (shipfn-api) depends on this package and copies the installed version into every build, so a release only reaches deployed functions once the API is updated to it. See [RELEASING.md](RELEASING.md).
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Callables Python SDK
|
|
2
|
+
|
|
3
|
+
Turn Python functions into forms, schedules and run history your whole team can use. Decorate a function with `@deploy`, push the repo, and people run it from the Callables web app — no Python needed on their end.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install callables
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
This installs the `call` command. Code written for the SDK's old name, `shipfn`, keeps working: `from shipfn import deploy` and the `shipfn` command are aliases for this package.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from typing import Literal
|
|
13
|
+
from callables import deploy
|
|
14
|
+
|
|
15
|
+
@deploy(title="Weekly report", secrets=["SALES_API_TOKEN"], schedule="0 9 * * MON")
|
|
16
|
+
def weekly_report(region: Literal["NA", "EMEA", "APAC"] = "NA", days: int = 7) -> list[dict]:
|
|
17
|
+
"""Deals closed in the last N days."""
|
|
18
|
+
...
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- **Inputs** come from the type hints: `str`, `int`, `float`, `bool`, `date`, `Literal[...]`/`Enum` (dropdowns), `list[...]`, optional (`X | None`) and Pydantic models. Defaults become prefilled values.
|
|
22
|
+
- **Results** are shown by return type: `str` as Markdown, `list[dict]` (or a DataFrame) as a table with CSV download, `dict`/Pydantic model as a key-value list, `None` as "done".
|
|
23
|
+
- **Files in**: a `File` parameter becomes an upload field. Your function gets a `File` with `.path`, `.name`, `.read_text()`, `.read_bytes()` and `.open()`. Limit the picker with `Annotated[File, accept(".csv", ".xlsx")]`.
|
|
24
|
+
- **Files out**: return a `File` (or a `list[File]`) and people get download buttons. To hand back files alongside another result, call `save_file(path_or_bytes, name=...)`. Files are kept for 24 hours by default.
|
|
25
|
+
- **Secrets** you list are injected as environment variables at run time (`os.environ["SALES_API_TOKEN"]`); set their values in the Callables console.
|
|
26
|
+
- **Schedules** are cron expressions, in UTC.
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from typing import Annotated
|
|
30
|
+
from callables import deploy, File, accept, save_file
|
|
31
|
+
|
|
32
|
+
@deploy(title="Clean up a sales sheet")
|
|
33
|
+
def clean(sheet: Annotated[File, accept(".csv")]) -> File:
|
|
34
|
+
rows = sheet.read_text().splitlines()
|
|
35
|
+
save_file(f"{len(rows)} rows".encode(), name="summary.txt") # an extra download
|
|
36
|
+
out = "/tmp/clean.csv"
|
|
37
|
+
...
|
|
38
|
+
return File(out, name=f"clean-{sheet.name}")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Try a function on your machine before pushing:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
call check # problems a deploy would hit, with fixes (exit 1 on errors)
|
|
45
|
+
call weekly_report --region EMEA --days 30 # every function is also a CLI command
|
|
46
|
+
call local weekly_report -i region=EMEA -i days=30
|
|
47
|
+
call local clean -i sheet=@sales.csv # @path passes a file; outputs land in ./callables-output/
|
|
48
|
+
call manifest # what the platform will see
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Coding agents
|
|
52
|
+
|
|
53
|
+
The SDK carries its own guide for Claude Code, Cursor, Codex and other agents:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
call init # an example automation, .claude/skills/callables/SKILL.md and an AGENTS.md section
|
|
57
|
+
call docs # print the full guide
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`call check --json` and `call local ... --json` print one JSON object on stdout, so an agent can check and run functions and read the result. Re-run `call init` after upgrading the SDK to refresh the instructions. Your own files and the rest of AGENTS.md are left alone.
|
|
61
|
+
|
|
62
|
+
On the platform, builds use the server's own copy of this package, so you don't need to add `callables` to your `requirements.txt` (it's fine if you do).
|
|
63
|
+
|
|
64
|
+
## Developing the SDK
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
|
|
68
|
+
.venv/bin/pytest
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The platform (shipfn-api) depends on this package and copies the installed version into every build, so a release only reaches deployed functions once the API is updated to it. See [RELEASING.md](RELEASING.md).
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# Writing Callables automations
|
|
2
|
+
|
|
3
|
+
Callables turns Python functions into web forms that non-technical teammates run. A developer decorates a function with `@deploy` and pushes the repo. The platform builds it and shows each function as a form, generated from the type hints, with run history. People can also schedule runs from the web app.
|
|
4
|
+
|
|
5
|
+
The person running an automation can't see the code, a traceback or a terminal. Everything they see comes from the decorator, the docstring, the parameter names and types, and the return value. Write for them.
|
|
6
|
+
|
|
7
|
+
## The loop
|
|
8
|
+
|
|
9
|
+
1. Write or change a function decorated with `@deploy`.
|
|
10
|
+
2. `call check` (or `call check --json`) lists every problem a deploy would hit, with a fix for each. Exit code 1 means the deploy would fail.
|
|
11
|
+
3. `call local <name> -i key=value --json` runs the function on this machine, with the same input validation as the platform.
|
|
12
|
+
4. Push. The platform builds and deploys on push.
|
|
13
|
+
|
|
14
|
+
Run `call check` before every push. If any module fails to import, nothing is published.
|
|
15
|
+
|
|
16
|
+
### First-time setup
|
|
17
|
+
|
|
18
|
+
1. Install the CLI into the project's virtualenv: `pip install callables`. Keep it out of `requirements.txt`, because the platform adds its own copy.
|
|
19
|
+
2. The project must be a GitHub repo. Callables deploys a repo and branch, not a local folder. Commit and push it first, with `.env`, key files and data kept out by `.gitignore`.
|
|
20
|
+
3. In the web app, open **New project**. Pick the repo and branch (install the GitHub App when asked), then **Connect and deploy**. The first deploy starts right away.
|
|
21
|
+
4. Set the secrets your functions declare on the project page. After that, every push to the branch deploys.
|
|
22
|
+
|
|
23
|
+
## Anatomy
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from datetime import date
|
|
27
|
+
from typing import Literal
|
|
28
|
+
|
|
29
|
+
from callables import deploy
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@deploy(title="Closed deals", secrets=["CRM_TOKEN"], timeout=120)
|
|
33
|
+
def closed_deals(region: Literal["NA", "EMEA", "APAC"] = "NA", since: date | None = None,
|
|
34
|
+
min_value: int = 0) -> list[dict]:
|
|
35
|
+
"""Deals closed in a region, newest first.
|
|
36
|
+
|
|
37
|
+
Pulls from the CRM; amounts are in USD.
|
|
38
|
+
"""
|
|
39
|
+
...
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- `name`: the function's identity on the platform. It defaults to the function name. **Renaming it creates a new automation**, and the old one's history and schedules stay behind. Pass `name="..."` to keep the identity if you rename the Python function.
|
|
43
|
+
- `title`: the card and page heading. It defaults to the name, turned into a sentence.
|
|
44
|
+
- `description`: the first paragraph of the docstring is the default. Write one plain-language sentence about what it does for the person.
|
|
45
|
+
- `secrets`: names of environment variables, which builders set in the web app. Read them with `os.environ["CRM_TOKEN"]`. Never hard-code credentials and never take them as inputs. A declared secret that nobody has set doesn't block the run: the variable is simply missing, and builders see a "missing secrets" warning. For an optional secret, declare it and read it with `os.environ.get(...)`. Each secret is a single value, so a key file such as a `.pem` goes in as the file's contents, and the code reads it from the variable.
|
|
46
|
+
- `timeout`: in seconds. The default is 300 and the maximum is 900.
|
|
47
|
+
- `schedule`: a cron expression in UTC, for schedules owned by the code. Only use it when the user asks for one. Anyone can add a schedule in the web app, so you usually don't need it.
|
|
48
|
+
|
|
49
|
+
## Groups: organise related functions
|
|
50
|
+
|
|
51
|
+
A project (one repo) can hold many functions. Put related ones in a `Group`, like a Typer sub-app. People see them as one section of the project in the web app, and you run them as `call <group> <action>`.
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from typing import Literal
|
|
55
|
+
|
|
56
|
+
from callables import Group
|
|
57
|
+
|
|
58
|
+
backups = Group("backups", title="Database backups", description="Postgres dumps to object storage",
|
|
59
|
+
secrets=["SPACES_KEY", "SPACES_SECRET"]) # given to every action in the group
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@backups.deploy(title="Back up now", secrets=["DATABASE_URL"])
|
|
63
|
+
def run(env: Literal["prod", "staging"] = "prod", keep: int = 7) -> str:
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@backups.deploy(title="List backups")
|
|
68
|
+
def list_all(days: int = 30) -> list[dict]:
|
|
69
|
+
...
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
call backups --help # the group's actions
|
|
74
|
+
call backups run --help # flags, from the type hints
|
|
75
|
+
call backups run --env staging --keep 3
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- A group name is lowercase letters, digits, `-` or `_`, and can't be a built-in command (`check`, `local`, ...). Define each group once and import it wherever you need it.
|
|
79
|
+
- An action's full name is `<group>.<name>`, e.g. `backups.run`. Use that with `call local`.
|
|
80
|
+
- A function's name is its identity on the platform. Moving an existing function into a group gives it a new name, so it starts with fresh run history and sharing settings.
|
|
81
|
+
- A plain `@deploy` still works. Those functions appear in the project without a section, and run as `call <name> --flags`.
|
|
82
|
+
|
|
83
|
+
### Local commands
|
|
84
|
+
|
|
85
|
+
Some tools only make sense on your own machine: they read local files, write reports to disk or open a browser. Write them with `@command` (or `@group.command`) instead of `@deploy`, so the project has one CLI and no separate Typer app:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from callables import Group
|
|
89
|
+
|
|
90
|
+
audits = Group("audits", title="Audits")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@audits.command
|
|
94
|
+
def render(slug: str, open_browser: bool = False) -> str:
|
|
95
|
+
...
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- They run as `call audits render --slug x` and with `call local`, with flags from the type hints, like any action. `call check` lists them as "(local only)".
|
|
99
|
+
- They're never deployed: the platform doesn't see them and nobody can run them from the web app. `@command` takes `name`, `title`, `description` and `next`, but no `secrets`, `schedule` or `timeout`, because it runs in your own environment (read secrets from `.env`). A deployed action's `next=` can't point at one.
|
|
100
|
+
- Their module is still imported when the platform builds, so keep imports that only work locally inside the function.
|
|
101
|
+
- Return the result instead of printing it. The CLI prints what you return (a table as aligned columns in a terminal, JSON when piped), so printing it as well shows it twice. Keep prints for progress.
|
|
102
|
+
- To deploy one later, change `@command` to `@deploy`. It keeps its name.
|
|
103
|
+
|
|
104
|
+
## Flows: what to do next with a result
|
|
105
|
+
|
|
106
|
+
Chain actions into step-by-step flows with `next=`. Each row of a table result (or a single dict/model result) offers the next actions, with their inputs filled in from that row. Every step is still an ordinary action: it can be run on its own, scheduled or shared separately.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from typing import Literal
|
|
110
|
+
|
|
111
|
+
from callables import Group, Next
|
|
112
|
+
|
|
113
|
+
users = Group("users", title="Users")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@users.deploy(title="Make admin")
|
|
117
|
+
def make_admin(user_id: int) -> str: ...
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@users.deploy(title="Subscription requirement")
|
|
121
|
+
def set_subscription(user_id: int, required: Literal["Required", "Not required"]) -> str: ...
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@users.deploy(title="Find user", next=[make_admin, Next(set_subscription, label="Change subscription", user_id="id")])
|
|
125
|
+
def search(query: str) -> list[dict]:
|
|
126
|
+
return [{"id": 42, "user_id": 42, "name": "Ann Lee", "email": "ann@example.com"}]
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
- An action's inputs are filled from result fields with the same name. `Next(action, user_id="id")` maps a field with a different name, and `label=` changes the button text.
|
|
130
|
+
- `next` takes deployed functions, or names (`"make_admin"`, or `"users.make_admin"` for another group's action) when the action is defined further down.
|
|
131
|
+
- In the web app, each result row gets a button per next action. It opens that action's form with the row's values filled in, and the new run links back to the one it came from.
|
|
132
|
+
- In a terminal, `call users search --query ann` lists the rows, asks which one and what to do next, prompts for the inputs the row didn't fill, and runs it. Press Enter to stop. Scripts and `--json` get plain output with no prompts.
|
|
133
|
+
- People only see next actions they're allowed to run.
|
|
134
|
+
|
|
135
|
+
## Inputs: type hints become the form
|
|
136
|
+
|
|
137
|
+
| Type hint | Form field |
|
|
138
|
+
|---|---|
|
|
139
|
+
| `str` | text box |
|
|
140
|
+
| `int`, `float` | number |
|
|
141
|
+
| `bool` | checkbox |
|
|
142
|
+
| `date`, `datetime` | date or date-time picker |
|
|
143
|
+
| `Literal["a", "b"]` or an `Enum` | dropdown |
|
|
144
|
+
| `list[str]` and so on | list editor |
|
|
145
|
+
| `X \| None = None` | optional field |
|
|
146
|
+
| a Pydantic model | grouped sub-form |
|
|
147
|
+
| `File`, or `Annotated[File, accept(".csv")]` | file upload |
|
|
148
|
+
|
|
149
|
+
- A default value makes the field optional and prefills it. A parameter without a default is required.
|
|
150
|
+
- Parameter names become labels: `min_value` shows as "Min value". Choose names that read well.
|
|
151
|
+
- Prefer `Literal[...]` over free text whenever the valid values are known.
|
|
152
|
+
- Not supported: `*args`, `**kwargs`, `Callable` or arbitrary classes. `call check` names the offending parameter.
|
|
153
|
+
- Avoid `dict` inputs. They show as a raw JSON box, so use a Pydantic model instead.
|
|
154
|
+
- Inputs are validated and coerced before your function runs. A `date` arrives as a `datetime.date`, and a model arrives as an instance.
|
|
155
|
+
|
|
156
|
+
## Results: the return type picks the display
|
|
157
|
+
|
|
158
|
+
| Return annotation | Shown as |
|
|
159
|
+
|---|---|
|
|
160
|
+
| `-> str` | Markdown (headings, lists, links and tables work) |
|
|
161
|
+
| `-> list[dict]`, a list of models, or a DataFrame | a table with CSV download |
|
|
162
|
+
| `-> dict` or a Pydantic model | a key-value list |
|
|
163
|
+
| `-> File` or `-> list[File]` | download buttons |
|
|
164
|
+
| `-> None` | "Done" |
|
|
165
|
+
| missing or anything else | raw JSON (avoid this) |
|
|
166
|
+
|
|
167
|
+
Always annotate the return type. Keep table rows flat (scalar values) and use readable keys, because the keys become column headers.
|
|
168
|
+
|
|
169
|
+
## Files
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
from typing import Annotated
|
|
173
|
+
|
|
174
|
+
from callables import File, accept, deploy, save_file
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@deploy(title="Clean up a contact list")
|
|
178
|
+
def clean_contacts(sheet: Annotated[File, accept(".csv")]) -> File:
|
|
179
|
+
"""Removes duplicate rows and fixes capitalisation."""
|
|
180
|
+
rows = sheet.read_text().splitlines() # also: .read_bytes(), .open(), .path, .name, .size
|
|
181
|
+
save_file(f"{len(rows)} rows in".encode(), name="summary.txt") # an extra download
|
|
182
|
+
out = "/tmp/clean.csv"
|
|
183
|
+
...
|
|
184
|
+
return File(out, name=f"clean-{sheet.name}")
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
- A `File` parameter is an upload field. Use `accept(...)` to limit the file types.
|
|
188
|
+
- Returning `File` gives the person a download. `save_file(path_or_bytes, name=...)` adds more downloads alongside any return value. It needs `name=` when you pass bytes.
|
|
189
|
+
- Locally, pass a file with `-i sheet=@path/to/file.csv`. Outputs land in `./callables-output/`.
|
|
190
|
+
- Limits: 100 MB per upload, and 100 MB and 20 files of output per run. Files are deleted after 24 hours.
|
|
191
|
+
- Functions with file inputs can't be scheduled.
|
|
192
|
+
|
|
193
|
+
## Runtime environment
|
|
194
|
+
|
|
195
|
+
- Each run gets a fresh sandbox on Debian 13 (trixie). It runs as a non-root user and has internet access.
|
|
196
|
+
- **Python version:** taken from `.python-version` (e.g. `3.13`), else the newest version your `pyproject.toml` `requires-python` allows, else 3.12. Supported: 3.10 to 3.14.
|
|
197
|
+
- **Python dependencies** come from `requirements.txt` at the repo root, or from `[project].dependencies` in `pyproject.toml`. **Don't list `callables`.** The platform installs its own matching copy.
|
|
198
|
+
- **Automations in a subfolder:** by default the whole repo is built. To build only one folder, add `callables.toml` at the repo root containing `root = "automations"` (or `[tool.callables] root = "automations"` in `pyproject.toml`). Then only that folder goes into the image, and its own `requirements.txt`, `apt.txt` and `.python-version` are used. Code in it can't import from the rest of the repo. `call check`, `local` and `manifest` use the same folder when run from the repo root.
|
|
199
|
+
- **Writable space:** your home directory (`~`), and `/tmp`, which is limited to 256 MB. Your code in `/app` is read-only. Every run starts fresh, so nothing persists between runs. Keep state in your own database or storage.
|
|
200
|
+
- **Running downloaded binaries:** `/tmp` doesn't allow running programs, so a binary downloaded there fails with `Permission denied`. Put it under `~` and `chmod +x` it. Better still, install the tool with `apt.txt` (below).
|
|
201
|
+
- Every module that imports `callables` and mentions `deploy` is imported at build time. Keep import-time code cheap and free of side effects: no network calls, and no reading secrets at module level. Read secrets inside the function.
|
|
202
|
+
- `print()` output and `logging` show as the run's logs. Tracebacks are shown only to builders, never to the person running the automation.
|
|
203
|
+
|
|
204
|
+
## System packages
|
|
205
|
+
|
|
206
|
+
When a function shells out to a command-line tool (a converter, a database client, a headless browser), list the Debian packages it needs in `apt.txt` at the repo root:
|
|
207
|
+
|
|
208
|
+
```
|
|
209
|
+
# apt.txt: one package per line
|
|
210
|
+
ffmpeg
|
|
211
|
+
poppler-utils
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
- Names only, no versions. You get the version Debian 13 (trixie) ships, which stays the same apart from security fixes. If the tool talks to an outside service, check that this version works with it.
|
|
215
|
+
- Packages are installed without their "recommended" extras. If a tool is missing something (fonts, for example), list that package too.
|
|
216
|
+
- They're installed once per change to `apt.txt`, not on every push.
|
|
217
|
+
- `call local` uses the tools on your own machine, so install them there too to test.
|
|
218
|
+
|
|
219
|
+
## Memory and CPU
|
|
220
|
+
|
|
221
|
+
You don't size anything. A run can use memory and CPU up to your organisation's plan limit, and usage is billed on what the run actually used, not on the limit. Builders see each run's peak memory and CPU time on its page.
|
|
222
|
+
|
|
223
|
+
If a run goes over the memory limit, it fails with an error that says so. Fix it by using less memory (process files in chunks, stream instead of loading everything, free large objects you're done with), or move to a bigger plan. Child processes count toward the same limit.
|
|
224
|
+
|
|
225
|
+
## Errors people will see
|
|
226
|
+
|
|
227
|
+
Raise exceptions with messages written for the person running the automation. They see `ValueError: No deals found for EMEA since 2026-01-01`, so make it say what to change. For bad input, raise `ValueError` with a message about that field.
|
|
228
|
+
|
|
229
|
+
## Commands
|
|
230
|
+
|
|
231
|
+
- `call check [--json]`: problems a deploy would hit, with fixes. Exit code 1 on errors.
|
|
232
|
+
- `call <group> <action> [--flags]` (or `call <function> [--flags]`): run a function like a CLI command. Each input is a flag (`days` becomes `--days`, a bool becomes `--dry-run/--no-dry-run`, a list takes several values). Add `--help` to see them.
|
|
233
|
+
- `call local <name> [-i key=value ...] [--json]`: run a function on this machine. Values are parsed as JSON when possible, and `@path` passes a file. `--json` prints one JSON object on stdout (`ok`, `value`, `files`, `error`, `field_errors`), and the function's own prints go to stderr.
|
|
234
|
+
- `call manifest`: the full JSON the platform reads, including each input's JSON Schema. Local `@command`s are left out.
|
|
235
|
+
- `call docs`: prints this guide.
|
|
236
|
+
- `call init`: an example automation plus agent instructions (`.claude/skills/callables/SKILL.md` and an `AGENTS.md` section). Run it again after upgrading the SDK to refresh the instructions.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Callables SDK: mark functions with @deploy and push the repo; the platform does the rest."""
|
|
2
|
+
from callables.decorator import DeployedFunction, Group, Next, command, deploy, registry
|
|
3
|
+
from callables.files import File, accept, save_file
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
__all__ = ["deploy", "command", "Group", "Next", "DeployedFunction", "registry", "File", "accept", "save_file", "__version__"]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""`call check`: the problems a deploy would hit, found on this machine before pushing.
|
|
2
|
+
|
|
3
|
+
Errors match what makes the platform reject a deploy; warnings match what it would ignore or cap.
|
|
4
|
+
"""
|
|
5
|
+
import inspect
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from callables.decorator import registry
|
|
11
|
+
from callables.files import FILE_FORMAT
|
|
12
|
+
from callables.manifest import build_manifest
|
|
13
|
+
|
|
14
|
+
MAX_TIMEOUT = 900 # the platform caps timeouts here
|
|
15
|
+
SECRET_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
16
|
+
CRON_FIELD = re.compile(r"^[\d*/,\-A-Za-z?]+$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _issue(level: str, message: str, function: str | None = None, fix: str | None = None) -> dict[str, Any]:
|
|
20
|
+
issue: dict[str, Any] = {"level": level, "message": message}
|
|
21
|
+
if function:
|
|
22
|
+
issue["function"] = function
|
|
23
|
+
if fix:
|
|
24
|
+
issue["fix"] = fix
|
|
25
|
+
return issue
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _file_fields(schema: dict[str, Any]) -> list[str]:
|
|
29
|
+
return [
|
|
30
|
+
name for name, prop in (schema.get("properties") or {}).items()
|
|
31
|
+
if any(o.get("format") == FILE_FORMAT for o in [prop, *prop.get("anyOf", [])])
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _check_function(fn: dict[str, Any]) -> list[dict[str, Any]]:
|
|
36
|
+
name, issues = fn["name"], []
|
|
37
|
+
schema = fn["input_schema"]
|
|
38
|
+
deployed = registry.get(name)
|
|
39
|
+
|
|
40
|
+
if not fn.get("description") and not fn.get("local_only"):
|
|
41
|
+
issues.append(_issue("warning", "No description; people see a blank card", name,
|
|
42
|
+
"Add a docstring: its first paragraph becomes the description"))
|
|
43
|
+
if deployed is not None:
|
|
44
|
+
untyped = [p for p in inspect.signature(deployed.func).parameters if p not in deployed.func.__annotations__]
|
|
45
|
+
for param in untyped:
|
|
46
|
+
issues.append(_issue("warning", f"Parameter '{param}' has no type hint, so it's a plain text field", name,
|
|
47
|
+
f"Annotate it, e.g. `{param}: int` or `{param}: Literal['a', 'b']`"))
|
|
48
|
+
if "return" not in deployed.func.__annotations__:
|
|
49
|
+
issues.append(_issue("warning", "No return type, so the result is shown as raw JSON", name,
|
|
50
|
+
"Annotate the return: -> str (Markdown), -> list[dict] (table), -> dict (key-value), "
|
|
51
|
+
"-> File (download) or -> None"))
|
|
52
|
+
|
|
53
|
+
if fn["timeout"] > MAX_TIMEOUT:
|
|
54
|
+
issues.append(_issue("warning", f"timeout={fn['timeout']} is above the {MAX_TIMEOUT}s limit and will be capped",
|
|
55
|
+
name, f"Use timeout={MAX_TIMEOUT} or less"))
|
|
56
|
+
elif fn["timeout"] <= 0:
|
|
57
|
+
issues.append(_issue("error", "timeout must be a positive number of seconds", name))
|
|
58
|
+
|
|
59
|
+
for secret in fn.get("secrets") or []:
|
|
60
|
+
if not SECRET_NAME.match(secret):
|
|
61
|
+
issues.append(_issue("error", f"Secret name '{secret}' isn't a valid environment variable name", name,
|
|
62
|
+
"Use letters, digits and underscores, e.g. SALES_API_TOKEN"))
|
|
63
|
+
|
|
64
|
+
if fn.get("next") and fn["output_kind"] not in ("table", "kv"):
|
|
65
|
+
issues.append(_issue("warning", "next actions are offered on table rows or a single record, so they won't "
|
|
66
|
+
"show for this result", name, "Return list[dict] (rows) or a dict/model (one record)"))
|
|
67
|
+
|
|
68
|
+
schedule = fn.get("schedule")
|
|
69
|
+
if schedule:
|
|
70
|
+
fields = schedule.split()
|
|
71
|
+
if len(fields) != 5 or not all(CRON_FIELD.match(f) for f in fields):
|
|
72
|
+
issues.append(_issue("warning", f"schedule '{schedule}' isn't a 5-field cron expression and will be ignored",
|
|
73
|
+
name, "e.g. schedule='0 9 * * MON' (minute hour day month weekday, UTC)"))
|
|
74
|
+
if schema.get("required"):
|
|
75
|
+
issues.append(_issue("warning", "Scheduled functions need a default for every input; the schedule will "
|
|
76
|
+
f"be ignored (no default: {', '.join(schema['required'])})", name))
|
|
77
|
+
if _file_fields(schema):
|
|
78
|
+
issues.append(_issue("warning", "Functions with file inputs can't run on a schedule", name,
|
|
79
|
+
"Drop the schedule, or read the file from storage instead of an upload"))
|
|
80
|
+
return issues
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def check_project(root: Path) -> dict[str, Any]:
|
|
84
|
+
manifest = build_manifest(root, include_local=True)
|
|
85
|
+
issues = []
|
|
86
|
+
for err in manifest["errors"]:
|
|
87
|
+
where = err.get("module") or err.get("function")
|
|
88
|
+
issues.append(_issue("error", f"{where}: {err['error']}", err.get("function"),
|
|
89
|
+
"Fix this first: if any module fails to import, the platform publishes nothing"))
|
|
90
|
+
if not manifest["functions"] and not manifest["errors"]:
|
|
91
|
+
issues.append(_issue("error", "No @deploy functions found",
|
|
92
|
+
fix="Decorate a function: `from callables import deploy` then `@deploy` above `def ...`"))
|
|
93
|
+
elif manifest["functions"] and all(f.get("local_only") for f in manifest["functions"]):
|
|
94
|
+
issues.append(_issue("warning", "Every function is a local @command, so a deploy publishes nothing",
|
|
95
|
+
fix="Use @deploy for anything teammates should run"))
|
|
96
|
+
for fn in manifest["functions"]:
|
|
97
|
+
issues.extend(_check_function(fn))
|
|
98
|
+
if not (Path(root) / "requirements.txt").is_file() and not (Path(root) / "pyproject.toml").is_file():
|
|
99
|
+
issues.append(_issue("warning", "No requirements.txt, so only the standard library is installed",
|
|
100
|
+
fix="List your dependencies (not callables itself) in requirements.txt"))
|
|
101
|
+
return {
|
|
102
|
+
"ok": not any(i["level"] == "error" for i in issues),
|
|
103
|
+
"functions": [
|
|
104
|
+
{"name": f["name"], "title": f["title"], "entrypoint": f["entrypoint"],
|
|
105
|
+
"inputs": f["input_schema"].get("x-order", []), "output_kind": f["output_kind"],
|
|
106
|
+
"local_only": bool(f.get("local_only"))}
|
|
107
|
+
for f in manifest["functions"]
|
|
108
|
+
],
|
|
109
|
+
"issues": issues,
|
|
110
|
+
}
|