feat(log): --serving-amount + --serving-unit flags for unit overrides#26
Merged
Conversation
Phase 1 of food-id-flow spec. Lowercase hex (32 chars) is the user-facing alias for the 16-byte SimplePrimaryKey; ints in [-128, 127] remain the SDK-internal 'response form'.
Phase 2 of food-id-flow spec. Wire shape matches the 2026-06-11 official-UI capture (UserId envelope + 16-byte SimplePrimaryKey, no name/locale). Returns a FoodSearchResult so the caller can pipe it straight into get_unsaved_food_log_entry.
Phase 3 of food-id-flow spec. The 32-char hex food_id appears alongside the existing pk_bytes in JSON, and as a truncated 10-char column in the text table. Lays the groundwork for log --food-id.
Phase 4 of food-id-flow spec. `lose-it log --food-id <hex>` skips search + pick and goes straight through getFood -> unsaved-entry -> log. The positional query becomes optional; the two paths are mutually exclusive (validated up-front with exit 2). Dry-run + success lines include a (id <prefix>...) tag.
Adds src/lose_it_utils/client/_units.py with CONVERSIONS, UNIT_ALIASES, CANONICAL_UNIT_NAMES, resolve_unit(), conversion_factor() — the unit-conversion table the official UI uses (236.5882365 mL/US cup, etc). No string parsing; the CLI passes --serving-unit as a discrete value and this module just looks it up. Threads (measure_ord_override, quantity_in_chosen_unit, conversion_factor) through entries._build_log_payload + entries.log_food. When set as a triple, the FoodServingSize wire block emits the chosen-unit ord, the universal conversion factor, and the user's raw input — matching the official UI's outbound payload shape. Legacy callers (no override) are unchanged. Spec: docs/serving-unit-spec.md (Phase 1).
Phase 5 of food-id-flow spec. Documents the stable Food ID alongside the existing --pick examples and adds a one-liner --food-id invocation showing the drift-proof path.
After --grams is rewritten internally to --serving-amount/--serving-unit, the rejection error flows through 'unit_not_supported' instead of the legacy 'not_gram_measured' code. Adjust assertions accordingly.
- Upfront validation: both-or-neither, mutual exclusion with --servings and --grams, positive-amount check, resolve_unit for unit-name → ordinal. - --grams N now rewrites to serving_amount=N, serving_unit=g so the override path is the single code path. - After get_unsaved_food_log_entry, look up the conversion factor for (native_ord, chosen_ord). If unsupported, exit 2 with a clear message (Phase 3 alternatives are TODO). - Compute canonical_servings = serving_amount / factor and forward (measure_ord_override, quantity_in_chosen_unit, conversion_factor) to entries.log_food. - Dry-run output now renders in the user's chosen unit. Spec: docs/serving-unit-spec.md (Phase 2).
tests/conformance/test_units.py covers the Phase 1 case table from docs/serving-unit-spec.md: resolve_unit dispatch (cup/mL/g/fl_oz with case-insensitivity, oz-ambiguity rejection, unknown-unit rejection) and conversion_factor lookups (cup→mL ≈ 236.588, cup→g returns None, mL→cup reciprocal, diagonal entries = 1.0 except grams special case). tests/conformance/test_entries_serving_unit.py byte-compares the FoodServingSize block emitted by _build_log_payload against the spec's captured wire snippet (27|<canonical>|1|28|11|1|236.5882365|490|), and asserts the FoodNutrients HashMap is per-serving (no double-scaling regression). Includes a legacy-path regression guard so the existing --servings/--grams behaviour is unchanged. Spec: docs/serving-unit-spec.md verification section.
…e --grams Adds FoodMeasurement IntEnum + decoder hook so every decoded FoodMeasure carries a plain-English 'unit' field. Surfaces in lose-it -o json output. 9 ordinals confirmed across >30 distinct foods: tbsp(2) cup(3) each(5) grams(8) fl_oz(10) mL(11) slice(26) serving(27) scoop(33). Parser now extracts FoodServingSize.f3/f4 — these encode the food's stored per-serving qty in its native unit (e.g. Built Bar f4=40 g/serv, TJ '8 fl oz' soup f4=8.0 fl_oz/serv, Core Power milkshake f4=414 mL/serv). Math becomes: chosen_qty_per_serving = (f4/f3) × generic_cf(native→chosen), then canonical_servings = user_amount / chosen_qty_per_serving. This fixes a class of bugs where '1 serving = 100 g' or '1 fl_oz = 1 serving' were assumed everywhere. The legacy --grams flag is removed (its math was off whenever a food's per-serving wasn't 100 g — e.g. Built Bar, Orgain scoop, Real Good chicken strips per the user's catalog). Use --serving-amount N --serving-unit g instead.
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the
--serving-amount <N>+--serving-unit <unit>flag pair onlose-it log, letting the CLI mirror the official UI'sdisplay-unit-override behavior for cup/mL/fl_oz/tbsp/g foods. The
existing
--grams Nflag is preserved as a legacy alias that internallyrewrites to
--serving-amount N --serving-unit g, so there is exactlyone code path through
_build_log_payload.Spec:
docs/serving-unit-spec.md(merged separately via PR #23).
Wire-byte change (FoodServingSize block)
For
lose-it log "...soup" --serving-amount 490 --serving-unit mLon acup-measured food (native ord = 3):
Before —
_build_log_payloadwithservings=2.07(legacy--servings):After —
_build_log_payloadwith override params:The override path matches the spec's wire-evidence snippet
27|2.071...|1|28|11|1.0|236.588|490|byte-for-byte modulofloat-precision serialization (the universal cup→mL constant is
236.5882365— the captured UI value236.58800000000002is a3-decimal-place rounded copy of the same constant; the spec explicitly
tolerates the precision difference).
CLI surface
--serving-amount 490 --serving-unit mL--serving-amount 490(no unit)--serving-unit mL(no amount)--serving-amount 0 --serving-unit mL--serving-amount 490 --serving-unit oz--serving-amount 490 --serving-unit mL --servings 2.0--serving-amount 490 --serving-unit mL --grams 100--grams 86(legacy)serving_amount=86, serving_unit=g; same wire payloadTests added
tests/conformance/test_units.py(14 tests):resolve_unitdispatch(case-insensitivity, oz-ambiguity rejection, unknown-unit rejection),
conversion_factorlookups (cup→mL ≈ 236.588, cup→g returns None,mL→cup reciprocal, diagonal entries = 1.0, grams special case = 100.0).
tests/conformance/test_entries_serving_unit.py(8 tests):byte-compares the FoodServingSize block against the spec's wire
evidence; regression-guards the per-serving FoodNutrients HashMap
(no double-scaling); legacy-path regression guard for the existing
--servings/--gramsbehavior.Total: 93 tests pass, 5 skipped, 0 failed.
Deferred / out of scope
The CLI currently exits with a clear
unit_not_supportederror;proactively probing the rest of the search results for an entry whose
native unit supports the requested one is left as a follow-up
(marked with
# TODO(serving-unit-spec.md Phase 3)incli.py).Rationale: the parallel
--food-idwork removes most of the need(the user can pin the right entry directly), so the suggestion table
is no longer urgent.
Composition with the parallel food-id-flow PR
This PR adds only the unit-override flow; the companion
--food-idflow (PR #TBD, branch
feat/food-id-flow) adds stable food references.The two compose cleanly:
Both PRs touch
cli.pyandentries.py, but in disjoint regions (thisPR adds new option params to
log's signature; the food-id PR adds--food-idnext toquery). A merge conflict is possible at theimport block; a 3-way merge will resolve it trivially.
Test plan
uv run pytest --no-cov— all 93 tests pass, 5 skipped (live-only).uv run prek run --all-files— clean.tests/conformance/test_units.pyadded per spec Phase 1 case table.tests/conformance/test_entries_serving_unit.pyadded withbyte-compare against the spec's wire-evidence snippet.
block emits
|27|2.0711088904878836|1|28|11|1|236.5882365|490|.constructed-fixture byte-compare is the load-bearing verification).