nutrition-cli 1.0.0__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.
- nutrition_cli-1.0.0/.gitignore +20 -0
- nutrition_cli-1.0.0/LICENSE +21 -0
- nutrition_cli-1.0.0/PKG-INFO +92 -0
- nutrition_cli-1.0.0/README.md +77 -0
- nutrition_cli-1.0.0/nutrition_cli/__init__.py +1 -0
- nutrition_cli-1.0.0/nutrition_cli/__main__.py +276 -0
- nutrition_cli-1.0.0/nutrition_cli/config.py +40 -0
- nutrition_cli-1.0.0/nutrition_cli/format.py +159 -0
- nutrition_cli-1.0.0/nutrition_cli/openfoodfacts.py +103 -0
- nutrition_cli-1.0.0/nutrition_cli/rda.py +129 -0
- nutrition_cli-1.0.0/nutrition_cli/search.py +73 -0
- nutrition_cli-1.0.0/nutrition_cli/usda.py +134 -0
- nutrition_cli-1.0.0/pyproject.toml +25 -0
- nutrition_cli-1.0.0/skill/SKILL.md +137 -0
- nutrition_cli-1.0.0/tests/__init__.py +0 -0
- nutrition_cli-1.0.0/tests/test_format.py +85 -0
- nutrition_cli-1.0.0/tests/test_openfoodfacts.py +85 -0
- nutrition_cli-1.0.0/tests/test_search.py +72 -0
- nutrition_cli-1.0.0/tests/test_usda.py +128 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Javier Ruiz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nutrition-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: CLI tool for nutrition data lookup from USDA and Open Food Facts
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: click>=8.0
|
|
9
|
+
Requires-Dist: requests>=2.28
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest-mock; extra == 'dev'
|
|
13
|
+
Requires-Dist: responses; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# nutrition-cli
|
|
17
|
+
|
|
18
|
+
CLI tool for nutrition data lookup, powered by [USDA FoodData Central](https://fdc.nal.usda.gov/) and [Open Food Facts](https://world.openfoodfacts.org/). Also ships as an [OpenClaw](https://github.com/openclaw) skill for AI-assisted nutrition queries.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
### Via ClawHub (recommended for OpenClaw users)
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
claw install nutrition-cli
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
This installs both the CLI tool and the OpenClaw skill that lets your agent answer nutrition questions automatically.
|
|
29
|
+
|
|
30
|
+
### Via pip
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
pip install nutrition-cli
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Look up a food
|
|
40
|
+
nutrition search "chicken breast" --grams 200
|
|
41
|
+
|
|
42
|
+
# Scan a barcode
|
|
43
|
+
nutrition barcode 3017624010701
|
|
44
|
+
|
|
45
|
+
# Compare foods
|
|
46
|
+
nutrition compare "white rice" "brown rice" "quinoa"
|
|
47
|
+
|
|
48
|
+
# Calculate a meal
|
|
49
|
+
nutrition meal "200g chicken breast" "150g rice" "1 avocado" --rda
|
|
50
|
+
|
|
51
|
+
# Calories burned
|
|
52
|
+
nutrition burn running 30 --weight 70
|
|
53
|
+
|
|
54
|
+
# Daily targets
|
|
55
|
+
nutrition daily --sex female --age 25 --weight 60 --activity moderate
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Commands
|
|
59
|
+
|
|
60
|
+
| Command | Description |
|
|
61
|
+
|---------|-------------|
|
|
62
|
+
| `nutrition search QUERY` | Look up nutrition for any food |
|
|
63
|
+
| `nutrition barcode CODE` | Look up by barcode (EAN-8/13, UPC-A) |
|
|
64
|
+
| `nutrition compare FOOD1 FOOD2 ...` | Side-by-side comparison (2-5 foods) |
|
|
65
|
+
| `nutrition meal "Xg food" ...` | Sum nutrition across a meal |
|
|
66
|
+
| `nutrition burn ACTIVITY MINUTES` | Estimate calories burned |
|
|
67
|
+
| `nutrition daily` | Personalized daily intake targets |
|
|
68
|
+
| `nutrition config set` | Set API key or defaults |
|
|
69
|
+
|
|
70
|
+
All commands support `--format json` for machine-readable output and `--grams` for custom serving sizes.
|
|
71
|
+
|
|
72
|
+
## Rate limits
|
|
73
|
+
|
|
74
|
+
No API key is required. The tool uses USDA's `DEMO_KEY` (50 requests/day per IP) and Open Food Facts (unlimited, no key needed).
|
|
75
|
+
|
|
76
|
+
If you hit the USDA rate limit, get a free personal key in 30 seconds:
|
|
77
|
+
|
|
78
|
+
1. Sign up at https://fdc.nal.usda.gov/api-key-signup
|
|
79
|
+
2. Run: `nutrition config set --usda-key YOUR_KEY`
|
|
80
|
+
|
|
81
|
+
This raises your limit to 1,000 requests/hour.
|
|
82
|
+
|
|
83
|
+
## Data sources
|
|
84
|
+
|
|
85
|
+
- **USDA FoodData Central** — generic foods (SR Legacy, Foundation) and branded products. US government data, high quality.
|
|
86
|
+
- **Open Food Facts** — community-contributed product database with barcode lookup, Nutri-Score, NOVA classification, allergen data, and vegan/vegetarian status.
|
|
87
|
+
|
|
88
|
+
The CLI prefers USDA SR Legacy/Foundation results for generic food queries and falls back to Open Food Facts automatically.
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# nutrition-cli
|
|
2
|
+
|
|
3
|
+
CLI tool for nutrition data lookup, powered by [USDA FoodData Central](https://fdc.nal.usda.gov/) and [Open Food Facts](https://world.openfoodfacts.org/). Also ships as an [OpenClaw](https://github.com/openclaw) skill for AI-assisted nutrition queries.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
### Via ClawHub (recommended for OpenClaw users)
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
claw install nutrition-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
This installs both the CLI tool and the OpenClaw skill that lets your agent answer nutrition questions automatically.
|
|
14
|
+
|
|
15
|
+
### Via pip
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
pip install nutrition-cli
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Look up a food
|
|
25
|
+
nutrition search "chicken breast" --grams 200
|
|
26
|
+
|
|
27
|
+
# Scan a barcode
|
|
28
|
+
nutrition barcode 3017624010701
|
|
29
|
+
|
|
30
|
+
# Compare foods
|
|
31
|
+
nutrition compare "white rice" "brown rice" "quinoa"
|
|
32
|
+
|
|
33
|
+
# Calculate a meal
|
|
34
|
+
nutrition meal "200g chicken breast" "150g rice" "1 avocado" --rda
|
|
35
|
+
|
|
36
|
+
# Calories burned
|
|
37
|
+
nutrition burn running 30 --weight 70
|
|
38
|
+
|
|
39
|
+
# Daily targets
|
|
40
|
+
nutrition daily --sex female --age 25 --weight 60 --activity moderate
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Commands
|
|
44
|
+
|
|
45
|
+
| Command | Description |
|
|
46
|
+
|---------|-------------|
|
|
47
|
+
| `nutrition search QUERY` | Look up nutrition for any food |
|
|
48
|
+
| `nutrition barcode CODE` | Look up by barcode (EAN-8/13, UPC-A) |
|
|
49
|
+
| `nutrition compare FOOD1 FOOD2 ...` | Side-by-side comparison (2-5 foods) |
|
|
50
|
+
| `nutrition meal "Xg food" ...` | Sum nutrition across a meal |
|
|
51
|
+
| `nutrition burn ACTIVITY MINUTES` | Estimate calories burned |
|
|
52
|
+
| `nutrition daily` | Personalized daily intake targets |
|
|
53
|
+
| `nutrition config set` | Set API key or defaults |
|
|
54
|
+
|
|
55
|
+
All commands support `--format json` for machine-readable output and `--grams` for custom serving sizes.
|
|
56
|
+
|
|
57
|
+
## Rate limits
|
|
58
|
+
|
|
59
|
+
No API key is required. The tool uses USDA's `DEMO_KEY` (50 requests/day per IP) and Open Food Facts (unlimited, no key needed).
|
|
60
|
+
|
|
61
|
+
If you hit the USDA rate limit, get a free personal key in 30 seconds:
|
|
62
|
+
|
|
63
|
+
1. Sign up at https://fdc.nal.usda.gov/api-key-signup
|
|
64
|
+
2. Run: `nutrition config set --usda-key YOUR_KEY`
|
|
65
|
+
|
|
66
|
+
This raises your limit to 1,000 requests/hour.
|
|
67
|
+
|
|
68
|
+
## Data sources
|
|
69
|
+
|
|
70
|
+
- **USDA FoodData Central** — generic foods (SR Legacy, Foundation) and branded products. US government data, high quality.
|
|
71
|
+
- **Open Food Facts** — community-contributed product database with barcode lookup, Nutri-Score, NOVA classification, allergen data, and vegan/vegetarian status.
|
|
72
|
+
|
|
73
|
+
The CLI prefers USDA SR Legacy/Foundation results for generic food queries and falls back to Open Food Facts automatically.
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""nutrition-cli — CLI tool for nutrition data lookup."""
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""CLI entrypoint for nutrition-cli."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from . import config, format, search
|
|
9
|
+
from .rda import MET_TABLE, get_rda
|
|
10
|
+
from .usda import RateLimitError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
def cli():
|
|
15
|
+
"""Nutrition data lookup from USDA and Open Food Facts."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# --- config ---
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@cli.group("config")
|
|
22
|
+
def config_cmd():
|
|
23
|
+
"""Manage nutrition-cli configuration."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@config_cmd.command("set")
|
|
27
|
+
@click.option("--usda-key", default=None, help="Personal USDA FoodData Central API key.")
|
|
28
|
+
@click.option("--default-grams", default=None, type=float, help="Default serving size in grams.")
|
|
29
|
+
def config_set(usda_key, default_grams):
|
|
30
|
+
"""Save configuration values."""
|
|
31
|
+
if usda_key is None and default_grams is None:
|
|
32
|
+
# Show current config status
|
|
33
|
+
data = config.load_config()
|
|
34
|
+
has_key = "usda_key" in data
|
|
35
|
+
grams = data.get("default_grams", 100)
|
|
36
|
+
click.echo(f" USDA key : {'configured' if has_key else 'not set (using DEMO_KEY)'}")
|
|
37
|
+
click.echo(f" Default g: {grams}")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
if usda_key is not None:
|
|
41
|
+
config.set("usda_key", usda_key)
|
|
42
|
+
click.echo(" USDA API key saved.")
|
|
43
|
+
if default_grams is not None:
|
|
44
|
+
config.set("default_grams", default_grams)
|
|
45
|
+
click.echo(f" Default serving size set to {default_grams}g.")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --- search ---
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@cli.command("search")
|
|
52
|
+
@click.argument("query")
|
|
53
|
+
@click.option("--grams", "-g", default=None, type=float, help="Serving size in grams.")
|
|
54
|
+
@click.option("--format", "fmt", type=click.Choice(["summary", "json"]), default="summary")
|
|
55
|
+
@click.option("--rda", "show_rda", is_flag=True, help="Show daily intake percentage.")
|
|
56
|
+
@click.option("--sex", type=click.Choice(["male", "female"]), default="male")
|
|
57
|
+
@click.option("--age", type=int, default=30)
|
|
58
|
+
def search_cmd(query, grams, fmt, show_rda, sex, age):
|
|
59
|
+
"""Look up nutrition data for a food."""
|
|
60
|
+
if grams is None:
|
|
61
|
+
grams = config.get("default_grams", 100)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
result = search.lookup(query, grams)
|
|
65
|
+
except RateLimitError as e:
|
|
66
|
+
click.echo(format.format_error_rate_limit(e.retry_after), err=True)
|
|
67
|
+
raise SystemExit(1)
|
|
68
|
+
|
|
69
|
+
if fmt == "json":
|
|
70
|
+
click.echo(json.dumps(result, indent=2))
|
|
71
|
+
else:
|
|
72
|
+
click.echo(format.nutrition_block(result))
|
|
73
|
+
if show_rda:
|
|
74
|
+
rda = get_rda(sex, age)
|
|
75
|
+
click.echo()
|
|
76
|
+
click.echo(format.rda_progress(result, rda))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# --- barcode ---
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@cli.command()
|
|
84
|
+
@click.argument("barcode")
|
|
85
|
+
@click.option("--grams", "-g", default=None, type=float, help="Serving size in grams.")
|
|
86
|
+
@click.option("--format", "fmt", type=click.Choice(["summary", "json"]), default="summary")
|
|
87
|
+
def barcode(barcode, grams, fmt):
|
|
88
|
+
"""Look up a product by barcode (EAN-8/EAN-13/UPC-A)."""
|
|
89
|
+
if grams is None:
|
|
90
|
+
grams = config.get("default_grams", 100)
|
|
91
|
+
|
|
92
|
+
from . import openfoodfacts
|
|
93
|
+
product = openfoodfacts.get_by_barcode(barcode)
|
|
94
|
+
if product is None:
|
|
95
|
+
raise click.ClickException(
|
|
96
|
+
f"Barcode '{barcode}' not found in Open Food Facts. "
|
|
97
|
+
"Try searching by name: nutrition search <name>"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
result = openfoodfacts.parse_product(product, grams)
|
|
101
|
+
|
|
102
|
+
if fmt == "json":
|
|
103
|
+
click.echo(json.dumps(result, indent=2))
|
|
104
|
+
else:
|
|
105
|
+
click.echo(format.nutrition_block(result))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# --- compare ---
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@cli.command()
|
|
112
|
+
@click.argument("foods", nargs=-1, required=True)
|
|
113
|
+
@click.option("--grams", "-g", default=None, type=float, help="Serving size in grams.")
|
|
114
|
+
@click.option("--format", "fmt", type=click.Choice(["summary", "json"]), default="summary")
|
|
115
|
+
def compare(foods, grams, fmt):
|
|
116
|
+
"""Compare nutrition data for 2-5 foods."""
|
|
117
|
+
if len(foods) < 2:
|
|
118
|
+
raise click.ClickException("Provide at least 2 foods to compare.")
|
|
119
|
+
if len(foods) > 5:
|
|
120
|
+
raise click.ClickException("Compare supports up to 5 foods.")
|
|
121
|
+
|
|
122
|
+
if grams is None:
|
|
123
|
+
grams = config.get("default_grams", 100)
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
results = search.lookup_multi(list(foods), grams)
|
|
127
|
+
except RateLimitError as e:
|
|
128
|
+
click.echo(format.format_error_rate_limit(e.retry_after), err=True)
|
|
129
|
+
raise SystemExit(1)
|
|
130
|
+
|
|
131
|
+
if fmt == "json":
|
|
132
|
+
click.echo(json.dumps(results, indent=2))
|
|
133
|
+
else:
|
|
134
|
+
click.echo(format.compare_table(results))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# --- meal ---
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _parse_meal_arg(arg: str, default_grams: float) -> tuple[str, float]:
|
|
141
|
+
"""Parse '200g chicken' into (query, grams). Falls back to default_grams."""
|
|
142
|
+
m = re.match(r"^(\d+\.?\d*)\s*g\s+(.+)$", arg, re.IGNORECASE)
|
|
143
|
+
if m:
|
|
144
|
+
return m.group(2), float(m.group(1))
|
|
145
|
+
return arg, default_grams
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@cli.command()
|
|
149
|
+
@click.argument("items", nargs=-1, required=True)
|
|
150
|
+
@click.option("--grams", "-g", default=None, type=float, help="Default serving size in grams.")
|
|
151
|
+
@click.option("--rda", "show_rda", is_flag=True, help="Show daily intake percentage.")
|
|
152
|
+
@click.option("--format", "fmt", type=click.Choice(["summary", "json"]), default="summary")
|
|
153
|
+
@click.option("--sex", type=click.Choice(["male", "female"]), default="male")
|
|
154
|
+
@click.option("--age", type=int, default=30)
|
|
155
|
+
def meal(items, grams, show_rda, fmt, sex, age):
|
|
156
|
+
"""Calculate nutrition for a full meal. Use '200g chicken' syntax for portions."""
|
|
157
|
+
if grams is None:
|
|
158
|
+
grams = config.get("default_grams", 100)
|
|
159
|
+
|
|
160
|
+
results = []
|
|
161
|
+
errors = []
|
|
162
|
+
for item in items:
|
|
163
|
+
query, item_grams = _parse_meal_arg(item, grams)
|
|
164
|
+
try:
|
|
165
|
+
results.append(search.lookup(query, item_grams))
|
|
166
|
+
except RateLimitError as e:
|
|
167
|
+
click.echo(format.format_error_rate_limit(e.retry_after), err=True)
|
|
168
|
+
raise SystemExit(1)
|
|
169
|
+
except click.ClickException as e:
|
|
170
|
+
errors.append(f" - {query}: {e.format_message()}")
|
|
171
|
+
|
|
172
|
+
if errors:
|
|
173
|
+
click.echo("Some lookups failed:", err=True)
|
|
174
|
+
for err in errors:
|
|
175
|
+
click.echo(err, err=True)
|
|
176
|
+
|
|
177
|
+
if not results:
|
|
178
|
+
raise click.ClickException("No foods could be looked up.")
|
|
179
|
+
|
|
180
|
+
if fmt == "json":
|
|
181
|
+
click.echo(json.dumps(results, indent=2))
|
|
182
|
+
else:
|
|
183
|
+
for r in results:
|
|
184
|
+
click.echo(format.nutrition_block(r))
|
|
185
|
+
click.echo()
|
|
186
|
+
click.echo(format.meal_summary(results))
|
|
187
|
+
if show_rda:
|
|
188
|
+
# Build combined food dict for RDA
|
|
189
|
+
keys = ["kcal", "protein", "fat", "carbs", "fiber"]
|
|
190
|
+
combined = {}
|
|
191
|
+
for key in keys:
|
|
192
|
+
values = [r.get(key) for r in results if r.get(key) is not None]
|
|
193
|
+
combined[key] = round(sum(values), 1) if values else None
|
|
194
|
+
rda = get_rda(sex, age)
|
|
195
|
+
click.echo()
|
|
196
|
+
click.echo(format.rda_progress(combined, rda))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# --- burn ---
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@cli.command()
|
|
203
|
+
@click.argument("activity")
|
|
204
|
+
@click.argument("duration", type=float)
|
|
205
|
+
@click.option("--weight", "-w", default=None, type=float, help="Body weight in kg.")
|
|
206
|
+
def burn(activity, duration, weight):
|
|
207
|
+
"""Estimate calories burned for an activity."""
|
|
208
|
+
if weight is None:
|
|
209
|
+
weight = config.get("default_weight", 75)
|
|
210
|
+
|
|
211
|
+
activity_lower = activity.lower()
|
|
212
|
+
met = MET_TABLE.get(activity_lower)
|
|
213
|
+
if met is None:
|
|
214
|
+
available = ", ".join(sorted(MET_TABLE.keys()))
|
|
215
|
+
raise click.ClickException(
|
|
216
|
+
f"Unknown activity '{activity}'. Available: {available}"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
calories = round(met * weight * (duration / 60), 1)
|
|
220
|
+
click.echo(f" {activity.capitalize()} for {duration:.0f} min ({weight}kg)")
|
|
221
|
+
click.echo(f" Estimated burn: {calories} kcal")
|
|
222
|
+
|
|
223
|
+
# Show equivalent in another activity
|
|
224
|
+
walking_met = MET_TABLE["walking"]
|
|
225
|
+
walking_min = round(calories / (walking_met * weight) * 60, 0)
|
|
226
|
+
click.echo(f" Equivalent to {walking_min:.0f} min of walking")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# --- daily ---
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@cli.command()
|
|
233
|
+
@click.option("--sex", type=click.Choice(["male", "female"]), default="male")
|
|
234
|
+
@click.option("--age", type=int, default=30)
|
|
235
|
+
@click.option("--weight", "-w", type=float, default=75, help="Body weight in kg.")
|
|
236
|
+
@click.option("--height", type=float, default=175, help="Height in cm.")
|
|
237
|
+
@click.option(
|
|
238
|
+
"--activity",
|
|
239
|
+
type=click.Choice(["sedentary", "light", "moderate", "active"]),
|
|
240
|
+
default="moderate",
|
|
241
|
+
)
|
|
242
|
+
def daily(sex, age, weight, height, activity):
|
|
243
|
+
"""Show recommended daily nutrition intake (Harris-Benedict TDEE)."""
|
|
244
|
+
# Harris-Benedict BMR
|
|
245
|
+
if sex == "male":
|
|
246
|
+
bmr = 88.362 + (13.397 * weight) + (4.799 * height) - (5.677 * age)
|
|
247
|
+
else:
|
|
248
|
+
bmr = 447.593 + (9.247 * weight) + (3.098 * height) - (4.330 * age)
|
|
249
|
+
|
|
250
|
+
multipliers = {
|
|
251
|
+
"sedentary": 1.2,
|
|
252
|
+
"light": 1.375,
|
|
253
|
+
"moderate": 1.55,
|
|
254
|
+
"active": 1.725,
|
|
255
|
+
}
|
|
256
|
+
tdee = round(bmr * multipliers[activity])
|
|
257
|
+
|
|
258
|
+
# Macro split: 30% protein, 25% fat, 45% carbs
|
|
259
|
+
protein_g = round(tdee * 0.30 / 4, 1)
|
|
260
|
+
fat_g = round(tdee * 0.25 / 9, 1)
|
|
261
|
+
carbs_g = round(tdee * 0.45 / 4, 1)
|
|
262
|
+
|
|
263
|
+
rda = get_rda(sex, age)
|
|
264
|
+
fiber_g = rda["fiber_g"]
|
|
265
|
+
|
|
266
|
+
click.echo(f" Daily targets ({sex}, age {age}, {weight}kg, {height}cm, {activity}):")
|
|
267
|
+
click.echo(f" {'=' * 40}")
|
|
268
|
+
click.echo(f" Calories : {tdee} kcal")
|
|
269
|
+
click.echo(f" Protein : {protein_g}g")
|
|
270
|
+
click.echo(f" Fat : {fat_g}g")
|
|
271
|
+
click.echo(f" Carbs : {carbs_g}g")
|
|
272
|
+
click.echo(f" Fiber : {fiber_g}g")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
if __name__ == "__main__":
|
|
276
|
+
cli()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Persistent config management at ~/.config/nutrition-cli/config.json."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_config_path() -> Path:
|
|
8
|
+
"""Return config file path, creating parent dirs if missing."""
|
|
9
|
+
path = Path.home() / ".config" / "nutrition-cli" / "config.json"
|
|
10
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
11
|
+
return path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_config() -> dict:
|
|
15
|
+
"""Read config JSON. Returns {} if file missing or invalid."""
|
|
16
|
+
path = get_config_path()
|
|
17
|
+
if not path.exists():
|
|
18
|
+
return {}
|
|
19
|
+
try:
|
|
20
|
+
return json.loads(path.read_text())
|
|
21
|
+
except (json.JSONDecodeError, OSError):
|
|
22
|
+
return {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def save_config(data: dict) -> None:
|
|
26
|
+
"""Write config dict as JSON with indent=2."""
|
|
27
|
+
path = get_config_path()
|
|
28
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get(key: str, default=None):
|
|
32
|
+
"""Read one key from config."""
|
|
33
|
+
return load_config().get(key, default)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def set(key: str, value: str) -> None:
|
|
37
|
+
"""Load config, update one key, save."""
|
|
38
|
+
data = load_config()
|
|
39
|
+
data[key] = value
|
|
40
|
+
save_config(data)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""All output formatting. Returns strings — never prints directly."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _val(value, unit="g", width=7) -> str:
|
|
5
|
+
"""Format a nutrient value, showing dash for None."""
|
|
6
|
+
if value is None:
|
|
7
|
+
return "\u2014".rjust(width)
|
|
8
|
+
return f"{value}{unit}".rjust(width)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _source_label(food: dict) -> str:
|
|
12
|
+
"""Build source label from food dict."""
|
|
13
|
+
source = food.get("source", "")
|
|
14
|
+
data_type = food.get("data_type", "")
|
|
15
|
+
brand = food.get("brand", "")
|
|
16
|
+
if data_type:
|
|
17
|
+
return f"{source} ({data_type})"
|
|
18
|
+
if brand:
|
|
19
|
+
return f"{source} ({brand})"
|
|
20
|
+
return source
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def nutrition_block(food: dict) -> str:
|
|
24
|
+
"""Format a single food lookup result."""
|
|
25
|
+
lines = []
|
|
26
|
+
lines.append(f" {food['name']}")
|
|
27
|
+
lines.append(f" Source : {_source_label(food)}")
|
|
28
|
+
|
|
29
|
+
if food.get("nutriscore"):
|
|
30
|
+
lines.append(f" Nutri-Score: {food['nutriscore']}")
|
|
31
|
+
if food.get("nova_group"):
|
|
32
|
+
lines.append(f" NOVA : {food['nova_group']}")
|
|
33
|
+
|
|
34
|
+
lines.append(f" Serving : {food['grams']}g")
|
|
35
|
+
lines.append(" " + "\u2500" * 35)
|
|
36
|
+
|
|
37
|
+
lines.append(f" Calories : {_val(food.get('kcal'), ' kcal')}")
|
|
38
|
+
lines.append(f" Protein : {_val(food.get('protein'))}")
|
|
39
|
+
|
|
40
|
+
fat_str = _val(food.get("fat"))
|
|
41
|
+
sat = food.get("saturated_fat")
|
|
42
|
+
if sat is not None:
|
|
43
|
+
fat_str += f" (sat. {sat}g)"
|
|
44
|
+
lines.append(f" Fat : {fat_str}")
|
|
45
|
+
|
|
46
|
+
lines.append(f" Carbs : {_val(food.get('carbs'))}")
|
|
47
|
+
lines.append(f" Fiber : {_val(food.get('fiber'))}")
|
|
48
|
+
lines.append(f" Sugar : {_val(food.get('sugar'))}")
|
|
49
|
+
|
|
50
|
+
sodium_key = "sodium_mg" if "sodium_mg" in food else "salt_mg"
|
|
51
|
+
sodium_label = "Sodium" if sodium_key == "sodium_mg" else "Salt"
|
|
52
|
+
lines.append(f" {sodium_label:9s}: {_val(food.get(sodium_key), ' mg')}")
|
|
53
|
+
|
|
54
|
+
allergens = food.get("allergens", [])
|
|
55
|
+
if allergens:
|
|
56
|
+
lines.append(f" Allergens: {', '.join(allergens)}")
|
|
57
|
+
|
|
58
|
+
return "\n".join(lines)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def compare_table(foods: list[dict]) -> str:
|
|
62
|
+
"""Aligned comparison table for 2-5 foods."""
|
|
63
|
+
label_width = 12
|
|
64
|
+
col_width = 20
|
|
65
|
+
|
|
66
|
+
# Truncate food names
|
|
67
|
+
names = [f["name"][:18] for f in foods]
|
|
68
|
+
|
|
69
|
+
lines = []
|
|
70
|
+
header = " " * label_width + "".join(n.rjust(col_width) for n in names)
|
|
71
|
+
lines.append(header)
|
|
72
|
+
lines.append("\u2500" * (label_width + col_width * len(foods)))
|
|
73
|
+
|
|
74
|
+
rows = [
|
|
75
|
+
("Calories", "kcal", " kcal"),
|
|
76
|
+
("Protein", "protein", "g"),
|
|
77
|
+
("Fat", "fat", "g"),
|
|
78
|
+
("Carbs", "carbs", "g"),
|
|
79
|
+
("Fiber", "fiber", "g"),
|
|
80
|
+
("Sugar", "sugar", "g"),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
for label, key, unit in rows:
|
|
84
|
+
row = label.ljust(label_width)
|
|
85
|
+
for f in foods:
|
|
86
|
+
val = f.get(key)
|
|
87
|
+
if val is None:
|
|
88
|
+
row += "\u2014".rjust(col_width)
|
|
89
|
+
else:
|
|
90
|
+
row += f"{val}{unit}".rjust(col_width)
|
|
91
|
+
lines.append(row)
|
|
92
|
+
|
|
93
|
+
return "\n".join(lines)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def meal_summary(foods: list[dict], label: str = "Meal total") -> str:
|
|
97
|
+
"""Sum nutrients across foods and format as a totals block."""
|
|
98
|
+
keys = ["kcal", "protein", "fat", "saturated_fat", "carbs", "fiber", "sugar"]
|
|
99
|
+
totals = {}
|
|
100
|
+
total_grams = 0.0
|
|
101
|
+
|
|
102
|
+
for key in keys:
|
|
103
|
+
values = [f.get(key) for f in foods if f.get(key) is not None]
|
|
104
|
+
totals[key] = round(sum(values), 1) if values else None
|
|
105
|
+
|
|
106
|
+
for f in foods:
|
|
107
|
+
total_grams += f.get("grams", 0)
|
|
108
|
+
|
|
109
|
+
total_food = {
|
|
110
|
+
"name": label,
|
|
111
|
+
"source": f"{len(foods)} items",
|
|
112
|
+
"grams": total_grams,
|
|
113
|
+
**totals,
|
|
114
|
+
}
|
|
115
|
+
return nutrition_block(total_food)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def rda_progress(food: dict, rda: dict) -> str:
|
|
119
|
+
"""Show percentage of daily recommended intake with ASCII bars."""
|
|
120
|
+
bar_width = 20
|
|
121
|
+
lines = []
|
|
122
|
+
|
|
123
|
+
target_cal = rda["calories"]
|
|
124
|
+
lines.append(f" Daily intake contribution ({target_cal} kcal target):")
|
|
125
|
+
|
|
126
|
+
rows = [
|
|
127
|
+
("Calories", "kcal", rda["calories"], " kcal"),
|
|
128
|
+
("Protein", "protein", rda["protein_g"], "g"),
|
|
129
|
+
("Fat", "fat", rda["fat_g"], "g"),
|
|
130
|
+
("Carbs", "carbs", rda["carbs_g"], "g"),
|
|
131
|
+
("Fiber", "fiber", rda["fiber_g"], "g"),
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
for label, key, daily, unit in rows:
|
|
135
|
+
val = food.get(key)
|
|
136
|
+
if val is None:
|
|
137
|
+
pct = 0.0
|
|
138
|
+
else:
|
|
139
|
+
pct = (val / daily * 100) if daily > 0 else 0.0
|
|
140
|
+
|
|
141
|
+
filled = min(int(pct / 100 * bar_width), bar_width)
|
|
142
|
+
empty = bar_width - filled
|
|
143
|
+
bar = "\u2588" * filled + "\u2591" * empty
|
|
144
|
+
|
|
145
|
+
val_str = f"{val}{unit}" if val is not None else "\u2014"
|
|
146
|
+
lines.append(f" {label:9s} {bar} {val_str:>10s} ({pct:.1f}%)")
|
|
147
|
+
|
|
148
|
+
return "\n".join(lines)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def format_error_rate_limit(retry_after: int) -> str:
|
|
152
|
+
"""Format rate limit error with upgrade instructions."""
|
|
153
|
+
minutes = max(1, retry_after // 60)
|
|
154
|
+
return (
|
|
155
|
+
f" Rate limit reached (USDA DEMO_KEY: 50 requests/day).\n"
|
|
156
|
+
f" Try again in {minutes} minutes, or upgrade in 30 seconds:\n"
|
|
157
|
+
f" \u2192 Get a free key: https://fdc.nal.usda.gov/api-key-signup\n"
|
|
158
|
+
f" \u2192 Then run: nutrition config set --usda-key YOUR_KEY"
|
|
159
|
+
)
|