folio-cli 0.3.0__tar.gz → 0.3.1__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 (33) hide show
  1. {folio_cli-0.3.0/src/folio_cli.egg-info → folio_cli-0.3.1}/PKG-INFO +7 -7
  2. {folio_cli-0.3.0 → folio_cli-0.3.1}/README.md +6 -4
  3. {folio_cli-0.3.0 → folio_cli-0.3.1}/pyproject.toml +9 -2
  4. {folio_cli-0.3.0 → folio_cli-0.3.1}/src/folio/__init__.py +1 -1
  5. {folio_cli-0.3.0 → folio_cli-0.3.1}/src/folio/cli.py +6 -2
  6. {folio_cli-0.3.0 → folio_cli-0.3.1}/src/folio/distribute.py +52 -4
  7. {folio_cli-0.3.0 → folio_cli-0.3.1}/src/folio/store.py +55 -4
  8. {folio_cli-0.3.0 → folio_cli-0.3.1/src/folio_cli.egg-info}/PKG-INFO +7 -7
  9. {folio_cli-0.3.0 → folio_cli-0.3.1}/src/folio_cli.egg-info/requires.txt +0 -2
  10. {folio_cli-0.3.0 → folio_cli-0.3.1}/tests/test_distribute.py +1 -1
  11. {folio_cli-0.3.0 → folio_cli-0.3.1}/LICENSE +0 -0
  12. {folio_cli-0.3.0 → folio_cli-0.3.1}/setup.cfg +0 -0
  13. {folio_cli-0.3.0 → folio_cli-0.3.1}/src/folio/__main__.py +0 -0
  14. {folio_cli-0.3.0 → folio_cli-0.3.1}/src/folio/assets/SKILL.md +0 -0
  15. {folio_cli-0.3.0 → folio_cli-0.3.1}/src/folio/assets/__init__.py +0 -0
  16. {folio_cli-0.3.0 → folio_cli-0.3.1}/src/folio/assets/config.yaml +0 -0
  17. {folio_cli-0.3.0 → folio_cli-0.3.1}/src/folio/chunk.py +0 -0
  18. {folio_cli-0.3.0 → folio_cli-0.3.1}/src/folio/config.py +0 -0
  19. {folio_cli-0.3.0 → folio_cli-0.3.1}/src/folio/ingest.py +0 -0
  20. {folio_cli-0.3.0 → folio_cli-0.3.1}/src/folio/install.py +0 -0
  21. {folio_cli-0.3.0 → folio_cli-0.3.1}/src/folio/retrieve.py +0 -0
  22. {folio_cli-0.3.0 → folio_cli-0.3.1}/src/folio/sync.py +0 -0
  23. {folio_cli-0.3.0 → folio_cli-0.3.1}/src/folio_cli.egg-info/SOURCES.txt +0 -0
  24. {folio_cli-0.3.0 → folio_cli-0.3.1}/src/folio_cli.egg-info/dependency_links.txt +0 -0
  25. {folio_cli-0.3.0 → folio_cli-0.3.1}/src/folio_cli.egg-info/entry_points.txt +0 -0
  26. {folio_cli-0.3.0 → folio_cli-0.3.1}/src/folio_cli.egg-info/top_level.txt +0 -0
  27. {folio_cli-0.3.0 → folio_cli-0.3.1}/tests/test_chunk.py +0 -0
  28. {folio_cli-0.3.0 → folio_cli-0.3.1}/tests/test_cli.py +0 -0
  29. {folio_cli-0.3.0 → folio_cli-0.3.1}/tests/test_config.py +0 -0
  30. {folio_cli-0.3.0 → folio_cli-0.3.1}/tests/test_ingest_retrieve.py +0 -0
  31. {folio_cli-0.3.0 → folio_cli-0.3.1}/tests/test_install.py +0 -0
  32. {folio_cli-0.3.0 → folio_cli-0.3.1}/tests/test_rrf_and_format.py +0 -0
  33. {folio_cli-0.3.0 → folio_cli-0.3.1}/tests/test_sync.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: folio-cli
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Local hybrid RAG over curated AI / MLOps / infra curricula — graphify-style CLI for any assistant
5
5
  Author: Iman Sharei
6
6
  License-Expression: Apache-2.0
@@ -29,8 +29,6 @@ Requires-Dist: sentence-transformers<6,>=3.3.0; extra == "rerank"
29
29
  Provides-Extra: dev
30
30
  Requires-Dist: pytest>=8.0; extra == "dev"
31
31
  Requires-Dist: pytest-cov>=5.0; extra == "dev"
32
- Requires-Dist: build>=1.2; extra == "dev"
33
- Requires-Dist: twine>=5.0; extra == "dev"
34
32
  Dynamic: license-file
35
33
 
36
34
  # folio
@@ -64,8 +62,10 @@ From a checkout (editable / unreleased):
64
62
 
65
63
  ```bash
66
64
  cd ~/Desktop/folio
65
+ uv sync # creates .venv + uv.lock-backed env
66
+ uv run folio status
67
+ # or install the CLI globally from this tree:
67
68
  uv tool install --editable .
68
- # or: uv sync && uv run folio status
69
69
  ```
70
70
 
71
71
  Git install without waiting for PyPI:
@@ -138,7 +138,7 @@ folio uninstall --project [--purge]
138
138
  ## Tests
139
139
 
140
140
  ```bash
141
- uv sync --extra dev
141
+ uv sync
142
142
  uv run pytest -q # all
143
143
  uv run pytest -q -m "not integration" # fast (no Chroma embed)
144
144
  uv run pytest -q -m integration # sync + ingest + retrieve + pack
@@ -159,8 +159,8 @@ One-time setup (API token **or** Trusted Publishing):
159
159
  After Trusted Publishing is configured, a version tag is enough:
160
160
 
161
161
  ```bash
162
- git tag -a v0.3.0 -m v0.3.0
163
- git push origin v0.3.0
162
+ git tag -a v0.3.1 -m v0.3.1
163
+ git push origin v0.3.1
164
164
  ```
165
165
 
166
166
  Manual local publish (token in env):
@@ -29,8 +29,10 @@ From a checkout (editable / unreleased):
29
29
 
30
30
  ```bash
31
31
  cd ~/Desktop/folio
32
+ uv sync # creates .venv + uv.lock-backed env
33
+ uv run folio status
34
+ # or install the CLI globally from this tree:
32
35
  uv tool install --editable .
33
- # or: uv sync && uv run folio status
34
36
  ```
35
37
 
36
38
  Git install without waiting for PyPI:
@@ -103,7 +105,7 @@ folio uninstall --project [--purge]
103
105
  ## Tests
104
106
 
105
107
  ```bash
106
- uv sync --extra dev
108
+ uv sync
107
109
  uv run pytest -q # all
108
110
  uv run pytest -q -m "not integration" # fast (no Chroma embed)
109
111
  uv run pytest -q -m integration # sync + ingest + retrieve + pack
@@ -124,8 +126,8 @@ One-time setup (API token **or** Trusted Publishing):
124
126
  After Trusted Publishing is configured, a version tag is enough:
125
127
 
126
128
  ```bash
127
- git tag -a v0.3.0 -m v0.3.0
128
- git push origin v0.3.0
129
+ git tag -a v0.3.1 -m v0.3.1
130
+ git push origin v0.3.1
129
131
  ```
130
132
 
131
133
  Manual local publish (token in env):
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
  # PyPI distribution name (plain "folio" is taken by an unrelated static-site tool).
7
7
  # Import package + CLI command remain `folio`.
8
8
  name = "folio-cli"
9
- version = "0.3.0"
9
+ version = "0.3.1"
10
10
  description = "Local hybrid RAG over curated AI / MLOps / infra curricula — graphify-style CLI for any assistant"
11
11
  readme = "README.md"
12
12
  license = "Apache-2.0"
@@ -30,9 +30,12 @@ dependencies = [
30
30
  "numpy>=1.26.0,<3",
31
31
  ]
32
32
 
33
+ [dependency-groups]
34
+ dev = ["pytest>=8.0", "pytest-cov>=5.0"]
35
+
33
36
  [project.optional-dependencies]
34
37
  rerank = ["sentence-transformers>=3.3.0,<6"]
35
- dev = ["pytest>=8.0", "pytest-cov>=5.0", "build>=1.2", "twine>=5.0"]
38
+ dev = ["pytest>=8.0", "pytest-cov>=5.0"]
36
39
 
37
40
  [project.urls]
38
41
  Homepage = "https://github.com/Iman-Sharei/folio"
@@ -49,6 +52,10 @@ where = ["src"]
49
52
  [tool.setuptools.package-data]
50
53
  folio = ["assets/*"]
51
54
 
55
+ [tool.uv]
56
+ package = true
57
+ default-groups = ["dev"]
58
+
52
59
  [tool.pytest.ini_options]
53
60
  testpaths = ["tests"]
54
61
  markers = [
@@ -1,3 +1,3 @@
1
1
  """folio — local hybrid RAG over curated AI / MLOps / data curricula (graphify-style)."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.1"
@@ -17,10 +17,14 @@ from .sync import sync_sources
17
17
 
18
18
 
19
19
  def _cli_version() -> str:
20
+ # PyPI distribution is folio-cli; import package remains folio.
20
21
  try:
21
- return pkg_version("folio")
22
+ return pkg_version("folio-cli")
22
23
  except PackageNotFoundError:
23
- return __version__
24
+ try:
25
+ return pkg_version("folio")
26
+ except PackageNotFoundError:
27
+ return __version__
24
28
 
25
29
 
26
30
  def _cfg(args: argparse.Namespace) -> dict:
@@ -16,6 +16,7 @@ from .sync import sync_sources
16
16
 
17
17
  # Files/dirs included in a portable index pack (query-ready without sources).
18
18
  _PACK_MEMBERS = ("chroma", "bm25", "manifest.json", "config.yaml")
19
+ _PACK_TOP = frozenset(_PACK_MEMBERS)
19
20
 
20
21
 
21
22
  def _release_url(cfg: dict[str, Any]) -> str:
@@ -23,6 +24,41 @@ def _release_url(cfg: dict[str, Any]) -> str:
23
24
  return (release.get("index_url") or "").strip()
24
25
 
25
26
 
27
+ def _require_https_url(url: str) -> str:
28
+ """Bootstrap downloads must be https (blocks file:// and SSRF-ish schemes)."""
29
+ from urllib.parse import urlparse
30
+
31
+ parsed = urlparse(url)
32
+ if parsed.scheme != "https" or not parsed.netloc:
33
+ raise RuntimeError(
34
+ f"Refusing non-https index URL (scheme={parsed.scheme!r}). "
35
+ "Use https://… or pass a local --archive path."
36
+ )
37
+ return url
38
+
39
+
40
+ def _safe_tar_members(tar: tarfile.TarFile) -> list[tarfile.TarInfo]:
41
+ """Allowlist pack members; reject path traversal, absolute paths, and links."""
42
+ safe: list[tarfile.TarInfo] = []
43
+ for member in tar.getmembers():
44
+ name = member.name
45
+ if name.startswith("./"):
46
+ name = name[2:]
47
+ parts = Path(name).parts
48
+ if not parts or parts[0] not in _PACK_TOP:
49
+ continue
50
+ if Path(name).is_absolute() or ".." in parts:
51
+ raise RuntimeError(f"Unsafe tar member path: {member.name!r}")
52
+ if member.issym() or member.islnk():
53
+ raise RuntimeError(f"Tar symlinks/hardlinks are not allowed: {member.name!r}")
54
+ # Re-bind normalized relative name for extract.
55
+ member.name = name
56
+ safe.append(member)
57
+ if not safe:
58
+ raise RuntimeError("Invalid index pack: no allowed members found")
59
+ return safe
60
+
61
+
26
62
  def pack_index(cfg: dict[str, Any], *, dest: Path | None = None) -> Path:
27
63
  """Create a tarball of the shared index for GitHub Release upload."""
28
64
  out = Path(cfg["paths"]["out_dir"])
@@ -51,8 +87,12 @@ def pack_index(cfg: dict[str, Any], *, dest: Path | None = None) -> Path:
51
87
  def _extract_pack(archive: Path, dest: Path) -> None:
52
88
  dest.mkdir(parents=True, exist_ok=True)
53
89
  with tarfile.open(archive, "r:gz") as tar:
54
- # Python 3.12+ supports filter=; keep compatible extract.
55
- tar.extractall(dest) # noqa: S202 — local/trusted release asset
90
+ members = _safe_tar_members(tar)
91
+ # Prefer PEP 706 data filter when available (3.12+).
92
+ extract_kw: dict[str, Any] = {"path": dest, "members": members}
93
+ if hasattr(tarfile, "data_filter"):
94
+ extract_kw["filter"] = "data"
95
+ tar.extractall(**extract_kw)
56
96
 
57
97
 
58
98
  def bootstrap_index(
@@ -87,6 +127,8 @@ def bootstrap_index(
87
127
  "pass --url, or run `folio build` / `folio update --from-source`."
88
128
  )
89
129
 
130
+ resolved = _require_https_url(resolved)
131
+
90
132
  with tempfile.TemporaryDirectory(prefix="folio-bootstrap-") as tmp:
91
133
  tmp_path = Path(tmp) / "folio-index.tar.gz"
92
134
  print(f"[bootstrap] downloading {resolved}")
@@ -115,10 +157,16 @@ def _extract_into_out(archive: Path, out: Path) -> None:
115
157
  shutil.rmtree(dest)
116
158
  else:
117
159
  dest.unlink()
160
+ if src.is_symlink():
161
+ raise RuntimeError(f"Refusing symlink pack member: {src}")
118
162
  if src.is_dir():
119
- shutil.copytree(src, dest)
163
+ # symlinks=True copies link nodes as links (does not follow into secrets).
164
+ shutil.copytree(src, dest, symlinks=True, ignore_dangling_symlinks=False)
165
+ for p in dest.rglob("*"):
166
+ if p.is_symlink():
167
+ raise RuntimeError(f"Refusing symlink inside pack member {name}: {p}")
120
168
  else:
121
- shutil.copy2(src, dest)
169
+ shutil.copy2(src, dest, follow_symlinks=False)
122
170
 
123
171
 
124
172
  def update_index(
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  import os
4
5
  import pickle
5
6
  import shutil
@@ -13,6 +14,27 @@ from rank_bm25 import BM25Okapi
13
14
 
14
15
  from .chunk import Chunk
15
16
 
17
+ # Legacy pickle allowlist (BM25Okapi is pure-Python; block __reduce__ gadgets).
18
+ _PICKLE_ALLOWED = {
19
+ ("builtins", "dict"),
20
+ ("builtins", "list"),
21
+ ("builtins", "tuple"),
22
+ ("builtins", "str"),
23
+ ("builtins", "int"),
24
+ ("builtins", "float"),
25
+ ("builtins", "bool"),
26
+ ("builtins", "set"),
27
+ ("builtins", "frozenset"),
28
+ ("rank_bm25", "BM25Okapi"),
29
+ }
30
+
31
+
32
+ class _RestrictedBm25Unpickler(pickle.Unpickler):
33
+ def find_class(self, module: str, name: str) -> Any:
34
+ if (module, name) not in _PICKLE_ALLOWED:
35
+ raise pickle.UnpicklingError(f"Refusing to unpickle {module}.{name}")
36
+ return super().find_class(module, name)
37
+
16
38
 
17
39
  def _ensure_local_chroma_cache(model_cache_dir: Path | None = None) -> Path:
18
40
  """Keep ONNX model cache under the folio index home (sandbox-friendly).
@@ -103,18 +125,47 @@ def build_bm25(chunks: list[Chunk]) -> tuple[BM25Okapi, list[str], list[Chunk]]:
103
125
  return BM25Okapi(tokenized), [c.chunk_id for c in chunks], chunks
104
126
 
105
127
 
128
+ def _bm25_json_path(path: Path) -> Path:
129
+ """Prefer index.json alongside (or instead of) legacy index.pkl."""
130
+ if path.suffix == ".json":
131
+ return path
132
+ return path.with_suffix(".json")
133
+
134
+
106
135
  def save_bm25(path: Path, bm25: BM25Okapi, ids: list[str], chunks: list[Chunk]) -> None:
136
+ """Persist BM25 corpus as JSON and rebuild the index on load (no pickle)."""
137
+ del bm25 # rebuilt from texts on load
107
138
  path.parent.mkdir(parents=True, exist_ok=True)
139
+ json_path = _bm25_json_path(path)
108
140
  payload = {
109
- "bm25": bm25,
110
141
  "ids": ids,
111
142
  "texts": [c.text for c in chunks],
112
143
  "metadatas": [c.to_metadata() for c in chunks],
113
144
  }
114
- with path.open("wb") as f:
115
- pickle.dump(payload, f)
145
+ json_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
146
+ # Remove legacy pickle so packs don't ship an RCE surface.
147
+ if path.suffix == ".pkl" and path.exists():
148
+ path.unlink()
116
149
 
117
150
 
118
151
  def load_bm25(path: Path) -> dict[str, Any]:
152
+ json_path = _bm25_json_path(path)
153
+
154
+ if json_path.exists():
155
+ data = json.loads(json_path.read_text(encoding="utf-8"))
156
+ texts: list[str] = data["texts"]
157
+ ids: list[str] = data["ids"]
158
+ metas: list[dict] = data["metadatas"]
159
+ tokenized = [t.lower().split() for t in texts]
160
+ bm25 = BM25Okapi(tokenized) if tokenized else BM25Okapi([["_"]])
161
+ return {"bm25": bm25, "ids": ids, "texts": texts, "metadatas": metas}
162
+
163
+ if not path.exists():
164
+ raise FileNotFoundError(path)
165
+
166
+ # Legacy pickle path: restricted unpickler only (blocks RCE gadgets).
119
167
  with path.open("rb") as f:
120
- return pickle.load(f)
168
+ payload = _RestrictedBm25Unpickler(f).load()
169
+ if not isinstance(payload, dict) or "bm25" not in payload:
170
+ raise RuntimeError(f"Invalid legacy BM25 pickle at {path}")
171
+ return payload
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: folio-cli
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Local hybrid RAG over curated AI / MLOps / infra curricula — graphify-style CLI for any assistant
5
5
  Author: Iman Sharei
6
6
  License-Expression: Apache-2.0
@@ -29,8 +29,6 @@ Requires-Dist: sentence-transformers<6,>=3.3.0; extra == "rerank"
29
29
  Provides-Extra: dev
30
30
  Requires-Dist: pytest>=8.0; extra == "dev"
31
31
  Requires-Dist: pytest-cov>=5.0; extra == "dev"
32
- Requires-Dist: build>=1.2; extra == "dev"
33
- Requires-Dist: twine>=5.0; extra == "dev"
34
32
  Dynamic: license-file
35
33
 
36
34
  # folio
@@ -64,8 +62,10 @@ From a checkout (editable / unreleased):
64
62
 
65
63
  ```bash
66
64
  cd ~/Desktop/folio
65
+ uv sync # creates .venv + uv.lock-backed env
66
+ uv run folio status
67
+ # or install the CLI globally from this tree:
67
68
  uv tool install --editable .
68
- # or: uv sync && uv run folio status
69
69
  ```
70
70
 
71
71
  Git install without waiting for PyPI:
@@ -138,7 +138,7 @@ folio uninstall --project [--purge]
138
138
  ## Tests
139
139
 
140
140
  ```bash
141
- uv sync --extra dev
141
+ uv sync
142
142
  uv run pytest -q # all
143
143
  uv run pytest -q -m "not integration" # fast (no Chroma embed)
144
144
  uv run pytest -q -m integration # sync + ingest + retrieve + pack
@@ -159,8 +159,8 @@ One-time setup (API token **or** Trusted Publishing):
159
159
  After Trusted Publishing is configured, a version tag is enough:
160
160
 
161
161
  ```bash
162
- git tag -a v0.3.0 -m v0.3.0
163
- git push origin v0.3.0
162
+ git tag -a v0.3.1 -m v0.3.1
163
+ git push origin v0.3.1
164
164
  ```
165
165
 
166
166
  Manual local publish (token in env):
@@ -7,8 +7,6 @@ numpy<3,>=1.26.0
7
7
  [dev]
8
8
  pytest>=8.0
9
9
  pytest-cov>=5.0
10
- build>=1.2
11
- twine>=5.0
12
10
 
13
11
  [rerank]
14
12
  sentence-transformers<6,>=3.3.0
@@ -44,7 +44,7 @@ def test_pack_and_bootstrap_roundtrip(
44
44
 
45
45
  assert (other / "manifest.json").exists()
46
46
  assert (other / "chroma").exists()
47
- assert (other / "bm25" / "index.pkl").exists()
47
+ assert (other / "bm25" / "index.json").exists() or (other / "bm25" / "index.pkl").exists()
48
48
  assert (other / "config.yaml").exists()
49
49
 
50
50
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes