Skip to content

docs: add a rule for writing multi-line text in Python - #125

Merged
eirkkr merged 8 commits into
mainfrom
feature/multi-line-text-rule
Oct 4, 2026
Merged

eirkkr merged 8 commits into
mainfrom
feature/multi-line-text-rule

Conversation

@eirkkr

@eirkkr eirkkr commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

The Python code conventions now say how to write text of three or more lines: one triple-quoted block through textwrap.dedent, ending in .strip(), or in .lstrip() where the text must end in a newline. They name four cases that stay as adjacent literals, and say review is what enforces the rule. The Python testing conventions gain a short section for expected text that points at the rule and adds the two things only a comparison needs: assign the block to a name first, and strip the expected side only.

This is the one stage of Write multi-line text as dedented triple-quoted strings, and delivers R1-R8. Nothing is left unbuilt.

Changed from the plan

  • The .lstrip() example has three lines of usage text, not the two the build notes gave it: at two it fell under the rule's own threshold.
  • The f-string case reads "a value which can span lines", narrower than R3's "can itself hold a newline": a value that only ends in a newline dedents correctly.
  • The four cases "stay as adjacent literals", where R3 says "one literal per line", because one of them is a single wrapped line.
  • The testing section says "assign" where R5 says "bound to a name". Same rule, plainer word.
  • The old form is named but not shown: the build notes had it written out once as an example, and it was cut when the section was shortened at the gate.
  • The testing section has no example: the build notes gave it one, and it was cut at the gate because the code conventions' rule already covers tests and the example only repeated its form.
  • Five steps were added to the stage's issue: three for review fixes, and two for what was cut at the gate.

Yours to overturn

  • "Shorter text stays one literal" was added to the code conventions, so both docs say what text under three lines does. No requirement asked for it.
  • The reason for dedent over inspect.cleandoc is one sentence in the section, in place of an ADR.

Try it

Paste the _USAGE block from the new Multi-line text section into python3 after import textwrap, then run print(repr(_USAGE)). A good result is 'usage: prog [-h] FILE\n FILE the file to read\n -h show this help\n'.

The agent did this for both examples in the section, pulled from the doc as committed, and ruff format --isolated --diff left each one unchanged.

Closes #124
Closes #111

eirkkr added 4 commits October 4, 2026 16:11
Text written as one string literal per line, each ending in \n,
hides the text behind quotes and escapes. The new section makes one
triple-quoted block through textwrap.dedent the form for text of
three or more lines, gives its two endings, and says review is what
enforces it.

Each new-form example was run under python3 against the text the
section states for it, and ruff format --isolated --diff left it
unchanged.
A dedented block changes the text in four cases, or is not the
right tool for it: a line ending in spaces, a first line that is
indented or blank, one long line wrapped across literals, and an
f-string whose inserted value can hold a newline. Each is listed
with its reason so a reviewer can tell an exception from a miss.

Checked under python3: the f-string case comes back with its indent
intact, and .strip() removes an indented first line's indent.
A comparison needs two things the source rule does not: the block
bound to a name first, because ruff format rewraps the inline
assert, and the strip kept to the expected side, so a stray newline
in the result still fails. The testing doc gains a section saying
so and pointing at the code doc's rule, which links back.

The example was run under python3 against the same text written as
adjacent literals, and ruff format --isolated --diff left it
unchanged; the inline form was rewrapped as the section describes.
The stage's two reviews found the rule's wording short of what it
meant in several places:

- .strip() was shown but never stated as the default ending, and
  what it and .lstrip() remove was understated.
- The .lstrip() example was two lines, under the rule's own
  threshold. It is now three.
- "Quotes and an escape on every line" is untrue of a joined list.
- The f-string case fails only for a value that spans lines, not
  one that merely ends in a newline.
- The testing section stated its two rules twice, said "bind"
  where "assign" is plainer, and gave its example's text only by
  pointing at the other file.

Also says what shorter text does, and why the quotes sit on their
own lines. Every example was pulled from the docs and re-run.
@eirkkr

eirkkr commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner Author

Review findings

Three rounds, newest last. Each finding was reproduced before it was acted on.

First review

Two fresh reviews read the stage, one against the repo's conventions and one against R1-R8. Each finding was reproduced before it was acted on. All fixes are in the commit for step 1.4.

Fixed in this stage

  • R2 was not met as worded. .strip() appeared in the example but was never stated as the default ending. The rule sentence now says "end the block in .strip()".
  • The .lstrip() example broke the rule above it. Its text was two lines, under the three-line threshold. It now has three.
  • "Both put quotes and an escape on every line" was false for a list passed to "\n".join, which has no escape per line. Reworded.
  • What .strip() and .lstrip() remove was understated. Both remove all whitespace at their end, so .strip() also drops a trailing blank line. The section now says so, and sends text that ends in a blank line to .lstrip().
  • The inspect.cleandoc comparison was too broad. It matches dedent(...).strip() for the example, not in general. Now "would do the same for this example".
  • The f-string case was too broad. A value that only ends in a newline dedents correctly; the failure needs a value that spans lines. Reworded, and the reason rewritten as two plain sentences.
  • "One literal per line" contradicted the section's own meaning of "line", since one of the four cases is a single wrapped line. Now "stay as adjacent literals".
  • .strip() was referred to before it was explained. Its explanation moved up beside the first example.
  • The quotes-on-their-own-lines rule had no reason. One was added.
  • The two docs named the fallback differently, and the code conventions never said what shorter text does. Both now say it "stays one literal".
  • The testing section stated its two rules twice, once in the opening sentence and again in the bullets. The bullets now carry them alone.
  • "Bind" is jargon. Now "assign".
  • The test example's text was stated only by pointing at an example in the other file. It is now described in its own section.
  • "Editors and hooks" was vague. Now "editors and pre-commit hooks".

Left as it is

  • The old-form example's last literal has no \n, though the sentence above says "each ending in \n". The text does not end in a newline, so the last literal cannot; the phrase is R1's and describes the form well enough.
  • No requirement has anything behind it that would catch a regression. True, and already a decision in the change's brief: requirements are held by reading the sections and one run of their examples, over adding a doc-example test suite to a repo that has none.
  • The section carries a rejected alternative and a threshold, which could warrant an ADR. Also decided in the brief: no ADR, with the reason for dedent over cleandoc given in the section.
  • Several sentences are asked for by no requirement - the reasons beside each rule, the examples of text that must end in a newline. They are what the build notes asked each rule to carry. The one addition with no source in the plan is listed in the PR body under Yours to overturn.

Second review

After the section in code.md was shortened at the gate (step 1.5). Two fresh reviews read the stage again; each finding was reproduced before it was acted on. The requirements review found R1-R8 still met and nothing a requirement needs lost.

Fixed in this stage (step 1.7)

  • "ending in .strip()" had nothing to attach to. Now "passed through textwrap.dedent and ended in .strip()".
  • The two forms not to use were bare "Not ..." bullets under a sentence describing a value, and sat between the two examples. They are one sentence after both examples.
  • The inspect.cleandoc clause read as an argument for it, and "the same" had no referent. Now "gives _BODY in one call, but it also expands tabs".
  • "The block" was used before it was introduced. The sentence no longer needs the word.
  • dedent "removes only the indent the lines share" had lost its consequence. Restored: "so a line indented further keeps the difference".
  • The trailing-spaces case no longer said where the spaces are stripped. Now "inside the block".
  • The wrapped-line case said adjacent literals stay as adjacent literals. "Not multi-line text" is back.
  • The four cases used a bullet form the file uses nowhere else. Now "lead - reason", as the Constants section has it.
  • "Or a blank line" was redundant in the .lstrip() sentence: text ending in a blank line already ends in a newline.

Left as it is

  • The reason for putting the quotes on their own lines was cut in the shortening. Left out: the reviewer asked for a shorter section and the rule reads without it.
  • The rule does not exclude docstrings. They have their own section in the same file, and the change's brief lists them as out of scope.
  • inspect.cleandoc keeps an indented first line, which the section's second case says a block cannot. True, and not worth lengthening the section for.
  • The commit for step 1.5 refers to "R1-R4", which the repository does not define. The commit is pushed, and rewording it would rewrite the branch. The requirements are in the change's issue, which the PR body links.
  • The lead-in "stay as adjacent literals" drops R3's "one per line". Already listed in the PR body under Changed from the plan.

Findings on the testing.md example and its surrounding text are moot: step 1.6 removed them at the reviewer's request while the review was running.

Third review

After the testing section was cut at the gate (step 1.6) and the second review's fixes (step 1.7). The first two rounds were run against the default branch's conventions by mistake; this one read the stage against the conventions of the branch that defines how a change is built. The requirements review found R1-R8 met, every claim the sections make about Python and ruff format true but one, and nothing a requirement needs lost.

Fixed in this stage (step 1.8)

  • One claim was wrong. ruff format pushes only the opening quotes deeper when it rewraps the inline assert; the closing quotes are inside the string and stay level with the text. The testing section now says so.
  • "The block" was used without being introduced. The rule sentence now calls the string a block.
  • The inspect.cleandoc sentence never said not to use it. Now "Don't use inspect.cleandoc either".
  • Two loose referents in the f-string case. Now "the value's second line" and "the string's lines".

Left as it is

  • The two sections use different bullet forms. Each matches its own file: the code conventions' other bullets use "lead - reason", and the testing section's leads are full sentences.
  • Several reason sentences are asked for by no requirement. Kept: a rule without its reason is harder to review against, and the sections are already cut to what the reviewer asked for.
  • R3 says "can itself hold a newline" where the section says "can span lines". The section is the correct one; the brief is amended to match.
  • One step's check, "its example passes", no longer applies now the testing section has no example. Noted on the stage's issue.

Issues to file

None.

The reviewer asked at the stage's gate for a more concise section.
It drops from 418 words to 293: the worked example of the old form
goes, since the sentence naming it is enough; the two forms not to
use become two bullets; and each of the four cases keeps its reason
in one short sentence.

Nothing R1-R4 require was removed, and both examples were re-run
against their stated text.
@eirkkr

eirkkr commented Oct 4, 2026

Copy link
Copy Markdown
Owner Author

At the gate: the reviewer asked whether code.md could be more concise. The Multi-line text section was shortened in step 1.5, from 418 words to 293: the written-out example of the old form was cut, the two forms not to use became two bullets, and each of the four cases keeps its reason in one sentence. The stage is being reviewed again before it is offered a second time.

The reviewer pointed out at the stage's gate that the code doc's
rule already covers tests, so the testing doc need not repeat it.
The section loses its example and its restatement of the form, and
keeps the pointer and the two rules only a comparison needs: assign
the block before the assertion, and strip the expected side only.

It stays as a section because someone writing tests is sent to the
testing doc, and those two rules appear nowhere else.
@eirkkr

eirkkr commented Oct 4, 2026

Copy link
Copy Markdown
Owner Author

At the gate: the reviewer asked whether testing.md needs its example and text at all, since code.md's rule applies to everything. The section was cut in step 1.6 to a pointer and the two rules only a comparison needs - assign the block before the assertion, and strip the expected side only. It stays as a section because someone writing tests is sent to testing.md, and those two rules appear nowhere else. R5 and R6 are still met as worded, so the brief is unchanged.

A second review of the shortened section found places where the
cut left a sentence unclear:

- "ending in .strip()" had nothing to attach to.
- The two forms not to use sat as bare "Not ..." bullets under a
  sentence about a value, splitting the rule from its .lstrip()
  variant. They are one sentence after both examples now.
- The wrapped-line case said adjacent literals stay as adjacent
  literals, having lost "not multi-line text".
- The trailing-spaces case no longer said where the spaces are
  stripped.

The four cases take the "lead - reason" form the file's other
bullets use, and "or a blank line" goes: such text already ends in
a newline.
@eirkkr

This comment has been minimized.

A review of the sections as cut found one claim that was wrong and
three sentences a reader had to work at:

- ruff format pushes only the opening quotes deeper when it rewraps
  the inline assert. The closing quotes are inside the string and
  stay level with the text.
- "The block" was used without being introduced. The rule sentence
  now names it.
- The inspect.cleandoc sentence never said not to use it.
- In the f-string case, "its" and "the lines" could each be read
  two ways.
@eirkkr

eirkkr commented Oct 4, 2026

Copy link
Copy Markdown
Owner Author

At the gate: the reviewer approved the stage as it stands after step 1.8. Decided here: both sections stay as cut - no written-out example of the old form, no example in the testing section - and the brief's R3 and R5 were reworded to match what was built, with a comment on the change's issue.

@eirkkr
eirkkr merged commit e464464 into main Oct 4, 2026
5 checks passed
@eirkkr
eirkkr deleted the feature/multi-line-text-rule branch October 4, 2026 08:32
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.

Stage 1 - The multi-line text rule Write multi-line expected text in tests as dedented triple-quoted strings

1 participant