__doc__ is consumed and discarded
"""A docstring."""
print(__doc__.strip()) # error: undefined variable (__doc__)
lekkerzeilen/__main__.py:312, in the --help path:
if "--help" in argv or "-h" in argv:
print(__doc__.strip())
return 0
The line number names no file, and that is the documented trap
The diagnostic is a bare pascal26:312: with no file name, so a reader supplies
the file they invoked. grep -rn '__doc__' --include=*.py settles it: there are
ten in the tree, and lekkerzeilen/__main__.py:312 is the only one inside the
package — the other nine are argparse(description=__doc__...) calls in
tools/*.py, which the closure never reaches.
Why this is retention, not parsing
compiler/pyparser.inc:41629:
{ A leading module docstring may precede the imports; consume it. }
if (CurTok.Kind = tkString) and (TokPos < TokCount) and
(Tokens[TokPos].Kind = tkNewline) then
begin
Next;
PySkipNewlines;
end;
The token is in hand and is dropped on the floor. Capturing it costs a global;
emitting it costs the eleven lines sys.platform already uses at
compiler/pyparser.inc:12934.
The two hazards, and both are silent
1. Absent docstring must be None, not ''. The call site is
__doc__.strip(). In CPython that raises AttributeError on a module with no
docstring; an empty string would .strip() happily and print a blank line —
a plausible wrong output with no diagnostic, which is the class this project
refuses over. Whatever is emitted for the no-docstring case, assert it: a
fixture whose module has no docstring is the positive control, and it must
be written, because the only arrangement anyone writes naturally is the one with
a docstring present.
2. __doc__ is per-module. A single global holding "the docstring" will
hand an imported module the importer's text. The failure is a wrong string, not
an error. A fixture needs a module with a docstring importing a module with a
different docstring and printing both — and per the ordered-list rule, put the
interesting one where it does not pass by position: have the IMPORTED module's
docstring be the one read first.
Expected-value collision to avoid
Do not let the fixture's expected docstring be something the machinery could
produce by doing nothing — not '', not the file name, not None spelled as a
string. Use a distinctive sentence.
Closure position
Tenth wall of 2026-09-12. See [[umbrella-lekkerzeilen-compiles-and-runs-under-nilpy]]; three of the last four walls were library gaps, and this one is back to being a frontend gap.
The line number is volatile and the defect is not (2026-09-12)
lekkerzeilen-a2 flagged it and it checked out: lekkerzeilen/__main__.py is
modified on disk in the owner's tree — an in-flight port of the platform
seam to pxx (_pxx.py +976, _gl.py +154, gfx.py stripped of ctypes, a new
_vocab.py). So:
working tree 312: print(__doc__.strip())
HEAD 299: print(__doc__.strip())
The finding survives the caveat, and that is why it is written down rather
than left to a later re-measurement: the construct is present in BOTH, and
both spellings of the module open with the same leading Entry point.
docstring, so the __doc__ gap is real at HEAD and in the working tree alike.
Only the citation moves.
Quote the construct and the --help path. A line number against a tree somebody
else is editing is the classic stale pointer — it does not error, it points
somewhere.
Resolution 2026-09-12 — implemented, and the ticket's own two hazards were both real
__doc__ answers the module docstring. The closure moved off it; see below for
where it went.
Both predicted hazards were real, and a THIRD one was not predicted.
- Absent →
None, never''— as the ticket said. Measured: withNone,print(__doc__.strip())raisesAttributeError, byte-identical to CPython; with''every assertion in the fixture still passes and the program prints a blank line. The fixture asserts theAttributeErrorrow for exactly that reason. - Per-module — REFUSED rather than answered.
ParsePyProgramis the only routine that records a docstring (an imported module's leading string is an ordinary no-op expression statement), so the only value the emit site could hand an imported module is the MAIN module's.CurrentUnitIdx >= 0— the same seam__file__uses — raises instead. Control: a two-file probe where CPython answersModule M own docstring.confirms the main module's text really would have been a plausible wrong value. - NOT PREDICTED, and it is the one that would have shipped a wrong string for
this very app: CPython DEDENTS a docstring at compile time (3.13+,
_PyCompile_CleanDoc), so__doc__is not the literal and the ticket's prescribed emit pattern — the token's own span — is wrong for every indented docstring.lekkerzeilen/__main__.py's docstring is indented four, and its--helpisprint(__doc__.strip()), which strips the ends and leaves all forty interior lines four columns over. Found by a fixture written with indentation in it; a flush-left fixture passes in both worlds. The rule was derived by MEASURING CPython 3.14, and two of its three clauses are not what a first reading gives: a whitespace-only line is ignored in the common-indent minimum (counting it gives 2 where CPython gives 6), and stripping is by COLUMN with the surplus re-materialised as SPACES, so"""a\n\tb\n\t\tc\n"""puts eight spaces beforecrather than a tab.
The ticket's prescription was also wrong about WHERE. It named
pasparser_expr.inc's identifier factor (via the sys.platform pattern). That
arm alone leaves __doc__ undefined with the code sitting right there: a .npy
comes through pyparser.inc's own identifier factor, which is why __name__
and __file__ each appear in BOTH files. The arm is in both, as they are.
What landed: PyModuleDocSeen/SOffset/SLen in defs.inc; the recorder in
ParsePyProgram (dedent via PyCleanDoc + StoredName, so the node is an
ordinary pooled literal); the emit arm in both identifier factors; PyMakeNone
forward-declared in compiler.pas because the Pascal tkNil arm's tyPointer 0
answers True to is None and prints 0 under repr where CPython prints
None — measured, not assumed. Three fixtures, all diffed against CPython:
test_nilpy_module_docstring, _absent, _dedent_columns.
Log
- 2026-09-12 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit 4db262e08.