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.
Files changed (33) hide show
  1. callables-0.1.0/PKG-INFO +86 -0
  2. callables-0.1.0/README.md +71 -0
  3. callables-0.1.0/callables/GUIDE.md +236 -0
  4. callables-0.1.0/callables/__init__.py +6 -0
  5. callables-0.1.0/callables/__main__.py +5 -0
  6. callables-0.1.0/callables/checks.py +110 -0
  7. callables-0.1.0/callables/cli.py +335 -0
  8. callables-0.1.0/callables/commands.py +121 -0
  9. callables-0.1.0/callables/decorator.py +234 -0
  10. callables-0.1.0/callables/files.py +134 -0
  11. callables-0.1.0/callables/init.py +121 -0
  12. callables-0.1.0/callables/manifest.py +75 -0
  13. callables-0.1.0/callables/project.py +54 -0
  14. callables-0.1.0/callables/py.typed +0 -0
  15. callables-0.1.0/callables/runtime.py +77 -0
  16. callables-0.1.0/callables/schema.py +77 -0
  17. callables-0.1.0/callables.egg-info/PKG-INFO +86 -0
  18. callables-0.1.0/callables.egg-info/SOURCES.txt +31 -0
  19. callables-0.1.0/callables.egg-info/dependency_links.txt +1 -0
  20. callables-0.1.0/callables.egg-info/entry_points.txt +4 -0
  21. callables-0.1.0/callables.egg-info/requires.txt +4 -0
  22. callables-0.1.0/callables.egg-info/top_level.txt +2 -0
  23. callables-0.1.0/pyproject.toml +40 -0
  24. callables-0.1.0/setup.cfg +4 -0
  25. callables-0.1.0/shipfn/__init__.py +10 -0
  26. callables-0.1.0/shipfn/__main__.py +5 -0
  27. callables-0.1.0/tests/test_agent_tools.py +159 -0
  28. callables-0.1.0/tests/test_command.py +86 -0
  29. callables-0.1.0/tests/test_files.py +85 -0
  30. callables-0.1.0/tests/test_groups.py +138 -0
  31. callables-0.1.0/tests/test_next.py +132 -0
  32. callables-0.1.0/tests/test_project.py +60 -0
  33. callables-0.1.0/tests/test_sdk.py +142 -0
@@ -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,5 @@
1
+ import sys
2
+
3
+ from callables.cli import main
4
+
5
+ sys.exit(main())
@@ -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
+ }