tabella-docs 2.0.1__tar.gz → 2.0.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tabella-docs
3
- Version: 2.0.1
3
+ Version: 2.0.2
4
4
  Summary: Portable document lifecycle suite — create (.docx/.xlsx/.pptx/.pdf/.odt/.ods/.odp), convert (pandoc), and read (anydoc + LiteParse OCR) documents. Agent Plugins v1 compatible.
5
5
  Author-email: D Yiapanis <d@yiapanis.co>
6
6
  License-Expression: MIT
@@ -119,7 +119,7 @@ Or install only the modules you need:
119
119
  ```bash
120
120
  pip install tabella-docs[create] # python-docx, openpyxl, python-pptx, weasyprint, odfpy
121
121
  pip install tabella-docs[read] # firecrawl-anydoc, liteparse, PyMuPDF
122
- pip install tabella-docs[convert] # no python deps (needs system pandoc + texlive-xetex)
122
+ pip install tabella-docs[convert] # no python deps (needs system pandoc; a TeX engine only for pdf output)
123
123
  ```
124
124
 
125
125
  ## Modules
@@ -240,6 +240,23 @@ sudo apt install libcairo2 libpango-1.0-0 libgdk-pixbuf-2.0-0
240
240
  brew install cairo pango gdk-pixbuf
241
241
  ```
242
242
 
243
+ ### Windows
244
+
245
+ tabella runs natively on Windows 10/11 (64-bit, Python 3.10+); all five MCP
246
+ tools are exercised on `windows-latest` in CI. Native dependencies, installed
247
+ per feature:
248
+
249
+ - **PDF creation (WeasyPrint)** — the
250
+ [GTK3 runtime installer](https://github.com/tschoonj/GTK-for-Windows-Runtime-Environment-Installer)
251
+ (its default install location is on WeasyPrint's search path)
252
+ - **Convert** — [pandoc](https://pandoc.org/installing.html); a TeX engine
253
+ (e.g. MiKTeX) only when the *output* format is PDF
254
+ - **OCR fallback** — [Tesseract for Windows](https://github.com/UB-Mannheim/tesseract/wiki),
255
+ only for scanned PDFs and images
256
+
257
+ The first-run dependency venv bootstraps natively on Windows (stdlib venv
258
+ module — no bash, MSYS2, or WSL required).
259
+
243
260
  ## Installation from source
244
261
 
245
262
  ```bash
@@ -260,10 +277,11 @@ pip install -e ".[all]"
260
277
  ### Automatic bootstrap
261
278
 
262
279
  On first startup, the MCP server checks if the docs venv exists. If not,
263
- it runs `scripts/setup.sh` to create a venv in `PLUGIN_DATA` (provided by
264
- the v1 client) or `~/.venvs/docs` as fallback, and installs all dependencies:
280
+ it creates one in `PLUGIN_DATA` (provided by the v1 client) or
281
+ `~/.venvs/docs` as fallback, and installs all dependencies:
265
282
  python-docx, openpyxl, python-pptx, weasyprint, odfpy, PyMuPDF,
266
- firecrawl-anydoc, liteparse.
283
+ firecrawl-anydoc, liteparse. On POSIX this runs `scripts/setup.sh`; on
284
+ Windows the venv is built with the standard library's `venv` module.
267
285
 
268
286
  This means the v1 package is fully self-contained — no manual pip install
269
287
  required. The bootstrap runs once; subsequent startups use the existing venv.
@@ -70,7 +70,7 @@ Or install only the modules you need:
70
70
  ```bash
71
71
  pip install tabella-docs[create] # python-docx, openpyxl, python-pptx, weasyprint, odfpy
72
72
  pip install tabella-docs[read] # firecrawl-anydoc, liteparse, PyMuPDF
73
- pip install tabella-docs[convert] # no python deps (needs system pandoc + texlive-xetex)
73
+ pip install tabella-docs[convert] # no python deps (needs system pandoc; a TeX engine only for pdf output)
74
74
  ```
75
75
 
76
76
  ## Modules
@@ -191,6 +191,23 @@ sudo apt install libcairo2 libpango-1.0-0 libgdk-pixbuf-2.0-0
191
191
  brew install cairo pango gdk-pixbuf
192
192
  ```
193
193
 
194
+ ### Windows
195
+
196
+ tabella runs natively on Windows 10/11 (64-bit, Python 3.10+); all five MCP
197
+ tools are exercised on `windows-latest` in CI. Native dependencies, installed
198
+ per feature:
199
+
200
+ - **PDF creation (WeasyPrint)** — the
201
+ [GTK3 runtime installer](https://github.com/tschoonj/GTK-for-Windows-Runtime-Environment-Installer)
202
+ (its default install location is on WeasyPrint's search path)
203
+ - **Convert** — [pandoc](https://pandoc.org/installing.html); a TeX engine
204
+ (e.g. MiKTeX) only when the *output* format is PDF
205
+ - **OCR fallback** — [Tesseract for Windows](https://github.com/UB-Mannheim/tesseract/wiki),
206
+ only for scanned PDFs and images
207
+
208
+ The first-run dependency venv bootstraps natively on Windows (stdlib venv
209
+ module — no bash, MSYS2, or WSL required).
210
+
194
211
  ## Installation from source
195
212
 
196
213
  ```bash
@@ -211,10 +228,11 @@ pip install -e ".[all]"
211
228
  ### Automatic bootstrap
212
229
 
213
230
  On first startup, the MCP server checks if the docs venv exists. If not,
214
- it runs `scripts/setup.sh` to create a venv in `PLUGIN_DATA` (provided by
215
- the v1 client) or `~/.venvs/docs` as fallback, and installs all dependencies:
231
+ it creates one in `PLUGIN_DATA` (provided by the v1 client) or
232
+ `~/.venvs/docs` as fallback, and installs all dependencies:
216
233
  python-docx, openpyxl, python-pptx, weasyprint, odfpy, PyMuPDF,
217
- firecrawl-anydoc, liteparse.
234
+ firecrawl-anydoc, liteparse. On POSIX this runs `scripts/setup.sh`; on
235
+ Windows the venv is built with the standard library's `venv` module.
218
236
 
219
237
  This means the v1 package is fully self-contained — no manual pip install
220
238
  required. The bootstrap runs once; subsequent startups use the existing venv.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tabella-docs"
7
- version = "2.0.1"
7
+ version = "2.0.2"
8
8
  description = "Portable document lifecycle suite — create (.docx/.xlsx/.pptx/.pdf/.odt/.ods/.odp), convert (pandoc), and read (anydoc + LiteParse OCR) documents. Agent Plugins v1 compatible."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,83 @@
1
+ """Shared docs-venv bootstrap for the stdio and HTTP servers.
2
+
3
+ Returns the path to the docs venv Python, bootstrapping if needed.
4
+
5
+ Checks DOCS_VENV_PYTHON first. If unset or the venv doesn't exist, one is
6
+ created in PLUGIN_DATA (provided by the v1 client) or ~/.venvs/docs.
7
+
8
+ Platform notes:
9
+ - POSIX runs scripts/setup.sh via bash.
10
+ - Windows has no bash on a stock box — the venv is built with the stdlib
11
+ venv module + the venv's own pip, mirroring setup.sh
12
+ (--require-hashes -r requirements-pinned.txt).
13
+ - The venv layout differs: bin/python vs Scripts/python.exe.
14
+ """
15
+
16
+ import os
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+
21
+
22
+ def ensure_venv() -> str:
23
+ # If DOCS_VENV_PYTHON is set and exists, use it
24
+ venv_python = os.environ.get("DOCS_VENV_PYTHON")
25
+ if venv_python and Path(venv_python).exists():
26
+ return venv_python
27
+
28
+ # Determine where to create the venv
29
+ plugin_data = os.environ.get("PLUGIN_DATA")
30
+ if plugin_data:
31
+ venv_dir = Path(plugin_data) / "venv"
32
+ else:
33
+ venv_dir = Path.home() / ".venvs" / "docs"
34
+
35
+ if os.name == "nt":
36
+ venv_python = str(venv_dir / "Scripts" / "python.exe")
37
+ else:
38
+ venv_python = str(venv_dir / "bin" / "python")
39
+
40
+ if venv_dir.exists() and Path(venv_python).exists():
41
+ return venv_python
42
+
43
+ plugin_root = Path(__file__).parent.parent.resolve()
44
+
45
+ if os.name == "nt":
46
+ # No bash on a stock Windows box — build the venv with the stdlib
47
+ # venv module + the venv's own pip, mirroring scripts/setup.sh.
48
+ pinned = plugin_root / "requirements-pinned.txt"
49
+ sys.stderr.write("tabella: bootstrapping docs venv (first run)...\n")
50
+ result = subprocess.run(
51
+ [sys.executable, "-m", "venv", str(venv_dir)],
52
+ capture_output=True, text=True, timeout=600,
53
+ )
54
+ if result.returncode == 0:
55
+ result = subprocess.run(
56
+ [venv_python, "-m", "pip", "install", "--quiet", "--require-hashes",
57
+ "-r", str(pinned)],
58
+ capture_output=True, text=True, timeout=600,
59
+ )
60
+ if result.returncode != 0:
61
+ sys.stderr.write(f"tabella: bootstrap failed: {result.stderr[:500]}\n")
62
+ else:
63
+ sys.stderr.write("tabella: venv ready\n")
64
+ return venv_python
65
+
66
+ # POSIX bootstrap: run the setup script
67
+ setup_script = plugin_root / "scripts" / "setup.sh"
68
+ if not setup_script.exists():
69
+ sys.stderr.write(f"tabella: setup script not found at {setup_script}\n")
70
+ return venv_python # let tool calls fail with a helpful error
71
+
72
+ sys.stderr.write("tabella: bootstrapping docs venv (first run)...\n")
73
+ data_dir = str(venv_dir.parent) if plugin_data else str(venv_dir)
74
+ result = subprocess.run(
75
+ ["bash", str(setup_script), data_dir],
76
+ capture_output=True, text=True, timeout=300,
77
+ )
78
+ if result.returncode != 0:
79
+ sys.stderr.write(f"tabella: bootstrap failed: {result.stderr[:500]}\n")
80
+ else:
81
+ sys.stderr.write("tabella: venv ready\n")
82
+
83
+ return venv_python
@@ -5,6 +5,7 @@ Output directory is configurable via DOCS_OUTPUT_DIR (defaults to ~/Documents).
5
5
  Merged from the doc_convert plugin.
6
6
  """
7
7
 
8
+ import re
8
9
  import json
9
10
  import logging
10
11
  import shutil
@@ -17,11 +18,11 @@ from .utils import _get_output_dir, _is_in_output_dir, _resolve_output_dir
17
18
  logger = logging.getLogger(__name__)
18
19
 
19
20
 
20
- def _check_deps() -> Optional[str]:
21
- """Return error string if pandoc or LaTeX is missing, else None."""
21
+ def _check_deps(needs_tex: bool = False) -> Optional[str]:
22
+ """Return error string if pandoc — or, for pdf targets, LaTeX — is missing."""
22
23
  if not shutil.which("pandoc"):
23
24
  return "pandoc is not installed. Install it (e.g. `sudo apt install pandoc`)."
24
- if not shutil.which("pdflatex") and not shutil.which("xelatex"):
25
+ if needs_tex and not shutil.which("pdflatex") and not shutil.which("xelatex"):
25
26
  return "No LaTeX engine found. Install texlive (e.g. `sudo apt install texlive-xetex`)."
26
27
  return None
27
28
 
@@ -58,10 +59,6 @@ def doc_convert(
58
59
  {"success": bool, "output_path": str, "from_format": str, "to_format": str,
59
60
  "pdf_engine": str | None, "message": str}
60
61
  """
61
- dep_err = _check_deps()
62
- if dep_err:
63
- return {"success": False, "error": dep_err}
64
-
65
62
  src = Path(input_path).expanduser().resolve()
66
63
  if not src.exists():
67
64
  return {"success": False, "error": f"Source file not found: {src}"}
@@ -69,6 +66,13 @@ def doc_convert(
69
66
  # Determine formats
70
67
  src_fmt = from_format or _infer_format(src.suffix.lstrip(".").lower())
71
68
  tgt_fmt = to_format.lower()
69
+ _check_format(src_fmt, "from_format")
70
+ _check_format(tgt_fmt, "to_format")
71
+
72
+ # pandoc is always required; a TeX engine only for pdf output
73
+ dep_err = _check_deps(tgt_fmt == "pdf")
74
+ if dep_err:
75
+ return {"success": False, "error": dep_err}
72
76
 
73
77
  # Determine output path
74
78
  if output_path:
@@ -93,7 +97,20 @@ def doc_convert(
93
97
  engine = _pdf_engine()
94
98
  cmd.extend(["--pdf-engine", engine])
95
99
  if extra_args:
96
- cmd.extend(extra_args)
100
+ # strict allowlist — pandoc can execute arbitrary code via --filter/
101
+ # --lua-filter/--pdf-engine, so only formatting flags are honored
102
+ # (catalog review #2026-10-16, finding 1).
103
+ allowed = {"--toc", "--number-sections"}
104
+ for a in extra_args:
105
+ if a in allowed:
106
+ cmd.append(a)
107
+ elif re.match(r"^-V\S*=", a):
108
+ cmd.append(a)
109
+ else:
110
+ raise ValueError(
111
+ f"unsupported pandoc argument {a!r}: extra_args allows only "
112
+ "--toc, --number-sections and -V <key>=<value>"
113
+ )
97
114
 
98
115
  try:
99
116
  result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
@@ -138,6 +155,22 @@ def _infer_format(ext: str) -> str:
138
155
  return mapping.get(ext, ext)
139
156
 
140
157
 
158
+ # Pandoc treats a format name ending in ".lua" as a path to a custom Lua
159
+ # reader/writer and executes it with the full Lua stdlib (os.execute) —
160
+ # same code-exec class as --lua-filter via a different argument. Restrict
161
+ # format names to pandoc's built-in name grammar (catalog finding 2026-10-17).
162
+ _FORMAT_RE = re.compile(r"^[a-z0-9_]+([+-][a-z0-9_]+)*$")
163
+
164
+
165
+ def _check_format(name: str, which: str) -> None:
166
+ """Reject anything that isn't a built-in pandoc format name."""
167
+ if not _FORMAT_RE.match(name):
168
+ raise ValueError(
169
+ f"unsupported {which} {name!r}: must be a built-in pandoc format "
170
+ "(alphanumeric/underscore, optional +ext/-ext modifiers)"
171
+ )
172
+
173
+
141
174
  def _handle_doc_convert(args: dict, **kwargs) -> str:
142
175
  """Handler wrapper for doc_convert tool."""
143
176
  result = doc_convert(**args)
@@ -2,7 +2,7 @@
2
2
 
3
3
  Merged from the doc_create plugin. Dependencies (python-docx, openpyxl,
4
4
  python-pptx, weasyprint, odfpy, nano-pdf) are now installed directly in the
5
- main Hermes venv — the old ~/.hermes/venvs/docs subprocess pattern is removed.
5
+ the bootstrapped per-plugin venv (PYTHON_BOOTSTRAP: scripts/setup.sh; stdio/HTTP servers exec into it on first serve)
6
6
  Builder functions import and use the libraries directly.
7
7
  """
8
8
 
@@ -18,8 +18,44 @@ from .utils import _get_output_dir, _is_in_output_dir, _resolve_output_dir
18
18
  logger = logging.getLogger(__name__)
19
19
 
20
20
 
21
+ def _safe_url_fetcher(font_config):
22
+ """URL fetcher for model-supplied HTML: refuse network + host filesystem.
23
+
24
+ Only data: URIs and WeasyPrint's own font temp-file references resolve
25
+ — <img src>, @import and file:// against anything else raise (catalog
26
+ review finding 2: 'no network' must be true, not a wording promise).
27
+ """
28
+ from pathlib import Path as _P
29
+
30
+ def fetch(url, *a, **kw):
31
+ tmp_dir = str(getattr(font_config, "tmp_directory", "") or "")
32
+ if url.startswith("data:") or (
33
+ url.startswith("file:") and tmp_dir
34
+ and url[len("file://"):].startswith(_P(tmp_dir).resolve().as_posix())
35
+ ):
36
+ from weasyprint import default_url_fetcher as _default
37
+ return _default(url, *a, **kw)
38
+ raise ValueError(
39
+ f"blocked external URL in HTML: {url[:80]!r} — network and "
40
+ "host-file access are disabled; use embedded data: URIs"
41
+ )
42
+
43
+ return fetch
44
+
45
+
21
46
  def _css_escape(s: str) -> str:
22
- return s.replace('\\', '\\\\').replace("'", "\\'")
47
+ # Escape string delimiters AND CSS-significant chars. CSS strings don't
48
+ # understand \n/\r as literal chars — convert them to the \A control
49
+ # sequence so an injected newline can't break out of a `content: '...'`
50
+ # declaration and smuggle in @font-face urls or import statements.
51
+ return (
52
+ s.replace("\\", "\\\\")
53
+ .replace("'", "\\'")
54
+ .replace("\n", "\\A ")
55
+ .replace("\r", "\\A ")
56
+ .replace("{", "\\{")
57
+ .replace("}", "\\}")
58
+ )
23
59
 
24
60
 
25
61
  # ── builder functions (direct, no subprocess) ──
@@ -265,7 +301,8 @@ def _build_pdf(content: dict, output_path: str, template: str = None, options: d
265
301
  """
266
302
 
267
303
  font_config = FontConfiguration()
268
- HTML(string=html_str).write_pdf(output_path, stylesheets=[CSS(string=css_str, font_config=font_config)])
304
+ fetcher = _safe_url_fetcher(font_config)
305
+ HTML(string=html_str, url_fetcher=fetcher).write_pdf(output_path, stylesheets=[CSS(string=css_str, url_fetcher=fetcher, font_config=font_config)])
269
306
  return {"success": True, "path": output_path, "format": "pdf"}
270
307
  except Exception as e:
271
308
  return {"success": False, "error": str(e)}
@@ -483,16 +520,20 @@ def _build_pdf_from_html(content: dict, output_path: str, template: str = None,
483
520
  css_lines.append("td, th { border: 1px solid #ccc; padding: 6px; text-align: left; }")
484
521
 
485
522
  if header_text:
486
- css_lines.append("@page { @top-center { content: '" + header_text.replace("'", "\\'") + "'; font-size: 9pt; color: #666; } }")
523
+ css_lines.append("@page { @top-center { content: '" + _css_escape(header_text) + "'; font-size: 9pt; color: #666; } }")
487
524
  if footer_text or page_numbers:
488
525
  footer_content = footer_text or ""
489
526
  if page_numbers:
490
527
  footer_content += " Page " + ("" if not footer_text else " ") + "counter(page)"
491
- css_lines.append("@page { @bottom-center { content: '" + footer_content.replace("'", "\\'") + "'; font-size: 9pt; color: #666; } }")
528
+ css_lines.append("@page { @bottom-center { content: '" + _css_escape(footer_content) + "'; font-size: 9pt; color: #666; } }")
492
529
 
493
530
  css_str = "\n".join(css_lines)
494
531
  font_config = FontConfiguration()
495
- HTML(string=html_str).write_pdf(output_path, stylesheets=[CSS(string=css_str, font_config=font_config)])
532
+ fetcher = _safe_url_fetcher(font_config)
533
+ HTML(string=html_str, url_fetcher=fetcher).write_pdf(
534
+ output_path,
535
+ stylesheets=[CSS(string=css_str, url_fetcher=fetcher, font_config=font_config)],
536
+ )
496
537
  return {"success": True, "path": output_path, "format": "pdf"}
497
538
  except Exception as e:
498
539
  return {"success": False, "error": str(e)}
@@ -1,7 +1,7 @@
1
1
  """documents.read — read documents via anydoc (primary) + LiteParse (OCR fallback).
2
2
 
3
3
  Merged from the doc_read plugin. Dependencies (firecrawl-anydoc, liteparse) are
4
- now installed directly in the main Hermes venv — no subprocess/venv indirection.
4
+ now installed directly in the the bootstrapped per-plugin venv (PYTHON_BOOTSTRAP: scripts/setup.sh; stdio/HTTP servers exec into it on first serve)
5
5
 
6
6
  Architecture:
7
7
  - Office formats (DOCX, DOC, XLSX, XLSB, PPTX, PPT, ODT, ODS, ODP, RTF,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tabella-docs
3
- Version: 2.0.1
3
+ Version: 2.0.2
4
4
  Summary: Portable document lifecycle suite — create (.docx/.xlsx/.pptx/.pdf/.odt/.ods/.odp), convert (pandoc), and read (anydoc + LiteParse OCR) documents. Agent Plugins v1 compatible.
5
5
  Author-email: D Yiapanis <d@yiapanis.co>
6
6
  License-Expression: MIT
@@ -119,7 +119,7 @@ Or install only the modules you need:
119
119
  ```bash
120
120
  pip install tabella-docs[create] # python-docx, openpyxl, python-pptx, weasyprint, odfpy
121
121
  pip install tabella-docs[read] # firecrawl-anydoc, liteparse, PyMuPDF
122
- pip install tabella-docs[convert] # no python deps (needs system pandoc + texlive-xetex)
122
+ pip install tabella-docs[convert] # no python deps (needs system pandoc; a TeX engine only for pdf output)
123
123
  ```
124
124
 
125
125
  ## Modules
@@ -240,6 +240,23 @@ sudo apt install libcairo2 libpango-1.0-0 libgdk-pixbuf-2.0-0
240
240
  brew install cairo pango gdk-pixbuf
241
241
  ```
242
242
 
243
+ ### Windows
244
+
245
+ tabella runs natively on Windows 10/11 (64-bit, Python 3.10+); all five MCP
246
+ tools are exercised on `windows-latest` in CI. Native dependencies, installed
247
+ per feature:
248
+
249
+ - **PDF creation (WeasyPrint)** — the
250
+ [GTK3 runtime installer](https://github.com/tschoonj/GTK-for-Windows-Runtime-Environment-Installer)
251
+ (its default install location is on WeasyPrint's search path)
252
+ - **Convert** — [pandoc](https://pandoc.org/installing.html); a TeX engine
253
+ (e.g. MiKTeX) only when the *output* format is PDF
254
+ - **OCR fallback** — [Tesseract for Windows](https://github.com/UB-Mannheim/tesseract/wiki),
255
+ only for scanned PDFs and images
256
+
257
+ The first-run dependency venv bootstraps natively on Windows (stdlib venv
258
+ module — no bash, MSYS2, or WSL required).
259
+
243
260
  ## Installation from source
244
261
 
245
262
  ```bash
@@ -260,10 +277,11 @@ pip install -e ".[all]"
260
277
  ### Automatic bootstrap
261
278
 
262
279
  On first startup, the MCP server checks if the docs venv exists. If not,
263
- it runs `scripts/setup.sh` to create a venv in `PLUGIN_DATA` (provided by
264
- the v1 client) or `~/.venvs/docs` as fallback, and installs all dependencies:
280
+ it creates one in `PLUGIN_DATA` (provided by the v1 client) or
281
+ `~/.venvs/docs` as fallback, and installs all dependencies:
265
282
  python-docx, openpyxl, python-pptx, weasyprint, odfpy, PyMuPDF,
266
- firecrawl-anydoc, liteparse.
283
+ firecrawl-anydoc, liteparse. On POSIX this runs `scripts/setup.sh`; on
284
+ Windows the venv is built with the standard library's `venv` module.
267
285
 
268
286
  This means the v1 package is fully self-contained — no manual pip install
269
287
  required. The bootstrap runs once; subsequent startups use the existing venv.
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  tabella/__init__.py
5
+ tabella/bootstrap.py
5
6
  tabella/convert.py
6
7
  tabella/create.py
7
8
  tabella/read.py
@@ -11,4 +12,5 @@ tabella_docs.egg-info/SOURCES.txt
11
12
  tabella_docs.egg-info/dependency_links.txt
12
13
  tabella_docs.egg-info/requires.txt
13
14
  tabella_docs.egg-info/top_level.txt
15
+ tests/test_ensure_venv_win.py
14
16
  tests/test_text_section_alias.py
@@ -0,0 +1,125 @@
1
+ r"""Seam test for tabella.bootstrap.ensure_venv platform handling (direct-run).
2
+
3
+ Shared module serving BOTH servers (mcp_server.py + scripts/mcp_http_server.py).
4
+ Proves, without a Windows box:
5
+ 1. DOCS_VENV_PYTHON override short-circuits.
6
+ 2. POSIX fast path unchanged (bin/python).
7
+ 3. os.name=="nt": Scripts\python.exe layout, venv+pip bootstrap commands
8
+ mirror setup.sh (--require-hashes -r requirements-pinned.txt), success
9
+ and failure paths both behave.
10
+
11
+ The nt seam swaps mcp_server.os for a namespace (NOT global os.name — that
12
+ flips the shared pathlib factory to WindowsPath, uninstantiable on Linux).
13
+ Path separators in expectations are built with the same Path arithmetic, so
14
+ they match this platform; the real backslash layout is proven by CI on
15
+ windows-latest.
16
+ """
17
+ import os
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ from pathlib import Path
22
+ from types import SimpleNamespace
23
+
24
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
25
+ from tabella import bootstrap as B # noqa: E402
26
+
27
+
28
+ def check(name, cond, detail=""):
29
+ if not cond:
30
+ print(f"FAIL {name}: {detail}")
31
+ sys.exit(1)
32
+ print(f"PASS {name}")
33
+
34
+
35
+ # 1. override short-circuits
36
+ with tempfile.TemporaryDirectory() as td:
37
+ fake = Path(td) / "wpython.exe"
38
+ fake.write_text("")
39
+ os.environ["DOCS_VENV_PYTHON"] = str(fake)
40
+ check("override", B.ensure_venv() == str(fake))
41
+ del os.environ["DOCS_VENV_PYTHON"]
42
+
43
+ # 2. POSIX fast path: existing venv dir with bin/python returns that path
44
+ with tempfile.TemporaryDirectory() as td:
45
+ os.environ["PLUGIN_DATA"] = str(Path(td) / "data")
46
+ venv = Path(td) / "data" / "venv"
47
+ (venv / "bin").mkdir(parents=True)
48
+ (venv / "bin" / "python").write_text("")
49
+ check("posix fast path", B.ensure_venv() == str(venv / "bin" / "python"))
50
+ del os.environ["PLUGIN_DATA"]
51
+
52
+ # 2b. POSIX bootstrap seam: bash + setup.sh with the data_dir arg
53
+ with tempfile.TemporaryDirectory() as td:
54
+ os.environ["PLUGIN_DATA"] = str(Path(td) / "data")
55
+ calls = []
56
+ real_run = subprocess.run
57
+
58
+ def fake_run_ok(cmd, **kw):
59
+ calls.append(cmd)
60
+ return R0(0)
61
+
62
+ class R0:
63
+ def __init__(self, returncode=0, stderr=""):
64
+ self.returncode, self.stderr = returncode, stderr
65
+
66
+ subprocess.run = fake_run_ok
67
+ try:
68
+ out = B.ensure_venv()
69
+ finally:
70
+ subprocess.run = real_run
71
+ want = str(Path(td) / "data" / "venv" / "bin" / "python")
72
+ check("posix bash cmd", calls and calls[0][0] == "bash"
73
+ and calls[0][1].endswith("scripts/setup.sh")
74
+ and calls[0][2] == str(Path(td) / "data"), f"got {calls}")
75
+ check("posix returns venv python", out == want, f"got {out}")
76
+ del os.environ["PLUGIN_DATA"]
77
+
78
+ # 3. Windows seam
79
+ with tempfile.TemporaryDirectory() as td:
80
+ os.environ["PLUGIN_DATA"] = str(Path(td) / "data")
81
+ venv_dir = Path(td) / "data" / "venv"
82
+ calls = []
83
+ real_run = subprocess.run
84
+ real_os = B.os
85
+
86
+ class R2:
87
+ def __init__(self, returncode=0, stderr=""):
88
+ self.returncode, self.stderr = returncode, stderr
89
+
90
+ def fake_run(cmd, **kw):
91
+ calls.append(cmd)
92
+ return R2(0) if len(calls) == 1 else R2(1, "boom") # first ok, second fails
93
+
94
+ B.os = SimpleNamespace(name="nt", environ=os.environ)
95
+ subprocess.run = fake_run
96
+ try:
97
+ out = B.ensure_venv()
98
+ finally:
99
+ B.os, subprocess.run = real_os, real_run
100
+
101
+ want_python = str(venv_dir / "Scripts" / "python.exe")
102
+ check("nt layout", out == want_python, f"got {out}")
103
+ check("nt venv cmd", calls and calls[0] == [sys.executable, "-m", "venv", str(venv_dir)],
104
+ f"got {calls}")
105
+ check("nt pip cmd", len(calls) == 2 and calls[1][:3] == [want_python, "-m", "pip"]
106
+ and "--require-hashes" in calls[1] and "requirements-pinned.txt" in calls[1][-1],
107
+ f"got {calls}")
108
+
109
+ # failure at venv-creation stage: single call, still returns the path
110
+ calls.clear()
111
+
112
+ def fake_run2(cmd, **kw):
113
+ calls.append(cmd)
114
+ return R2(1, "no venv for you")
115
+
116
+ B.os = SimpleNamespace(name="nt", environ=os.environ)
117
+ subprocess.run = fake_run2
118
+ try:
119
+ out = B.ensure_venv()
120
+ finally:
121
+ B.os, subprocess.run = real_os, real_run
122
+ check("nt create-fail graceful", out == want_python and len(calls) == 1)
123
+ del os.environ["PLUGIN_DATA"]
124
+
125
+ print("ensure_venv seam: ALL PASS")
File without changes
File without changes