feat(raw): raw derive writes an Excel file's sheets as TSV files with a formula list (#202)
CI / verify (push) Successful in 6m47s
CI / pwsh (push) Successful in 2m25s
Release / release (push) Successful in 34s
Release / wiki-mirror (push) Skipped

Each sheet becomes NN-<sheet>.tsv (RFC 4180, tab-delimited, A1-aligned) in a
<stem>.sheets/ folder beside the .md; sheets with formulas also get a
.formulas.tsv. derived.md is the overview. Adds testfiles/ with a CI test.

Files changed:
- .gitattributes
- CHANGES.md
- VERSION
- docs/dev/development.md
- docs/how-to/ingest-a-source.md
- docs/reference/commands/raw-material-and-uploads.md
- raw/CONTRACT.md
- testfiles/README.md
- tools/CONTRACT.md
- tools/chemenu/commands/raw_cmd.py
- tools/chemenu/filters/__init__.py
- tools/chemenu/filters/archive.py
- tools/chemenu/filters/ooxml.py
- tools/chemenu/tests/test_raw_derive.py
- tools/chemenu/tests/test_raw_derive_testfiles.py

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
torbenandClaude Code committed 2026-10-09 13:35:03 +02:00
1 parent 8ea2795d88
commit 9868fce3d5
15 files changed
+719 -50

No files matched your search

+3 -2
View File
@@ -1495,13 +1495,13 @@ Derive reading text from a PDF, e-mail, Office file or web page in `incoming/`,
- 0 success
- 1 The path is not directly in `incoming/`, does not exist, or no filter reads the file's extension
- 1 The target folder or a `.md` already exists, or the path would be over the path budget
- 1 The target folder, a `.md` or a workbook's `.sheets` folder already exists, or the path would be over the path budget
- 1 The document cannot be read (damaged, encrypted), a container breaks one of its rules (a ZIP inside a ZIP, an unsafe path, a limit) or the filter's package is not installed
**ON FAILURE**
- The path is not directly in `incoming/`, does not exist, or no filter reads the file's extension -> Nothing was written. Fix the argument and retry once - a file in a subfolder is moved up, or its folder is derived whole
- The target folder or a `.md` already exists, or the path would be over the path budget -> Nothing was written. Remove what is there or pass `--name <shorter stem>`, then retry once. An existing `.md` is never overwritten
- The target folder, a `.md` or a workbook's `.sheets` folder already exists, or the path would be over the path budget -> Nothing was written. Remove what is there or pass `--name <shorter stem>`, then retry once. An existing `.md` is never overwritten
- The document cannot be read (damaged, encrypted), a container breaks one of its rules (a ZIP inside a ZIP, an unsafe path, a limit) or the filter's package is not installed -> Nothing was written. A missing package is reported with the preflight hint - show it to the user and stop, never install it. Any other filter keeps working
**NEVER**
@@ -1516,6 +1516,7 @@ Derive reading text from a PDF, e-mail, Office file or web page in `incoming/`,
- A folder is derived in place: each file with a filter gets `<stem>.md` beside it, nothing is renamed, and the files without a filter are named in the output.
- `derived.md` starts with a fixed header (`derived_by`, `filter`, `engine`, `derived`, `derived_from`, `original_name` for a renamed file, `title`, then the fields of the format) followed by the derived text. The same bytes always give the same text; only `derived` differs between runs. `engine` names the tool version and, where a package reads the format, its version.
- Formats: web pages (`.html`), Word (`.docx`), PowerPoint (`.pptx`), Excel (`.xlsx`, `.xlsm`), e-mail (`.eml`) and ZIP archives (`.zip`), read with the standard library only; PDF (`.pdf`, `pypdf`) and Outlook messages (`.msg`, `olefile`), each read by a package in `tools/requirements.txt` that is imported only when such a file is derived. A missing package refuses that format alone, with the preflight hint; `wikitool doctor` shows one `import-filter-<name>` line per package-backed format. A PDF gets `<!-- page N -->` markers and a WARN when it yields almost no text (a scan; there is no OCR); an encrypted PDF is refused. A `.msg` is a container like an `.eml`; an embedded message inside it is named, not read.
- A workbook (`.xlsx`, `.xlsm`) gets its cells as files: beside its `.md` a folder `<md stem>.sheets/` (`derived.sheets/` for a single file) holds one `NN-<sheet>.tsv` per sheet - tab-separated per RFC 4180, UTF-8, row 1 of the file is row 1 of the sheet - and, for a sheet with formulas, `NN-<sheet>.formulas.tsv` listing cell, formula and last stored value. The `.md` is the overview: one section per sheet with its range, the file names and a preview. Formulas are not recalculated; a shared formula is written out per cell, and one that cannot be is named `shared:<master cell>` with a WARN. The folder counts against the path budget and is accepted with the rest; an existing one is never overwritten.
- A ZIP and a mail are containers. A ZIP at the top of `incoming/` becomes the folder `incoming/<stem>/` holding its files under their own names, each with a `<stem>.md` where a filter reads it; the ZIP is removed and the output names it with its sha256. A mail becomes a folder with `original.eml`, `derived.md` and its attachments under their own names; a ZIP attached to it is unpacked once into a folder of its own, and an attached mail is quoted in `derived.md`, not unpacked. A ZIP inside a ZIP, an absolute or `..` path, a symbolic link, an encrypted entry, more than 1000 entries, a file over 25 MiB or more than 250 MiB in total refuse the whole call. Hidden entries (`__MACOSX/`, `.DS_Store`, anything starting with a dot) are named, not written. A name taken twice, or reserved (`original.*` and `derived.md` at the top of a mail's folder, `_capture.json` anywhere), gets `-2`, `-3` before its extension, and the output lists each rename.
- A name already taken under `raw/` is a WARN, not a refusal: whether the folder is a later edition of that source or a separate one is the human's call. A later edition of a derived source is replaced file by file with `raw accept --replaces`; there is no `--replaces-bundle` for it.
- After a success the output prints the warnings the filters raised, at most three `CHECK` lines per format that was derived in this run - what to look at before the text is trusted - and the `raw accept` line for the result.
+59 -8
View File
@@ -1616,15 +1616,23 @@ def _write_exclusive(writes: list[tuple[Path, bytes]]) -> None:
"""Create every file or none: `x` mode refuses a file that appeared since
the check above, and a failure part-way removes what this call wrote."""
written: list[Path] = []
made: list[Path] = []
try:
for path, data in writes:
missing = [d for d in reversed(path.parents) if not d.exists()]
path.parent.mkdir(parents=True, exist_ok=True)
made.extend(missing)
with path.open("xb") as handle:
written.append(path)
handle.write(data)
except OSError as exc:
for path in written:
path.unlink(missing_ok=True)
for directory in reversed(made):
try:
directory.rmdir()
except OSError:
pass
fail(escape(f"Could not write {rel_path(path)}: {exc}. Nothing was left in incoming/."))
@@ -1851,6 +1859,7 @@ class _Derived:
source: Path # the file in incoming/
markdown: bytes
target: Path # where the .md goes
extras: tuple[tuple[Path, bytes], ...] = () # the derivation's further files, beside the .md
def _derive_hint(path: Path) -> Optional[str]:
@@ -1897,6 +1906,7 @@ class _FilePlan:
markdown: bytes
container: Optional[filters.Container]
derivations: list[filters.Derivation]
extras: tuple[tuple[str, bytes], ...] = () # (path in the folder, bytes) of the derivation's further files
def _plan_derive_file(path: Path, name: Optional[str]) -> _FilePlan:
@@ -1942,15 +1952,19 @@ def _plan_derive_file(path: Path, name: Optional[str]) -> _FilePlan:
markdown = b""
targets = [rel for rel, _data in container.files]
derivations = list(container.derivations)
extras = ()
else:
original = folder / f"{ORIGINAL_STEM}{extension}"
derived = folder / DERIVED_NAME
derivation = container.text if container is not None else _run_filter(candidate, data, path)
assert derivation is not None
companion = filters.companion_name(DERIVED_NAME) if derivation.files else None
markdown = filters.render(
candidate, derivation, derived_from=original.name, original_name=path.name
candidate, derivation, derived_from=original.name, original_name=path.name,
companion=companion,
)
targets = [original.name, derived.name] + (
extras = tuple((f"{companion}/{name}", content) for name, content in derivation.files)
targets = [original.name, derived.name] + [rel for rel, _data in extras] + (
[rel for rel, _data in container.files] if container is not None else []
)
derivations = [derivation] + (list(container.derivations) if container is not None else [])
@@ -1961,7 +1975,7 @@ def _plan_derive_file(path: Path, name: Optional[str]) -> _FilePlan:
f"Cannot write {problem}. The folder name comes from the file's name: pass "
"--name <shorter stem>."
)
return _FilePlan(folder, stem, candidate, original, derived, markdown, container, derivations)
return _FilePlan(folder, stem, candidate, original, derived, markdown, container, derivations, extras)
def _write_folder(source: Path, plan: _FilePlan) -> None:
@@ -1975,6 +1989,11 @@ def _write_folder(source: Path, plan: _FilePlan) -> None:
if plan.derived is not None:
with plan.derived.open("xb") as handle:
handle.write(plan.markdown)
for rel, data in plan.extras:
target = plan.folder / rel
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("xb") as handle:
handle.write(data)
if plan.container is not None:
for rel, data in plan.container.files:
target = plan.folder / rel
@@ -2054,8 +2073,26 @@ def _plan_derive_folder(folder: Path):
derived: list[_Derived] = []
for file, candidate, md in plan:
derivation = _run_filter(candidate, _read_for_derive(file), file)
markdown = filters.render(candidate, derivation, derived_from=file.name)
derived.append(_Derived(candidate, derivation, file, markdown, md))
companion = md.with_name(filters.companion_name(md.name))
extras: tuple[tuple[Path, bytes], ...] = ()
if derivation.files:
if os.path.lexists(companion):
raise _Refused(
f"{rel_path(companion)} already exists - raw derive never overwrites. Remove it "
"or rename it, then derive the folder again."
)
extras = tuple((companion / name, content) for name, content in derivation.files)
for target, _content in extras:
problem = path_budget_problem_for(_shard_dir() / folder.name / target.relative_to(folder))
if problem:
raise _Refused(
f"Cannot write {problem}. The path comes from the folder in incoming/: "
"shorten the folder's name or the names inside it."
)
markdown = filters.render(
candidate, derivation, derived_from=file.name, companion=companion.name
)
derived.append(_Derived(candidate, derivation, file, markdown, md, extras))
return derived, untouched
@@ -2141,6 +2178,15 @@ def _derive_next(target: Path) -> str:
"`<!-- page N -->` markers and a WARN when it yields almost no text (a scan; there is "
"no OCR); an encrypted PDF is refused. A `.msg` is a container like an `.eml`; an "
"embedded message inside it is named, not read.",
"A workbook (`.xlsx`, `.xlsm`) gets its cells as files: beside its `.md` a folder "
"`<md stem>.sheets/` (`derived.sheets/` for a single file) holds one `NN-<sheet>.tsv` "
"per sheet - tab-separated per RFC 4180, UTF-8, row 1 of the file is row 1 of the sheet "
"- and, for a sheet with formulas, `NN-<sheet>.formulas.tsv` listing cell, formula and "
"last stored value. The `.md` is the overview: one section per sheet with its range, "
"the file names and a preview. Formulas are not recalculated; a shared formula is "
"written out per cell, and one that cannot be is named `shared:<master cell>` with a "
"WARN. The folder counts against the path budget and is accepted with the rest; an "
"existing one is never overwritten.",
"A ZIP and a mail are containers. A ZIP at the top of `incoming/` becomes the folder "
"`incoming/<stem>/` holding its files under their own names, each with a `<stem>.md` "
"where a filter reads it; the ZIP is removed and the output names it with its sha256. A "
@@ -2170,7 +2216,7 @@ def _derive_next(target: Path) -> str:
"subfolder is moved up, or its folder is derived whole",
),
cli_contract.Failure(
cause="The target folder or a `.md` already exists, or the path would be over the "
cause="The target folder, a `.md` or a workbook's `.sheets` folder already exists, or the path would be over the "
"path budget",
reaction="Nothing was written. Remove what is there or pass `--name <shorter stem>`, "
"then retry once. An existing `.md` is never overwritten",
@@ -2214,9 +2260,11 @@ def raw_derive_command(
if name is not None:
fail("--name applies to a single file; a folder keeps its own name.")
derived, untouched = _refusals_fail(_plan_derive_folder, resolved)
_write_exclusive([(d.target, d.markdown) for d in derived])
_write_exclusive([write for d in derived for write in [(d.target, d.markdown), *d.extras]])
lines = [f"Derived {len(derived)} file(s) in {rel_path(resolved)}/:"]
lines += [f" {rel_path(d.source)} -> {rel_path(d.target)} ({d.filter.name})" for d in derived]
for d in derived:
more = f" + {len(d.extras)} file(s) in {rel_path(d.extras[0][0].parent)}/" if d.extras else ""
lines.append(f" {rel_path(d.source)} -> {rel_path(d.target)} ({d.filter.name}){more}")
if untouched:
lines.append(
" No filter for, left as they are: " + ", ".join(rel_path(p) for p in untouched)
@@ -2248,6 +2296,9 @@ def raw_derive_command(
f"Derived {rel_path(plan.derived)} from {resolved.name} ({plan.candidate.name}); the "
f"original is now {rel_path(plan.original)}."
))
if plan.extras:
lines = f" + {len(plan.extras)} file(s) beside it in {rel_path(plan.folder)}/{plan.extras[0][0].rsplit('/', 1)[0]}/"
typer.echo(escape(lines))
if container is not None:
report = _container_report(container)
if report:
+22 -1
View File
@@ -23,6 +23,9 @@ from chemenu.version import VersionError, read_version
MAX_CHECKS = 3
COMPANION_SUFFIX = ".sheets"
COMPANION_DIR = "\x00companion\x00" # NUL cannot occur in XML text: a safe placeholder inside `Derivation.text`
PREFLIGHT_HINT = (
"Run the preflight to install it (tools/preflight.sh, or tools/preflight.ps1 on Windows; "
"instructions/preflight.md). Every other format keeps working."
@@ -36,6 +39,11 @@ class Derivation:
`fields` are the format-specific header lines, after `title`; a value is a
string or a list of strings. `checks` are the short English lines `raw derive`
prints after a success - already narrowed to the case that occurred.
`files` are further files of the derivation (a spreadsheet's sheets), as
`(name, bytes)`. They are written into a folder beside the `.md`, named
`<md stem>.sheets`; the text refers to that folder as `COMPANION_DIR`, which
`render` replaces with the folder's real name.
"""
text: str
@@ -43,6 +51,7 @@ class Derivation:
fields: tuple[tuple[str, "str | list[str]"], ...] = ()
warnings: tuple[str, ...] = ()
checks: tuple[str, ...] = ()
files: tuple[tuple[str, bytes], ...] = ()
def __post_init__(self) -> None:
if len(self.checks) > MAX_CHECKS:
@@ -176,9 +185,11 @@ def render(
derived_from: str,
original_name: Optional[str] = None,
derived_at: Optional[str] = None,
companion: Optional[str] = None,
) -> bytes:
"""The `.md` for one document: the fixed header, then the derived text.
`original_name` is left out for a file that was not renamed."""
`original_name` is left out for a file that was not renamed. `companion` is the
name of the folder holding `derivation.files`."""
fields: list[tuple[str, "str | list[str]"]] = [
("derived_by", "wikitool raw derive"),
("filter", candidate.name),
@@ -191,10 +202,20 @@ def render(
fields.append(("title", derivation.title))
fields.extend(derivation.fields)
text = derivation.text.strip("\n")
if COMPANION_DIR in text:
if companion is None:
raise ValueError("a derivation with files needs the name of the folder they go into")
text = text.replace(COMPANION_DIR, companion)
content = web_capture.header(fields) + "\n" + (text + "\n" if text else "")
return content.encode("utf-8")
def companion_name(md_name: str) -> str:
"""`derived.md` -> `derived.sheets`: the folder beside a `.md` for its `files`."""
stem = md_name[:-3] if md_name.lower().endswith(".md") else md_name
return stem + COMPANION_SUFFIX
def unique(lines: Iterable[str]) -> list[str]:
seen: set[str] = set()
out = []
+11 -1
View File
@@ -19,6 +19,7 @@ from chemenu.filters import (
Container,
Derivation,
Filter,
companion_name,
filter_for,
register,
render,
@@ -211,10 +212,19 @@ class Collector:
raise ValidationError(f"{path}: {exc}") from None
stem = path[: len(path) - len(os.path.splitext(base)[1])]
md = self._tree.claim(stem + ".md")
companion = ""
if derivation.files:
directory, _, md_base = md.rpartition("/")
companion = self._tree.claim_folder((directory + "/" if directory else "") + companion_name(md_base))
files.append((
md,
render(candidate, derivation, derived_from=base, original_name=came_as if came_as != base else None),
render(
candidate, derivation, derived_from=base,
original_name=came_as if came_as != base else None,
companion=companion.rsplit("/", 1)[-1],
),
))
files.extend((f"{companion}/{name}", content) for name, content in derivation.files)
derived.append((path, md, candidate.name))
derivations.append(derivation)
checks: list[str] = []
+252 -23
View File
@@ -12,10 +12,11 @@ import posixpath
import re
import xml.etree.ElementTree as ET
import zipfile
from typing import Iterator, Optional
from dataclasses import dataclass, field
from typing import Iterable, Iterator, Optional
from chemenu.errors import ValidationError
from chemenu.filters import Derivation, Filter, register
from chemenu.filters import COMPANION_DIR, Derivation, Filter, register
PART_LIMIT = 100 * 1024 * 1024
CELL_LIMIT = 2_000_000
@@ -596,11 +597,20 @@ class _Workbook:
self.formulas_without_value += 1
return text
def table(self, part: str) -> str:
def sheet(self, part: str) -> "_Sheet":
"""The cells and formulas of one worksheet part, read as stored."""
cells: dict[tuple[int, int], str] = {}
pending: list[_PendingFormula] = []
row_no, last_col = 0, 0
for _event, el in ET.iterparse(io.BytesIO(self.pkg.read(part)), events=("end",)):
root_seen = False
chart = False
for event, el in ET.iterparse(io.BytesIO(self.pkg.read(part)), events=("start", "end")):
tag = _local(el.tag)
if event == "start":
if not root_seen:
root_seen = True
chart = tag == "chartsheet"
continue
if tag == "c":
ref = re.fullmatch(r"([A-Za-z]+)(\d+)", el.get("r") or "")
if ref:
@@ -609,25 +619,226 @@ class _Workbook:
col, row = last_col + 1, row_no + 1
last_col = col
text = self._value(el)
if text.strip():
if text != "":
cells[(row, col)] = text
f = _child(el, "f")
if f is not None:
pending.append(_PendingFormula(
row, col, _attr(f, "t") or "normal", _attr(f, "si") or "", _attr(f, "ref") or "",
(f.text or "").strip(), (_attr(f, "r1") or "", _attr(f, "r2") or ""), text,
))
el.clear()
elif tag == "row":
row_no = int(el.get("r") or row_no + 1)
last_col = 0
el.clear()
if not cells:
return "*(empty sheet)*"
rows = [r for r, _c in cells]
cols = [c for _r, c in cells]
min_r, max_r, min_c, max_c = min(rows), max(rows), min(cols), max(cols)
if (max_r - min_r + 1) * (max_c - min_c + 1) > CELL_LIMIT:
if chart:
return _Sheet(chart=True)
rows = max((r for r, _c in cells), default=0)
cols = max((c for _r, c in cells), default=0)
if rows * cols > CELL_LIMIT:
raise ValidationError(
f"A sheet spans {max_r - min_r + 1} rows by {max_c - min_c + 1} columns - too large "
"or too sparse to derive."
f"A sheet spans {rows} rows by {cols} columns from A1 - too large or too sparse "
"to derive."
)
grid = [[cells.get((r, c), "") for c in range(min_c, max_c + 1)] for r in range(min_r, max_r + 1)]
return _table(grid)
formulas, untranslated = _resolve_formulas(pending)
return _Sheet(cells=cells, rows=rows, cols=cols, formulas=formulas, untranslated=untranslated)
@dataclass(frozen=True)
class _PendingFormula:
row: int
col: int
kind: str # normal, shared, array, dataTable
si: str
ref: str
text: str
inputs: tuple[str, str]
value: str
@dataclass(frozen=True)
class _Formula:
row: int
col: int
text: str
value: str
@dataclass(frozen=True)
class _Sheet:
chart: bool = False
cells: dict = field(default_factory=dict)
rows: int = 0
cols: int = 0
formulas: tuple[_Formula, ...] = ()
untranslated: int = 0
class _Unsafe(Exception):
pass
_MAX_COL, _MAX_ROW = 16384, 1048576
_NOT_AFTER = r"(?<![\w.$])"
_NOT_BEFORE = r"(?![\w(!])"
_REFERENCE = re.compile(
"|".join((
r'"(?:[^"]|"")*"',
r"'(?:[^']|'')*'",
r"\[(?:[^\[\]]|\[[^\[\]]*\])*\]",
_NOT_AFTER + r"(?P<cells>\$?[A-Z]{1,3}\$?\d+(?::\$?[A-Z]{1,3}\$?\d+)?)" + _NOT_BEFORE,
_NOT_AFTER + r"(?P<cols>\$?[A-Z]{1,3}:\$?[A-Z]{1,3})" + _NOT_BEFORE,
_NOT_AFTER + r"(?P<rows>\$?\d+:\$?\d+)" + _NOT_BEFORE,
)),
re.IGNORECASE,
)
_CELL = re.compile(r"(\$?)([A-Z]{1,3})(\$?)(\d+)", re.IGNORECASE)
def _col_letters(index: int) -> str:
letters = ""
while index:
index, rest = divmod(index - 1, 26)
letters = chr(65 + rest) + letters
return letters
def _shift_formula(text: str, dr: int, dc: int) -> str:
"""The formula of a shared-formula cell: the master's text with every relative
reference moved by the cell's offset. Strings, quoted sheet names and structured
references are left alone; a reference that would leave the sheet raises `_Unsafe`."""
def moved(index: int, absolute: str, limit: int, offset: int) -> int:
if absolute:
return index
index += offset
if not 1 <= index <= limit:
raise _Unsafe
return index
def cell(ref: str) -> str:
c_abs, letters, r_abs, digits = _CELL.fullmatch(ref).groups()
column, row = _col_index(letters.upper()), int(digits)
if not (1 <= column <= _MAX_COL and 1 <= row <= _MAX_ROW):
return ref
return (f"{c_abs}{_col_letters(moved(column, c_abs, _MAX_COL, dc))}"
f"{r_abs}{moved(row, r_abs, _MAX_ROW, dr)}")
def column(ref: str) -> str:
absolute, letters = re.fullmatch(r"(\$?)([A-Za-z]+)", ref).groups()
return f"{absolute}{_col_letters(moved(_col_index(letters.upper()), absolute, _MAX_COL, dc))}"
def row(ref: str) -> str:
absolute, digits = re.fullmatch(r"(\$?)(\d+)", ref).groups()
return f"{absolute}{moved(int(digits), absolute, _MAX_ROW, dr)}"
def replace(match: re.Match) -> str:
for group, shift in (("cells", cell), ("cols", column), ("rows", row)):
if match.group(group):
return ":".join(shift(part) for part in match.group(group).split(":"))
return match.group(0)
return _REFERENCE.sub(replace, text)
def _a1(row: int, col: int) -> str:
return f"{_col_letters(col)}{row}"
def _resolve_formulas(pending: list[_PendingFormula]) -> tuple[tuple[_Formula, ...], int]:
"""The formula text of every formula cell. Excel stores a shared formula once, in
its master cell; the others get the master's text moved to their own position.
Returns the formulas in sheet order and how many could not be written out."""
masters = {f.si: f for f in pending if f.kind == "shared" and f.text}
out: list[_Formula] = []
untranslated = 0
for f in sorted(pending, key=lambda item: (item.row, item.col)):
if f.kind == "shared" and not f.text:
master = masters.get(f.si)
try:
if master is None:
raise _Unsafe
text = "=" + _shift_formula(master.text, f.row - master.row, f.col - master.col)
except _Unsafe:
text = f"shared:{_a1(master.row, master.col)}" if master else f"shared:si={f.si}"
untranslated += 1
elif f.kind == "array":
text = f"={f.text}" + (f" (array formula, range {f.ref})" if f.ref else "")
elif f.kind == "dataTable":
text = f"=TABLE({f.inputs[0]},{f.inputs[1]})"
else:
text = "=" + f.text
out.append(_Formula(f.row, f.col, text, f.value))
return tuple(out), untranslated
def _tsv(rows: Iterable[list[str]]) -> bytes:
"""RFC 4180 with a tab as the delimiter: a field holding a tab, a quote or a line
break is quoted and its quotes doubled. A row of one empty field is written
quoted, or a reader would take the line for no row at all."""
lines = []
for row in rows:
fields = [
'"' + value.replace('"', '""') + '"' if any(ch in value for ch in '\t"\r\n') else value
for value in row
]
if fields == [""]:
fields = ['""']
lines.append("\t".join(fields) + "\n")
return "".join(lines).encode("utf-8")
PREVIEW_ROWS = 5
PREVIEW_COLUMNS = 20
PREVIEW_CELL = 60
def _slug(name: str) -> str:
slug = re.sub(r"\W+", "-", name.lower()).strip("-")[:32].strip("-")
return slug or "sheet"
def _fence(text: str) -> str:
longest = max((len(run) for run in re.findall(r"`+", text)), default=0)
return "`" * max(3, longest + 1)
def _sheet_section(
number: int, total: int, width: int, name: str, state: str, sheet: _Sheet, files: list[tuple[str, bytes]]
) -> str:
lines = [f"## {name}", "", f"- Sheet {number} of {total}, {state}"]
if sheet.chart:
return "\n".join(lines + ["", "*(chart sheet, no cell data)*"])
if not sheet.cells:
return "\n".join(lines + ["", "*(empty sheet)*"])
grid = [[sheet.cells.get((r, c), "") for c in range(1, sheet.cols + 1)] for r in range(1, sheet.rows + 1)]
stem = f"{number:0{width}d}-{_slug(name)}"
files.append((f"{stem}.tsv", _tsv(grid)))
lines.append(
f"- Range A1:{_a1(sheet.rows, sheet.cols)} ({sheet.rows} rows x {sheet.cols} columns), "
+ (f"{len(sheet.formulas)} formula cell(s)" if sheet.formulas else "no formulas")
)
lines.append(f"- Cells: `{COMPANION_DIR}/{stem}.tsv`")
if sheet.formulas:
rows = [["cell", "formula", "value"]] + [[_a1(f.row, f.col), f.text, f.value] for f in sheet.formulas]
files.append((f"{stem}.formulas.tsv", _tsv(rows)))
lines.append(f"- Formulas: `{COMPANION_DIR}/{stem}.formulas.tsv`")
cut = sheet.cols > PREVIEW_COLUMNS or any(
len(value) > PREVIEW_CELL for row in grid[:PREVIEW_ROWS] for value in row[:PREVIEW_COLUMNS]
)
preview = _tsv(
[[v if len(v) <= PREVIEW_CELL else v[: PREVIEW_CELL - 1] + "…" for v in row[:PREVIEW_COLUMNS]]
for row in grid[:PREVIEW_ROWS]]
).decode("utf-8")
fence = _fence(preview)
label = (
f"Preview (all {sheet.rows} rows)" if sheet.rows <= PREVIEW_ROWS
else f"Preview (first {PREVIEW_ROWS} of {sheet.rows} rows)"
)
if cut:
label += f" (columns past {PREVIEW_COLUMNS} and text past {PREVIEW_CELL} characters cut)"
return "\n".join(lines + ["", f"{label}:", "", fence + "tsv", preview.rstrip("\n"), fence])
class XlsxFilter(Filter):
@@ -640,31 +851,49 @@ class XlsxFilter(Filter):
sheets = book.sheets()
if not sheets:
raise ValidationError("The XLSX file lists no sheets.")
sections, hidden, warnings = [], [], []
for sheet_name, state, part in sheets:
width = max(2, len(str(len(sheets))))
sections, files, hidden, warnings = [], [], [], []
formulas = untranslated = 0
for number, (sheet_name, state, part) in enumerate(sheets, 1):
if state != "visible":
hidden.append(sheet_name)
if not part or not pkg.has(part):
warnings.append(f"Sheet '{sheet_name}' has no readable worksheet part and was left out.")
continue
if _child(pkg.xml(part), "sheetData") is None and _local(pkg.xml(part).tag) == "chartsheet":
sections.append(f"## {sheet_name}\n\n*(chart sheet, no cell data)*")
continue
sections.append(f"## {sheet_name}\n\n{book.table(part)}")
sheet = book.sheet(part)
formulas += len(sheet.formulas)
untranslated += sheet.untranslated
sections.append(_sheet_section(number, len(sheets), width, sheet_name, state, sheet, files))
if book.formulas_without_value:
warnings.append(
f"{book.formulas_without_value} formula cell(s) have no stored value (the file was "
"never calculated) and are empty."
)
if untranslated:
warnings.append(
f"{untranslated} shared formula cell(s) could not be written out and show "
"'shared:<master cell>' - the master cell holds the formula."
)
intro = []
if files:
intro.append(
"Each sheet's cells are in a TSV file beside this one: RFC 4180 with a tab as the "
"delimiter, so a cell holding a tab, a quote or a line break is quoted. Read the "
'files with a CSV reader (`csv.reader(f, delimiter="\\t")`, `pandas.read_csv(sep="\\t")`), '
"not with `cut` or `awk`. Row n of a file is row n of the sheet, column k its k-th column."
)
checks = ["Values are as last saved; formulas were not recalculated."]
if formulas:
checks.append("Each sheet with formulas has a .formulas.tsv beside its cells: cell, formula, stored value.")
if hidden:
checks.append("Hidden sheets are included and named in hidden_sheets.")
return Derivation(
text="\n\n".join(sections),
text="\n\n".join(intro + sections),
title=pkg.title(),
fields=(("sheets", str(len(sheets))), ("hidden_sheets", hidden)),
fields=(("sheets", str(len(sheets))), ("hidden_sheets", hidden), ("formulas", str(formulas))),
warnings=tuple(warnings),
checks=tuple(checks),
files=tuple(files),
)
+196 -11
View File
@@ -5,9 +5,11 @@ readable in the test that uses it and no binary sits in the repository.
"""
from __future__ import annotations
import csv
import hashlib
import io
import json
import shutil
import zipfile
from email import message_from_bytes, policy
from email.message import EmailMessage
@@ -156,6 +158,30 @@ def make_xlsx() -> bytes:
})
def make_book(sheets: list[tuple[str, str, str]]) -> bytes:
"""A workbook of `(name, state, <row> elements)` with inline strings only."""
rels = "".join(
f'<Relationship Id="rId{n}" Type="{R}/worksheet" Target="worksheets/sheet{n}.xml"/>'
for n in range(1, len(sheets) + 1)
)
listed = "".join(
f'<sheet name="{name}" sheetId="{n}" state="{state}" r:id="rId{n}"/>'
for n, (name, state, _rows) in enumerate(sheets, 1)
)
parts = {
"_rels/.rels": _rels("xl/workbook.xml"),
"xl/workbook.xml": f'<workbook xmlns="{S}" xmlns:r="{R}"><sheets>{listed}</sheets></workbook>',
"xl/_rels/workbook.xml.rels": f'<Relationships xmlns="{REL}">{rels}</Relationships>',
}
for n, (_name, _state, rows) in enumerate(sheets, 1):
parts[f"xl/worksheets/sheet{n}.xml"] = f'<worksheet xmlns="{S}"><sheetData>{rows}</sheetData></worksheet>'
return _zip(parts)
def _text_cell(ref: str, text: str) -> str:
return f'<c r="{ref}" t="inlineStr"><is><t xml:space="preserve">{text.replace(chr(13), "&#13;")}</t></is></c>'
def make_eml(body: str = "Hallo Welt\n", *, html: str = "", attach: bool = False, inline: bool = False,
subject: str = "Angebot") -> bytes:
msg = EmailMessage()
@@ -374,7 +400,11 @@ def test_a_folder_gets_a_md_beside_each_file_with_a_filter(tree, capsys):
_derive("incoming/mappe")
out = capsys.readouterr().out
assert sorted(p.name for p in folder.iterdir()) == ["foto.jpg", "plan.docx", "plan.md", "zahlen.md", "zahlen.xlsx"]
assert sorted(p.name for p in folder.iterdir()) == ["foto.jpg", "plan.docx", "plan.md", "zahlen.md", "zahlen.sheets", "zahlen.xlsx"]
assert sorted(p.name for p in (folder / "zahlen.sheets").iterdir()) == [
"01-budget.formulas.tsv", "01-budget.tsv", "02-geheim.tsv",
]
assert "`zahlen.sheets/01-budget.tsv`" in (folder / "zahlen.md").read_text(encoding="utf-8")
header, _ = _header_and_text(folder / "plan.md")
assert header["derived_from"] == "plan.docx"
assert "original_name" not in header
@@ -487,20 +517,175 @@ def test_pptx_slides_notes_and_footer_placeholders(tree):
# --- XLSX -------------------------------------------------------------------
def test_xlsx_values_hidden_sheet_and_trimmed_edges(tree, capsys):
def _tsv_rows(path: Path) -> list[list[str]]:
with path.open(newline="", encoding="utf-8") as handle:
return list(csv.reader(handle, delimiter="\t"))
def test_xlsx_becomes_an_overview_and_one_tsv_per_sheet(tree, capsys):
_put(tree, "zahlen.xlsx", make_xlsx())
_derive("incoming/zahlen.xlsx")
out = capsys.readouterr().out
header, text = _header_and_text(tree / "incoming/zahlen/derived.md")
folder = tree / "incoming/zahlen"
header, text = _header_and_text(folder / "derived.md")
assert header["sheets"] == 2 and header["hidden_sheets"] == ["Geheim"]
assert "## Budget" in text and "## Geheim" in text and "VERSTECKT-INHALT" in text
assert "| Posten | Wert |" in text
assert "| Miete | 1200 |" in text
assert "2024-01-01" in text
assert "TRUE" in text
assert text.splitlines()[text.splitlines().index("## Budget") + 2].startswith("| Posten")
assert "Values are as last saved" in out and "Hidden sheets" in out
assert header["sheets"] == 2 and header["hidden_sheets"] == ["Geheim"] and header["formulas"] == 1
assert "## Budget" in text and "## Geheim" in text
assert "Sheet 2 of 2, hidden" in text
assert "Range A1:C5 (5 rows x 3 columns), 1 formula cell(s)" in text
assert "`derived.sheets/01-budget.tsv`" in text and "`derived.sheets/01-budget.formulas.tsv`" in text
assert "`derived.sheets/02-geheim.tsv`" in text and "02-geheim.formulas" not in text
assert "Preview" in text and "Posten\tWert" in text
# Row n of the file is row n of the sheet, column k its k-th column: the sheet starts at B2.
assert _tsv_rows(folder / "derived.sheets/01-budget.tsv") == [
["", "", ""],
["", "Posten", "Wert"],
["", "Miete", "1200"],
["", "Stichtag", "2024-01-01"],
["", "Fertig", "TRUE"],
]
assert _tsv_rows(folder / "derived.sheets/01-budget.formulas.tsv") == [
["cell", "formula", "value"],
["C3", "=1200+0", "1200"],
]
assert _tsv_rows(folder / "derived.sheets/02-geheim.tsv") == [["VERSTECKT-INHALT"]]
assert "Values are as last saved" in out and "Hidden sheets" in out and ".formulas.tsv" in out
assert "derived.sheets" in out
def test_tsv_quotes_per_rfc_4180_and_reads_back_cell_for_cell(tree):
cells = ["a\tb", 'say "hi"', "two\nlines", "cr\rhere", " padded ", "plain", "="]
row = "".join(_text_cell(f"{chr(65 + i)}1", c) for i, c in enumerate(cells))
single = '<row r="1"><c r="A1" t="inlineStr"><is><t></t></is></c></row><row r="2">' + _text_cell("A2", "x") + "</row>"
_put(tree, "q.xlsx", make_book([("Quoting", "visible", f'<row r="1">{row}</row>'), ("One column", "visible", single)]))
_derive("incoming/q.xlsx")
sheets = tree / "incoming/q/derived.sheets"
assert _tsv_rows(sheets / "01-quoting.tsv") == [cells]
raw = (sheets / "01-quoting.tsv").read_bytes()
assert raw.endswith(b"\n") and b'"a\tb"' in raw and b'"say ""hi"""' in raw and b'"cr\rhere"' in raw
# A single-column sheet whose first row is empty: the empty row is still a row.
assert (sheets / "02-one-column.tsv").read_bytes() == b'""\nx\n'
only_empty_first = make_book([("S", "visible", '<row r="2">' + _text_cell("A2", "x") + "</row>")])
_put(tree, "e.xlsx", only_empty_first)
_derive("incoming/e.xlsx")
assert _tsv_rows(tree / "incoming/e/derived.sheets/01-s.tsv") == [[""], ["x"]]
assert (tree / "incoming/e/derived.sheets/01-s.tsv").read_bytes() == b'""\nx\n'
def test_shared_array_and_plain_formulas_are_listed_with_their_stored_values(tree, capsys):
master = "A2*$B$1+B2+SUM(A$1:B$1)+LOG10(A2)&amp;\"A2\"&amp;'My Sheet'!A2"
rows = (
'<row r="1"><c r="A1"><v>10</v></c><c r="B1"><v>2</v></c></row>'
'<row r="2"><c r="A2"><v>20</v></c><c r="B2"><v>3</v></c>'
f'<c r="C2"><f t="shared" ref="C2:C4" si="0">{master}</f><v>1</v></c></row>'
'<row r="3"><c r="A3"><v>30</v></c><c r="C3"><f t="shared" si="0"/><v>2</v></c></row>'
'<row r="4"><c r="C4"><f t="shared" si="0"/><v>3</v></c>'
'<c r="D4"><f t="array" ref="D4:D5">SUM(A2:A4*B2:B4)</f><v>7</v></c></row>'
)
_put(tree, "f.xlsx", make_book([("Calc", "visible", rows)]))
_derive("incoming/f.xlsx")
formulas = _tsv_rows(tree / "incoming/f/derived.sheets/01-calc.formulas.tsv")
def shifted(n: int) -> str:
return f"=A{n}*$B$1+B{n}+SUM(A$1:B$1)+LOG10(A{n})&\"A2\"&'My Sheet'!A{n}"
assert formulas == [
["cell", "formula", "value"],
["C2", shifted(2), "1"],
["C3", shifted(3), "2"],
["C4", shifted(4), "3"],
["D4", "=SUM(A2:A4*B2:B4) (array formula, range D4:D5)", "7"],
]
assert "WARN" not in capsys.readouterr().out
assert _header_and_text(tree / "incoming/f/derived.md")[0]["formulas"] == 4
def test_a_shared_formula_that_would_leave_the_sheet_is_named_by_its_master(tree, capsys):
rows = (
'<row r="5"><c r="B5"><f t="shared" ref="B5:B6" si="3">A4+1</f><v>1</v></c></row>'
'<row r="6"><c r="A6"><f t="shared" si="3"/><v>2</v></c><c r="C6"><f t="shared" si="9"/><v>3</v></c></row>'
)
_put(tree, "u.xlsx", make_book([("S", "visible", rows)]))
_derive("incoming/u.xlsx")
out = capsys.readouterr().out
formulas = _tsv_rows(tree / "incoming/u/derived.sheets/01-s.formulas.tsv")
assert formulas[1:] == [["B5", "=A4+1", "1"], ["A6", "shared:B5", "2"], ["C6", "shared:si=9", "3"]]
assert "WARN 2 shared formula cell(s) could not be written out" in out
def test_xlsx_files_are_the_same_on_a_second_run(tree):
data = make_xlsx()
_put(tree, "a.xlsx", data)
_derive("incoming/a.xlsx")
first = {k: v for k, v in _snapshot(tree / "incoming").items() if k.endswith(".tsv")}
shutil.rmtree(tree / "incoming/a")
_put(tree, "a.xlsx", data)
_derive("incoming/a.xlsx")
assert {k: v for k, v in _snapshot(tree / "incoming").items() if k.endswith(".tsv")} == first
assert first
def test_an_empty_and_a_chart_sheet_get_no_file(tree):
data = make_book([("Leer", "visible", ""), ("Daten", "visible", '<row r="1">' + _text_cell("A1", "x") + "</row>")])
_put(tree, "l.xlsx", data)
_derive("incoming/l.xlsx")
_header, text = _header_and_text(tree / "incoming/l/derived.md")
assert "*(empty sheet)*" in text
assert [p.name for p in (tree / "incoming/l/derived.sheets").iterdir()] == ["02-daten.tsv"]
def test_sheet_files_count_against_the_path_budget_and_a_refusal_leaves_incoming_alone(tree, capsys):
stem = "s" * 120
_put(tree, f"{stem}.xlsx", make_xlsx())
before = _snapshot(tree / "incoming")
_refused(f"incoming/{stem}.xlsx")
assert "--name" in capsys.readouterr().out
assert _snapshot(tree / "incoming") == before
_derive(f"incoming/{stem}.xlsx", "kurz")
assert (tree / "incoming/kurz/derived.sheets/01-budget.tsv").is_file()
def test_the_folder_form_never_overwrites_a_sheets_folder_and_changes_nothing(tree):
folder = tree / "incoming/mappe"
(folder / "zahlen.sheets").mkdir(parents=True)
(folder / "zahlen.xlsx").write_bytes(make_xlsx())
before = _snapshot(tree / "incoming")
(folder / "zahlen.sheets/x.txt").write_text("mine", encoding="utf-8")
before = _snapshot(tree / "incoming")
_refused("incoming/mappe")
assert _snapshot(tree / "incoming") == before
def test_a_workbook_in_a_zip_or_a_mail_keeps_its_sheets_beside_its_md(tree):
_put(tree, "s.zip", make_zip({"sub/Budget 2024.xlsx": make_xlsx(), "Budget 2024.sheets": b"in the way"}))
_derive("incoming/s.zip")
folder = tree / "incoming/s"
assert (folder / "sub/Budget 2024.md").is_file()
assert sorted(p.name for p in (folder / "sub/Budget 2024.sheets").iterdir()) == [
"01-budget.formulas.tsv", "01-budget.tsv", "02-geheim.tsv",
]
assert "`Budget 2024.sheets/01-budget.tsv`" in (folder / "sub/Budget 2024.md").read_text(encoding="utf-8")
_put(tree, "m.eml", make_mail([("derived.xlsx", make_xlsx(), "application/octet-stream")]))
_derive("incoming/m.eml")
mail = tree / "incoming/m"
assert (mail / "derived-2.md").is_file() and (mail / "derived-2.sheets/01-budget.tsv").is_file()
assert "`derived-2.sheets/01-budget.tsv`" in (mail / "derived-2.md").read_text(encoding="utf-8")
def test_the_derived_folder_with_its_sheet_files_is_accepted_whole(tree):
_put(tree, "zahlen.xlsx", make_xlsx())
_derive("incoming/zahlen.xlsx")
raw_accept_command(
files=[Path("incoming/zahlen")], fidelity="verbatim", authority="reporting",
page=None, replaces=None, dry_run=False,
)
accepted = sorted(p for p in (tree / "raw").rglob("*.tsv"))
assert [p.name for p in accepted] == ["01-budget.formulas.tsv", "01-budget.tsv", "02-geheim.tsv"]
assert not (tree / "incoming/zahlen").exists()
def test_xlsx_check_is_printed_only_for_the_formats_derived_in_the_run(tree, capsys):
@@ -0,0 +1,104 @@
"""`wikitool raw derive` against the real documents in `testfiles/`.
The synthetic fixtures in `binary_formats.py` and `test_raw_derive.py` prove the filters on
documents built to be easy; the files in `testfiles/` are whatever the world produced. Every file
there is derived, compared with itself on a second run and accepted into `raw/`, and an optional
`<file>.expect` next to it names the text that must come out. A distributed instance has no
`testfiles/`, so the whole module is then skipped.
"""
from __future__ import annotations
import importlib.util
import shutil
from pathlib import Path
import pytest
from chemenu import filters
from chemenu.commands.raw_cmd import raw_accept_command
from chemenu.tests.test_raw_derive import ( # noqa: F401 (tree is a fixture)
_derive,
_header_and_text,
tree,
)
TESTFILES = Path(__file__).resolve().parents[3] / "testfiles"
SIDECAR = ".expect"
pytestmark = pytest.mark.skipif(
importlib.util.find_spec("pypdf") is None or importlib.util.find_spec("olefile") is None,
reason="pypdf and olefile are needed for real PDFs, MSGs and archives holding them",
)
def _documents() -> list[Path]:
if not TESTFILES.is_dir():
return []
return sorted(
path
for path in TESTFILES.rglob("*")
if path.is_file()
and not path.name.startswith(".")
and path.name != "README.md"
and path.suffix != SIDECAR
)
def _id(path: Path) -> str:
return path.relative_to(TESTFILES).as_posix()
def _texts(folder: Path) -> dict[str, str]:
texts = {}
for md in sorted(folder.rglob("*.md")):
lines = md.read_text(encoding="utf-8").splitlines()
texts[md.relative_to(folder).as_posix()] = "\n".join(
line for line in lines if not line.startswith("derived:")
)
return texts
def _expected(document: Path) -> list[str]:
sidecar = document.with_name(document.name + SIDECAR)
if not sidecar.is_file():
return []
lines = sidecar.read_text(encoding="utf-8").splitlines()
return [line for line in lines if line.strip() and not line.startswith("#")]
def _put_and_derive(tree: Path, document: Path) -> Path:
(tree / "incoming" / document.name).write_bytes(document.read_bytes())
_derive(f"incoming/{document.name}")
return tree / "incoming" / document.stem
@pytest.mark.parametrize("document", _documents(), ids=_id)
def test_a_real_document_derives_stably_and_is_accepted(tree, document):
assert filters.filter_for(document.name) is not None, (
f"no import filter takes {document.name}; a file in testfiles/ must be one `raw derive` reads"
)
folder = _put_and_derive(tree, document)
assert not (tree / "incoming" / document.name).exists()
if document.suffix.lower() == ".zip":
assert any(folder.rglob("*.md")), "an unpacked archive gets a .md beside its documents"
else:
original = folder / f"original{document.suffix.lower()}"
assert original.read_bytes() == document.read_bytes()
header, _text = _header_and_text(folder / "derived.md")
assert header["derived_by"] == "wikitool raw derive"
assert header["original_name"] == document.name
first = _texts(folder)
for needle in _expected(document):
assert any(needle in text for text in first.values()), f"{needle!r} not in the derived text"
shutil.rmtree(folder)
assert _texts(_put_and_derive(tree, document)) == first, "the same bytes gave a different text"
raw_accept_command(
files=[Path(f"incoming/{document.stem}")], fidelity="verbatim", authority="reporting",
page=None, replaces=None, dry_run=False,
)
assert not (tree / "incoming" / document.stem).exists()