Skip to content

Commit 760b0ea

Browse files
simontreanorclaude
andcommitted
Examples: extend interop cookbook with effects (pathlib, requests)
Two entries centered on inferred effects — Pyfun's differentiator: - read_files (pathlib): offline-runnable io-effect showcase; effect inference + propagation, the `let pure` rejection as the guarantee, and try→Result on a missing file. - http_fetch (requests/httpx): check-valid effects/async entry (needs requests + network to run); io from requests.get, `->{async}` for httpx, and the compile-time effect guarantee. Building these dogfooded four extern-FFI reach gaps (submodule imports, nullary calls, builtin-type dotted targets, property access) — documented in the cookbook README's limits and added as a consolidated ROADMAP item. All offline examples run end-to-end; http_fetch pyfun-checks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 570c2e8 commit 760b0ea

4 files changed

Lines changed: 113 additions & 10 deletions

File tree

ROADMAP.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ Keep this a *forward-looking* backlog — do not let it grow back into a changel
3939
- **`Format` module — dates follow-on** (S) — the numeric/string first cut shipped (`Format.fixed`/
4040
`thousands`/`percent`/`currency`/`grouped`/`padLeft`/`padRight`). `formatDate` is still open: it needs a
4141
date type or a Python `datetime` `extern`, so it was left out of the pure-stdlib first cut.
42+
- **Extern FFI reach** (M) — four related boundary gaps surfaced by the `examples/interop/` cookbook
43+
(dogfooding real Python libraries). None blocks the showcase — each has a workaround noted in that
44+
README — but together they decide how many popular libraries wrap cleanly: (1) the importer emits only
45+
the *first* dotted segment (`import urllib`), so a target inside a **submodule**
46+
(`urllib.request.urlopen`, `http.client.HTTPConnection`) fails at runtime — emit the full parent
47+
package path instead; (2) a **nullary** Python callable can't be invoked — `f : unit -> a` lowers
48+
`f ()` to `f(None)`, not `f()`; needs a zero-arg calling convention for a `unit` domain; (3) a dotted
49+
target on a **builtin type** (`bytes.decode`) emits `import bytes` and fails — recognize builtins;
50+
(4) object **properties/attributes** (`response.text`, `.status_code`) aren't reachable — the
51+
unbound-method trick only calls real methods, so an attribute-getter extern form (or `operator.attrgetter`
52+
sugar) would round it out. `DESIGN.md` §6.
4253
- **Larger prelude / package manager / macros** — added on demand. A future Python-side runtime package
4354
could default to `uv`.
4455

examples/interop/README.md

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
# Interop cookbook
22

3-
Short, runnable Pyfun programs that call **well-known Python libraries** and show what
4-
Pyfun's typed, effect-tracked boundary adds to the calling code. Each file runs with:
3+
Short Pyfun programs that call **well-known Python libraries** and show what Pyfun's
4+
typed, effect-tracked boundary adds to the calling code. Most run offline with:
55

66
```bash
77
pyfun run examples/interop/<name>.pyfun
88
```
99

10+
(`http_fetch.pyfun` is the exception — it `pyfun check`s offline but needs `requests`
11+
+ network to `run`; see the table.)
12+
1013
## The idea: boundary libraries, not engine libraries
1114

1215
Popular Python libraries split in two, and only one kind showcases Pyfun:
@@ -27,14 +30,13 @@ The honest headline is therefore **not** "rewrite the popular libraries in Pyfun
2730

2831
## Entries
2932

30-
| File | Library | Shows |
31-
|------|---------|-------|
32-
| [`json_decode.pyfun`](./json_decode.pyfun) | `json` (stdlib) | `try``Result` totality; homogeneous JSON → `List`/`Map`; total `Map.tryFind` lookup |
33-
| [`json_to_adt.pyfun`](./json_to_adt.pyfun) | `json` (stdlib) | **the headline** — decode a heterogeneous object into your own record, totally, via `result {}` railway composition (KeyError/ValueError → `Error`) |
34-
| [`sqlite_query.pyfun`](./sqlite_query.pyfun) | `sqlite3` (stdlib) | opaque handle types + unbound-method externs; rows as tuples; `List`/tuple decoding |
35-
36-
Planned: `http_fetch` (`requests`/`httpx``io`/`->{async}` effects, typed responses),
37-
`read_files` (`pathlib``io` effects).
33+
| File | Library | Runs offline | Shows |
34+
|------|---------|:---:|-------|
35+
| [`json_decode.pyfun`](./json_decode.pyfun) | `json` (stdlib) || `try``Result` totality; homogeneous JSON → `List`/`Map`; total `Map.tryFind` lookup |
36+
| [`json_to_adt.pyfun`](./json_to_adt.pyfun) | `json` (stdlib) || **the headline** — decode a heterogeneous object into your own record, totally, via `result {}` railway composition (KeyError/ValueError → `Error`) |
37+
| [`sqlite_query.pyfun`](./sqlite_query.pyfun) | `sqlite3` (stdlib) || opaque handle types + unbound-method externs; rows as tuples; `List`/tuple decoding |
38+
| [`read_files.pyfun`](./read_files.pyfun) | `pathlib` (stdlib) || inferred `io` effect + propagation; `let pure` rejection; `try``Result` on a missing file |
39+
| [`http_fetch.pyfun`](./http_fetch.pyfun) | `requests`/`httpx` | check-only | inferred `io` / `->{async}` effects; the effect *guarantee* (`let pure` over `io` is a compile error) |
3840

3941
## Reusable patterns (all verified against the current compiler)
4042

@@ -51,6 +53,9 @@ Planned: `http_fetch` (`requests`/`httpx` — `io`/`->{async}` effects, typed re
5153
(`operator.getitem`), coerce it (`int`/`str`), wrap each step in `try`, and compose on
5254
the `result {}` railway so the first bad field short-circuits to `Error`. See
5355
`json_to_adt.pyfun`; this is the shape a decoder-combinator library would generalize.
56+
- **Effects for free.** A plain `extern` is `io`; the checker infers and propagates it, so
57+
any function touching the boundary is `io` with no annotation, and `let pure` over it is
58+
a compile error. Override the boundary default with `->{async}` for async libraries.
5459

5560
## Honest limits (the frontier these examples expose)
5661

@@ -65,3 +70,10 @@ Planned: `http_fetch` (`requests`/`httpx` — `io`/`->{async}` effects, typed re
6570
wrapper library would pull in the deferred package manager).
6671
- **Anonymous record types** aren't accepted in an extern signature, so an ad-hoc request
6772
or response body needs a named `type`. (Tracked separately.)
73+
- **Extern FFI rough edges surfaced while building these** (tracked in `ROADMAP.md`): the
74+
importer emits only the *first* dotted segment (`import urllib`), so a target in a
75+
**submodule** (`urllib.request.urlopen`, `http.client`) fails at runtime — which is why
76+
the HTTP entry uses `requests`, not stdlib; a **nullary** Python function can't be called
77+
(`gettempdir ()` passes unit as an argument); a dotted target on a **builtin type**
78+
(`bytes.decode`) tries to `import bytes`; and object **properties** (`response.text`,
79+
`.status_code`) aren't reachable by the unbound-method trick (only real methods are).

examples/interop/http_fetch.pyfun

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Interop cookbook — HTTP: inferred effects (io / async), the Pyfun differentiator.
2+
#
3+
# pyfun check examples/interop/http_fetch.pyfun # type/effect-checks offline
4+
# pyfun run examples/interop/http_fetch.pyfun # needs `requests`+`httpx` + network
5+
#
6+
# NOTE: unlike the other cookbook entries this one is CHECK-VALID rather than
7+
# offline-runnable — it calls the third-party `requests`/`httpx` against the network.
8+
# The point it showcases is a *compile-time* guarantee anyway: Pyfun infers effects
9+
# and won't let you lie about them. (Why not a stdlib HTTP client? `urllib.request` /
10+
# `http.client` live in submodules the extern importer doesn't yet load — see the
11+
# cookbook README's limits.)
12+
13+
type Response = RespH
14+
15+
# A plain `extern` is `io` at the boundary. `requests.get` therefore contributes an
16+
# `io` effect wherever it is called — no annotation required.
17+
extern get: string -> Response = requests.get
18+
# `.json()` is a real method (unbound-method trick); it returns a raw parsed value,
19+
# the same opaque handle json_to_adt.pyfun decodes into a typed record.
20+
extern getJson: Response -> a = requests.Response.json
21+
22+
# `fetch` calls `get`, so the compiler INFERS `fetch : string -> Result Response
23+
# Exception` and that it performs `io` — impurity propagates outward automatically.
24+
# `try` makes a failed request an `Error` value instead of a raised exception.
25+
let fetch url = try (get url)
26+
27+
# The effect is a checked guarantee, not documentation. Uncommenting this is a
28+
# compile error — "`cached` is declared `pure` but performs `io`" — because `pure`
29+
# forbids the `io` that `get` introduces:
30+
#
31+
# let pure cached url = get url
32+
#
33+
# Async is a distinct effect. Annotating an extern arrow `->{async}` (overriding the
34+
# default `io`) marks `httpx.get` as performing `async`; any caller inherits it, so it
35+
# could not sit inside a `let pure` either. Async CEs (`async { let! r = … }`) then
36+
# compose these exactly as `result {}` composes the `Result`-returning steps elsewhere.
37+
extern getAsync: string ->{async} Response = httpx.get
38+
let fetchAsync url = getAsync url
39+
40+
# Full application collapses to a direct n-ary Python call — `fetch "https://…"`
41+
# lowers to `requests.get("https://…")` wrapped in try/except, no currying overhead.
42+
let example = fetch "https://example.com"
43+
let exampleJson = Result.map getJson example # Result a Exception, decode next

examples/interop/read_files.pyfun

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Interop cookbook — pathlib: file I/O, with effects inferred and failure made total.
2+
#
3+
# pyfun run examples/interop/read_files.pyfun
4+
#
5+
# `pathlib` is stdlib file I/O — the plainest kind of side effect. Two Pyfun features
6+
# show up here for free: the compiler *infers* that these calls are `io` and
7+
# propagates it (no annotation needed), and `try` turns a missing-file exception into
8+
# a `Result` you must handle. This file runs offline and cleans up after itself.
9+
10+
# `pathlib.Path(str)` builds a path; the rest are unbound methods on `pathlib.Path`
11+
# (see sqlite_query.pyfun for the pattern). Each is `io` by the boundary default.
12+
type Path = PathH
13+
extern toPath: string -> Path = pathlib.Path
14+
extern writeText: Path -> string -> int = pathlib.Path.write_text
15+
extern readText: Path -> string = pathlib.Path.read_text
16+
extern unlink: Path -> unit = pathlib.Path.unlink
17+
18+
# `roundTrip` calls `io` externs, so the compiler infers `roundTrip : string -> unit`
19+
# is itself `io` — impurity propagates outward with no annotation. Writing
20+
# `let pure roundTrip …` would be rejected: "`roundTrip` is declared `pure` but
21+
# performs `io`". That is the effect guarantee — purity is checked, not hoped for.
22+
let roundTrip name =
23+
let p = toPath name
24+
let _ = writeText p "hello from pyfun"
25+
let back = readText p
26+
let _ = unlink p
27+
back
28+
29+
# Reading a missing file is total: `try` catches the FileNotFoundError into `Error`,
30+
# so a bad path yields a value describing the failure rather than crashing.
31+
let readOrNote name =
32+
match try (readText (toPath name)):
33+
case Ok s: s
34+
case Error e: f"(could not read {name}: {e.errorKind})"
35+
36+
print (roundTrip "pyfun_interop_demo.txt") # hello from pyfun
37+
print (readOrNote "no_such_file.txt") # (could not read no_such_file.txt: FileNotFoundError)

0 commit comments

Comments
 (0)