Input That Becomes Code · practice
Limit and Normalize Every Field
validate_book lets the right keys through. It says nothing yet about the values under them. Each of these three bodies comes back from it as a book:
{"title": "(200 spaces)", "author": "A"}
{"title": "T", "author": "A", "year": true}
{"title": "T", "author": "A", "tags": ["tag", "tag", (500 of them)]}
Send the second or the third to your server and it answers 201: a book whose year is true, and a book with 500 tags. Only the first is stopped, by the old book_error, which knows about blank titles and little else.
This lesson gives every field a rule, puts all the rules in validate_book, and retires book_error.
Normalize, then check, then use what you checked
Start with the title. Two rules come to mind: remove the white space around it with strip(), and allow at most 200 characters. The order of the two decides what gets stored.
| The title that was sent | Measure, then strip | Strip, then measure |
|---|---|---|
| 200 spaces | Passes at 200 characters, and an empty title is stored | Refused: nothing is left |
| 200 letters and five spaces | Refused for its 205 characters | Accepted: 200 letters |
The middle column gets both cases wrong, because it measured one text and stored another. So the rule has three steps, in this order:
Normalize. Bring the
into the one form you want to store. Check the normalized value.
Use what you checked. The normalized value goes into the book. The value the client sent is not looked at again.
A validator checks len(title) <= 200 and then stores title.strip(). A client sends 200 spaces. What is stored?
Two strings that look the same
Stripping is one kind of normalizing. Text needs a second kind. The sample book Café Society has an é in its title, and Unicode has two ways to write that letter: as one character, or as a plain e followed by a combining accent, a character that attaches itself to the letter before it. On screen the two look the same. In this block, ́ is the combining accent:
import unicodedata
typed = "Café Society"
stored = "Café Society"
print(typed == stored, len(typed), len(stored))
print(unicodedata.normalize("NFC", typed) == stored)
It prints False 13 12 and then True. The two titles are different ==, in, and len all tell them apart, and a client may send either one.
unicodedata.normalize("NFC", text) returns the text in one agreed form, called NFC, in which a letter and its accent are joined into one character. unicodedata is part of the standard library and is already imported at the top of reading_server.py. This is the one new call of the lesson. For every text value, the first of the three steps becomes unicodedata.normalize("NFC", value).strip().
In this code, a writes the accent as a separate combining character after the e, and b writes é as one character. What does it print?
import unicodedata
a = "Café"
b = "Café"
print(a == b, len(a), len(b), unicodedata.normalize("NFC", a) == b)
A limit for every field
| Field | Rule |
|---|---|
title, author | Required. Text of 1 to 200 characters, counted after normalizing and stripping |
year | Optional. An int from 1 to 2100, and not a bool |
status | Optional. One of to-read, reading, done, exactly as sent |
tags | Optional. A |
The numbers are constants near the top of the file: TEXT_LIMIT, FIRST_YEAR, LAST_YEAR, MOST_TAGS, and TAG_LIMIT, beside the set STATUSES. Two of the rules have a trap in Python.
The first is the year. true arrives in Python as True, and isinstance(True, int) is True: a bool counts as an int, and True + 1998 is 1999. A test for int alone accepts it. So ask about bool first: if isinstance(year, bool) or not isinstance(year, int). The same line refuses the text "1999" and the float 1999.0.
The second is the status. status not in STATUSES looks complete. But a client can send a list, and ["done"] in STATUSES raises TypeError passes straight by your except ValueError, and the client’s mistake becomes a 500. Test isinstance(status, str) first. The status is not stripped: it is a code from a fixed list, not text a person
Why does the year check refuse True before it asks isinstance(year, int)?
Each tag goes through the same three steps as a title, and the test for a repeat compares the normalized tags. So "x" and " x " are one tag twice, and so is one word sent in both Unicode forms.
After normalizing and checking a tag, which value goes into the book?
Your task
In reading_server.py:
Write a helper first,
clean_text(value, name, limit). It raisesValueErrorwhenvalueis not astr. It normalizes to NFC and strips. It raisesValueErrorwhen the result is shorter than 1 or longer thanlimitcharacters. Then it returns the result.nameis for the messages, as inf"{name} must be text".Grow
validate_book. Keep the key checks of Lesson 1 at the top. Then build the newfield by field, by the table: titleandauthormust be there and go throughclean_text;year,status, andtagsare checked when they are there. Every refusal is aValueErrorwhose message names the field.In
book_request_error, remove the call ofbook_error.validate_bookreports a missing title now. You can delete thebook_errortoo, because nothing else calls it.
Then try it in the
python -c "import reading_server; print(reading_server.validate_book({'title': ' Dune ', 'author': 'Frank Herbert', 'tags': [' sand ']}))"
It prints {'title': 'Dune', 'author': 'Frank Herbert', 'tags': ['sand']}: the book that will be stored is the one that was checked.
Two limits of all this. NFC makes text equal only where the difference was a letter joined with its accent or written apart. Letters from different alphabets that look alike stay different. A stronger form, NFKC, folds more and also changes what some text means, so a real service chooses with care. And a title may still hold any character, including a line break in the middle of it. Such a title passes every rule in this lesson.
Then press Check my work. The next lesson is about why a title that passed can still do harm.
Task
Give every field of a book a rule, in validate_book in reading_server.py.
Keep what Lesson 1 wrote: a body that is not a owner and id, and any key outside ALLOWED_FIELDS raise
| Field | Rule | Returned as |
|---|---|---|
title, author | Required. A str of 1 to TEXT_LIMIT (200) characters, counted after unicodedata.normalize("NFC", value).strip() | The normalized, stripped text |
year | Optional. An int from FIRST_YEAR (1) to LAST_YEAR (2100). True and False are refused | The number |
status | Optional. A str that is in STATUSES, compared as it was sent | The text |
tags | Optional. A list of at most MOST_TAGS (10) tags. Each tag is a str of 1 to TAG_LIMIT (30) characters, counted like a title. The same tag twice, compared after normalizing and stripping, is refused | A new |
Every refusal is a ValueError whose message names the field: title, author, year, status, or the word tag. The wording is yours. A helper clean_text(value, name, limit) that does the three steps for one text value keeps the
In book_request_error, remove the call of book_error. create_book and replace_book keep looping over what validate_book returns.
Then press Check my work. It calls validate_book() with bodies you have not seen and compares each accepted value exactly: text with white space around it, 200 letters followed by five spaces, text typed with a combining accent, the years 1 and 2100, each status, an empty list of tags, ten tags, and a tag of 30 characters. These must be refused with ValueError: 200 spaces, 201 characters, an empty or missing title or author, a number as title; True, False, 0, 2101, -5, "1999", and 1999.0 as the year; a status in capitals, an empty one, a number, and a list; tags that are text or a dict, eleven tags, a tag of 31 characters, an empty or blank tag, a list or a number as a tag, and the same tag twice in two forms. It then calls handle() with its own users and tokens: the stored book must hold the normalized values, and a value of the wrong type must get 422, never 500. Nothing from your