cronometer-mcp 2.4.0__tar.gz → 2.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. cronometer_mcp-2.4.2/.github/workflows/drift.yml +56 -0
  2. cronometer_mcp-2.4.2/AGENTS.md +37 -0
  3. {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/PKG-INFO +1 -1
  4. {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/cronometer_mcp/__init__.py +1 -1
  5. {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/cronometer_mcp/client.py +11 -11
  6. {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/pyproject.toml +10 -1
  7. {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/server.json +2 -2
  8. {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/tests/test_client.py +41 -1
  9. cronometer_mcp-2.4.2/tests/test_drift.py +117 -0
  10. cronometer_mcp-2.4.2/tests/test_live.py +136 -0
  11. cronometer_mcp-2.4.2/tests/test_live_gate.py +73 -0
  12. cronometer_mcp-2.4.0/AGENTS.md +0 -15
  13. {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/.github/workflows/publish.yml +0 -0
  14. {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/.gitignore +0 -0
  15. {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/CLAUDE.md +0 -0
  16. {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/LICENSE +0 -0
  17. {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/README.md +0 -0
  18. {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/RESEARCH-copy-repeat-gwt-rpc.md +0 -0
  19. {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/SPEC-diary-groups.md +0 -0
  20. {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/SPEC-streamable-http.md +0 -0
  21. {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/cronometer_mcp/markdown.py +0 -0
  22. {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/cronometer_mcp/server.py +0 -0
  23. {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/release.sh +0 -0
  24. {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/tests/__init__.py +0 -0
  25. {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/tests/test_diary_groups.py +0 -0
  26. {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/tests/test_markdown.py +0 -0
  27. {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/tests/test_server.py +0 -0
@@ -0,0 +1,56 @@
1
+ name: Cronometer drift check
2
+
3
+ # Daily check that the RPC templates still match Cronometer's live
4
+ # serialization policy (tests marked drift; no login, no credentials).
5
+ on:
6
+ schedule:
7
+ - cron: "0 11 * * *"
8
+ workflow_dispatch:
9
+
10
+ permissions:
11
+ contents: read
12
+ issues: write
13
+ actions: write
14
+
15
+ jobs:
16
+ drift:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+
21
+ - uses: actions/setup-python@v5
22
+ with:
23
+ python-version: "3.12"
24
+
25
+ - name: Install
26
+ run: pip install -e . pytest
27
+
28
+ - name: Run drift check
29
+ id: drift
30
+ shell: bash # implies pipefail, so tee keeps pytest's exit code
31
+ run: python -m pytest -m drift -q 2>&1 | tee drift.log
32
+
33
+ - name: Open or update the drift issue
34
+ if: failure() && steps.drift.outcome == 'failure'
35
+ env:
36
+ GH_TOKEN: ${{ github.token }}
37
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
38
+ run: |
39
+ gh label create drift --color B60205 --description "Cronometer API drift" 2>/dev/null || true
40
+ { echo "Drift check failed: $RUN_URL"; echo; echo '```'; tail -40 drift.log; echo '```'; } > body.md
41
+ open=$(gh issue list --label drift --state open --json number --jq '.[0].number')
42
+ if [ -n "$open" ]; then
43
+ gh issue comment "$open" --body-file body.md
44
+ else
45
+ gh issue create --title "Cronometer drift check failed" --label drift --body-file body.md
46
+ fi
47
+
48
+ # Scheduled workflows in public repos stop after 60 days without
49
+ # repository activity. Re-enabling each run may reset that clock, though
50
+ # GitHub doesn't document it, so a failure here must not fail the job.
51
+ - name: Keep the schedule alive
52
+ if: always()
53
+ continue-on-error: true
54
+ env:
55
+ GH_TOKEN: ${{ github.token }}
56
+ run: gh workflow enable drift.yml
@@ -0,0 +1,37 @@
1
+ # AGENTS.md
2
+
3
+ Instructions for coding agents working in this repo.
4
+
5
+ ## Releasing
6
+
7
+ - Always release with `./release.sh <version>`. Never bump `pyproject.toml`,
8
+ `cronometer_mcp/__init__.py` or `server.json` by hand, and never hand-roll the bump commit, tag
9
+ or push. The script checks for a clean tree and an unused tag, bumps all three files, commits,
10
+ tags `v<version>` and pushes `main` with tags.
11
+ - Before running it: the working tree is clean and `python -m pytest` passes.
12
+ - Before running it: an adversarial Codex review of the unreleased diff has said ship. From Bash:
13
+ `codex exec -c 'model_reasoning_effort="high"' -s read-only --skip-git-repo-check -o <out.md>
14
+ "<prompt>" < /dev/null` (the `< /dev/null` stops it hanging on stdin). Ask it to attack named
15
+ risks and reproduce, fix real findings with a failing test first, rerun until the verdict is
16
+ SHIP. `codex mcp-server` no longer exists, so there is no MCP tool for this.
17
+ - Keep pushes to one per release: commit feature work locally without pushing and let
18
+ `release.sh` make the only push (feature commits plus the bump commit and tag). Create the
19
+ GitHub release with `gh release create v<version> --notes-file -`; that is an API call, not a
20
+ push.
21
+ - The tag alone does not publish. Create a GitHub release from the tag; that triggers
22
+ `.github/workflows/publish.yml`, which publishes to PyPI and then publishes `server.json` to the
23
+ MCP Registry (GitHub OIDC, no secrets). Never publish to the registry by hand; if that job
24
+ fails, re-run it with a manual `workflow_dispatch` of the same workflow, which skips PyPI.
25
+
26
+ ## Drift and live tests
27
+
28
+ - `python -m pytest` stays offline: tests marked `drift` or `live` are deselected by default.
29
+ - `pytest -m drift` fetches Cronometer's live GWT serialization policy (no login) and fails if
30
+ any RPC template sends a type signature the policy no longer lists. `.github/workflows/drift.yml`
31
+ runs it daily and opens or comments on an issue labelled `drift`.
32
+ - `pytest -m live` logs in with `CRONOMETER_USERNAME` / `CRONOMETER_PASSWORD` and writes to the
33
+ real account. Don't run it casually. Writes go only to 2000-01-01 and are refused if that day
34
+ already has servings or biometrics (notes and exercises aren't checked).
35
+ - Cronometer rations CSV exports per account; going over returns HTTP 429 for hours and breaks the
36
+ MCP's own reads too. The live tests make exactly two exports per run. Don't add more, and don't
37
+ loop export calls while debugging.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cronometer-mcp
3
- Version: 2.4.0
3
+ Version: 2.4.2
4
4
  Summary: MCP server for Cronometer nutrition data — pull food logs, macros, and micronutrients via the Model Context Protocol
5
5
  Project-URL: Homepage, https://github.com/cphoskins/cronometer-mcp
6
6
  Project-URL: Repository, https://github.com/cphoskins/cronometer-mcp
@@ -1,6 +1,6 @@
1
1
  """Cronometer MCP Server — nutrition data from Cronometer via MCP."""
2
2
 
3
- __version__ = "2.4.0"
3
+ __version__ = "2.4.2"
4
4
 
5
5
  from .client import CronometerClient
6
6
  from .markdown import generate_food_log_md
@@ -94,11 +94,11 @@ GWT_UPDATE_DIARY = (
94
94
  "I|java.util.List|{nonce}|"
95
95
  "java.util.Collections$SingletonList/1586180994|"
96
96
  "com.cronometer.shared.entries.changes.AddEntryChange/3949104564|"
97
- "com.cronometer.shared.entries.models.Serving/2553599101|"
97
+ "com.cronometer.shared.entries.models.Serving/2895043750|"
98
98
  "com.cronometer.shared.entries.models.Day/782579793|"
99
99
  "1|2|3|4|3|5|6|7|8|{user_id}|9|10|1|1|11|12|"
100
100
  "{day}|{month}|{year}|{quantity}|{diary_group}|0|{measure_id}|0|0|"
101
- "{weight_grams}|{food_source_id}|A|{food_id}|0|1|"
101
+ "{weight_grams}|{food_source_id}|A|{food_id}|0|"
102
102
  )
103
103
 
104
104
  GWT_REMOVE_SERVING = (
@@ -1425,13 +1425,13 @@ class CronometerClient:
1425
1425
 
1426
1426
  raw = self._gwt_post(body)
1427
1427
 
1428
- # Response format (example):
1429
- # //OK[0,0,1072101,"D80lp$",464674,50.0,2107848,0,65541,0,1,1,2026,3,4,
1428
+ # Response format (example, live since 2026-10-30, issue #13):
1429
+ # //OK[0,1072101,"FAoVo9",464674,50.0,2107848,0,321007,0,1,1,2026,10,30,
1430
1430
  # 4,3,2,1,1,["java.util.ArrayList/...","com.cronometer..."],0,7]
1431
1431
  # Positional layout (0-indexed from the inner array start):
1432
- # index 3 → serving_id (quoted string)
1433
- # index 2 → food_id
1434
- # index 4 → food_source_id
1432
+ # index 2 → serving_id (quoted string)
1433
+ # index 1 → food_id
1434
+ # index 3 → food_source_id
1435
1435
  inner_match = re.search(r"//OK\[(.+),\d+,7\]$", raw, re.DOTALL)
1436
1436
  if not inner_match:
1437
1437
  raise RuntimeError(
@@ -1440,10 +1440,10 @@ class CronometerClient:
1440
1440
 
1441
1441
  inner = inner_match.group(1)
1442
1442
  # The response layout is:
1443
- # 0,0,{food_id},"{serving_id}",{food_source_id},{weight},... ,[string_table],0,7
1444
- # Match the first five meaningful fields directly from the full inner string.
1443
+ # 0,{food_id},"{serving_id}",{food_source_id},{weight},... ,[string_table],0,7
1444
+ # Match the first four meaningful fields directly from the full inner string.
1445
1445
  fields_match = re.match(
1446
- r"\d+,\d+,(\d+),\"([^\"]+)\",(\d+),",
1446
+ r"\d+,(\d+),\"([^\"]+)\",(\d+),",
1447
1447
  inner,
1448
1448
  )
1449
1449
  if not fields_match:
@@ -2300,7 +2300,7 @@ class CronometerClient:
2300
2300
  biometric_id = ""
2301
2301
  if raw.startswith("//OK["):
2302
2302
  import re
2303
- match = re.search(r'"([A-Za-z0-9]+)"', raw)
2303
+ match = re.search(r'"([A-Za-z0-9$_]+)"', raw)
2304
2304
  if match:
2305
2305
  biometric_id = match.group(1)
2306
2306
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cronometer-mcp"
7
- version = "2.4.0"
7
+ version = "2.4.2"
8
8
  description = "MCP server for Cronometer nutrition data — pull food logs, macros, and micronutrients via the Model Context Protocol"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -40,3 +40,12 @@ cronometer-mcp = "cronometer_mcp.server:main"
40
40
 
41
41
  [tool.hatch.build.targets.wheel]
42
42
  packages = ["cronometer_mcp"]
43
+
44
+ [tool.pytest.ini_options]
45
+ # drift and live tests hit cronometer.com; a plain run stays offline.
46
+ # Select them with -m drift or -m live (a later -m overrides this one).
47
+ addopts = "-m 'not drift and not live'"
48
+ markers = [
49
+ "drift: checks Cronometer's live GWT policy; network, no login",
50
+ "live: logs in to a real Cronometer account and writes to 2000-01-01",
51
+ ]
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/cphoskins/cronometer-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.4.0",
9
+ "version": "2.4.2",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "pypi",
13
13
  "identifier": "cronometer-mcp",
14
- "version": "2.4.0",
14
+ "version": "2.4.2",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -897,8 +897,9 @@ class TestAddServing:
897
897
  return c
898
898
 
899
899
  def _mock_update_response(self, serving_id="D9TEST", food_id=502518, fsid=55985):
900
+ # Live layout since issue #13: one leading field fewer than before.
900
901
  return (
901
- f'//OK[0,0,{food_id},"{serving_id}",{fsid},170.0,2107848,0,'
902
+ f'//OK[0,{food_id},"{serving_id}",{fsid},170.0,2107848,0,'
902
903
  f'124399,0,1,1,2026,3,5,4,3,2,1,1,'
903
904
  f'["java.util.ArrayList/4159755760"],0,7]'
904
905
  )
@@ -993,6 +994,32 @@ class TestAddServing:
993
994
  # Should contain "170|" not "170.0|"
994
995
  assert "170|" in call_body
995
996
 
997
+ def test_serving_matches_live_serialization_policy(self):
998
+ """Serving uses the current type signature and field layout (issue #13).
999
+
1000
+ Cronometer's Oct 2026 deploy changed Serving to signature 2895043750
1001
+ and dropped its last serialized field: after the Day it writes
1002
+ bool, bool, obj, int, obj, int, double, int, long, int, int.
1003
+ """
1004
+ c = self._make_client()
1005
+ c.session.post = MagicMock(
1006
+ return_value=MagicMock(
1007
+ text=self._mock_update_response(), raise_for_status=lambda: None
1008
+ )
1009
+ )
1010
+
1011
+ c.add_serving(
1012
+ food_id=1072101, food_source_id=464674,
1013
+ measure_id=5, quantity=1, weight_grams=50,
1014
+ day=date(2026, 3, 4), diary_group=1,
1015
+ )
1016
+
1017
+ call_body = c.session.post.call_args[1].get("data") or c.session.post.call_args[0][1]
1018
+ assert "com.cronometer.shared.entries.models.Serving/2895043750|" in call_body
1019
+ assert call_body.endswith(
1020
+ "|11|12|4|3|2026|1|1|0|65541|0|0|50|464674|A|1072101|0|"
1021
+ )
1022
+
996
1023
 
997
1024
  class TestRemoveServing:
998
1025
  def test_remove_success(self):
@@ -2015,3 +2042,16 @@ class TestDeleteRepeatItem:
2015
2042
  )
2016
2043
  with pytest.raises(RuntimeError, match="GWT-RPC call failed"):
2017
2044
  c.delete_repeat_item(999999)
2045
+
2046
+
2047
+ class TestAddBiometric:
2048
+ def test_id_with_gwt_long_chars(self):
2049
+ """GWT long ids use A-Z a-z 0-9 $ _; all must survive parsing."""
2050
+ c = CronometerClient(username="test@x.com", password="pw")
2051
+ c._authenticated = True
2052
+ c.nonce = "n"
2053
+ c.user_id = "42"
2054
+ c.session.post = MagicMock(return_value=MagicMock(
2055
+ text='//OK["B$x_9P",[],0,7]', raise_for_status=lambda: None,
2056
+ ))
2057
+ assert c.add_biometric("weight", 150, date(2000, 1, 1)) == "B$x_9P"
@@ -0,0 +1,117 @@
1
+ """Drift check: do the RPC templates still match Cronometer's live policy?
2
+
3
+ A GWT type signature is a checksum of the class's serialized fields, so a
4
+ field change on Cronometer's side (issue #13) shows up here as a missing
5
+ signature before any user hits it. Run with ``pytest -m drift``.
6
+ """
7
+
8
+ import re
9
+
10
+ import pytest
11
+ import requests
12
+
13
+ from cronometer_mcp import client
14
+
15
+ # Templates nothing calls any more; their stale types are expected.
16
+ DEAD_TEMPLATES = {"GWT_FIND_FOODS"}
17
+
18
+ SIGNATURE_RE = re.compile(r"[\w.$\[;]+/\d{6,}")
19
+
20
+
21
+ def template_signatures() -> dict[str, set[str]]:
22
+ """Type signatures used by each live GWT_* RPC template in client.py."""
23
+ return {
24
+ name: set(SIGNATURE_RE.findall(value))
25
+ for name, value in vars(client).items()
26
+ if name.startswith("GWT_")
27
+ and isinstance(value, str)
28
+ and name not in DEAD_TEMPLATES
29
+ and SIGNATURE_RE.search(value)
30
+ }
31
+
32
+
33
+ def policy_signatures(policy_text: str) -> set[str]:
34
+ """Signatures listed in a GWT .gwt.rpc serialization policy file."""
35
+ sigs = set()
36
+ for line in policy_text.splitlines():
37
+ cols = [c.strip() for c in line.split(",")]
38
+ if len(cols) >= 7:
39
+ sigs.add(cols[5])
40
+ return sigs
41
+
42
+
43
+ def missing_signatures(
44
+ templates: dict[str, set[str]], policy: set[str]
45
+ ) -> dict[str, set[str]]:
46
+ """Template name -> signatures the policy no longer accepts."""
47
+ assert templates and policy, "empty scan: the check would pass without checking"
48
+ return {
49
+ name: sigs - policy for name, sigs in templates.items() if sigs - policy
50
+ }
51
+
52
+
53
+ def fetch_live_policy() -> str:
54
+ """Fetch the policy the same way the client discovers its hashes."""
55
+ s = requests.Session()
56
+ s.headers.update({"User-Agent": "cronometer-mcp/0.1"})
57
+ nocache = s.get(client.GWT_NOCACHE_JS_URL, timeout=30)
58
+ nocache.raise_for_status()
59
+ perm = re.search(r"='([A-F0-9]{32})'", nocache.text)
60
+ assert perm, "permutation hash not found in nocache.js: discovery is broken"
61
+
62
+ cache = s.get(
63
+ client.GWT_CACHE_JS_URL.replace("{permutation}", perm.group(1)), timeout=60
64
+ )
65
+ cache.raise_for_status()
66
+ header = re.search(r"'app','([A-F0-9]{32})'", cache.text)
67
+ assert header, "GWT header not found in cache.js: discovery is broken"
68
+
69
+ policy = s.get(f"{client.DEFAULT_GWT_MODULE_BASE}{header.group(1)}.gwt.rpc",
70
+ timeout=30)
71
+ policy.raise_for_status()
72
+ return policy.text
73
+
74
+
75
+ def test_missing_signatures_names_changed_type():
76
+ """Offline: the old Serving signature from issue #13 is reported by name."""
77
+ templates = {"GWT_UPDATE_DIARY": {
78
+ "com.cronometer.shared.entries.models.Serving/2553599101",
79
+ "com.cronometer.shared.entries.models.Day/782579793",
80
+ }}
81
+ policy = policy_signatures(
82
+ "com.cronometer.shared.entries.models.Serving, true, true, true, true, "
83
+ "com.cronometer.shared.entries.models.Serving/2895043750, 2895043750\n"
84
+ "com.cronometer.shared.entries.models.Day, true, true, true, true, "
85
+ "com.cronometer.shared.entries.models.Day/782579793, 782579793\n"
86
+ )
87
+ assert missing_signatures(templates, policy) == {
88
+ "GWT_UPDATE_DIARY": {"com.cronometer.shared.entries.models.Serving/2553599101"}
89
+ }
90
+
91
+
92
+ def test_empty_scan_cannot_pass():
93
+ """Offline: an empty template or policy scan is an error, not a clean result."""
94
+ with pytest.raises(AssertionError):
95
+ missing_signatures({}, set())
96
+ with pytest.raises(AssertionError):
97
+ missing_signatures({}, {"a.B/123456"})
98
+ with pytest.raises(AssertionError):
99
+ missing_signatures({"GWT_X": {"a.B/123456"}}, set())
100
+
101
+
102
+ def test_templates_found():
103
+ """Offline: the scan sees the write templates, so a pass means something."""
104
+ names = template_signatures()
105
+ assert "GWT_UPDATE_DIARY" in names
106
+ assert "GWT_FIND_FOODS" not in names
107
+
108
+
109
+ @pytest.mark.drift
110
+ def test_templates_match_live_policy():
111
+ missing = missing_signatures(
112
+ template_signatures(), policy_signatures(fetch_live_policy())
113
+ )
114
+ assert not missing, (
115
+ "Cronometer's serialization policy no longer accepts these types; "
116
+ f"the class changed and the template needs re-deriving: {missing}"
117
+ )
@@ -0,0 +1,136 @@
1
+ """Live smoke tests against a real Cronometer account.
2
+
3
+ Run with ``pytest -m live``; needs CRONOMETER_USERNAME and CRONOMETER_PASSWORD.
4
+
5
+ Cronometer rations its CSV export per account (HTTP 429 for hours once
6
+ exceeded, which also breaks the MCP's own reads), so a run makes exactly two
7
+ export calls, both for TEST_DAY: servings and biometrics. Both must be real
8
+ CSVs with no rows before anything is written; other entry types (notes,
9
+ exercises) aren't checked, since that would take more exports and the writes
10
+ only remove the ids they added. Writes touch TEST_DAY only and are checked by
11
+ add-then-remove of the returned id, without further exports. The first failure
12
+ stops the writes. A failed remove is never cleaned up automatically; the next
13
+ run's empty check refuses to write until a person has looked.
14
+ """
15
+
16
+ import csv
17
+ import io
18
+ import os
19
+ from datetime import date, timedelta
20
+
21
+ import pytest
22
+
23
+ from cronometer_mcp.client import CronometerClient
24
+
25
+ pytestmark = pytest.mark.live
26
+
27
+ TEST_DAY = date(2000, 1, 1)
28
+ SERVINGS_COLUMNS = {"Day", "Group", "Food Name", "Amount"}
29
+ BIOMETRICS_COLUMNS = {"Day", "Metric", "Unit", "Amount"}
30
+ TODAY = date.today()
31
+ WEEK_AGO = TODAY - timedelta(days=7)
32
+
33
+
34
+ @pytest.fixture(scope="module")
35
+ def c():
36
+ client = CronometerClient()
37
+ client.authenticate()
38
+ return client
39
+
40
+
41
+ def refuse_parallel_run():
42
+ # Each xdist worker would make its own two exports.
43
+ if os.environ.get("PYTEST_XDIST_WORKER"):
44
+ pytest.fail("Live tests must not run in parallel: export budget is per run.")
45
+
46
+
47
+ @pytest.fixture(scope="module")
48
+ def test_day_exports(c):
49
+ """The run's only two exports: (servings CSV text, biometrics CSV text)."""
50
+ refuse_parallel_run()
51
+ return (
52
+ c.export_raw("servings", TEST_DAY, TEST_DAY),
53
+ c.export_raw("biometrics", TEST_DAY, TEST_DAY),
54
+ )
55
+
56
+
57
+ def require_clean_day(servings_csv: str, biometrics_csv: str) -> None:
58
+ """Fail unless both exports are well-formed CSVs with no rows."""
59
+ exports = (
60
+ ("servings", servings_csv, SERVINGS_COLUMNS),
61
+ ("biometrics", biometrics_csv, BIOMETRICS_COLUMNS),
62
+ )
63
+ for name, text, columns in exports:
64
+ try:
65
+ rows = list(csv.reader(io.StringIO(text), strict=True))
66
+ except csv.Error as e:
67
+ pytest.fail(f"The {name} export is not valid CSV ({e}). Not writing.")
68
+ if not rows or not columns <= set(rows[0]):
69
+ pytest.fail(f"The {name} export is missing columns. Not writing.")
70
+ if rows[1:]:
71
+ pytest.fail(
72
+ f"{len(rows) - 1} leftover {name} rows on {TEST_DAY}; "
73
+ "clean up first. Not writing."
74
+ )
75
+
76
+
77
+ def write_round_trips(c) -> None:
78
+ """Add then remove one serving, then one biometric. Stops at the first failure."""
79
+ food = c.find_foods("egg")[0]
80
+ added = c.add_serving(
81
+ food_id=food["food_id"], food_source_id=food["food_source_id"],
82
+ measure_id=0, quantity=50, weight_grams=50, day=TEST_DAY,
83
+ )
84
+ assert added["serving_id"]
85
+ assert c.remove_serving(added["serving_id"])
86
+
87
+ biometric_id = c.add_biometric("weight", 150, TEST_DAY)
88
+ assert biometric_id, "addBiometric succeeded but its id could not be parsed"
89
+ assert c.remove_biometric(biometric_id)
90
+
91
+
92
+ # ── Reads ─────────────────────────────────────────────────────────────
93
+
94
+
95
+ def test_servings_export_columns(test_day_exports):
96
+ header = next(csv.reader(io.StringIO(test_day_exports[0])), [])
97
+ assert SERVINGS_COLUMNS <= set(header)
98
+
99
+
100
+ def test_food_search_and_details(c):
101
+ hits = c.find_foods("egg")
102
+ assert hits and {"food_id", "food_source_id", "name"} <= hits[0].keys()
103
+ assert c.get_food(hits[0]["food_source_id"])
104
+
105
+
106
+ def test_diary_groups_parse(c):
107
+ # diary_groups() falls back to defaults on a parse failure; catch that here.
108
+ groups = c._fetch_diary_groups()
109
+ assert groups and all("name" in g and "wire_index" in g for g in groups)
110
+
111
+
112
+ def test_recent_biometrics(c):
113
+ assert isinstance(c.get_recent_biometrics(), list)
114
+
115
+
116
+ def test_macro_targets(c):
117
+ assert isinstance(c.get_all_macro_schedules(), list)
118
+ assert c.get_daily_macro_targets(TODAY)
119
+ assert isinstance(c.get_macro_target_templates(), list)
120
+
121
+
122
+ def test_fasting(c):
123
+ assert isinstance(c.get_user_fasts_for_range(WEEK_AGO, TODAY), list)
124
+ assert isinstance(c.get_fasting_stats(), dict)
125
+
126
+
127
+ def test_repeated_items(c):
128
+ assert isinstance(c.get_repeated_items(), list)
129
+
130
+
131
+ # ── Writes (TEST_DAY only) ────────────────────────────────────────────
132
+
133
+
134
+ def test_write_round_trips(c, test_day_exports):
135
+ require_clean_day(*test_day_exports)
136
+ write_round_trips(c)
@@ -0,0 +1,73 @@
1
+ """Offline: the safety checks that guard the live tests' writes."""
2
+
3
+ import pytest
4
+
5
+ from tests.test_live import TEST_DAY, refuse_parallel_run, require_clean_day, write_round_trips
6
+
7
+ SERVINGS_HEADER = "Day,Time,Group,Food Name,Amount\n"
8
+ BIOMETRICS_HEADER = "Day,Group,Metric,Unit,Amount\n"
9
+
10
+
11
+ class FakeClient:
12
+ def __init__(self, serving_removed=True):
13
+ self.serving_removed = serving_removed
14
+ self.biometric_added = False
15
+
16
+ def find_foods(self, query):
17
+ return [{"food_id": 1, "food_source_id": 2}]
18
+
19
+ def add_serving(self, **kwargs):
20
+ assert kwargs["day"] == TEST_DAY
21
+ return {"serving_id": "S1"}
22
+
23
+ def remove_serving(self, serving_id):
24
+ return self.serving_removed
25
+
26
+ def add_biometric(self, metric, amount, day):
27
+ assert day == TEST_DAY
28
+ self.biometric_added = True
29
+ return "B1"
30
+
31
+ def remove_biometric(self, biometric_id):
32
+ return True
33
+
34
+
35
+ def test_clean_day_passes():
36
+ require_clean_day(SERVINGS_HEADER, BIOMETRICS_HEADER)
37
+
38
+
39
+ @pytest.mark.parametrize("servings,biometrics", [
40
+ ("", BIOMETRICS_HEADER),
41
+ (SERVINGS_HEADER, ""),
42
+ ("<html>error</html>\n", BIOMETRICS_HEADER),
43
+ ("Day\n", BIOMETRICS_HEADER),
44
+ (SERVINGS_HEADER, "Day,Unexpected\n"),
45
+ ('Day,"unterminated\n', BIOMETRICS_HEADER),
46
+ (SERVINGS_HEADER, 'Day,"unterminated\n'),
47
+ (SERVINGS_HEADER, BIOMETRICS_HEADER + '2000-01-01,"unterminated\n'),
48
+ (SERVINGS_HEADER, "<html>error</html>\n"),
49
+ (SERVINGS_HEADER + "2000-01-01,,Breakfast,Egg,50\n", BIOMETRICS_HEADER),
50
+ (SERVINGS_HEADER, BIOMETRICS_HEADER + "2000-01-01,,Weight,lbs,150\n"),
51
+ ])
52
+ def test_unclean_or_malformed_day_refuses_writes(servings, biometrics):
53
+ with pytest.raises(pytest.fail.Exception):
54
+ require_clean_day(servings, biometrics)
55
+
56
+
57
+ def test_failed_serving_remove_stops_further_writes():
58
+ fake = FakeClient(serving_removed=False)
59
+ with pytest.raises(AssertionError):
60
+ write_round_trips(fake)
61
+ assert not fake.biometric_added
62
+
63
+
64
+ def test_round_trips_run_both_writes():
65
+ fake = FakeClient()
66
+ write_round_trips(fake)
67
+ assert fake.biometric_added
68
+
69
+
70
+ def test_parallel_run_refused(monkeypatch):
71
+ monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw0")
72
+ with pytest.raises(pytest.fail.Exception):
73
+ refuse_parallel_run()
@@ -1,15 +0,0 @@
1
- # AGENTS.md
2
-
3
- Instructions for coding agents working in this repo.
4
-
5
- ## Releasing
6
-
7
- - Always release with `./release.sh <version>`. Never bump `pyproject.toml`,
8
- `cronometer_mcp/__init__.py` or `server.json` by hand, and never hand-roll the bump commit, tag
9
- or push. The script checks for a clean tree and an unused tag, bumps all three files, commits,
10
- tags `v<version>` and pushes `main` with tags.
11
- - Before running it: the working tree is clean and `python -m pytest` passes.
12
- - The tag alone does not publish. Create a GitHub release from the tag; that triggers
13
- `.github/workflows/publish.yml`, which publishes to PyPI and then publishes `server.json` to the
14
- MCP Registry (GitHub OIDC, no secrets). Never publish to the registry by hand; if that job
15
- fails, re-run it with a manual `workflow_dispatch` of the same workflow, which skips PyPI.
File without changes
File without changes
File without changes