Skip to content

Upgrade a dependency safely

  • Case study 2
  • 150 min
  • Optional · 1 case study needed for the certificate

What you will be able to do

Move a small project from one major version of a package to the next. Read the changelog before you install, and save today’s behaviour. Let an assistant propose the code changes. Then check each change against the real changelog and the saved behaviour, pin the new version, and write the decision down.

Case studyPortfolio pieceSynthetic data

Time
About 2.5 hours
Runs in
VS Code, Python 3.12+, Git
Skills used
  • Read a changelog
  • Deprecation warnings
  • A baseline of behaviour
  • Upgrade on a branch
  • A bounded task for an assistant
  • Review against the changelog
  • Pin and record

Unzip the starter, open the folder in VS Code, and follow the steps below. They start with a new virtual environment.

The course team wrote form-intake for this case study, under the MIT licence. Its 32 web-form submissions are invented. The package that it depends on, marshmallow, is real and widely used, and so are its releases and its changelog. The assistant session is a real recording that comes with the project, so you need no assistant and no account.

Before you startWhat to know and to have readyAbout 150 minutes

What you need to know

This case study uses the whole course on a project that is new to you. It leans most on Verify dependencies, Inspect the diff, Reproduce first, Choose meaningful checks and Document decisions. The lock file comes from Isolate dependencies in the Python course, and branches come from the Git course.

What you need on your computer

  • Python 3.12 or newer, Git, and VS Code.
  • About 60 MB of free disk space, and an internet connection for pip.
  • No account and no money. An assistant is optional: the recorded session is in the download.

Tested on macOS (Apple silicon) with Python 3.14.6 and pip 26.1.2. The Windows commands are written, not tested. The commit IDs on your computer will differ from the ones here.

Grace, the support lead, sends Amira two rejected submissions from the web form:

“Two customers pressed Help, and form-intake rejected them with ‘Not a valid URL.’ Both pages exist: https://larkfield.example#help on the home page, and https://café.larkfield.example/help on the café shop. Can you fix it?”

form-intake checks each submission of Larkfield’s web form before it reaches the help desk. It describes the rules with marshmallow, a package that checks data against a schema: a list of fields and their rules. marshmallow’s own URL rule checks the web addresses, not Larkfield’s code.

Amira reads marshmallow’s changelog. Newer releases accept both addresses: international domain names since 4.2.4, and #help straight after the domain since 4.3.1. form-intake pins 3.26.2. So the fix is an upgrade across a new major version, from 3 to 4.

Tomás has a quick plan: “Change the 3 to a 4, let the assistant fix whatever breaks, and merge it when the tests pass.” Kwame asks: “What else does version 4 change, and how will we know?”

The objective. Move form-intake to marshmallow 4.3.1 so that:

  1. Grace’s two submissions are accepted.
  2. Every other decision stays the same, unless the team chooses to change it and writes down why.
  3. Each code change answers a real entry of marshmallow’s changelog.
  4. The tests pass, with no warnings left.
  5. The new version is pinned, and the decision is recorded.
Nine steps, each with what you do and what the author's run showed. 1 Reproduce: write Grace's two page addresses as tests and the counts that she expects; with 3.26.2, 3 failed, 12 passed. 2 Read first: the warnings and the 4.0.0 changelog, mapped to our code; 7 different warnings, and with warnings as errors, 2 errors. 3 Pin today's behaviour: run the 10 time formats and the 12 submissions with 3.26.2 and keep the results; time formats 4 accepted, 6 rejected. 4 Upgrade on a branch: one line in each requirements file, install from the lock file; with 4.3.1, 2 errors during collection. 5 Ask the assistant: one bounded task; 1 call, 2 files +27 −15, 15 passed. 6 Review: each change against the changelog, and the decisions against step 3; 7 of 7 reasons match an entry, but 6 time formats changed their decision. 7 Fix what the review found: our own time rule with a test for each form; 24 passed, 1 decision changed on purpose. 8 Pin and record: one commit, 7 files, +114 −21. 9 New input: 10 new submissions, 5 accepted and 5 rejected, as predicted. Footer: the tests passed at step 5; step 6 found what no test pinned.
The nine steps of this case study, with what each one showed in the author's run.
Text description of the diagram

Nine steps, from Grace’s problem to new input. The tests pass at step 5, after the assistant’s change. The review at step 6 finds six time formats that now get another decision. No test had noticed them.

File What it does
form_intake/schema.py The rules, as a marshmallow schema, and check_submissions()
form_intake/cli.py The command: reads a JSON file, writes out/accepted.json and out/rejected.json
tests/ 13 tests of the rules and the command
data/submissions.json 12 invented submissions, among them Grace’s two
data/time-formats.json 10 ways to write the time of a submission, one submission each
data/new-submissions.json 10 more submissions, for the end
tools/compare.py Lists the submissions whose decision changed between two runs
DECISIONS.md, README.md The choices, and the rules in a table

The project is MIT, “Copyright (c) Nextia Learning”. marshmallow is MIT too, by Steven Loria and contributors. pip downloads it from PyPI; it is not in the zip. The download’s dataset.md card lists what is real and what is constructed.

  1. Download the starter project from the box at the top of this page. Make a folder ~/projects/upgrade-case-study, and unzip the file into it. You get two folders: form-intake and assistant-session.

  2. In VS Code, choose File > Open Folder, and open form-intake. Then open a terminal: Terminal > New Terminal.

  3. Make and activate a virtual environment. Then install the exact versions from the lock file:

    Terminal window
    python3 -m venv .venv
    source .venv/bin/activate
    python -m pip install -r requirements-lock.txt
  4. Run the tests and the program:

    Terminal window
    python -m pytest
    python -m form_intake data/submissions.json
    python -m form_intake --version
    Output (the last lines)
    ======================= 13 passed, 20 warnings in 0.03s ========================
    12 submissions: 3 accepted, 9 rejected.
    Saved: out/accepted.json, out/rejected.json
    form_intake 1.0.0 (marshmallow 3.26.2)
  5. Make the project a Git repository with one commit, as in the Git course:

    Terminal window
    git init
    git add -A
    git commit -m "Start"

What this tells us. The tests pass, but with 20 warnings. You read them in Part 2. Open out/rejected.json: T-4007 and T-4011 are Grace’s two submissions, rejected with "page_url": ["Not a valid URL."].

Your favourite recipe book has a new edition. Most pages are the same. But the new edition no longer has the sauce of your Sunday dinner, and it bakes the cake at a lower heat. If you cook from it without reading its foreword, Sunday’s dinner fails and the cake is pale. Nobody warns you at the stove. But the publisher did warn you: in the foreword, and in a note in the last printing of the old edition.

Tomás’s plan is to change marshmallow==3.26.2 to 4.3.1 and see. The author did exactly that, on a branch (Part 4 shows it). Not one test ran:

python -m pytest (part)
E TypeError: Field.__init__() got an unexpected keyword argument 'missing'
!!!!!!!!!!!!!!!!!!! Interrupted: 2 errors during collection !!!!!!!!!!!!!!!!!!!!

That error is the loud kind. The quiet kind is worse: a release can change what the code does, with no error at all. You find those only if you know where to look.

The idea: the version number tells you where to look

Section titled “The idea: the version number tells you where to look”

Most Python packages number their releases MAJOR.MINOR.PATCH. The scheme is called semantic versioning. A new PATCH number (4.3.0 → 4.3.1) fixes bugs. A new MINOR number (4.2 → 4.3) adds features. It may also deprecate something: it still works, but it prints a warning that it will be removed. A new MAJOR number (3 → 4) may remove or change things: a breaking release. The project’s changelog lists the changes of every release. marshmallow marks the breaking ones Backwards-incompatible, and it has an upgrading guide.

So a new MAJOR number means: read the changelog before you install. And today’s deprecation warning is the next MAJOR version’s error.

A tiny example: the releases that matter to form-intake

Section titled “A tiny example: the releases that matter to form-intake”
marshmallow's versions that matter to form-intake, from its changelog. 3.13.0: load_default replaces missing; missing still works with a warning (deprecated). 3.21.0: marshmallow.__version__ is deprecated. 3.24.0: Schema(context=...) and validators that return False are deprecated. 3.26.0: the ordered class Meta option is deprecated. 4.0.0, 2025-04-16: all of these are removed, and DateTime reads times with datetime.fromisoformat (breaking, MAJOR). 3.26.2, 2025-12-19: a security fix for the 3.x line, released after 4.0.0; form-intake pins this one (PATCH). 4.2.4, 2026-04-02: URL accepts international domain names, https://café.larkfield.example/help. 4.3.1, 2026-08-08: URL accepts a fragment after an empty path, https://larkfield.example#help. Footer: version numbers are not dates; 3.26.2 came out eight months after 4.0.0.
Four deprecations in the 3.x line, their removal in 4.0.0, and the two later fixes that Grace needs.
Text description of the diagram

A list of releases from marshmallow’s changelog. Four 3.x MINOR releases deprecated things that form-intake uses. 4.0.0 removed them all, and changed how times are read. 3.26.2, the version that form-intake pins, is a PATCH with a security fix, and it came out after 4.0.0. 4.2.4 and 4.3.1 fixed the two web addresses.

Two details in the picture matter. First, Grace’s fixes are in 4.2.4 and 4.3.1, not in 4.0.0. The author checked: 4.0.0 still rejects both addresses. Second, 3.26.2 is newer than 4.0.0 by date. A project can keep fixing an old MAJOR line for a while, for teams that have not moved yet. Use the version number, not the date, to judge what changed.

The four checks of Verify dependencies also apply to a new version of a package that you already use. It exists. You need it, for Grace’s fixes. Its licence is still MIT. And it supports your Python: the 4.1.0 changelog says “Test against Python 3.14”.

As in Reproduce first, the evidence comes before the change. Add import pytest to the imports of tests/test_schema.py, and this test at the end:

tests/test_schema.py (the new test)
@pytest.mark.parametrize(
"page_url",
["https://larkfield.example#help", "https://café.larkfield.example/help"],
)
def test_page_addresses_from_the_web_form_are_accepted(page_url):
accepted, rejected = check(page_url=page_url)
assert rejected == []
assert accepted[0].page_url == page_url

Grace also expects the counts of the whole file to change. In tests/test_cli.py, change three expected values:

  • the output: 12 submissions: 5 accepted, 7 rejected.
  • the accepted list: ["T-4001", "T-4002", "T-4007", "T-4011", "T-4012"]
  • the number of rejected submissions: 7

Then run the tests:

Terminal window
python -m pytest -q
Output (end)
FAILED tests/test_cli.py::test_cli_writes_accepted_and_rejected - AssertionEr...
FAILED tests/test_schema.py::test_page_addresses_from_the_web_form_are_accepted[https://larkfield.example#help]
FAILED tests/test_schema.py::test_page_addresses_from_the_web_form_are_accepted[https://caf\xe9.larkfield.example/help]
3 failed, 12 passed, 22 warnings in 0.04s

Three tests fail for the right reason: 'page_url': ['Not a valid URL.']. Commit them with git commit -am "Describe Grace's page addresses as tests". These tests now say what “done” means. Nobody may change them to make an upgrade pass.

The 20 warnings of the first run are the old edition’s note. List each different one:

Terminal window
python -m pytest -q 2>&1 | grep -E 'Warning:' | sed -e 's/^ *//' | sort | uniq -c
Output (the paths into .venv shortened to the file name)
1 fields.py:1753: RemovedInMarshmallow4Warning: The 'missing' argument to fields is deprecated. Use 'load_default' instead.
1 fields.py:276: ChangedInMarshmallow4Warning: Returning `False` from a validator is deprecated. Raise a `ValidationError` instead.
1 fields.py:986: RemovedInMarshmallow4Warning: The 'missing' argument to fields is deprecated. Use 'load_default' instead.
1 schema.py:129: RemovedInMarshmallow4Warning: The `ordered` `class Meta` option is deprecated. Field order is already preserved by default. Set `Schema.dict_class` to OrderedDict to maintain the previous behavior.
1 ~/projects/upgrade-case-study/form-intake/form_intake/cli.py:28: DeprecationWarning: The '__version__' attribute is deprecated and will be removed in in a future version. Use feature detection or 'importlib.metadata.version("marshmallow")' instead.
1 ~/projects/upgrade-case-study/form-intake/form_intake/schema.py:46: RemovedInMarshmallow4Warning: The 'missing' argument to fields is deprecated. Use 'load_default' instead.
1 ~/projects/upgrade-case-study/form-intake/form_intake/schema.py:64: RemovedInMarshmallow4Warning: The `context` parameter is deprecated and will be removed in marshmallow 4.0. Use `contextvars.ContextVar` to pass context instead.

The warning names say it: RemovedInMarshmallow4, ChangedInMarshmallow4. You can also ask Python to treat these warnings as errors. Then you see the first failure of version 4 before you install it:

Terminal window
python -m pytest -q -W error::DeprecationWarning
Output (part)
E marshmallow.warnings.RemovedInMarshmallow4Warning: The 'missing' argument to fields is deprecated. Use 'load_default' instead.
!!!!!!!!!!!!!!!!!!! Interrupted: 2 errors during collection !!!!!!!!!!!!!!!!!!!!

Now open the 4.0.0 section of marshmallow’s changelog and the upgrading guide. The changelog is long, and most of it does not touch form-intake. Search the project for each entry’s name: in VS Code, Edit > Find in Files, for example missing= or context. Keep only the entries that hit a line of your code. Then write down what you expect each one to do:

Each entry of the 4.0.0 changelog, the line of our code that it hits, and the prediction with 4.3.1. 'default and missing parameters ... removed': schema.py missing='other', missing=2, missing=None; TypeError when the module loads, nothing runs, not even the tests. 'Custom validators must raise a ValidationError ... Returning False is no longer supported': not_blank() returns False; silent, a subject of spaces is accepted. 'Decorated methods now receive data_key as a keyword argument': not_in_the_future(self, value); TypeError on every load. 'Remove Schema's context attribute': SubmissionSchema(context=...) and self.context['now']; TypeError on every load. 'The ordered class Meta option is removed': Meta ordered = True; silent and harmless, the field order is kept anyway. 'Remove __version__ ... attributes': cli.py marshmallow.__version__; AttributeError on every command. 'YYYY-MM-DD is now accepted as a datetime': fields.DateTime(); silent, a date without a time is accepted. 4.2.4 and 4.3.1 URL fixes: fields.Url(); Grace's two addresses are accepted. Footer: four entries will stop the program loudly; three will not, and only a test or a comparison can find them.
The changelog, reduced to the eight entries that touch form-intake, with a prediction for each.
Text description of the diagram

Eight rows. Four entries stop the program with an error: missing, data_key, context and __version__. Three change what it does without an error: a validator that returns False, the ordered option, and a date without a time. The last row is the change that Grace needs.

Here is the most important quote for this project, from the 4.0.0 changelog:

Backwards-incompatible: Use datetime.date.fromisoformat, datetime.time.fromisoformat, and datetime.datetime.fromisoformat from the standard library to deserialize dates, times and datetimes. As a consequence of this change: Time with time offsets are now supported. YYYY-MM-DD is now accepted as a datetime and deserialized as naive 00:00 AM.

form-intake has a test for exactly that: test_date_without_time_is_rejected. But a changelog lists the consequences that its authors noticed. It cannot list every input that your program meets. That is the reason for Part 3.

In Repository preparation, the baseline was the known state before a change. The tests pin the rules that someone wrote down. But a program also makes decisions that no test describes, and an upgrade can change them. So keep a second baseline: today’s decisions, on more inputs than the tests have.

data/time-formats.json has one submission for each of 10 ways to write a time. The form sends only the first one. The others are a probe: inputs chosen to find out what the code does. Run the probe and the 12 submissions with 3.26.2, and keep the results:

Terminal window
python -m form_intake data/time-formats.json --output-dir out/before/time-formats
python -m form_intake data/submissions.json --output-dir out/before/submissions
Output
10 submissions: 4 accepted, 6 rejected.
Saved: out/before/time-formats/accepted.json, out/before/time-formats/rejected.json
12 submissions: 3 accepted, 9 rejected.
Saved: out/before/submissions/accepted.json, out/before/submissions/rejected.json
Submission submitted_at 3.26.2
T-5001 2026-10-09T08:12:00 (what the form sends) accepted
T-5002 2026-10-09 08:12:00 accepted
T-5003 2026-10-09T08:12 accepted
T-5004 2026-10-09 rejected
T-5005 2026-10-09T08 rejected
T-5006 20261009T081200 rejected
T-5007 2026-W41-5T08:12 (a week date) rejected
T-5008 2026-10-09T08:12:00,5 rejected
T-5009 2026-10-09T0812 rejected
T-5010 2026-1-9T8:05 accepted

out/ is in .gitignore, so these results stay on your computer and out of the commits.

  1. Make a branch for the upgrade:

    Terminal window
    git switch -c upgrade/marshmallow-4
  2. In both requirements.txt and requirements-lock.txt, change marshmallow==3.26.2 to marshmallow==4.3.1. Nothing else in the lock file changes: on Python 3.11 and newer, 4.3.1 needs no other package.

  3. Install from the lock file:

    Terminal window
    python -m pip install -r requirements-lock.txt
    Output (part)
    Installing collected packages: marshmallow
    Attempting uninstall: marshmallow
    Found existing installation: marshmallow 3.26.2
    Uninstalling marshmallow-3.26.2:
    Successfully uninstalled marshmallow-3.26.2
    Successfully installed marshmallow-4.3.1
  4. Run the tests and the program:

    Terminal window
    python -m pytest -q
    python -m form_intake --version
    Output (end of each)
    E TypeError: Field.__init__() got an unexpected keyword argument 'missing'
    ...
    !!!!!!!!!!!!!!!!!!! Interrupted: 2 errors during collection !!!!!!!!!!!!!!!!!!!!
    2 errors in 0.07s
    category = fields.Str(missing="other", validate=validate.OneOf(CATEGORIES))
    TypeError: Field.__init__() got an unexpected keyword argument 'missing'

This is the baseline of the upgrade: the new version with no code change. You predicted it in Part 2. Notice what it does not show. Python stops at the first error when a module loads. So this one error hides the other three loud ones, and the silent ones cannot show at all.

A migration is a good task for an assistant: many small changes that the changelog describes. Make the task bounded, as in Problem and acceptance criteria. Give what changed, the evidence, the files that it may change, and the rules. Amira sent this prompt to Aider, an open-source assistant in a terminal, with the larger model of the course:

The prompt (the pytest output in the middle is shortened here)
We are upgrading marshmallow from 3.26.2 to 4.3.1 (a new major version). requirements.txt and
requirements-lock.txt already say 4.3.1, and it is installed. Now the tests fail:
```
E TypeError: Field.__init__() got an unexpected keyword argument 'missing'
...
2 errors in 0.06s
```
Task: change form_intake/schema.py and form_intake/cli.py so that the project works with marshmallow 4.3.1.
Rules:
- Do not change the tests or the requirements files. The tests describe the behaviour that we want.
The two tests of the page addresses failed with 3.26.2 on purpose: 4.3.1 should make them pass.
- Keep every other behaviour the same.
- Do not add a dependency.
- For each change, name the change in marshmallow 4 that makes it necessary.

The last rule matters most: it asks for a reason that you can check. The assistant could change schema.py and cli.py. It could only read the tests and requirements.txt, and it could run the tests. The whole session is in assistant-session/upgrade-marshmallow-4.md, with the exact prompt and reply.

What happened: one model call, about 24 seconds, 4,664 tokens in and 1,529 out. That is about US$0.025 at the price that the course team paid. Prices change, and you pay nothing to read the transcript. The assistant saw only the first error. Yet it changed all seven places, in six numbered sections, each with a reason. Two of them:

What the assistant printed (part)
### 1. Validators must raise `ValidationError`
Marshmallow 4 no longer treats a validator returning `False` as a validation failure. Keep the previous default error message.
### 2. Preserve the requirement for a time component
Marshmallow 4 uses `datetime.fromisoformat`, which also accepts date-only strings. Add a datetime field that retains the requirement for a date/time separator.

Then Aider ran the tests: 15 passed. Apply the same change to your project, and check it:

Terminal window
git apply ../assistant-session/upgrade-marshmallow-4.patch
git diff --stat -- form_intake
python -m pytest -q
python -m pytest -q -W error
ruff check .
ruff format --check .
Output
form_intake/cli.py | 5 ++---
form_intake/schema.py | 37 +++++++++++++++++++++++++------------
2 files changed, 27 insertions(+), 15 deletions(-)
............... [100%]
15 passed in 0.04s
............... [100%]
15 passed in 0.04s
All checks passed!
9 files already formatted

Every test passes, also with warnings as errors, and ruff is clean. Tomás: “Merge it.” Not yet. The tests were written for 3.26.2, and they check only what someone thought to write down.

Part 6: review each change against the changelog

Section titled “Part 6: review each change against the changelog”

Open the diff with git diff, or in the Source Control view of VS Code. Take one change at a time, as in Inspect the diff. Ask two questions of each one. Does its reason match a real entry of the changelog? And does it keep the behaviour that the objective asks for?

The assistant's seven changes, checked against the changelog and the saved results. Recorded session, real: 1 call, 2 files, +27 −15, all 15 tests passed. missing= to load_default=: matches 'default and missing parameters ... removed'; accept, the defaults test passes. not_blank() raises ValidationError: matches 'Returning False is no longer supported'; accept, same message as 3.26.2, 'Invalid value.'. **kwargs on the @validates method: matches 'receive data_key as a keyword argument'; accept. context to SubmissionSchema(now=...): matches 'Remove Schema's context', and the changelog suggests contextvars; accept and record why, simpler and no experimental API. Meta.ordered removed: matches 'The ordered class Meta option is removed'; accept. importlib.metadata.version(): matches 'Remove __version__', and 3.26.2's own warning said so; accept, --version prints 4.3.1. SubmissionDateTime, a date needs T or a space: matches 'YYYY-MM-DD is now accepted'; change it, the saved results show 5 forms newly accepted and 1 rejected. Footer: every reason matched a real changelog entry; the problem was a behaviour that no test pinned.
Seven changes, seven real changelog entries. Six are accepted; the date check changes after the comparison with 3.26.2.
Text description of the diagram

Seven rows, one per change. All seven reasons match a real entry of the changelog: in this recording, the assistant invented no API and no entry. Five changes are accepted as they are. One, the context, is accepted with a recorded reason. The last one, the date check, passes its test but fails the comparison with 3.26.2.

The context. marshmallow 4 removed Schema(context=...). The changelog and the guide suggest Python’s contextvars, or marshmallow’s new Context class, which they call experimental. The assistant did something else: it gave the schema a constructor argument, SubmissionSchema(now=now). That is plain Python, easy to test, and it avoids an experimental API. Accept it, and write the reason down in Part 8. A reviewer who only compares the diff with the guide would call it wrong. It is a choice.

The date check. The reason is right: marshmallow 4 accepts a date alone. But is “every other behaviour the same”? Compare the decisions with the ones that you saved in Part 3:

Terminal window
python -m form_intake data/time-formats.json --output-dir out/after/time-formats
python tools/compare.py out/before/time-formats out/after/time-formats
Output
10 submissions: 8 accepted, 2 rejected.
Saved: out/after/time-formats/accepted.json, out/after/time-formats/rejected.json
Same decision: 4
Accepted before, rejected now: T-5010
Rejected before, accepted now: T-5005, T-5006, T-5007, T-5008, T-5009

Six of the ten decisions changed. The assistant’s check stops a date alone: the one case that the changelog names and the test checks. But datetime.fromisoformat accepts more than marshmallow 3 did. It accepts an hour without minutes (2026-10-09T08), no hyphens (20261009T081200), a week date, a comma, and no colon. And it refuses one-digit months and hours (2026-1-9T8:05), which 3.26.2 accepted.

Did the assistant get it wrong? It did what the evidence in front of it asked: the tests pass. The behaviour that changed was not written down anywhere, so neither the tests nor the assistant could see it. Your saved baseline could.

Larkfield’s rule for the time is in the README: a date and a time. So write that rule in the code, instead of leaving it to the library. In form_intake/schema.py, add import re at the top, and change the assistant’s SubmissionDateTime:

form_intake/schema.py: the assistant's version → the reviewed version
# Our rule for submitted_at: a date and a local time, as the form sends them.
# marshmallow 4 reads times with datetime.fromisoformat, which also accepts a
# date alone and other ISO 8601 forms (changelog, 4.0.0). So the rule is ours.
LOCAL_TIME = re.compile(r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?")
class SubmissionDateTime(fields.DateTime):
"""Require a time component as well as a date."""
"""A date and a local time: YYYY-MM-DD, T or a space, HH:MM, seconds if any."""
def _deserialize(self, value, attr, data, **kwargs):
if isinstance(value, str) and "T" not in value and " " not in value:
if not isinstance(value, str) or not LOCAL_TIME.fullmatch(value):
raise self.make_error("invalid", input=value, obj_type=self.OBJ_TYPE)
return super()._deserialize(value, attr, data, **kwargs)

The pattern reads: four digits, two, two, then T or a space, then hours and minutes, and seconds if they are there. A custom field that changes _deserialize is the way that marshmallow’s own guide writes one.

Then pin each form with a test, so that the next upgrade cannot change it without a red test. Add to tests/test_schema.py:

tests/test_schema.py (two new tests)
@pytest.mark.parametrize(
"submitted_at",
["2026-10-09T08:12:00", "2026-10-09 08:12:00", "2026-10-09T08:12"],
)
def test_date_and_local_time_are_accepted(submitted_at):
accepted, _ = check(submitted_at=submitted_at)
assert accepted[0].submitted_at == datetime(2026, 10, 9, 8, 12)
@pytest.mark.parametrize(
"submitted_at",
[
"2026-10-09", # a date alone: marshmallow 4 accepts it
"2026-10-09T08", # the hour alone: marshmallow 4 accepts it
"20261009T081200", # ISO 8601 without hyphens: marshmallow 4 accepts it
"2026-W41-5T08:12", # a week date: marshmallow 4 accepts it
"2026-10-09T08:12:00+02:00", # a time zone: 3.26.2 crashed on it
"2026-1-9T8:05", # one-digit parts: 3.26.2 accepted them
],
)
def test_other_time_formats_are_rejected(submitted_at):
assert problems(submitted_at=submitted_at) == {
"submitted_at": ["Not a valid datetime."]
}

Run everything again, and compare both inputs with the baseline:

Terminal window
python -m pytest -q -W error
ruff check .
ruff format --check .
python -m form_intake data/time-formats.json --output-dir out/after/time-formats
python tools/compare.py out/before/time-formats out/after/time-formats
python -m form_intake data/submissions.json --output-dir out/after/submissions
python tools/compare.py out/before/submissions out/after/submissions
Output (without the Saved: lines)
........................ [100%]
24 passed in 0.05s
All checks passed!
9 files already formatted
10 submissions: 3 accepted, 7 rejected.
Same decision: 9
Accepted before, rejected now: T-5010
Rejected before, accepted now: none
12 submissions: 5 accepted, 7 rejected.
Same decision: 10
Accepted before, rejected now: none
Rejected before, accepted now: T-4007, T-4011

What this tells us. Of the 22 saved decisions, three changed, each on purpose. T-4007 and T-4011 are Grace’s two submissions. T-5010, 2026-1-9T8:05, is now rejected, because Larkfield’s rule asks for two digits; the form never sends one. That is a choice, so it goes into DECISIONS.md.

Four versions of form-intake ran, each in a fresh folder with a fresh environment from its own lock file. All four got the same inputs: the 12 submissions and the 10 time formats. One measure: how many of the 22 decisions differ from Larkfield’s written rule. The rule is the README’s table, input by input: Grace’s two pages accepted, a date and a time with two-digit parts, and so on.

Version marshmallow Tests Does it run? Decisions that differ from the rule
A. Stay on 3.26.2 3.26.2 3 failed, 12 passed, 22 warnings yes 3: Grace’s two, and T-5010
B. Change the number only 4.3.1 2 errors no: TypeError –
C. The assistant’s change 4.3.1 15 passed yes 5: T-5005 to T-5009
D. The reviewed change 4.3.1 24 passed yes 0
The same 22 inputs for each version. Version B is not shown: it does not start. The assistant's change passes all its tests and still differs on five inputs.
Text description of the chart

Of 22 decisions, staying on 3.26.2 differs from the rule on 3. The assistant’s change differs on 5, and the reviewed change on 0. Changing only the version number gives no decisions at all: the program does not start.

What this tells us.

  • Staying is safe for the time formats and wrong for Grace. Her two customers stay rejected, and the warnings remain.
  • Changing the number only is the worst option. Nothing runs, and the first error hides the others.
  • The assistant’s change fixes everything that the tests describe, with real reasons from the changelog. Yet it makes five decisions that nobody chose. “All tests pass” was true, and not enough.
  • The reviewed change meets the objective. Be honest about why it scores zero: its time rule is the written rule, put into code. The measure shows that the code now does what the README says, on these 22 inputs. It cannot show what the form might send one day.

Recommendation: version D. It keeps the assistant’s six correct changes and replaces one. Each change has a changelog entry. Three decisions changed on purpose, and each one is written down.

Both requirements files already pin marshmallow==4.3.1, and nothing else in them changed. Now write the decision. Copy decision 2 from the finished project’s DECISIONS.md, or write your own with the same parts. Here is the start:

DECISIONS.md (part of decision 2)
## 2. Upgrade marshmallow from 3.26.2 to 4.3.1
**Why.** Grace's team saw two real submissions rejected with "Not a valid
URL.": `https://larkfield.example#help` and
`https://café.larkfield.example/help`. Both are fixed in marshmallow 4
(changelog: 4.2.4, "`URL` and `Email` accept Internationalized Domain Names";
4.3.1, "`URL` accepts a fragment that follows an empty path"). The 3.x line
has had no release since 3.26.2.

Next comes a table: each change in our code, next to its changelog entry. Then how it was checked (24 tests, no warnings, the two comparisons). Then what was changed or not taken (the date check, the experimental Context). Last comes the time-zone crash that the upgrade found. In README.md, write the time rule into the rules table. Then commit:

Terminal window
git add -A
git commit -m "Upgrade marshmallow to 4.3.1"
git diff --stat main
Output of the last command
DECISIONS.md | 48 +++++++++++++++++++++++++++++++++++++++++++++++-
README.md | 8 +++++---
form_intake/cli.py | 5 ++---
form_intake/schema.py | 44 ++++++++++++++++++++++++++++++++------------
requirements-lock.txt | 2 +-
requirements.txt | 2 +-
tests/test_schema.py | 26 ++++++++++++++++++++++++++
7 files changed, 114 insertions(+), 21 deletions(-)

One commit holds the whole upgrade: the pin, the code, the tests and the record. If the team ever has to undo it, git revert takes all of it back at once.

Common mistake

The code moved to version 4, the pin did not

Symptom: a colleague ran python -m pip install --upgrade marshmallow in his own environment. He committed the new code, and python -m pytest printed 24 passed for him. Kwame makes a fresh environment from the lock file, and gets:

FAILED tests/test_cli.py::test_cli_writes_accepted_and_rejected - AssertionEr...
FAILED tests/test_schema.py::test_page_addresses_from_the_web_form_are_accepted[https://larkfield.example#help]
FAILED tests/test_schema.py::test_page_addresses_from_the_web_form_are_accepted[https://caf\xe9.larkfield.example/help]
3 failed, 21 passed in 0.03s

Cause: pip install --upgrade changed one computer, not the project. Both requirements files still say marshmallow==3.26.2, so every fresh environment installs the old version. The new code runs on it, but it rejects Grace’s addresses again.

Fix: an upgrade is a change to the requirements files, in the same commit as the code. Change the lock file, then install from it, never with --upgrade. Kwame’s fresh environment is the check: it must pass too.

data/new-submissions.json has 10 submissions that no step has used yet. First, predict each decision from the README’s rules, on paper. Then run it:

Terminal window
python -m form_intake data/new-submissions.json
Output
10 submissions: 5 accepted, 5 rejected.
Saved: out/accepted.json, out/rejected.json
Show each decision
Submission What is special Decision
T-4013 a normal page address accepted
T-4014 https://larkfield.example#contact accepted: the 4.3.1 fix
T-4015 https://müller.larkfield.example/help accepted: the 4.2.4 fix
T-4016 an empty subject rejected: subject “Invalid value.”
T-4017 quinn.example.com rejected: “Not a valid email address.”
T-4018 no category, priority or page accepted: other, 2, no page
T-4019 2026-10-10, a date alone rejected: “Not a valid datetime.”
T-4020 priority "high" rejected: “Not a valid integer.”
T-4021 a normal page address accepted
T-4022 the year 2062 rejected: “The time is in the future.”

If your prediction differs from the output, find out which one is wrong: the rule in the README, your reading of it, or the code. T-4016 and T-4019 test two of the silent changes of Part 2. With 4.3.1 and the old validator or the plain DateTime field, both would be accepted.

  • Read before you install. The 20 warnings and the 4.0.0 changelog predicted every loud error. When the upgrade ran, only one of them showed.
  • The silent changes are the real work. Four breaks stopped the program, and three did not. Two of the silent ones had a test, and ordered is harmless. But the time change was wider than its changelog entry, and only the saved baseline found the rest.
  • The assistant was right about the changelog. In this recording, all seven reasons matched real entries. It found the hidden breaks from one error message and the code. Its one weak change followed the only evidence that it had. A different run, model or assistant can do better or worse: check every time.
  • Limits. One package, one upgrade, one recorded session. The probe has 10 time formats, not every input that a web form can send. Tested on macOS only.
  • Install what you checked. Install from the lock file, with exact versions. Read the release page on pypi.org before you trust it: who published it, when, and with which licence.
  • Keep the assistant inside the task. The prompt allowed two files, and no change to the tests or the requirements. An assistant that edits the tests until they pass has removed the evidence.
  • Send the least data. The prompt had test output and code, and no customer data. Never paste real submissions into an assistant. Use invented ones, as here.
  • Name the dependency’s licence in the record. A new MAJOR version can change it. marshmallow 4.3.1 is still MIT.
  • Say what the assistant did. The decision record names the recorded session, and the change that the team made to it.

Every team must upgrade its dependencies, and nobody sees the work when it goes well. This project shows that you can do it with care. You read first, save the behaviour, use an assistant, and review it with evidence.

What to put in the repository:

form-intake/
README.md what it does, how to run and check it, the rules
DECISIONS.md decision 2: why, each change and its changelog entry, the checks, what you changed
LICENSE MIT
form_intake/ the schema and the command
tests/ 24 tests, among them the time formats
data/ the three invented input files
tools/ compare.py
assistant/ the transcript and the patch, with a note on what you kept
  1. Make a new repository from your project folder, with its history: Start, Grace’s tests, and the upgrade. Push it to GitHub if you have an account; it is not required.
  2. In the README, add a short section, The marshmallow 4 upgrade. Give the comparison table of the four versions, and link DECISIONS.md.
  3. Check that a fresh clone with a fresh environment from the lock file prints 24 passed, as Kwame’s did.

The other case study of this course is Fix a bug in an unfamiliar codebase.

Check your understanding

After the assistant's change, all 15 tests passed, also with warnings as errors. Why was that not enough to merge?

Show the answer

The tests check only the rules that someone wrote down for 3.26.2. marshmallow 4 reads times with datetime.fromisoformat. It accepts forms that 3.26.2 rejected, and no test covered them. The saved decisions of Part 3 showed that 6 of the 10 time formats changed their decision. “The tests pass” says that the written rules hold. It does not say that nothing else changed.

Check your understanding

With 4.3.1 and no code change, pytest showed only one error: 'missing'. How many breaks did the project really have, and how could you know before the upgrade?

Show the answer

Seven: missing (in three fields), the validator that returns False, the data_key argument, context, ordered, __version__, and the time format. Python stops at the first error when a module loads, so one error hides the others, and the silent ones give no error. You could know before the upgrade from three sources. The 20 warnings of 3.26.2 named most of them. pytest -W error::DeprecationWarning showed the first error. And the 4.0.0 changelog, mapped to the code, listed them all.

Check your understanding

The changelog suggests contextvars or the experimental Context class to replace Schema(context=...). The assistant used a constructor argument instead. Is the change wrong?

Show the answer

No. The changelog says what was removed and suggests one way to replace it; it does not forbid others. SubmissionSchema(now=now) is plain Python. It keeps the behaviour (all tests pass), and it avoids an API that the changelog itself calls experimental. It is a choice, so it goes into DECISIONS.md with its reason. A review checks that a change is correct and explained, not that it copies the guide.

Recap

  • A new MAJOR version may remove or change things. Read the changelog and the warnings before you install.
  • Write the user’s problem as tests first. Save today’s decisions on more inputs than the tests have.
  • Upgrade on a branch, in both requirements files, and install from the lock file.
  • Give the assistant a bounded task, and ask for a reason for each change. In this recording, all seven reasons matched the changelog.
  • Review each change against the changelog and the saved decisions. The assistant’s version passed every test and still changed six decisions. The reviewed one changed three, each on purpose.
  • Pin, record and commit the upgrade as one change.

Next, the final assignment: add one feature and repair one defect in a small Python project, with the evidence that this course has taught.

Next: Final assignment