mirror of
https://github.com/langchain-ai/langsmith-fetch.git
synced 2026-07-01 20:54:19 -04:00
feat: add filename pattern support for threads command
Add --filename-pattern option to threads command with sensible default
behavior for saving multiple thread files.
Changes:
- Add --filename-pattern option with default {thread_id}.json
- Support {thread_id}, {index}, and {idx} placeholders
- Support format specifications like {index:03d}
- Add sanitize_filename() helper for cross-platform safe filenames
- Add regex validation to ensure pattern contains valid placeholders
- Add test coverage for custom filename patterns
- Update .gitignore to exclude example/ directory
The validation uses regex to handle both simple placeholders like
{index} and format specifications like {index:03d}, ensuring flexible
file naming for bulk thread operations.
This commit is contained in:
@@ -39,3 +39,6 @@ Thumbs.db
|
||||
|
||||
# Config (local)
|
||||
.env
|
||||
|
||||
# Example/testing code
|
||||
example/
|
||||
|
||||
+42
-15
@@ -270,7 +270,12 @@ def trace(trace_id, format_type, output_file):
|
||||
default=10,
|
||||
help="Maximum number of threads to fetch (default: 10)",
|
||||
)
|
||||
def threads(output_dir, project_uuid, limit):
|
||||
@click.option(
|
||||
"--filename-pattern",
|
||||
default="{thread_id}.json",
|
||||
help="Filename pattern for saved threads. Use {thread_id} for thread ID, {index} for sequential number (default: {thread_id}.json)",
|
||||
)
|
||||
def threads(output_dir, project_uuid, limit, filename_pattern):
|
||||
"""Fetch recent threads for a project and save to files (BULK OPERATION).
|
||||
|
||||
This command is designed for bulk operations where you need multiple threads
|
||||
@@ -291,10 +296,13 @@ def threads(output_dir, project_uuid, limit):
|
||||
|
||||
\b
|
||||
FILE NAMING:
|
||||
- Files named: {thread_id}.json (sanitized for safety)
|
||||
- Default pattern: {thread_id}.json
|
||||
- Customize with --filename-pattern option
|
||||
- Available placeholders: {thread_id}, {index}
|
||||
- Example: --filename-pattern "thread_{index:03d}.json" → thread_001.json, thread_002.json
|
||||
- All filenames sanitized to ensure safe names across platforms
|
||||
- Directory created automatically if it doesn't exist
|
||||
- Existing files with same name will be overwritten
|
||||
- Thread IDs sanitized to ensure safe filenames across all platforms
|
||||
|
||||
\b
|
||||
RETURNS:
|
||||
@@ -303,11 +311,14 @@ def threads(output_dir, project_uuid, limit):
|
||||
|
||||
\b
|
||||
EXAMPLES:
|
||||
# Fetch 10 most recent threads to ./my-threads directory
|
||||
# Fetch 10 most recent threads to ./my-threads directory (default naming)
|
||||
langsmith-fetch threads ./my-threads
|
||||
|
||||
# Fetch 25 most recent threads
|
||||
langsmith-fetch threads ./my-threads --limit 25
|
||||
# Fetch 25 most recent threads with sequential numbering
|
||||
langsmith-fetch threads ./my-threads --limit 25 --filename-pattern "thread_{index:03d}.json"
|
||||
|
||||
# Use custom pattern with thread ID
|
||||
langsmith-fetch threads ./my-threads --filename-pattern "{thread_id}_export.json"
|
||||
|
||||
# Fetch threads with explicit project UUID
|
||||
langsmith-fetch threads ./my-threads --project-uuid 80f1ecb3-a16b-411e-97ae-1c89adbb5c49
|
||||
@@ -370,19 +381,35 @@ def threads(output_dir, project_uuid, limit):
|
||||
|
||||
click.echo(f"Found {len(threads_data)} thread(s). Saving to {output_path}/")
|
||||
|
||||
# Save each thread to a file
|
||||
for thread_id, messages in threads_data:
|
||||
# Sanitize thread_id for safe filename
|
||||
safe_thread_id = sanitize_filename(thread_id)
|
||||
# Ensure .json extension
|
||||
if not safe_thread_id.endswith(".json"):
|
||||
safe_thread_id = f"{safe_thread_id}.json"
|
||||
# Validate filename pattern (check for placeholders with or without format specs)
|
||||
has_thread_id = re.search(r"\{thread_id[^}]*\}", filename_pattern)
|
||||
has_index = re.search(r"\{index[^}]*\}", filename_pattern) or re.search(
|
||||
r"\{idx[^}]*\}", filename_pattern
|
||||
)
|
||||
if not (has_thread_id or has_index):
|
||||
click.echo(
|
||||
"Error: Filename pattern must contain {thread_id} or {index}",
|
||||
err=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
filename = output_path / safe_thread_id
|
||||
# Save each thread to a file
|
||||
for index, (thread_id, messages) in enumerate(threads_data, start=1):
|
||||
# Generate filename from pattern
|
||||
filename_str = filename_pattern.format(
|
||||
thread_id=thread_id, index=index, idx=index
|
||||
)
|
||||
# Sanitize the generated filename
|
||||
safe_filename = sanitize_filename(filename_str)
|
||||
# Ensure .json extension if not present
|
||||
if not safe_filename.endswith(".json"):
|
||||
safe_filename = f"{safe_filename}.json"
|
||||
|
||||
filename = output_path / safe_filename
|
||||
with open(filename, "w") as f:
|
||||
json.dump(messages, f, indent=2, default=str)
|
||||
click.echo(
|
||||
f" ✓ Saved {thread_id} to {safe_thread_id} ({len(messages)} messages)"
|
||||
f" ✓ Saved {thread_id} to {safe_filename} ({len(messages)} messages)"
|
||||
)
|
||||
|
||||
click.echo(
|
||||
|
||||
@@ -404,6 +404,68 @@ class TestThreadsCommand:
|
||||
assert result.exit_code == 1
|
||||
assert "project-uuid required" in result.output
|
||||
|
||||
@responses.activate
|
||||
def test_threads_custom_filename_pattern(
|
||||
self, sample_thread_response, mock_env_api_key, temp_config_dir, tmp_path
|
||||
):
|
||||
"""Test threads command with custom filename pattern."""
|
||||
with patch("langsmith_cli.config.CONFIG_DIR", temp_config_dir):
|
||||
with patch(
|
||||
"langsmith_cli.config.CONFIG_FILE", temp_config_dir / "config.yaml"
|
||||
):
|
||||
from langsmith_cli.config import set_config_value
|
||||
|
||||
set_config_value("project-uuid", TEST_PROJECT_UUID)
|
||||
|
||||
# Mock the runs query endpoint
|
||||
responses.add(
|
||||
responses.POST,
|
||||
f"{TEST_BASE_URL}/runs/query",
|
||||
json={
|
||||
"runs": [
|
||||
{
|
||||
"id": "run-1",
|
||||
"start_time": "2024-01-01T00:00:00Z",
|
||||
"extra": {"metadata": {"thread_id": "thread-1"}},
|
||||
},
|
||||
{
|
||||
"id": "run-2",
|
||||
"start_time": "2024-01-02T00:00:00Z",
|
||||
"extra": {"metadata": {"thread_id": "thread-2"}},
|
||||
},
|
||||
]
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
# Mock the thread fetch endpoints
|
||||
for thread_id in ["thread-1", "thread-2"]:
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f"{TEST_BASE_URL}/runs/threads/{thread_id}",
|
||||
json=sample_thread_response,
|
||||
status=200,
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
output_dir = tmp_path / "threads"
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"threads",
|
||||
str(output_dir),
|
||||
"--filename-pattern",
|
||||
"thread_{index:03d}.json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Found 2 thread(s)" in result.output
|
||||
|
||||
# Check that files were created with custom pattern
|
||||
assert (output_dir / "thread_001.json").exists()
|
||||
assert (output_dir / "thread_002.json").exists()
|
||||
|
||||
|
||||
class TestLatestCommand:
|
||||
"""Tests for latest command."""
|
||||
|
||||
Reference in New Issue
Block a user