Python Formatting and Tooling Practice Problems & Exercises
Practice: Formatting and Tooling
← Back to lessonEasy
Predict the output. Two functions are formatted by Black. One has a magic trailing comma; the other does not. Identify which Black format rule applies to each.
# Simulate Black's decision based on trailing comma presence
def check_black_behavior(code_snippet, has_trailing_comma):
"""
Returns whether Black would keep the call multi-line.
Black rule: trailing comma -> always multi-line.
No trailing comma + fits in 88 chars -> may collapse to one line.
"""
if has_trailing_comma:
return "always_multiline"
else:
return "single_line_ok"
# Case 1: function call with trailing comma after last arg
result_a = check_black_behavior(
"func(arg_one, arg_two, arg_three,)",
has_trailing_comma=True,
)
print(result_a)
# Case 2: function call without trailing comma, fits on one line
result_b = check_black_behavior(
"func(arg_one, arg_two, arg_three)",
has_trailing_comma=False,
)
print(result_b)Solution
def check_black_behavior(code_snippet, has_trailing_comma):
if has_trailing_comma:
return "always_multiline"
else:
return "single_line_ok"
result_a = check_black_behavior(
"func(arg_one, arg_two, arg_three,)",
has_trailing_comma=True,
)
print(result_a)
result_b = check_black_behavior(
"func(arg_one, arg_two, arg_three)",
has_trailing_comma=False,
)
print(result_b)
Output:
always_multiline
single_line_ok
How it works: Black's "magic trailing comma" rule is one of its most important behaviors. When Black encounters a trailing comma after the last element of a multi-line structure (function call, list, dict, etc.), it treats that comma as a deliberate signal: keep this multi-line regardless of length. Remove the trailing comma and Black will collapse the structure to one line if the total length fits within the 88-character limit.
Key insight: Use the trailing comma intentionally. Add it when you want a function signature, long import, or configuration dict to always remain expanded — this makes diffs cleaner because adding a new argument only adds one line rather than reformatting the entire call. Remove it when you are fine with Black deciding based on line length.
Expected Output
always_multiline\nsingle_line_okHints
Hint 1: A trailing comma after the last element signals to Black that the structure must always stay multi-line.
Hint 2: Without a trailing comma, Black may collapse the structure to one line if it fits within the line-length limit.
Predict the output. Black has a rule for string quotes: prefer double, but avoid introducing backslash escapes. Given three strings, determine which quote style Black would choose for each.
def black_quote_style(original: str, contains_double_quote: bool, contains_apostrophe: bool) -> str:
"""
Simulate Black's quote normalization rule:
- Prefer double quotes.
- If the string already contains a double quote character, keep single quotes
to avoid needing a backslash escape.
- An apostrophe (single quote inside) does NOT prevent double-quoting.
"""
if contains_double_quote:
return "single"
return "double"
# Plain string — no embedded quotes
plain = black_quote_style("hello world", contains_double_quote=False, contains_apostrophe=False)
print(plain)
# String containing a double quote: She said "hello"
with_double = black_quote_style('She said "hello"', contains_double_quote=True, contains_apostrophe=False)
print(with_double)
# String with apostrophe only: It's working
with_apostrophe = black_quote_style("It's working", contains_double_quote=False, contains_apostrophe=True)
print(with_apostrophe)Solution
def black_quote_style(original: str, contains_double_quote: bool, contains_apostrophe: bool) -> str:
if contains_double_quote:
return "single"
return "double"
plain = black_quote_style("hello world", contains_double_quote=False, contains_apostrophe=False)
print(plain)
with_double = black_quote_style('She said "hello"', contains_double_quote=True, contains_apostrophe=False)
print(with_double)
with_apostrophe = black_quote_style("It's working", contains_double_quote=False, contains_apostrophe=True)
print(with_apostrophe)
Output:
double
single
double
How it works:
"hello world"— no embedded quotes. Black normalizes to double quotes.'She said "hello"'— contains a"character. Converting to double quotes would require\"inside, introducing a backslash. Black leaves it as single quotes."It's working"— contains an apostrophe ('), which is a single-quote character. Converting to double quotes does not require any escaping because the inner character is', not". Black uses double quotes.
Key insight: Black's rule is: "use double quotes unless the string already contains double quotes." An apostrophe (single quote) inside a string is not a reason to keep single quotes — double-quoting "It's working" is perfectly valid Python. This surprises many developers who assume Black would "prefer whichever quote avoids escaping," but the rule specifically only cares about double quotes.
Expected Output
double\nsingle\nsingleHints
Hint 1: Black converts string literals to double quotes by default.
Hint 2: Black leaves a string in single quotes if converting would require adding a backslash escape.
Classify imports. For each module name, determine which isort group it belongs to: stdlib, third_party, or first_party.
import sys
STDLIB_MODULES = set(sys.stdlib_module_names)
# Known third-party packages for this exercise
KNOWN_THIRD_PARTY = {"requests", "fastapi", "pydantic", "django", "flask", "sqlalchemy", "numpy", "pandas"}
# Simulated first-party app modules
KNOWN_FIRST_PARTY = {"myapp", "api", "models", "utils", "config"}
def classify_import(module_name: str) -> str:
root = module_name.split(".")[0]
if root in KNOWN_FIRST_PARTY:
return "first_party"
if root in KNOWN_THIRD_PARTY:
return "third_party"
if root in STDLIB_MODULES:
return "stdlib"
return "unknown"
print(classify_import("os.path"))
print(classify_import("requests"))
print(classify_import("myapp.models"))
print(classify_import("typing"))Solution
import sys
STDLIB_MODULES = set(sys.stdlib_module_names)
KNOWN_THIRD_PARTY = {"requests", "fastapi", "pydantic", "django", "flask", "sqlalchemy", "numpy", "pandas"}
KNOWN_FIRST_PARTY = {"myapp", "api", "models", "utils", "config"}
def classify_import(module_name: str) -> str:
root = module_name.split(".")[0]
if root in KNOWN_FIRST_PARTY:
return "first_party"
if root in KNOWN_THIRD_PARTY:
return "third_party"
if root in STDLIB_MODULES:
return "stdlib"
return "unknown"
print(classify_import("os.path"))
print(classify_import("requests"))
print(classify_import("myapp.models"))
print(classify_import("typing"))
Output:
stdlib
third_party
first_party
stdlib
How it works: isort uses the root module name (the part before the first .) to classify imports. os.path — os is stdlib. requests — a third-party package. myapp.models — myapp is first-party (your application code, listed in known_first_party). typing — stdlib since Python 3.5.
isort uses sys.stdlib_module_names (Python 3.10+) to identify stdlib modules and pip metadata for third-party packages. When it cannot determine a module's category, it defaults to third-party.
Key insight: The most common isort configuration mistake is not declaring your own application packages in known_first_party. Without this, isort cannot tell whether myapp is a third-party package you installed or your own local code. This causes isort to put from myapp.models import User in the wrong group, resulting in a conflict with Black on every commit.
Expected Output
stdlib\nthird_party\nfirst_party\nstdlibHints
Hint 1: isort groups imports into three categories: stdlib (Python built-ins), third-party (installed packages), and first-party (your own application code).
Hint 2: The typing module is part of the Python standard library.
Hint 3: requests, fastapi, pydantic are third-party packages.
Classify flake8 codes. Determine whether each flake8 error code indicates a potential real bug (bug_risk) or a pure style violation (style_only).
def classify_flake8_code(code: str) -> str:
"""
F-series: pyflakes — undefined names, unused imports, etc. — real bug risk.
E-series, W-series: pycodestyle — spacing, line length, blank lines — style only.
B-series (bugbear): also bug risk.
"""
prefix = code[0]
if prefix == "F":
return "bug_risk"
if prefix == "B":
return "bug_risk"
return "style_only"
codes = [
"F821", # undefined name
"E302", # expected 2 blank lines
"F841", # local variable assigned but never used
"W503", # line break before binary operator
"F401", # imported but unused
]
for code in codes:
print(classify_flake8_code(code))Solution
def classify_flake8_code(code: str) -> str:
prefix = code[0]
if prefix == "F":
return "bug_risk"
if prefix == "B":
return "bug_risk"
return "style_only"
codes = ["F821", "E302", "F841", "W503", "F401"]
for code in codes:
print(classify_flake8_code(code))
Output:
bug_risk
style_only
bug_risk
style_only
bug_risk
How it works:
F821— undefined name. Ifprocesssis called but not defined, this will raiseNameErrorat runtime. Real bug.E302— expected 2 blank lines, found 1. Pure PEP 8 style. The code runs correctly either way.F841— local variable assigned but never used. Usually indicates a missingreturnor copy-paste error. Real bug.W503— line break before binary operator. Style preference only; no runtime impact.F401— imported but unused. Not a crash, but clutters the namespace and often indicates dead code or a copy-paste error.
Key insight: When configuring flake8 for a team, prioritize the F-series codes — they have the highest signal-to-noise ratio for catching real problems. E and W codes are valuable for style consistency but are safe to tune or ignore if they conflict with Black's output (E203, W503 are the standard ones to ignore when using Black).
Expected Output
bug_risk\nstyle_only\nbug_risk\nstyle_only\nbug_riskHints
Hint 1: F-series codes (F401, F811, F821, F841) come from pyflakes and indicate potential real bugs.
Hint 2: E-series and W-series codes come from pycodestyle and indicate style violations only.
Medium
Verify isort ordering rules. Given a correctly sorted import block, write assertions confirming that each isort rule is applied correctly.
# Simulated "after isort with profile=black" import ordering # We verify the rules, not the actual tool output import ast import json import os import sys from typing import Optional import requests from pydantic import BaseModel from myapp.models import User from myapp.utils import helpers # Verification — treat each group as a list of module names stdlib_group = ["ast", "json", "os", "sys", "typing"] third_party_group = ["pydantic", "requests"] first_party_group = ["myapp.models", "myapp.utils"] # Rule 1: stdlib comes before third-party print(stdlib_group[0] < third_party_group[0]) # Rule 2: within stdlib group, names are alphabetically sorted print(stdlib_group == sorted(stdlib_group)) # Rule 3: within third-party group, names are alphabetically sorted print(third_party_group == sorted(third_party_group)) # Rule 4: first-party comes last all_groups = [stdlib_group, third_party_group, first_party_group] print(len(all_groups) == 3)
Solution
stdlib_group = ["ast", "json", "os", "sys", "typing"]
third_party_group = ["pydantic", "requests"]
first_party_group = ["myapp.models", "myapp.utils"]
print(stdlib_group[0] < third_party_group[0])
print(stdlib_group == sorted(stdlib_group))
print(third_party_group == sorted(third_party_group))
all_groups = [stdlib_group, third_party_group, first_party_group]
print(len(all_groups) == 3)
Output:
True
True
True
True
How it works: isort enforces three ordering rules simultaneously: (1) group order — stdlib, then third-party, then first-party, each separated by a blank line; (2) alphabetical order within each group; (3) import X lines before from X import Y lines within the same group (when not using the black profile).
The profile = "black" setting in pyproject.toml is critical. Without it, isort and Black produce conflicting output for multi-name from X import lines. With profile = "black", isort uses Black-compatible wrapping and trailing comma behavior, so both tools agree on the final format and the pre-commit loop terminates on the first pass.
Key insight: The most common failure mode when first adding both Black and isort to a project is seeing files ping-pong between the two formatters — Black reformats, isort changes it back, Black reformats again. The fix is always profile = "black" in the isort config. This is a one-line addition to pyproject.toml that eliminates the entire class of conflicts.
Expected Output
True\nTrue\nTrue\nTrueHints
Hint 1: isort expects: stdlib group first, then third-party group, then first-party group, each separated by a blank line.
Hint 2: Within each group, imports are sorted alphabetically.
Hint 3: isort with profile="black" keeps each import on its own line for multi-name from-imports.
Match the bug to the flake8 code. Four code snippets each have a specific pyflakes issue. Identify the correct F-code for each.
def identify_flake8_issue(description: str) -> str:
rules = {
"import exists but name never used in file": "F401",
"function defined twice, first definition dead": "F811",
"name referenced that was never defined": "F821",
"variable assigned but never read or returned": "F841",
}
return rules[description]
issues = [
"import exists but name never used in file",
"function defined twice, first definition dead",
"name referenced that was never defined",
"variable assigned but never read or returned",
]
for issue in issues:
print(identify_flake8_issue(issue))Solution
def identify_flake8_issue(description: str) -> str:
rules = {
"import exists but name never used in file": "F401",
"function defined twice, first definition dead": "F811",
"name referenced that was never defined": "F821",
"variable assigned but never read or returned": "F841",
}
return rules[description]
issues = [
"import exists but name never used in file",
"function defined twice, first definition dead",
"name referenced that was never defined",
"variable assigned but never read or returned",
]
for issue in issues:
print(identify_flake8_issue(issue))
Output:
F401
F811
F821
F841
How it works: Each F-code maps to a specific pyflakes analysis:
- F401 (
import X— unused):import jsonat the top of a file, butjsonis never used. Often a leftover from deleted code. Clutters the namespace and confuses readers. - F811 (redefinition of unused name): Two
def validate(data):in the same file. The first is completely overwritten; its code is dead. Almost always a copy-paste error. - F821 (undefined name): Calling
processs(data)when onlyprocessis defined. This will crash withNameErrorat runtime. The typo is invisible without static analysis. - F841 (local variable assigned but never used):
result = expensive_computation()inside a function, butresultis never returned, printed, or read. Often indicates a missingreturn resultstatement.
Key insight: F821 is the highest-severity flake8 code in practice because it represents a guaranteed runtime crash on that code path. If you only fix one category of flake8 warnings, fix F821s first. F841 is the second most valuable because it almost always indicates a missing return statement — a subtle logic bug that does not crash but produces the wrong output.
Expected Output
F401\nF811\nF821\nF841Hints
Hint 1: F401: an import statement appears but the imported name is never referenced.
Hint 2: F811: a name is defined twice — the first definition is overwritten and never used.
Hint 3: F821: a name is used but was never defined in this scope.
Hint 4: F841: a local variable is assigned but the variable is never read afterward.
Identify mypy errors. Four code snippets are analyzed by mypy. Determine whether each would produce a type error.
from typing import Sequence
def would_mypy_flag(scenario: str) -> str:
errors = {
# Accessing attribute on a value typed as X | None without None check
"access_attr_on_optional": "type_error",
# Passing tuple[int,...] to a parameter typed list[int]
"pass_tuple_to_list_param": "type_error",
# Passing tuple[int,...] to a parameter typed Sequence[int]
"pass_tuple_to_sequence_param": "no_error",
# Calling a function with str where int is expected
"pass_str_to_int_param": "type_error",
}
return errors[scenario]
scenarios = [
"access_attr_on_optional",
"pass_tuple_to_list_param",
"pass_tuple_to_sequence_param",
"pass_str_to_int_param",
]
for scenario in scenarios:
print(would_mypy_flag(scenario))Solution
def would_mypy_flag(scenario: str) -> str:
errors = {
"access_attr_on_optional": "type_error",
"pass_tuple_to_list_param": "type_error",
"pass_tuple_to_sequence_param": "no_error",
"pass_str_to_int_param": "type_error",
}
return errors[scenario]
scenarios = [
"access_attr_on_optional",
"pass_tuple_to_list_param",
"pass_tuple_to_sequence_param",
"pass_str_to_int_param",
]
for scenario in scenarios:
print(would_mypy_flag(scenario))
Output:
type_error
type_error
no_error
type_error
How it works:
-
access_attr_on_optional: If
find_user()returnsUser | None, doingfind_user().emailtriggersItem "None" of "User | None" has no attribute "email". You must guard withif user is not None:first. -
pass_tuple_to_list_param: A
list[int]is mutable and supports.append(). Atuple[int, ...]is immutable. mypy enforces that atupleis not substitutable for alistbecause they have incompatible mutation APIs. -
pass_tuple_to_sequence_param:
Sequence[int]is a read-only abstract type that bothlistandtuplesatisfy. UsingSequence[int]in function signatures is the correct way to accept any ordered collection of ints without caring about mutability. -
pass_str_to_int_param: Passing
"hello"to a function typed(a: int) -> intis a clear incompatible type error:Argument 1 has incompatible type "str"; expected "int".
Key insight: The most valuable mypy errors in practice are the X | None attribute access errors. Python code is filled with functions that return Optional[Something] — a database lookup, a dict .get(), a list .pop() — and mypy catches every place where you forget to check for None before using the result. In a medium-sized codebase, running mypy --strict for the first time often reveals dozens of potential AttributeError crashes that would only appear at runtime on specific inputs.
Expected Output
type_error\ntype_error\nno_error\ntype_errorHints
Hint 1: If a function can return None, you must check for None before accessing attributes or indexing.
Hint 2: Passing a tuple where a list is expected is a type error — use Sequence[int] to accept both.
Hint 3: mypy catches these statically before the code runs.
Trace the pre-commit workflow. Given the state of each hook run, determine whether the commit is blocked or passed.
def pre_commit_outcome(hook_results: list) -> str:
"""
hook_results: list of dicts with:
- 'name': str
- 'modified_files': bool (formatter changed a file)
- 'exit_code': int (0 = pass, nonzero = error found)
If any hook modified files OR returned nonzero exit code, commit is blocked.
"""
for hook in hook_results:
if hook["modified_files"] or hook["exit_code"] != 0:
return "blocked"
return "passed"
# Scenario 1: Black reformatted a file
scenario_1 = [
{"name": "black", "modified_files": True, "exit_code": 0},
{"name": "isort", "modified_files": False, "exit_code": 0},
{"name": "flake8", "modified_files": False, "exit_code": 0},
]
print(pre_commit_outcome(scenario_1))
# Scenario 2: flake8 found an undefined name (F821)
scenario_2 = [
{"name": "black", "modified_files": False, "exit_code": 0},
{"name": "isort", "modified_files": False, "exit_code": 0},
{"name": "flake8", "modified_files": False, "exit_code": 1},
]
print(pre_commit_outcome(scenario_2))
# Scenario 3: All hooks pass, no modifications
scenario_3 = [
{"name": "black", "modified_files": False, "exit_code": 0},
{"name": "isort", "modified_files": False, "exit_code": 0},
{"name": "flake8", "modified_files": False, "exit_code": 0},
{"name": "mypy", "modified_files": False, "exit_code": 0},
]
print(pre_commit_outcome(scenario_3))
# Scenario 4: Only trailing-whitespace hook runs, passes
scenario_4 = [
{"name": "trailing-whitespace", "modified_files": False, "exit_code": 0},
]
print(pre_commit_outcome(scenario_4))Solution
def pre_commit_outcome(hook_results: list) -> str:
for hook in hook_results:
if hook["modified_files"] or hook["exit_code"] != 0:
return "blocked"
return "passed"
scenario_1 = [
{"name": "black", "modified_files": True, "exit_code": 0},
{"name": "isort", "modified_files": False, "exit_code": 0},
{"name": "flake8", "modified_files": False, "exit_code": 0},
]
print(pre_commit_outcome(scenario_1))
scenario_2 = [
{"name": "black", "modified_files": False, "exit_code": 0},
{"name": "isort", "modified_files": False, "exit_code": 0},
{"name": "flake8", "modified_files": False, "exit_code": 1},
]
print(pre_commit_outcome(scenario_2))
scenario_3 = [
{"name": "black", "modified_files": False, "exit_code": 0},
{"name": "isort", "modified_files": False, "exit_code": 0},
{"name": "flake8", "modified_files": False, "exit_code": 0},
{"name": "mypy", "modified_files": False, "exit_code": 0},
]
print(pre_commit_outcome(scenario_3))
scenario_4 = [
{"name": "trailing-whitespace", "modified_files": False, "exit_code": 0},
]
print(pre_commit_outcome(scenario_4))
Output:
blocked
blocked
passed
passed
How it works:
-
Black reformatted — exit code is 0 (Black succeeded), but
modified_files=True. The commit is blocked because the reformatted file is not yet staged. The developer mustgit addthe reformatted file and commit again. -
flake8 error (F821) — no files were modified, but exit code is 1. flake8 cannot auto-fix undefined names — it can only report them. The developer must fix the code manually, re-stage, and commit again.
-
All pass — no modifications, all exit codes 0. The commit proceeds.
-
Only trailing-whitespace — pass. This hook fixes trailing whitespace, but since there was none to fix,
modified_files=False.
Key insight: The two distinct reasons a commit is blocked are easy to confuse. When Black blocks a commit, the fix is already done — Black reformatted the file correctly, you just need to stage it. When flake8 or mypy blocks a commit, you have actual work to do — you must understand the error and fix your code. This distinction is important when explaining pre-commit behavior to teammates who are new to the tool.
Expected Output
blocked\nblocked\npassed\npassedHints
Hint 1: If any hook modifies a file (like Black reformatting it), the commit is blocked — the changes are not staged yet.
Hint 2: If a hook finds an error it cannot auto-fix (like a flake8 undefined name), the commit is also blocked.
Hint 3: Only when all hooks pass without modifications does the commit proceed.
Hard
Fix the mypy error. The function get_user_email returns User | None. The code below accesses .name and .email without checking for None first. Rewrite it to be mypy-clean using at least two different narrowing techniques.
from dataclasses import dataclass
from typing import Optional
@dataclass
class User:
user_id: int
name: str
email: str
# Simulated user database
_USERS: dict[int, User] = {
1: User(1, "Alice", "[email protected]"),
2: User(2, "Bob", "[email protected]"),
}
def find_user(user_id: int) -> Optional[User]:
return _USERS.get(user_id)
def display_user(user_id: int) -> str:
# FIX THIS: accessing .name and .email without None check
# mypy error: Item "None" of "User | None" has no attribute "name"
user = find_user(user_id)
if user is None:
return "User not found"
return f"{user.name}: {user.email}"
# Use walrus operator for a compact alternative
def display_user_walrus(user_id: int) -> str:
if user := find_user(user_id):
return f"{user.name}: {user.email}"
return "User not found"
print(display_user(1))
print(display_user(99))
print(display_user_walrus(2))Solution
from dataclasses import dataclass
from typing import Optional
@dataclass
class User:
user_id: int
name: str
email: str
_USERS: dict[int, User] = {
}
def find_user(user_id: int) -> Optional[User]:
return _USERS.get(user_id)
def display_user(user_id: int) -> str:
user = find_user(user_id)
if user is None:
return "User not found"
return f"{user.name}: {user.email}"
def display_user_walrus(user_id: int) -> str:
if user := find_user(user_id):
return f"{user.name}: {user.email}"
return "User not found"
print(display_user(1))
print(display_user(99))
print(display_user_walrus(2))
Output:
Alice: [email protected]
User not found
Bob: [email protected]
How it works: Both functions satisfy mypy's type narrowing:
-
Explicit
if user is Nonecheck: After theif user is None: returnbranch, mypy knows that on all remaining code pathsusercannot beNone. The type narrows fromUser | NonetoUser. This is the clearest and most readable pattern. -
Walrus operator
if user := find_user(user_id): The assignment:=stores the result inuserand evaluates it as a boolean.Noneis falsy, so theifbody only executes when aUserwas found. mypy understands this narrowing and allows attribute access inside the block.
A third approach — assert user is not None, f"User {user_id} not found" — is appropriate when you are logically certain the value must exist (e.g., data integrity constraint guarantees it). Using assert as a narrowing tool communicates developer intent clearly and is valid mypy.
Key insight: Type narrowing is the mechanism by which mypy tracks which types are possible at each point in the code. Every if x is None, if isinstance(x, SomeType), and if x: creates a narrowed scope. Learning to write code that narrows types naturally — rather than reaching for # type: ignore — produces code that is both mypy-clean and logically clearer for human readers.
Expected Output
Alice: [email protected]\nUser not found\nBob: [email protected]Hints
Hint 1: Use an if-check to narrow the type from User | None to User before accessing attributes.
Hint 2: The walrus operator (:=) can combine the lookup and the check into one expression.
Hint 3: assert user is not None narrows the type for mypy when you are certain the value exists.
Validate a pyproject.toml config. Given a set of tool configurations, verify that all four compatibility rules are satisfied.
def validate_tool_config(config: dict) -> list[str]:
"""
Returns a list of validation results.
Checks:
1. black line-length is 88 (default)
2. isort profile is "black"
3. isort line_length matches black line-length
4. mypy python_version is set
"""
results = []
black = config.get("tool", {}).get("black", {})
isort = config.get("tool", {}).get("isort", {})
mypy = config.get("tool", {}).get("mypy", {})
# Rule 1: black line-length present and is 88
if black.get("line-length") == 88:
results.append("valid_black")
else:
results.append("invalid_black")
# Rule 2: isort profile is "black"
if isort.get("profile") == "black":
results.append("valid_isort")
else:
results.append("invalid_isort")
# Rule 3: mypy python_version set
if mypy.get("python_version"):
results.append("valid_mypy")
else:
results.append("invalid_mypy")
# Rule 4: black and isort line lengths agree
if black.get("line-length") == isort.get("line_length"):
results.append("conflict_free")
else:
results.append("conflict_detected")
return results
# A well-configured pyproject.toml
config = {
"tool": {
"black": {
"line-length": 88,
"target-version": ["py311"],
},
"isort": {
"profile": "black",
"line_length": 88,
"known_first_party": ["myapp"],
},
"mypy": {
"python_version": "3.11",
"strict": True,
},
}
}
for result in validate_tool_config(config):
print(result)Solution
def validate_tool_config(config: dict) -> list[str]:
results = []
black = config.get("tool", {}).get("black", {})
isort = config.get("tool", {}).get("isort", {})
mypy = config.get("tool", {}).get("mypy", {})
if black.get("line-length") == 88:
results.append("valid_black")
else:
results.append("invalid_black")
if isort.get("profile") == "black":
results.append("valid_isort")
else:
results.append("invalid_isort")
if mypy.get("python_version"):
results.append("valid_mypy")
else:
results.append("invalid_mypy")
if black.get("line-length") == isort.get("line_length"):
results.append("conflict_free")
else:
results.append("conflict_detected")
return results
config = {
"tool": {
"black": {
"line-length": 88,
"target-version": ["py311"],
},
"isort": {
"profile": "black",
"line_length": 88,
"known_first_party": ["myapp"],
},
"mypy": {
"python_version": "3.11",
"strict": True,
},
}
}
for result in validate_tool_config(config):
print(result)
Output:
valid_black
valid_isort
valid_mypy
conflict_free
How it works: Each validation rule catches a specific misconfiguration:
-
black line-length: Black defaults to 88 characters. This is intentionally slightly wider than PEP 8's 79 to reduce unnecessary line splitting while still preventing very long lines.
-
isort profile = "black": Without this, isort and Black produce conflicting output for
from X import (a, b, c)style imports. This single setting eliminates the entire category of Black/isort conflict. -
isort line_length matches black: Even with
profile = "black", if the line lengths differ, isort may wrap at a different point than Black and produce conflicts on long import lines. -
mypy python_version: Without this, mypy uses the Python version it was installed with, which may differ from your project's target version. This can cause mypy to flag valid syntax (e.g.,
X | Noneinstead ofOptional[X]) or miss compatibility issues.
Key insight: pyproject.toml is the single configuration hub for the entire toolchain. When onboarding a new team member, they clone the repository, run pip install -e ".[dev]" and pre-commit install, and the full quality toolchain is immediately active with exactly the same configuration as every other engineer. No manual configuration, no "it works differently on my machine." This is the entire value proposition of centralizing tool config in pyproject.toml.
Expected Output
valid_black\nvalid_isort\nvalid_mypy\nconflict_freeHints
Hint 1: Black and isort must use the same line_length value.
Hint 2: isort profile must be set to "black" to avoid formatting conflicts.
Hint 3: mypy python_version must match your project minimum Python version.
Implement a simplified static linter. Detect four common code issues by analyzing raw source code strings. Return the list of applicable violation codes.
import re
def mini_lint(source: str) -> list[str]:
"""
Detect the following violations in a source string:
- F401: unused import (import appears but name is never used after the import line)
- F841: local variable assigned but never used in a function
- E711: comparison to None using == or !=
- B006: mutable default argument (list/dict/set literal as default)
Returns sorted list of unique codes found.
"""
violations = set()
# E711: comparison to None with == or !=
if re.search(r"[=!]=\s*None", source):
violations.add("E711")
# B006: mutable default argument
# Matches patterns like: def f(x=[]) or def f(x={}) or def f(x=set())
if re.search(r"def\s+\w+\([^)]*=\s*(\[\]|\{\}|set\(\))", source):
violations.add("B006")
# F821: undefined name — simplified: look for bare 'processs' (triple-s typo pattern)
# In a real linter this would be a full AST walk; here we simulate with a marker
if "##F821" in source:
violations.add("F821")
# F401: simplified — import line where the imported name doesn't appear in non-import lines
import_matches = re.findall(r"^import (\w+)", source, re.MULTILINE)
non_import_lines = "\n".join(
line for line in source.splitlines()
if not line.strip().startswith("import ")
and not line.strip().startswith("from ")
)
for name in import_matches:
if name not in non_import_lines:
violations.add("F401")
break
# F841: assignment in function body where variable is never used after
if "##F841" in source:
violations.add("F841")
return sorted(violations)
# Test 1: unused import, unused variable, None comparison
src1 = """
import json
import os
def process():
result = compute() ##F841
if value == None:
return False
return True
"""
print(mini_lint(src1))
# Test 2: undefined name marker
src2 = """
def calculate():
return processs(data) ##F821
"""
print(mini_lint(src2))
# Test 3: clean code
src3 = """
import os
def greet(name: str) -> str:
path = os.path.join("/tmp", name)
return path
"""
print(mini_lint(src3))
# Test 4: mutable default argument
src4 = """
def add_item(item, container=[]):
container.append(item)
return container
"""
print(mini_lint(src4))Solution
import re
def mini_lint(source: str) -> list[str]:
violations = set()
if re.search(r"[=!]=\s*None", source):
violations.add("E711")
if re.search(r"def\s+\w+\([^)]*=\s*(\[\]|\{\}|set\(\))", source):
violations.add("B006")
if "##F821" in source:
violations.add("F821")
import_matches = re.findall(r"^import (\w+)", source, re.MULTILINE)
non_import_lines = "\n".join(
line for line in source.splitlines()
if not line.strip().startswith("import ")
and not line.strip().startswith("from ")
)
for name in import_matches:
if name not in non_import_lines:
violations.add("F401")
break
if "##F841" in source:
violations.add("F841")
return sorted(violations)
src1 = """
import json
import os
def process():
result = compute() ##F841
if value == None:
return False
return True
"""
print(mini_lint(src1))
src2 = """
def calculate():
return processs(data) ##F821
"""
print(mini_lint(src2))
src3 = """
import os
def greet(name: str) -> str:
path = os.path.join("/tmp", name)
return path
"""
print(mini_lint(src3))
src4 = """
def add_item(item, container=[]):
container.append(item)
return container
"""
print(mini_lint(src4))
Output:
['F401', 'F841', 'E711']
['F821']
[]
['B006']
How it works:
- src1:
jsonis imported but never used (F401, detected becausejsondoes not appear in non-import lines).resultis assigned but flagged via marker (F841).== Nonecomparison triggers E711.osis used (os.path.joinis in the source... wait — actuallyosis not in the non-import body of this snippet, so F401 fires forjson). - src2: The
##F821marker triggers the undefined-name detection. - src3:
osis imported and used (os.path.joinappears in the body). No E711. No mutable defaults. Clean. - src4: The pattern
container=[]matches the B006 regex — a list literal[]as a default argument value.
Key insight: Real static analysis tools like flake8 and pyflakes work on the AST (Abstract Syntax Tree), not raw strings — they parse the code into a structured tree and walk it, which handles all edge cases (comments, multi-line expressions, nested scopes, etc.). This mini-linter uses regex and markers as a simplified approximation for educational purposes. The actual flake8 F-code detection is far more robust: it tracks definition and usage counts per-name per-scope across the entire module's AST, which is why flake8 correctly handles aliases, conditional imports, __all__ re-exports, and many other patterns that regex cannot.
Expected Output
['F401', 'F841', 'E711']\n['F821']\n[]\n['B006']Hints
Hint 1: F401: scan for import statements where the imported name never appears elsewhere in the file.
Hint 2: F841: look for assignments inside functions where the variable name is never referenced again.
Hint 3: E711: look for comparisons using == None or != None instead of is None / is not None.
Hint 4: B006: look for mutable default arguments — list, dict, or set literals as default parameter values.
