omarsol commited on
Commit
c8c398a
·
1 Parent(s): 332195a

Archive private KB bundles for fast cold starts

Browse files
app/config.py CHANGED
@@ -152,11 +152,10 @@ def _snapshot_bundle(repo_id: str, *, token: str | bool | None) -> None:
152
 
153
  Mutes httpx's per-file flood during the cold-start download only. The
154
  GraphRAG experiment index (~150 MB) lives in the private repo for
155
- reproducibility but prod does not use it; skip it on cold start. Pull it
156
- explicitly to run that eval. ``README.md`` is the public repo's dataset
157
- card, not runtime data. (The private bundle has neither a kb archive nor
158
- a README, and the public one has no graphrag/ tree, so unmatched patterns
159
- are no-ops.)
160
  """
161
  from huggingface_hub import snapshot_download
162
 
@@ -169,7 +168,12 @@ def _snapshot_bundle(repo_id: str, *, token: str | bool | None) -> None:
169
  local_dir="data",
170
  repo_type="dataset",
171
  token=token,
172
- ignore_patterns=["graphrag/**", "README.md"],
 
 
 
 
 
173
  )
174
  finally:
175
  httpx_logger.setLevel(previous_level)
@@ -179,10 +183,9 @@ def _snapshot_bundle(repo_id: str, *, token: str | bool | None) -> None:
179
  def _extract_kb_archive(base_dir: str = "data") -> None:
180
  """Extract ``kb.tar.gz`` into ``data/kb`` and delete the archive.
181
 
182
- The public bundle ships the KB as one archive instead of ~3,000 files so
183
- an anonymous cold start is not throttled by HF's per-request rate limits
184
- (see build_public_docs_bundle.archive_kb). A no-op when no archive was
185
- downloaded (the private bundle ships the unpacked tree).
186
  """
187
  import tarfile
188
 
 
152
 
153
  Mutes httpx's per-file flood during the cold-start download only. The
154
  GraphRAG experiment index (~150 MB) lives in the private repo for
155
+ reproducibility but prod does not use it; skip it on cold start. The
156
+ contextual-nodes pickle is another rebuild-only artifact. Pull either
157
+ explicitly for data/eval work. ``README.md`` is the public repo's dataset
158
+ card, not runtime data. Both runtime bundles ship ``kb.tar.gz``.
 
159
  """
160
  from huggingface_hub import snapshot_download
161
 
 
168
  local_dir="data",
169
  repo_type="dataset",
170
  token=token,
171
+ ignore_patterns=[
172
+ "graphrag/**",
173
+ "all_sources_contextual_nodes.pkl",
174
+ ".gitattributes",
175
+ "README.md",
176
+ ],
177
  )
178
  finally:
179
  httpx_logger.setLevel(previous_level)
 
183
  def _extract_kb_archive(base_dir: str = "data") -> None:
184
  """Extract ``kb.tar.gz`` into ``data/kb`` and delete the archive.
185
 
186
+ Both private and public runtime bundles ship the KB as one archive instead
187
+ of ~3,000 files, avoiding per-file Hub overhead during cold starts. A no-op
188
+ for legacy bundles that still contain the unpacked tree.
 
189
  """
190
  import tarfile
191
 
data/scraping_scripts/README.md CHANGED
@@ -212,8 +212,10 @@ place on startup either way).
212
 
213
  - **Built** by `data.scraping_scripts.build_kb_artifacts` (from
214
  `data/all_sources_data.jsonl`) and `data.scraping_scripts.update_kb_wiki`.
215
- - **Uploaded** to `towardsai-tutors/ai-tutor-vector-db` by
216
- `data.scraping_scripts.upload_dbs_to_hf` (already includes `kb/**`).
 
 
217
  - **Downloaded** by `app.config.ensure_local_vector_db` on the first
218
  chatbot start (or any start where the local KB is missing).
219
 
@@ -323,8 +325,12 @@ After the audit, the KB tree is packed into a single **`kb.tar.gz`** and the
323
  unpacked tree is dropped from staging: the public repo ships ~6 files instead
324
  of ~3,000, which keeps a token-free cold start clear of HF's anonymous
325
  per-request rate limits (~15 min unpacked vs a few min archived). The runtime
326
- extracts the archive after download (`app.config._extract_kb_archive`); the
327
- private bundle still ships the unpacked KB tree and is unaffected.
 
 
 
 
328
 
329
  The staged tree lives in `data/public_docs_bundle/` (gitignored). Uploading
330
  needs an `HF_TOKEN` with write access to the `towardsai-tutors` org; the script
@@ -368,4 +374,6 @@ download small).
368
  - `update_kb_wiki.py` refreshes wiki navigation pages under `data/kb/wiki/`, preserving maintainer prose outside `<!-- AUTO-GENERATED -->` markers
369
  - `lint_kb_wiki.py` then verifies every KB path and relative link the wiki pages reference (existence on disk plus `corpus_manifest.jsonl` membership for `raw/` files) and fails the workflow before upload on any broken reference; run it standalone with `uv run -m data.scraping_scripts.lint_kb_wiki [--kb-dir data/kb]`
370
  - The docs and course workflows run these steps automatically unless `--skip-kb` is passed
371
- - `upload_dbs_to_hf.py` uploads `data/kb/**` with the vector database so the runtime can download one KB bundle
 
 
 
212
 
213
  - **Built** by `data.scraping_scripts.build_kb_artifacts` (from
214
  `data/all_sources_data.jsonl`) and `data.scraping_scripts.update_kb_wiki`.
215
+ - **Archived and uploaded** to `towardsai-tutors/ai-tutor-vector-db` by
216
+ `data.scraping_scripts.upload_dbs_to_hf` as `kb.tar.gz`. The local unpacked
217
+ tree is preserved; legacy remote `kb/**` files are pruned only after the
218
+ archive upload is verified.
219
  - **Downloaded** by `app.config.ensure_local_vector_db` on the first
220
  chatbot start (or any start where the local KB is missing).
221
 
 
325
  unpacked tree is dropped from staging: the public repo ships ~6 files instead
326
  of ~3,000, which keeps a token-free cold start clear of HF's anonymous
327
  per-request rate limits (~15 min unpacked vs a few min archived). The runtime
328
+ extracts the archive after download (`app.config._extract_kb_archive`).
329
+
330
+ The private production publisher now uses the same archive layout: it creates
331
+ `data/kb.tar.gz` transiently, uploads and verifies it, then prunes the legacy
332
+ remote `kb/**` tree. It never removes or rewrites the canonical local
333
+ `data/kb/` directory.
334
 
335
  The staged tree lives in `data/public_docs_bundle/` (gitignored). Uploading
336
  needs an `HF_TOKEN` with write access to the `towardsai-tutors` org; the script
 
374
  - `update_kb_wiki.py` refreshes wiki navigation pages under `data/kb/wiki/`, preserving maintainer prose outside `<!-- AUTO-GENERATED -->` markers
375
  - `lint_kb_wiki.py` then verifies every KB path and relative link the wiki pages reference (existence on disk plus `corpus_manifest.jsonl` membership for `raw/` files) and fails the workflow before upload on any broken reference; run it standalone with `uv run -m data.scraping_scripts.lint_kb_wiki [--kb-dir data/kb]`
376
  - The docs and course workflows run these steps automatically unless `--skip-kb` is passed
377
+ - `upload_dbs_to_hf.py` packs `data/kb/` into `kb.tar.gz`, uploads it with
378
+ the vector database, verifies the remote archive, and only then prunes
379
+ the old unpacked remote KB files
data/scraping_scripts/upload_dbs_to_hf.py CHANGED
@@ -1,7 +1,9 @@
1
- """Upload the Chroma vector database to a Hugging Face dataset repository."""
2
 
3
  import argparse
 
4
  import os
 
5
  from pathlib import Path
6
 
7
  from dotenv import load_dotenv
@@ -18,30 +20,93 @@ load_dotenv()
18
  DEFAULT_REPO_ID = "towardsai-tutors/ai-tutor-vector-db"
19
 
20
  FOLDER_PATH = "data"
 
 
21
  ALLOW_PATTERNS = [
22
  "chroma-db-all_sources/**",
23
  "all_sources_contextual_nodes.pkl",
24
- "kb/**",
25
  ]
 
 
 
 
26
  IGNORE_PATTERNS = ["*.py", "*.ipynb", "*.pyc", "__pycache__/**"]
27
 
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  def _prune_stale_remote_files(
30
  api: HfApi,
31
  repo_id: str,
32
  *,
33
  folder_path: str,
34
  allow_patterns: list[str],
 
35
  ignore_patterns: list[str],
36
  ) -> None:
37
- """Restore the auto-prune behavior we lost when switching from
38
- `upload_folder(..., delete_patterns=["*"])` to `upload_large_folder`.
39
-
40
- Compute the set of remote files that match `allow_patterns` (and don't
41
- match `ignore_patterns`) but are no longer present locally, and delete
42
- them in a single commit before the upload runs. We deliberately do this
43
- in its own commit so the subsequent `upload_large_folder` stays
44
- additive and resumable.
45
  """
46
  local_root = Path(folder_path)
47
  local_files_all = [
@@ -57,11 +122,22 @@ def _prune_stale_remote_files(
57
  )
58
  )
59
 
60
- remote_files_all = api.list_repo_files(repo_id, repo_type="dataset")
 
 
 
 
 
 
 
 
 
 
 
61
  remote_kept = list(
62
  filter_repo_objects(
63
  remote_files_all,
64
- allow_patterns=allow_patterns,
65
  ignore_patterns=ignore_patterns,
66
  )
67
  )
@@ -71,9 +147,11 @@ def _prune_stale_remote_files(
71
  print("Prune step: no stale remote files to delete.")
72
  return
73
 
74
- print(f"Prune step: deleting {len(stale)} stale remote file(s):")
75
- for f in stale:
76
  print(f" - {f}")
 
 
77
 
78
  api.create_commit(
79
  repo_id=repo_id,
@@ -88,10 +166,11 @@ def upload_bundle(
88
  *,
89
  folder_path: str = FOLDER_PATH,
90
  allow_patterns: list[str] | None = None,
 
91
  ignore_patterns: list[str] | None = None,
92
  create_public: bool = False,
93
  ) -> None:
94
- """Prune stale remote files, then upload ``folder_path`` to ``repo_id``.
95
 
96
  The default arguments reproduce the production upload (the private
97
  all-sources bundle). ``build_public_docs_bundle`` reuses this with a
@@ -114,32 +193,42 @@ def upload_bundle(
114
  print(exc)
115
  raise SystemExit(1) from exc
116
 
117
- # Step 1: prune. Restore the auto-cleanup we lost when moving off
118
- # `upload_folder(..., delete_patterns=["*"])`. See helper docstring.
119
- _prune_stale_remote_files(
120
- api,
121
- repo_id,
122
  folder_path=folder_path,
 
 
123
  allow_patterns=allow,
124
  ignore_patterns=ignore,
125
  )
126
 
127
- # Step 2: upload. `upload_large_folder` is the recommended path once the
128
- # total payload crosses ~1 GB — it commits in many small batches instead
129
- # of one big atomic commit, which sidesteps the `commit/main` 500s
130
- # `upload_folder` tends to hit at this size. It is resumable: re-running
131
- # after a failure skips already-uploaded blobs.
132
- api.upload_large_folder(
133
  folder_path=folder_path,
134
- repo_id=repo_id,
135
- repo_type="dataset",
136
  allow_patterns=allow,
 
137
  ignore_patterns=ignore,
138
  )
139
 
140
 
141
  def upload_vector_db(repo_id: str = DEFAULT_REPO_ID) -> None:
142
- upload_bundle(repo_id)
 
 
 
 
 
 
 
 
 
 
143
 
144
 
145
  GRAPHRAG_LOCAL_DIR = "data/graphrag/output"
 
1
+ """Publish the private vector database and archived KB to Hugging Face."""
2
 
3
  import argparse
4
+ import gzip
5
  import os
6
+ import tarfile
7
  from pathlib import Path
8
 
9
  from dotenv import load_dotenv
 
20
  DEFAULT_REPO_ID = "towardsai-tutors/ai-tutor-vector-db"
21
 
22
  FOLDER_PATH = "data"
23
+ KB_DIR_NAME = "kb"
24
+ KB_ARCHIVE_NAME = "kb.tar.gz"
25
  ALLOW_PATTERNS = [
26
  "chroma-db-all_sources/**",
27
  "all_sources_contextual_nodes.pkl",
28
+ KB_ARCHIVE_NAME,
29
  ]
30
+ # The upload contains only the archive, but the prune pass also owns the
31
+ # unpacked tree left by older publishes. Keeping these scopes separate lets us
32
+ # delete kb/** without re-uploading it from the local build artifacts.
33
+ PRUNE_ALLOW_PATTERNS = [*ALLOW_PATTERNS, f"{KB_DIR_NAME}/**"]
34
  IGNORE_PATTERNS = ["*.py", "*.ipynb", "*.pyc", "__pycache__/**"]
35
 
36
 
37
+ def _normalized_tar_info(info: tarfile.TarInfo) -> tarfile.TarInfo:
38
+ """Remove host-specific metadata so unchanged KBs produce one stable blob."""
39
+ info.uid = 0
40
+ info.gid = 0
41
+ info.uname = ""
42
+ info.gname = ""
43
+ info.mtime = 0
44
+ info.pax_headers = {}
45
+ if info.isdir():
46
+ info.mode = 0o755
47
+ elif info.isfile():
48
+ info.mode = 0o644
49
+ return info
50
+
51
+
52
+ def create_kb_archive(folder_path: str = FOLDER_PATH) -> Path:
53
+ """Build ``kb.tar.gz`` atomically without modifying the local KB tree."""
54
+ root = Path(folder_path)
55
+ kb_dir = root / KB_DIR_NAME
56
+ required = (
57
+ kb_dir / "generated" / "corpus_manifest.jsonl",
58
+ kb_dir / "wiki" / "index.md",
59
+ )
60
+ missing = [str(path) for path in required if not path.is_file()]
61
+ if missing:
62
+ raise FileNotFoundError(
63
+ "Cannot publish an incomplete KB; missing: " + ", ".join(missing)
64
+ )
65
+
66
+ archive_path = root / KB_ARCHIVE_NAME
67
+ temp_path = archive_path.with_name(f"{archive_path.name}.tmp")
68
+ temp_path.unlink(missing_ok=True)
69
+ print(f"Archiving {kb_dir} -> {archive_path} ...")
70
+ try:
71
+ with temp_path.open("wb") as raw:
72
+ # Fix the gzip timestamp/name and tar ownership/timestamps so an
73
+ # unchanged KB hashes identically across machines and publishes.
74
+ with gzip.GzipFile(
75
+ filename="", mode="wb", fileobj=raw, mtime=0
76
+ ) as compressed:
77
+ with tarfile.open(fileobj=compressed, mode="w") as tar:
78
+ tar.add(
79
+ kb_dir,
80
+ arcname=KB_DIR_NAME,
81
+ filter=_normalized_tar_info,
82
+ )
83
+ os.replace(temp_path, archive_path)
84
+ except BaseException:
85
+ temp_path.unlink(missing_ok=True)
86
+ raise
87
+
88
+ print(
89
+ f"KB archive ready: {archive_path} ({archive_path.stat().st_size / 1e6:.1f} MB)"
90
+ )
91
+ return archive_path
92
+
93
+
94
  def _prune_stale_remote_files(
95
  api: HfApi,
96
  repo_id: str,
97
  *,
98
  folder_path: str,
99
  allow_patterns: list[str],
100
+ prune_allow_patterns: list[str] | None,
101
  ignore_patterns: list[str],
102
  ) -> None:
103
+ """Verify the upload, then prune files owned by this publisher.
104
+
105
+ ``allow_patterns`` describes files expected from the current upload.
106
+ ``prune_allow_patterns`` may be wider, which is how the archive migration
107
+ deletes legacy remote ``kb/**`` files without treating the local unpacked
108
+ tree as publishable. Refuse to delete anything unless every expected local
109
+ upload is visible remotely.
 
110
  """
111
  local_root = Path(folder_path)
112
  local_files_all = [
 
122
  )
123
  )
124
 
125
+ remote_files_all = set(api.list_repo_files(repo_id, repo_type="dataset"))
126
+ missing_remote = sorted(local_kept - remote_files_all)
127
+ if missing_remote:
128
+ preview = ", ".join(missing_remote[:10])
129
+ suffix = "" if len(missing_remote) <= 10 else ", ..."
130
+ raise RuntimeError(
131
+ "Upload verification failed; refusing to prune because "
132
+ f"{len(missing_remote)} expected remote file(s) are missing: "
133
+ f"{preview}{suffix}"
134
+ )
135
+
136
+ prune_allow = prune_allow_patterns or allow_patterns
137
  remote_kept = list(
138
  filter_repo_objects(
139
  remote_files_all,
140
+ allow_patterns=prune_allow,
141
  ignore_patterns=ignore_patterns,
142
  )
143
  )
 
147
  print("Prune step: no stale remote files to delete.")
148
  return
149
 
150
+ print(f"Prune step: deleting {len(stale)} stale remote file(s).")
151
+ for f in stale[:20]:
152
  print(f" - {f}")
153
+ if len(stale) > 20:
154
+ print(f" ... and {len(stale) - 20} more")
155
 
156
  api.create_commit(
157
  repo_id=repo_id,
 
166
  *,
167
  folder_path: str = FOLDER_PATH,
168
  allow_patterns: list[str] | None = None,
169
+ prune_allow_patterns: list[str] | None = None,
170
  ignore_patterns: list[str] | None = None,
171
  create_public: bool = False,
172
  ) -> None:
173
+ """Upload ``folder_path``, verify it, then prune stale remote files.
174
 
175
  The default arguments reproduce the production upload (the private
176
  all-sources bundle). ``build_public_docs_bundle`` reuses this with a
 
193
  print(exc)
194
  raise SystemExit(1) from exc
195
 
196
+ # Step 1: upload additively. Stale files remain available until the
197
+ # verification and explicit prune below, so a failed archive migration
198
+ # cannot strand the repo without either KB representation.
199
+ api.upload_folder(
 
200
  folder_path=folder_path,
201
+ repo_id=repo_id,
202
+ repo_type="dataset",
203
  allow_patterns=allow,
204
  ignore_patterns=ignore,
205
  )
206
 
207
+ # Step 2: verify the expected files reached the repo, then prune. Uploading
208
+ # first is load-bearing for archive migrations: the old unpacked KB stays
209
+ # usable if the archive upload fails.
210
+ _prune_stale_remote_files(
211
+ api,
212
+ repo_id,
213
  folder_path=folder_path,
 
 
214
  allow_patterns=allow,
215
+ prune_allow_patterns=prune_allow_patterns,
216
  ignore_patterns=ignore,
217
  )
218
 
219
 
220
  def upload_vector_db(repo_id: str = DEFAULT_REPO_ID) -> None:
221
+ archive_path = create_kb_archive(FOLDER_PATH)
222
+ try:
223
+ upload_bundle(
224
+ repo_id,
225
+ allow_patterns=ALLOW_PATTERNS,
226
+ prune_allow_patterns=PRUNE_ALLOW_PATTERNS,
227
+ )
228
+ finally:
229
+ # The archive is a transient publishing artifact. The canonical local
230
+ # KB stays unpacked for development and maintenance workflows.
231
+ archive_path.unlink(missing_ok=True)
232
 
233
 
234
  GRAPHRAG_LOCAL_DIR = "data/graphrag/output"
tests/test_config.py CHANGED
@@ -187,6 +187,28 @@ def test_download_bundle_falls_back_to_public_on_no_access() -> None:
187
  ]
188
 
189
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  def test_extract_kb_archive_unpacks_and_removes_archive(tmp_path: Path) -> None:
191
  import tarfile
192
 
 
187
  ]
188
 
189
 
190
+ def test_snapshot_bundle_skips_rebuild_only_artifacts() -> None:
191
+ with (
192
+ patch("huggingface_hub.snapshot_download") as snapshot_download,
193
+ patch("app.config._extract_kb_archive") as extract,
194
+ ):
195
+ config._snapshot_bundle("org/bundle", token="tok")
196
+
197
+ snapshot_download.assert_called_once_with(
198
+ repo_id="org/bundle",
199
+ local_dir="data",
200
+ repo_type="dataset",
201
+ token="tok",
202
+ ignore_patterns=[
203
+ "graphrag/**",
204
+ "all_sources_contextual_nodes.pkl",
205
+ ".gitattributes",
206
+ "README.md",
207
+ ],
208
+ )
209
+ extract.assert_called_once_with()
210
+
211
+
212
  def test_extract_kb_archive_unpacks_and_removes_archive(tmp_path: Path) -> None:
213
  import tarfile
214
 
tests/test_upload_dbs_to_hf.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import tarfile
5
+ from pathlib import Path
6
+ from unittest.mock import patch
7
+
8
+ import pytest
9
+
10
+ from data.scraping_scripts import upload_dbs_to_hf as uploader
11
+
12
+
13
+ def _write_minimal_kb(root: Path) -> None:
14
+ kb = root / "kb"
15
+ (kb / "generated").mkdir(parents=True)
16
+ (kb / "generated" / "corpus_manifest.jsonl").write_text(
17
+ '{"doc_id":"1"}\n', encoding="utf-8"
18
+ )
19
+ (kb / "wiki").mkdir()
20
+ (kb / "wiki" / "index.md").write_text("# Index\n", encoding="utf-8")
21
+
22
+
23
+ def test_create_kb_archive_is_deterministic_and_preserves_tree(
24
+ tmp_path: Path,
25
+ ) -> None:
26
+ _write_minimal_kb(tmp_path)
27
+
28
+ archive = uploader.create_kb_archive(str(tmp_path))
29
+ first_digest = hashlib.sha256(archive.read_bytes()).hexdigest()
30
+ archive = uploader.create_kb_archive(str(tmp_path))
31
+ second_digest = hashlib.sha256(archive.read_bytes()).hexdigest()
32
+
33
+ assert first_digest == second_digest
34
+ assert (tmp_path / "kb" / "wiki" / "index.md").is_file()
35
+ with tarfile.open(archive, "r:gz") as tar:
36
+ assert "kb/generated/corpus_manifest.jsonl" in tar.getnames()
37
+ assert "kb/wiki/index.md" in tar.getnames()
38
+
39
+
40
+ def test_create_kb_archive_refuses_incomplete_kb(tmp_path: Path) -> None:
41
+ (tmp_path / "kb").mkdir()
42
+
43
+ with pytest.raises(FileNotFoundError, match="incomplete KB"):
44
+ uploader.create_kb_archive(str(tmp_path))
45
+
46
+ assert not (tmp_path / "kb.tar.gz").exists()
47
+
48
+
49
+ class _FakeApi:
50
+ def __init__(self, remote_files: list[str]) -> None:
51
+ self.remote_files = remote_files
52
+ self.events: list[str] = []
53
+ self.deleted: list[str] = []
54
+
55
+ def upload_folder(self, **_kwargs) -> None:
56
+ self.events.append("upload")
57
+
58
+ def list_repo_files(self, _repo_id: str, *, repo_type: str) -> list[str]:
59
+ assert repo_type == "dataset"
60
+ self.events.append("list")
61
+ return self.remote_files
62
+
63
+ def create_commit(self, **kwargs) -> None:
64
+ self.events.append("prune")
65
+ self.deleted = [operation.path_in_repo for operation in kwargs["operations"]]
66
+
67
+
68
+ def test_upload_verifies_archive_then_prunes_legacy_kb(tmp_path: Path) -> None:
69
+ (tmp_path / "chroma-db-all_sources").mkdir()
70
+ (tmp_path / "chroma-db-all_sources" / "chroma.sqlite3").write_bytes(b"db")
71
+ (tmp_path / "kb.tar.gz").write_bytes(b"archive")
72
+ api = _FakeApi(
73
+ [
74
+ "chroma-db-all_sources/chroma.sqlite3",
75
+ "kb.tar.gz",
76
+ "kb/wiki/index.md",
77
+ "kb/generated/corpus_manifest.jsonl",
78
+ ]
79
+ )
80
+
81
+ with patch(
82
+ "data.scraping_scripts.upload_dbs_to_hf.validate_hf_access", return_value=api
83
+ ):
84
+ uploader.upload_bundle(
85
+ "org/private-bundle",
86
+ folder_path=str(tmp_path),
87
+ allow_patterns=["chroma-db-all_sources/**", "kb.tar.gz"],
88
+ prune_allow_patterns=[
89
+ "chroma-db-all_sources/**",
90
+ "kb.tar.gz",
91
+ "kb/**",
92
+ ],
93
+ )
94
+
95
+ assert api.events == ["upload", "list", "prune"]
96
+ assert api.deleted == [
97
+ "kb/generated/corpus_manifest.jsonl",
98
+ "kb/wiki/index.md",
99
+ ]
100
+
101
+
102
+ def test_upload_refuses_to_prune_when_archive_is_missing_remotely(
103
+ tmp_path: Path,
104
+ ) -> None:
105
+ (tmp_path / "kb.tar.gz").write_bytes(b"archive")
106
+ api = _FakeApi(["kb/wiki/index.md"])
107
+
108
+ with patch(
109
+ "data.scraping_scripts.upload_dbs_to_hf.validate_hf_access", return_value=api
110
+ ):
111
+ with pytest.raises(RuntimeError, match="refusing to prune"):
112
+ uploader.upload_bundle(
113
+ "org/private-bundle",
114
+ folder_path=str(tmp_path),
115
+ allow_patterns=["kb.tar.gz"],
116
+ prune_allow_patterns=["kb.tar.gz", "kb/**"],
117
+ )
118
+
119
+ assert api.events == ["upload", "list"]
120
+ assert api.deleted == []