Skip to content

Add bt datasets snapshots for dataset version control#176

Merged
max-braintrust merged 18 commits into
mainfrom
max/add-dataset-snapshots
Jun 9, 2026
Merged

Add bt datasets snapshots for dataset version control#176
max-braintrust merged 18 commits into
mainfrom
max/add-dataset-snapshots

Conversation

@max-braintrust

@max-braintrust max-braintrust commented May 6, 2026

Copy link
Copy Markdown
Contributor

TL;DR

Follow up to: #104

Adds bt datasets snapshots for managing saved dataset snapshots from the CLI: list, create, and restore dataset states from saved snapshots.

What changed?

Added dataset snapshot management functionality:

  • New bt datasets snapshots command with subcommands: list, create, delete and restore
  • Uses snapshot/dataset snapshot terminology to match the UI
  • Snapshot listing displays saved snapshots for a dataset
  • Snapshot creation can infer the current dataset head xact when --xact-id is omitted
  • Snapshot restore supports restore by snapshot name or by xact ID with --snapshot
  • Restore includes preview support and requires confirmation unless --force is passed
  • JSON output includes snapshot list/create/restore modes and found_existing for create responses
  • Moved snapshot-specific CLI args into the snapshot module to match existing subcommand patterns
  • Added shared profile utilities for resolving profile info and generating default snapshot name slugs

Added test coverage:

  • Unit tests for snapshot arg parsing, read/write auth routing, response deserialization, default snapshot names, and restore target selection
  • Fixture coverage for snapshot creation, duplicate xact handling via x-bt-found-existing, and snapshot listing
  • Mock server support for dataset snapshot create and list endpoints

How to test?

# Create a dataset
bt datasets create my-dataset --rows '[{"id":"case-1","input":{"text":"hi"},"expected":"hello"}]'

# List snapshots
bt datasets snapshots list my-dataset

# Create a snapshot
bt datasets snapshots create my-dataset baseline

# Re-run create for the same xact to verify found_existing handling
bt datasets snapshots create my-dataset baseline-again --xact-id 1000192656880881099 --json

# Restore by snapshot name
bt datasets snapshots restore my-dataset --name baseline

# Restore by xact ID
bt datasets snapshots restore my-dataset --snapshot 1000192656880881099 --force

@max-braintrust max-braintrust changed the title Add bt datasets snapshots for better dataset version control Add bt datasets snapshots for dataset version control May 6, 2026
@github-actions

github-actions Bot commented May 6, 2026

Copy link
Copy Markdown

Latest downloadable build artifacts for this PR commit ad9934c772ce:

Available artifact names
  • ``artifacts-build-global
  • ``artifacts-build-local-aarch64-pc-windows-msvc
  • ``artifacts-build-local-x86_64-pc-windows-msvc
  • ``artifacts-build-local-x86_64-apple-darwin
  • ``artifacts-build-local-aarch64-apple-darwin
  • ``artifacts-build-local-x86_64-unknown-linux-musl
  • ``artifacts-build-local-x86_64-unknown-linux-gnu
  • ``artifacts-build-local-aarch64-unknown-linux-gnu
  • ``artifacts-plan-dist-manifest
  • ``cargo-dist-cache

Comment thread src/datasets/api.rs Outdated

@viadezo1er Cedric / ViaDézo1er (viadezo1er) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cargo clippy tells me to cargo clippy --fix --bin "bt" -p bt --, which gives a small formatting diff, nothing blocking but nice to format.

diff --git a/src/functions/pull.rs b/src/functions/pull.rs
index 8f2e6de..0b51ef2 100644
--- a/src/functions/pull.rs
+++ b/src/functions/pull.rs
@@ -1410,7 +1410,7 @@ fn format_py_value_inner(value: &Value, depth: usize) -> String {
             let closing_indent = "    ".repeat(depth);
             let mut out = String::from("{\n");
             let mut entries = object.iter().collect::<Vec<_>>();
-            entries.sort_by(|(left, _), (right, _)| left.cmp(right));
+            entries.sort_by_key(|(left, _)| *left);
             for (index, (key, val)) in entries.into_iter().enumerate() {
                 out.push_str(&indent);
                 out.push_str(&serde_json::to_string(key).unwrap_or_else(|_| "\"\"".to_string()));
diff --git a/src/sql.rs b/src/sql.rs
index 4e4b4ac..f6619e4 100644
--- a/src/sql.rs
+++ b/src/sql.rs
@@ -258,11 +258,10 @@ fn handle_key_event(
         KeyCode::End => app.move_end(),
         KeyCode::Up => app.history_prev(),
         KeyCode::Down => app.history_next(),
-        KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
-            if !key.modifiers.contains(KeyModifiers::ALT) {
+        KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL)
+            && !key.modifiers.contains(KeyModifiers::ALT) => {
                 app.insert_char(ch);
             }
-        }
         _ => {}
     }
 
diff --git a/src/traces.rs b/src/traces.rs
index 94e0b3d..c136548 100644
--- a/src/traces.rs
+++ b/src/traces.rs
@@ -2084,8 +2084,8 @@ fn handle_span_detail_key(
                 }
             }
         }
-        KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
-            if app.detail_pane_focus == DetailPaneFocus::Detail {
+        KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL)
+            && app.detail_pane_focus == DetailPaneFocus::Detail => {
                 if detail_message_list_mode {
                     if app.detail_view == DetailView::Thread {
                         for _ in 0..10 {
@@ -2100,7 +2100,6 @@ fn handle_span_detail_key(
                     scroll_detail_down_bounded(app, 10)
                 }
             }
-        }
         _ => {}
     }
     Ok(())
@@ -5229,11 +5228,10 @@ fn parse_trace_url(input: &str) -> Result<ParsedTraceUrl> {
                     parsed.span_id = Some(value.to_string());
                 }
             }
-            "tvt" => {
-                if !value.is_empty() {
+            "tvt"
+                if !value.is_empty() => {
                     parsed.trace_view_type = Some(value.to_string());
                 }
-            }
             _ => {}
         }
     }

@viadezo1er

Copy link
Copy Markdown
Contributor

I have never used bt datasets before so I don't really know if it's good or not.

@viadezo1er

Copy link
Copy Markdown
Contributor
bt datasets snapshots list
error: no datasets found
If this seems like a bug, file an issue at https://github.com/braintrustdata/bt/issues/new and include `bt --version`, `bt status --json`, and the command you ran.

Maybe make an empty list instead?

@viadezo1er

Copy link
Copy Markdown
Contributor

Why is there a -d/-dataset flag when datasets can also be passed as arguments?

@viadezo1er

Copy link
Copy Markdown
Contributor

Maybe a bt datasets snapshots rm/delete?

@viadezo1er

Cedric / ViaDézo1er (viadezo1er) commented Jun 9, 2026

Copy link
Copy Markdown
Contributor
❯ bt -V
bt 0.10.1-canary.aa9ca1c4cfb3
❯ bt status
org: ced-test-1
project: input
profile: profile
user: Cédric Halber (cedric@braintrustdata.com)
source: /Users/cedric@braintrustdata.com/.config/bt/config.json
❯ bt auth profiles
✓ profile — oauth — Cédric Halber (cedric@braintrustdata.com)

Credentials: /Users/cedric@braintrustdata.com/.config/bt/auth.json
❯ # Create a dataset
bt datasets create my-dataset --rows '[{"id":"case-1","input":{"text":"hi"},"expected":"hello"}]'

# List snapshots
bt datasets snapshots list my-dataset

# Create a snapshot
bt datasets snapshots create my-dataset baseline

# Re-run create for the same xact to verify found_existing handling
bt datasets snapshots create my-dataset baseline-again --xact-id 1000192656880881099 --json

# Restore by snapshot name
bt datasets snapshots restore my-dataset --name baseline

# Restore by xact ID
bt datasets snapshots restore my-dataset --snapshot 1000192656880881099 --force
✓ Created 'my-dataset' with 1 records
0 snapshots found for ced-test-1 / input / my-dataset

Name      Description      Xact      Created      
✓ Created snapshot 'baseline' for 'my-dataset' (xact 1000197309515474282).
{"dataset":{"created":"2026-06-09T00:14:47.512Z","created_at":null,"description":null,"id":"ae6420c8-713f-40b5-b3af-25f34100d4e5","metadata":null,"name":"my-dataset","project_id":"e91206c4-9b2f-4222-9891-5412a6528e5d"},"found_existing":false,"mode":"snapshot_create","snapshot":{"created":"2026-06-09T00:14:54.528Z","dataset_id":"ae6420c8-713f-40b5-b3af-25f34100d4e5","description":null,"id":"a3b7b38d-8dc4-41da-bc84-e1eebf80aa1e","name":"baseline-again","xact_id":"1000192656880881099"}}
Restore preview for my-dataset to snapshot 'baseline' (xact 1000197309515474282):

Rows to restore: 0
Rows to delete: 0

Restore dataset 'my-dataset' to snapshot 'baseline' (xact 1000197309515474282)? yes
✗ Failed to restore dataset 'my-dataset' to snapshot 'baseline' (xact 1000197309515474282)
error: failed to parse response
Restore preview for my-dataset to xact 1000192656880881099:

Rows to restore: 0
Rows to delete: 1

✓ Restored dataset 'my-dataset' to xact 1000192656880881099 (xact 1000197309516552558; 0 restored, 1 deleted).

Is it normal that I get ✗ Failed to restore dataset 'my-dataset' to snapshot 'baseline' ?

@viadezo1er

Cedric / ViaDézo1er (viadezo1er) commented Jun 9, 2026

Copy link
Copy Markdown
Contributor
echo 'bt datasets delete my-dataset'|sh
✓ Deleted 'my-dataset'
Run `bt datasets list` to see remaining datasets.

When in non interactive mode, the delete command does not require the --force flag, but it's required for the restore command. I think it would be better if the -f/--force flag was required for delete too.

@max-braintrust

Copy link
Copy Markdown
Contributor Author
❯ bt -V
bt 0.10.1-canary.aa9ca1c4cfb3
❯ bt status
org: ced-test-1
project: input
profile: profile
user: Cédric Halber (cedric@braintrustdata.com)
source: /Users/cedric@braintrustdata.com/.config/bt/config.json
❯ bt auth profiles
✓ profile — oauth — Cédric Halber (cedric@braintrustdata.com)

Credentials: /Users/cedric@braintrustdata.com/.config/bt/auth.json
❯ # Create a dataset
bt datasets create my-dataset --rows '[{"id":"case-1","input":{"text":"hi"},"expected":"hello"}]'

# List snapshots
bt datasets snapshots list my-dataset

# Create a snapshot
bt datasets snapshots create my-dataset baseline

# Re-run create for the same xact to verify found_existing handling
bt datasets snapshots create my-dataset baseline-again --xact-id 1000192656880881099 --json

# Restore by snapshot name
bt datasets snapshots restore my-dataset --name baseline

# Restore by xact ID
bt datasets snapshots restore my-dataset --snapshot 1000192656880881099 --force
✓ Created 'my-dataset' with 1 records
0 snapshots found for ced-test-1 / input / my-dataset

Name      Description      Xact      Created      
✓ Created snapshot 'baseline' for 'my-dataset' (xact 1000197309515474282).
{"dataset":{"created":"2026-06-09T00:14:47.512Z","created_at":null,"description":null,"id":"ae6420c8-713f-40b5-b3af-25f34100d4e5","metadata":null,"name":"my-dataset","project_id":"e91206c4-9b2f-4222-9891-5412a6528e5d"},"found_existing":false,"mode":"snapshot_create","snapshot":{"created":"2026-06-09T00:14:54.528Z","dataset_id":"ae6420c8-713f-40b5-b3af-25f34100d4e5","description":null,"id":"a3b7b38d-8dc4-41da-bc84-e1eebf80aa1e","name":"baseline-again","xact_id":"1000192656880881099"}}
Restore preview for my-dataset to snapshot 'baseline' (xact 1000197309515474282):

Rows to restore: 0
Rows to delete: 0

Restore dataset 'my-dataset' to snapshot 'baseline' (xact 1000197309515474282)? yes
✗ Failed to restore dataset 'my-dataset' to snapshot 'baseline' (xact 1000197309515474282)
error: failed to parse response
Restore preview for my-dataset to xact 1000192656880881099:

Rows to restore: 0
Rows to delete: 1

✓ Restored dataset 'my-dataset' to xact 1000192656880881099 (xact 1000197309516552558; 0 restored, 1 deleted).

Is it normal that I get ✗ Failed to restore dataset 'my-dataset' to snapshot 'baseline' ?

We were not parsing the no-op case correctly - if nothing gets written (ie you run restore on case where there is no diff) then no xact id is returned.

Fixed this, it should display an appropriate message now.

@viadezo1er

Copy link
Copy Markdown
Contributor
❯ git pull
Already up to date.
❯ cargo build
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s
❯ bt datasets create my-dataset --rows '[{"id":"case-1","input":{"text":"hi"},"expected":"hello"}]'

⠋ Creating dataset...                                                                                                                        ✓ Created 'my-dataset' with 1 records
❯ bt datasets list
1 dataset found in ced-test-1 / My Project

Name            Description      Created         
my-dataset      -                2026-06-0…      
❯ echo 'bt datasets delete my-dataset'|sh
✓ Deleted 'my-dataset'
Run `bt datasets list` to see remaining datasets.
❯ bt datasets list
0 datasets found in ced-test-1 / My Project

Name      Description      Created

I think the -f flag is not required in non interactive mode?

@max-braintrust

max-braintrust commented Jun 9, 2026

Copy link
Copy Markdown
Contributor Author
❯ git pull
Already up to date.
❯ cargo build
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s
❯ bt datasets create my-dataset --rows '[{"id":"case-1","input":{"text":"hi"},"expected":"hello"}]'

⠋ Creating dataset...                                                                                                                        ✓ Created 'my-dataset' with 1 records
❯ bt datasets list
1 dataset found in ced-test-1 / My Project

Name            Description      Created         
my-dataset      -                2026-06-0…      
❯ echo 'bt datasets delete my-dataset'|sh
✓ Deleted 'my-dataset'
Run `bt datasets list` to see remaining datasets.
❯ bt datasets list
0 datasets found in ced-test-1 / My Project

Name      Description      Created

I think the -f flag is not required in non interactive mode?

oh sorry misread, I added it to bt datasets snapshots delete, not bt datasets delete - should require confirmation or -f for dataset delete as well now.

@viadezo1er

Cedric / ViaDézo1er (viadezo1er) commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

I got confused yesterday (was a bit ill....) I was thinking of snapshot delete that's on me 😅 (though I also think requiring the -f flag for bt datasets delete is good).

@viadezo1er

Copy link
Copy Markdown
Contributor

Anyway lgtm

@max-braintrust max-braintrust marked this pull request as ready for review June 9, 2026 22:06
@max-braintrust max-braintrust merged commit e54bcc3 into main Jun 9, 2026
32 checks passed
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