feat(raw): raw derive writes an Excel file's sheets as TSV files with a formula list (#202)
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:
1 parent
8ea2795d88
commit
9868fce3d5
15 files changed
+719
-50
No files matched your search
@@ -11,3 +11,7 @@
|
|||||||
# and normalizing a CRLF source on `git add` would change it.
|
# and normalizing a CRLF source on `git add` would change it.
|
||||||
/raw/** -text
|
/raw/** -text
|
||||||
/incoming/** -text
|
/incoming/** -text
|
||||||
|
|
||||||
|
# Real documents for the `raw derive` tests are compared byte for byte (an .eml with CRLF
|
||||||
|
# included), so nothing under testfiles/ is ever normalized.
|
||||||
|
/testfiles/** -text
|
||||||
+2
-1
@@ -14,7 +14,7 @@ place only: `instructions/dev/version-parts.md` § The candidate model.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9.0.0-beta.17 - 2026-10-08 - raw pending names only the raw fetch pair as a bundle; a derived document is a folder
|
## 9.0.0-beta.18 - 2026-10-09 - raw derive writes an Excel file's sheets as tab-separated files with a formula list; derived.md becomes the overview (#202)
|
||||||
|
|
||||||
**Author:** Torben Nehmer
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
@@ -47,6 +47,7 @@ place only: `instructions/dev/version-parts.md` § The candidate model.
|
|||||||
- tools/README.md: Asset-Modus beschreibt Entpacken in den eigenen Ordner, Wiederholung und Zurückrollen
|
- tools/README.md: Asset-Modus beschreibt Entpacken in den eigenen Ordner, Wiederholung und Zurückrollen
|
||||||
- Gitea-Wiki als generierter Spiegel von docs/: Konverter, Push-Skript, Workflows
|
- Gitea-Wiki als generierter Spiegel von docs/: Konverter, Push-Skript, Workflows
|
||||||
- raw pending names only the raw fetch pair as a bundle; a derived document is a folder
|
- raw pending names only the raw fetch pair as a bundle; a derived document is a folder
|
||||||
|
- raw derive writes an Excel file's sheets as tab-separated files with a formula list; derived.md becomes the overview (#202)
|
||||||
<!-- /wikitool:bumps -->
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
### Tutorials: installation in English, and a first source to a first answer (#191)
|
### Tutorials: installation in English, and a first source to a first answer (#191)
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ Then have the agent run `instructions/bootstrap.md`: the preflight, then
|
|||||||
and how `dist export` runs as a test tool - is
|
and how `dist export` runs as a test tool - is
|
||||||
[instructions/dev/dev-setup.md](../../instructions/dev/dev-setup.md).
|
[instructions/dev/dev-setup.md](../../instructions/dev/dev-setup.md).
|
||||||
|
|
||||||
|
Real documents for `raw derive` live in [testfiles/](../../testfiles/README.md) and are
|
||||||
|
committed, so CI derives them too. The repository is public: that README says what may go in.
|
||||||
|
|
||||||
What a clone has and an instance does not:
|
What a clone has and an instance does not:
|
||||||
|
|
||||||
- **The demo corpus.** Some 170 pages documenting the stack itself: gates, lint, versioning,
|
- **The demo corpus.** Some 170 pages documenting the stack itself: gates, lint, versioning,
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ pick a directory:
|
|||||||
Outlook `.msg`) or a ZIP archive - is
|
Outlook `.msg`) or a ZIP archive - is
|
||||||
put into `incoming/` as it is. `tools/wikitool raw derive incoming/<file>` turns it into a folder with the
|
put into `incoming/` as it is. `tools/wikitool raw derive incoming/<file>` turns it into a folder with the
|
||||||
untouched original and a readable text beside it, with a header saying how it was made. The
|
untouched original and a readable text beside it, with a header saying how it was made. The
|
||||||
folder is accepted whole; the agent runs this itself when it meets such a file. A ZIP is
|
folder is accepted whole; the agent runs this itself when it meets such a file. An Excel file's
|
||||||
|
sheets land beside the text as tab-separated files, with their formulas listed apart. A ZIP is
|
||||||
unpacked into a folder instead, and a mail's attachments land beside it. A scanned PDF has no
|
unpacked into a folder instead, and a mail's attachments land beside it. A scanned PDF has no
|
||||||
text to derive - the tool warns, and there is no OCR.
|
text to derive - the tool warns, and there is no OCR.
|
||||||
- **A page behind a paywall or a login** is saved from your browser into `incoming/`, HTML only. The
|
- **A page behind a paywall or a login** is saved from your browser into `incoming/`, HTML only. The
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ tools/wikitool raw derive incoming/scans.zip
|
|||||||
- 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.
|
- 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.
|
- `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.
|
- 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 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.
|
- 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.
|
- 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.
|
||||||
@@ -136,7 +137,7 @@ tools/wikitool raw derive incoming/scans.zip
|
|||||||
|
|
||||||
- `0` - success
|
- `0` - success
|
||||||
- `1` - The path is not directly in `incoming/`, does not exist, or no filter reads the file's extension
|
- `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
|
- `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
|
||||||
|
|
||||||
**See also**
|
**See also**
|
||||||
|
|||||||
@@ -289,6 +289,17 @@ output. A name that is taken or reserved (`original.*` and `derived.md` at the t
|
|||||||
folder, `_capture.json` anywhere) gets `-2`, `-3` before its extension, and the output lists
|
folder, `_capture.json` anywhere) gets `-2`, `-3` before its extension, and the output lists
|
||||||
every rename.
|
every rename.
|
||||||
|
|
||||||
|
**A workbook's cells are files, not text.** An Excel file becomes an overview in `derived.md` -
|
||||||
|
one section per sheet with its range, its file names and a short preview - and, beside it, a
|
||||||
|
folder `<stem>.sheets/` (`derived.sheets/` for a single file) with one tab-separated file per
|
||||||
|
sheet. The format is RFC 4180 with a tab as the delimiter, so a cell holding a tab, a quote or a
|
||||||
|
line break is quoted and a reader gets every cell back unchanged; row *n* of the file is row *n*
|
||||||
|
of the sheet, so a cell address in the file is the address in Excel. A sheet with formulas gets
|
||||||
|
a second file listing each cell, its formula and the value Excel last stored: the tool reads
|
||||||
|
the file and does not recalculate it. Text that is data is read by a script, not by a model's
|
||||||
|
eye; the overview is what tells the reader which file to open. The folder is part of the
|
||||||
|
derivation, accepted with it, and an existing one is never overwritten.
|
||||||
|
|
||||||
**The original stays.** The derivation is the tool's reading of it, never the source: with the
|
**The original stays.** The derivation is the tool's reading of it, never the source: with the
|
||||||
original kept, a claim in `kb/` can still be checked against what the document said when a
|
original kept, a claim in `kb/` can still be checked against what the document said when a
|
||||||
derivation dropped something, and a later version of the tool can derive it better. Both go into
|
derivation dropped something, and a later version of the tool can derive it better. Both go into
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# testfiles
|
||||||
|
|
||||||
|
Real documents (PDF, mail, Office, ZIP) that CI feeds to `raw derive`, beyond the synthetic
|
||||||
|
fixtures in `tools/chemenu/tests/binary_formats.py`. `tools/chemenu/tests/test_raw_derive_testfiles.py`
|
||||||
|
picks up every file under this directory, so adding a file is adding a test case - no code change.
|
||||||
|
The directory exists only in a development clone: `dist export` does not ship it.
|
||||||
|
|
||||||
|
## What goes in
|
||||||
|
|
||||||
|
**Everything here is committed to a public repository.** A file belongs here only when it can be
|
||||||
|
public:
|
||||||
|
|
||||||
|
- a document you wrote for the purpose, or one whose licence allows redistribution;
|
||||||
|
- no real people, customers, employers, addresses or internal URLs - in the text *and* in the
|
||||||
|
metadata (PDF author and producer, Office author and company, mail headers, ZIP entry names);
|
||||||
|
- nothing that was received in confidence.
|
||||||
|
|
||||||
|
Removing a file later does not remove it from the history.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| Folder | For |
|
||||||
|
|--------|-----|
|
||||||
|
| `pdf/` | PDFs, including scanned ones without a text layer |
|
||||||
|
| `mail/` | `.eml` and `.msg`, with and without attachments, with attached mails |
|
||||||
|
| `office/` | `.docx`, `.pptx`, `.xlsx` (tracked changes, comments, hidden sheets) |
|
||||||
|
| `zip/` | archives, including mail-in-ZIP cases |
|
||||||
|
|
||||||
|
The folders are a convention; the test reads every file below this directory. Keep file names
|
||||||
|
short and free of spaces - a name that is too long for the `raw/` path budget fails the test for
|
||||||
|
the right reason, but it is a nuisance to find.
|
||||||
|
|
||||||
|
## What the test checks
|
||||||
|
|
||||||
|
For each file, in a throwaway instance: `raw derive` succeeds; the original comes back byte for
|
||||||
|
byte (a ZIP is unpacked and gets a `.md` beside its documents instead); a second run over the same
|
||||||
|
bytes gives the same text apart from `derived:`; and `raw accept` takes the folder.
|
||||||
|
|
||||||
|
A file may carry a sidecar `<file name>.expect` (`offer.pdf.expect` for `offer.pdf`): one line per
|
||||||
|
piece of text that must appear in the derived text, empty lines and lines starting with `#`
|
||||||
|
ignored. Without one the file only has to derive, be stable and be accepted. A document that is
|
||||||
|
meant to be refused (encrypted, damaged) does not belong here; the synthetic tests cover refusals.
|
||||||
|
|
||||||
|
## Trying a file by hand
|
||||||
|
|
||||||
|
`raw derive` moves what it is given, so work on a copy in a throwaway instance, never on
|
||||||
|
`incoming/` of this clone.
|
||||||
+3
-2
@@ -1495,13 +1495,13 @@ Derive reading text from a PDF, e-mail, Office file or web page in `incoming/`,
|
|||||||
|
|
||||||
- 0 success
|
- 0 success
|
||||||
- 1 The path is not directly in `incoming/`, does not exist, or no filter reads the file's extension
|
- 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
|
- 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**
|
**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 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
|
- 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**
|
**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.
|
- 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.
|
- `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.
|
- 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 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -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
|
"""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."""
|
the check above, and a failure part-way removes what this call wrote."""
|
||||||
written: list[Path] = []
|
written: list[Path] = []
|
||||||
|
made: list[Path] = []
|
||||||
try:
|
try:
|
||||||
for path, data in writes:
|
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)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
made.extend(missing)
|
||||||
with path.open("xb") as handle:
|
with path.open("xb") as handle:
|
||||||
written.append(path)
|
written.append(path)
|
||||||
handle.write(data)
|
handle.write(data)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
for path in written:
|
for path in written:
|
||||||
path.unlink(missing_ok=True)
|
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/."))
|
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/
|
source: Path # the file in incoming/
|
||||||
markdown: bytes
|
markdown: bytes
|
||||||
target: Path # where the .md goes
|
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]:
|
def _derive_hint(path: Path) -> Optional[str]:
|
||||||
@@ -1897,6 +1906,7 @@ class _FilePlan:
|
|||||||
markdown: bytes
|
markdown: bytes
|
||||||
container: Optional[filters.Container]
|
container: Optional[filters.Container]
|
||||||
derivations: list[filters.Derivation]
|
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:
|
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""
|
markdown = b""
|
||||||
targets = [rel for rel, _data in container.files]
|
targets = [rel for rel, _data in container.files]
|
||||||
derivations = list(container.derivations)
|
derivations = list(container.derivations)
|
||||||
|
extras = ()
|
||||||
else:
|
else:
|
||||||
original = folder / f"{ORIGINAL_STEM}{extension}"
|
original = folder / f"{ORIGINAL_STEM}{extension}"
|
||||||
derived = folder / DERIVED_NAME
|
derived = folder / DERIVED_NAME
|
||||||
derivation = container.text if container is not None else _run_filter(candidate, data, path)
|
derivation = container.text if container is not None else _run_filter(candidate, data, path)
|
||||||
assert derivation is not None
|
assert derivation is not None
|
||||||
|
companion = filters.companion_name(DERIVED_NAME) if derivation.files else None
|
||||||
markdown = filters.render(
|
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 []
|
[rel for rel, _data in container.files] if container is not None else []
|
||||||
)
|
)
|
||||||
derivations = [derivation] + (list(container.derivations) 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 "
|
f"Cannot write {problem}. The folder name comes from the file's name: pass "
|
||||||
"--name <shorter stem>."
|
"--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:
|
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:
|
if plan.derived is not None:
|
||||||
with plan.derived.open("xb") as handle:
|
with plan.derived.open("xb") as handle:
|
||||||
handle.write(plan.markdown)
|
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:
|
if plan.container is not None:
|
||||||
for rel, data in plan.container.files:
|
for rel, data in plan.container.files:
|
||||||
target = plan.folder / rel
|
target = plan.folder / rel
|
||||||
@@ -2054,8 +2073,26 @@ def _plan_derive_folder(folder: Path):
|
|||||||
derived: list[_Derived] = []
|
derived: list[_Derived] = []
|
||||||
for file, candidate, md in plan:
|
for file, candidate, md in plan:
|
||||||
derivation = _run_filter(candidate, _read_for_derive(file), file)
|
derivation = _run_filter(candidate, _read_for_derive(file), file)
|
||||||
markdown = filters.render(candidate, derivation, derived_from=file.name)
|
companion = md.with_name(filters.companion_name(md.name))
|
||||||
derived.append(_Derived(candidate, derivation, file, markdown, md))
|
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
|
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 "
|
"`<!-- 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 "
|
"no OCR); an encrypted PDF is refused. A `.msg` is a container like an `.eml`; an "
|
||||||
"embedded message inside it is named, not read.",
|
"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 "
|
"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` "
|
"`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 "
|
"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",
|
"subfolder is moved up, or its folder is derived whole",
|
||||||
),
|
),
|
||||||
cli_contract.Failure(
|
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",
|
"path budget",
|
||||||
reaction="Nothing was written. Remove what is there or pass `--name <shorter stem>`, "
|
reaction="Nothing was written. Remove what is there or pass `--name <shorter stem>`, "
|
||||||
"then retry once. An existing `.md` is never overwritten",
|
"then retry once. An existing `.md` is never overwritten",
|
||||||
@@ -2214,9 +2260,11 @@ def raw_derive_command(
|
|||||||
if name is not None:
|
if name is not None:
|
||||||
fail("--name applies to a single file; a folder keeps its own name.")
|
fail("--name applies to a single file; a folder keeps its own name.")
|
||||||
derived, untouched = _refusals_fail(_plan_derive_folder, resolved)
|
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"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:
|
if untouched:
|
||||||
lines.append(
|
lines.append(
|
||||||
" No filter for, left as they are: " + ", ".join(rel_path(p) for p in untouched)
|
" 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"Derived {rel_path(plan.derived)} from {resolved.name} ({plan.candidate.name}); the "
|
||||||
f"original is now {rel_path(plan.original)}."
|
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:
|
if container is not None:
|
||||||
report = _container_report(container)
|
report = _container_report(container)
|
||||||
if report:
|
if report:
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ from chemenu.version import VersionError, read_version
|
|||||||
|
|
||||||
MAX_CHECKS = 3
|
MAX_CHECKS = 3
|
||||||
|
|
||||||
|
COMPANION_SUFFIX = ".sheets"
|
||||||
|
COMPANION_DIR = "\x00companion\x00" # NUL cannot occur in XML text: a safe placeholder inside `Derivation.text`
|
||||||
|
|
||||||
PREFLIGHT_HINT = (
|
PREFLIGHT_HINT = (
|
||||||
"Run the preflight to install it (tools/preflight.sh, or tools/preflight.ps1 on Windows; "
|
"Run the preflight to install it (tools/preflight.sh, or tools/preflight.ps1 on Windows; "
|
||||||
"instructions/preflight.md). Every other format keeps working."
|
"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
|
`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`
|
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.
|
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
|
text: str
|
||||||
@@ -43,6 +51,7 @@ class Derivation:
|
|||||||
fields: tuple[tuple[str, "str | list[str]"], ...] = ()
|
fields: tuple[tuple[str, "str | list[str]"], ...] = ()
|
||||||
warnings: tuple[str, ...] = ()
|
warnings: tuple[str, ...] = ()
|
||||||
checks: tuple[str, ...] = ()
|
checks: tuple[str, ...] = ()
|
||||||
|
files: tuple[tuple[str, bytes], ...] = ()
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if len(self.checks) > MAX_CHECKS:
|
if len(self.checks) > MAX_CHECKS:
|
||||||
@@ -176,9 +185,11 @@ def render(
|
|||||||
derived_from: str,
|
derived_from: str,
|
||||||
original_name: Optional[str] = None,
|
original_name: Optional[str] = None,
|
||||||
derived_at: Optional[str] = None,
|
derived_at: Optional[str] = None,
|
||||||
|
companion: Optional[str] = None,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""The `.md` for one document: the fixed header, then the derived text.
|
"""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]"]] = [
|
fields: list[tuple[str, "str | list[str]"]] = [
|
||||||
("derived_by", "wikitool raw derive"),
|
("derived_by", "wikitool raw derive"),
|
||||||
("filter", candidate.name),
|
("filter", candidate.name),
|
||||||
@@ -191,10 +202,20 @@ def render(
|
|||||||
fields.append(("title", derivation.title))
|
fields.append(("title", derivation.title))
|
||||||
fields.extend(derivation.fields)
|
fields.extend(derivation.fields)
|
||||||
text = derivation.text.strip("\n")
|
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 "")
|
content = web_capture.header(fields) + "\n" + (text + "\n" if text else "")
|
||||||
return content.encode("utf-8")
|
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]:
|
def unique(lines: Iterable[str]) -> list[str]:
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
out = []
|
out = []
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from chemenu.filters import (
|
|||||||
Container,
|
Container,
|
||||||
Derivation,
|
Derivation,
|
||||||
Filter,
|
Filter,
|
||||||
|
companion_name,
|
||||||
filter_for,
|
filter_for,
|
||||||
register,
|
register,
|
||||||
render,
|
render,
|
||||||
@@ -211,10 +212,19 @@ class Collector:
|
|||||||
raise ValidationError(f"{path}: {exc}") from None
|
raise ValidationError(f"{path}: {exc}") from None
|
||||||
stem = path[: len(path) - len(os.path.splitext(base)[1])]
|
stem = path[: len(path) - len(os.path.splitext(base)[1])]
|
||||||
md = self._tree.claim(stem + ".md")
|
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((
|
files.append((
|
||||||
md,
|
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))
|
derived.append((path, md, candidate.name))
|
||||||
derivations.append(derivation)
|
derivations.append(derivation)
|
||||||
checks: list[str] = []
|
checks: list[str] = []
|
||||||
|
|||||||
+252
-23
@@ -12,10 +12,11 @@ import posixpath
|
|||||||
import re
|
import re
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
import zipfile
|
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.errors import ValidationError
|
||||||
from chemenu.filters import Derivation, Filter, register
|
from chemenu.filters import COMPANION_DIR, Derivation, Filter, register
|
||||||
|
|
||||||
PART_LIMIT = 100 * 1024 * 1024
|
PART_LIMIT = 100 * 1024 * 1024
|
||||||
CELL_LIMIT = 2_000_000
|
CELL_LIMIT = 2_000_000
|
||||||
@@ -596,11 +597,20 @@ class _Workbook:
|
|||||||
self.formulas_without_value += 1
|
self.formulas_without_value += 1
|
||||||
return text
|
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] = {}
|
cells: dict[tuple[int, int], str] = {}
|
||||||
|
pending: list[_PendingFormula] = []
|
||||||
row_no, last_col = 0, 0
|
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)
|
tag = _local(el.tag)
|
||||||
|
if event == "start":
|
||||||
|
if not root_seen:
|
||||||
|
root_seen = True
|
||||||
|
chart = tag == "chartsheet"
|
||||||
|
continue
|
||||||
if tag == "c":
|
if tag == "c":
|
||||||
ref = re.fullmatch(r"([A-Za-z]+)(\d+)", el.get("r") or "")
|
ref = re.fullmatch(r"([A-Za-z]+)(\d+)", el.get("r") or "")
|
||||||
if ref:
|
if ref:
|
||||||
@@ -609,25 +619,226 @@ class _Workbook:
|
|||||||
col, row = last_col + 1, row_no + 1
|
col, row = last_col + 1, row_no + 1
|
||||||
last_col = col
|
last_col = col
|
||||||
text = self._value(el)
|
text = self._value(el)
|
||||||
if text.strip():
|
if text != "":
|
||||||
cells[(row, col)] = 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()
|
el.clear()
|
||||||
elif tag == "row":
|
elif tag == "row":
|
||||||
row_no = int(el.get("r") or row_no + 1)
|
row_no = int(el.get("r") or row_no + 1)
|
||||||
last_col = 0
|
last_col = 0
|
||||||
el.clear()
|
el.clear()
|
||||||
if not cells:
|
if chart:
|
||||||
return "*(empty sheet)*"
|
return _Sheet(chart=True)
|
||||||
rows = [r for r, _c in cells]
|
rows = max((r for r, _c in cells), default=0)
|
||||||
cols = [c for _r, c in cells]
|
cols = max((c for _r, c in cells), default=0)
|
||||||
min_r, max_r, min_c, max_c = min(rows), max(rows), min(cols), max(cols)
|
if rows * cols > CELL_LIMIT:
|
||||||
if (max_r - min_r + 1) * (max_c - min_c + 1) > CELL_LIMIT:
|
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"A sheet spans {max_r - min_r + 1} rows by {max_c - min_c + 1} columns - too large "
|
f"A sheet spans {rows} rows by {cols} columns from A1 - too large or too sparse "
|
||||||
"or too sparse to derive."
|
"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)]
|
formulas, untranslated = _resolve_formulas(pending)
|
||||||
return _table(grid)
|
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):
|
class XlsxFilter(Filter):
|
||||||
@@ -640,31 +851,49 @@ class XlsxFilter(Filter):
|
|||||||
sheets = book.sheets()
|
sheets = book.sheets()
|
||||||
if not sheets:
|
if not sheets:
|
||||||
raise ValidationError("The XLSX file lists no sheets.")
|
raise ValidationError("The XLSX file lists no sheets.")
|
||||||
sections, hidden, warnings = [], [], []
|
width = max(2, len(str(len(sheets))))
|
||||||
for sheet_name, state, part in sheets:
|
sections, files, hidden, warnings = [], [], [], []
|
||||||
|
formulas = untranslated = 0
|
||||||
|
for number, (sheet_name, state, part) in enumerate(sheets, 1):
|
||||||
if state != "visible":
|
if state != "visible":
|
||||||
hidden.append(sheet_name)
|
hidden.append(sheet_name)
|
||||||
if not part or not pkg.has(part):
|
if not part or not pkg.has(part):
|
||||||
warnings.append(f"Sheet '{sheet_name}' has no readable worksheet part and was left out.")
|
warnings.append(f"Sheet '{sheet_name}' has no readable worksheet part and was left out.")
|
||||||
continue
|
continue
|
||||||
if _child(pkg.xml(part), "sheetData") is None and _local(pkg.xml(part).tag) == "chartsheet":
|
sheet = book.sheet(part)
|
||||||
sections.append(f"## {sheet_name}\n\n*(chart sheet, no cell data)*")
|
formulas += len(sheet.formulas)
|
||||||
continue
|
untranslated += sheet.untranslated
|
||||||
sections.append(f"## {sheet_name}\n\n{book.table(part)}")
|
sections.append(_sheet_section(number, len(sheets), width, sheet_name, state, sheet, files))
|
||||||
if book.formulas_without_value:
|
if book.formulas_without_value:
|
||||||
warnings.append(
|
warnings.append(
|
||||||
f"{book.formulas_without_value} formula cell(s) have no stored value (the file was "
|
f"{book.formulas_without_value} formula cell(s) have no stored value (the file was "
|
||||||
"never calculated) and are empty."
|
"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."]
|
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:
|
if hidden:
|
||||||
checks.append("Hidden sheets are included and named in hidden_sheets.")
|
checks.append("Hidden sheets are included and named in hidden_sheets.")
|
||||||
return Derivation(
|
return Derivation(
|
||||||
text="\n\n".join(sections),
|
text="\n\n".join(intro + sections),
|
||||||
title=pkg.title(),
|
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),
|
warnings=tuple(warnings),
|
||||||
checks=tuple(checks),
|
checks=tuple(checks),
|
||||||
|
files=tuple(files),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ readable in the test that uses it and no binary sits in the repository.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import shutil
|
||||||
import zipfile
|
import zipfile
|
||||||
from email import message_from_bytes, policy
|
from email import message_from_bytes, policy
|
||||||
from email.message import EmailMessage
|
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), " ")}</t></is></c>'
|
||||||
|
|
||||||
|
|
||||||
def make_eml(body: str = "Hallo Welt\n", *, html: str = "", attach: bool = False, inline: bool = False,
|
def make_eml(body: str = "Hallo Welt\n", *, html: str = "", attach: bool = False, inline: bool = False,
|
||||||
subject: str = "Angebot") -> bytes:
|
subject: str = "Angebot") -> bytes:
|
||||||
msg = EmailMessage()
|
msg = EmailMessage()
|
||||||
@@ -374,7 +400,11 @@ def test_a_folder_gets_a_md_beside_each_file_with_a_filter(tree, capsys):
|
|||||||
_derive("incoming/mappe")
|
_derive("incoming/mappe")
|
||||||
out = capsys.readouterr().out
|
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")
|
header, _ = _header_and_text(folder / "plan.md")
|
||||||
assert header["derived_from"] == "plan.docx"
|
assert header["derived_from"] == "plan.docx"
|
||||||
assert "original_name" not in header
|
assert "original_name" not in header
|
||||||
@@ -487,20 +517,175 @@ def test_pptx_slides_notes_and_footer_placeholders(tree):
|
|||||||
# --- XLSX -------------------------------------------------------------------
|
# --- 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())
|
_put(tree, "zahlen.xlsx", make_xlsx())
|
||||||
_derive("incoming/zahlen.xlsx")
|
_derive("incoming/zahlen.xlsx")
|
||||||
out = capsys.readouterr().out
|
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 header["sheets"] == 2 and header["hidden_sheets"] == ["Geheim"] and header["formulas"] == 1
|
||||||
assert "## Budget" in text and "## Geheim" in text and "VERSTECKT-INHALT" in text
|
assert "## Budget" in text and "## Geheim" in text
|
||||||
assert "| Posten | Wert |" in text
|
assert "Sheet 2 of 2, hidden" in text
|
||||||
assert "| Miete | 1200 |" in text
|
assert "Range A1:C5 (5 rows x 3 columns), 1 formula cell(s)" in text
|
||||||
assert "2024-01-01" in text
|
assert "`derived.sheets/01-budget.tsv`" in text and "`derived.sheets/01-budget.formulas.tsv`" in text
|
||||||
assert "TRUE" in text
|
assert "`derived.sheets/02-geheim.tsv`" in text and "02-geheim.formulas" not in text
|
||||||
assert text.splitlines()[text.splitlines().index("## Budget") + 2].startswith("| Posten")
|
assert "Preview" in text and "Posten\tWert" in text
|
||||||
assert "Values are as last saved" in out and "Hidden sheets" in out
|
|
||||||
|
# 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)&\"A2\"&'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):
|
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()
|
||||||
Reference in new issue
Block a user