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.
Files changed (81) hide show
  1. fancy_holy_sheet-0.1.0/.gitattributes +23 -0
  2. fancy_holy_sheet-0.1.0/.github/workflows/ci.yml +79 -0
  3. fancy_holy_sheet-0.1.0/.github/workflows/publish.yml +87 -0
  4. fancy_holy_sheet-0.1.0/.gitignore +13 -0
  5. fancy_holy_sheet-0.1.0/AGENTS.md +239 -0
  6. fancy_holy_sheet-0.1.0/CHANGELOG.md +86 -0
  7. fancy_holy_sheet-0.1.0/CLAUDE.md +1 -0
  8. fancy_holy_sheet-0.1.0/LICENSE +21 -0
  9. fancy_holy_sheet-0.1.0/PKG-INFO +250 -0
  10. fancy_holy_sheet-0.1.0/README.md +206 -0
  11. fancy_holy_sheet-0.1.0/pyproject.toml +52 -0
  12. fancy_holy_sheet-0.1.0/scripts/php_tobytes.php +48 -0
  13. fancy_holy_sheet-0.1.0/src/holy_sheet/__init__.py +93 -0
  14. fancy_holy_sheet-0.1.0/src/holy_sheet/agent.py +169 -0
  15. fancy_holy_sheet-0.1.0/src/holy_sheet/exceptions.py +42 -0
  16. fancy_holy_sheet-0.1.0/src/holy_sheet/helpers/__init__.py +6 -0
  17. fancy_holy_sheet-0.1.0/src/holy_sheet/helpers/array_builder.py +69 -0
  18. fancy_holy_sheet-0.1.0/src/holy_sheet/helpers/csv_builder.py +85 -0
  19. fancy_holy_sheet-0.1.0/src/holy_sheet/helpers/php.py +285 -0
  20. fancy_holy_sheet-0.1.0/src/holy_sheet/helpers/xml.py +47 -0
  21. fancy_holy_sheet-0.1.0/src/holy_sheet/holy_sheet.schema.json +196 -0
  22. fancy_holy_sheet-0.1.0/src/holy_sheet/py.typed +0 -0
  23. fancy_holy_sheet-0.1.0/src/holy_sheet/reader/__init__.py +17 -0
  24. fancy_holy_sheet-0.1.0/src/holy_sheet/reader/comments_parser.py +35 -0
  25. fancy_holy_sheet-0.1.0/src/holy_sheet/reader/format/__init__.py +6 -0
  26. fancy_holy_sheet-0.1.0/src/holy_sheet/reader/format/date_inverter.py +27 -0
  27. fancy_holy_sheet-0.1.0/src/holy_sheet/reader/format/num_fmt_parser.py +122 -0
  28. fancy_holy_sheet-0.1.0/src/holy_sheet/reader/rels_parser.py +38 -0
  29. fancy_holy_sheet-0.1.0/src/holy_sheet/reader/shared_strings_parser.py +30 -0
  30. fancy_holy_sheet-0.1.0/src/holy_sheet/reader/styles_parser.py +161 -0
  31. fancy_holy_sheet-0.1.0/src/holy_sheet/reader/worksheet_parser.py +150 -0
  32. fancy_holy_sheet-0.1.0/src/holy_sheet/reader/xlsx_reader.py +227 -0
  33. fancy_holy_sheet-0.1.0/src/holy_sheet/reader/xml.py +100 -0
  34. fancy_holy_sheet-0.1.0/src/holy_sheet/schema/__init__.py +10 -0
  35. fancy_holy_sheet-0.1.0/src/holy_sheet/schema/formula_linter.py +617 -0
  36. fancy_holy_sheet-0.1.0/src/holy_sheet/schema/inference.py +169 -0
  37. fancy_holy_sheet-0.1.0/src/holy_sheet/schema/normalizer.py +282 -0
  38. fancy_holy_sheet-0.1.0/src/holy_sheet/schema/repairer.py +170 -0
  39. fancy_holy_sheet-0.1.0/src/holy_sheet/schema/theme.py +38 -0
  40. fancy_holy_sheet-0.1.0/src/holy_sheet/schema/types.py +174 -0
  41. fancy_holy_sheet-0.1.0/src/holy_sheet/schema/validator.py +254 -0
  42. fancy_holy_sheet-0.1.0/src/holy_sheet/workbook/__init__.py +20 -0
  43. fancy_holy_sheet-0.1.0/src/holy_sheet/workbook/cell.py +47 -0
  44. fancy_holy_sheet-0.1.0/src/holy_sheet/workbook/cell_address.py +50 -0
  45. fancy_holy_sheet-0.1.0/src/holy_sheet/workbook/cell_comment.py +13 -0
  46. fancy_holy_sheet-0.1.0/src/holy_sheet/workbook/cell_format.py +124 -0
  47. fancy_holy_sheet-0.1.0/src/holy_sheet/workbook/merged_region.py +14 -0
  48. fancy_holy_sheet-0.1.0/src/holy_sheet/workbook/sheet.py +54 -0
  49. fancy_holy_sheet-0.1.0/src/holy_sheet/workbook/workbook.py +20 -0
  50. fancy_holy_sheet-0.1.0/src/holy_sheet/writer/__init__.py +6 -0
  51. fancy_holy_sheet-0.1.0/src/holy_sheet/writer/format/__init__.py +6 -0
  52. fancy_holy_sheet-0.1.0/src/holy_sheet/writer/format/date_converter.py +110 -0
  53. fancy_holy_sheet-0.1.0/src/holy_sheet/writer/format/num_fmt_builder.py +68 -0
  54. fancy_holy_sheet-0.1.0/src/holy_sheet/writer/styles_registry.py +232 -0
  55. fancy_holy_sheet-0.1.0/src/holy_sheet/writer/xlsx_writer.py +397 -0
  56. fancy_holy_sheet-0.1.0/tests/__init__.py +1 -0
  57. fancy_holy_sheet-0.1.0/tests/_oracle.py +126 -0
  58. fancy_holy_sheet-0.1.0/tests/conformance/__init__.py +1 -0
  59. fancy_holy_sheet-0.1.0/tests/conformance/loader.py +214 -0
  60. fancy_holy_sheet-0.1.0/tests/conformance/test_shared_suites.py +89 -0
  61. fancy_holy_sheet-0.1.0/tests/conftest.py +39 -0
  62. fancy_holy_sheet-0.1.0/tests/fixtures.py +157 -0
  63. fancy_holy_sheet-0.1.0/tests/test_agent.py +115 -0
  64. fancy_holy_sheet-0.1.0/tests/test_cell_address.py +46 -0
  65. fancy_holy_sheet-0.1.0/tests/test_dates.py +117 -0
  66. fancy_holy_sheet-0.1.0/tests/test_determinism.py +24 -0
  67. fancy_holy_sheet-0.1.0/tests/test_formula_linter.py +211 -0
  68. fancy_holy_sheet-0.1.0/tests/test_formula_promotion.py +96 -0
  69. fancy_holy_sheet-0.1.0/tests/test_helpers.py +131 -0
  70. fancy_holy_sheet-0.1.0/tests/test_numeric.py +125 -0
  71. fancy_holy_sheet-0.1.0/tests/test_package_validity.py +159 -0
  72. fancy_holy_sheet-0.1.0/tests/test_parity_php.py +85 -0
  73. fancy_holy_sheet-0.1.0/tests/test_php_semantics.py +197 -0
  74. fancy_holy_sheet-0.1.0/tests/test_reader.py +254 -0
  75. fancy_holy_sheet-0.1.0/tests/test_reader_parity_php.py +42 -0
  76. fancy_holy_sheet-0.1.0/tests/test_repairer.py +151 -0
  77. fancy_holy_sheet-0.1.0/tests/test_schema_sync.py +87 -0
  78. fancy_holy_sheet-0.1.0/tests/test_styles.py +250 -0
  79. fancy_holy_sheet-0.1.0/tests/test_validator.py +100 -0
  80. fancy_holy_sheet-0.1.0/tests/test_version_is_single_sourced.py +55 -0
  81. 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,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .mypy_cache/
6
+ build/
7
+ dist/
8
+ *.egg-info/
9
+ .venv/
10
+ venv/
11
+ .coverage
12
+ htmlcov/
13
+ .DS_Store
@@ -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 `&apos;` rather than `&#39;` 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 `&apos;`-vs-`&#39;` 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.