nutrilog 0.1.0__py3-none-any.whl
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.
- nutrilog/__init__.py +3 -0
- nutrilog/auth.py +148 -0
- nutrilog/auth_test.py +120 -0
- nutrilog/cli.py +549 -0
- nutrilog/cli_test.py +241 -0
- nutrilog/client.py +155 -0
- nutrilog/client_test.py +157 -0
- nutrilog/models.py +268 -0
- nutrilog/models_test.py +134 -0
- nutrilog/parser.py +254 -0
- nutrilog/parser_test.py +108 -0
- nutrilog/skill.py +269 -0
- nutrilog/skill_test.py +116 -0
- nutrilog/skills/nutrilog/SKILL.md +116 -0
- nutrilog/storage.py +136 -0
- nutrilog/storage_test.py +89 -0
- nutrilog-0.1.0.dist-info/METADATA +282 -0
- nutrilog-0.1.0.dist-info/RECORD +21 -0
- nutrilog-0.1.0.dist-info/WHEEL +4 -0
- nutrilog-0.1.0.dist-info/entry_points.txt +2 -0
- nutrilog-0.1.0.dist-info/licenses/LICENSE +21 -0
nutrilog/__init__.py
ADDED
nutrilog/auth.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""OAuth 2.0 authentication manager for Google Health API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
from google.auth.transport.requests import Request
|
|
9
|
+
from google.oauth2.credentials import Credentials
|
|
10
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
11
|
+
|
|
12
|
+
from nutrilog.storage import (
|
|
13
|
+
delete_tokens,
|
|
14
|
+
get_credentials_path,
|
|
15
|
+
load_credentials,
|
|
16
|
+
load_tokens,
|
|
17
|
+
save_tokens,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
SCOPES = [
|
|
21
|
+
"https://www.googleapis.com/auth/health.nutrition.writeonly",
|
|
22
|
+
"https://www.googleapis.com/auth/health.nutrition.readonly",
|
|
23
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
|
24
|
+
"openid",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
TOKEN_URI = "https://oauth2.googleapis.com/token"
|
|
28
|
+
AUTH_URI = "https://accounts.google.com/o/oauth2/auth"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_client_config() -> Optional[dict[str, Any]]:
|
|
32
|
+
"""Retrieve client credentials from env vars or credentials.json."""
|
|
33
|
+
env_id = os.getenv("NUTRILOG_CLIENT_ID") or os.getenv("GOOGLE_CLIENT_ID")
|
|
34
|
+
env_secret = os.getenv("NUTRILOG_CLIENT_SECRET") or os.getenv("GOOGLE_CLIENT_SECRET")
|
|
35
|
+
if env_id and env_secret:
|
|
36
|
+
return {
|
|
37
|
+
"installed": {
|
|
38
|
+
"client_id": env_id,
|
|
39
|
+
"client_secret": env_secret,
|
|
40
|
+
"auth_uri": AUTH_URI,
|
|
41
|
+
"token_uri": TOKEN_URI,
|
|
42
|
+
"redirect_uris": ["http://localhost"],
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
saved = load_credentials()
|
|
47
|
+
if saved:
|
|
48
|
+
return saved
|
|
49
|
+
|
|
50
|
+
creds_path = get_credentials_path()
|
|
51
|
+
if creds_path.exists():
|
|
52
|
+
import json
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
return json.loads(creds_path.read_text(encoding="utf-8"))
|
|
56
|
+
except Exception:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _token_dict_from_creds(creds: Credentials) -> dict[str, Any]:
|
|
63
|
+
return {
|
|
64
|
+
"token": creds.token,
|
|
65
|
+
"refresh_token": creds.refresh_token,
|
|
66
|
+
"token_uri": creds.token_uri,
|
|
67
|
+
"client_id": creds.client_id,
|
|
68
|
+
"client_secret": creds.client_secret,
|
|
69
|
+
"scopes": creds.scopes,
|
|
70
|
+
"expiry": creds.expiry.isoformat() if creds.expiry else None,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_credentials() -> Optional[Credentials]:
|
|
75
|
+
"""Load valid credentials from storage, refreshing them if expired."""
|
|
76
|
+
token_data = load_tokens()
|
|
77
|
+
if not token_data:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
creds = Credentials.from_authorized_user_info(token_data, SCOPES)
|
|
82
|
+
except Exception:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
if creds and creds.expired and creds.refresh_token:
|
|
86
|
+
try:
|
|
87
|
+
creds.refresh(Request())
|
|
88
|
+
save_tokens(_token_dict_from_creds(creds))
|
|
89
|
+
except Exception:
|
|
90
|
+
# If refresh fails, creds might be invalid
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
return creds if (creds and (creds.valid or creds.token)) else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def login(
|
|
97
|
+
client_config_path: Optional[Path] = None,
|
|
98
|
+
port: int = 0,
|
|
99
|
+
open_browser: bool = True,
|
|
100
|
+
) -> Credentials:
|
|
101
|
+
"""Run local server OAuth 2.0 flow to obtain user credentials."""
|
|
102
|
+
client_config = None
|
|
103
|
+
if client_config_path and client_config_path.exists():
|
|
104
|
+
flow = InstalledAppFlow.from_client_secrets_file(
|
|
105
|
+
str(client_config_path),
|
|
106
|
+
scopes=SCOPES,
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
client_config = get_client_config()
|
|
110
|
+
if not client_config:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
"No OAuth client credentials found. Please provide a client_secrets.json file, "
|
|
113
|
+
"set NUTRILOG_CLIENT_ID and NUTRILOG_CLIENT_SECRET, or run 'nutrilog auth setup'."
|
|
114
|
+
)
|
|
115
|
+
flow = InstalledAppFlow.from_client_config(client_config, scopes=SCOPES)
|
|
116
|
+
|
|
117
|
+
creds = flow.run_local_server(
|
|
118
|
+
port=port,
|
|
119
|
+
open_browser=open_browser,
|
|
120
|
+
prompt="consent",
|
|
121
|
+
access_type="offline",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
save_tokens(_token_dict_from_creds(creds))
|
|
125
|
+
return creds
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def get_auth_status() -> dict[str, Any]:
|
|
129
|
+
"""Get the current authentication status and metadata."""
|
|
130
|
+
creds = get_credentials()
|
|
131
|
+
if not creds:
|
|
132
|
+
return {
|
|
133
|
+
"authenticated": False,
|
|
134
|
+
"has_saved_tokens": load_tokens() is not None,
|
|
135
|
+
"has_credentials_configured": get_client_config() is not None,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
"authenticated": True,
|
|
140
|
+
"expiry": creds.expiry.isoformat() if creds.expiry else None,
|
|
141
|
+
"scopes": creds.scopes,
|
|
142
|
+
"has_credentials_configured": True,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def logout() -> bool:
|
|
147
|
+
"""Log out by clearing stored user tokens."""
|
|
148
|
+
return delete_tokens()
|
nutrilog/auth_test.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Unit tests for nutrilog.auth."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
import pytest
|
|
7
|
+
from google.oauth2.credentials import Credentials
|
|
8
|
+
|
|
9
|
+
from nutrilog.auth import (
|
|
10
|
+
get_auth_status,
|
|
11
|
+
get_client_config,
|
|
12
|
+
get_credentials,
|
|
13
|
+
login,
|
|
14
|
+
logout,
|
|
15
|
+
)
|
|
16
|
+
from nutrilog.storage import ENV_CONFIG_DIR, save_credentials, save_tokens
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def temp_config_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
21
|
+
custom_dir = tmp_path / "nutrilog_auth_test"
|
|
22
|
+
monkeypatch.setenv(ENV_CONFIG_DIR, str(custom_dir))
|
|
23
|
+
return custom_dir
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_get_client_config_from_env(monkeypatch: pytest.MonkeyPatch, temp_config_dir: Path):
|
|
27
|
+
monkeypatch.setenv("NUTRILOG_CLIENT_ID", "env-client-id")
|
|
28
|
+
monkeypatch.setenv("NUTRILOG_CLIENT_SECRET", "env-client-secret")
|
|
29
|
+
|
|
30
|
+
config = get_client_config()
|
|
31
|
+
assert config is not None
|
|
32
|
+
assert config["installed"]["client_id"] == "env-client-id"
|
|
33
|
+
assert config["installed"]["client_secret"] == "env-client-secret"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_get_client_config_from_storage(monkeypatch: pytest.MonkeyPatch, temp_config_dir: Path):
|
|
37
|
+
monkeypatch.delenv("NUTRILOG_CLIENT_ID", raising=False)
|
|
38
|
+
monkeypatch.delenv("NUTRILOG_CLIENT_SECRET", raising=False)
|
|
39
|
+
monkeypatch.delenv("GOOGLE_CLIENT_ID", raising=False)
|
|
40
|
+
monkeypatch.delenv("GOOGLE_CLIENT_SECRET", raising=False)
|
|
41
|
+
|
|
42
|
+
assert get_client_config() is None
|
|
43
|
+
|
|
44
|
+
stored = {"installed": {"client_id": "stored-id", "client_secret": "stored-secret"}}
|
|
45
|
+
save_credentials(stored)
|
|
46
|
+
assert get_client_config() == stored
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_get_credentials_none_when_empty(temp_config_dir: Path):
|
|
50
|
+
assert get_credentials() is None
|
|
51
|
+
status = get_auth_status()
|
|
52
|
+
assert status["authenticated"] is False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_get_credentials_valid(temp_config_dir: Path):
|
|
56
|
+
token_data = {
|
|
57
|
+
"token": "test-access-token",
|
|
58
|
+
"refresh_token": "test-refresh-token",
|
|
59
|
+
"token_uri": "https://oauth2.googleapis.com/token",
|
|
60
|
+
"client_id": "test-client-id",
|
|
61
|
+
"client_secret": "test-client-secret",
|
|
62
|
+
"scopes": ["https://www.googleapis.com/auth/health.nutrition.writeonly"],
|
|
63
|
+
"expiry": "2030-01-01T00:00:00Z",
|
|
64
|
+
}
|
|
65
|
+
save_tokens(token_data)
|
|
66
|
+
|
|
67
|
+
creds = get_credentials()
|
|
68
|
+
assert creds is not None
|
|
69
|
+
assert creds.token == "test-access-token"
|
|
70
|
+
|
|
71
|
+
status = get_auth_status()
|
|
72
|
+
assert status["authenticated"] is True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_get_credentials_expired_refreshed(temp_config_dir: Path):
|
|
76
|
+
token_data = {
|
|
77
|
+
"token": "expired-token",
|
|
78
|
+
"refresh_token": "test-refresh-token",
|
|
79
|
+
"token_uri": "https://oauth2.googleapis.com/token",
|
|
80
|
+
"client_id": "test-client-id",
|
|
81
|
+
"client_secret": "test-client-secret",
|
|
82
|
+
"scopes": ["https://www.googleapis.com/auth/health.nutrition.writeonly"],
|
|
83
|
+
"expiry": "2020-01-01T00:00:00Z",
|
|
84
|
+
}
|
|
85
|
+
save_tokens(token_data)
|
|
86
|
+
|
|
87
|
+
with patch.object(Credentials, "refresh") as mock_refresh:
|
|
88
|
+
with patch.object(Credentials, "valid", return_value=True):
|
|
89
|
+
creds = get_credentials()
|
|
90
|
+
assert creds is not None
|
|
91
|
+
mock_refresh.assert_called_once()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_logout(temp_config_dir: Path):
|
|
95
|
+
save_tokens({"token": "abc", "expiry": "2030-01-01T00:00:00Z"})
|
|
96
|
+
assert logout() is True
|
|
97
|
+
assert get_auth_status()["authenticated"] is False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_login_flow(temp_config_dir: Path, monkeypatch: pytest.MonkeyPatch):
|
|
101
|
+
monkeypatch.setenv("NUTRILOG_CLIENT_ID", "test-id")
|
|
102
|
+
monkeypatch.setenv("NUTRILOG_CLIENT_SECRET", "test-secret")
|
|
103
|
+
|
|
104
|
+
mock_creds = MagicMock()
|
|
105
|
+
mock_creds.token = "new-token"
|
|
106
|
+
mock_creds.refresh_token = "new-refresh"
|
|
107
|
+
mock_creds.token_uri = "https://oauth2.googleapis.com/token"
|
|
108
|
+
mock_creds.client_id = "test-id"
|
|
109
|
+
mock_creds.client_secret = "test-secret"
|
|
110
|
+
mock_creds.scopes = ["scope1"]
|
|
111
|
+
mock_creds.expiry = datetime(2030, 1, 1, 0, 0, tzinfo=timezone.utc)
|
|
112
|
+
|
|
113
|
+
with patch("nutrilog.auth.InstalledAppFlow.from_client_config") as mock_flow_init:
|
|
114
|
+
mock_flow = MagicMock()
|
|
115
|
+
mock_flow.run_local_server.return_value = mock_creds
|
|
116
|
+
mock_flow_init.return_value = mock_flow
|
|
117
|
+
|
|
118
|
+
creds = login(port=0, open_browser=False)
|
|
119
|
+
assert creds == mock_creds
|
|
120
|
+
mock_flow.run_local_server.assert_called_once()
|