Skip to content

feat(artifacts): add store_dataframe and delete_dataframe - #135

Draft
tkislan wants to merge 34 commits into
mainfrom
feat/artifacts-store-dataframe
Draft

tkislan wants to merge 34 commits into
mainfrom
feat/artifacts-store-dataframe

Conversation

@tkislan

@tkislan tkislan commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

@coderabbitai ignore

Summary

Toolkit side of the Full Dataframe storage RFC (§3.4): notebook code can store a whole pandas or polars DataFrame under a name, and delete it. #131 builds on this to store a block's result automatically.

from deepnote_toolkit import artifacts

reference_id = artifacts.store_dataframe(df, "revenue")  # replaces the previous "revenue"
artifacts.delete_dataframe("revenue")  # True if there was anything to delete

Changes

  • deepnote_toolkit/artifacts/: new package, exported from deepnote_toolkit like ocelots.
  • deepnote_toolkit/config.py: resolve_notebook_root(), moved out of set_notebook_path.py so frames use the same project root.
  • Tests in tests/unit/artifacts/.

Behaviour

  • Layout: <project root>/deepnote_dataframes/<name>/<uuid>.arrow plus manifest.json (version, file, previous, written_at, rows, toolkit_version), which names the current file and the one it replaced. The UUID is the returned reference ID.
  • Format: pandas via pyarrow's write_feather, polars via write_ipc, in 64k-row batches (polars from 1.37). From polars 1.2.1, strings and binaries are written without the view types that pyarrow < 15 and apache-arrow < 21.2 can't read. Compression is zstd, else lz4, else none.
  • Commit: frame, then manifest (renamed into place), then cleanup. A failure or Stop before the manifest step deletes the new file and keeps the previous frame. From the manifest step on, every error (EROFS too) raises and keeps the new file, which the manifest may already name.
  • Cleanup: deletes the replaced manifest's previous frame and unreferenced <uuid>.arrow files older than a day, only inside the name's directory. A manifest counts as absent if it is over 4 KiB or not a JSON object, lacks a field (only previous is optional) or has one of the wrong type, has a version other than 1 or negative rows, or names anything but <uuid>.arrow. Unknown keys are ignored.
  • Read-only mount (EROFS): store_dataframe returns None before converting the frame; delete_dataframe returns False.
  • Errors: a name outside [A-Za-z0-9_-]{1,128} raises ValueError. Anything but a pandas or polars DataFrame, such as a PySpark frame or a polars LazyFrame, raises TypeError; a SQL query preview gets a message pointing to the DataFrame output mode. A frame pyarrow or polars can't serialize, such as a GeoDataFrame, raises their own error and keeps the previous frame.

Not in this PR

The read endpoint and cleanup on project deletion. No feature flag or webapp error reports: a call is the user's own code (RFC §3.4.1, §3.4.3).

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

📦 Python package built successfully!

  • Version: 2.8.0.dev39+6ae58c5
  • Wheel: deepnote_toolkit-2.8.0.dev39+6ae58c5-py3-none-any.whl
  • Install:
    pip install "deepnote-toolkit @ https://deepnote-staging-runtime-artifactory.s3.amazonaws.com/deepnote-toolkit-packages/2.8.0.dev39%2B6ae58c5/deepnote_toolkit-2.8.0.dev39%2B6ae58c5-py3-none-any.whl"

`artifacts.store_dataframe(df, name)` writes a whole pandas or polars
DataFrame to `deepnote_dataframes/<name>/<uuid>.arrow` in the project's
filesystem, with `to_feather` / `write_ipc`, zstd falling back to lz4 and then
uncompressed. A `manifest.json` names the current file and the one it replaced,
and every write rewrites it. `artifacts.delete_dataframe(name)` deletes a name's
directory.

The function raises like the user's own `df.to_feather()` would, and returns
None on a read-only mount. An interrupt stops the write and removes the partial
file, except during the manifest update, which finishes first.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B441SpUqRn7QTzcnWxjNa5
@tkislan
tkislan force-pushed the feat/artifacts-store-dataframe branch from 3d68fb4 to 62a4d0e Compare October 8, 2026 12:37
@codecov

codecov Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.90110% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.90%. Comparing base (f07a89f) to head (77f8d94).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
deepnote_toolkit/artifacts/dataframe_storage.py 97.46% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #135      +/-   ##
==========================================
+ Coverage   77.26%   77.90%   +0.64%     
==========================================
  Files         115      118       +3     
  Lines        6589     6754     +165     
  Branches      961      979      +18     
==========================================
+ Hits         5091     5262     +171     
+ Misses       1186     1182       -4     
+ Partials      312      310       -2     
Flag Coverage Δ
combined 77.90% <98.90%> (+0.64%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

The public API (store_dataframe, delete_dataframe) moves to
deepnote_toolkit/artifacts/dataframes.py and is re-exported from the
package, so `deepnote_toolkit.artifacts.store_dataframe` is unchanged.
The writer and its manifest move alongside it, and their tests move to
tests/unit/artifacts/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx
@deepnote-bot

deepnote-bot commented Oct 12, 2026 •

Copy link
Copy Markdown

🚀 Review App Deployment Started

📝 Description 🌐 Link / Info
🌍 Review application ra-135
🔑 Sign-in URL Click to sign-in
📊 Application logs View logs
🔄 Actions Click to redeploy
🚀 ArgoCD deployment View deployment
⏰ Last deployed 2026-10-16 20:02:32 (UTC)
📜 Deployed commit 428aa1ec91c6546eeef81ba3578503bcfdc15368
🛠️ Toolkit version 6ae58c5

tkislan and others added 23 commits October 12, 2026 19:17
- Write manifest.json to a temporary file and rename it over the old one.
  s3fs uploads an empty object when a file is opened with O_TRUNC, so the
  in-place write left the name without a current frame during every store,
  and a failed write or upload left the manifest empty or naming a frame
  the call then deleted.
- Raise a SIGINT held during the manifest update even when the update
  fails, so an I/O error can't swallow Stop.
- delete_dataframe returns False on EROFS, as store_dataframe returns None,
  and documents the OSError it raises.
- store_dataframe's docstring covers failures after the manifest names the
  new frame.
- Drop the codec guard for an exception pyarrow never raises for zstd or
  lz4, with the two tests of that impossible state.
- set_notebook_path() and the writer share config.resolve_notebook_root(),
  which keeps ipykernel out of the artifacts package's imports.
- Move the Spark ANSI-mode toggle into a fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx
From 1.0 polars writes strings and binaries as Utf8View/BinaryView by
default, which pyarrow before 15 and apache-arrow before 21.2.0 can't
read, while the same data written by pandas reads there. From polars
1.2.1, where `pl.CompatLevel` is first exported, the writer passes
`compat_level=pl.CompatLevel.oldest()`, so they are stored as
large_string and large_binary. polars 1.0 to 1.2.0 still write views.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx
- An s3fs rename copies the object and then deletes the source, so it can
  fail after the new manifest is already in place. store_frame then deleted
  the new frame, leaving the manifest naming a missing file. It now deletes
  the new frame only when the frame write or the manifest read fails; from
  write_manifest on, a failure keeps it, and a later write prunes it once
  it is a day old if no manifest names it.
- With that, Stop at any point leaves a manifest that names a complete
  frame, so the SIGINT deferral around the manifest update is gone. Stop
  raises KeyboardInterrupt wherever it lands; the worst case is an
  unreferenced file.
- Tests cover a rename that fails after the copy, a failed manifest read and
  an interrupted manifest write. The SIGINT test helpers and the tests of
  the held interrupt go with the deferral.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx
Drop docstring paragraphs and comments that restate the code, describe
behaviour the tests pin, or point at the RFC without a link. Keep the
external facts: s3fs upload and rename semantics, polars and Python
version quirks, the public store_dataframe and delete_dataframe contract.

Fix the ones that were wrong: json raises RecursionError on deep nesting
only before Python 3.12, polars has its own write_ipc errors, and the
bad-frame helper returns the good call's manifest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx
…here used

The options dict was untyped, so mypy inferred dict[str, int] and only the
repo's disabled assignment check hid the CompatLevel value going into it. The
polars version is now parsed once.

The comments above the constants moved to the two options they explain, each
cut to one line: batches are capped so a page read stays cheap, which polars
before 1.37 can't do, and the oldest compat level avoids the string and binary
views older Arrow readers can't read. How readers treat batch sizes needs no
comment, because an Arrow IPC file lists its batches and their row counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx
DataFrame.to_feather before pandas 2.1 refuses any index but a default
RangeIndex and any non-string column label, so filtered, sorted, grouped,
concatenated and transposed frames raised ValueError on pandas 2.0 and 1.5,
which the declared range still allows on Python 3.10 and 3.11. pandas 2.1+
calls pyarrow's write_feather itself, so the files on the locked versions are
byte-for-byte the same.

GeoDataFrames, which geopandas writes with its own to_feather, now raise
ArrowTypeError on their geometry column.

The tests that faked or spied on to_feather now target write_feather, and
the test helper that predicts refusals asks pyarrow too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx
…esystem

Two tests replaced read_manifest and _delete_replaced_frames wholesale, so
no test made the filesystem calls inside them fail. read_manifest treating
any OSError as "no manifest", or the cleanup swallowing OSError, kept the
whole suite green. The first lets a transient EIO on manifest.json drop the
frame that was current until then from the manifest, and delete it in the
same call if it is more than a day old.

The tests now fail Path.open on manifest.json and Path.unlink on the
replaced frame, and both of those regressions fail them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx
The statvfs probe ran before anything was written, so when the project root
did not exist, which is the default outside Deepnote Cloud, store_dataframe
and delete_dataframe both raised FileNotFoundError instead of creating the
root or returning False. The probe also never caught anything the writes
don't: on a read-only mount the first write (creating the name's directory,
opening the frame file, or rmtree's first unlink) fails with EROFS before any
conversion.

store_frame now maps EROFS to None itself, and only while nothing is
stored yet: creating the directory moved into the step that deletes the new
frame on failure. EROFS from the manifest step on raises like any other
error, because by then the new frame may already be current and None would
say nothing was stored.

The read-only test helper now refuses writes with EROFS instead of faking
statvfs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx
A query preview is a pandas DataFrame subclass, and the frames derived from
it keep the subclass, so "needs a pandas or polars DataFrame, got
DeepnoteQueryPreview" was false on its face and said nothing about why or what
to do instead. The message now says a preview holds at most 100 rows of the
query's result and points to the DataFrame output mode or pd.DataFrame(df).

classify_frame still refuses previews, which the post-run hook relies on to
skip them silently; store_dataframe only picks the message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx
The 1,225-line test_artifacts.py is split into eight files by behaviour:
storing, arguments, manifest and cleanup, file format, write failures,
read-only mount, project root and delete_dataframe. The helpers and
constants shared across them move to tests/unit/helpers/dataframe_storage.py
under public names. No test changed: the same 150 tests are collected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx
…nstants

The test fixture root becomes tmp_project_root, _SAFE_ID and _FRAME_NAME
become _SAFE_ID_REGEX and _FRAME_NAME_REGEX, and _FailingClose becomes
_FailingCloseS3Upload. The Spark fixtures are typed as SparkSession, and
store_frame loses the comment before the manifest step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx
Manifest is now a pydantic model of the whole manifest, and read_manifest and
write_manifest both go through it. Reading used to check only file and
previous; now pydantic checks every field's type, all of them required but
previous, with strict types so "1", 1.5 and true are not ints. read_manifest
then refuses a version other than 1, negative rows, and a file or previous
that is not <uuid>.arrow. The name check stays on the read side because
cleanup unlinks the replaced manifest's previous, and any code in the project
can write the manifest. A refused manifest counts as absent, as before.
Unknown keys are ignored.

The written manifest is unchanged, previous still omitted when there is none.
The pydantic_compat_helpers keep both pydantic 1 and 2 working; only known
keys reach the model, since pydantic 1 raises TypeError on __pydantic_self__.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx
A pytest.raises that names only a type passes on any error of that type, such
as an OSError from a different step than the one under test. Each one now
checks what it caught: the errno of an OSError, the message of a ValueError or
TypeError, the identity of an injected error or interrupt, and the exact type
where a subclass would also match. The read-only TypeError test is split so a
SQL query preview's message is checked apart from an unsupported value's.

rmtree's symbolic link error is compared by its args: newer Pythons (3.12.x
and up) set its filename, and str() then no longer shows the message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx
…ditional

_store_bad_frame_after_a_good_one took an optional expected_error and only
wrapped the second store in pytest.raises when one was given. It now requires
expected_error. _store_frame_after_a_good_one stores the second frame without
expecting an error, and _store_a_good_frame holds the first store both share.
test_frames_pyarrow_may_refuse_follow_what_pyarrow_does picks the helper by
what pyarrow does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx
stored_manifest validated nothing and returned a Dict[str, Any]. It now
validates the written JSON with the Manifest model and returns it, so a field
of the wrong type fails there, naming the field, and callers read typed
attributes.

"previous" not in X became X.previous is None: on a pydantic model, in checks
against (name, value) pairs and would always pass. That no longer tells an
omitted previous from null, and the model ignores unknown keys, so
test_stores_pandas_frame_and_manifest checks the written keys from the raw
JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx
When pyarrow accepts a version-dependent frame, the test only compared row
counts. The uuid case has one row, as does the good frame stored before it,
so a store that silently kept the previous frame passed. The test now also
checks that the stored table is not the good frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx
…fest

The test claimed the store left nothing but checked only the frame files,
so a store that wrote the manifest before the frame still passed it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx
…ary file

This is the only test where the rename fails, so nothing caught a
write_manifest whose cleanup covered only the write and not the rename.
That version leaves .manifest.json.*.tmp behind for good, because the
cleanup prunes only <uuid>.arrow names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx
… kind

classify_frame mapped a frame to a "pandas" or "polars" string, and
store_frame and _write_frame_file trusted that string without checking it
against the frame. store_dataframe now checks the frame with the ocelots
TypeGuards and passes on only the narrowed frame, typed as StorableFrame.
_write_frame_file dispatches on it with isinstance and ends in assert_never.

classify_frame also refused query previews while is_pandas_dataframe listed
DeepnoteQueryPreview as pandas, and store_dataframe checked for a preview a
second time to pick its error message. The refusal now lives only in
store_dataframe. DeepnoteQueryPreview subclasses pd.DataFrame, so dropping
it from is_pandas_dataframe changes no result.

Stored files are byte-identical before and after, and every refused input
raises the same TypeError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx
tkislan and others added 9 commits October 16, 2026 12:21
The global config disables arg-type, the code mypy reports a non-exhaustive
assert_never under, so a missing branch in _write_frame_file's isinstance
chain went unreported. pandas has no stubs here, so PandasDF is Any and only
the polars branch is checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx
pydantic ignores unknown keys by default on both v1 and v2. The filter to
the model's fields only worked around v1's parse_obj, which calls
cls(**obj): a key named __pydantic_self__, or a non-string key from a list
of pairs, makes it raise TypeError rather than ValidationError. read_manifest
now catches TypeError instead.

The isinstance(dict) guard is gone too. On v2 every result is unchanged. On
v1 a list of pairs is now honoured, still through every field and file-name
check, and a __pydantic_self__ key counts as absent. The
unknown-key-named-like-self case only covered the removed workaround, and
on v2 it duplicates unknown-key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx
…del class

Callers got Any back, so mypy checked nothing they did with the result. The
TypeVar is bound to BaseModel, so passing a class that isn't a model is a
type-var error, which the repo's mypy config reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx
…s and name checks

6c1fe41 removed the version, rows and file-name checks from read_manifest, so
a manifest with version 2, negative rows, or a file or previous that isn't
<uuid>.arrow is now honoured. The 12 cases that expected it to count as
absent go, and so does the ../../data/x.csv sentinel only the traversal cases
used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhzfrytCUojy8Vw1gbX3Bx

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants