Three conversions. Two of them do what you expect and the third has ruined somebody’s afternoon in every language that has it.
int('8000') # -> 8000
float('3.5') # -> 3.5
bool('False') # -> True
bool('0') # -> True
bool('') # -> False
# bool(str) asks 'is this string non-empty', never 'what does it say'
int('3.0') # ValueError: invalid literal for int() with base 10
int(' 8000 ') # -> 8000, whitespace is handled for you
- bool() measures length, not meaning
Every non-empty string is truthy, so 'False', 'no', 'off' and '0' all come out True. The failure is silent and it defaults to on, which is why it tends to be discovered by finding debug logging in production rather than by a test. A boolean read from text needs a lookup table of the words you accept, and everything else has to be an error.
- int is stricter than people expect and kinder than they notice
It strips surrounding whitespace for you, so ' 8000 ' is fine and no .strip() is needed. It refuses '3.0', because a string holding a float is not a string holding an integer — if you want truncation you have to ask for it with int(float(text)) and say so. Refusing is right: '3.0' in an integer field usually means the data is not what you thought.
- The schema is data, so the loop is trivial
Passing {'port': int} rather than writing a branch per field means adding a field is a line of configuration, not a line of code, and the conversion logic exists once. The types are ordinary objects — int is a value you can put in a dictionary and call later, which is the same fact that makes key=int work in sorted().
Copy the record first, then walk the schema — not the record — converting each field it names. bool needs its own branch; the other types do not.
result = dict(record) gives you the copy. For each field and type in the schema, skip it when the field is absent. For bool, look the lowercased text up in a set of true words and a set of false words. For everything else, call the type and catch what it raises.
Catch both ValueError and TypeError around the conversion — int(None) raises TypeError, not ValueError — and re-raise a ValueError with the field name in it. `raise ... from error` keeps the original as the cause, so the traceback still shows what really happened.
TRUE = {'true', 'yes', 'on', '1'} FALSE = {'false', 'no', 'off', '0'} def coerce_types(record, schema): result = ____(record) for field, wanted in schema.items(): if field not in result: continue value = result[field] if wanted is ____: if isinstance(value, bool): continue text = str(value).strip().____() if text in TRUE: result[field] = True elif text in FALSE: result[field] = False else: raise ValueError(...) else: try: result[field] = ____(value) except (ValueError, TypeError) as error: raise ValueError( f'{field}: cannot read {value!r} as {wanted.__name__}' ) from error return result
TRUE = {"true", "yes", "on", "1"}
FALSE = {"false", "no", "off", "0"}
def coerce_types(record, schema):
result = dict(record)
for field, wanted in schema.items():
if field not in result:
continue
value = result[field]
if wanted is bool:
if isinstance(value, bool):
continue
text = str(value).strip().lower()
if text in TRUE:
result[field] = True
elif text in FALSE:
result[field] = False
else:
raise ValueError(
f"{field}: cannot read {value!r} as bool"
)
else:
try:
result[field] = wanted(value)
except (ValueError, TypeError) as error:
raise ValueError(
f"{field}: cannot read {value!r} as {wanted.__name__}"
) from error
return result
Every other type in the schema means 'call this'; bool means 'interpret this', because the built-in answers a different question from the one being asked. Writing that as a special case is not a wart — it is the code admitting something true about Python. The alternative, putting a parse_bool function in the schema instead of the type, is arguably cleaner and makes the schema less obvious to read.
int('abc') raises ValueError and int(None) raises TypeError, and a record read from JSON produces None constantly. Catching only ValueError lets the None case escape as a different exception type with a different message, so callers who carefully handle bad data still crash. When you wrap a conversion, wrap both of the ways it can fail.
The `from` clause attaches the original exception as the cause, so the traceback shows both your helpful message and the underlying 'invalid literal for int()'. Without it Python still chains them, but with 'During handling of the above exception, another occurred', which reads like your error handler is the thing that broke. One keyword, and the traceback tells the story in the right order.
This raises on the first bad field, which is fine for a config file read once at startup and hostile for a form with twelve fields — see require-fields for the collect-then-report shape, and combine the two if the caller is a person. Beyond a flat record with four types, stop: Pydantic and attrs do conversion, validation, nesting, defaults and error paths, and they have already argued about what 'off' means. And when the values come from environment variables specifically, pydantic-settings or environs handle the whole job including the boolean words.
- Collect every failure and report them together, the way require-fields does, and decide what the returned record should be when some fields converted and others did not.
- Allow a callable in the schema as well as a type, so {'tags': normalise_tags} works, and see how much of the special-casing for bool disappears.
- Add a strict flag that refuses record fields the schema does not mention, and work out whether that belongs in this function or a separate one.