taskcheck 1.1.2__tar.gz → 1.2.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.
Files changed (25) hide show
  1. {taskcheck-1.1.2/taskcheck.egg-info → taskcheck-1.2.0}/PKG-INFO +4 -1
  2. {taskcheck-1.1.2 → taskcheck-1.2.0}/pyproject.toml +4 -1
  3. {taskcheck-1.1.2 → taskcheck-1.2.0}/taskcheck/__main__.py +11 -0
  4. {taskcheck-1.1.2 → taskcheck-1.2.0}/taskcheck/common.py +25 -10
  5. taskcheck-1.2.0/taskcheck/google_calendar.py +172 -0
  6. {taskcheck-1.1.2 → taskcheck-1.2.0}/taskcheck/report.py +13 -4
  7. {taskcheck-1.1.2 → taskcheck-1.2.0/taskcheck.egg-info}/PKG-INFO +4 -1
  8. {taskcheck-1.1.2 → taskcheck-1.2.0}/taskcheck.egg-info/SOURCES.txt +2 -0
  9. {taskcheck-1.1.2 → taskcheck-1.2.0}/taskcheck.egg-info/requires.txt +3 -0
  10. {taskcheck-1.1.2 → taskcheck-1.2.0}/tests/test_common.py +25 -0
  11. taskcheck-1.2.0/tests/test_google_calendar.py +81 -0
  12. {taskcheck-1.1.2 → taskcheck-1.2.0}/tests/test_report.py +14 -4
  13. {taskcheck-1.1.2 → taskcheck-1.2.0}/LICENSE +0 -0
  14. {taskcheck-1.1.2 → taskcheck-1.2.0}/README.md +0 -0
  15. {taskcheck-1.1.2 → taskcheck-1.2.0}/setup.cfg +0 -0
  16. {taskcheck-1.1.2 → taskcheck-1.2.0}/taskcheck/ical.py +0 -0
  17. {taskcheck-1.1.2 → taskcheck-1.2.0}/taskcheck/install.py +0 -0
  18. {taskcheck-1.1.2 → taskcheck-1.2.0}/taskcheck/parallel.py +0 -0
  19. {taskcheck-1.1.2 → taskcheck-1.2.0}/taskcheck.egg-info/dependency_links.txt +0 -0
  20. {taskcheck-1.1.2 → taskcheck-1.2.0}/taskcheck.egg-info/entry_points.txt +0 -0
  21. {taskcheck-1.1.2 → taskcheck-1.2.0}/taskcheck.egg-info/top_level.txt +0 -0
  22. {taskcheck-1.1.2 → taskcheck-1.2.0}/tests/test_ical.py +0 -0
  23. {taskcheck-1.1.2 → taskcheck-1.2.0}/tests/test_install.py +0 -0
  24. {taskcheck-1.1.2 → taskcheck-1.2.0}/tests/test_main.py +0 -0
  25. {taskcheck-1.1.2 → taskcheck-1.2.0}/tests/test_parallel.py +0 -0
@@ -1,10 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taskcheck
3
- Version: 1.1.2
3
+ Version: 1.2.0
4
4
  Summary: Automatic scheduler for taskwarrior
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
7
  Requires-Dist: appdirs>=1.4.4
8
+ Requires-Dist: google-api-python-client>=2.198.0
9
+ Requires-Dist: google-auth>=2.56.2
10
+ Requires-Dist: google-auth-oauthlib>=1.2.2
8
11
  Requires-Dist: icalendar>=6.0.1
9
12
  Requires-Dist: random-unicode-emoji>=2.8
10
13
  Requires-Dist: requests>=2.32.3
@@ -1,11 +1,14 @@
1
1
  [project]
2
2
  name = "taskcheck"
3
- version = "1.1.2"
3
+ version = "1.2.0"
4
4
  description = "Automatic scheduler for taskwarrior"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
7
7
  dependencies = [
8
8
  "appdirs>=1.4.4",
9
+ "google-api-python-client>=2.198.0",
10
+ "google-auth>=2.56.2",
11
+ "google-auth-oauthlib>=1.2.2",
9
12
  "icalendar>=6.0.1",
10
13
  "random-unicode-emoji>=2.8",
11
14
  "requests>=2.32.3",
@@ -54,6 +54,11 @@ arg_parser.add_argument(
54
54
  default=True,
55
55
  help="Disable automatic reduction of urgency weight when tasks cannot be completed on time. (Enabled by default, meaning automatic reduction will occur.)",
56
56
  )
57
+ arg_parser.add_argument(
58
+ "--add-google-calendar",
59
+ action="store_true",
60
+ help="Authenticate with Google, list calendars, and print a config section for the selected calendar.",
61
+ )
57
62
 
58
63
 
59
64
  # Load working hours and exceptions from TOML file
@@ -100,6 +105,12 @@ def main():
100
105
  install()
101
106
  return
102
107
 
108
+ if getattr(args, "add_google_calendar", False) is True:
109
+ from taskcheck.google_calendar import add_google_calendar
110
+
111
+ add_google_calendar(taskrc=args.taskrc)
112
+ return
113
+
103
114
  if args.schedule:
104
115
  config = load_config()
105
116
  check_tasks_kwargs = dict(
@@ -211,16 +211,31 @@ def mark_end_date(
211
211
  def get_calendars(config, verbose=False, force_update=False):
212
212
  calendars = []
213
213
  for calname in config["calendars"]:
214
- calendar = config["calendars"][calname]
215
- calendar = ical_to_dict(
216
- calendar["url"],
217
- config["scheduler"]["days_ahead"],
218
- all_day=calendar["event_all_day_is_blocking"],
219
- expiration=calendar["expiration"],
220
- verbose=verbose,
221
- tz_name=calendar.get("timezone"),
222
- force_update=force_update,
223
- )
214
+ calendar_config = config["calendars"][calname]
215
+ if (
216
+ calendar_config.get("provider") == "google"
217
+ or calendar_config["url"].startswith("google://")
218
+ ):
219
+ from taskcheck.google_calendar import google_calendar_to_dict
220
+
221
+ calendar = google_calendar_to_dict(
222
+ calendar_config.get(
223
+ "calendar_id", calendar_config["url"].removeprefix("google://")
224
+ ),
225
+ calendar_config["token_path"],
226
+ config["scheduler"]["days_ahead"],
227
+ all_day=calendar_config["event_all_day_is_blocking"],
228
+ )
229
+ else:
230
+ calendar = ical_to_dict(
231
+ calendar_config["url"],
232
+ config["scheduler"]["days_ahead"],
233
+ all_day=calendar_config["event_all_day_is_blocking"],
234
+ expiration=calendar_config["expiration"],
235
+ verbose=verbose,
236
+ tz_name=calendar_config.get("timezone"),
237
+ force_update=force_update,
238
+ )
224
239
  calendar.sort(key=lambda e: e["start"])
225
240
  calendars.append(calendar)
226
241
  if verbose:
@@ -0,0 +1,172 @@
1
+ import json
2
+ import warnings
3
+ from datetime import datetime, time, timedelta
4
+ from pathlib import Path
5
+ from urllib.parse import urlparse
6
+
7
+ import requests
8
+ from google.auth.transport.requests import Request
9
+ from google.oauth2.credentials import Credentials
10
+ from google_auth_oauthlib.flow import InstalledAppFlow
11
+ from googleapiclient.discovery import build
12
+
13
+ from taskcheck.common import config_dir, get_task_env
14
+
15
+ SCOPES = [
16
+ "openid",
17
+ "https://www.googleapis.com/auth/userinfo.email",
18
+ "https://www.googleapis.com/auth/userinfo.profile",
19
+ "https://www.googleapis.com/auth/calendar.readonly",
20
+ ]
21
+
22
+
23
+ def get_google_data_dir(taskrc=None):
24
+ env = get_task_env(taskrc)
25
+ base = Path(env.get("TASKDATA", config_dir))
26
+ return base / "google"
27
+
28
+
29
+ def get_client_secrets_path(taskrc=None):
30
+ repo_secret = Path.cwd() / "client_secret.json"
31
+ if repo_secret.exists():
32
+ return repo_secret
33
+ return get_google_data_dir(taskrc) / "client_secret.json"
34
+
35
+
36
+ def get_token_path(account_id, taskrc=None):
37
+ return get_google_data_dir(taskrc) / f"{account_id}.token.json"
38
+
39
+
40
+ def _safe_account_id(email):
41
+ return email.replace("@", "_").replace(".", "_")
42
+
43
+
44
+ def load_credentials(taskrc=None):
45
+ secrets_path = get_client_secrets_path(taskrc)
46
+ if not secrets_path.exists():
47
+ raise FileNotFoundError(f"Missing Google OAuth client secrets: {secrets_path}")
48
+
49
+ token_dir = get_google_data_dir(taskrc)
50
+ token_dir.mkdir(parents=True, exist_ok=True)
51
+ token_path = token_dir / "current.token.json"
52
+ creds = None
53
+ if token_path.exists():
54
+ creds = Credentials.from_authorized_user_file(token_path, SCOPES)
55
+ if creds and creds.expired and creds.refresh_token:
56
+ creds.refresh(Request())
57
+ if not creds or not creds.valid:
58
+ flow = InstalledAppFlow.from_client_secrets_file(str(secrets_path), SCOPES)
59
+ with warnings.catch_warnings():
60
+ warnings.filterwarnings("ignore", message=r'^Scope has changed from .*')
61
+ creds = flow.run_local_server(port=0)
62
+ token_path.write_text(creds.to_json())
63
+ return creds
64
+
65
+
66
+ def get_google_user_email(credentials):
67
+ response = requests.get(
68
+ "https://openidconnect.googleapis.com/v1/userinfo",
69
+ headers={"Authorization": f"Bearer {credentials.token}"},
70
+ timeout=30,
71
+ )
72
+ response.raise_for_status()
73
+ payload = response.json()
74
+ return payload["email"], payload.get("name", payload["email"])
75
+
76
+
77
+ def list_calendars(credentials):
78
+ service = build("calendar", "v3", credentials=credentials, cache_discovery=False)
79
+ result = service.calendarList().list().execute()
80
+ return result.get("items", [])
81
+
82
+
83
+ def google_calendar_to_dict(calendar_id, token_path, days_ahead, all_day=True):
84
+ token_path = Path(token_path).expanduser()
85
+ if not token_path.exists():
86
+ raise FileNotFoundError(f"Missing Google OAuth token: {token_path}")
87
+
88
+ credentials = Credentials.from_authorized_user_file(str(token_path), SCOPES)
89
+ if credentials.expired and credentials.refresh_token:
90
+ credentials.refresh(Request())
91
+ token_path.write_text(credentials.to_json())
92
+ if not credentials.valid:
93
+ raise RuntimeError(f"Invalid Google OAuth token: {token_path}")
94
+
95
+ service = build("calendar", "v3", credentials=credentials, cache_discovery=False)
96
+ timezone = datetime.now().astimezone().tzinfo
97
+ start = datetime.combine(datetime.now().date(), time.min, timezone)
98
+ end = start + timedelta(days=days_ahead + 1)
99
+ params = {
100
+ "calendarId": calendar_id,
101
+ "timeMin": start.isoformat(),
102
+ "timeMax": end.isoformat(),
103
+ "singleEvents": True,
104
+ "orderBy": "startTime",
105
+ }
106
+ events = []
107
+ while True:
108
+ result = service.events().list(**params).execute()
109
+ for item in result.get("items", []):
110
+ event_start = item.get("start", {})
111
+ event_end = item.get("end", {})
112
+ if "dateTime" in event_start and "dateTime" in event_end:
113
+ events.append(
114
+ {"start": event_start["dateTime"], "end": event_end["dateTime"]}
115
+ )
116
+ elif all_day and "date" in event_start and "date" in event_end:
117
+ events.append(
118
+ {
119
+ "start": datetime.fromisoformat(event_start["date"]).isoformat(),
120
+ "end": datetime.fromisoformat(event_end["date"]).isoformat(),
121
+ }
122
+ )
123
+ page_token = result.get("nextPageToken")
124
+ if not page_token:
125
+ break
126
+ params["pageToken"] = page_token
127
+ return events
128
+
129
+
130
+ def select_from_list(items, title_key="summary"):
131
+ for idx, item in enumerate(items, start=1):
132
+ print(f"{idx}. {item.get(title_key, item.get('id'))}")
133
+ while True:
134
+ choice = input("Select one calendar: ").strip()
135
+ if choice.isdigit() and 1 <= int(choice) <= len(items):
136
+ return items[int(choice) - 1]
137
+ print("Invalid selection")
138
+
139
+
140
+ def render_calendar_config(account_id, calendar, taskrc=None):
141
+ token_path = get_token_path(account_id, taskrc)
142
+ return f'''[calendars."{account_id}.{calendar["id"]}"]
143
+ url = "google://{calendar["id"]}"
144
+ provider = "google"
145
+ account = "{account_id}"
146
+ calendar_id = "{calendar["id"]}"
147
+ token_path = "{token_path}"
148
+ event_all_day_is_blocking = true
149
+ expiration = 0.25
150
+ '''
151
+
152
+
153
+ def add_google_calendar(taskrc=None):
154
+ credentials = load_credentials(taskrc)
155
+ email, name = get_google_user_email(credentials)
156
+ account_id = _safe_account_id(email)
157
+ token_path = get_token_path(account_id, taskrc)
158
+ token_path.parent.mkdir(parents=True, exist_ok=True)
159
+ token_path.write_text(credentials.to_json())
160
+
161
+ calendars = list_calendars(credentials)
162
+ if not calendars:
163
+ raise RuntimeError("No Google calendars found for this account")
164
+ calendar = select_from_list(calendars)
165
+ print(render_calendar_config(account_id, calendar, taskrc=taskrc))
166
+ return {
167
+ "account_id": account_id,
168
+ "email": email,
169
+ "name": name,
170
+ "calendar": calendar,
171
+ "token_path": str(token_path),
172
+ }
@@ -187,7 +187,7 @@ def get_tasks(config, tasks, year, month, day):
187
187
  if m:
188
188
  due = task.get("due", "")
189
189
  if due:
190
- due = datetime.strptime(due, "%Y%m%dT%H%M%SZ")
190
+ due = parse_taskwarrior_timestamp(due)
191
191
  due = due - datetime.today()
192
192
  valid_tasks.append(
193
193
  {
@@ -219,7 +219,7 @@ def get_taskwarrior_date(date, _retry=True, taskrc=None):
219
219
  )
220
220
  date = date.stdout.strip()
221
221
  try:
222
- date = datetime.strptime(date, "%Y-%m-%dT%H:%M:%S")
222
+ date = parse_taskwarrior_timestamp(date)
223
223
  except Exception as _:
224
224
  if _retry:
225
225
  return get_taskwarrior_date("today+" + date, False, taskrc)
@@ -247,7 +247,7 @@ def tostring(value):
247
247
  return value.strftime("%Y-%m-%d %H:%M")
248
248
  elif isinstance(value, str):
249
249
  try:
250
- date = datetime.strptime(value, "%Y%m%dT%H%M%SZ")
250
+ date = parse_taskwarrior_timestamp(value)
251
251
  return date.strftime("%Y-%m-%d %H:%M")
252
252
  except ValueError:
253
253
  return value
@@ -275,7 +275,7 @@ def get_unplanned_tasks(config, tasks, taskrc=None):
275
275
  task
276
276
  for task in tasks
277
277
  if not task.get("due")
278
- or datetime.strptime(task["due"], "%Y%m%dT%H%M%SZ") <= due_limit
278
+ or parse_taskwarrior_timestamp(task["due"]) <= due_limit
279
279
  ]
280
280
 
281
281
 
@@ -298,6 +298,15 @@ def generate_report(config, constraint, verbose=False, force_update=False, taskr
298
298
  display_tasks_table(console, config, this_day_tasks)
299
299
 
300
300
 
301
+ def parse_taskwarrior_timestamp(value):
302
+ for fmt in ("%Y%m%dT%H%M%SZ", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S"):
303
+ try:
304
+ return datetime.strptime(value, fmt)
305
+ except ValueError:
306
+ pass
307
+ raise ValueError(value)
308
+
309
+
301
310
  def fetch_tasks(taskrc=None):
302
311
  """Fetch tasks from the task manager and return them as a JSON object."""
303
312
  from taskcheck.common import get_task_env
@@ -1,10 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taskcheck
3
- Version: 1.1.2
3
+ Version: 1.2.0
4
4
  Summary: Automatic scheduler for taskwarrior
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
7
  Requires-Dist: appdirs>=1.4.4
8
+ Requires-Dist: google-api-python-client>=2.198.0
9
+ Requires-Dist: google-auth>=2.56.2
10
+ Requires-Dist: google-auth-oauthlib>=1.2.2
8
11
  Requires-Dist: icalendar>=6.0.1
9
12
  Requires-Dist: random-unicode-emoji>=2.8
10
13
  Requires-Dist: requests>=2.32.3
@@ -3,6 +3,7 @@ README.md
3
3
  pyproject.toml
4
4
  taskcheck/__main__.py
5
5
  taskcheck/common.py
6
+ taskcheck/google_calendar.py
6
7
  taskcheck/ical.py
7
8
  taskcheck/install.py
8
9
  taskcheck/parallel.py
@@ -14,6 +15,7 @@ taskcheck.egg-info/entry_points.txt
14
15
  taskcheck.egg-info/requires.txt
15
16
  taskcheck.egg-info/top_level.txt
16
17
  tests/test_common.py
18
+ tests/test_google_calendar.py
17
19
  tests/test_ical.py
18
20
  tests/test_install.py
19
21
  tests/test_main.py
@@ -1,4 +1,7 @@
1
1
  appdirs>=1.4.4
2
+ google-api-python-client>=2.198.0
3
+ google-auth>=2.56.2
4
+ google-auth-oauthlib>=1.2.2
2
5
  icalendar>=6.0.1
3
6
  random-unicode-emoji>=2.8
4
7
  requests>=2.32.3
@@ -150,6 +150,31 @@ class TestGetCalendars:
150
150
  assert calendars[0] == sample_calendar_events
151
151
  mock_ical.assert_called_once()
152
152
 
153
+ @patch("taskcheck.google_calendar.google_calendar_to_dict")
154
+ @patch("taskcheck.common.ical_to_dict")
155
+ def test_get_google_calendars(
156
+ self, mock_ical, mock_google, sample_config, sample_calendar_events
157
+ ):
158
+ sample_config["calendars"] = {
159
+ "google": {
160
+ "url": "google://primary",
161
+ "provider": "google",
162
+ "calendar_id": "primary",
163
+ "token_path": "/tmp/google.token.json",
164
+ "event_all_day_is_blocking": True,
165
+ "expiration": 0.25,
166
+ }
167
+ }
168
+ mock_google.return_value = sample_calendar_events
169
+
170
+ calendars = get_calendars(sample_config)
171
+
172
+ assert calendars == [sample_calendar_events]
173
+ mock_google.assert_called_once_with(
174
+ "primary", "/tmp/google.token.json", 7, all_day=True
175
+ )
176
+ mock_ical.assert_not_called()
177
+
153
178
 
154
179
  class TestEnvironmentVariables:
155
180
  def test_get_task_env_sets_both_variables(self, test_taskrc):
@@ -0,0 +1,81 @@
1
+ from pathlib import Path
2
+ from unittest.mock import Mock, patch
3
+
4
+ from taskcheck.google_calendar import (
5
+ _safe_account_id,
6
+ get_client_secrets_path,
7
+ google_calendar_to_dict,
8
+ render_calendar_config,
9
+ )
10
+ from taskcheck.__main__ import main
11
+
12
+
13
+ def test_safe_account_id():
14
+ assert _safe_account_id("foo.bar@example.com") == "foo_bar_example_com"
15
+
16
+
17
+ def test_get_client_secrets_path_prefers_repo_root(tmp_path, monkeypatch):
18
+ secret = tmp_path / "client_secret.json"
19
+ secret.write_text("{}")
20
+ monkeypatch.chdir(tmp_path)
21
+ assert get_client_secrets_path("/tmp/task") == secret
22
+
23
+
24
+ @patch("taskcheck.google_calendar.get_token_path")
25
+ def test_render_calendar_config(mock_token_path):
26
+ mock_token_path.return_value = "/tmp/task/google/foo.token.json"
27
+ cfg = render_calendar_config("foo", {"id": "abc123"}, taskrc="/tmp/task")
28
+ assert '[calendars."foo.abc123"]' in cfg
29
+ assert "calendar_id = \"abc123\"" in cfg
30
+
31
+
32
+ @patch("taskcheck.google_calendar.build")
33
+ @patch("taskcheck.google_calendar.Credentials.from_authorized_user_file")
34
+ def test_google_calendar_to_dict(mock_credentials, mock_build, tmp_path):
35
+ token_path = tmp_path / "token.json"
36
+ token_path.write_text("{}")
37
+ mock_credentials.return_value = Mock(valid=True, expired=False)
38
+ events = Mock()
39
+ events.list.return_value.execute.return_value = {
40
+ "items": [
41
+ {
42
+ "start": {"dateTime": "2025-01-02T09:00:00+01:00"},
43
+ "end": {"dateTime": "2025-01-02T10:00:00+01:00"},
44
+ },
45
+ {
46
+ "start": {"date": "2025-01-03"},
47
+ "end": {"date": "2025-01-04"},
48
+ },
49
+ ]
50
+ }
51
+ mock_build.return_value.events.return_value = events
52
+
53
+ result = google_calendar_to_dict("primary", token_path, 7, all_day=True)
54
+
55
+ assert result == [
56
+ {
57
+ "start": "2025-01-02T09:00:00+01:00",
58
+ "end": "2025-01-02T10:00:00+01:00",
59
+ },
60
+ {"start": "2025-01-03T00:00:00", "end": "2025-01-04T00:00:00"},
61
+ ]
62
+ events.list.assert_called_once()
63
+
64
+
65
+ @patch("taskcheck.google_calendar.add_google_calendar")
66
+ @patch("taskcheck.__main__.arg_parser.parse_args")
67
+ def test_main_add_google_calendar(mock_args, mock_add):
68
+ mock_args.return_value = Mock(
69
+ install=False,
70
+ add_google_calendar=True,
71
+ schedule=False,
72
+ report=None,
73
+ verbose=False,
74
+ force_update=False,
75
+ taskrc="/tmp/task",
76
+ urgency_weight=None,
77
+ dry_run=False,
78
+ auto_adjust_urgency=True,
79
+ )
80
+ main()
81
+ mock_add.assert_called_once_with(taskrc="/tmp/task")
@@ -12,6 +12,7 @@ from taskcheck.report import (
12
12
  tostring,
13
13
  get_unplanned_tasks,
14
14
  fetch_tasks,
15
+ parse_taskwarrior_timestamp,
15
16
  )
16
17
 
17
18
 
@@ -152,10 +153,19 @@ class TestStringFormatting:
152
153
  dt = datetime(2023, 12, 5, 14, 30, 0)
153
154
  assert tostring(dt) == "2023-12-05 14:30"
154
155
 
155
- def test_tostring_taskwarrior_date(self):
156
- tw_date = "20231205T143000Z"
157
- result = tostring(tw_date)
158
- assert "2023-12-05 14:30" == result
156
+ @pytest.mark.parametrize(
157
+ ("timestamp", "expected"),
158
+ [
159
+ ("20231205T143000Z", datetime(2023, 12, 5, 14, 30)),
160
+ ("2023-12-05T14:30:00Z", datetime(2023, 12, 5, 14, 30)),
161
+ ],
162
+ )
163
+ def test_parse_taskwarrior_timestamp_supports_v2_and_v3(self, timestamp, expected):
164
+ assert parse_taskwarrior_timestamp(timestamp) == expected
165
+
166
+ @pytest.mark.parametrize("timestamp", ["20231205T143000Z", "2023-12-05T14:30:00Z"])
167
+ def test_tostring_taskwarrior_date(self, timestamp):
168
+ assert tostring(timestamp) == "2023-12-05 14:30"
159
169
 
160
170
  def test_tostring_regular_string(self):
161
171
  assert tostring("hello") == "hello"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes