Skip to content
VLSI Mentor

Python Part II · Module 34 · From Script to Maintainable Tool

Changing a Tool Other People Depend On

The tool is now portable, documented and tested, which means people use it — and the moment somebody writes a wrapper around it, its flags, its output format and its exit codes stop being implementation and become an interface that was never designed. This chapter is about changing one of those anyway. The standard advice is to emit a deprecation warning, and the measured behaviour of that warning is narrower than the advice implies: written the obvious way, nobody will ever see it.

Advanced24 min readPythonPart IIAutomationInterfacesDeprecation

Part II · Module 34 · Chapter 34.7 · Changing a Tool Other People Depend On

1. What Became an Interface

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
SURFACE = [
    ("the flag names", "run_regress.py --tests smoke", "yes", "loud"),
    ("the output file names", "summary.csv", "yes", "loud"),
    ("the CSV column names", "status", "yes", "loud or silent — §6"),
    ("the CSV column ORDER", "test,seed,status", "yes", "silent"),
    ("the exit codes", "0 / 1 / 2 / 3 / 4", "yes", "silent"),
    ("the env var names", "FLOW_RUN_ROOT", "yes", "silent"),
    ("the printed wording", "3/4 passed", "no — 34.6 said so", "n/a"),
    ("the internal function names", "parse_sim_log", "no", "n/a"),
]

print(f"{'surface':<26} {'example':<30} {'interface':<22} breaks")
for what, example, iface, how in SURFACE:
    print(f"{what:<26} {example:<30} {iface:<22} {how}")

print()
iface = sum(1 for _, _, i, _ in SURFACE if i.startswith("yes"))
silent = sum(1 for _, _, i, h in SURFACE if i.startswith("yes")
             and "silent" in h)
print("surfaces that are an interface:", iface)
print("of those, breaking SILENTLY   :", silent)
print()
print("the ones you can change freely:", 2)
print("and 34.6 is why               :", "the docs said the wording is not promised")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
surface                    example                        interface              breaks
the flag names             run_regress.py --tests smoke   yes                    loud
the output file names      summary.csv                    yes                    loud
the CSV column names       status                         yes                    loud or silent — §6
the CSV column ORDER       test,seed,status               yes                    silent
the exit codes             0 / 1 / 2 / 3 / 4              yes                    silent
the env var names          FLOW_RUN_ROOT                  yes                    silent
the printed wording        3/4 passed                     no — 34.6 said so      n/a
the internal function names parse_sim_log                  no                     n/a

surfaces that are an interface: 6
of those, breaking SILENTLY   : 4

the ones you can change freely: 2
and 34.6 is why               : the docs said the wording is not promised

Six surfaces are an interface and four of them can break silently. The two reliably loud ones are the easy cases: a renamed flag produces unrecognized arguments, a renamed output file produces a missing-file error, and somebody tells you within the hour.

The others are the subject of Sections 6 and 7. A reordered CSV column, a reused exit code and a renamed environment variable all let the wrapper keep running and start being wrong, which is Chapter 30.7's confidently-wrong value arriving through an interface change. The column-name row is marked both ways because it is genuinely both: Section 6 measures a rename breaking one kind of consumer loudly and the other kind silently, in the same change.

Note the last two rows. Chapter 34.6's documentation explicitly declined to promise the printed wording, which is what makes it changeable — a promise you never made is the only kind you can withdraw.

2. The Annotation

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
# --tests became --suite: keep --tests working, warn for one release

Three decisions in one line, and all three are load-bearing. The new name exists, so new callers get the better flag. The old name keeps working, so no wrapper breaks today. And the overlap has an end — one release — so the shim does not become permanent.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
PLANS = [
    ("rename it and tell people", "every wrapper breaks at once", 0),
    ("rename it and keep the old one forever", "nothing breaks, ever",
     "unbounded"),
    ("keep the old one, warn, remove after one release", "nothing breaks now",
     1),
]

print(f"{'plan':<48} {'immediate effect':<30} releases of overlap")
for plan, effect, overlap in PLANS:
    print(f"{plan:<48} {effect:<30} {overlap}")

print()
print("the first plan's problem :", "a flag day across every team")
print("the second plan's problem:", "two names forever, both documented")
print("the annotation's plan    :", "a deprecation with a deadline")
print()
print("what makes the third work:", "the warning is actually SEEN — §5")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
plan                                             immediate effect               releases of overlap
rename it and tell people                        every wrapper breaks at once   0
rename it and keep the old one forever           nothing breaks, ever           unbounded
keep the old one, warn, remove after one release nothing breaks now             1

the first plan's problem : a flag day across every team
the second plan's problem: two names forever, both documented
the annotation's plan    : a deprecation with a deadline

what makes the third work: the warning is actually SEEN — §5

The second plan is the one teams drift into, and its cost is not maintenance — it is that Chapter 34.6's documentation now has to describe two flags that do the same thing, and a new user has to choose. The deadline is what the annotation adds over "be backwards compatible".

3. The Shim

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import argparse
import contextlib
import io


def build_parser():
    p = argparse.ArgumentParser(prog="run_regress.py")
    p.add_argument("--suite", dest="suite", default="smoke",
                   help="a suite name from flow.toml (default: %(default)s)")
    # the shim: the OLD flag, same dest, hidden from --help
    p.add_argument("--tests", dest="suite", help=argparse.SUPPRESS)
    return p


parser = build_parser()

print(f"{'invocation':<30} {'parsed suite':<14} note")
for argv in ([], ["--suite", "nightly"], ["--tests", "nightly"],
             ["--tests", "a", "--suite", "b"], ["--suite", "b", "--tests", "a"]):
    ns = parser.parse_args(argv)
    note = ("default" if not argv else
            "last one wins" if len(argv) == 4 else "")
    print(f"{str(argv):<30} {ns.suite!r:<14} {note}")

buf = io.StringIO()
with contextlib.redirect_stdout(buf):
    parser.print_help()
help_text = buf.getvalue()

print()
print("--tests appears in --help:", "--tests" in help_text)
print("--suite appears in --help:", "--suite" in help_text)
print("because the old flag uses:", "help=argparse.SUPPRESS")
print()
print("both flags write to       :", "the same dest")
print("so the body of main sees  :", "one name only")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
invocation                     parsed suite   note
[]                             'smoke'        default
['--suite', 'nightly']         'nightly'      
['--tests', 'nightly']         'nightly'      
['--tests', 'a', '--suite', 'b'] 'b'            last one wins
['--suite', 'b', '--tests', 'a'] 'a'            last one wins

--tests appears in --help: False
--suite appears in --help: True
because the old flag uses: help=argparse.SUPPRESS

both flags write to       : the same dest
so the body of main sees  : one name only

Two flags, one dest, and the body of main never learns there was a rename. That is what makes the shim cheap: args.suite is the only name in the code, so the deprecation lives entirely in the parser and deleting it later touches one line.

help=argparse.SUPPRESS hides the old flag from --help, which is the right default: the shim exists for wrappers that already use it, not to be discovered by new users. Chapter 34.6's documentation should mention it once, in a deprecations line, with the removal release.

The last two rows are worth noticing because they are a real hazard. When both flags are given, the last one wins — argparse simply assigns to dest in order — so a wrapper that passes --tests a while a site default adds --suite b gets b, silently. If that matters, the shim needs to detect the conflict explicitly rather than rely on ordering.

4. argparse Can Do the Warning Itself

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import argparse
import contextlib
import io
import sys

print("python", ".".join(str(n) for n in sys.version_info[:2]))
print()

parser = argparse.ArgumentParser(prog="run_regress.py")
parser.add_argument("--suite", dest="suite", default="smoke")
parser.add_argument("--tests", dest="suite", deprecated=True,
                    help=argparse.SUPPRESS)

for argv in (["--suite", "nightly"], ["--tests", "nightly"]):
    err = io.StringIO()
    with contextlib.redirect_stderr(err):
        ns = parser.parse_args(argv)
    lines = err.getvalue().strip().splitlines()
    print(f"  {str(argv):<24} suite={ns.suite!r:<12} "
          f"stderr lines={len(lines)}")
    for line in lines:
        print(f"      {line}")

print()
print("it uses the warnings module:", False)
print("it writes straight to       :", "stderr")
print("so it is always visible     :", True)
print("and it does not affect      :", "the exit code or stdout")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
python 3.13

  ['--suite', 'nightly']   suite='nightly'    stderr lines=0
  ['--tests', 'nightly']   suite='nightly'    stderr lines=1
      run_regress.py: warning: option '--tests' is deprecated

it uses the warnings module: False
it writes straight to       : stderr
so it is always visible     : True
and it does not affect      : the exit code or stdout

One line on stderr, and only when the old flag is used. Python 3.13's add_argument(..., deprecated=True) is the whole deprecation mechanism for a flag, and it deliberately bypasses the warnings module — which, as Section 5 measures, is the right call.

Three properties make it the correct tool here. It goes to stderr, so Chapter 33.8 Section 6's clean-stdout rule holds and a wrapper doing run_regress.py > summary.csv is unaffected. It does not change the exit code. And it is unconditional — no interpreter flag, no filter configuration, no stacklevel subtlety.

For a deprecated function in flow.parse there is no argparse to help, and that is where Section 5 applies.

5. The Warning Nobody Sees

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
import sys
from pathlib import Path

Path("flow").mkdir(exist_ok=True)
(Path("flow") / "__init__.py").write_text("", encoding="utf-8")
(Path("flow") / "api.py").write_text(
    "import warnings\n"
    "\n"
    "\n"
    "def no_stacklevel(suite):\n"
    '    warnings.warn("no_stacklevel() is deprecated; use run_suite()",\n'
    "                  DeprecationWarning)\n"
    "    return suite\n"
    "\n"
    "\n"
    "def with_stacklevel(suite):\n"
    '    warnings.warn("with_stacklevel() is deprecated; use run_suite()",\n'
    "                  DeprecationWarning, stacklevel=2)\n"
    "    return suite\n", encoding="utf-8")

Path("user_script.py").write_text(
    "from flow.api import no_stacklevel, with_stacklevel\n"
    "\n"
    'no_stacklevel("smoke")\n'
    'with_stacklevel("smoke")\n'
    'print("done")\n', encoding="utf-8")

r = subprocess.run([sys.executable, "user_script.py"],
                   stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)

print("a user runs their own script, which calls two deprecated functions")
print()
print("  no_stacklevel   warning shown:", "no_stacklevel() is deprecated"
      in r.stderr)
print("  with_stacklevel warning shown:", "with_stacklevel() is deprecated"
      in r.stderr)
print("  stderr lines                 :", len(r.stderr.strip().splitlines()))
print()
for line in r.stderr.splitlines():
    if "DeprecationWarning" in line:
        print("  attributed to:", line.split("/")[-1].strip()[:60])
print()
print("the rule: a DeprecationWarning is shown only when attributed to:",
      "__main__")
print("and stacklevel=2 attributes it to            :", "the CALLER")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
a user runs their own script, which calls two deprecated functions

  no_stacklevel   warning shown: False
  with_stacklevel warning shown: True
  stderr lines                 : 2

  attributed to: user_script.py:4: DeprecationWarning: with_stacklevel() is d

the rule: a DeprecationWarning is shown only when attributed to: __main__
and stacklevel=2 attributes it to            : the CALLER

One of the two warnings was shown. The code is identical apart from stacklevel=2, and that one argument is the difference between a deprecation every user sees and one nobody ever will.

The mechanism is worth stating precisely, because the usual summary — "DeprecationWarning is hidden by default" — is wrong in a way that leads to the wrong fix. The default warning filters do show DeprecationWarning, but only when it is attributed to __main__. A warnings.warn call with no stacklevel is attributed to the file containing the call — flow/api.py — which is not __main__, so it is filtered out. stacklevel=2 says blame my caller, and the caller here is the user's own script, which is __main__.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
import sys
from pathlib import Path

Path("flow").mkdir(exist_ok=True)
(Path("flow") / "__init__.py").write_text("", encoding="utf-8")
(Path("flow") / "api.py").write_text(
    "import warnings\n"
    "\n"
    "\n"
    "def deprecated_dep(suite):\n"
    '    warnings.warn("use run_suite()", DeprecationWarning)\n'
    "    return suite\n"
    "\n"
    "\n"
    "def deprecated_future(suite):\n"
    '    warnings.warn("use run_suite()", FutureWarning)\n'
    "    return suite\n", encoding="utf-8")

Path("u.py").write_text(
    "from flow.api import deprecated_dep, deprecated_future\n"
    'deprecated_dep("a")\n'
    'deprecated_future("a")\n', encoding="utf-8")

r = subprocess.run([sys.executable, "u.py"], stdout=subprocess.PIPE,
                   stderr=subprocess.PIPE, text=True)

print("both raised from the library, NEITHER with stacklevel:")
print("  DeprecationWarning shown:", "DeprecationWarning" in r.stderr)
print("  FutureWarning shown     :", "FutureWarning" in r.stderr)
print()
CATEGORIES = [
    ("DeprecationWarning", "for other developers", "filtered unless __main__"),
    ("FutureWarning", "for END USERS", "always shown"),
    ("UserWarning", "the default category", "always shown"),
]
print(f"  {'category':<22} {'intended audience':<22} default visibility")
for cat, who, vis in CATEGORIES:
    print(f"  {cat:<22} {who:<22} {vis}")
print()
print("so for a tool whose users are not its developers:", "FutureWarning")
print("and for a library API used by other code        :",
      "DeprecationWarning + stacklevel=2")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
both raised from the library, NEITHER with stacklevel:
  DeprecationWarning shown: False
  FutureWarning shown     : True

  category               intended audience      default visibility
  DeprecationWarning     for other developers   filtered unless __main__
  FutureWarning          for END USERS          always shown
  UserWarning            the default category   always shown

so for a tool whose users are not its developers: FutureWarning
and for a library API used by other code        : DeprecationWarning + stacklevel=2

FutureWarning is shown from anywhere; DeprecationWarning is not. That is the category distinction doing exactly what it was designed for, and it answers the question the previous measurement raises. DeprecationWarning means this is addressed to whoever maintains the calling code, so Python filters it out of ordinary runs; FutureWarning means this is addressed to the person running the program.

A flow tool's callers are usually verification engineers running wrappers, not library maintainers — so FutureWarning is the honest category for a changed flag or output format, and DeprecationWarning with stacklevel=2 is right for flow.parse's public functions.

6. Changing the Output Format

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import csv
import io

VARIANTS = {
    "unchanged": "test,seed,status\naxi_burst,4121,FAIL\n",
    "a column added at the END": "test,seed,status,errors\n"
                                 "axi_burst,4121,FAIL,2\n",
    "a column added in the MIDDLE": "test,seed,errors,status\n"
                                    "axi_burst,4121,2,FAIL\n",
    "columns reordered": "status,test,seed\nFAIL,axi_burst,4121\n",
    "the column RENAMED": "test,seed,verdict\naxi_burst,4121,FAIL\n",
}


def positional_reader(text):
    """A wrapper written in a hurry: awk-style, by column index."""
    return text.splitlines()[1].split(",")[2]


def named_reader(text):
    """A wrapper written carefully: by column name."""
    try:
        return next(csv.DictReader(io.StringIO(text)))["status"]
    except KeyError:
        return "KeyError"


print(f"{'change':<30} {'row[2]':<10} {'by name':<10} who breaks")
for label, text in VARIANTS.items():
    a, b = positional_reader(text), named_reader(text)
    if a == "FAIL" and b == "FAIL":
        who = "nobody"
    elif a != "FAIL" and b == "FAIL":
        who = "the careless one"
    elif a == "FAIL" and b != "FAIL":
        who = "the CAREFUL one"
    else:
        who = "both"
    print(f"{label:<30} {a:<10} {b:<10} {who}")

print()
print("the only change that breaks nobody:", "adding a column at the end")
print("the change that breaks only the    ")
print("  careful consumer                :", "renaming a column")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
change                         row[2]     by name    who breaks
unchanged                      FAIL       FAIL       nobody
a column added at the END      FAIL       FAIL       nobody
a column added in the MIDDLE   2          FAIL       the careless one
columns reordered              4121       FAIL       the careless one
the column RENAMED             FAIL       KeyError   the CAREFUL one

the only change that breaks nobody: adding a column at the end
the change that breaks only the    
  careful consumer                : renaming a column

Adding a column at the end is the only change that breaks nobody, and the last row is the finding worth carrying: renaming a column breaks the consumer who did it right and spares the one who indexed by position.

That inverts the usual intuition and it has a practical consequence. A rename produces a KeyError in every carefully written wrapper and silence in every hasty one, so the hasty wrappers keep running and keep reading a column whose name no longer describes it. The careless consumers are the ones you will not hear from, and they are the ones now reporting the wrong field.

The two middle rows are the mirror image and are the reason column order is an interface at all: a positional reader gets 2 where it wanted FAIL, which is a string that is not a status and may or may not raise downstream.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
RULES = [
    ("add a column", "at the end only", "nobody breaks"),
    ("remove a column", "never without a release of deprecation",
     "both break"),
    ("rename a column", "emit both for one release", "careful ones break"),
    ("reorder columns", "never — there is no benefit", "careless ones break"),
    ("change a value's meaning", "never — rename the column instead",
     "everybody is silently wrong"),
]

print(f"{'change':<26} {'the rule':<42} if you ignore it")
for change, rule, cost in RULES:
    print(f"{change:<26} {rule:<42} {cost}")

print()
print("changes that are always safe:", 1)
print("changes that are never safe :", 2)
print("the worst one               :", "changing what a value MEANS")
print("because                     :", "no reader can detect it")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
change                     the rule                                   if you ignore it
add a column               at the end only                            nobody breaks
remove a column            never without a release of deprecation     both break
rename a column            emit both for one release                  careful ones break
reorder columns            never — there is no benefit                careless ones break
change a value's meaning   never — rename the column instead          everybody is silently wrong

changes that are always safe: 1
changes that are never safe : 2
the worst one               : changing what a value MEANS
because                     : no reader can detect it

Emitting both columns for one release is the CSV equivalent of the flag shim: write status and verdict with the same value, document the removal release, then drop status. It costs one column and it turns a KeyError in somebody else's code into a warning in yours.

7. The Exit Code Is the Worst Thing to Change

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
OLD = {0: "every job passed", 1: "at least one job failed",
       2: "the command line was wrong"}
NEW = {0: "every job passed", 1: "the tool itself crashed",
       2: "the command line was wrong", 3: "at least one job failed"}

WRAPPER = """if run_regress.py --suite smoke; then
    publish_results
else
    open_bug_report
fi"""

print("the wrapper, unchanged:")
for line in WRAPPER.splitlines():
    print("   ", line)
print()
print(f"{'exit':>5}  {'old meaning':<26} {'new meaning':<26} wrapper does")
for code in sorted(set(OLD) | set(NEW)):
    old = OLD.get(code, "—")
    new = NEW.get(code, "—")
    action = "publish" if code == 0 else "open a bug"
    print(f"{code:>5}  {old:<26} {new:<26} {action}")

print()
print("codes whose meaning changed:", sum(1 for c in set(OLD) & set(NEW)
                                          if OLD[c] != NEW[c]))
print("the wrapper still runs      :", True)
print("it still branches correctly :", False)
print()
print("a failing suite used to exit:", 1, "->", "open a bug  (right)")
print("a failing suite now exits   :", 3, "->", "open a bug  (right)")
print("a CRASH now exits           :", 1, "->", "open a bug  (wrong kind)")
print()
print("so the breakage is          :", "a misclassified bug report")
print("and nothing anywhere        :", "errors or warns")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
the wrapper, unchanged:
    if run_regress.py --suite smoke; then
        publish_results
    else
        open_bug_report
    fi

 exit  old meaning                new meaning                wrapper does
    0  every job passed           every job passed           publish
    1  at least one job failed    the tool itself crashed    open a bug
    2  the command line was wrong the command line was wrong open a bug
    3  —                          at least one job failed    open a bug

codes whose meaning changed: 1
the wrapper still runs      : True
it still branches correctly : False

a failing suite used to exit: 1 -> open a bug  (right)
a failing suite now exits   : 3 -> open a bug  (right)
a CRASH now exits           : 1 -> open a bug  (wrong kind)

so the breakage is          : a misclassified bug report
and nothing anywhere        : errors or warns

One code's meaning changed and the wrapper kept working. A pass/fail wrapper that only distinguishes zero from non-zero is unaffected here, which is why this change looks safe — and a wrapper that distinguishes kinds of failure now files design bugs for tool crashes.

That is the reason exit codes are the one surface to treat as permanently frozen. There is no shim: a process has one integer to say everything with, you cannot emit both the old and the new value, and there is nowhere to put a warning that a wrapper reading $? will see.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
STRATEGIES = [
    ("add a new code for a new condition", "yes",
     "old codes keep their meaning"),
    ("reuse a retired code for something else", "no",
     "silent misclassification"),
    ("renumber for tidiness", "no", "there is no benefit at all"),
    ("widen 'failed' to include crashes", "no",
     "destroys 30.1's design/infra split"),
    ("narrow 'failed' and add a crash code", "yes",
     "old wrappers over-report, never under-report"),
]

print(f"{'strategy':<42} {'safe':<6} why")
for s, safe, why in STRATEGIES:
    print(f"{s:<42} {safe:<6} {why}")

print()
print("safe strategies:", 2)
print("the shared property of both:", "an old code never changes meaning")
print()
print("and the asymmetry that makes the last one safe:")
print("  an old wrapper seeing a NEW code  :", "falls into its else branch")
print("  an old wrapper seeing a REUSED one:", "takes the wrong branch")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
strategy                                   safe   why
add a new code for a new condition         yes    old codes keep their meaning
reuse a retired code for something else    no     silent misclassification
renumber for tidiness                      no     there is no benefit at all
widen 'failed' to include crashes          no     destroys 30.1's design/infra split
narrow 'failed' and add a crash code       yes    old wrappers over-report, never under-report

safe strategies: 2
the shared property of both: an old code never changes meaning

and the asymmetry that makes the last one safe:
  an old wrapper seeing a NEW code  : falls into its else branch
  an old wrapper seeing a REUSED one: takes the wrong branch

Only two strategies are safe and both preserve every old code's meaning. The asymmetry in the last three lines is the whole rule: an unknown code lands in a wrapper's else branch, which is conservative — it treats something new as a failure. A reused code lands in a branch that was written for a different meaning, which is not conservative at all.

8. A Deprecation With an End

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import argparse
import contextlib
import io

RELEASES = [
    ("1.4", "--tests only", "none", "the original"),
    ("1.5", "--suite added, --tests deprecated", "on --tests",
     "the annotation's release"),
    ("1.6", "--suite only", "n/a", "--tests removed"),
]

print(f"{'release':<9} {'flags accepted':<36} {'warning':<12} note")
for rel, flags, warn, note in RELEASES:
    print(f"{rel:<9} {flags:<36} {warn:<12} {note}")

print()
print("releases of overlap        :", 1)
print("what must be written down  :", "the removal release, in --help and docs")
print()
DEPRECATIONS = [
    ("--tests", "--suite", "1.5", "1.6", "argparse deprecated=True"),
    ("status column", "verdict column", "1.5", "1.6", "emit both"),
    ("flow.api.old_entry", "flow.api.run_suite", "1.5", "1.7",
     "FutureWarning, stacklevel=2"),
]
print("the deprecations table, which lives in the repository:")
print(f"  {'old':<22} {'new':<22} {'since':<7} {'removed':<9} mechanism")
for old, new, since, removed, how in DEPRECATIONS:
    print(f"  {old:<22} {new:<22} {since:<7} {removed:<9} {how}")

print()
print("entries with a removal release:", len(DEPRECATIONS))
print("entries without one           :", 0)
print("why that matters              :",
      "a deprecation with no deadline is a second interface")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
release   flags accepted                       warning      note
1.4       --tests only                         none         the original
1.5       --suite added, --tests deprecated    on --tests   the annotation's release
1.6       --suite only                         n/a          --tests removed

releases of overlap        : 1
what must be written down  : the removal release, in --help and docs

the deprecations table, which lives in the repository:
  old                    new                    since   removed   mechanism
  --tests                --suite                1.5     1.6       argparse deprecated=True
  status column          verdict column         1.5     1.6       emit both
  flow.api.old_entry     flow.api.run_suite     1.5     1.7       FutureWarning, stacklevel=2

entries with a removal release: 3
entries without one           : 0
why that matters              : a deprecation with no deadline is a second interface

Every entry has a removal release. That column is what separates the annotation's plan from "be backwards compatible", and the table belongs in the repository next to Chapter 34.5's ALLOWED table and Chapter 34.6's docstring — three small files that are executable or checkable and therefore cannot drift.

Note the three different mechanisms for three kinds of surface, each chosen from the measurements above: deprecated=True for a flag because it always shows; emitting both columns because there is no warning channel in a CSV; and FutureWarning with stacklevel=2 for a library function because a plain DeprecationWarning would be invisible.

9. What the Specimen Became

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
STAGES = [
    ("34.1", "measured it", 166, 3, 0, 1, False),
    ("34.2", "split into modules", 166, 9, 5, 1, True),
    ("34.3", "three layers, one direction", 170, 11, 7, 1, True),
    ("34.4", "no class needed; a frozen Result", 174, 12, 8, 1, True),
    ("34.5", "config, 7 facts removed", 196, 15, 10, 6, True),
    ("34.6", "documented, 5 sections", 219, 15, 10, 6, True),
    ("34.7", "a deprecations table", 224, 16, 11, 6, True),
]

print(f"{'ch':<6} {'what changed':<34} {'lines':>6} {'funcs':>6} "
      f"{'testable':>9} {'audiences':>10} importable")
for ch, what, lines, funcs, testable, audiences, imp in STAGES:
    print(f"{ch:<6} {what:<34} {lines:>6} {funcs:>6} {testable:>9} "
          f"{audiences:>10} {imp}")

first, last = STAGES[0], STAGES[-1]
print()
print("lines                :", first[2], "->", last[2],
      f"(+{round(100 * (last[2] - first[2]) / first[2])}%)")
print("testable functions   :", first[4], "->", last[4])
print("audiences who can run:", first[5], "->", last[5])
print()
print("the parsing logic rewritten:", "never")
print("the one defect found        :", "34.1 §6's empty report")
print("and it was found by         :", "measuring, not by reading")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
ch     what changed                        lines  funcs  testable  audiences importable
34.1   measured it                           166      3         0          1 False
34.2   split into modules                    166      9         5          1 True
34.3   three layers, one direction           170     11         7          1 True
34.4   no class needed; a frozen Result      174     12         8          1 True
34.5   config, 7 facts removed               196     15        10          6 True
34.6   documented, 5 sections                219     15        10          6 True
34.7   a deprecations table                  224     16        11          6 True

lines                : 166 -> 224 (+35%)
testable functions   : 0 -> 11
audiences who can run: 1 -> 6

the parsing logic rewritten: never
the one defect found        : 34.1 §6's empty report
and it was found by         : measuring, not by reading

Thirty-five per cent more lines, zero to eleven testable functions, one audience to six. That is the honest accounting for the module: the tool got bigger, and what grew was configuration handling, documentation and an entry point — not the logic, which was never rewritten.

The last two lines are the module's thesis. One real defect was found in 166 lines of working code, and it was found by ast counting references to a global, not by anybody reading carefully. Chapter 34.1's RESULTS had been read many times.

10. Predict, Run, Observe

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import argparse
import contextlib
import csv
import io

p = argparse.ArgumentParser(prog="t")
p.add_argument("--suite", dest="suite", default="smoke")
p.add_argument("--tests", dest="suite", deprecated=True,
               help=argparse.SUPPRESS)

# A
err = io.StringIO()
with contextlib.redirect_stderr(err):
    ns = p.parse_args(["--tests", "nightly"])
print("A:", ns.suite, len(err.getvalue().strip().splitlines()))

# B
err = io.StringIO()
with contextlib.redirect_stderr(err):
    ns = p.parse_args(["--tests", "a", "--suite", "b"])
print("B:", ns.suite)

# C
RENAMED = "test,seed,verdict\naxi_burst,4121,FAIL\n"
print("C:", RENAMED.splitlines()[1].split(",")[2],
      "status" in (csv.DictReader(io.StringIO(RENAMED)).fieldnames or []))

# D
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
    p.print_help()
print("D:", "--tests" in buf.getvalue())

The verified output:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: nightly 1
B: b
C: FAIL False
D: False

A — the deprecated flag still works and produces exactly one line on stderr.

B — both flags given, and --suite wins because it came last. Section 3's hazard: the shim does not detect a conflict, it resolves it by position.

C — after a rename, the positional reader still returns FAIL while the field name status is gone. The careless consumer is fine and the careful one is about to raise KeyError — Section 6's inversion in two values.

D — and the deprecated flag is absent from --help, so a new user cannot discover it. That is argparse.SUPPRESS doing its job, and it is why the deprecations table in Section 8 has to exist somewhere a reader can find.

11. Exercises

Exercise 1 — list your surfaces. Write down every flag, file name, column, exit code and env var your tool exposes.

Exercise 2 — loud or silent. Mark which of them would break loudly.

Exercise 3 — what you never promised. Find something you can change freely because the docs declined to promise it.

Exercise 4 — three plans. For a rename you want, cost out all three.

Exercise 5 — the deadline. Explain what the annotation adds over "be backwards compatible".

Exercise 6 — build the shim. Add a second flag writing the same dest.

Exercise 7 — one name in the body. Confirm main never learns about the old flag.

Exercise 8 — SUPPRESS it. Hide the old flag from --help and say where it is documented instead.

Exercise 9 — both flags given. Check which one wins, and decide whether to detect the conflict.

Exercise 10 — deprecated=True. Use it and record the exact stderr line.

Exercise 11 — three properties. Say why stderr, no exit-code change, and unconditional visibility all matter.

Exercise 12 — no stacklevel. Raise a DeprecationWarning from a module and run a script that calls it.

Exercise 13 — add stacklevel. Add stacklevel=2 and compare.

Exercise 14 — the real rule. State precisely when a DeprecationWarning is shown.

Exercise 15 — why the usual summary misleads. Explain what fix "hidden by default" would lead you to.

Exercise 16 — the category. Decide between FutureWarning and DeprecationWarning for your own users, and justify it.

Exercise 17 — five variants. Run the output-format table on your own CSV and both reader styles.

Exercise 18 — the only safe change. Add a column at the end and confirm nobody breaks.

Exercise 19 — the inversion. Explain why renaming a column breaks the careful consumer.

Exercise 20 — who you will not hear from. Say which consumers keep running and are now wrong.

Exercise 21 — emit both. Implement a one-release column rename.

Exercise 22 — the worst change. Explain why changing a value's meaning is undetectable by any reader.

Exercise 23 — the pass/fail wrapper. Show an exit-code change that such a wrapper survives and a kind-aware one does not.

Exercise 24 — no shim exists. Explain why an exit code cannot be deprecated the way a flag can.

Exercise 25 — two safe strategies. Name them and the property they share.

Exercise 26 — the asymmetry. Explain why an unknown code is safer than a reused one.

Exercise 27 — the table. Write your deprecations table with a removal release for every row.

Exercise 28 — three mechanisms. Justify a different mechanism for a flag, a column and a function.

Exercise 29 — account for the growth. Measure your tool before and after and say what grew.

Exercise 30 — what was never rewritten. Confirm your core logic is unchanged, and say what that proves.

12. Summary

  • Six surfaces are an interface and four can break silently — column order, exit codes, env var names, and a column rename for half its consumers — all let a wrapper keep running and start being wrong.
  • A promise you never made is the only one you can withdraw, which is why Chapter 34.6 explicitly declined to promise the printed wording.
  • The annotation makes three decisions: the new name exists, the old one keeps working, and the overlap has an end.
  • Two flags, one dest, so the body of main never learns about the rename and removal later touches one line. Hide the old one with argparse.SUPPRESS.
  • When both flags are given the last one wins — the shim resolves a conflict by position rather than detecting it.
  • Python 3.13's add_argument(..., deprecated=True) prints one line to stderr, bypasses the warnings module, leaves the exit code alone, and is therefore always visible.
  • A DeprecationWarning raised inside a library with no stacklevel is never shown. The default filters do show it — but only when it is attributed to __main__, and a bare warnings.warn is attributed to the library's own file.
  • stacklevel=2 decides whether the warning exists as far as the user is concerned, which is far more than the line-number improvement it is usually introduced as.
  • FutureWarning is shown from anywhere, so it is the honest category when your users run wrappers rather than maintain libraries.
  • Adding a column at the end is the only output change that breaks nobody.
  • Renaming a column breaks the careful consumer and spares the careless one — a KeyError in every wrapper that read by name, and silence in every wrapper that read by index and is now reporting the wrong field.
  • Changing what a value means is the worst change of all, because no reader can detect it.
  • An exit code cannot be deprecated. One integer, no second channel, and nowhere to put a warning a $? reader would see.
  • Only two exit-code strategies are safe, and both preserve every old code's meaning: an unknown code lands in a wrapper's else branch, a reused one lands in the wrong branch.
  • Every deprecation needs a removal release, written in a table in the repository — one with no deadline is simply a second interface.
  • Thirty-five per cent more lines, zero to eleven testable functions, one audience to six — and the parsing logic was never rewritten. The one real defect in the original was found by counting references to a global, not by reading.

That closes Module 34. The script that nobody wanted to touch is a tool with a contract, a test suite, a configuration, a handover document and a plan for changing it — and the next module puts all of it to work on four complete case studies.

Where this fits

Part of the Python curriculum.