-
Notifications
You must be signed in to change notification settings - Fork 0
docs: split the purity contract out of file semantics; digest CompletedProcess #831
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| ../../notebooks/PathsInContainers.ipynb |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -44,19 +44,18 @@ functions returning identical files share one stored body (see | |
| Argument mutation | ||
| ~~~~~~~~~~~~~~~~~ | ||
|
|
||
| A call is keyed on its arguments **as passed**: argument content is captured | ||
| *before* the function body runs. A function that mutates its own argument — | ||
| most commonly, writing an output file *into* a directory it received — is | ||
| still recorded under the pre-call content, so honest repeat calls hit. The | ||
| mutation itself, however, is neither recorded nor replayed: a cache hit | ||
| leaves the argument untouched, so a side effect on the input happens on cold | ||
| calls only. | ||
|
|
||
| fleche caches *pure* functions. What a function does to its arguments | ||
| without passing it back out is invisible to the cache — treat received paths | ||
| as read-only and write outputs to a fresh directory (``tempfile.mkdtemp``). | ||
| A mutated argument that *is* returned is captured faithfully in its final, | ||
| post-mutation state: if the mutation is the point, return it. | ||
| Arguments are keyed **as passed** — captured before the body runs — and a | ||
| mutation the body performs on one is neither recorded nor replayed. That is | ||
| a general rule (:ref:`argument-mutation`); its most common instance here is a | ||
| function that writes an output file *into* a directory it received. The call | ||
| is recorded under the directory's pre-call tree, so honest repeat calls hit, | ||
| but the written file does not reappear on a hit — it exists only on cold | ||
| calls. | ||
|
|
||
| So treat received paths as read-only and write outputs to a fresh directory | ||
| (``tempfile.mkdtemp``). If the mutation is the point, return the path: a | ||
| mutated argument that *is* returned is stored in its final, post-mutation | ||
| state. | ||
|
|
||
| Only *content* changes count as mutation: permissions are not part of | ||
| identity (see :ref:`fidelity-limits`), so a ``chmod`` on a received path is | ||
|
|
@@ -146,13 +145,22 @@ the file. If you need the file at a stable location, copy it out: | |
| Paths nested inside containers | ||
| ------------------------------ | ||
|
|
||
| Paths are found and content-stored inside the containers fleche takes apart: | ||
| ``dict``, ``OrderedDict``, ``list``, ``tuple`` (**exact types** — see below), | ||
| ``dataclasses`` and ``attrs`` classes — nested to any depth, as values *or* as | ||
| dict keys. Everything above about identity, materialization, and lifetime | ||
| applies to each nested path individually. Container structure is otherwise | ||
| faithful on a hit: lists and tuples keep their element order, and a mended | ||
| dict keeps the insertion order the stored value had. | ||
| Whether a nested path is stored by content comes down to one thing: | ||
| **destructuring** — whether storage takes the surrounding container apart into | ||
| independently-stored children, or pickles it whole as one opaque value. Only | ||
| children reach the path machinery, so the list of containers fleche | ||
| destructures *is* the list of places a nested path gets content treatment. | ||
| That list is | ||
| :data:`~fleche.storage.destructuring._DESTRUCTURERS`: ``dict``, | ||
| ``OrderedDict``, ``list``, ``tuple`` (**exact types** — see below), | ||
| ``dataclasses`` and ``attrs`` classes. See :ref:`extending-destructurer` for | ||
| the mechanism and how to add your own container to it. | ||
|
|
||
| Within those, paths are found nested to any depth, as values *or* as dict | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Checked this rather than trusting the wording — it holds at any A (It also propagates: a container holding a path gets Verified empirically — Added to the docs:
Generated by Claude Code |
||
| keys, and everything above about identity, materialization, and lifetime | ||
| applies to each one individually. Container structure is otherwise faithful | ||
| on a hit: lists and tuples keep their element order, and a mended dict keeps | ||
| the insertion order the stored value had. | ||
|
|
||
| Three caveats specific to nesting: | ||
|
|
||
|
|
@@ -175,16 +183,18 @@ Opaque containers store paths by *location* | |
| ------------------------------------------- | ||
|
|
||
| A path inside an opaque value (a ``namedtuple``, a ``set``, an arbitrary | ||
| object) never reaches the content machinery. The call is still *keyed* | ||
| object) is never destructured out of it, and so never reaches the content | ||
| machinery. The call is still *keyed* | ||
| correctly — the digest layer does look inside — but what is stored is the path | ||
| object itself, pointing at wherever the file was when it was saved. A hit | ||
| returns that original *location* (whether as the same object or an equal copy | ||
| is backend-dependent — rely on neither): if the file has since been deleted or | ||
| edited, the hit hands you a dangling or stale path, **without any warning**. | ||
|
|
||
| Rule of thumb: return paths in plain dicts / lists / tuples / dataclasses. If | ||
| you need a custom container to participate, see | ||
| :func:`~fleche.storage.destructuring.register_destructurer`. | ||
| you need a custom container to participate, register a destructurer for it | ||
| with :func:`~fleche.storage.destructuring.register_destructurer` — see | ||
| :ref:`extending-destructurer`. | ||
|
|
||
| .. _file-dedup: | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| .. _purity: | ||
|
|
||
| Purity and Side Effects | ||
| ======================= | ||
|
|
||
| What fleche assumes about the functions you decorate, and what therefore does | ||
| *not* survive a cache hit. The rules here apply to every argument and result | ||
| type; :doc:`file_semantics` is the same contract seen through ``Path`` values. | ||
|
|
||
| The assumption | ||
| -------------- | ||
|
|
||
| fleche caches **pure** functions: the result is determined by the arguments, | ||
| and anything else the body does is incidental. A cache hit replays the | ||
| *result* and nothing else — so every effect a function has besides returning | ||
| a value happens on cold calls only. | ||
|
|
||
| That is not a restriction fleche can check, and it does not raise if you break | ||
| it. It shows up as a function that behaves differently the second time you | ||
| call it. | ||
|
|
||
| .. _argument-mutation: | ||
|
|
||
| Arguments are keyed as passed | ||
| ----------------------------- | ||
|
|
||
| Argument content is captured **before** the body runs, so a function that | ||
| mutates its own argument is still recorded under the pre-call content and | ||
| honest repeat calls hit. The mutation itself is neither recorded nor | ||
| replayed: | ||
|
|
||
| .. code-block:: python | ||
|
|
||
| @fleche | ||
| def append_and_report(xs, n): | ||
| xs.append(n) | ||
| return len(xs) | ||
|
|
||
| a = [1, 2] | ||
| append_and_report(a, 9) # 3 — body runs, and a is now [1, 2, 9] | ||
|
|
||
| b = [1, 2] | ||
| append_and_report(b, 9) # 3 — cache hit; b is still [1, 2] | ||
|
|
||
| Both calls return ``3``, because that is the recorded result. Only the first | ||
| one changed its argument. Nothing about this is specific to lists: a | ||
| ``dict``, a numpy array, and a directory a function writes into all behave the | ||
| same way. | ||
|
|
||
| Return what you mutate | ||
| ~~~~~~~~~~~~~~~~~~~~~~ | ||
|
|
||
| An argument that is mutated **and returned** is captured faithfully in its | ||
| final, post-mutation state — the result is stored after the body runs: | ||
|
|
||
| .. code-block:: python | ||
|
|
||
| @fleche | ||
| def append_and_return(xs, n): | ||
| xs.append(n) | ||
| return xs | ||
|
|
||
| append_and_return([1, 2], 9) # [1, 2, 9] — body runs | ||
| append_and_return([1, 2], 9) # [1, 2, 9] — cache hit, same value | ||
|
|
||
| If the mutation is the point of the function, return it. Treat arguments you | ||
| receive as read-only otherwise, and build results fresh. | ||
|
|
||
| Other side effects | ||
| ------------------ | ||
|
|
||
| Everything else a body does — printing, logging, writing files outside the | ||
| returned value, sending a request, inserting a row — happens on the cold call | ||
| and never again: | ||
|
|
||
| .. code-block:: python | ||
|
|
||
| @fleche | ||
| def record(x): | ||
| db.insert(x) # runs once, ever | ||
| return x * 2 | ||
|
|
||
| If an effect must happen on every call, it belongs outside the cached | ||
| function; keep the cached part to the computation whose result you want | ||
| stored. | ||
|
|
||
| Two related cases | ||
| ----------------- | ||
|
|
||
| * A function returning ``None`` is never cached, so it re-executes every time | ||
| — including its side effects. See :ref:`none-not-cached`. | ||
| * Paths follow all of the above, with the mutation case made concrete: | ||
| a directory a function receives and writes into is recorded under its | ||
| pre-call tree, and the written file does not reappear on a hit. See | ||
| :doc:`file_semantics`. | ||
|
|
||
| See also | ||
| -------- | ||
|
|
||
| * :doc:`file_semantics` — the same contract for files and directories. | ||
| * :doc:`helpers` — ``.rerun()`` for forcing a cold call deliberately. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
second clause is correct and intended behavior, but the first one makes it read as it if was some edge behavior.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 62a2c3c. You're right that the ordering did the damage — leading with "treat received paths as read-only" implies the returning case is the exception you fall back to, when it's the normal shape, and the one the shell pipeline in
Files.ipynbactually uses.Flipped it: returning what you wrote to now leads and is stated as intended, and the read-only advice is scoped to paths that are pure input.
Generated by Claude Code