Migrating to v3¶
What changed in v3¶
Methods on the namespaced Fatsecret surface (fs.foods.*,
fs.recipes.*, fs.diary.*, fs.profile.*, fs.weight.*,
fs.exercises.*) that previously returned dict[str, Any] or
list[dict[str, Any]] now return typed Pydantic v2 models —
Food, Recipe, Profile, FoodEntry, ExerciseEntry,
Exercise, Day and friends. The wire-level behavior is
unchanged; only the in-Python return shape is new. pydantic>=2.7
is now a base dependency.
Quick fix for most callers¶
Replace square-bracket dict access with dotted attribute access:
# v2
food["food_id"]
food["food_name"]
# v3
food.food_id
food.food_name
If you have a downstream consumer that genuinely needs the dict
shape (JSON serialization, a templating layer, an existing
dict-typed signature you can’t change today) call .to_dict()
on the model. That’s the one-line escape hatch:
payload_for_legacy_consumer = food.to_dict()
.to_dict() is model.model_dump(mode="json") under the hood, so
Decimal values come back as JSON-safe strings — same shape your
v2 code was already handling off the wire.
Side-by-side examples¶
Single food lookup¶
food = fs.foods.get_v1(food_id=12345)
# v2
food["food_name"]
food["food_type"]
food["servings"]["serving"][0]["calories"]
# v3
food.food_name # str
food.food_type # Literal["Brand", "Generic"]
food.servings.serving[0].calories # Decimal
Food search¶
search_v1 returns a list of typed Food objects directly
(the wrapping foods/results envelope is unwrapped for you):
foods = fs.foods.search_v1(search_expression="banana")
# v2
for entry in foods:
print(entry["food_id"], entry["food_name"])
# v3
for entry in foods:
print(entry.food_id, entry.food_name) # int, str
Diary entries¶
import datetime
today = datetime.date.today()
entries = fs.diary.entries_get_v2(date=today) # list[FoodEntry]
# v2
for e in entries:
print(e["meal"], e["calories"], e["protein"])
# v3
for e in entries:
print(e.meal, e.calories, e.protein)
# e.meal -> Literal["Breakfast", "Lunch", "Dinner", "Other"]
# e.calories -> Decimal (not float!)
# e.protein -> Decimal
Recipe lookup¶
recipe = fs.recipes.get_v1(recipe_id=88339) # Recipe
# v2
recipe["recipe_name"]
recipe["recipe_nutrition"]["calories"]
# v3
recipe.recipe_name # str
recipe.recipe_nutrition.calories # Decimal
(Recipe is a friendly alias for the XSD-derived RecipesRecipe
class. Either name imports the same model.)
User profile¶
profile = fs.profile.get_v1() # Profile
# v2
profile["last_weight_kg"]
profile["weight_measure"]
# v3
profile.last_weight_kg # Optional[Decimal]
profile.weight_measure # Optional[Literal["Kg", "Lb"]]
Resources that still return dict¶
Four resource families intentionally kept their dict return
shape because the upstream XSD doesn’t model them. Nothing changed
for callers of these:
Food Classification (
fs.classification.*) — brands, categories, sub-categories.Saved Meals (
fs.meals.*) — saved meal CRUD and items.Native APIs (
fs.native.*) — image recognition, NLP.Feedback (
fs.feedback.*) — feedback submission.
If we get an XSD (or a stable enough HTML overlay) for any of these in the future they’ll be promoted to typed models in a minor release. The dict shape is the migration path; we won’t reshape it.
Mutators still return bool¶
Methods that write rather than read return a plain success boolean, not a model. No change from v2:
fs.weight.update_v1(...)->boolfs.diary.entry_create_v1(...)->list[FoodEntry](the resulting entries, typed)fs.diary.entry_edit_v1(...)->boolfs.diary.entry_delete_v1(...)->boolfs.recipes.add_favorite_v1(...)->boolfs.recipes.delete_favorite_v1(...)->bool
Type details worth knowing¶
Decimal for nutrition macros¶
Calories, protein, carbs, fat, micronutrients — all Decimal,
not float. The XSD says xsd:decimal and we honor it because
floating-point rounding in nutrition math compounds quickly across a
day’s worth of entries. Two consequences:
Arithmetic between a
Decimaland afloatraisesTypeError. UseDecimal(str(my_float))to lift, or convert the model value withfloat(entry.calories)if you genuinely want lossy float math.json.dumps(model)won’t work —Decimalisn’t JSON-serializable by default. Usemodel.model_dump_json()orjson.dumps(model.to_dict())(to_dictalready invokesmodel_dump(mode="json")which stringifies Decimals).
Literal for closed enums¶
Where the XSD declares an enum, the field is a Literal[...] —
the IDE autocompletes it and a typo lights up under mypy/pyright:
Food.food_type->Literal["Brand", "Generic"]FoodEntry.meal->Literal["Breakfast", "Lunch", "Dinner", "Other"]Profile.weight_measure->Optional[Literal["Kg", "Lb"]]Profile.height_measure->Optional[Literal["Cm", "Inch"]]
Ternary for FatSecret’s tri-state booleans¶
A handful of fields (Allergen.value, Preference.value) use
FatSecret’s quirky three-valued truthiness:
Ternary = Literal[-1, 0, 1, "Unknown", "True", "False"]
Both numeric and string forms are accepted because the upstream API
emits both depending on the endpoint version. Don’t compare with
is True / is False — use == "True" / == 1 or, more
defensively, normalize via a small helper.
extra="allow" — schema drift is non-fatal¶
Every model is configured with extra="allow". If FatSecret adds
a new field tomorrow that we haven’t regenerated for, the model
preserves it (accessible via model.__pydantic_extra__) instead of
rejecting the response. Drift is detected by CI’s
oas-regen-check, not by runtime validation failures — your
production code keeps working.
The .to_dict() migration helper¶
Every model inherits a .to_dict() method that returns the v2
dict shape:
food = fs.foods.get_v1(food_id=12345)
legacy_shape = food.to_dict() # {"food_id": "12345", "food_name": "...", ...}
Use it as a per-call escape hatch when migrating a large codebase
incrementally. Decimal becomes a string (mode="json"),
matching what v2 callers were already getting off the wire.
Frequently broken patterns¶
Iterating like a dict¶
# v2 — works on dicts
for k, v in food.items():
...
# v3 — Pydantic models aren't dicts
for k, v in food.model_dump().items():
...
JSON-serializing a model¶
# v2
json.dumps(food)
# v3 — pick one
food.model_dump_json() # returns a JSON string directly
json.dumps(food.to_dict()) # round-trips through the dict shape
.get() with a default¶
# v2
brand = food.get("brand_name", "Unknown")
# v3 — use getattr or check Optional
brand = food.brand_name or "Unknown"
# or, if the field could be missing on a model with extra="allow":
brand = getattr(food, "brand_name", None) or "Unknown"
Membership tests¶
# v2
if "brand_name" in food:
...
# v3
if food.brand_name is not None:
...
Why typed models?¶
IDE autocompletion.
food.<TAB>lists every field with its type. No more guessing whether the key isfood_nameorfoodNameorname.Real types.
food.food_idisint, notstr;serving.caloriesisDecimal, not whatever the wire happened to send. Pydantic coerces both directions, so legacy fixtures that serialized integers as strings still validate.Runtime validation catches schema drift. When FatSecret changes an XSD-typed field’s shape (rename, type change), validation fails loudly at the call site instead of silently propagating malformed data into your business logic.
Closed enums document themselves.
Literal["Brand", "Generic"]is the spec — your type checker will tell you if you compare against the wrong string.