Skip to content

feat(edit): add edit.set_cell, a one-cell edit that keeps padding - #277

Merged
CameronBrooks11 merged 8 commits into
mainfrom
feat/edit-set-cell
Oct 23, 2026
Merged

CameronBrooks11 merged 8 commits into
mainfrom
feat/edit-set-cell

Conversation

@CameronBrooks11

@CameronBrooks11 CameronBrooks11 commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Adds rowspec.edit.set_cell(text, row_key, column, value, /), the one-cell edit that docs/editing-api.md reserved under that name for a later release. It changes one stored data cell in the text, and only that cell's span of the row's line.

Why

Measured at d368364, the released rowspec.set_cell plus render rewrites the whole row unpadded, and it writes a value it cannot read back:

qty=11 on | r_20 | nut    |  10 |   ->  | r_20 | nut | 11 |
item="  nut"                       ->  | r_20 |   nut | 10 |   parse reads "nut"

edit.set_cell gives | r_20 | nut | 11 | for the first. It refuses the second as not-representable. rowspec.set_cell is unchanged.

Contract (docs/editing-api.md)

  • Diff: one changed line, the row. The row's other cells, its terminator and every other line keep their bytes.
  • Padding: rule 3 now has one definition, used for header and data cells alike. A cell is padded when it is more than two characters wider than its text, counted in code points.
    • If the cell or the table is padded, the cell keeps its width where the new text fits. A padded table is one whose table lines all have the same cell widths, with at least one padded cell.
    • A padded cell places the new text as its old text was placed. Any other cell that keeps its width is placed by the column's alignment cell.
    • Text that does not fit is written text (rule 4, never reflow).
    • Otherwise the cell keeps the spaces it had beside its text: |3| becomes |30|.
  • Arguments: all str, positional-only, and each of the four raises TypeError naming it otherwise. There are no column types in .mdtbl, so the value is the cell's text, read as any cell is (§4.1.6). The API edits .mdtbl text only, like the other edits, so a CSV file and its sidecar are not covered.
  • Which cells: any stored column, the key column included. Setting the key changes the row's key and so its identity (§6). A computed column takes only "", which is a no-op. Anything else is refused as value-in-computed-cell.
  • No-op: setting a cell to its own text, compared in NFC, returns the input byte for byte.
  • Refusals:
    • API reasons, raised before any text is built: no-key, no-such-row, no-such-column, line-break, and not-representable for a value with leading or trailing space or tab.
    • Every §9 refusal of the result, for example duplicate-row-id, bad-identifier for a blank key, and §9.10 for an order column.
    • The row is read back last, and a mismatch is not-representable.
    • No new reason code is added.

There is no CLI command; cli-missing-subcommands in #271 kept new subcommands out, and this does not change that.

Commits

  • b34b957 docs(edit): the contract, written before any code.
  • 96fab16 feat(edit): the function, the CHANGELOG entry, tests in tests/test_edit.py, and three real-merge pairings in tests/test_edit_merge.py.
  • 879e704 test(edit): tests/test_edit_set_cell.py. It was written from SPEC.md and docs/editing-api.md at b34b957 by an author who saw no implementation. The author worked in a git archive of SPEC.md, docs/editing-api.md, CONTRIBUTING.md and tests/test_edit.py, with no .git and no reference/. It lands as written, with the same sha256 (09f8e72ae5d0c962…) in the export and here. Its first run against 96fab16: 145 passed, 145 cases.
  • 1c8cbab docs(edit): four outcomes the first draft left undetermined, as the author of those cases listed them. Right- or left-placed text gets one space on the far side. The odd spare space of centred text goes on the right. "Padded" means more than two characters wider than the text. The no-op comparison is in NFC. Each has a test in tests/test_edit.py, so these four are not blind cases.
  • 16a7d6a docs(edit): one definition of "padded" across rule 3. The code has always used one test, _padded, for header and data cells, so the header-cell behaviour is 0.5.0's; only its description changes. A test pins | id | qq| renamed to q giving | q|, and a padded table's | 33| set to 4 giving | 4 |.
  • 2e39136 fix(edit): text, row_key and column are type-checked before parsing. Before, text=None raised AttributeError, and column=3 raised a TypeError that did not name the argument. Two tests pin refusal rule 2's order: " 1" in a computed column and " r_02" in the key column are both not-representable. Without the up-front check they become value-in-computed-cell and duplicate-row-id.
  • ea1fc83 test(edit): three more merge pairings. Two edits setting one cell to different values conflict. Setting one cell to the same value on both branches merges cleanly. A set_cell against a delete of the same row conflicts.
  • 9b664e7 docs(edit): the CHANGELOG states when the width is kept, rule 3 says an unpadded header cell keeps its own spaces, and the bullet states the TypeError and the key's identity.

Mutation sweep

just mutants does not cover edit.py; its targets are table.py and the CSV modules. So this is a separate sweep.

  • It copies reference/ and tests/ into a fresh directory per mutant and applies exact-anchor replacements to edit.py.
  • It prints BROKEN if an anchor does not occur exactly once or the sha256 does not change.
  • Each pytest run gets a fresh PYTHONPYCACHEPREFIX.
  • The suite is tests/test_edit_set_cell.py tests/test_edit.py tests/test_edit_line_endings.py tests/test_edit_merge.py at 9b664e7.

The verdict lines are as printed, without the per-test failure lines.

unmutated: rc=0 398 passed in 1.64s
killed   no-op-guard-off: sha 942c6800e75d -> 2c8352845c68; 3 failed, 395 passed in 1.61s
killed   no-op-guard-skips-computed: sha 942c6800e75d -> 5fbf9ba5a448; 1 failed, 397 passed in 1.37s
killed   result-refusal-skipped-for-computed: sha 942c6800e75d -> 878ff5e4013e; 2 failed, 396 passed in 1.42s
SURVIVED read-back-off: sha 942c6800e75d -> 56f469708ccd; 398 passed in 1.68s
killed   padded-table-ignored: sha 942c6800e75d -> 75afe1241095; 7 failed, 391 passed in 1.68s
killed   own-side-ignored: sha 942c6800e75d -> bb2fde538041; 1 failed, 397 passed in 1.55s
killed   alignment-cell-ignored: sha 942c6800e75d -> 7fe7a5aca6bf; 5 failed, 393 passed in 1.52s
killed   centre-read-as-right: sha 942c6800e75d -> b3300a1c6bd6; 3 failed, 395 passed in 1.53s
killed   right-read-as-left: sha 942c6800e75d -> 53e6fa5848db; 9 failed, 389 passed in 1.56s
killed   unpadded-spacing-dropped: sha 942c6800e75d -> be5d1089022d; 6 failed, 392 passed in 1.58s
killed   unpadded-empty-cell-unspaced: sha 942c6800e75d -> 5ad18d4653c2; 3 failed, 395 passed in 1.40s
killed   backslash-guard-off: sha 942c6800e75d -> 942d0cfc696c; 2 failed, 396 passed in 1.50s
killed   escape-off: sha 942c6800e75d -> 232cf873230e; 9 failed, 389 passed in 1.73s
killed   trim-refusal-off: sha 942c6800e75d -> e78ec06d1524; 2 failed, 396 passed in 1.55s
killed   arg-type-check-off: sha 942c6800e75d -> ce0da48eb3cf; 4 failed, 394 passed in 1.58s
killed   width-off-by-one: sha 942c6800e75d -> ae6153d1ea63; 29 failed, 369 passed in 1.80s
killed   alignment-cell-of-first-column: sha 942c6800e75d -> 18ed5ae4d56b; 5 failed, 393 passed in 1.75s
killed   result-not-evaluated: sha 942c6800e75d -> 7337c5b345a2; 8 failed, 390 passed in 1.83s
killed   whole-row-rerendered: sha 942c6800e75d -> b333c8486faa; 43 failed, 355 passed in 1.93s
killed   no-op-compared-raw: sha 942c6800e75d -> f1a9b7021a4d; 1 failed, 397 passed in 1.75s
killed   shared-_pad-centre-extra-left: sha 942c6800e75d -> dc9eddbaf048; 1 failed, 397 passed in 1.72s
killed   shared-_padded-threshold-one: sha 942c6800e75d -> e491175b7f2b; 17 failed, 381 passed in 1.71s
killed   trim-refusal-and-read-back-off: sha 942c6800e75d -> 0d3c9f2cd152; 9 failed, 389 passed in 1.57s

One mutant survives. read-back-off removes the last check, which compares the written row with the request. No input I tried reaches it: every byte set_cell writes is escape_cell(value) plus spaces, split_row undoes the escape, and a trailing \ gets a space. It stays as a guard, as the read-back in rename_column does, so that a gap in the rules is refused rather than written. trim-refusal-off is not equivalent: the two rule-2 tests in 2e39136 kill it.

Gate

At each commit, the tree was gated exactly as committed, with the rest stashed. just check exited 0 every time. just test exited 0 with 2 skipped each time:

commit passed
b34b957 1650
96fab16 1663
879e704 1808
1c8cbab 1808
16a7d6a 1809
2e39136 1815
ea1fc83 1818
9b664e7 1818

just conform, conform-alt, conform-csv and just mutants were not run, because nothing they read changed: SPEC.md, the case trees, table.py, csvmode.py and sidecar.py. rowspec_alt has no editing API.

The editing API reserved edit.set_cell(text, row_key, column, value)
for a text-level cell edit. This states its contract before the code
exists, so the cases for it can be written from the text alone:

- one changed line, the row, and only the cell's span within it;
- the cell keeps its width when it was padded or the table is, and
  places its new text as the old text was placed, or as the column's
  alignment cell says when the old cell gives no side;
- every argument is a str, any stored column can be set, a computed
  column takes only "", and a value that is already there is a no-op;
- the API's existing refusals apply, a value with outer space or tab
  is not-representable, and the row is read back before returning.

Measured at d368364, rowspec.set_cell + render turns the padded
row `| r_20 | nut    |  10 |` into `| r_20 | nut | 11 |` for qty=11,
and writes "  nut" as `|   nut |`, which parse reads back as "nut".

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
set_cell(text, row_key, column, value, /) rewrites one stored data
cell in place, under the name and rules docs/editing-api.md gives it.
Only the cell's span of the row's line changes. A padded cell, or any
cell of a padded table, keeps its width where the new text fits, and
the text is placed as the old text was, or as the alignment cell says.

Refusals are the editing API's own: no-key, no-such-row,
no-such-column, line-break, and not-representable for a value with
leading or trailing space or tab. A value in a computed column is left
to the result's evaluation, which refuses it as value-in-computed-cell,
and the row is read back before the text is returned. A value equal to
the cell's text, compared in NFC, returns the input unchanged.

The row key is type-checked up front. Without that, a table with no
rows reports no-such-row for a key that is not a str, which a test now
pins. tests/test_edit_merge.py gains three set_cell pairings: two edits
of one row conflict, and edits two rows apart merge, padded or not.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
tests/test_edit_set_cell.py was written from docs/editing-api.md and
SPEC.md at b34b957, the commit that specified set_cell, by an author
who saw no implementation. The author worked in a directory holding
only SPEC.md, docs/editing-api.md, CONTRIBUTING.md and
tests/test_edit.py, the last for harness style, with no .git and no
reference/.

The file is as written: sha256 09f8e72ae5d0c962... here and in that
directory. Its first run against 96fab16 passed 145 of 145.

The author listed what the docs left open: the space count on the far
side of a placed cell, the split of an odd spare in a centred cell,
whether an empty cell of two spaces is padded, and whether a cell that
is not NFC reads as its NFC form. Those cases are left out here, and
the docs now answer each of them in their own commit.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
The cases written from the first draft found four outcomes it did not
determine. Each now has the answer the implementation gives, and a
test in tests/test_edit.py:

- text placed right or left has exactly one space on its other side,
  so `|   3|` set to `4` becomes `|  4 |`;
- centred text with an odd spare space has the extra one on its right;
- a cell is padded when it is more than two characters wider than its
  text, so three spaces are padded and two are not;
- the no-op comparison is in NFC, so setting a cell that is not NFC to
  its NFC form keeps the cell's bytes.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Rule 3 said "padded" two ways: more than one space beside the text,
for header cells and for a padded table, and more than two characters
wider than the text, for set_cell. The code has one test for both,
_padded, and it is the second. So `| id |  qq|` renamed to q gives
`|  q|`, and in a padded table set_cell places `|  33|` set to 4 by
the alignment cell, as `| 4  |`, not by its own two spaces.

Rule 3 now gives the one definition first and uses it for header cells,
data cells and the padded table. It also says widths count code
points, with a tab as one. A test pins both of the cases above, and it
fails with the threshold moved from 2 to 1.

The set_cell bullet now says a `|` in the value is written as `\|`.
The CHANGELOG now says that "" in a computed column is accepted.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
set_cell(None, ...) raised AttributeError from the parser, and a
column of 3 raised a TypeError from unicodedata that did not name the
argument. text, row_key and column are now checked first, and each
raises "<name> must be a str"; the value already was. A test covers
all four, and three of them fail with the check removed.

Two tests pin refusal rule 2 for set_cell, which refuses an outer space
before any text is built. Without that check, " 1" in a computed
column is refused as value-in-computed-cell, and " r_02" in the key
column as duplicate-row-id. Both must stay not-representable.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Through real merges with stock git, on the canonical table:

- set_cell of one cell to two different values conflicts;
- set_cell of one cell to the same value on both branches merges
  cleanly, as the same change;
- set_cell of a row against a delete of that row conflicts.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
- The CHANGELOG said the cell keeps its width where the new text fits.
  It does that only when the cell or the table is padded, and keeps
  its own spaces otherwise. It now says so.
- Rule 3 said an unpadded header cell is re-rendered to its new text.
  It keeps the spaces it had beside its text, as `|  qq|` renamed to q
  giving `|  q|` shows, so the rule now says that.
- The set_cell bullet says that any argument that is not a str raises
  TypeError, and that setting the key column changes the row's
  identity (§6): an edit that still names the old key no longer finds
  the row.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
@CameronBrooks11
CameronBrooks11 merged commit b55b913 into main Oct 23, 2026
10 checks passed
@CameronBrooks11
CameronBrooks11 deleted the feat/edit-set-cell branch October 23, 2026 14:57
CameronBrooks11 added a commit that referenced this pull request Oct 23, 2026
Prepare 0.6.0.

- Version 0.6.0 in pyproject.toml, reference/rowspec/__init__.py,
  action.yml's default pin (which release.yml checks against the
  build), the README's @v0.6.0 example and rowspec's line in uv.lock.
  Every other 0.5.0 mention is historical.
- CHANGELOG: Unreleased becomes [0.6.0] - 2026-10-23, with
  rowspec.edit.set_cell (#277) under Added and a Known issues section
  for #278 (a data cell holding := is refused as
  malformed-declaration) and #279 (a row ending in an escaped | is
  accepted where section 9.18 says malformed-table-line). Both were
  already in 0.5.0; rowspec_alt reads each line as SPEC.md says.
- ROADMAP names v0.6.0 as the latest release and adds a Shipped in
  0.6.0 section; of its figures only just test moved (1650 to 1818).

just check, conform (631), conform-alt (631), conform-csv (281),
mutants (114 killed, 2 equivalent), test (1818 passed), test-xlsx and
test-libreoffice all pass. The sdist and wheel built from a git
archive install in fresh venvs at 0.6.0, edit.set_cell works from
each, and release.yml's tag and pin checks pass on that build.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant