SP. Secure Programming
Five habits that keep a program working correctly when input is wrong, files are missing, or someone is trying to break it.
What “secure” means here
When people hear security, they think of hackers. That is part of it. But most security problems start with ordinary mistakes: a program trusts a value it should have checked, hides an error it should have reported, or forgets a case it should have handled. Those mistakes corrupt data, crash programs, and expose private information whether or not anyone is attacking.
A secure program keeps doing the right thing when conditions are bad. The input is mistyped. The file is missing. The number is negative. Someone is deliberately feeding it garbage. In every case, the program either handles the situation or fails clearly and safely.
The habits in this lab come from the NSA’s secure programming principles. They apply to any program, including the small command-line application your team is building.
The principles
- Validate your input. Check every value that enters your program before you use it.
- Handle exceptions at the lowest sensible level. Catch errors where you can fix them; let the rest propagate.
- Avoid information leakage through error messages. Tell the user what went wrong without revealing how your program works inside.
- Cover all cases. Every
if chain and match statement needs a path for input you did not expect. - Heed static analysis warnings. Your tools can find many of these problems before you run the code.
Each page uses the same small example: a command-line inventory tracker that lets a user add items, remove items, list what is in stock, and save to a file. It is close to what your team project does, so the fixes should look familiar.
How this lab connects to the course
The Information Literacy assignment asks you to research one of these principles and decide whether it applies to your team’s project. These pages are the starting point for that research.
1 - Validate Your Input
Check every value that enters your program before you use it.
Why this matters
Everything that comes into your program from outside is untrusted until you check it. input() always returns a string, and the user can type anything: letters where you expected a number, a negative quantity, a blank line, or ten thousand characters. If your program reads files, those files may be edited to contain something malicious or unexpected.
If you use a bad input value without checking it, one of three things happens. The program crashes with a traceback. The program keeps running with bad data, and the problem shows up later somewhere confusing. Or, if the value reaches something powerful like a file path or a database query, the user can make your program do things you never intended. Validation stops all three.
The rule: Every value that enters the program is checked for type, range, and format before it is used. Values that fail the check are rejected with a clear message. The program never “repairs” bad input by guessing.
Benefits
- Errors are caught at the door, where you know exactly what went wrong and can tell the user.
- The rest of the code can assume its inputs are sane, which makes it simpler.
- Bad data never reaches your files or your calculations.
- Deliberate misuse (a path like
../../secrets.txt, a quantity of -1000) is stopped at the same place as honest mistakes.
Red flags (violations)
int(input(...)) or float(input(...)) with no try/except around it.- A menu that reads a choice and uses it without checking that it is one of the options.
- A quantity, price, or age that is converted to a number but never checked for a sensible range.
- A filename from the user passed straight to
open(). - Code that “fixes” bad input silently, such as
abs(quantity) to make a negative number positive.
Example 1 - A number that is not a number
Problematic code
def ask_quantity() -> int:
return int(input("Quantity: "))
Type five and the program dies:
ValueError: invalid literal for int() with base 10: 'five'
Type -3 and the program accepts it, and now the inventory has a negative count.
Fixed with type and range checks
def ask_quantity() -> int:
"""Ask until the user enters a whole number greater than zero."""
while True:
raw = input("Quantity: ").strip()
try:
quantity = int(raw)
except ValueError:
print(f"'{raw}' is not a whole number. Try again.")
continue
if quantity <= 0:
print("Quantity must be greater than zero. Try again.")
continue
return quantity
Why this is better: The conversion is wrapped in try/except ValueError, so a bad string produces a message instead of a crash. The range check rejects zero and negatives. The loop keeps asking until the input is valid, so every caller of ask_quantity() can trust the number it gets back.
Problematic code
def main_menu():
print("1. Add item 2. Remove item 3. List items 4. Quit")
choice = input("Choose: ")
if choice == "1":
add_item()
elif choice == "2":
remove_item()
elif choice == "3":
list_items()
elif choice == "4":
save_and_quit()
Type 7 and nothing happens. Type 1 with a trailing space and nothing happens. The user has no idea why.
Fixed by checking against the valid set
MENU_CHOICES = {"1", "2", "3", "4"}
def ask_menu_choice() -> str:
"""Ask until the user enters one of the menu numbers."""
while True:
choice = input("Choose: ").strip()
if choice in MENU_CHOICES:
return choice
print(f"'{choice}' is not an option. Enter 1, 2, 3, or 4.")
def main_menu():
print("1. Add item 2. Remove item 3. List items 4. Quit")
choice = ask_menu_choice()
if choice == "1":
add_item()
elif choice == "2":
remove_item()
elif choice == "3":
list_items()
else:
save_and_quit()
Why this is better: The valid choices are written down once. The input is stripped of stray whitespace and checked against that set before the if chain runs. The if chain can now use a plain else for the last option because every other value has already been rejected.
Example 3 - A filename from the user
Problematic code
def load_inventory():
filename = input("Load which file? ")
with open(filename) as f:
return f.read()
The user can type ../../.ssh/id_rsa or /etc/passwd and your program will read it for them.
Fixed by restricting what is allowed
from pathlib import Path
DATA_DIR = Path("data")
def ask_data_filename() -> Path:
"""Ask for a filename inside the data folder. Reject paths that leave it."""
while True:
name = input("Load which file? ").strip()
if not name or "/" in name or "\\" in name or name.startswith("."):
print("Enter a plain filename with no folders, for example inventory.txt.")
continue
path = DATA_DIR / name
if not path.is_file():
print(f"There is no file named {name} in the data folder.")
continue
return path
Why this is better: The program decides where files live. The user only supplies a name, and anything that looks like a path is rejected. The check that the file exists happens here, where the program can ask again, instead of deep inside the loading code.
How to apply this rule
- Find every entry point. Search your project for
input(, open(, sys.argv, and any place a file is read. Each one is a place where untrusted data enters. - Decide what valid means. For each entry point, write down the type, the allowed range or set of values, and the format.
- Check before you use. Convert and check the value in one small function, like
ask_quantity(). Return only valid values. - Reject, do not repair. When a value fails, tell the user what was wrong and ask again. Do not guess what they meant.
- Keep the check near the input. The function that reads the value is the right place to validate it. Code further in should be able to trust what it receives.
Knowledge Check
- A program reads a price with
price = float(input("Price: ")). What happens when the user types $5?- Python strips the dollar sign and stores 5.0
- The program raises
ValueError and crashes unless the call is inside a try block price is set to None- The program asks again automatically
Answer
2 — `float("$5")` raises `ValueError`. Without a `try`/`except`, the exception propagates up and ends the program with a traceback. - Which check is missing from this code?
age = int(input("Age: ")) inside a try/except ValueError that prints a message and asks again.- A type check
- A range check
- A format check for letters
- Nothing is missing
Answer
2 — the `try`/`except` handles the type, but `-40` and `9999` are both accepted as valid ages. A range check is still needed. - Why is it a bad idea to “fix” a negative quantity with
quantity = abs(quantity)?abs() is slow- The user typed something wrong, and the program is guessing what they meant instead of asking
- Negative numbers cannot be stored in Python
- It is fine; this is the recommended approach
Answer
2 — silently changing bad input hides the mistake from the user. If they meant to type 10 and typed -1, the program now records 1 and nobody knows. - A user is asked for a filename and types
../config/passwords.txt. What is the safest response?- Open it, since the user asked for it
- Strip the
../ and open config/passwords.txt - Reject any name containing a path separator and ask again
- Open it only if it exists
Answer
3 — the program should control which folder files come from. Any input that tries to leave that folder is rejected outright. Stripping the `../` still lets the user reach a folder you did not intend.
Next up
Validation stops bad values at the door. The next principle is about what to do when something still goes wrong: handle exceptions at the lowest sensible level.
2 - Handle Exceptions at the Lowest Sensible Level
Catch errors where you can fix them. Let the rest propagate.
Why this matters
Validation catches bad input, but things still go wrong. A file is missing. The disk is full. A value that passed the check at the door is still wrong for a calculation three functions later. Python reports these problems with exceptions.
Where you catch an exception decides what your program can do about it. Catch it too low, in a function that has no way to fix the problem, and the best you can do is hide it. Catch it too high, in main(), and you have lost the context that tells you what to do. Catch it nowhere, and the program ends with a traceback, which for a user is a crash and for an attacker is a free look at your code.
A hidden error is a security problem in its own right. If save_inventory() fails and nobody notices, the user walks away believing their data was saved. If remove_item() quietly ignores a bad name, the inventory slowly drifts away from reality. Part of writing a secure programs is to make sure it never fails silently.
The rule: Catch an exception in the function that can do something useful about it. If the function cannot fix the problem or give the user a sensible choice, let the exception propagate to a caller that can. Never catch an exception just to make it go away.
This principle is also design rule 4 in the Low-level Design lab, which has a class recording and more examples.
Benefits
- Every error is either handled or reported. Nothing disappears.
- Each function has a clear contract: “I handle these problems, and I raise these.”
- The code that talks to the user is the code that decides what the user sees.
- When something does fail, the traceback points at the real cause.
Red flags (violations)
except: or except Exception: followed by pass, return None, or return [].- A
try block that wraps twenty lines when only one of them can raise. print() inside a low-level function that should be returning or raising, because that is the only way the author could think to report a problem.- A function that catches an exception and immediately raises a new one with no added information.
main() wrapped in one giant try/except Exception that prints “Something went wrong.”
Example 1 - Swallowing an error
Problematic code
def save_inventory(items: dict, filename: str) -> None:
try:
with open(filename, "w") as f:
for name, quantity in items.items():
f.write(f"{name},{quantity}\n")
except:
pass # If it fails, oh well
If the folder does not exist, or the disk is full, or items contains something that is not a string, the function returns normally. The user sees “Saved!” and the data is gone.
Fixed by letting the error propagate
def save_inventory(items: dict, filename: str) -> None:
"""Write the inventory to filename. Raises OSError if the file cannot be written."""
with open(filename, "w") as f:
for name, quantity in items.items():
f.write(f"{name},{quantity}\n")
def save_and_quit(items: dict) -> None:
try:
save_inventory(items, "data/inventory.txt")
print("Saved.")
except OSError as e:
print("Could not save the inventory. Your changes are still in memory.")
print(f"Reason: {e.strerror}")
# Give the user a chance to fix the problem and try again
Why this is better: save_inventory() has no way to fix a disk problem, so it does not try. It lets OSError propagate. save_and_quit() is talking to the user, so it is the right place to catch the error, explain it, and offer a way forward. The try block is small and catches one specific exception.
Example 2 - Handling at the right level
Problematic code
def main():
items = {}
try:
while True:
choice = ask_menu_choice()
if choice == "1":
name = input("Item name: ")
quantity = int(input("Quantity: "))
items[name] = quantity
elif choice == "2":
name = input("Item name: ")
del items[name]
elif choice == "4":
save_inventory(items, "data/inventory.txt")
break
except Exception as e:
print(f"Error: {e}")
One try around the whole program. A typo in a quantity, a misspelled item name, and a full disk all land in the same except, and all of them end the program. The user loses everything they typed.
Fixed by handling each error where it can be fixed
def ask_quantity() -> int:
while True:
try:
return int(input("Quantity: "))
except ValueError:
print("Enter a whole number.")
def remove_item(items: dict) -> None:
name = input("Item name: ").strip()
try:
del items[name]
except KeyError:
print(f"There is no item named '{name}'.")
def main():
items = {}
while True:
choice = ask_menu_choice()
if choice == "1":
name = input("Item name: ").strip()
items[name] = ask_quantity()
elif choice == "2":
remove_item(items)
elif choice == "4":
try:
save_inventory(items, "data/inventory.txt")
break
except OSError as e:
print(f"Could not save: {e.strerror}. Fix the problem and choose 4 again.")
Why this is better: A bad quantity is fixed by asking again, inside ask_quantity(). A missing item name is reported, inside remove_item(), and the menu continues. A save failure is reported where the user can act on it, and the loop keeps running so nothing is lost. There is no except Exception anywhere, so a real bug still produces a traceback the developer can read.
How to apply this rule
- Ask what you can do about it. Before writing
except, decide what the function will do with the error. If the answer is “nothing,” do not catch it. - Catch specific exceptions.
except ValueError, except FileNotFoundError, except KeyError. Never a bare except:. - Keep
try blocks small. Wrap only the line or two that can raise. Then the except can be specific about what went wrong. - Let low-level functions raise. A function that reads or computes should raise on failure and say so in its docstring. The caller decides what to tell the user.
- Add context when you re-raise.
raise ValueError(f"Bad line {n} in {filename}") from e keeps the original cause and adds what you know.
Knowledge Check
load_inventory() opens a file and gets FileNotFoundError. It has no way to create the file. What should it do?- Return an empty dictionary so the program can continue
- Print “File not found” and return
None - Let the exception propagate to the caller
- Catch it with
except: and pass
Answer
3 — the function cannot fix the problem, so it should not hide it. The caller, which may be able to offer a different file or start fresh, decides what to do.- Which of these is the strongest sign that errors are being swallowed?
- A docstring that says “Raises OSError”
except KeyError: print("No such item") inside a menu functionexcept Exception: return []- A
try block around a single int() call
Answer
3 — catching every exception and returning a default hides the failure completely. The caller cannot tell an empty result from a broken one. - Why is a single
try/except around the whole main() loop a problem?- It is slow
- Every error, from a typo to a disk failure, gets the same treatment and ends the program
- Python does not allow
try inside while - It is not a problem; it is the recommended pattern
Answer
2 — the handler is too far from the error to do anything useful. Errors that could have been fixed by asking again instead terminate the program. - A function catches
ValueError and immediately runs raise ValueError("Error"). What is wrong?- Nothing
- The new exception throws away the original message and adds nothing
ValueError cannot be re-raised- The function should have used
except: instead
Answer
2 — re-raising with less information than you started with makes debugging harder. Either let the original propagate, or add context with `raise ... from e`.
Next up
Once an error reaches the code that talks to the user, the question becomes what to say. The next principle: avoid information leakage through error messages.
3 - Avoid Information Leakage Through Error Messages
Tell the user what went wrong without revealing how your program works inside.
Why this matters
When a Python program crashes, it prints a traceback. A traceback is written for the developer. It shows the full path to every source file, the line of code that failed, the names of your functions and variables, and sometimes the values they held. That is exactly the information a developer needs and exactly the information a user should never see.
For an ordinary user, a traceback is confusing and alarming. For someone probing your program, it is a map: where the files live, what libraries you use, how the code is organized, and which inputs make it fail. Error messages that reveal too much also leak private data. A message like No account for user alice@example.com tells anyone who asks which email addresses are registered.
Security here means two things at once. The user gets a message they can act on, and the program’s internals stay internal.
The rule: Messages shown to the user describe the problem in the user’s terms and say what to do next. Technical details go to a log the developer can read. The program never shows a raw traceback to the user, and never confirms or denies private facts through its error messages.
Benefits
- Users understand what happened and what to try, so they stop and ask for help less often.
- File paths, library names, and code structure stay out of the user’s view.
- The developer still gets full details, in a log, when something needs fixing.
- Error messages cannot be used to probe for which usernames, files, or records exist.
Red flags (violations)
- The program ends with a traceback on any error a user can cause.
print(e) or print(f"Error: {e}") where e is an exception the user did not need to understand.- Messages that include file system paths, such as
Could not open /Users/alice/project/data/inventory.txt. - Different messages for “wrong password” and “no such user.”
except Exception as e: print(traceback.format_exc()) in user-facing code.
Example 1 - The raw traceback
Problematic code
def load_inventory(filename: str) -> dict:
items = {}
with open(filename) as f:
for line in f:
name, quantity = line.strip().split(",")
items[name] = int(quantity)
return items
def main():
items = load_inventory("data/inventory.txt")
...
If a line in the file is widgets,lots, the user sees:
Traceback (most recent call last):
File "/Users/alice/seng201/inventory/main.py", line 42, in <module>
main()
File "/Users/alice/seng201/inventory/main.py", line 31, in main
items = load_inventory("data/inventory.txt")
File "/Users/alice/seng201/inventory/storage.py", line 12, in load_inventory
items[name] = int(quantity)
ValueError: invalid literal for int() with base 10: 'lots'
The user now knows Alice’s username, the project layout, the module names, and that the quantity column is parsed with int().
Fixed with a user message and a developer log
import logging
logger = logging.getLogger(__name__)
def load_inventory(filename: str) -> dict:
"""Read the inventory file. Raises ValueError on a malformed line."""
items = {}
with open(filename) as f:
for line_number, line in enumerate(f, start=1):
try:
name, quantity = line.strip().split(",")
items[name] = int(quantity)
except ValueError as e:
raise ValueError(f"line {line_number} is malformed") from e
return items
def main():
logging.basicConfig(filename="inventory.log", level=logging.INFO)
try:
items = load_inventory("data/inventory.txt")
except FileNotFoundError:
logger.exception("Inventory file missing")
print("No saved inventory was found. Starting with an empty list.")
items = {}
except ValueError as e:
logger.exception("Inventory file could not be read")
print(f"The saved inventory file is damaged ({e}).")
print("Fix the file or delete it to start fresh.")
return
...
Why this is better: The user sees one sentence about what happened and one about what to do. logger.exception() writes the full traceback to inventory.log, where the developer can find it. The path, module names, and parsing details never reach the screen.
Example 2 - Confirming what exists
Problematic code
def remove_item(items: dict) -> None:
name = input("Item name: ").strip()
if name not in items:
print(f"Error: '{name}' not found in inventory dict (keys: {list(items)})")
return
del items[name]
The error message prints every item in the inventory. In a bigger system, the same habit prints every username, every account number, or every file in a folder.
Fixed by saying only what the user needs
def remove_item(items: dict) -> None:
name = input("Item name: ").strip()
if name not in items:
print(f"There is no item named '{name}'. Choose 3 to list the items you can remove.")
return
del items[name]
Why this is better: The message tells the user the name was not found and where to look. Listing the inventory is a separate feature the user chooses on purpose, with whatever access checks that feature has. The error path does not become a shortcut around them.
How to apply this rule
- Write messages for the user. Say what happened and what to do next, in words the user would use. “The file is damaged” instead of
ValueError: invalid literal. - Log the details. Use the
logging module. logger.exception() inside an except block records the full traceback to a file. - Catch at the top. The function that talks to the user is the only place that should turn an exception into a message. Lower-level functions raise.
- Never print paths or internals. If the message would help someone understand how your program is built, it belongs in the log.
- Use one message for one situation. When the difference between two failures would reveal private facts, give them the same message.
Knowledge Check
- What is wrong with printing
f"Error: {e}" to the user when e is a FileNotFoundError?- Nothing; the user should see the real error
- The message includes the full file path and a Python error name, which helps nobody but a developer
FileNotFoundError cannot be converted to a string- The program will crash
Answer
2 — the string form of the exception includes the path the program tried to open. The user needs "no saved file was found," and the developer needs the path in the log. - Where should the full traceback go when a user-caused error occurs?
- To the screen, so the user can send it to you
- Nowhere; discard it
- To a log file, via
logger.exception() - To a comment in the code
Answer
3 — the log keeps the details for the developer without exposing them to the user. - A login function prints “No such user” for an unknown name and “Wrong password” for a known one. Why is this a leak?
- It is not a leak
- The two messages let anyone discover which usernames exist
- Passwords should never be checked
- The messages are too short
Answer
2 — by trying names and watching which message comes back, someone can build a list of valid accounts. One message, "Username or password is incorrect," closes that door. - Which message follows this principle?
Could not open /home/sam/app/data/inventory.txtKeyError: 'widgets'The saved inventory could not be read. Fix the file or delete it to start fresh.Traceback (most recent call last): ...
Answer
3 — it describes the problem in plain words and tells the user what to do. The others expose a path, a Python internal, or the whole stack.
Next up
Error handling covers the problems you expected. The next principle covers the ones you did not: cover all cases.
4 - Cover All Cases
Every branch needs a path for the input you did not expect. Use defaults to handle it.
Why this matters
An if/elif chain with no else does nothing when none of the conditions match. A match statement with no case _: does the same. A dictionary lookup with a key you did not plan for raises KeyError. A function whose last if does not fire returns None without anyone deciding that it should.
In each of these, the program has an input it never thought about, and its behavior for that input was never chosen. It just happens. That is the gap people find, by accident or on purpose. The user types a menu option you forgot. The file contains a status word you never defined. A value is None because a function fell off the end.
A program that covers all cases has decided, for every branch, what happens when nothing else matches. Usually that decision is “report the unexpected value and stop,” which is far safer than silently continuing.
The rule: Every if/elif chain ends in an else. Every match ends in case _:. Every function returns a value on every path or raises an exception. The default case handles the unexpected input deliberately, by rejecting it, instead of letting the program drift on.
Benefits
- The program fails loudly on input it does not understand, instead of doing something undefined.
- When you add a new option and forget to handle it somewhere, the default case tells you.
- Readers can see that you thought about the unexpected, which makes the code easier to trust.
- Tests can target the default case directly.
Red flags (violations)
- An
if/elif chain over a menu choice, a status, or a command with no else. - A
match statement with no wildcard case. items[name] where name came from the user, with no check and no .get().- A function that returns a value inside an
if and nothing after it. - A default that quietly does the “safe” thing instead of saying something went wrong, such as
else: pass.
Problematic code
def run_choice(choice: str, items: dict) -> None:
if choice == "1":
add_item(items)
elif choice == "2":
remove_item(items)
elif choice == "3":
list_items(items)
The menu says there is a 4. Quit. The programmer forgot to handle it. Choose 4 and the loop just shows the menu again. If a teammate later adds option 5 to the menu text, the same thing happens.
Fixed with a default that complains
def run_choice(choice: str, items: dict) -> None:
if choice == "1":
add_item(items)
elif choice == "2":
remove_item(items)
elif choice == "3":
list_items(items)
elif choice == "4":
save_and_quit(items)
else:
raise ValueError(f"Unhandled menu choice: {choice!r}")
Why this is better: The missing branch is added, and the else makes sure the next missing branch cannot hide. Because ask_menu_choice() already validates input, the else should never run. If it does, a developer made a mistake, and raising is the right way to say so.
The same shape with match:
def run_choice(choice: str, items: dict) -> None:
match choice:
case "1":
add_item(items)
case "2":
remove_item(items)
case "3":
list_items(items)
case "4":
save_and_quit(items)
case _:
raise ValueError(f"Unhandled menu choice: {choice!r}")
Example 2 - A status word from a file
Problematic code
def restock_needed(item: dict) -> bool:
status = item["status"]
if status == "in_stock":
return False
elif status == "low":
return True
elif status == "out":
return True
The file was edited by hand and one line says backordered. The function returns None. None is falsy, so the caller treats it as “no restock needed,” and the item is never reordered. Nobody sees an error.
Fixed so every path returns or raises
RESTOCK_STATUSES = {"low", "out"}
KNOWN_STATUSES = {"in_stock"} | RESTOCK_STATUSES
def restock_needed(item: dict) -> bool:
status = item.get("status")
if status not in KNOWN_STATUSES:
raise ValueError(f"Unknown status {status!r} for item {item.get('name')!r}")
return status in RESTOCK_STATUSES
Why this is better: The known statuses are written down once. Anything else raises immediately, with the item name, so the bad line in the file gets fixed. item.get("status") returns None instead of raising KeyError when the key is missing, and None then fails the same check. Every path through the function returns a bool or raises.
Example 3 - Dictionary lookups
Problematic code
def quantity_of(items: dict, name: str) -> int:
return items[name]
A name that is not in the inventory raises KeyError, which is the right thing only if the caller expects it. Most callers do not.
Fixed by choosing the default on purpose
def quantity_of(items: dict, name: str) -> int:
"""Return the quantity on hand, or 0 if the item is not stocked."""
return items.get(name, 0)
Why this is better: The default of 0 is a decision, written in the docstring, and the caller can rely on it. Sometimes the right default is to raise; then keep items[name] and document that it raises KeyError. Either way, someone chose.
How to apply this rule
- Finish every chain. Add
else to every if/elif chain that selects among values, and case _: to every match. - Make the default loud. The default should raise or print a clear message. It should not quietly skip or guess.
- Return on every path. If a function returns a value, every branch returns one or raises. Run the function in your head with an input you did not plan for and see where it ends up.
- Write the valid set down. A set like
KNOWN_STATUSES makes the check one line and gives the default case something to compare against. - Use
.get() when a default makes sense. When a missing key has a sensible meaning, say so with .get(key, default). When it does not, let KeyError raise and document it.
Knowledge Check
- An
if/elif chain handles choices “1”, “2”, and “3” with no else. The user enters “9”. What happens?- Python raises an error
- Nothing; no branch runs and execution continues after the chain
- The first branch runs by default
- The program exits
Answer
2 — with no matching condition and no `else`, the chain does nothing. The program continues as if the choice had been handled. - Why does a function that “falls off the end” cause trouble?
- Python forbids it
- It returns
None, which the caller may treat as False, 0, or an empty value without noticing - It uses more memory
- It only matters in loops
Answer
2 — the implicit `None` looks like a legitimate value to the caller, so the mistake travels further into the program before anyone sees it. - What should the default case usually do when the input is something the program never expected?
- Pick the most common option and continue
- Do nothing
- Report the unexpected value clearly, by raising or printing a message
- Restart the program
Answer
3 — an unexpected value means something is wrong. Saying so, with the value included, is how the problem gets fixed. - When is
items[name] better than items.get(name, 0)?- Never
- When a missing name is a bug that should stop the program, and the docstring says it raises
KeyError - When
name came from the user - When the dictionary is large
Answer
2 — raising is the right default when a missing key means something has gone wrong. The point is to choose and document the behavior, not to always pick one form.
Next up
Many of the problems in this lab can be found by a tool before you run the program. The last principle: heed static analysis warnings.
5 - Heed Static Analysis Warnings
Your tools can find many of these problems before you run the code. Read what they say and fix it.
Why this matters
A static analysis tool reads your source code without running it and reports things that look wrong: a variable you assigned but never used, a name you typed that does not exist, a bare except:, a name you misspelled on one line. In compiled languages, the compiler does some of this and prints warnings. Python has no compiler step, so the job falls to separate tools, called linters, and to your IDE.
The principle says to heed the warnings. That is the hard part. Warnings are easy to ignore because the program still runs. But every warning is a place where the tool could not make sense of what you wrote, and that is usually a place where you did not either. An unused variable is often a typo that silently broke a calculation. An undefined name is a crash waiting for the one input that reaches that line. A bare except: is the swallowed error from the exception handling page.
Fixing warnings is cheap. The tool found the line for you. Ignoring them means finding the same problem later, at run time, with less information.
The rule: Run a linter on your code and keep the warning count at zero. Treat each warning as a question about your code that needs an answer: fix it, or understand exactly why it is safe and silence that one warning with a comment that says why.
Benefits
- Typos, unused code, and undefined names are caught in seconds instead of in testing.
- Several of the other principles in this lab have a matching warning. The linter enforces them for you.
- Code that is warning-free is easier for teammates to read, because every line is there on purpose.
- A clean baseline means a new warning stands out immediately.
Red flags (violations)
- The Problems window in PyCharm shows dozens of items and nobody has looked at them.
- Yellow or red underlines in the editor that have been there for weeks.
# noqa or # type: ignore comments with no reason given.- A linter that is installed but never run, or that is run only right before a submission.
- Warnings fixed by deleting the check instead of fixing the cause.
PyCharm’s built-in inspections
PyCharm analyzes your code as you type. A yellow underline is a warning. A red underline is an error. Hover over the underline to read the message, and press Alt+Enter (Option+Enter on macOS) to see suggested fixes.
To see every problem in the project at once, open the Problems tool window (View > Tool Windows > Problems) or run Code > Inspect Code... on the whole project. Work through the list.
Ruff from the command line
Ruff is a fast linter that checks for the most common mistakes. Install it into your project’s environment and run it on the project folder:
pip install ruff
ruff check .
A clean project prints All checks passed!. Otherwise it reports each problem with the file, the line, a code, the message, and the lines of code around it:
F841 Local variable `totl` is assigned to but never used
--> inventory/storage.py:19:9
|
17 | total = 0
18 | for name, quantity in items.items():
19 | totl = total + quantity
| ^^^^
20 | return total
|
help: Remove assignment to unused variable `totl`
For a shorter list with one line per problem, add --output-format concise:
$ ruff check --output-format concise .
inventory/storage.py:1:8: F401 [*] `os` imported but unused
inventory/storage.py:2:8: F401 [*] `sys` imported but unused
inventory/storage.py:8:5: E722 Do not use bare `except`
inventory/storage.py:19:9: F841 Local variable `totl` is assigned to but never used
inventory/storage.py:20:12: F821 Undefined name `itmes`
Found 5 errors.
[*] 2 fixable with the `--fix` option.
The code tells you what kind of problem it is. F codes come from Pyflakes and are almost always real bugs: unused imports (F401), unused variables (F841), undefined names (F821). E codes are correctness and style rules from pycodestyle; E722, the bare except, is the one you will see most. You may also see I001, which means the imports at the top of the file are out of order. Ruff can fix that one for you.
ruff check --fix . repairs the problems marked [*], such as removing an unused import. Look at what it changed before you commit.
To see Ruff’s results inside PyCharm, install the Ruff plugin from Settings > Plugins. Then its warnings appear as underlines alongside PyCharm’s own.
Example 1 - A typo the linter catches and the tests might not
Problematic code
def total_quantity(items: dict) -> int:
total = 0
for name, quantity in items.items():
totl = total + quantity
return total
This runs. It returns 0 for every inventory. A test with an empty inventory passes. Ruff reports:
F841 Local variable `totl` is assigned to but never used
Fixed
def total_quantity(items: dict) -> int:
total = 0
for name, quantity in items.items():
total = total + quantity
return total
Why this is better: The warning pointed straight at the line. PyCharm shows the same thing as a gray totl with a hover message. Reading the warning took ten seconds; finding the bug from a wrong total somewhere in the program could have taken an hour.
Example 2 - Warnings that match the other principles
Problematic code
import os
import sys
def load_inventory(filename):
try:
with open(filename) as f:
return parse(f)
except:
return {}
def find_item(items, name):
try:
return items[name]
except:
print("Not found")
Ruff reports (concise format):
inventory/storage.py:1:8: F401 [*] `os` imported but unused
inventory/storage.py:2:8: F401 [*] `sys` imported but unused
inventory/storage.py:8:5: E722 Do not use bare `except`
inventory/storage.py:16:5: E722 Do not use bare `except`
Fixed
def load_inventory(filename: str) -> dict:
"""Read the inventory file. Raises OSError if it cannot be opened."""
with open(filename) as f:
return parse(f)
def find_item(items: dict, name: str) -> int | None:
"""Return the quantity for name, or None if the item is not stocked."""
return items.get(name)
Why this is better: The first bare except was swallowing every error, including a missing file, and returning an empty inventory as if nothing happened. The fix is the one from the exception handling page: let it raise. The second bare except caught KeyError and printed from inside a lookup function, which is a user-interface decision made in the wrong place; .get() returns None and lets the caller decide. The unused imports are gone, so a reader does not wonder what they were for.
How to apply this rule
- Run the linter before every commit.
ruff check . takes under a second. Make it a habit, like saving the file. - Read the message, then the line. The message says what the tool saw. The line shows why. Decide which one is wrong.
- Fix the cause. Rename the typo, delete the unused code, catch the specific exception. Do not just make the warning go away.
- Silence on purpose, with a reason. Rarely, a warning is wrong for your situation. Add
# noqa: <code> to that one line with a comment explaining why. Never silence a whole file. - Keep the count at zero. When the baseline is clean, a new warning is a new problem, and you see it immediately.
Knowledge Check
- Ruff reports
F841 Local variable 'reslt' is assigned to but never used. What is the most likely cause?- Ruff is wrong; unused variables are fine
- A typo: the code assigned to
reslt but later used result - The variable is used in another file
- Python requires every variable to be returned
Answer
2 — a misspelled name on the assignment creates a new variable nobody reads, and the intended variable keeps its old value. The program runs, with the wrong answer. - Why does Python need a separate linter where a C or Java compiler would print warnings itself?
- Python has no compile step that checks the whole program before it runs
- Python code has no bugs
- Linters are only for style
- Python compilers are too slow
Answer
1 — Python checks a line when it runs it. A tool that reads the whole file ahead of time is the only way to find problems on lines that have not run yet. - A teammate adds
# noqa to the end of every line Ruff complains about and commits. What is wrong with this?- Nothing; the warnings are gone
- The problems are still in the code; only the messages were removed
# noqa is not valid Python- Ruff will refuse to run
Answer
2 — silencing a warning without fixing or understanding it keeps the bug and loses the reminder. A `noqa` is acceptable only for a single line, with a specific code and a reason. - Which warning points at a violation of the “handle exceptions at the lowest sensible level” principle?
F401 'os' imported but unusedI001 Import block is un-sorted or un-formattedE722 Do not use bare 'except'F841 Local variable assigned but never used
Answer
3 — a bare `except` catches everything, including errors the function cannot handle, which is the swallowing pattern that principle warns against.