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

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(...) -> bool

  • fs.diary.entry_create_v1(...) -> list[FoodEntry] (the resulting entries, typed)

  • fs.diary.entry_edit_v1(...) -> bool

  • fs.diary.entry_delete_v1(...) -> bool

  • fs.recipes.add_favorite_v1(...) -> bool

  • fs.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 Decimal and a float raises TypeError. Use Decimal(str(my_float)) to lift, or convert the model value with float(entry.calories) if you genuinely want lossy float math.

  • json.dumps(model) won’t work — Decimal isn’t JSON-serializable by default. Use model.model_dump_json() or json.dumps(model.to_dict()) (to_dict already invokes model_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 is food_name or foodName or name.

  • Real types. food.food_id is int, not str; serving.calories is Decimal, 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.