embedsync 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {embedsync-0.4.0 → embedsync-0.5.0}/CHANGELOG.md +5 -0
  2. {embedsync-0.4.0 → embedsync-0.5.0}/PKG-INFO +3 -2
  3. {embedsync-0.4.0 → embedsync-0.5.0}/README.md +2 -1
  4. {embedsync-0.4.0 → embedsync-0.5.0}/pyproject.toml +1 -1
  5. {embedsync-0.4.0 → embedsync-0.5.0}/src/embedsync/__init__.py +1 -1
  6. {embedsync-0.4.0 → embedsync-0.5.0}/src/embedsync/cli.py +6 -2
  7. {embedsync-0.4.0 → embedsync-0.5.0}/src/embedsync/sync/engine.py +24 -7
  8. {embedsync-0.4.0 → embedsync-0.5.0}/tests/test_embedders.py +3 -3
  9. {embedsync-0.4.0 → embedsync-0.5.0}/tests/test_engine.py +13 -0
  10. embedsync-0.5.0/tests/test_scenarios_v05.py +196 -0
  11. {embedsync-0.4.0 → embedsync-0.5.0}/.env.example +0 -0
  12. {embedsync-0.4.0 → embedsync-0.5.0}/.github/workflows/ci.yml +0 -0
  13. {embedsync-0.4.0 → embedsync-0.5.0}/.gitignore +0 -0
  14. {embedsync-0.4.0 → embedsync-0.5.0}/CONTRIBUTING.md +0 -0
  15. {embedsync-0.4.0 → embedsync-0.5.0}/LICENSE +0 -0
  16. {embedsync-0.4.0 → embedsync-0.5.0}/SECURITY.md +0 -0
  17. {embedsync-0.4.0 → embedsync-0.5.0}/compose.yaml +0 -0
  18. {embedsync-0.4.0 → embedsync-0.5.0}/docker/Dockerfile +0 -0
  19. {embedsync-0.4.0 → embedsync-0.5.0}/examples/docs/api.md +0 -0
  20. {embedsync-0.4.0 → embedsync-0.5.0}/examples/docs/getting-started.md +0 -0
  21. {embedsync-0.4.0 → embedsync-0.5.0}/plans/README.md +0 -0
  22. {embedsync-0.4.0 → embedsync-0.5.0}/plans/v0.1-completed-foundation.md +0 -0
  23. {embedsync-0.4.0 → embedsync-0.5.0}/plans/v0.2-next-phase-roadmap.md +0 -0
  24. {embedsync-0.4.0 → embedsync-0.5.0}/src/embedsync/chunking.py +0 -0
  25. {embedsync-0.4.0 → embedsync-0.5.0}/src/embedsync/config.py +0 -0
  26. {embedsync-0.4.0 → embedsync-0.5.0}/src/embedsync/destinations/__init__.py +0 -0
  27. {embedsync-0.4.0 → embedsync-0.5.0}/src/embedsync/destinations/jsonl.py +0 -0
  28. {embedsync-0.4.0 → embedsync-0.5.0}/src/embedsync/destinations/memory.py +0 -0
  29. {embedsync-0.4.0 → embedsync-0.5.0}/src/embedsync/embedders.py +0 -0
  30. {embedsync-0.4.0 → embedsync-0.5.0}/src/embedsync/sources/__init__.py +0 -0
  31. {embedsync-0.4.0 → embedsync-0.5.0}/src/embedsync/sources/local.py +0 -0
  32. {embedsync-0.4.0 → embedsync-0.5.0}/src/embedsync/state/__init__.py +0 -0
  33. {embedsync-0.4.0 → embedsync-0.5.0}/src/embedsync/state/store.py +0 -0
  34. {embedsync-0.4.0 → embedsync-0.5.0}/src/embedsync/sync/__init__.py +0 -0
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0] - 2026-09-14
4
+
5
+ ### Added
6
+ - `--full-reindex` on `run` / `plan`: force re-embed all current docs (ignore content-hash equality); removed docs still DELETE
7
+
3
8
  ## [0.4.0] - 2026-09-14
4
9
 
5
10
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: embedsync
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Incremental synchronization between source documents and vector indexes
5
5
  Project-URL: Homepage, https://github.com/yashshah9/embedsync
6
6
  Project-URL: Repository, https://github.com/yashshah9/embedsync
@@ -29,7 +29,7 @@ Incremental synchronization between **source documents** and **vector indexes**
29
29
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
30
30
  [![CI](https://github.com/yashshah9/embedsync/actions/workflows/ci.yml/badge.svg)](https://github.com/yashshah9/embedsync/actions/workflows/ci.yml)
31
31
 
32
- > **Status:** v0.4 — hash + Ollama embedders, paragraph chunks, JSONL destination, chunk-level re-embed.
32
+ > **Status:** v0.5 — hash + Ollama embedders, paragraph chunks, JSONL destination, chunk-level re-embed, `--full-reindex`.
33
33
 
34
34
  ## 60-second try
35
35
 
@@ -84,6 +84,7 @@ embedsync health
84
84
  embedsync plan examples/docs --state-db /tmp/embedsync-demo.db
85
85
  embedsync run examples/docs --dry-run --state-db /tmp/embedsync-demo.db
86
86
  embedsync run examples/docs --embedder hash --destination memory --state-db /tmp/embedsync-demo.db
87
+ embedsync run examples/docs --full-reindex --embedder hash --destination jsonl:/tmp/index.jsonl
87
88
  embedsync run examples/docs --embedder hash --destination jsonl:/tmp/index.jsonl
88
89
  # Requires a running Ollama with an embedding model:
89
90
  embedsync run examples/docs --embedder ollama --destination jsonl:/tmp/index.jsonl
@@ -6,7 +6,7 @@ Incremental synchronization between **source documents** and **vector indexes**
6
6
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
7
7
  [![CI](https://github.com/yashshah9/embedsync/actions/workflows/ci.yml/badge.svg)](https://github.com/yashshah9/embedsync/actions/workflows/ci.yml)
8
8
 
9
- > **Status:** v0.4 — hash + Ollama embedders, paragraph chunks, JSONL destination, chunk-level re-embed.
9
+ > **Status:** v0.5 — hash + Ollama embedders, paragraph chunks, JSONL destination, chunk-level re-embed, `--full-reindex`.
10
10
 
11
11
  ## 60-second try
12
12
 
@@ -61,6 +61,7 @@ embedsync health
61
61
  embedsync plan examples/docs --state-db /tmp/embedsync-demo.db
62
62
  embedsync run examples/docs --dry-run --state-db /tmp/embedsync-demo.db
63
63
  embedsync run examples/docs --embedder hash --destination memory --state-db /tmp/embedsync-demo.db
64
+ embedsync run examples/docs --full-reindex --embedder hash --destination jsonl:/tmp/index.jsonl
64
65
  embedsync run examples/docs --embedder hash --destination jsonl:/tmp/index.jsonl
65
66
  # Requires a running Ollama with an embedding model:
66
67
  embedsync run examples/docs --embedder ollama --destination jsonl:/tmp/index.jsonl
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "embedsync"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Incremental synchronization between source documents and vector indexes"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """Incremental vector index synchronization."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.5.0"
@@ -46,11 +46,12 @@ def _destination(dest_spec: str) -> MemoryDestination | JsonlDestination:
46
46
  @main.command("plan")
47
47
  @click.argument("source_dir", type=click.Path(exists=True, file_okay=False, path_type=Path))
48
48
  @click.option("--state-db", default=None, help="SQLite state database path")
49
- def plan_cmd(source_dir: Path, state_db: str | None) -> None:
49
+ @click.option("--full-reindex", is_flag=True, help="Treat all current docs as updates")
50
+ def plan_cmd(source_dir: Path, state_db: str | None, full_reindex: bool) -> None:
50
51
  store, path = _store(state_db)
51
52
  try:
52
53
  source = LocalFileSource(source_dir)
53
- sync_plan = plan_sync(source, store)
54
+ sync_plan = plan_sync(source, store, full_reindex=full_reindex)
54
55
  except ValueError as exc:
55
56
  console.print(f"[red]Error:[/red] {exc}")
56
57
  raise SystemExit(2) from exc
@@ -76,12 +77,14 @@ def plan_cmd(source_dir: Path, state_db: str | None) -> None:
76
77
  @click.option("--state-db", default=None)
77
78
  @click.option("--embedder", default="hash")
78
79
  @click.option("--destination", "dest_spec", default="memory", help="memory | jsonl:/path")
80
+ @click.option("--full-reindex", is_flag=True, help="Force re-embed all current docs")
79
81
  def run_cmd(
80
82
  source_dir: Path,
81
83
  dry_run: bool,
82
84
  state_db: str | None,
83
85
  embedder: str,
84
86
  dest_spec: str,
87
+ full_reindex: bool,
85
88
  ) -> None:
86
89
  store, path = _store(state_db)
87
90
  try:
@@ -92,6 +95,7 @@ def run_cmd(
92
95
  destination=dest,
93
96
  dry_run=dry_run,
94
97
  embedder=resolve_embedder(embedder),
98
+ full_reindex=full_reindex,
95
99
  )
96
100
  except (ValueError, click.UsageError) as exc:
97
101
  store.close()
@@ -5,7 +5,12 @@ from dataclasses import dataclass, field
5
5
  import structlog
6
6
 
7
7
  from embedsync.chunking import chunk_document
8
- from embedsync.destinations.memory import Destination, DestinationReport, MemoryDestination, SyncAction
8
+ from embedsync.destinations.memory import (
9
+ Destination,
10
+ DestinationReport,
11
+ MemoryDestination,
12
+ SyncAction,
13
+ )
9
14
  from embedsync.embedders import Embedder, HashEmbedder
10
15
  from embedsync.sources.local import LocalFileSource
11
16
  from embedsync.state.store import DocumentState, StateStore, content_hash
@@ -24,7 +29,11 @@ class SyncPlan:
24
29
  return len(self.adds) + len(self.updates) + len(self.deletes)
25
30
 
26
31
 
27
- def plan_sync(source: LocalFileSource, store: StateStore) -> SyncPlan:
32
+ def plan_sync(
33
+ source: LocalFileSource,
34
+ store: StateStore,
35
+ full_reindex: bool = False,
36
+ ) -> SyncPlan:
28
37
  """Compute add/update/delete plan without touching the destination."""
29
38
  plan = SyncPlan()
30
39
  current_ids: set[str] = set()
@@ -43,7 +52,7 @@ def plan_sync(source: LocalFileSource, store: StateStore) -> SyncPlan:
43
52
  )
44
53
  if existing is None:
45
54
  plan.adds.append(action)
46
- elif existing.content_hash != digest:
55
+ elif full_reindex or existing.content_hash != digest:
47
56
  action.action = "update"
48
57
  plan.updates.append(action)
49
58
 
@@ -60,19 +69,27 @@ def execute_sync(
60
69
  destination: Destination | None = None,
61
70
  dry_run: bool = False,
62
71
  embedder: Embedder | None = None,
72
+ full_reindex: bool = False,
63
73
  ) -> DestinationReport:
64
74
  dest: Destination = destination or MemoryDestination()
65
75
  encoder = embedder or HashEmbedder()
66
- plan = plan_sync(source, store)
76
+ plan = plan_sync(source, store, full_reindex=full_reindex)
67
77
  report = DestinationReport()
68
78
  docs = {d.doc_id: d for d in source.list_documents()}
69
79
 
70
80
  for action in plan.adds + plan.updates:
71
81
  old = store.chunks_for(action.doc_id)
72
82
  new_hashes = {chunk.chunk_id: content_hash(chunk.content) for chunk in action.chunks}
73
- changed = [chunk for chunk in action.chunks if old.get(chunk.chunk_id) != new_hashes[chunk.chunk_id]]
74
- removed = [chunk_id for chunk_id in old if chunk_id not in new_hashes]
75
- write_chunks = action.chunks if action.action == "add" else changed
83
+ if full_reindex and action.action == "update":
84
+ # Force DELETE old chunks + ADD fresh embeddings, ignore content-hash equality.
85
+ write_chunks = action.chunks
86
+ removed = list(old.keys())
87
+ else:
88
+ changed = [
89
+ chunk for chunk in action.chunks if old.get(chunk.chunk_id) != new_hashes[chunk.chunk_id]
90
+ ]
91
+ removed = [chunk_id for chunk_id in old if chunk_id not in new_hashes]
92
+ write_chunks = action.chunks if action.action == "add" else changed
76
93
  texts = [c.content for c in write_chunks]
77
94
  vectors = encoder.embed(texts) if texts else []
78
95
  dest_action = SyncAction(
@@ -1,8 +1,8 @@
1
1
  """Tests for embedders."""
2
2
 
3
3
  import json
4
- from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
5
4
  import threading
5
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
6
6
 
7
7
  from embedsync.embedders import HashEmbedder, OllamaEmbedder, resolve_embedder
8
8
 
@@ -23,7 +23,7 @@ def test_resolve_ollama_model_name() -> None:
23
23
 
24
24
  def test_ollama_embedder_against_mock_server() -> None:
25
25
  class Handler(BaseHTTPRequestHandler):
26
- def do_POST(self) -> None: # noqa: N802
26
+ def do_POST(self) -> None:
27
27
  length = int(self.headers.get("Content-Length", "0"))
28
28
  body = json.loads(self.rfile.read(length))
29
29
  assert body["model"] == "nomic-embed-text"
@@ -34,7 +34,7 @@ def test_ollama_embedder_against_mock_server() -> None:
34
34
  self.end_headers()
35
35
  self.wfile.write(payload)
36
36
 
37
- def log_message(self, format: str, *args: object) -> None: # noqa: A003
37
+ def log_message(self, format: str, *args: object) -> None:
38
38
  return
39
39
 
40
40
  httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
@@ -30,6 +30,19 @@ def test_run_indexes_documents(tmp_path: Path) -> None:
30
30
  store.close()
31
31
 
32
32
 
33
+ def test_full_reindex_rewrites_unchanged_content(tmp_path: Path) -> None:
34
+ store = StateStore(str(tmp_path / "state.db"))
35
+ source = LocalFileSource(DOCS)
36
+ dest = MemoryDestination()
37
+ first = execute_sync(source, store, dest)
38
+ assert first.embeddings_written > 0
39
+ second = execute_sync(source, store, dest)
40
+ assert second.embeddings_written == 0
41
+ third = execute_sync(source, store, dest, full_reindex=True)
42
+ assert third.embeddings_written == first.embeddings_written
43
+ store.close()
44
+
45
+
33
46
  def test_update_reembeds_changed_chunks_only(tmp_path: Path) -> None:
34
47
  docs = tmp_path / "docs"
35
48
  docs.mkdir()
@@ -0,0 +1,196 @@
1
+ """Scenario tests for embedsync 0.5 behavior."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import click
9
+ import pytest
10
+ from click.testing import CliRunner
11
+
12
+ from embedsync.cli import _destination, main
13
+ from embedsync.destinations.jsonl import JsonlDestination
14
+ from embedsync.destinations.memory import MemoryDestination
15
+ from embedsync.embedders import HashEmbedder, OllamaEmbedder, resolve_embedder
16
+ from embedsync.sources.local import LocalFileSource
17
+ from embedsync.state.store import StateStore
18
+ from embedsync.sync.engine import execute_sync, plan_sync
19
+
20
+ DOCS = Path(__file__).parent.parent / "examples" / "docs"
21
+
22
+
23
+ def _docs(tmp_path: Path, files: dict[str, str]) -> Path:
24
+ root = tmp_path / "docs"
25
+ root.mkdir()
26
+ for name, text in files.items():
27
+ path = root / name
28
+ path.parent.mkdir(parents=True, exist_ok=True)
29
+ path.write_text(text, encoding="utf-8")
30
+ return root
31
+
32
+
33
+ def test_full_reindex_forces_rewrite_when_unchanged(tmp_path: Path) -> None:
34
+ store = StateStore(str(tmp_path / "state.db"))
35
+ source = LocalFileSource(DOCS)
36
+ dest = MemoryDestination()
37
+ first = execute_sync(source, store, dest)
38
+ assert first.embeddings_written > 0
39
+ assert execute_sync(source, store, dest).embeddings_written == 0
40
+ third = execute_sync(source, store, dest, full_reindex=True)
41
+ assert third.embeddings_written == first.embeddings_written
42
+ store.close()
43
+
44
+
45
+ def test_plan_adds_new_documents(tmp_path: Path) -> None:
46
+ docs = _docs(tmp_path, {"a.md": "alpha content here"})
47
+ store = StateStore(str(tmp_path / "state.db"))
48
+ plan = plan_sync(LocalFileSource(docs), store)
49
+ assert len(plan.adds) == 1
50
+ assert plan.adds[0].doc_id == "a.md"
51
+ assert plan.updates == []
52
+ assert plan.deletes == []
53
+ store.close()
54
+
55
+
56
+ def test_plan_updates_changed_documents(tmp_path: Path) -> None:
57
+ docs = _docs(tmp_path, {"a.md": "original text"})
58
+ store = StateStore(str(tmp_path / "state.db"))
59
+ execute_sync(LocalFileSource(docs), store, MemoryDestination())
60
+ (docs / "a.md").write_text("changed text", encoding="utf-8")
61
+ plan = plan_sync(LocalFileSource(docs), store)
62
+ assert plan.adds == []
63
+ assert len(plan.updates) == 1
64
+ assert plan.updates[0].doc_id == "a.md"
65
+ store.close()
66
+
67
+
68
+ def test_plan_deletes_removed_documents(tmp_path: Path) -> None:
69
+ docs = _docs(tmp_path, {"keep.md": "keep", "gone.md": "gone"})
70
+ store = StateStore(str(tmp_path / "state.db"))
71
+ execute_sync(LocalFileSource(docs), store, MemoryDestination())
72
+ (docs / "gone.md").unlink()
73
+ plan = plan_sync(LocalFileSource(docs), store)
74
+ assert len(plan.deletes) == 1
75
+ assert plan.deletes[0].doc_id == "gone.md"
76
+ store.close()
77
+
78
+
79
+ def test_plan_full_reindex_marks_existing_as_updates(tmp_path: Path) -> None:
80
+ docs = _docs(tmp_path, {"a.md": "stable"})
81
+ store = StateStore(str(tmp_path / "state.db"))
82
+ execute_sync(LocalFileSource(docs), store, MemoryDestination())
83
+ plan = plan_sync(LocalFileSource(docs), store, full_reindex=True)
84
+ assert plan.adds == []
85
+ assert len(plan.updates) == 1
86
+ store.close()
87
+
88
+
89
+ def test_idempotent_run_writes_zero_on_second_pass(tmp_path: Path) -> None:
90
+ store = StateStore(str(tmp_path / "state.db"))
91
+ source = LocalFileSource(DOCS)
92
+ dest = MemoryDestination()
93
+ first = execute_sync(source, store, dest)
94
+ second = execute_sync(source, store, dest)
95
+ assert first.embeddings_written > 0
96
+ assert second.embeddings_written == 0
97
+ assert second.actions == []
98
+ store.close()
99
+
100
+
101
+ def test_hash_embedder_dimension() -> None:
102
+ emb = HashEmbedder(dimension=16)
103
+ vectors = emb.embed(["hello", "world"])
104
+ assert emb.dimension == 16
105
+ assert len(vectors) == 2
106
+ assert all(len(v) == 16 for v in vectors)
107
+
108
+
109
+ def test_hash_embedder_stability() -> None:
110
+ emb = HashEmbedder(dimension=8)
111
+ a = emb.embed(["stable text"])[0]
112
+ b = emb.embed(["stable text"])[0]
113
+ c = emb.embed(["different"])[0]
114
+ assert a == b
115
+ assert a != c
116
+
117
+
118
+ def test_resolve_ollama_default_model() -> None:
119
+ emb = resolve_embedder("ollama")
120
+ assert isinstance(emb, OllamaEmbedder)
121
+ assert emb.model == "nomic-embed-text"
122
+
123
+
124
+ def test_resolve_ollama_colon_model() -> None:
125
+ emb = resolve_embedder("ollama:mxbai-embed-large")
126
+ assert isinstance(emb, OllamaEmbedder)
127
+ assert emb.model == "mxbai-embed-large"
128
+
129
+
130
+ def test_bad_destination_raises() -> None:
131
+ with pytest.raises(click.UsageError, match="memory.*jsonl"):
132
+ _destination("pgvector://localhost")
133
+
134
+
135
+ def test_empty_source_dir_cli_error(tmp_path: Path) -> None:
136
+ empty = tmp_path / "empty"
137
+ empty.mkdir()
138
+ runner = CliRunner()
139
+ # Empty dir is valid for click Path, but plan reports zero actions (no docs).
140
+ result = runner.invoke(
141
+ main,
142
+ ["plan", str(empty), "--state-db", str(tmp_path / "state.db")],
143
+ )
144
+ assert result.exit_code == 0
145
+ assert "Total: 0 action(s)" in result.output
146
+
147
+
148
+ def test_missing_source_dir_cli_error(tmp_path: Path) -> None:
149
+ runner = CliRunner()
150
+ result = runner.invoke(
151
+ main,
152
+ ["plan", str(tmp_path / "does-not-exist"), "--state-db", str(tmp_path / "state.db")],
153
+ )
154
+ assert result.exit_code != 0
155
+
156
+
157
+ def test_bad_destination_cli_error(tmp_path: Path) -> None:
158
+ docs = _docs(tmp_path, {"a.md": "hi"})
159
+ runner = CliRunner()
160
+ result = runner.invoke(
161
+ main,
162
+ [
163
+ "run",
164
+ str(docs),
165
+ "--state-db",
166
+ str(tmp_path / "state.db"),
167
+ "--destination",
168
+ "bogus",
169
+ ],
170
+ )
171
+ assert result.exit_code != 0
172
+
173
+
174
+ def test_jsonl_destination_write_roundtrip(tmp_path: Path) -> None:
175
+ docs = _docs(tmp_path, {"note.md": "jsonl roundtrip content"})
176
+ out = tmp_path / "out" / "embeddings.jsonl"
177
+ store = StateStore(str(tmp_path / "state.db"))
178
+ dest = JsonlDestination(out)
179
+ report = execute_sync(LocalFileSource(docs), store, dest)
180
+ assert report.embeddings_written > 0
181
+ assert out.exists()
182
+ rows = [json.loads(line) for line in out.read_text(encoding="utf-8").splitlines() if line.strip()]
183
+ assert len(rows) == report.embeddings_written
184
+ assert all("embedding" in r and "chunk_id" in r and r["doc_id"] == "note.md" for r in rows)
185
+ store.close()
186
+
187
+
188
+ def test_dry_run_writes_zero_embeddings(tmp_path: Path) -> None:
189
+ store = StateStore(str(tmp_path / "state.db"))
190
+ dest = MemoryDestination()
191
+ report = execute_sync(LocalFileSource(DOCS), store, dest, dry_run=True)
192
+ assert report.embeddings_written == 0
193
+ assert dest.indexed == {}
194
+ assert dest.vectors == {}
195
+ assert store.all_ids() == set()
196
+ store.close()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes