Lesson 02 / 16
Syntax and Indentation
Indentation is not a style preference but part of the grammar: the same 21 content tokens produce two different trees and two different results under two different indentations, four different widths give a single tree, and only 5 of ten parts are expressions.
Contents
The previous lesson showed that the source is compiled as a whole before anything runs, and said that the first step of compilation is splitting the text into tokens. That step left a question open: what happens to the whitespace at the start of a line?
In most languages that build a block with braces, nothing — the whitespace is discarded, braces say where the body starts and ends, and indentation is there only for the reader. In Python, the whitespace is not discarded. Indentation adds its own tokens to the token sequence, and it is exactly those tokens that build the block structure. The observable consequence is this: the same token sequence turns into a different tree and a different result just by changing its indentation.
Whitespace Builds the Block, Not Braces
The tokenizing step measures the whitespace at the start of every line and keeps it on a
stack. If a line sits deeper than the one before it, an INDENT token is placed in the
stream; if it sits shallower, a DEDENT token is placed for every level that closes. If
several levels close at once, that many DEDENT tokens are produced, so the number of
DEDENT tokens in the sequence gives the number of blocks that closed. These tokens have
no character in the source text that corresponds to them — they are produced from the
whitespace itself.
The two sources below are indistinguishable if you only look at the tokens’ names. Only
one line’s indentation differs: the print call sits outside the loop body in one,
inside it in the other.
The measurement’s assumptions:
- LF8 — The two sources differ only in the last line’s indentation; no character was added, removed, or changed.
- LF9 — The comparison is made through the tokens’ names;
INDENT,DEDENT,NEWLINE,NL, and the end-of-file token are counted as “layout tokens” and set aside, the rest count as “content tokens.” - LF10 — The tree is written with the class names of the statements at the parsed structure’s top level; the loop statement’s body is given in square brackets. What is read is not the tree’s full dump but the shell that is enough for this measurement.
- LF11 — Execution output is captured into a separate buffer and written as a list of printed lines; no environment-dependent value is read.
"""Same token sequence, two indentations: three views.""" import ast import contextlib import io import tokenize OUTSIDE = "total = 0\nfor x in (1, 2, 3):\n total += x\nprint(total)\n" INSIDE = "total = 0\nfor x in (1, 2, 3):\n total += x\n print(total)\n" SOURCE = {"outside the body": OUTSIDE, "inside the body": INSIDE} LAYOUT = {"INDENT", "DEDENT", "NEWLINE", "NL", "ENDMARKER"} def tokens(source): reader = io.StringIO(source).readline return [tokenize.tok_name[t.type] for t in tokenize.generate_tokens(reader)] def tree(source): parts = [] for d in ast.parse(source).body: name = type(d).__name__ if isinstance(d, ast.For): name += "[" + ",".join(type(g).__name__ for g in d.body) + "]" parts.append(name) return " ".join(parts) def run(source): buffer = io.StringIO() with contextlib.redirect_stdout(buffer): exec(compile(source, "<lesson>", "exec"), {}) return buffer.getvalue().split() for name, source in SOURCE.items(): all_tokens = tokens(source) content = [t for t in all_tokens if t not in LAYOUT] print(f"{name:17s} tokens {len(all_tokens)} non-layout {len(content)} " f"INDENT/DEDENT {[i for i, t in enumerate(all_tokens) if t in ('INDENT', 'DEDENT')]}") a, b = (tokens(k) for k in SOURCE.values()) print(f"equal once layout tokens are dropped: " f"{[x for x in a if x not in LAYOUT] == [x for x in b if x not in LAYOUT]}") print() for name, source in SOURCE.items(): print(f"{name:17s} tree: {tree(source)}") print(f"{'':17s} printed: {run(source)}")
outside the body tokens 28 non-layout 21 INDENT/DEDENT [16, 21]
inside the body tokens 28 non-layout 21 INDENT/DEDENT [16, 26]
equal once layout tokens are dropped: True
outside the body tree: Assign For[AugAssign] Expr
printed: ['6']
inside the body tree: Assign For[AugAssign,Expr]
printed: ['1', '3', '6']
Both sources produce 28 tokens, and 21 of them are content tokens. Once layout
tokens are dropped, the two remaining sequences are exactly equal — same names, same
order. The difference sits at a single token’s position: DEDENT stands at position
21 in one, position 26 in the other. The only thing that changed in the source is
four spaces, and those four spaces moved one token five positions over.
That single difference changes the tree. In the first source, the call is the loop’s
sibling: in the tree, For[AugAssign] finishes and a separate Expr follows. In the
second, the call is the loop’s child: For[AugAssign,Expr]. The result splits
accordingly — one prints a single line, the other prints three.
The rule that follows from this closes one of the most expensive misunderstandings when approaching the language: breaking indentation is not a style defect, it is changing the program. In a brace-based language, these same two sources would be formatted versions of each other and both would do the same thing. Here indentation is the sole determinant; no other marker states the body’s boundary.
Width Is Free, Consistency Is Mandatory
Indentation being grammatical does not mean a particular width is grammatical. The language does not impose a number; what it imposes is that a block be internally consistent. The boundary is observable: the same structure can be written with four different widths and their trees compared, then four inconsistent sources can be tried for compilation.
- LF12 — In valid sources, width changes but stays consistent within a block; in broken sources, consistency breaks in four different ways. The sources are compiled, not executed — what is measured is whether the compile phase accepts them.
"""The rule of indentation: width is free, consistency is mandatory.""" import ast WIDTH = {"two spaces": "if 1:\n a = 1\n b = 2\n", "four spaces": "if 1:\n a = 1\n b = 2\n", "eight spaces": "if 1:\n a = 1\n b = 2\n", "tab": "if 1:\n\ta = 1\n\tb = 2\n"} BROKEN = {"no indentation at all": "for x in (1, 2):\nb = 1\n", "unexpected indent": "a = 0\n b = 1\n", "misaligned dedent": "if 1:\n a = 1\n b = 2\n", "tab mixed with spaces": "if 1:\n\ta = 1\n b = 2\n"} def tree(source): d = ast.parse(source).body[0] return f"{type(d).__name__}[{','.join(type(g).__name__ for g in d.body)}]" print("same structure, different width:") for name, source in WIDTH.items(): print(f" {name:12s} tree: {tree(source)}") print(f" are all four trees the same: " f"{len({tree(k) for k in WIDTH.values()}) == 1}") print("\ninconsistent indentation:") for name, source in BROKEN.items(): try: compile(source, "<lesson>", "exec") print(f" {name:24s} compiled") except SyntaxError as e: print(f" {name:24s} {type(e).__name__}") print(f" is IndentationError a SyntaxError: " f"{issubclass(IndentationError, SyntaxError)}") print(f" is TabError an IndentationError: " f"{issubclass(TabError, IndentationError)}")
same structure, different width: two spaces tree: If[Assign,Assign] four spaces tree: If[Assign,Assign] eight spaces tree: If[Assign,Assign] tab tree: If[Assign,Assign] are all four trees the same: True inconsistent indentation: no indentation at all IndentationError unexpected indent IndentationError misaligned dedent IndentationError tab mixed with spaces TabError is IndentationError a SyntaxError: True is TabError an IndentationError: True
Four widths give a single tree. What is grammatical is not the amount of indentation but its structure: a level opens and continues at the same alignment, and when it closes it lands on an alignment that was already opened. A program written with two spaces and one written with eight spaces are the same program; the choice between them is a matter of team convention, not the language.
When consistency breaks, compilation stops in all four cases. Three give
IndentationError: a block whose body is never opened, a line that slides inward
without opening anything, and a dedent that lands on no previously opened alignment.
The fourth stands apart — a source mixing tabs and spaces gives TabError, because the
width of these two characters cannot be resolved just by looking at the source. The
output’s last two lines show all three come from a single lineage: TabError is an
IndentationError, which is in turn a SyntaxError.
This lineage also settles the previous lesson’s boundary. Indentation defects are found at compile time; none of them make it to execution. A broken indentation on the file’s last line keeps even the assignment on the first line from running.
Logical Line vs. Physical Line
Indentation being grammatical stops somewhere, and that limit is worth knowing. What
builds the block structure is the whitespace at the start of a logical line; not
every line start on screen begins a logical line. While an expression sits inside an
open bracket, a line ending does not finish the statement — an NL is placed in the
stream instead of a NEWLINE, and the indentation of those lines produces no token at
all. The same continuation can also be built with a backslash.
- LF13 — Four forms write the same sum and differ only in how they split across
lines; what is measured is the physical line count, the logical line ending
(
NEWLINE), the continuation line ending (NL), and theINDENTcount. The value column is the sum the source itself produces.
"""Logical line vs physical line: indentation is not grammatical inside parens.""" import ast import io import tokenize FORM = { "single line": "total = 1 + 2 + 3\n", "aligned inside parens": "total = (1 +\n 2 +\n 3)\n", "ragged inside parens": "total = (1 +\n2 +\n 3)\n", "with backslash": "total = 1 + \\\n 2 + \\\n 3\n", } def count(source): reader = io.StringIO(source).readline names = [tokenize.tok_name[t.type] for t in tokenize.generate_tokens(reader)] return names.count("NEWLINE"), names.count("NL"), names.count("INDENT") print(f"{'form':<22s} {'physical':>8s} {'NEWLINE':>7s} {'NL':>3s} {'INDENT':>6s}" f" tree value") for name, source in FORM.items(): logical, nl, indent = count(source) env = {} exec(compile(source, "<lesson>", "exec"), env) tree = " ".join(type(d).__name__ for d in ast.parse(source).body) print(f"{name:<22s} {len(source.splitlines()):8d} {logical:7d} {nl:3d} {indent:6d}" f" {tree:8s} {env['total']}")
form physical NEWLINE NL INDENT tree value single line 1 1 0 0 Assign 6 aligned inside parens 3 1 2 0 Assign 6 ragged inside parens 3 1 2 0 Assign 6 with backslash 3 1 0 0 Assign 6
All four forms give a single Assign node and a single value. Even though three of
them spread across 3 physical lines, the NEWLINE count is 1 in every one:
there is exactly one logical line. In the two forms inside parentheses, the line
endings in between pass through as NL, meaning “no statement ended here.” In the
backslash form, the line ending does not turn into a token at all; the continuation is
resolved before the text is even split into tokens.
The most telling column is INDENT: 0 in all four. The “ragged” form’s second line
starts at column zero, its third starts deep in, and neither changes anything. So
indentation stops being grammatical inside brackets; the alignment done there is
entirely for the reader, and inconsistency there produces no defect. The rule can be
narrowed like this: indentation only carries meaning at the start of a logical line.
Statement and Expression
Indentation builds a block; a block, in turn, is made of statements. This requires the language’s second fundamental distinction. An expression produces a value: it is computed and leaves an object behind. A statement does a job: it binds a name, enters a block, directs the flow; it leaves no value behind.
The distinction does not have to be left to intuition, because the compile mode states
it directly. eval mode accepts only an expression; exec mode accepts a sequence of
statements.
- LF14 — Every part is attempted in both modes; what is measured is whether it is accepted. Parts whose value is printed are evaluated in an environment where a single name is bound, so the value column depends on that binding, not on the environment.
"""Statement or expression: 'eval' mode only compiles an expression, 'exec' compiles both.""" PARTS = ( "2 + 3", "total", "len('value')", "[k for k in (1, 2, 3)]", "total if total else 0", "total = 5", "total += 1", "if total: pass", "for k in (1, 2): pass", "pass", ) print(f"{'part':<26s} {'expr':>5s} {'stmt':>5s} value") expr_count = stmt_count = 0 for p in PARTS: try: code_obj = compile(p, "<lesson>", "eval") is_expr = True except SyntaxError: is_expr = False try: compile(p, "<lesson>", "exec") is_stmt = True except SyntaxError: is_stmt = False expr_count += is_expr stmt_count += is_stmt value = repr(eval(code_obj, {"total": 3})) if is_expr else "—" print(f"{p:<26s} {str(is_expr):>5s} {str(is_stmt):>5s} {value}") print(f"\n{len(PARTS)} parts: expression {expr_count}, statement {stmt_count}; " f"statement-only {stmt_count - expr_count}")
part expr stmt value
2 + 3 True True 5
total True True 3
len('value') True True 5
[k for k in (1, 2, 3)] True True [1, 2, 3]
total if total else 0 True True 3
total = 5 False True —
total += 1 False True —
if total: pass False True —
for k in (1, 2): pass False True —
pass False True —
10 parts: expression 5, statement 10; statement-only 5
5 of ten parts are expressions, 10 are statements. The statement column being entirely filled gives the direction of the distinction: every expression is also a statement — placed alone on a line, its value is computed and discarded. The reverse does not hold; the 5 statement-only parts cannot be placed anywhere an expression could stand.
The distinction has a direct consequence for the course. All eleven forms the previous
lesson measured consumed an object, and what they consumed was an expression’s
value; special methods operate on expressions. This is also why it matters that the row
total += 1 does not come out as an expression: the Programming Fundamentals course
said that a compound assignment is a statement; what is measured here is which
method that statement calls and what it does with the returned value. The result of
the __iadd__ call does not sit around as a value, it is bound straight back to the
name — the detail is paid off in the operators lesson.
One last observation hides in the table: if total: pass, even though written on a
single line, is a valid statement and uses no indentation at all. Indentation is not
the only way to write a block; what makes a block mandatory is the statement itself,
and indentation is the way to write a body with more than one statement.
Summary
- Indentation is not discarded; the whitespace at the start of a line is turned into
INDENTandDEDENTtokens, and it is these tokens that build the block structure. - Two sources differing only in indentation carry the same 21 content tokens in the
same order; the position of a single
DEDENTtoken changes the tree and the result. - Indentation’s width is free — four different widths give a single tree — but it must be consistent within a block.
- Inconsistent indentation stops at compile time;
TabErroris anIndentationError, which is in turn aSyntaxError. - Indentation is only grammatical at the start of a logical line: 3 physical lines
inside an open bracket produce a single
NEWLINE, and theINDENTcount stays 0. - 5 of ten parts are expressions, 10 are statements: every expression can be written as a statement, no statement can stand in for an expression.
Next Step
In this lesson, assignment — the most commonly written statement — showed up only as “not an expression” in the table; what it does was never examined. The Programming Fundamentals course established that assignment is a binding — a name is not a box, it is a label attached to an object. The next lesson measures this bond from the object’s side: what does it mean when two names are bound to the same object, under what condition do two equal values turn out to be the same object, and how does a type being “immutable” affect binding?
To keep your progress and take notes, Log in
My notes
Log in to take notes.