The same failure reported four ways. Only the last one can be acted on without opening the code.
raise ValueError('invalid input')
# which input? invalid how?
raise ValueError('invalid port')
# now we know the field, and nothing else
raise ValueError(f'invalid port: {text}')
# better — but 'invalid port: 80 ' hides the trailing space
raise ValueError(f'port must be between 1 and 65535, got {number}')
# the field, the rule, and the value that broke it
- What, expected, received
Name the thing being read, say what would have been acceptable, and show what actually arrived. Any message missing one of the three sends the reader back to the source. The rule scales: a parser reporting a line number, a validator reporting a field path, and an API reporting a request id are all the same three pieces with 'where' added.
- !r when the value could be mistaken for something else
f'{text}' prints 80 and '80' and '80 ' identically, which is exactly the difference you are trying to show somebody. !r calls repr and wraps strings in quotes, so whitespace and type become visible. Use it for anything that came from outside; plain formatting is fine for a number you computed yourself.
- Two causes deserve two messages
'abc' is a typo and 70000 is a misunderstanding, and the fixes are different — one is 'check what you typed', the other is 'that number is too big'. Collapsing them into one message saves a line of code and costs every future reader the distinction. Each distinct cause gets its own sentence.
Two checks in order: can this be read as a whole number at all, and is that number in range. They need different messages because they have different fixes.
Wrap the int() in try/except ValueError and raise your own with the original text in it. Then compare against the range. Use !r for the text and plain formatting for the number you have already parsed.
Put the bounds in named constants, so the message and the comparison cannot drift apart — build the message from the same names the check uses. `raise ... from error` keeps the original conversion error as the cause.
LOWEST = 1 HIGHEST = 65535 def parse_port(text): try: number = int(text) except ValueError as error: raise ValueError( f’port must be a whole number, got {text!____}' ) from error if not (LOWEST <= number <= ____): raise ____( f’port must be between {LOWEST} and {HIGHEST}, got {number}' ) return ____
LOWEST = 1
HIGHEST = 65535
def parse_port(text):
try:
number = int(text)
except ValueError as error:
# !r so that '', ' 80 ' and 80 are all distinguishable.
raise ValueError(
f"port must be a whole number, got {text!r}"
) from error
if not LOWEST <= number <= HIGHEST:
raise ValueError(
f"port must be between {LOWEST} and {HIGHEST}, got {number}"
)
return number
If the range ever changes, the comparison and the message change together, because they are the same two names. Hard-coding '1 and 65535' into the string is the classic way to end up with a message confidently stating a rule the code stopped enforcing two years ago.
The text came from outside and its exact form is the evidence — quotes, spaces, type. The number was parsed by this function, so its repr adds nothing and 70000 reads better than it would with quotes round it. The rule of thumb: repr what you were given, format what you computed.
The traceback shows both the helpful message and Python’s own 'invalid literal for int()' underneath it, in the right order. Without the from clause the chain still appears, prefixed by 'During handling of the above exception, another occurred', which reads as though your error handler is what broke.
Do not put values into a message when they are secret: a password, a token or a full card number in an exception ends up in a log file, an error tracker and a support ticket. Name the field and its length or shape instead. Equally, an error message is for the person who can fix the problem — if that person is an end user rather than an operator, they want 'that port number is not available' and not a range they never chose, so keep the detailed message for the log and translate at the boundary. argparse and Pydantic both already produce messages of this quality for the inputs they handle.
- Add the name of the setting as an argument, so the same function can report 'admin_port must be...', and see how much better the message gets for one parameter.
- Add a suggestion to the range message — 'did you mean 7000?' — and decide whether guessing is a kindness or a distraction.
- Look at the last three exceptions you raised in your own code and check each one against the what-expected-received rule.