Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,8 @@ cached session, so it also overrides a stale cached timezone.
|------|-------------|
| `add_food_entry` | Log a food serving to the diary |
| `remove_food_entry` | Remove one or more diary entries |
| `add_custom_food` | Create a custom food with specified nutrition |
| `update_custom_food` | Edit a custom food in place: name, nutrition, or serving size |
| `add_custom_food` | Create a custom food with specified nutrition and optional barcodes |
| `update_custom_food` | Edit a custom food in place: name, nutrition, serving size, or barcodes |
| `delete_custom_food` | Retire a custom food so it leaves search and the Custom Foods list (existing diary entries are kept) |
| `add_recipe` | Create a recipe from existing foods referenced by ID and gram weight |
| `import_recipe` | Create a recipe from a free-text ingredient list; Cronometer matches each line to a database food and converts the amount to grams |
Expand Down
60 changes: 60 additions & 0 deletions src/cronometer_api_mcp/barcode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Product barcode (EAN, UPC and GTIN) validation for custom foods."""

import re


def normalize_barcodes(barcodes: list[str]) -> list[str]:
"""Validate product barcodes and return them as bare digit strings.

Accepts the GTIN family the app's scanner reads -- EAN-13, UPC-A (12),
EAN-8 or UPC-E (8) and GTIN-14 -- including the spaces or dashes the
digits are printed with under the bars ("0 12345 67890 5"). The check
digit is verified, so a digit misread off a label is rejected instead of
saved as a code that never scans. Duplicates are dropped. Bare digit
strings are what get_food reports for branded foods (["602652270284"]).
"""
normalized: list[str] = []
for raw in barcodes:
code = re.sub(r"[\s-]", "", str(raw))
if not (code.isascii() and code.isdigit()) or len(code) not in (8, 12, 13, 14):
raise ValueError(
f"barcode {raw!r} must be 8, 12, 13 or 14 digits (EAN, UPC or GTIN)"
)
if not _has_valid_check_digit(code):
raise ValueError(
f"barcode {raw!r} has the wrong check digit (last digit); "
f"re-check the digits"
)
if code not in normalized:
normalized.append(code)
return normalized


def _gtin_check_digit(body: str) -> int:
"""GS1 check digit for `body`: weights 3, 1, 3, ... from the right."""
total = sum(int(d) * (1 if i % 2 else 3) for i, d in enumerate(reversed(body)))
return -total % 10


def _has_valid_check_digit(code: str) -> bool:
if _gtin_check_digit(code[:-1]) == int(code[-1]):
return True
# An 8-digit code that isn't a valid EAN-8 can still be UPC-E, whose check
# digit is the one of the 12-digit UPC-A it abbreviates.
return (
len(code) == 8
and code[0] in "01"
and _gtin_check_digit(_expand_upc_e(code)) == int(code[-1])
)


def _expand_upc_e(code: str) -> str:
"""The UPC-A digits (check digit excluded) a UPC-E code abbreviates."""
system, d = code[0], code[1:7]
if d[5] in "012":
return system + d[0:2] + d[5] + "0000" + d[2:5]
if d[5] == "3":
return system + d[0:3] + "00000" + d[3:5]
if d[5] == "4":
return system + d[0:4] + "00000" + d[4]
return system + d[0:5] + "0000" + d[5]
19 changes: 19 additions & 0 deletions src/cronometer_api_mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@

import httpx

from .barcode import normalize_barcodes

logger = logging.getLogger(__name__)

BASE_URL = "https://mobile.cronometer.com"
Expand Down Expand Up @@ -659,6 +661,7 @@ def create_custom_food(
extra_nutrients: dict[int, float] | None = None,
serving_name: str = "1 serving",
serving_grams: float = 100.0,
barcodes: list[str] | None = None,
) -> dict:
"""Create a custom food in Cronometer.

Expand All @@ -676,9 +679,14 @@ def create_custom_food(
Must not reuse an ID already written by the named macro args
above; doing so raises ValueError rather than silently
duplicating or shadowing an entry.
barcodes: Product barcodes (EAN/UPC/GTIN) to attach; scanning one
in the app finds this food. Spaces and dashes are stripped and
the check digit verified; a bad code raises ValueError.

Returns {"food_id": int, "measure_id": int | None}.
"""
barcodes = normalize_barcodes(barcodes or [])

# Cronometer stores nutrients per 100g -- normalize from per-serving.
scale = 100.0 / serving_grams if serving_grams > 0 else 1.0

Expand Down Expand Up @@ -741,6 +749,7 @@ def create_custom_food(
"nutrients": nutrients,
"properties": {},
"foodTags": [],
"barcodes": barcodes,
},
"config": {"call_version": 1},
}
Expand Down Expand Up @@ -831,6 +840,7 @@ def update_custom_food(
serving_name: str | None = None,
serving_grams: float | None = None,
serving_quantity: float | None = None,
barcodes: list[str] | None = None,
) -> dict:
"""Edit a user-created custom food in place.

Expand All @@ -851,6 +861,9 @@ def update_custom_food(
reason. A quantity set without serving_grams keeps the total weight,
so the per-unit weight changes.

barcodes replaces the food's whole barcode list ([] clears it) and is
validated like create_custom_food's.

Returns {"food_id": int, "name": str}.
"""
if extra_nutrients:
Expand Down Expand Up @@ -882,9 +895,15 @@ def update_custom_food(
if v is not None and not (math.isfinite(v) and v > 0):
raise ValueError(f"{label} must be a positive number, got {v!r}")

if barcodes is not None:
barcodes = normalize_barcodes(barcodes)

with self._food_lock(food_id):
food = self._get_custom_food(food_id)

if barcodes is not None:
food["barcodes"] = barcodes

if name is not None:
old_name = food.get("name")
food["name"] = name
Expand Down
14 changes: 14 additions & 0 deletions src/cronometer_api_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,7 @@ def get_food_details(food_id: int) -> str:
"name": data.get("name"),
"default_measure_id": data.get("defaultMeasureId"),
"measures": measures,
"barcodes": data.get("barcodes", []),
"nutrients": data.get("nutrients", []),
}
)
Expand All @@ -589,6 +590,7 @@ def add_custom_food(
extra_nutrients: dict[int, float] | None = None,
serving_name: str = "1 serving",
serving_grams: float = 100.0,
barcodes: list[str] | None = None,
) -> str:
"""Create a custom food in Cronometer with specified nutrition.

Expand All @@ -613,6 +615,12 @@ def add_custom_food(
named macro args already cover.
serving_name: Name for the serving size (default "1 serving").
serving_grams: Weight of one serving in grams (default 100).
barcodes: Product barcodes (EAN-13, UPC-A, EAN-8, UPC-E or GTIN-14),
e.g. the digits printed under the bars on the package; spaces and
dashes are ignored. Scanning one of them in the Cronometer app
then finds this food (search_foods does not match barcodes). The
check digit is verified, so a misread digit is rejected rather
than saved.
"""
try:
client = _get_client()
Expand All @@ -629,6 +637,7 @@ def add_custom_food(
extra_nutrients=extra_nutrients,
serving_name=serving_name,
serving_grams=serving_grams,
barcodes=barcodes,
)

# Fetch back to get the server-assigned measure_id
Expand Down Expand Up @@ -663,6 +672,7 @@ def update_custom_food(
serving_name: str | None = None,
serving_grams: float | None = None,
serving_quantity: float | None = None,
barcodes: list[str] | None = None,
) -> str:
"""Edit an existing custom food (one you created) in place.

Expand Down Expand Up @@ -703,6 +713,9 @@ def update_custom_food(
gives "3 cookies = 30 g". To keep the per-unit weight, also pass
serving_grams (45 here). Check the result with get_food_details,
which lists each measure's quantity.
barcodes: Replaces the food's barcodes (see add_custom_food for the
accepted formats); pass [] to remove them all. To add one, pass
the current barcodes from get_food_details plus the new one.
"""
try:
client = _get_client()
Expand All @@ -721,6 +734,7 @@ def update_custom_food(
serving_name=serving_name,
serving_grams=serving_grams,
serving_quantity=serving_quantity,
barcodes=barcodes,
)
return _ok({"food_id": result["food_id"], "name": result["name"]})
except Exception as e:
Expand Down
68 changes: 68 additions & 0 deletions tests/test_barcode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Tests for barcode validation of custom foods.

normalize_barcodes accepts the GTIN family (EAN-13, UPC-A, EAN-8, UPC-E,
GTIN-14), strips the spaces and dashes codes are printed with, and verifies
the GS1 check digit so a misread digit is rejected rather than saved.
"""

from __future__ import annotations

import pytest

from cronometer_api_mcp.barcode import normalize_barcodes


@pytest.mark.parametrize(
("raw", "expected"),
[
("4006381333931", "4006381333931"), # EAN-13
("602652270284", "602652270284"), # UPC-A (from a real get_food)
("0 49000 05010 3", "049000050103"), # UPC-A as printed on a label
("9638-5074", "96385074"), # EAN-8
# UPC-E, one per expansion rule (the sixth digit picks it). The first
# three aren't valid EAN-8s, so only the UPC-E check accepts them;
# under rule 5-9 the two check digits always coincide.
("04252614", "04252614"), # -> UPC-A 042100005264
("01234531", "01234531"), # -> UPC-A 012300000451
("01234543", "01234543"), # -> UPC-A 012340000053
("01234565", "01234565"), # -> UPC-A 012345000065
("10012345678902", "10012345678902"), # GTIN-14
],
)
def test_normalize_barcodes_accepts_gtin_family(raw, expected):
assert normalize_barcodes([raw]) == [expected]


@pytest.mark.parametrize(
"raw",
[
"4006381333932", # EAN-13, last digit off by one
"602652270285", # UPC-A, wrong check digit
"04252615", # neither a valid EAN-8 nor a valid UPC-E
"24252618", # UPC-E check digit, but UPC-E only has number systems 0, 1
"01234566", # rule 5-9: wrong check digit as EAN-8 and as UPC-E
],
)
def test_normalize_barcodes_rejects_wrong_check_digit(raw):
with pytest.raises(ValueError, match="check digit"):
normalize_barcodes([raw])


@pytest.mark.parametrize(
"raw",
[
"",
"12345", # no GTIN has 5 digits
"400638133393a",
"4006381333931", # full-width digits: isdigit() but not ASCII
],
)
def test_normalize_barcodes_rejects_malformed(raw):
with pytest.raises(ValueError, match="must be 8, 12, 13 or 14 digits"):
normalize_barcodes([raw])


def test_normalize_barcodes_drops_duplicates():
assert normalize_barcodes(["4006381333931", "4006 3813 3393 1"]) == [
"4006381333931"
]
38 changes: 38 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,44 @@ def test_create_custom_food_extra_nutrients_overlap_raises(tmp_path):
)


# ---------------------------------------------------------------------------
# create_custom_food: barcodes
# ---------------------------------------------------------------------------


def _create_test_food(client, **kwargs):
return client.create_custom_food(
"Test Food", calories=200, protein_g=10, fat_g=5, carbs_g=20, **kwargs
)


def test_create_custom_food_sends_normalized_barcodes(tmp_path):
"""Barcodes go out as the food's `barcodes` list of bare digit strings,
the shape get_food reports for branded foods."""
client, state = make_cold_client(tmp_path, [{"result": "SUCCESS", "id": 555}])

_create_test_food(client, barcodes=["0 49000 05010 3", "4006381333931"])

data = state["payloads"][0]["data"]
assert data["barcodes"] == ["049000050103", "4006381333931"]


def test_create_custom_food_without_barcodes_sends_empty_list(tmp_path):
client, state = make_cold_client(tmp_path, [{"result": "SUCCESS", "id": 555}])

_create_test_food(client)

assert state["payloads"][0]["data"]["barcodes"] == []


def test_create_custom_food_bad_barcode_raises_before_request(tmp_path):
client, state = make_cold_client(tmp_path, [{"result": "SUCCESS", "id": 555}])

with pytest.raises(ValueError, match="check digit"):
_create_test_food(client, barcodes=["4006381333932"])
assert state["payloads"] == []


def test_enrich_diary_no_servings_skips_lookup(tmp_path):
client = _enrich_client(tmp_path)
calls = {"n": 0}
Expand Down
36 changes: 36 additions & 0 deletions tests/test_custom_food_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,42 @@ def test_update_custom_food_extra_nutrients(tmp_path):
client2.update_custom_food(FOOD_ID, extra_nutrients={204: 1.0})


def test_update_custom_food_replaces_barcodes(tmp_path):
"""barcodes replaces the whole list, normalized; nothing else changes."""
client, state = make_cold_client(tmp_path, [custom_food(barcodes=["96385074"]), OK])

client.update_custom_food(FOOD_ID, barcodes=["0 49000 05010 3"])

data = state["payloads"][1]["data"]
assert data["barcodes"] == ["049000050103"]
assert data["nutrients"] == custom_food()["nutrients"]
assert data["measures"] == custom_food()["measures"]


def test_update_custom_food_empty_barcodes_clears_them(tmp_path):
client, state = make_cold_client(tmp_path, [custom_food(barcodes=["96385074"]), OK])

client.update_custom_food(FOOD_ID, barcodes=[])

assert state["payloads"][1]["data"]["barcodes"] == []


def test_update_custom_food_keeps_barcodes_when_not_passed(tmp_path):
client, state = make_cold_client(tmp_path, [custom_food(barcodes=["96385074"]), OK])

client.update_custom_food(FOOD_ID, name="Renamed")

assert state["payloads"][1]["data"]["barcodes"] == ["96385074"]


def test_update_custom_food_rejects_bad_barcode_before_fetch(tmp_path):
client, state = make_cold_client(tmp_path, [])

with pytest.raises(ValueError, match="check digit"):
client.update_custom_food(FOOD_ID, barcodes=["4006381333932"])
assert state["payloads"] == []


def test_update_custom_food_refuses_database_foods(tmp_path):
"""Only user-created foods may be edited; a database food is rejected
before anything is sent to add_food."""
Expand Down
Loading