Skip to content

Commit 31bb687

Browse files
committed
dfdf
1 parent b195398 commit 31bb687

65 files changed

Lines changed: 2138 additions & 375 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,95 @@
11
# Changelog
22

3+
## [2.6.0] - 2026-08-13
4+
5+
FuncToWeb moves onto `pytypehintweb 1.1.0`, and with it onto `pytypehint 1.0.0`.
6+
The annotation that marks a file parameter is now `FileHint`, and the division of
7+
labour around files is redrawn: the core stopped touching the filesystem, so
8+
everything that was a promise about the *real* file on disk — that it exists,
9+
that it is a file and not a directory, that its bytes fall between `min_size` and
10+
`max_size` — is now stated only where it is actually enforced. For FuncToWeb that
11+
is its own storage layer, which already did that work; nothing about uploads,
12+
references or the pending/promoted lifecycle is weaker than it was in 2.5.0.
13+
14+
### Changed
15+
- **Breaking:** `IsPathFile` is replaced by `FileHint`, re-exported from
16+
`func_to_web` with the same three fields (`extensions`, `min_size`,
17+
`max_size`). This is a clean cut: there is no alias, no deprecation warning and
18+
no support for `pytypehint 0.x`. Rename the import and the call —
19+
`Annotated[str, IsPathFile(extensions=(".png",))]` becomes
20+
`Annotated[str, FileHint(extensions=(".png",))]` — and nothing else changes.
21+
- **Breaking (behaviour):** `Signature.build()` no longer checks that a file
22+
value exists, that it is a file, or how large it is. In 2.5.0 an invocation
23+
carrying a path to a missing, oversized or undersized file was refused by the
24+
layer below; in 2.6.0 the core only checks the **extension** of the value it is
25+
given, and builds the arguments.
26+
- **Breaking (behaviour):** `FileHint.min_size` / `FileHint.max_size` no longer
27+
have a server-side second check during invocation. The browser applies them to
28+
a file it has just picked, because there it holds the bytes and knows
29+
`file.size`; a stored reference carries no bytes, the browser cannot weigh it
30+
and the core no longer stats it. Do not expect a `422` from `/invoke` for byte
31+
bounds on a reference. An application that needs an authoritative guarantee
32+
about stored content owns that policy itself — FuncToWeb does not invent one
33+
silently, and `max_upload_bytes` remains the endpoint policy it always was.
34+
- A file default written by the author as a server path is still refused unless
35+
it belongs to the storage directory, and is still published as a reference and
36+
never as a path. What changed is only what backs it: in 2.5.0 the core also
37+
refused a default that did not exist, so a stale default failed at build time.
38+
In 2.6.0 that default becomes a reference, and the resolver refuses it at
39+
invocation instead, when it fails to resolve.
40+
41+
### Fixed
42+
- `OpenForm` now forwards a union-valued prefill in the same transport a browser
43+
submit sends, so an opening whose target has a parameter of two or more union
44+
branches works. It answered `400` before, in every version that had the
45+
feature: the opening published the plan's own way of writing a default —
46+
`{"branch": …, "value": …}`, which names the control the page opens on — and
47+
the destination reads a prefill as a submit, where a branch is named by the
48+
value itself or not at all. Depending on the union the answer was
49+
`expected int | str, got dict` or `ambiguous value: wrap it as $type`. The
50+
conversion is the plan's own answer rather than a rule restated in FuncToWeb:
51+
each branch already carries the mode `pytypehintweb` chose for it, and the
52+
three modes emit what `form.js` emits — bare for `plain`,
53+
`{"$type": …, "$value": …}` for `wrapped`, `{"$type": …, …fields}` for
54+
`inline`. `X | None` was never affected and is unchanged: one real branch
55+
compiles no choice at all, so the two spellings already agreed.
56+
57+
### Unchanged, and now the only thing that says so
58+
- Reference syntax, separators, `..`, control characters, reserved names and
59+
markers, and length limits stay in `segment_of()` / `stored_of()`.
60+
- Confinement to the uploads directory, in both directions: `stored_of()`
61+
requires the resolved parent to be the storage root, and `reference_of()`
62+
refuses a path whose round trip through storage does not land back on itself,
63+
which is what keeps a symbolic link from escaping.
64+
- **Existence.** `stored_file()` promotes a pending upload or raises
65+
`FileNotFoundError`, so a reference that does not resolve never reaches the
66+
function. `/invoke` answers `422`; `/invoke-stream` answers `200` and carries
67+
the same error in its `result` event, as it does for every rejection once the
68+
stream is open. Either way the call is not made, and that refusal is a
69+
FuncToWeb storage rule — after 2.6.0, the only one on this path.
70+
- `max_upload_bytes` is authoritative and unchanged: it may reject early on
71+
`Content-Length`, but it never trusts it alone — the bytes are counted as they
72+
arrive, the transfer is cut the moment the limit is passed, the partial file is
73+
deleted and the answer is `413`.
74+
- The pending/promoted lifecycle, the sweeper, expiry and the reuse of a promoted
75+
reference without a second upload.
76+
- `OpenForm` still hands a file on to the next function as a reference, hidden
77+
and not re-uploaded; the local path never travels.
78+
79+
### Dependencies
80+
- `pytypehintweb==0.0.5` → `pytypehintweb==1.1.0`, which brings `pytypehint
81+
1.0.0` transitively. FuncToWeb still declares no direct dependency on
82+
`pytypehint`: it arrives through `pytypehintweb`, as it has since 2.3.0.
83+
- 1.1.0 delegates the portable decoding to the core inside its own `decode()`:
84+
it calls `schema.decode()` and then walks the decoded tree applying the
85+
`file_resolver` to each file node. The simplification happens in
86+
`pytypehintweb`; FuncToWeb implements none of it and its call sites are
87+
unchanged.
88+
- The mypy exception `untyped_calls_exclude = ["pytypehintweb"]` is removed:
89+
1.1.0 annotates `decode()`, so the package passes strict again with no
90+
exemption.
91+
- `starlette==0.49.3` and `uvicorn==0.38.0` are unchanged.
92+
393
## [2.5.0] - 2026-08-08
494

595
FuncToWeb now depends directly on Starlette instead of FastAPI. `app_of()`

‎README.md‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# FuncToWeb 2.5.0
1+
# FuncToWeb 2.6.0
22

33
[![PyPI version](https://img.shields.io/pypi/v/func-to-web.svg)](https://pypi.org/project/func-to-web/)
44
[![Python](https://img.shields.io/pypi/pyversions/func-to-web.svg)](https://pypi.org/project/func-to-web/)
@@ -299,11 +299,11 @@ from typing import Annotated
299299

300300
from PIL import Image
301301

302-
from func_to_web import Download, IsPathFile, OpenForm, run
302+
from func_to_web import Download, FileHint, OpenForm, run
303303

304304
ImagePath = Annotated[
305305
str,
306-
IsPathFile(extensions=(".png", ".jpg", ".jpeg")),
306+
FileHint(extensions=(".png", ".jpg", ".jpeg")),
307307
]
308308

309309

@@ -354,9 +354,9 @@ itself, and that name has to exist when `choose_image` is defined.
354354
```python
355355
from typing import Annotated
356356

357-
from func_to_web import IsPathFile, Label, Min, run
357+
from func_to_web import FileHint, Label, Min, run
358358

359-
AnyFile = Annotated[str, IsPathFile()]
359+
AnyFile = Annotated[str, FileHint()]
360360

361361
Dropped = Annotated[list[AnyFile], Min(1), Label("Files to send")]
362362

@@ -476,16 +476,16 @@ mini-app with the dict swapped for a store.
476476

477477
Those three libraries are the whole stack: nested forms, recursive validation,
478478
streaming, the file lifecycle and the published contract are all inside these
479-
lines (v2.5.0, `.py`/`.js`/`.css` under `src/`).
479+
lines (v2.6.0, `.py`/`.js`/`.css` under `src/`).
480480

481481
```text
482-
pytypehint 1,987 lines types, validation, defaults
483-
pytypehintweb 9,581 lines plan, widgets, transport (includes the JS/CSS)
484-
FuncToWeb 5,070 lines routes, execution, storage, /doc
485-
total ~16,000 lines
482+
pytypehint 2,740 lines types, validation, defaults
483+
pytypehintweb 9,468 lines plan, widgets, transport (includes the JS/CSS)
484+
FuncToWeb 5,071 lines routes, execution, storage, /doc
485+
total ~17,300 lines
486486
```
487487

488-
Each layer can be read on its own: pytypehint is about 2,000 lines of pure
488+
Each layer can be read on its own: pytypehint is under 3,000 lines of pure
489489
stdlib and takes an afternoon, and no layer needs the others to be understood.
490490
The whole set also fits inside the context window of a general-purpose AI: you
491491
can hand over an entire library, or the three of them, and ask it to review,
@@ -521,7 +521,7 @@ practice.
521521

522522
## Status
523523

524-
2.5.0 is a stable release: the public API is the one described in
524+
2.6.0 is a stable release: the public API is the one described in
525525
[`docs/`](docs/index.md), and the known limitations are listed in
526526
[limitations.md](docs/limitations.md).
527527

‎docs/architecture.md‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,25 @@ it every other request the server is handling. See
5252
The `file_resolver` is the only piece of that path that belongs to FuncToWeb,
5353
and it is the same resolver in both endpoints and in the prefill: `decode()`
5454
decides **where** a file is, walking the structure to any depth, while
55-
FuncToWeb decides **what** counts as a valid file for this server. Neither
56-
layer duplicates the other's work.
55+
FuncToWeb decides **what** counts as a valid file for this server.
56+
57+
That division is the whole story for a file, and no layer repeats another's
58+
work:
59+
60+
```text
61+
browser the extension and the size of the File the user just picked,
62+
from the plan, before anything is uploaded
63+
POST /upload the bytes on the wire, against max_upload_bytes
64+
file_resolver the reference belongs to the storage directory, no traversal
65+
and no symlink escape, the file is there → a local path
66+
pytypehint the extension of that path, and the arguments themselves
67+
the function receives the local path
68+
```
69+
70+
Because nothing is checked twice, nothing can be dropped either — and the
71+
converse holds as well: what no layer checks is not checked at all. The byte
72+
bounds of a `FileHint` are the browser's alone; see
73+
[files.md](files.md#what-filehint-checks-and-where).
5774

5875
## Where it is mounted
5976

‎docs/design/files.md‎

Lines changed: 95 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,86 @@
11
# Files: the upload endpoint, the reference and custody
22

33
Why `/upload`, the reference and the pending cycle are built the way they are.
4-
The contract — what `IsPathFile` certifies, the rejection and limit tables, the
5-
pending/promoted cycle and the border between path and reference — is in
6-
[files.md](../files.md).
4+
The contract — what `FileHint` checks and which layer checks it, the rejection
5+
and limit tables, the pending/promoted cycle and the border between path and
6+
reference — is in [files.md](../files.md).
77

88
## The two messages of a file default
99

1010
The two [messages](../files.md#declaring-a-file-default) quote different things,
11-
because they happen at different moments. The first is the core reading the
12-
string as the author typed it, before anything has looked at the disk, so it
13-
echoes that string whole. The second is FuncToWeb turning an already certified
14-
path into the reference it will publish, and by then the directory is the part
15-
that must not travel, so only the name is left.
16-
17-
## Why the endpoint does not apply the field limit
18-
19-
The `IsPathFile` limits belong to the parameter, and `/upload` does **not**
20-
apply them. This is not an oversight:
11+
because two different layers say them. The first is the core reading the string
12+
as the author typed it, before anything has looked at the disk, so it echoes
13+
that string whole, directory included — the author is the one who wrote it, and
14+
it never leaves the process. The second is FuncToWeb turning that path into the
15+
reference it will publish, and by then the directory is the part that must not
16+
travel, so only the name is left.
17+
18+
Neither of them touches the disk, and that is the honest shape of the check: the
19+
core compares an extension, FuncToWeb asks whether the name round-trips inside
20+
the storage directory. A default naming a file that is simply not there
21+
satisfies both. It is not a gap the build could close cheaply either — the path
22+
is read once, when the `WebFunction` is compiled, and a file that exists then
23+
can be gone by the time the first request arrives, so the answer that matters is
24+
the one the resolver gives on each execution.
25+
26+
## Why `/upload` does not apply `FileHint.max_size`
27+
28+
The `FileHint` limits belong to the parameter, and `/upload` does **not** apply
29+
them. The reason is *not* that something behind it will: nothing on the server
30+
weighs a stored file, so the endpoint is not standing aside for a verdict that
31+
comes later — no verdict comes.
2132

2233
```text
23-
max_upload_bytes operational ceiling of the endpoint, cuts early
24-
IsPathFile.min/max_size parameter constraint, validated on invocation
34+
max_upload_bytes operational ceiling of the endpoint, counts the real
35+
bytes as they arrive
36+
FileHint.min/max_size parameter constraint, applied by the browser to the
37+
File it is holding
2538
```
2639

27-
A request to `/upload` carries bytes and a reference. It does not say which
28-
parameter that reference will end up in, and it cannot: the same reference
29-
serves several executions, and inside a list, a union or a nested dataclass
30-
there is no reliable way to point at the node. Applying the field limit there
31-
would mean walking the schema a second time inside FuncToWeb, or trusting a size
32-
declared by the browser. Neither is acceptable.
33-
34-
It is not needed, because the field limit is checked twice without FuncToWeb
35-
taking part:
36-
37-
```text
38-
the browser weighs the local File before uploading it (pytypehintweb)
39-
the core weighs the real file when building (pytypehint)
40-
```
41-
42-
The first saves the transfer; the second is the verdict, and it is the only one
43-
a hand-written HTTP call cannot skip.
44-
45-
The browser's part is a courtesy, and it works with exactly what it can know: a
46-
freshly chosen `File` carries its `.size`, so it is weighed before anything is
47-
uploaded and, if it is over the limit, no reference is minted and nothing stays
48-
pending (a multiple selection with one bad file is rejected whole, not halfway).
49-
A reference supplied from outside names a file the browser never saw: there is
50-
no size to weigh, none is invented, and the server is not asked for one. A file
51-
edited after being uploaded, an expired reference or a hand-written client all
52-
end up in the same place: the core's measurement.
53-
54-
So the split is:
40+
A request to `/upload` carries bytes and a reference, and nothing else. It does
41+
not say which parameter that reference will end up in, and it cannot: the same
42+
reference serves several executions, and it can feed two functions whose bounds
43+
disagree. Inside a list, a union or a nested dataclass there is no node to point
44+
at either. Applying the field limit there would mean walking the schema a second
45+
time inside FuncToWeb — a second validator alongside pytypehint's, with its own
46+
reading of the same atoms, drifting from it the first time the core changes.
47+
That is the one thing this layer has undertaken not to build.
48+
49+
The two numbers are also different kinds of thing, and reading one as an
50+
implementation of the other is what produces the wrong expectation.
51+
`max_upload_bytes` is an operational policy of the endpoint: how much this
52+
server is willing to receive and keep, whatever it later turns out to be for.
53+
`FileHint.min_size`/`max_size` are part of the function's contract: what this
54+
parameter is prepared to work with. One belongs to the deployment, the other to
55+
the signature.
56+
57+
So each layer applies what it can genuinely know. The browser holds a real
58+
`File` with a real `.size`, so it weighs it before anything is uploaded and, if
59+
it is outside the bounds, no reference is minted and nothing stays pending (a
60+
multiple selection with one bad file is rejected whole, not halfway). The
61+
endpoint holds bytes on the wire, so it counts them, stops mid-write, deletes
62+
the partial file and answers `413`. The core holds a string, so it reads its
63+
extension. Nobody stats the stored file.
5564

5665
```text
5766
upload too large for the server → /upload responds 413
5867
local File that breaks the bound → the form neither uploads nor sends it
59-
opaque reference that breaks the bound → /upload accepts it,
60-
/invoke responds 422
61-
file below the field's min_size → /invoke responds 422
68+
opaque reference that breaks the bound → /upload accepts it, /invoke runs
69+
reference that names nothing in storage → /invoke responds 422
6270
```
6371

64-
The `422` is not a failure of the function: it comes from `schema.build()`,
65-
after the resolver has turned the reference into a path, so the core measures
66-
the real file. It is input validation, with the core's message:
67-
68-
```text
69-
{"error": "SchemaValueError: doc: file too small: 99 bytes, minimum 100"}
70-
```
72+
A reference supplied from outside names a file the browser never saw: there is
73+
no size to weigh, none is invented, and the server is not asked for one. A file
74+
edited after it was uploaded is in the same position, and so is a hand-written
75+
client. The size bound is simply not applied to any of them, and saying
76+
otherwise would be selling a guarantee this layer does not hold.
7177

72-
The same holds inside a list, a dataclass or a union, because `decode()` does
73-
the walking and the core does the measuring.
78+
That is a deliberate boundary, not an open hole. An application that needs an
79+
authoritative rule about the size of stored content owns that rule: it can check
80+
it inside the function, which is the one place that has both the path and the
81+
contract in hand, or in whatever wraps the mount. FuncToWeb does not invent that
82+
policy on its behalf, and the one ceiling it does own, `max_upload_bytes`, it
83+
enforces on the bytes themselves, where no client can talk it out of the number.
7484

7585
## Why the name rules are portable
7686

@@ -131,15 +141,36 @@ reference is what makes that decision genuinely yours —it never stops
131141
resolving and it never changes underneath you— so a retention policy written
132142
around it holds.
133143

134-
## Why the resolver speaks first
135-
136-
Both check that the file exists, and neither is redundant: the resolver decides
137-
whether the reference names something in the storage directory, and the core
138-
then certifies the path that came out of it. Since the resolver speaks first,
139-
the message you see on this path is the resolver's; the core's
140-
`file does not exist` is left for what does not go through the resolver, such as
141-
a default written in the signature, or for a file that disappears between one
142-
check and the other.
144+
## Why existence belongs to the resolver
145+
146+
The resolver is not the first of two checks; it is the only one. It decides
147+
whether the reference names something this server owns: a bare file name, a
148+
resolved path whose immediate parent is `UPLOADS_DIR`, and a file actually
149+
sitting there —under the bare name, or as a pending one it promotes on the way.
150+
What comes out is a local path that still carries the extension the reference
151+
had, which is what makes `FileHint(extensions=…)` work end to end.
152+
153+
The core repeats none of it. It does not stat, it does not test
154+
`Path.is_file()`, and a path that is not there builds without complaint, so
155+
there is no verdict waiting behind the resolver to catch what it let through.
156+
That is precisely why the rule is written once, on this side, and why the same
157+
resolver serves both execution endpoints and the prefill: the alternative here
158+
is not redundancy, it is a gap.
159+
160+
It also explains the shape of the message. Every rejection on this path is the
161+
resolver's —`File not found: <reference>`, or the storage refusal— and it names
162+
the file by its reference, because that is the only name the client ever knew.
163+
164+
The one value that skips the resolver is a default written in the signature,
165+
since there is nothing to resolve yet; it is checked for belonging to storage
166+
instead, which is what makes it publishable as a reference, and its existence is
167+
settled like everyone else's, on the first execution.
168+
169+
The resolved path is local, and it keeps its extension because storage is a
170+
directory of files. A resolver that returned an opaque key with no extension
171+
(`s3://bucket/key` and the like) would break `FileHint` outright — the core has
172+
nothing but that string to read. FuncToWeb does not have such a resolver, and
173+
this is the assumption anyone writing one would have to keep.
143174

144175
## The author/client border
145176

0 commit comments

Comments
 (0)