From 87ad47340dce5349b17e5d0891510dc358af3e45 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 05:17:54 +0000 Subject: [PATCH 1/5] Initial plan From ffa13d058a3574046eb8f3e6bb744c70260303aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 05:22:45 +0000 Subject: [PATCH 2/5] Add dump.py and load.py with comprehensive tests Co-authored-by: hzhangxyz <11623447+hzhangxyz@users.noreply.github.com> --- ddss/dump.py | 29 +++++++ ddss/load.py | 38 +++++++++ tests/test_dump.py | 129 +++++++++++++++++++++++++++++ tests/test_load.py | 201 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 397 insertions(+) create mode 100644 ddss/dump.py create mode 100644 ddss/load.py create mode 100644 tests/test_dump.py create mode 100644 tests/test_load.py diff --git a/ddss/dump.py b/ddss/dump.py new file mode 100644 index 0000000..9477757 --- /dev/null +++ b/ddss/dump.py @@ -0,0 +1,29 @@ +import sys +import asyncio +from sqlalchemy import select +from apyds_bnf import unparse +from .orm import initialize_database, Facts, Ideas + + +async def main(addr, engine=None, session=None): + if engine is None or session is None: + engine, session = await initialize_database(addr) + + try: + async with session() as sess: + # Output all ideas first + for i in await sess.scalars(select(Ideas)): + print("idea:", unparse(i.data)) + # Then output all facts + for f in await sess.scalars(select(Facts)): + print("fact:", unparse(f.data)) + await sess.commit() + finally: + await engine.dispose() + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} ") + sys.exit(1) + asyncio.run(main(sys.argv[1])) diff --git a/ddss/load.py b/ddss/load.py new file mode 100644 index 0000000..281d273 --- /dev/null +++ b/ddss/load.py @@ -0,0 +1,38 @@ +import sys +import asyncio +from apyds_bnf import parse +from .orm import initialize_database, insert_or_ignore, Facts, Ideas +from .utility import str_rule_get_str_idea + + +async def main(addr, engine=None, session=None): + if engine is None or session is None: + engine, session = await initialize_database(addr) + + try: + # Read all lines from stdin + for line in sys.stdin: + data = line.strip() + if not data: + continue + + try: + ds = parse(data) + except Exception as e: + print(f"error: {e}", file=sys.stderr) + continue + + async with session() as sess: + await insert_or_ignore(sess, Facts, ds) + if idea := str_rule_get_str_idea(ds): + await insert_or_ignore(sess, Ideas, idea) + await sess.commit() + finally: + await engine.dispose() + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} ") + sys.exit(1) + asyncio.run(main(sys.argv[1])) diff --git a/tests/test_dump.py b/tests/test_dump.py new file mode 100644 index 0000000..48559f7 --- /dev/null +++ b/tests/test_dump.py @@ -0,0 +1,129 @@ +import tempfile +import pathlib +import pytest +import pytest_asyncio +from ddss.orm import initialize_database, Facts, Ideas +from ddss.dump import main + + +@pytest_asyncio.fixture +async def temp_db(): + """Fixture to create a temporary database.""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = pathlib.Path(tmpdir) / "test.db" + addr = f"sqlite+aiosqlite:///{db_path.as_posix()}" + engine, session = await initialize_database(addr) + yield addr, engine, session + await engine.dispose() + + +@pytest.mark.asyncio +async def test_dump_facts_correctly(temp_db, capsys): + """Test that Facts data is dumped correctly using unparse().""" + addr, engine, session = temp_db + + # Add test data + async with session() as sess: + sess.add(Facts(data="a\n----\nb\n")) + await sess.commit() + + # Run the dump function + await main(addr, engine, session) + + # Check output + captured = capsys.readouterr() + assert "fact: a => b" in captured.out + + +@pytest.mark.asyncio +async def test_dump_ideas_correctly(temp_db, capsys): + """Test that Ideas data is dumped correctly using unparse().""" + addr, engine, session = temp_db + + # Add test data + async with session() as sess: + sess.add(Ideas(data="x\n----\ny\n")) + await sess.commit() + + # Run the dump function + await main(addr, engine, session) + + # Check output + captured = capsys.readouterr() + assert "idea: x => y" in captured.out + + +@pytest.mark.asyncio +async def test_dump_multiple_entries(temp_db, capsys): + """Test that dump handles multiple Facts and Ideas correctly.""" + addr, engine, session = temp_db + + # Add test data + async with session() as sess: + sess.add(Facts(data="a\n----\nb\n")) + sess.add(Facts(data="c\n----\nd\n")) + sess.add(Ideas(data="x\n----\ny\n")) + sess.add(Ideas(data="p\n----\nq\n")) + await sess.commit() + + # Run the dump function + await main(addr, engine, session) + + # Check output + captured = capsys.readouterr() + assert "idea: x => y" in captured.out + assert "idea: p => q" in captured.out + assert "fact: a => b" in captured.out + assert "fact: c => d" in captured.out + + +@pytest.mark.asyncio +async def test_dump_empty_database(temp_db, capsys): + """Test that dump handles an empty database correctly.""" + addr, engine, session = temp_db + + # Run the dump function with no data + await main(addr, engine, session) + + # Check output - should be empty or just whitespace + captured = capsys.readouterr() + assert captured.out.strip() == "" + + +@pytest.mark.asyncio +async def test_dump_order(temp_db, capsys): + """Test that ideas are dumped before facts.""" + addr, engine, session = temp_db + + # Add test data + async with session() as sess: + sess.add(Facts(data="a\n----\nb\n")) + sess.add(Ideas(data="x\n----\ny\n")) + await sess.commit() + + # Run the dump function + await main(addr, engine, session) + + # Check output order - ideas should come before facts + captured = capsys.readouterr() + idea_pos = captured.out.find("idea: x => y") + fact_pos = captured.out.find("fact: a => b") + assert idea_pos < fact_pos, "Ideas should be printed before facts" + + +@pytest.mark.asyncio +async def test_dump_with_simple_fact(temp_db, capsys): + """Test dumping a fact that starts with dashes.""" + addr, engine, session = temp_db + + # Add test data - a fact that starts with "--" + async with session() as sess: + sess.add(Facts(data="----\nsimple\n")) + await sess.commit() + + # Run the dump function + await main(addr, engine, session) + + # Check output - unparse converts "----\nsimple\n" to " => simple" + captured = capsys.readouterr() + assert "fact: => simple" in captured.out diff --git a/tests/test_load.py b/tests/test_load.py new file mode 100644 index 0000000..f7da656 --- /dev/null +++ b/tests/test_load.py @@ -0,0 +1,201 @@ +import tempfile +import pathlib +from unittest.mock import patch +from io import StringIO +import pytest +import pytest_asyncio +from sqlalchemy import select +from ddss.orm import initialize_database, Facts, Ideas +from ddss.load import main + + +@pytest_asyncio.fixture +async def temp_db(): + """Fixture to create a temporary database.""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = pathlib.Path(tmpdir) / "test.db" + addr = f"sqlite+aiosqlite:///{db_path.as_posix()}" + engine, session = await initialize_database(addr) + yield addr, engine, session + await engine.dispose() + + +@pytest.mark.asyncio +async def test_load_valid_fact(temp_db): + """Test that valid input is parsed and stored as a Fact in the database.""" + addr, engine, session = temp_db + + # Mock stdin with valid input + mock_stdin = StringIO("a => b\n") + + with patch("sys.stdin", mock_stdin): + await main(addr, engine, session) + + # Verify data was stored + async with session() as sess: + facts = await sess.scalars(select(Facts)) + facts_list = list(facts) + assert len(facts_list) == 1 + assert facts_list[0].data == "a\n----\nb\n" + + +@pytest.mark.asyncio +async def test_load_generates_idea(temp_db): + """Test that input that parses to non-dashed form generates an Idea.""" + addr, engine, session = temp_db + + # Mock stdin with valid input + mock_stdin = StringIO("a => b\n") + + with patch("sys.stdin", mock_stdin): + await main(addr, engine, session) + + # Verify both Fact and Idea were stored + async with session() as sess: + facts = await sess.scalars(select(Facts)) + facts_list = list(facts) + assert len(facts_list) == 1 + assert facts_list[0].data == "a\n----\nb\n" + + ideas = await sess.scalars(select(Ideas)) + ideas_list = list(ideas) + assert len(ideas_list) == 1 + assert ideas_list[0].data == "----\na\n" + + +@pytest.mark.asyncio +async def test_load_invalid_input_handling(temp_db, capsys): + """Test that invalid/malformed input is handled gracefully with error message.""" + addr, engine, session = temp_db + + # Mock stdin with invalid input + mock_stdin = StringIO("=>\n") + + with patch("sys.stdin", mock_stdin): + await main(addr, engine, session) + + # Check that error was printed to stderr + captured = capsys.readouterr() + assert "error:" in captured.err + + # Verify no data was stored + async with session() as sess: + facts = await sess.scalars(select(Facts)) + facts_list = list(facts) + assert len(facts_list) == 0 + + +@pytest.mark.asyncio +async def test_load_empty_input_skipped(temp_db): + """Test that empty lines are skipped.""" + addr, engine, session = temp_db + + # Mock stdin with empty lines and valid input + mock_stdin = StringIO("\n \na => b\n") + + with patch("sys.stdin", mock_stdin): + await main(addr, engine, session) + + # Verify only the valid input was stored + async with session() as sess: + facts = await sess.scalars(select(Facts)) + facts_list = list(facts) + assert len(facts_list) == 1 + assert facts_list[0].data == "a\n----\nb\n" + + +@pytest.mark.asyncio +async def test_load_multiple_entries(temp_db): + """Test that multiple valid inputs are stored correctly.""" + addr, engine, session = temp_db + + # Mock stdin with multiple valid inputs + mock_stdin = StringIO("a => b\nc => d\nsimple\n") + + with patch("sys.stdin", mock_stdin): + await main(addr, engine, session) + + # Verify all data was stored + async with session() as sess: + facts = await sess.scalars(select(Facts)) + facts_list = list(facts) + # Should have 3 facts + assert len(facts_list) == 3 + fact_data = [f.data for f in facts_list] + assert "a\n----\nb\n" in fact_data + assert "c\n----\nd\n" in fact_data + assert "----\nsimple\n" in fact_data + + # Should have 2 ideas (from "a => b" and "c => d") + ideas = await sess.scalars(select(Ideas)) + ideas_list = list(ideas) + assert len(ideas_list) == 2 + idea_data = [i.data for i in ideas_list] + assert "----\na\n" in idea_data + assert "----\nc\n" in idea_data + + +@pytest.mark.asyncio +async def test_load_mixed_valid_and_invalid(temp_db, capsys): + """Test that valid inputs are stored even when mixed with invalid ones.""" + addr, engine, session = temp_db + + # Mock stdin with mixed valid and invalid inputs + mock_stdin = StringIO("a => b\n=>\nc => d\n") + + with patch("sys.stdin", mock_stdin): + await main(addr, engine, session) + + # Check that error was printed for invalid input + captured = capsys.readouterr() + assert "error:" in captured.err + + # Verify valid data was stored + async with session() as sess: + facts = await sess.scalars(select(Facts)) + facts_list = list(facts) + assert len(facts_list) == 2 + fact_data = [f.data for f in facts_list] + assert "a\n----\nb\n" in fact_data + assert "c\n----\nd\n" in fact_data + + +@pytest.mark.asyncio +async def test_load_empty_stdin(temp_db): + """Test that loading from empty stdin completes without errors.""" + addr, engine, session = temp_db + + # Mock empty stdin + mock_stdin = StringIO("") + + with patch("sys.stdin", mock_stdin): + await main(addr, engine, session) + + # Verify no data was stored + async with session() as sess: + facts = await sess.scalars(select(Facts)) + facts_list = list(facts) + assert len(facts_list) == 0 + + +@pytest.mark.asyncio +async def test_load_duplicate_entries(temp_db): + """Test that duplicate entries are ignored due to unique constraint.""" + addr, engine, session = temp_db + + # Pre-populate with one entry + async with session() as sess: + sess.add(Facts(data="a\n----\nb\n")) + await sess.commit() + + # Try to load the same entry again + mock_stdin = StringIO("a => b\n") + + with patch("sys.stdin", mock_stdin): + await main(addr, engine, session) + + # Verify only one entry exists (duplicate was ignored) + async with session() as sess: + facts = await sess.scalars(select(Facts)) + facts_list = list(facts) + assert len(facts_list) == 1 From 7182a2361749fa1196e96692e0e5ddc01c974efa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 05:23:50 +0000 Subject: [PATCH 3/5] Remove unnecessary commit() call in dump.py read-only operation Co-authored-by: hzhangxyz <11623447+hzhangxyz@users.noreply.github.com> --- ddss/dump.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ddss/dump.py b/ddss/dump.py index 9477757..ff9f302 100644 --- a/ddss/dump.py +++ b/ddss/dump.py @@ -17,7 +17,6 @@ async def main(addr, engine=None, session=None): # Then output all facts for f in await sess.scalars(select(Facts)): print("fact:", unparse(f.data)) - await sess.commit() finally: await engine.dispose() From 13b52e53d2a3c1f4d921358dfb95ca5c7f56fe5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 05:35:31 +0000 Subject: [PATCH 4/5] Fix formatting issues found by pre-commit hooks Co-authored-by: hzhangxyz <11623447+hzhangxyz@users.noreply.github.com> --- ddss/load.py | 4 ++-- tests/test_load.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ddss/load.py b/ddss/load.py index 281d273..e7843b0 100644 --- a/ddss/load.py +++ b/ddss/load.py @@ -15,13 +15,13 @@ async def main(addr, engine=None, session=None): data = line.strip() if not data: continue - + try: ds = parse(data) except Exception as e: print(f"error: {e}", file=sys.stderr) continue - + async with session() as sess: await insert_or_ignore(sess, Facts, ds) if idea := str_rule_get_str_idea(ds): diff --git a/tests/test_load.py b/tests/test_load.py index f7da656..9b20a4a 100644 --- a/tests/test_load.py +++ b/tests/test_load.py @@ -27,7 +27,7 @@ async def test_load_valid_fact(temp_db): # Mock stdin with valid input mock_stdin = StringIO("a => b\n") - + with patch("sys.stdin", mock_stdin): await main(addr, engine, session) @@ -46,7 +46,7 @@ async def test_load_generates_idea(temp_db): # Mock stdin with valid input mock_stdin = StringIO("a => b\n") - + with patch("sys.stdin", mock_stdin): await main(addr, engine, session) @@ -70,7 +70,7 @@ async def test_load_invalid_input_handling(temp_db, capsys): # Mock stdin with invalid input mock_stdin = StringIO("=>\n") - + with patch("sys.stdin", mock_stdin): await main(addr, engine, session) @@ -92,7 +92,7 @@ async def test_load_empty_input_skipped(temp_db): # Mock stdin with empty lines and valid input mock_stdin = StringIO("\n \na => b\n") - + with patch("sys.stdin", mock_stdin): await main(addr, engine, session) @@ -111,7 +111,7 @@ async def test_load_multiple_entries(temp_db): # Mock stdin with multiple valid inputs mock_stdin = StringIO("a => b\nc => d\nsimple\n") - + with patch("sys.stdin", mock_stdin): await main(addr, engine, session) @@ -142,7 +142,7 @@ async def test_load_mixed_valid_and_invalid(temp_db, capsys): # Mock stdin with mixed valid and invalid inputs mock_stdin = StringIO("a => b\n=>\nc => d\n") - + with patch("sys.stdin", mock_stdin): await main(addr, engine, session) @@ -167,7 +167,7 @@ async def test_load_empty_stdin(temp_db): # Mock empty stdin mock_stdin = StringIO("") - + with patch("sys.stdin", mock_stdin): await main(addr, engine, session) @@ -190,7 +190,7 @@ async def test_load_duplicate_entries(temp_db): # Try to load the same entry again mock_stdin = StringIO("a => b\n") - + with patch("sys.stdin", mock_stdin): await main(addr, engine, session) From 214af6af6e925383eb06a9df4447ca34f7c5b567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hao=20Zhang=28=E5=BC=A0=E6=B5=A9=29?= Date: Tue, 23 Dec 2025 13:38:27 +0800 Subject: [PATCH 5/5] Update ddss/load.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ddss/load.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/ddss/load.py b/ddss/load.py index e7843b0..2723d67 100644 --- a/ddss/load.py +++ b/ddss/load.py @@ -10,23 +10,23 @@ async def main(addr, engine=None, session=None): engine, session = await initialize_database(addr) try: - # Read all lines from stdin - for line in sys.stdin: - data = line.strip() - if not data: - continue + async with session() as sess: + # Read all lines from stdin + for line in sys.stdin: + data = line.strip() + if not data: + continue - try: - ds = parse(data) - except Exception as e: - print(f"error: {e}", file=sys.stderr) - continue + try: + ds = parse(data) + except Exception as e: + print(f"error: {e}", file=sys.stderr) + continue - async with session() as sess: await insert_or_ignore(sess, Facts, ds) if idea := str_rule_get_str_idea(ds): await insert_or_ignore(sess, Ideas, idea) - await sess.commit() + await sess.commit() finally: await engine.dispose()