fancy-holy-sheet 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fancy_holy_sheet-0.1.0/.gitattributes +23 -0
- fancy_holy_sheet-0.1.0/.github/workflows/ci.yml +79 -0
- fancy_holy_sheet-0.1.0/.github/workflows/publish.yml +87 -0
- fancy_holy_sheet-0.1.0/.gitignore +13 -0
- fancy_holy_sheet-0.1.0/AGENTS.md +239 -0
- fancy_holy_sheet-0.1.0/CHANGELOG.md +86 -0
- fancy_holy_sheet-0.1.0/CLAUDE.md +1 -0
- fancy_holy_sheet-0.1.0/LICENSE +21 -0
- fancy_holy_sheet-0.1.0/PKG-INFO +250 -0
- fancy_holy_sheet-0.1.0/README.md +206 -0
- fancy_holy_sheet-0.1.0/pyproject.toml +52 -0
- fancy_holy_sheet-0.1.0/scripts/php_tobytes.php +48 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/__init__.py +93 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/agent.py +169 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/exceptions.py +42 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/helpers/__init__.py +6 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/helpers/array_builder.py +69 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/helpers/csv_builder.py +85 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/helpers/php.py +285 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/helpers/xml.py +47 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/holy_sheet.schema.json +196 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/py.typed +0 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/reader/__init__.py +17 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/reader/comments_parser.py +35 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/reader/format/__init__.py +6 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/reader/format/date_inverter.py +27 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/reader/format/num_fmt_parser.py +122 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/reader/rels_parser.py +38 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/reader/shared_strings_parser.py +30 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/reader/styles_parser.py +161 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/reader/worksheet_parser.py +150 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/reader/xlsx_reader.py +227 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/reader/xml.py +100 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/schema/__init__.py +10 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/schema/formula_linter.py +617 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/schema/inference.py +169 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/schema/normalizer.py +282 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/schema/repairer.py +170 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/schema/theme.py +38 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/schema/types.py +174 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/schema/validator.py +254 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/workbook/__init__.py +20 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/workbook/cell.py +47 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/workbook/cell_address.py +50 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/workbook/cell_comment.py +13 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/workbook/cell_format.py +124 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/workbook/merged_region.py +14 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/workbook/sheet.py +54 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/workbook/workbook.py +20 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/writer/__init__.py +6 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/writer/format/__init__.py +6 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/writer/format/date_converter.py +110 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/writer/format/num_fmt_builder.py +68 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/writer/styles_registry.py +232 -0
- fancy_holy_sheet-0.1.0/src/holy_sheet/writer/xlsx_writer.py +397 -0
- fancy_holy_sheet-0.1.0/tests/__init__.py +1 -0
- fancy_holy_sheet-0.1.0/tests/_oracle.py +126 -0
- fancy_holy_sheet-0.1.0/tests/conformance/__init__.py +1 -0
- fancy_holy_sheet-0.1.0/tests/conformance/loader.py +214 -0
- fancy_holy_sheet-0.1.0/tests/conformance/test_shared_suites.py +89 -0
- fancy_holy_sheet-0.1.0/tests/conftest.py +39 -0
- fancy_holy_sheet-0.1.0/tests/fixtures.py +157 -0
- fancy_holy_sheet-0.1.0/tests/test_agent.py +115 -0
- fancy_holy_sheet-0.1.0/tests/test_cell_address.py +46 -0
- fancy_holy_sheet-0.1.0/tests/test_dates.py +117 -0
- fancy_holy_sheet-0.1.0/tests/test_determinism.py +24 -0
- fancy_holy_sheet-0.1.0/tests/test_formula_linter.py +211 -0
- fancy_holy_sheet-0.1.0/tests/test_formula_promotion.py +96 -0
- fancy_holy_sheet-0.1.0/tests/test_helpers.py +131 -0
- fancy_holy_sheet-0.1.0/tests/test_numeric.py +125 -0
- fancy_holy_sheet-0.1.0/tests/test_package_validity.py +159 -0
- fancy_holy_sheet-0.1.0/tests/test_parity_php.py +85 -0
- fancy_holy_sheet-0.1.0/tests/test_php_semantics.py +197 -0
- fancy_holy_sheet-0.1.0/tests/test_reader.py +254 -0
- fancy_holy_sheet-0.1.0/tests/test_reader_parity_php.py +42 -0
- fancy_holy_sheet-0.1.0/tests/test_repairer.py +151 -0
- fancy_holy_sheet-0.1.0/tests/test_schema_sync.py +87 -0
- fancy_holy_sheet-0.1.0/tests/test_styles.py +250 -0
- fancy_holy_sheet-0.1.0/tests/test_validator.py +100 -0
- fancy_holy_sheet-0.1.0/tests/test_version_is_single_sourced.py +55 -0
- fancy_holy_sheet-0.1.0/tests/test_writer.py +216 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Line endings are part of the contract here, so they are pinned rather than
|
|
2
|
+
# left to a clone's `core.autocrlf`.
|
|
3
|
+
#
|
|
4
|
+
# Two reasons, both concrete:
|
|
5
|
+
#
|
|
6
|
+
# 1. The writers are string builders and the OOXML they emit is compared
|
|
7
|
+
# byte-for-byte against the PHP reference. A source file checked out with
|
|
8
|
+
# CRLF changes the bytes of any multi-line literal in it.
|
|
9
|
+
# 2. `holy-sheet`'s tool-definition JSON is pinned by a cross-repo SHA-256
|
|
10
|
+
# that is taken over CRLF-NORMALISED text. A Windows checkout hashes
|
|
11
|
+
# differently from the same file on Linux, which reads as a drifted schema
|
|
12
|
+
# when nothing has drifted at all. It has already cost one investigation.
|
|
13
|
+
#
|
|
14
|
+
# The markdown fixtures matter for the same reason: `last-word`'s markdown
|
|
15
|
+
# bridge is asserted byte-exact with LF newlines.
|
|
16
|
+
* text=auto eol=lf
|
|
17
|
+
|
|
18
|
+
*.docx binary
|
|
19
|
+
*.xlsx binary
|
|
20
|
+
*.pptx binary
|
|
21
|
+
*.png binary
|
|
22
|
+
*.jpg binary
|
|
23
|
+
*.jpeg binary
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
# Least-privilege: CI only reads the repo.
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
test:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
strategy:
|
|
16
|
+
fail-fast: false
|
|
17
|
+
matrix:
|
|
18
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
|
|
22
|
+
# Third-party dependency allowlist. Cheap, and failing here is cheap --
|
|
23
|
+
# the same check gates the release, so a PR that adds an unapproved or
|
|
24
|
+
# abandoned dependency finds out now rather than at tag time.
|
|
25
|
+
# The allowlist is FETCHED, never vendored: one source, no copies to drift.
|
|
26
|
+
- name: Fetch the third-party allowlist
|
|
27
|
+
uses: actions/checkout@v4
|
|
28
|
+
with:
|
|
29
|
+
repository: Particle-Academy/.github
|
|
30
|
+
path: .third-party-allowlist
|
|
31
|
+
persist-credentials: false
|
|
32
|
+
|
|
33
|
+
- name: Third-party dependency allowlist
|
|
34
|
+
run: |
|
|
35
|
+
node .third-party-allowlist/third-party/check.mjs --repo .
|
|
36
|
+
# Delete it. A fetched checkout left lying in the workspace is not
|
|
37
|
+
# inert: vitest globbed the checker's own tests out of it and failed
|
|
38
|
+
# a package whose code was fine. Removed with node, not rm -rf,
|
|
39
|
+
# because some of these matrices run on windows-latest where a
|
|
40
|
+
# multi-line run: block is PowerShell and rm -rf is not a command.
|
|
41
|
+
node -e "require('node:fs').rmSync('.third-party-allowlist',{recursive:true,force:true})"
|
|
42
|
+
|
|
43
|
+
- uses: actions/setup-python@v5
|
|
44
|
+
with:
|
|
45
|
+
python-version: ${{ matrix.python-version }}
|
|
46
|
+
|
|
47
|
+
# PHP is REQUIRED, not optional. tests/test_parity_php.py is the
|
|
48
|
+
# cross-runtime writer guarantee and it FAILS when php is absent rather
|
|
49
|
+
# than skipping - a skip here is a green build with zero parity coverage,
|
|
50
|
+
# which is exactly how two suites in this org reported success over
|
|
51
|
+
# nothing for months.
|
|
52
|
+
- uses: shivammathur/setup-php@v2
|
|
53
|
+
with:
|
|
54
|
+
php-version: "8.4"
|
|
55
|
+
coverage: none
|
|
56
|
+
extensions: mbstring, zip
|
|
57
|
+
|
|
58
|
+
- name: Check out the PHP holy-sheet
|
|
59
|
+
uses: actions/checkout@v4
|
|
60
|
+
with:
|
|
61
|
+
repository: Particle-Academy/holy-sheet
|
|
62
|
+
path: .php-holy-sheet
|
|
63
|
+
|
|
64
|
+
- name: Check out fancy-conformance
|
|
65
|
+
uses: actions/checkout@v4
|
|
66
|
+
with:
|
|
67
|
+
repository: Particle-Academy/fancy-conformance
|
|
68
|
+
path: .fancy-conformance
|
|
69
|
+
|
|
70
|
+
- name: Install
|
|
71
|
+
run: |
|
|
72
|
+
python -m pip install --upgrade pip
|
|
73
|
+
pip install -e ".[dev]"
|
|
74
|
+
|
|
75
|
+
- name: Test
|
|
76
|
+
run: python -m pytest
|
|
77
|
+
env:
|
|
78
|
+
HOLY_SHEET_PHP_SRC: ${{ github.workspace }}/.php-holy-sheet/src
|
|
79
|
+
FANCY_CONFORMANCE_ROOT: ${{ github.workspace }}/.fancy-conformance
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
permissions: {}
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
# Build and publish are separate jobs so `id-token: write` is never held by
|
|
11
|
+
# the job that executes build code.
|
|
12
|
+
build:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
with:
|
|
17
|
+
persist-credentials: false
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: "3.x"
|
|
21
|
+
|
|
22
|
+
# The one documentation failure that cannot be repaired later: once the
|
|
23
|
+
# sdist is on PyPI, whoever upgraded into it has no way to learn what
|
|
24
|
+
# changed. Checked before anything is built.
|
|
25
|
+
- name: Require a CHANGELOG entry for this tag
|
|
26
|
+
run: |
|
|
27
|
+
version="${GITHUB_REF_NAME#v}"
|
|
28
|
+
grep -q "^## \[${version}\]" CHANGELOG.md || {
|
|
29
|
+
echo "No CHANGELOG.md entry for ${version}."
|
|
30
|
+
exit 1
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Release gate: third-party dependencies must be approved and still
|
|
34
|
+
# maintained BEFORE the tarball reaches the registry. Once it is
|
|
35
|
+
# published, an unapproved or abandoned dependency has already shipped
|
|
36
|
+
# to everyone who upgrades -- the same reason the changelog gate sits
|
|
37
|
+
# here. The allowlist is FETCHED, never vendored: one source of truth,
|
|
38
|
+
# so there is no per-repo copy that can drift out of agreement with it.
|
|
39
|
+
- name: Fetch the third-party allowlist
|
|
40
|
+
uses: actions/checkout@v4
|
|
41
|
+
with:
|
|
42
|
+
repository: Particle-Academy/.github
|
|
43
|
+
path: .third-party-allowlist
|
|
44
|
+
persist-credentials: false
|
|
45
|
+
|
|
46
|
+
- name: Third-party dependency allowlist
|
|
47
|
+
run: |
|
|
48
|
+
node .third-party-allowlist/third-party/check.mjs --repo .
|
|
49
|
+
# Delete it. A fetched checkout left lying in the workspace is not
|
|
50
|
+
# inert: vitest globbed the checker's own tests out of it and failed
|
|
51
|
+
# a package whose code was fine. Removed with node, not rm -rf,
|
|
52
|
+
# because some of these matrices run on windows-latest where a
|
|
53
|
+
# multi-line run: block is PowerShell and rm -rf is not a command.
|
|
54
|
+
node -e "require('node:fs').rmSync('.third-party-allowlist',{recursive:true,force:true})"
|
|
55
|
+
|
|
56
|
+
- name: Require the version to match the tag
|
|
57
|
+
run: |
|
|
58
|
+
version="${GITHUB_REF_NAME#v}"
|
|
59
|
+
grep -q "^version = \"${version}\"$" pyproject.toml || {
|
|
60
|
+
echo "pyproject.toml version does not match tag ${GITHUB_REF_NAME}."
|
|
61
|
+
exit 1
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
- run: python -m pip install --upgrade build && python -m build
|
|
65
|
+
- uses: actions/upload-artifact@v4
|
|
66
|
+
with:
|
|
67
|
+
name: dists
|
|
68
|
+
path: dist/
|
|
69
|
+
|
|
70
|
+
publish:
|
|
71
|
+
needs: build
|
|
72
|
+
runs-on: ubuntu-latest
|
|
73
|
+
environment:
|
|
74
|
+
name: pypi
|
|
75
|
+
url: https://pypi.org/p/fancy-flow
|
|
76
|
+
permissions:
|
|
77
|
+
id-token: write # mandatory for Trusted Publishing
|
|
78
|
+
steps:
|
|
79
|
+
- uses: actions/download-artifact@v4
|
|
80
|
+
with:
|
|
81
|
+
name: dists
|
|
82
|
+
path: dist/
|
|
83
|
+
# Pinned by SHA, not by tag: a mutable reference in exactly this position
|
|
84
|
+
# is what the April 2026 supply-chain compromises exploited.
|
|
85
|
+
# v1.14.2 -- no `password:` (that is the point) and attestations default
|
|
86
|
+
# to true.
|
|
87
|
+
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# AGENTS.md — holy-sheet (Python)
|
|
2
|
+
|
|
3
|
+
This file describes **this repo's code**: its API, its invariants, and the traps
|
|
4
|
+
that will cost you a day. Process rules — publishing, versioning, backports,
|
|
5
|
+
support lifecycle — live in the envelope's `AGENTS.md` and are deliberately not
|
|
6
|
+
repeated here.
|
|
7
|
+
|
|
8
|
+
## What this is
|
|
9
|
+
|
|
10
|
+
A zero-dependency `.xlsx` writer, reader and formula linter. The **third**
|
|
11
|
+
implementation of one contract:
|
|
12
|
+
|
|
13
|
+
| | |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `particle-academy/holy-sheet` (PHP) | the reference — shipped first, most complete |
|
|
16
|
+
| `@particle-academy/holy-sheet` (Node) | the container/API model — bytes-in/bytes-out, no host |
|
|
17
|
+
| `holy-sheet` (this) | Python |
|
|
18
|
+
|
|
19
|
+
**PHP is normative for SEMANTICS. Node is normative for STRUCTURE.** That split
|
|
20
|
+
is why the module layout mirrors the Node port's directories while every
|
|
21
|
+
behavioural decision follows PHP's.
|
|
22
|
+
|
|
23
|
+
### Python never casts a deciding vote
|
|
24
|
+
|
|
25
|
+
Where PHP and Node already disagree, **this port follows PHP**. Not because PHP
|
|
26
|
+
is better, but because the disagreements are documented in
|
|
27
|
+
`.ai/plans/polyglot/parity/documents.md` with a ruling attached, and a third
|
|
28
|
+
engine picking its own answer turns a two-way drift into a permanent three-way
|
|
29
|
+
split. Following PHP keeps the tally 2–1 in the direction already decided.
|
|
30
|
+
|
|
31
|
+
Where PHP has a feature Node lacks, this implements PHP's. Two of those:
|
|
32
|
+
|
|
33
|
+
- **`=`-promotion** — a bare string cell beginning with `=` is a formula.
|
|
34
|
+
- **Bare scalar `cells` entries** — `{"A1": 42}` as well as `{"A1": {"value": 42}}`.
|
|
35
|
+
|
|
36
|
+
Node is a minor behind on both.
|
|
37
|
+
|
|
38
|
+
## The API shape
|
|
39
|
+
|
|
40
|
+
Module-level snake_case functions, mirroring PHP's static `Agent` and TS's
|
|
41
|
+
`Agent` object: `validate`, `validate_and_repair`, `to_bytes`, `write`, `read`,
|
|
42
|
+
`describe`, `lint`, `from_array`, `from_csv`, `tool_definition`, `version`.
|
|
43
|
+
Lower-level classes keep their peer names (`Validator`, `Normalizer`,
|
|
44
|
+
`XlsxWriter`, `XlsxReader`, `CellAddress`, …) so a reader moving between the
|
|
45
|
+
three repos recognises them.
|
|
46
|
+
|
|
47
|
+
**The input schema is a plain `dict` and must stay one.** The declarative
|
|
48
|
+
one-shot schema is the product: an agent emits the whole workbook in a single
|
|
49
|
+
JSON object, and the *Validator* is the gate. A dataclass would move the gate
|
|
50
|
+
into a constructor and reject exactly the loose input `validate_and_repair`
|
|
51
|
+
exists to fix. `schema/types.py` holds `TypedDict`s (`total=False`) for editor
|
|
52
|
+
support only — never construct from them.
|
|
53
|
+
|
|
54
|
+
The internal `Workbook` / `Sheet` / `Cell` / `CellFormat` objects the Normalizer
|
|
55
|
+
produces ARE real classes. They are internal, like their peers.
|
|
56
|
+
|
|
57
|
+
Three shape differences from a peer, each deliberate and each pinned by a test:
|
|
58
|
+
|
|
59
|
+
- `write()` is **synchronous** (PHP's is; Node's is async only for browsers).
|
|
60
|
+
- `from_csv()` accepts a **path or content** (PHP's superset; Node takes content).
|
|
61
|
+
- `read()` takes **bytes**, `describe()` takes a **path** (Node's split).
|
|
62
|
+
|
|
63
|
+
## Byte parity with PHP is the definition of done
|
|
64
|
+
|
|
65
|
+
`tests/test_parity_php.py` runs the PHP writer as a subprocess and asserts this
|
|
66
|
+
port emits **byte-identical OOXML parts** for every fixture. `KNOWN_DIVERGENT_PARTS`
|
|
67
|
+
is empty and the ledger ratchets both ways — a new divergence fails, and a stale
|
|
68
|
+
entry fails too.
|
|
69
|
+
|
|
70
|
+
**Never byte-compare the container.** PHP writes through `ZipArchive` (DEFLATE,
|
|
71
|
+
real mtimes); this writes a fixed 1980-01-01 DOS date. Those files can never
|
|
72
|
+
match, and a reader sees parts, never the compression.
|
|
73
|
+
|
|
74
|
+
### Therefore: the XML is a string builder, and must stay one
|
|
75
|
+
|
|
76
|
+
Attribute order (`<c r="A1" s="3" t="inlineStr">`, in that order), self-closing
|
|
77
|
+
style (`<c r="A1"/>` for a null cell, but `<xf …></xf>` always as a pair), the
|
|
78
|
+
absence of inter-element whitespace, and `'` rather than `'` are all
|
|
79
|
+
part of the contract. A DOM serialiser owns every one of those decisions and
|
|
80
|
+
would fail the first fixture.
|
|
81
|
+
|
|
82
|
+
`xml.etree` is used for **reading** and never for writing. That is the correct
|
|
83
|
+
division: nothing is serialised on the read side.
|
|
84
|
+
|
|
85
|
+
`helpers/xml.py` has two escapers and the difference is intentional:
|
|
86
|
+
`xml_escape` strips XML-illegal control characters first; `xml_escape_raw` does
|
|
87
|
+
not. The reference engine's `StylesRegistry` calls bare `htmlspecialchars` where
|
|
88
|
+
its writer calls its own `escape()`, so number-format codes and font names skip
|
|
89
|
+
the strip. Mirrored, not tidied — tidying it would change bytes both shipped
|
|
90
|
+
engines currently agree on.
|
|
91
|
+
|
|
92
|
+
## Numbers: the section to read before touching the writer
|
|
93
|
+
|
|
94
|
+
This is where every cross-runtime bug in this family has lived. Five real
|
|
95
|
+
PHP↔JS divergences shipped in the Node port, all of them here.
|
|
96
|
+
|
|
97
|
+
Everything numeric goes through `helpers/php.py`. **Do not reach for a builtin.**
|
|
98
|
+
|
|
99
|
+
### `bool` is a subclass of `int`
|
|
100
|
+
|
|
101
|
+
`isinstance(True, int)` is `True` and `True == 1`. Any branch that tests `int`
|
|
102
|
+
before `bool` writes a boolean into a numeric cell — `<v>1</v>` with no `t="b"`,
|
|
103
|
+
a sheet showing 1 where the author wrote TRUE, and no error anywhere. **Check
|
|
104
|
+
`bool` first, every time you branch on type.** The places that matter:
|
|
105
|
+
`Cell.excel_type`, `XlsxWriter.cell_xml`, `php.type_of`, `php.php_to_string`,
|
|
106
|
+
`Inference._all_numeric`, `Inference._all_integer`, the linter's coercions.
|
|
107
|
+
|
|
108
|
+
### `round()` is banker's rounding
|
|
109
|
+
|
|
110
|
+
`round(0.5) == 0` and `round(2.5) == 2`. PHP's is half away from zero. Use
|
|
111
|
+
`php_round`; the builtin is never called anywhere in this package. It matters in
|
|
112
|
+
the reader's column-width inverse and in the linter's `ROUND()`.
|
|
113
|
+
|
|
114
|
+
At a non-zero precision `php_round` quantises the **shortest repr**, so
|
|
115
|
+
`php_round(1.2345, 3)` is 1.235 — PHP's answer, and the one a caller writing
|
|
116
|
+
"1.2345" means. (The exact double is 1.23449999999999993.)
|
|
117
|
+
|
|
118
|
+
### `f"{v:.14f}"` rounds half to EVEN
|
|
119
|
+
|
|
120
|
+
That is the `<v>` serialisation of every float cell. PHP's `number_format` rounds
|
|
121
|
+
half **away from zero**, and the two disagree on any double whose decimal
|
|
122
|
+
expansion terminates in a 5 at the 15th place — `j / 2**15` with odd `j`. Use
|
|
123
|
+
`php_number_format`. Two such values live in the `numericHazards` parity fixture
|
|
124
|
+
so a half-even formatter fails the build rather than shipping.
|
|
125
|
+
|
|
126
|
+
**Known, deliberate deviation:** PHP reaches its answer through a floating-point
|
|
127
|
+
*pre-rounding* step whose result changed between PHP 8.3 and 8.4 (8.4 dropped the
|
|
128
|
+
`>= 1e15` bail-out, so `number_format(32.666666666666664, 14)` now disagrees with
|
|
129
|
+
`sprintf('%.14F', …)` of the same double). Reproducing that would make this
|
|
130
|
+
package's output a function of whichever PHP the maintainer built against, which
|
|
131
|
+
is parity with nothing. `php_number_format` implements the **rule** — round the
|
|
132
|
+
exact binary expansion, half away from zero, suppress the sign on a zero result
|
|
133
|
+
— and the two agree everywhere except the 15th significant digit of values below
|
|
134
|
+
10. The oracle proves it part-for-part on every fixture. If you change this,
|
|
135
|
+
re-run the parity suite; it is the only thing that can tell you.
|
|
136
|
+
|
|
137
|
+
### Only ever trim a fraction, never an exponent
|
|
138
|
+
|
|
139
|
+
`format_float` = `number_format(v, 14)` then strip trailing zeros then a trailing
|
|
140
|
+
dot. The Node port trimmed unconditionally, and because `toFixed` goes
|
|
141
|
+
exponential at 1e21 the strip chewed the **exponent**: 1e300 was written to the
|
|
142
|
+
sheet as `1e3`. Python's `format` never goes exponential for `f`, so the hazard
|
|
143
|
+
is absent — the rule is written down because the next port will be offered a
|
|
144
|
+
formatter that does.
|
|
145
|
+
|
|
146
|
+
### Numeric-string coercion is PHP's, not Python's
|
|
147
|
+
|
|
148
|
+
`float()` accepts `"inf"`, `"nan"`, `"1_000"` and `"0x1A"`-adjacent forms that
|
|
149
|
+
PHP's `is_numeric` rejects. Always guard with `is_numeric_string` before
|
|
150
|
+
`numeric_string_to_number`. And the coercion returns `int` for an
|
|
151
|
+
integer-shaped string inside zend_long's range and `float` otherwise — it does
|
|
152
|
+
**not** clamp, which is what `(int)` did and how `"1e21"` became
|
|
153
|
+
9223372036854775807.
|
|
154
|
+
|
|
155
|
+
### Python ints are unbounded; PHP's are 64-bit
|
|
156
|
+
|
|
157
|
+
An `int` outside `[-2**63, 2**63)` is written through the float formatter,
|
|
158
|
+
because that is what PHP's `json_decode` would have produced for the same
|
|
159
|
+
literal. Losing the extra precision is the point: it is what makes the document
|
|
160
|
+
the same on both backends.
|
|
161
|
+
|
|
162
|
+
### Non-finite values
|
|
163
|
+
|
|
164
|
+
NaN and ±Infinity have no `<v>` representation — "NaN" in a cell is a corrupt
|
|
165
|
+
sheet, not a big number. Both engines write `0`.
|
|
166
|
+
|
|
167
|
+
## Other invariants
|
|
168
|
+
|
|
169
|
+
**Cells within a row are ordered LEXICOGRAPHICALLY on the column letter**, so a
|
|
170
|
+
sheet wider than 26 columns emits `A, AA, AB, AC, AD, B, C, …`. That matches
|
|
171
|
+
neither Excel's canonical order nor intuition, and **both shipped engines do it**
|
|
172
|
+
(`ksort` on a string key in PHP, `[...keys].sort()` in Node). It is replicated on
|
|
173
|
+
purpose and pinned by the `wide` fixture. Fixing it changes the bytes of every
|
|
174
|
+
wide sheet in every engine simultaneously, so it belongs in a coordinated release
|
|
175
|
+
train — not in a port. Do not "fix" it here.
|
|
176
|
+
|
|
177
|
+
**`cells` is document-ordered.** Python dicts are insertion-ordered, so the
|
|
178
|
+
contract the Node port needed a deliberate structure for is free here — but it is
|
|
179
|
+
still a contract. `Sheet.comments()` and the comment/VML part numbering read it.
|
|
180
|
+
|
|
181
|
+
**The zip is deterministic.** Fixed part order, fixed `date_time=(1980, 1, 1, 0,
|
|
182
|
+
0, 0)`. Without it the container is a clock, the same input produces different
|
|
183
|
+
bytes every run, and a golden fixture is impossible.
|
|
184
|
+
|
|
185
|
+
**Sheet XML is rendered before `styles.xml` is serialised.** Every format has to
|
|
186
|
+
register first. Swapping those two lines in `XlsxWriter.to_bytes` produces a
|
|
187
|
+
styles part missing every style the sheets reference.
|
|
188
|
+
|
|
189
|
+
**Reading rejects a DOCTYPE before parsing.** An xlsx never legitimately carries
|
|
190
|
+
one, and an internal DTD subset is the entry point for entity expansion on
|
|
191
|
+
untrusted input. Refusing the construct is cheaper than depending on what a
|
|
192
|
+
parser does with it today. See `reader/xml.py`.
|
|
193
|
+
|
|
194
|
+
**Dates parse in UTC, from an explicit grammar.** Not `datetime.fromisoformat`,
|
|
195
|
+
whose accepted set changed in Python 3.11 — that would make a cell's serial
|
|
196
|
+
depend on the interpreter. Not PHP's `DateTimeImmutable` grammar either, which
|
|
197
|
+
accepts `"next monday"` and is unportable. Unparseable input yields serial 0.0,
|
|
198
|
+
matching the reference's failure path. The epoch anchor is 1899-12-30, which
|
|
199
|
+
cancels Excel's 1900-leap-year bug for every date from 1900-03-01.
|
|
200
|
+
|
|
201
|
+
**`tool_definition()` raises when the schema file is missing.** PHP returns an
|
|
202
|
+
empty array; that is the worse failure, because an empty tool definition produces
|
|
203
|
+
no error anywhere — just a model told nothing about the tool it is holding. The
|
|
204
|
+
file is byte-identical across all three repos and each pins its SHA-256 over
|
|
205
|
+
CRLF-normalised content (`tests/test_schema_sync.py`). Edit one copy, edit all
|
|
206
|
+
three, update all three constants.
|
|
207
|
+
|
|
208
|
+
**Linter hint strings are byte-compared against PHP.** They contain em dashes
|
|
209
|
+
(`Division by zero — the divisor evaluated to 0.`). A hyphen there is a parity
|
|
210
|
+
failure.
|
|
211
|
+
|
|
212
|
+
## Deliberately out of scope
|
|
213
|
+
|
|
214
|
+
Three things the PHP package has that neither port implements, and that a
|
|
215
|
+
contributor will otherwise assume were forgotten:
|
|
216
|
+
|
|
217
|
+
- `Laravel/` — the service provider, facade, artisan command and query adapter.
|
|
218
|
+
- `Toolkit/` — the prompt/tool/schema-store layer.
|
|
219
|
+
- `Schema/Dumper` + `DumpOptions` — `dumpJson()`, the cell-level content dump.
|
|
220
|
+
|
|
221
|
+
The Node port omits all three too. `Dumper` is the one with a real case for
|
|
222
|
+
being ported; it is the read-tool counterpart to `describe()` and it is what
|
|
223
|
+
lets an agent make targeted cell edits.
|
|
224
|
+
|
|
225
|
+
## Testing
|
|
226
|
+
|
|
227
|
+
`python -m pytest`. **Write the test first**; a bug fix lands with a test that
|
|
228
|
+
fails against the old code, and you verify that it does.
|
|
229
|
+
|
|
230
|
+
The parity oracle needs `php` on `PATH` (or `PHP_BIN` pointing at a real
|
|
231
|
+
interpreter — on Windows `php` is usually a `.bat` shim, which a bare `exec`
|
|
232
|
+
cannot spawn) and the PHP sources beside this checkout (or `HOLY_SHEET_PHP_SRC`).
|
|
233
|
+
Locally a missing toolchain skips those tests and says so; **under `CI` it
|
|
234
|
+
raises**, because a suite that quietly stops comparing anything reads exactly
|
|
235
|
+
like one that compares everything.
|
|
236
|
+
|
|
237
|
+
Do not weaken `tests/_oracle.py`, `tests/test_parity_php.py` or
|
|
238
|
+
`tests/test_determinism.py`. They are the reason this is a port rather than a
|
|
239
|
+
rewrite.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
**Pre-1.0: breaking changes land in MINOR releases.** Until `1.0.0`, a bump from
|
|
9
|
+
`0.1.x` to `0.2.0` may change or remove API. Pin accordingly, and read the entry
|
|
10
|
+
before upgrading — every breaking change here says what you have to DO, not just
|
|
11
|
+
what moved.
|
|
12
|
+
|
|
13
|
+
## [Unreleased]
|
|
14
|
+
|
|
15
|
+
## [0.1.0] - 2026-08-18
|
|
16
|
+
|
|
17
|
+
First release. The Python mirror of PHP `particle-academy/holy-sheet` 1.3.0 and
|
|
18
|
+
Node `@particle-academy/holy-sheet`.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **The Agent surface**, as module-level snake_case functions: `validate`,
|
|
23
|
+
`validate_and_repair`, `to_bytes`, `write`, `read`, `describe`,
|
|
24
|
+
`tool_definition`, `from_array`, `from_csv`, `lint`, `version`. No class to
|
|
25
|
+
instantiate and no DI container — `import holy_sheet` is the whole setup.
|
|
26
|
+
- **The declarative workbook schema**, taken as a plain `dict`. Row-oriented
|
|
27
|
+
(`columns` + `rows`) and sparse (`cells`) sheets, four themes, symbolic totals,
|
|
28
|
+
merged regions, column widths, frozen panes, comments, formulas with cached
|
|
29
|
+
values, and per-cell or per-column formatting.
|
|
30
|
+
- **`XlsxWriter`** — a complete OOXML package with deduplicated
|
|
31
|
+
fonts/fills/borders/numFmts. Output is deterministic: fixed part order, fixed
|
|
32
|
+
1980-01-01 zip timestamps, the same bytes for the same input every time.
|
|
33
|
+
- **`XlsxReader`** — `read(bytes)` and `describe(path)` round-trip an xlsx back
|
|
34
|
+
to a schema that can be written again unchanged. Handles shared strings, which
|
|
35
|
+
this writer never emits but Excel always does.
|
|
36
|
+
- **`FormulaLinter`** — evaluates every formula and reports `#VALUE!`, `#REF!`,
|
|
37
|
+
`#NAME?`, `#DIV/0!` and `#CIRC!` with an actionable hint, including the
|
|
38
|
+
header-row off-by-one ("Did you mean B2? (it holds 12000)").
|
|
39
|
+
- **`Validator` + `Repairer`** — structured errors, and conservative repairs for
|
|
40
|
+
the unambiguous mistakes agents make (`sheet` for `sheets`, `row` for `rows`,
|
|
41
|
+
integer-keyed rows objects, stringified numerics, unknown themes, whitespace in
|
|
42
|
+
A1 addresses, and an omitted date column type).
|
|
43
|
+
- **`from_array` / `from_csv`** with header-plus-sample type inference.
|
|
44
|
+
`from_csv` accepts a path as well as content, following PHP.
|
|
45
|
+
- **`tool_definition()`**, returning the JSON Schema that is byte-identical
|
|
46
|
+
across all three engines. It **raises** when the file is missing rather than
|
|
47
|
+
returning an empty dict — an empty tool definition hands a model no hints and
|
|
48
|
+
produces no error anywhere.
|
|
49
|
+
- **Cross-runtime parity as a test result.** `tests/test_parity_php.py` drives
|
|
50
|
+
the PHP writer as a subprocess and asserts byte-identical OOXML parts for every
|
|
51
|
+
fixture; `tests/test_reader_parity_php.py` asserts a PHP-written file describes
|
|
52
|
+
identically to a Python-written one. Both FAIL under `CI` when PHP is absent
|
|
53
|
+
rather than skipping.
|
|
54
|
+
- **`helpers/php.py`** — PHP's numeric and string semantics written down once:
|
|
55
|
+
`is_numeric_string`, `numeric_string_to_number`, `php_round`,
|
|
56
|
+
`php_number_format`, `format_float`, `php_to_string`, `type_of`. Every
|
|
57
|
+
numeric decision in the package routes through it, and the Python builtins it
|
|
58
|
+
replaces (`round`, `float`, `f"{v:.14f}"`, `str`) are never called on a value
|
|
59
|
+
that reaches a cell.
|
|
60
|
+
|
|
61
|
+
### Notes for anyone porting or reviewing this
|
|
62
|
+
|
|
63
|
+
- **`bool` is a subclass of `int` in Python**, so every type branch checks `bool`
|
|
64
|
+
first. A branch that does not writes `<v>1</v>` with no `t="b"` — a sheet
|
|
65
|
+
showing 1 where the author wrote TRUE, with no error anywhere.
|
|
66
|
+
- **`round()` is banker's rounding and `f"{v:.14f}"` rounds half to even.** PHP
|
|
67
|
+
does both half away from zero. The `numericHazards` fixture carries two exact
|
|
68
|
+
ties at the 14th decimal so a half-even formatter fails the build.
|
|
69
|
+
- **Cells within a row are ordered lexicographically on the column letter**, so a
|
|
70
|
+
sheet wider than 26 columns emits `A, AA, AB, …, B, C`. This is a shared wart
|
|
71
|
+
that both existing engines have; it is replicated on purpose and pinned by the
|
|
72
|
+
`wide` fixture, because changing it changes the bytes of every wide sheet in
|
|
73
|
+
every engine at once.
|
|
74
|
+
- **The XML is built by string concatenation, and must stay that way.** Attribute
|
|
75
|
+
order, self-closing style and `'`-vs-`'` are the cross-runtime
|
|
76
|
+
contract. `xml.etree` reads; it never writes.
|
|
77
|
+
- **`number_format` implements the rule, not PHP's floating-point artefact.** PHP
|
|
78
|
+
reaches its answer via a pre-rounding step whose result changed between 8.3 and
|
|
79
|
+
8.4; reproducing it would tie this package's output to the maintainer's PHP
|
|
80
|
+
build. The two agree everywhere except the 15th significant digit of values
|
|
81
|
+
below 10, and the parity oracle proves it part-for-part.
|
|
82
|
+
- **Not ported, deliberately** (the Node port omits them too): PHP's `Laravel/`
|
|
83
|
+
bridge, `Toolkit/`, and `Schema/Dumper` + `DumpOptions` (`dumpJson`).
|
|
84
|
+
|
|
85
|
+
[Unreleased]: https://github.com/Particle-Academy/holy-sheet-py/compare/v0.1.0...HEAD
|
|
86
|
+
[0.1.0]: https://github.com/Particle-Academy/holy-sheet-py/releases/tag/v0.1.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
AGENTS.md
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Particle Academy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|