Skip to content

Commit bd484a0

Browse files
biefanrlundeen2Copilot
authored
Respect export type in SQLite conversation exports (#1493)
Co-authored-by: Richard Lundeen <rlundeen@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: rlundeen2 <137218279+rlundeen2@users.noreply.github.com>
1 parent 43f5ffd commit bd484a0

2 files changed

Lines changed: 84 additions & 8 deletions

File tree

pyrit/memory/sqlite_memory.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from contextlib import closing, suppress
99
from datetime import datetime
1010
from pathlib import Path
11-
from typing import Any, Optional, TypeVar, Union
11+
from typing import Any, Optional, TypeVar, Union, cast
1212

1313
from sqlalchemy import and_, create_engine, func, or_, text
1414
from sqlalchemy.engine.base import Engine
@@ -35,6 +35,14 @@
3535
Model = TypeVar("Model")
3636

3737

38+
class _ExportableConversationPiece:
39+
def __init__(self, data: dict[str, Any]) -> None:
40+
self._data = data
41+
42+
def to_dict(self) -> dict[str, Any]:
43+
return self._data
44+
45+
3846
class SQLiteMemory(MemoryInterface, metaclass=Singleton):
3947
"""
4048
A memory interface that uses SQLite as the backend database.
@@ -474,6 +482,9 @@ def export_conversations(
474482
475483
Returns:
476484
Path: The path to the exported file.
485+
486+
Raises:
487+
ValueError: If the specified export format is not supported.
477488
"""
478489
# Import here to avoid circular import issues
479490
from pyrit.memory.memory_exporter import MemoryExporter
@@ -522,9 +533,20 @@ def export_conversations(
522533
piece_data["scores"] = [score.to_dict() for score in piece_scores]
523534
merged_data.append(piece_data)
524535

525-
# Export to JSON manually since the exporter expects objects but we have dicts
526-
with open(file_path, "w") as f:
527-
json.dump(merged_data, f, indent=4)
536+
if not merged_data:
537+
if export_type == "json":
538+
with open(file_path, "w", encoding="utf-8") as f:
539+
json.dump(merged_data, f, indent=4)
540+
elif export_type in self.exporter.export_strategies:
541+
file_path.write_text("", encoding="utf-8")
542+
else:
543+
raise ValueError(f"Unsupported export format: {export_type}")
544+
return file_path
545+
546+
exportable_pieces = [_ExportableConversationPiece(data=piece_data) for piece_data in merged_data]
547+
self.exporter.export_data(
548+
cast("list[MessagePiece]", exportable_pieces), file_path=file_path, export_type=export_type
549+
)
528550
return file_path
529551

530552
def print_schema(self) -> None:

tests/unit/memory/memory_interface/test_interface_export.py

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT license.
33

4+
import csv
5+
import json
46
import os
57
import tempfile
68
from collections.abc import Sequence
79
from pathlib import Path
810
from unittest.mock import MagicMock, patch
911

12+
import pytest
13+
1014
from pyrit.common.path import DB_DATA_PATH
1115
from pyrit.memory import MemoryExporter, MemoryInterface
1216
from pyrit.models import MessagePiece
@@ -103,8 +107,6 @@ def test_export_all_conversations_with_scores_correct_data(sqlite_instance: Memo
103107
assert file_path.exists()
104108

105109
# Read and verify the exported JSON content
106-
import json
107-
108110
with open(file_path) as f:
109111
exported_data = json.load(f)
110112

@@ -141,8 +143,6 @@ def test_export_all_conversations_with_scores_empty_data(sqlite_instance: Memory
141143
assert file_path.exists()
142144

143145
# Read and verify the exported JSON content is empty
144-
import json
145-
146146
with open(file_path) as f:
147147
exported_data = json.load(f)
148148

@@ -151,3 +151,57 @@ def test_export_all_conversations_with_scores_empty_data(sqlite_instance: Memory
151151
# Clean up the temp file
152152
if file_path.exists():
153153
os.remove(file_path)
154+
155+
156+
@pytest.mark.parametrize("export_type, suffix", [("json", ".json"), ("csv", ".csv"), ("md", ".md")])
157+
def test_export_all_conversations_with_scores_respects_export_type(
158+
sqlite_instance: MemoryInterface, export_type: str, suffix: str
159+
):
160+
sqlite_instance.exporter = MemoryExporter()
161+
162+
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
163+
file_path = Path(temp_file.name)
164+
temp_file.close()
165+
166+
try:
167+
with (
168+
patch.object(sqlite_instance, "get_message_pieces") as mock_get_pieces,
169+
patch.object(sqlite_instance, "get_prompt_scores") as mock_get_scores,
170+
):
171+
mock_piece = MagicMock()
172+
mock_piece.id = "piece_id_1234"
173+
mock_piece.to_dict.return_value = {
174+
"id": "piece_id_1234",
175+
"converted_value": "sample piece",
176+
}
177+
178+
mock_score = MagicMock()
179+
mock_score.message_piece_id = "piece_id_1234"
180+
mock_score.to_dict.return_value = {"message_piece_id": "piece_id_1234", "score_value": 10}
181+
182+
mock_get_pieces.return_value = [mock_piece]
183+
mock_get_scores.return_value = [mock_score]
184+
185+
sqlite_instance.export_conversations(file_path=file_path, export_type=export_type)
186+
187+
assert file_path.exists()
188+
exported_content = file_path.read_text(encoding="utf-8")
189+
assert "piece_id_1234" in exported_content
190+
assert "sample piece" in exported_content
191+
192+
if export_type == "json":
193+
exported_data = json.loads(exported_content)
194+
assert len(exported_data) == 1
195+
assert exported_data[0]["id"] == "piece_id_1234"
196+
elif export_type == "csv":
197+
with open(file_path, newline="") as exported_file:
198+
reader = csv.DictReader(exported_file)
199+
assert reader.fieldnames == ["id", "converted_value", "scores"]
200+
rows = list(reader)
201+
assert len(rows) == 1
202+
assert rows[0]["id"] == "piece_id_1234"
203+
elif export_type == "md":
204+
assert exported_content.startswith("| id | converted_value | scores |")
205+
finally:
206+
if file_path.exists():
207+
os.remove(file_path)

0 commit comments

Comments
 (0)