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.
- cronometer_mcp-2.4.2/.github/workflows/drift.yml +56 -0
- cronometer_mcp-2.4.2/AGENTS.md +37 -0
- {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/PKG-INFO +1 -1
- {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/cronometer_mcp/__init__.py +1 -1
- {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/cronometer_mcp/client.py +11 -11
- {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/pyproject.toml +10 -1
- {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/server.json +2 -2
- {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/tests/test_client.py +41 -1
- cronometer_mcp-2.4.2/tests/test_drift.py +117 -0
- cronometer_mcp-2.4.2/tests/test_live.py +136 -0
- cronometer_mcp-2.4.2/tests/test_live_gate.py +73 -0
- cronometer_mcp-2.4.0/AGENTS.md +0 -15
- {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/.github/workflows/publish.yml +0 -0
- {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/.gitignore +0 -0
- {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/CLAUDE.md +0 -0
- {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/LICENSE +0 -0
- {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/README.md +0 -0
- {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/RESEARCH-copy-repeat-gwt-rpc.md +0 -0
- {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/SPEC-diary-groups.md +0 -0
- {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/SPEC-streamable-http.md +0 -0
- {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/cronometer_mcp/markdown.py +0 -0
- {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/cronometer_mcp/server.py +0 -0
- {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/release.sh +0 -0
- {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/tests/__init__.py +0 -0
- {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/tests/test_diary_groups.py +0 -0
- {cronometer_mcp-2.4.0 → cronometer_mcp-2.4.2}/tests/test_markdown.py +0 -0
- {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.
|
|
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
|
|
@@ -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/
|
|
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|
|
|
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,
|
|
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
|
|
1433
|
-
# index
|
|
1434
|
-
# index
|
|
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,
|
|
1444
|
-
# Match the first
|
|
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
|
|
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.
|
|
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.
|
|
9
|
+
"version": "2.4.2",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "pypi",
|
|
13
13
|
"identifier": "cronometer-mcp",
|
|
14
|
-
"version": "2.4.
|
|
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,
|
|
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()
|
cronometer_mcp-2.4.0/AGENTS.md
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|