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 ADDED
@@ -0,0 +1,3 @@
1
+ """Nutrilog - Log meals, macronutrients, and calories directly to Google Health."""
2
+
3
+ __version__ = "0.1.0"
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()