microsoft-todo-cli 1.0.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.
@@ -0,0 +1,59 @@
1
+ from datetime import datetime, timezone
2
+
3
+
4
+ def _parse_datetime(dt_str):
5
+ """Parse datetime string from checklist items API.
6
+
7
+ The checklistItems endpoint returns datetimes without fractional seconds
8
+ (e.g. "2026-02-04T19:08:45Z"), unlike tasks which include 7 digits
9
+ (e.g. "2024-01-25T10:00:00.0000000Z").
10
+ """
11
+ if dt_str is None:
12
+ return None
13
+
14
+ if isinstance(dt_str, dict):
15
+ dt_str = dt_str["dateTime"]
16
+
17
+ # Strip trailing Z
18
+ dt_str = dt_str.rstrip("Z")
19
+
20
+ # Truncate fractional seconds to 6 digits (Python's %f limit)
21
+ if "." in dt_str:
22
+ base, frac = dt_str.rsplit(".", 1)
23
+ dt_str = f"{base}.{frac[:6]}"
24
+
25
+ for fmt in ("%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
26
+ try:
27
+ dt = datetime.strptime(dt_str, fmt)
28
+ return dt.replace(tzinfo=timezone.utc).astimezone(tz=None)
29
+ except ValueError:
30
+ continue
31
+
32
+ return None
33
+
34
+
35
+ class ChecklistItem:
36
+ def __init__(self, query_result):
37
+ self.id: str = query_result["id"]
38
+ self.display_name: str = query_result["displayName"]
39
+ self.is_checked: bool = bool(query_result["isChecked"])
40
+ self.created_datetime = _parse_datetime(query_result["createdDateTime"])
41
+
42
+ if "checkedDateTime" in query_result and query_result["checkedDateTime"]:
43
+ self.checked_datetime = _parse_datetime(query_result["checkedDateTime"])
44
+ else:
45
+ self.checked_datetime = None
46
+
47
+ def to_dict(self):
48
+ """Convert checklist item to dictionary for JSON serialization."""
49
+ return {
50
+ "id": self.id,
51
+ "display_name": self.display_name,
52
+ "is_checked": self.is_checked,
53
+ "created_datetime": (
54
+ self.created_datetime.isoformat() if self.created_datetime else None
55
+ ),
56
+ "checked_datetime": (
57
+ self.checked_datetime.isoformat() if self.checked_datetime else None
58
+ ),
59
+ }
@@ -0,0 +1,27 @@
1
+ from enum import Enum
2
+
3
+
4
+ class TodoList:
5
+ class WellKnownListName(Enum):
6
+ none = "none"
7
+ DefaultList = "defaultList"
8
+ FlaggedEmails = "flaggedEmails"
9
+
10
+ def __init__(self, query_result_list):
11
+ self.id: str = query_result_list["id"]
12
+ self.display_name: str = query_result_list["displayName"]
13
+ self.is_owner = bool(query_result_list["isOwner"])
14
+ self.is_shared = bool(query_result_list["isShared"])
15
+ self.well_known_list_name = TodoList.WellKnownListName(
16
+ query_result_list["wellknownListName"]
17
+ )
18
+
19
+ def to_dict(self):
20
+ """Convert list to dictionary for JSON serialization."""
21
+ return {
22
+ "id": self.id,
23
+ "display_name": self.display_name,
24
+ "is_owner": self.is_owner,
25
+ "is_shared": self.is_shared,
26
+ "well_known_list_name": self.well_known_list_name.value,
27
+ }
@@ -0,0 +1,105 @@
1
+ from enum import Enum
2
+ from todocli.utils.datetime_util import api_timestamp_to_datetime
3
+
4
+
5
+ class TaskStatus(str, Enum):
6
+ COMPLETED = "completed"
7
+ NOT_STARTED = "notStarted"
8
+ IN_PROGRESS = "inProgress"
9
+ WAITING_ON_OTHERS = "waitingOnOthers"
10
+ DEFERRED = "deferred"
11
+
12
+
13
+ class TaskImportance(str, Enum):
14
+ LOW = "low"
15
+ NORMAL = "normal"
16
+ HIGH = "high"
17
+
18
+
19
+ class RecurrencePatternType(str, Enum):
20
+ DAILY = "daily"
21
+ WEEKLY = "weekly"
22
+ ABSOLUTE_MONTHLY = "absoluteMonthly"
23
+ RELATIVE_MONTHLY = "relativeMonthly"
24
+ ABSOLUTE_YEARLY = "absoluteYearly"
25
+ RELATIVE_YEARLY = "relativeYearly"
26
+
27
+
28
+ class DayOfWeek(str, Enum):
29
+ MONDAY = "monday"
30
+ TUESDAY = "tuesday"
31
+ WEDNESDAY = "wednesday"
32
+ THURSDAY = "thursday"
33
+ FRIDAY = "friday"
34
+ SATURDAY = "saturday"
35
+ SUNDAY = "sunday"
36
+
37
+
38
+ class Task:
39
+ def __init__(self, query_result):
40
+ self.title = query_result["title"]
41
+ self.id = query_result["id"]
42
+ self.importance = TaskImportance(query_result["importance"])
43
+ self.status = TaskStatus(query_result["status"])
44
+ self.created_datetime = api_timestamp_to_datetime(
45
+ query_result["createdDateTime"]
46
+ )
47
+
48
+ if "completedDateTime" in query_result:
49
+ self.completed_datetime = api_timestamp_to_datetime(
50
+ query_result["completedDateTime"]
51
+ )
52
+ else:
53
+ self.completed_datetime = None
54
+
55
+ self.is_reminder_on: bool = bool(query_result["isReminderOn"])
56
+
57
+ if "dueDateTime" in query_result:
58
+ self.due_datetime = api_timestamp_to_datetime(query_result["dueDateTime"])
59
+ else:
60
+ self.due_datetime = None
61
+
62
+ if "reminderDateTime" in query_result:
63
+ self.reminder_datetime = api_timestamp_to_datetime(
64
+ query_result["reminderDateTime"]
65
+ )
66
+ else:
67
+ self.reminder_datetime = None
68
+
69
+ self.last_modified_datetime = api_timestamp_to_datetime(
70
+ query_result["lastModifiedDateTime"]
71
+ )
72
+
73
+ if "bodyLastModifiedDateTime" in query_result:
74
+ self.body_last_modified_datetime = api_timestamp_to_datetime(
75
+ query_result["bodyLastModifiedDateTime"]
76
+ )
77
+ else:
78
+ self.body_last_modified_datetime = None
79
+
80
+ def to_dict(self):
81
+ """Convert task to dictionary for JSON serialization."""
82
+ return {
83
+ "id": self.id,
84
+ "title": self.title,
85
+ "status": self.status.value,
86
+ "importance": self.importance.value,
87
+ "is_reminder_on": self.is_reminder_on,
88
+ "created_datetime": (
89
+ self.created_datetime.isoformat() if self.created_datetime else None
90
+ ),
91
+ "due_datetime": (
92
+ self.due_datetime.isoformat() if self.due_datetime else None
93
+ ),
94
+ "reminder_datetime": (
95
+ self.reminder_datetime.isoformat() if self.reminder_datetime else None
96
+ ),
97
+ "completed_datetime": (
98
+ self.completed_datetime.isoformat() if self.completed_datetime else None
99
+ ),
100
+ "last_modified_datetime": (
101
+ self.last_modified_datetime.isoformat()
102
+ if self.last_modified_datetime
103
+ else None
104
+ ),
105
+ }
File without changes
@@ -0,0 +1,321 @@
1
+ import re
2
+ from datetime import datetime, timedelta, timezone
3
+ from typing import Union
4
+
5
+
6
+ class TimeExpressionNotRecognized(Exception):
7
+ def __init__(self, time_str):
8
+ self.message = (
9
+ f"Time expression could not be parsed: {time_str}\n"
10
+ f"Supported formats: 1h, 30m, 9am, 5:30pm, 17:00, tomorrow, monday, mon, "
11
+ f"DD.MM.YYYY, YYYY-MM-DD, MM/DD/YYYY"
12
+ )
13
+ super(TimeExpressionNotRecognized, self).__init__(self.message)
14
+
15
+
16
+ class ErrorParsingTime(Exception):
17
+ def __init__(self, message):
18
+ self.message = message
19
+ super(ErrorParsingTime, self).__init__(message)
20
+
21
+
22
+ def parse_hour_minute(input_str):
23
+ split_str = input_str.split(":")
24
+ hour = int(split_str[0])
25
+ minute = int(split_str[1])
26
+ return hour, minute
27
+
28
+
29
+ def parse_day_month_DD_MM(input_str):
30
+ split_str = input_str.split(".")
31
+ day = int(split_str[0])
32
+ month = int(split_str[1])
33
+ return day, month
34
+
35
+
36
+ def parse_day_month_MM_DD(input_str):
37
+ split_str = input_str.split("/")
38
+ month = int(split_str[0])
39
+ day = int(split_str[1])
40
+ return day, month
41
+
42
+
43
+ def parse_day_month_DD_MM_YYorYYYY(input_str):
44
+ split_str = input_str.split(".")
45
+ day = int(split_str[0])
46
+ month = int(split_str[1])
47
+ year = split_str[2]
48
+ if len(year) == 2:
49
+ year = int("20" + year)
50
+ else:
51
+ year = int(year)
52
+ return day, month, year
53
+
54
+
55
+ def add_day_if_past(dt: datetime) -> datetime:
56
+ """This function will add a day to the datetime object 'dt' when 'dt' is in the past"""
57
+ dt_now = datetime.now()
58
+ if dt < dt_now:
59
+ return dt + timedelta(days=1)
60
+ else:
61
+ return dt
62
+
63
+
64
+ def parse_datetime(datetime_str: str):
65
+ try:
66
+ if match := re.match(
67
+ r"(?:(\d+)/(\d+)/)?(\d+d)?(\d+h)?(\d+m)?(\d+s)?$",
68
+ datetime_str,
69
+ re.IGNORECASE,
70
+ ):
71
+ """e.g. 1h / 12h"""
72
+ multiplier = 1
73
+ if match.group(1):
74
+ multiplier = int(match.group(2)) - int(match.group(1))
75
+ return (
76
+ datetime.now()
77
+ + timedelta(
78
+ days=0 if match.group(3) is None else int(match.group(3)[:-1]),
79
+ hours=0 if match.group(4) is None else int(match.group(4)[:-1]),
80
+ minutes=0 if match.group(5) is None else int(match.group(5)[:-1]),
81
+ seconds=0 if match.group(6) is None else int(match.group(6)[:-1]),
82
+ )
83
+ * multiplier
84
+ )
85
+
86
+ if datetime_str == "morning":
87
+ dt = datetime.now()
88
+ return add_day_if_past(
89
+ dt.replace(hour=7, minute=0, second=0, microsecond=0)
90
+ )
91
+
92
+ if datetime_str == "tomorrow":
93
+ dt = datetime.now()
94
+ return dt.replace(hour=7, minute=0, second=0, microsecond=0) + timedelta(
95
+ days=1
96
+ )
97
+
98
+ if datetime_str == "evening":
99
+ dt = datetime.now()
100
+ return add_day_if_past(
101
+ dt.replace(hour=18, minute=0, second=0, microsecond=0)
102
+ )
103
+
104
+ # Day names (monday, mon, tuesday, tue, etc.)
105
+ day_names = {
106
+ "monday": 0, "mon": 0,
107
+ "tuesday": 1, "tue": 1,
108
+ "wednesday": 2, "wed": 2,
109
+ "thursday": 3, "thu": 3,
110
+ "friday": 4, "fri": 4,
111
+ "saturday": 5, "sat": 5,
112
+ "sunday": 6, "sun": 6,
113
+ }
114
+ if datetime_str.lower() in day_names:
115
+ target_weekday = day_names[datetime_str.lower()]
116
+ dt = datetime.now()
117
+ current_weekday = dt.weekday()
118
+ days_ahead = target_weekday - current_weekday
119
+ if days_ahead <= 0: # Target day already happened this week
120
+ days_ahead += 7
121
+ return (dt + timedelta(days=days_ahead)).replace(
122
+ hour=7, minute=0, second=0, microsecond=0
123
+ )
124
+
125
+ # Time without space: 9am, 5pm, 5:30pm, 10:30am
126
+ if match := re.match(r"^(\d{1,2})(?::(\d{2}))?(am|pm)$", datetime_str, re.IGNORECASE):
127
+ hour = int(match.group(1))
128
+ minute = int(match.group(2)) if match.group(2) else 0
129
+ is_pm = match.group(3).lower() == "pm"
130
+
131
+ if is_pm and hour != 12:
132
+ hour += 12
133
+ elif not is_pm and hour == 12:
134
+ hour = 0
135
+
136
+ return add_day_if_past(
137
+ datetime.now().replace(
138
+ hour=hour, minute=minute, second=0, microsecond=0
139
+ )
140
+ )
141
+
142
+ if re.match(r"([0-9]{1,2}:[0-9]{2} (am|pm))", datetime_str, re.IGNORECASE):
143
+ """e.g. 5:30 pm"""
144
+ split_str = datetime_str.split(" ")
145
+ hour, minute = parse_hour_minute(split_str[0])
146
+
147
+ if split_str[1].lower() == "am":
148
+ if hour == 12:
149
+ hour = 0
150
+ else:
151
+ if hour != 12:
152
+ hour = hour + 12
153
+
154
+ return add_day_if_past(
155
+ datetime.now().replace(
156
+ hour=hour, minute=minute, second=0, microsecond=0
157
+ )
158
+ )
159
+
160
+ if re.match(r"([0-9]{1,2}:[0-9]{2})", datetime_str, re.IGNORECASE):
161
+ """e.g. 17:00"""
162
+ hour, minute = parse_hour_minute(datetime_str)
163
+ return add_day_if_past(
164
+ datetime.now().replace(
165
+ hour=hour, minute=minute, second=0, microsecond=0
166
+ )
167
+ )
168
+
169
+ if re.match(
170
+ r"([0-9]{1,2}\.[0-9]{1,2}\.(([0-9]{4})|([0-9]{2})))$",
171
+ datetime_str,
172
+ re.IGNORECASE,
173
+ ):
174
+ """e.g. 17.01.20 or 22.12.2020"""
175
+ day, month, year = parse_day_month_DD_MM_YYorYYYY(datetime_str)
176
+ return datetime.now().replace(
177
+ year=year,
178
+ day=day,
179
+ month=month,
180
+ hour=7,
181
+ minute=0,
182
+ second=0,
183
+ microsecond=0,
184
+ )
185
+
186
+ if re.match(r"\d{4}-\d{1,2}-\d{1,2}$", datetime_str):
187
+ """e.g. 2026-02-11 or 2026-2-11 (ISO 8601)"""
188
+ parts = datetime_str.split("-")
189
+ year = int(parts[0])
190
+ month = int(parts[1])
191
+ day = int(parts[2])
192
+ return datetime.now().replace(
193
+ year=year,
194
+ day=day,
195
+ month=month,
196
+ hour=7,
197
+ minute=0,
198
+ second=0,
199
+ microsecond=0,
200
+ )
201
+
202
+ if re.match(
203
+ r"([0-9]{1,2}/[0-9]{1,2}/(([0-9]{4})|([0-9]{2})))$",
204
+ datetime_str,
205
+ ):
206
+ """e.g. 02/11/2026 or 02/11/26 (US date, MM/DD/YYYY)"""
207
+ parts = datetime_str.split("/")
208
+ month = int(parts[0])
209
+ day = int(parts[1])
210
+ year_str = parts[2]
211
+ year = int("20" + year_str) if len(year_str) == 2 else int(year_str)
212
+ return datetime.now().replace(
213
+ year=year,
214
+ day=day,
215
+ month=month,
216
+ hour=7,
217
+ minute=0,
218
+ second=0,
219
+ microsecond=0,
220
+ )
221
+
222
+ if re.match(
223
+ r"([0-9]{1,2}\.[0-9]{1,2}\. [0-9]{1,2}:[0-9]{2})",
224
+ datetime_str,
225
+ re.IGNORECASE,
226
+ ):
227
+ """e.g. 17.01. 17:00"""
228
+ split_str = datetime_str.split(" ")
229
+ day, month = parse_day_month_DD_MM(split_str[0])
230
+ hour, minute = parse_hour_minute(split_str[1])
231
+ return datetime.now().replace(
232
+ day=day, month=month, hour=hour, minute=minute, second=0, microsecond=0
233
+ )
234
+
235
+ if re.match(
236
+ r"([0-9]{1,2}/[0-9]{1,2} [0-9]{1,2}:[0-9]{2} (am|pm))",
237
+ datetime_str,
238
+ re.IGNORECASE,
239
+ ):
240
+ """e.g. 01/17 5:00 pm"""
241
+ split_str = datetime_str.split(" ")
242
+ day, month = parse_day_month_MM_DD(split_str[0])
243
+ hour, minute = parse_hour_minute(split_str[1])
244
+
245
+ if split_str[2].lower() == "am":
246
+ if hour == 12:
247
+ hour = 0
248
+ else:
249
+ if hour != 12:
250
+ hour = hour + 12
251
+
252
+ return datetime.now().replace(
253
+ day=day, month=month, hour=hour, minute=minute, second=0, microsecond=0
254
+ )
255
+
256
+ except ValueError as e:
257
+ raise ErrorParsingTime(str(e))
258
+
259
+ raise TimeExpressionNotRecognized(datetime_str)
260
+
261
+
262
+ def format_date(dt: datetime, fmt: str = "eu") -> str:
263
+ if fmt == "iso":
264
+ return dt.strftime("%Y-%m-%d")
265
+ elif fmt == "us":
266
+ return dt.strftime("%m/%d/%Y")
267
+ else:
268
+ return dt.strftime("%d.%m.%Y")
269
+
270
+
271
+ def datetime_to_api_timestamp(dt: datetime | None):
272
+ if dt is None:
273
+ return None
274
+
275
+ utc_dt = dt.astimezone(timezone.utc)
276
+ timestamp_str = utc_dt.strftime("%Y-%m-%dT%H:%M:%S")
277
+
278
+ api_dt = {"dateTime": timestamp_str, "timeZone": "UTC"}
279
+ return api_dt
280
+
281
+
282
+ def api_timestamp_to_datetime(api_dt: Union[str, dict]):
283
+ """Converts the datetime string returned by the API to python datetime object.
284
+
285
+ Handles various timestamp formats from Microsoft Graph API:
286
+ - With 7-digit microseconds: "2024-01-25T10:00:00.0000000Z"
287
+ - Without microseconds: "2024-01-25T10:00:00Z"
288
+ - Dict format: {"dateTime": "...", "timeZone": "UTC"}
289
+ """
290
+ if api_dt is None:
291
+ return None
292
+
293
+ # Extract the datetime string
294
+ if isinstance(api_dt, str):
295
+ dt_str = api_dt
296
+ elif isinstance(api_dt, dict):
297
+ dt_str = api_dt.get("dateTime", "")
298
+ else:
299
+ raise TypeError(f"Expected str or dict, got {type(api_dt).__name__}")
300
+
301
+ # Strip trailing Z
302
+ dt_str = dt_str.rstrip("Z")
303
+
304
+ # Truncate fractional seconds to 6 digits (Python's %f limit)
305
+ if "." in dt_str:
306
+ base, frac = dt_str.rsplit(".", 1)
307
+ dt_str = f"{base}.{frac[:6]}"
308
+
309
+ # Try parsing with and without fractional seconds
310
+ for fmt in ("%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
311
+ try:
312
+ dt = datetime.strptime(dt_str, fmt)
313
+ return utc_to_local(dt)
314
+ except ValueError:
315
+ continue
316
+
317
+ raise ValueError(f"Unable to parse datetime: {api_dt}")
318
+
319
+
320
+ def utc_to_local(_dt):
321
+ return _dt.replace(tzinfo=timezone.utc).astimezone(tz=None)
@@ -0,0 +1,122 @@
1
+ import re
2
+ from datetime import date
3
+
4
+ from todocli.models.todotask import RecurrencePatternType, DayOfWeek
5
+
6
+
7
+ class InvalidRecurrenceExpression(Exception):
8
+ def __init__(self, expr):
9
+ self.message = f"Invalid recurrence expression: '{expr}'"
10
+ super().__init__(self.message)
11
+
12
+
13
+ DAY_MAP = {
14
+ "mon": DayOfWeek.MONDAY,
15
+ "tue": DayOfWeek.TUESDAY,
16
+ "wed": DayOfWeek.WEDNESDAY,
17
+ "thu": DayOfWeek.THURSDAY,
18
+ "fri": DayOfWeek.FRIDAY,
19
+ "sat": DayOfWeek.SATURDAY,
20
+ "sun": DayOfWeek.SUNDAY,
21
+ }
22
+
23
+ UNIT_MAP = {
24
+ "day": RecurrencePatternType.DAILY,
25
+ "days": RecurrencePatternType.DAILY,
26
+ "week": RecurrencePatternType.WEEKLY,
27
+ "weeks": RecurrencePatternType.WEEKLY,
28
+ "month": RecurrencePatternType.ABSOLUTE_MONTHLY,
29
+ "months": RecurrencePatternType.ABSOLUTE_MONTHLY,
30
+ "year": RecurrencePatternType.ABSOLUTE_YEARLY,
31
+ "years": RecurrencePatternType.ABSOLUTE_YEARLY,
32
+ }
33
+
34
+ PRESET_MAP = {
35
+ "daily": (RecurrencePatternType.DAILY, 1),
36
+ "weekly": (RecurrencePatternType.WEEKLY, 1),
37
+ "monthly": (RecurrencePatternType.ABSOLUTE_MONTHLY, 1),
38
+ "yearly": (RecurrencePatternType.ABSOLUTE_YEARLY, 1),
39
+ }
40
+
41
+ WEEKDAYS = [
42
+ DayOfWeek.MONDAY,
43
+ DayOfWeek.TUESDAY,
44
+ DayOfWeek.WEDNESDAY,
45
+ DayOfWeek.THURSDAY,
46
+ DayOfWeek.FRIDAY,
47
+ ]
48
+
49
+
50
+ def _parse_days(days_str):
51
+ days = []
52
+ for d in days_str.split(","):
53
+ d = d.strip().lower()
54
+ if d not in DAY_MAP:
55
+ raise ValueError(d)
56
+ days.append(DAY_MAP[d])
57
+ return days
58
+
59
+
60
+ def parse_recurrence(expr):
61
+ if expr is None:
62
+ return None
63
+
64
+ expr = expr.strip()
65
+ if not expr:
66
+ return None
67
+
68
+ # Split on ":" to separate base from day specifiers
69
+ parts = expr.split(":", 1)
70
+ base_part = parts[0].strip().lower()
71
+ days_part = parts[1].strip() if len(parts) > 1 else None
72
+
73
+ pattern_type = None
74
+ interval = 1
75
+
76
+ # Check for "weekdays" preset
77
+ if base_part == "weekdays":
78
+ pattern_type = RecurrencePatternType.WEEKLY
79
+ interval = 1
80
+ days_part = days_part or "mon,tue,wed,thu,fri"
81
+
82
+ # Check for simple presets
83
+ elif base_part in PRESET_MAP:
84
+ pattern_type, interval = PRESET_MAP[base_part]
85
+
86
+ # Check for "every N unit" format
87
+ else:
88
+ match = re.match(r"^every\s+(\d+)\s+(days?|weeks?|months?|years?)$", base_part)
89
+ if match:
90
+ interval = int(match.group(1))
91
+ unit = match.group(2)
92
+ pattern_type = UNIT_MAP[unit]
93
+ else:
94
+ raise InvalidRecurrenceExpression(expr)
95
+
96
+ # Build pattern
97
+ today = date.today()
98
+ pattern = {
99
+ "type": pattern_type.value,
100
+ "interval": interval,
101
+ }
102
+
103
+ if days_part:
104
+ try:
105
+ pattern["daysOfWeek"] = [d.value for d in _parse_days(days_part)]
106
+ except ValueError:
107
+ raise InvalidRecurrenceExpression(expr)
108
+
109
+ if pattern_type == RecurrencePatternType.ABSOLUTE_MONTHLY:
110
+ pattern["dayOfMonth"] = today.day
111
+
112
+ if pattern_type == RecurrencePatternType.ABSOLUTE_YEARLY:
113
+ pattern["dayOfMonth"] = today.day
114
+ pattern["month"] = today.month
115
+
116
+ return {
117
+ "pattern": pattern,
118
+ "range": {
119
+ "type": "noEnd",
120
+ "startDate": today.isoformat(),
121
+ },
122
+ }
@@ -0,0 +1,55 @@
1
+ import os
2
+ import yaml
3
+ import requests
4
+ import todocli
5
+ from datetime import datetime, timedelta
6
+
7
+ DATE_FORMAT = "%Y%m%d"
8
+ PYPI_PACKAGE = "microsoft-todo-cli"
9
+
10
+
11
+ def check():
12
+ """Check for updates once per day, silently fail on errors."""
13
+ config_dir = "{}/.config/tod0".format(os.path.expanduser("~"))
14
+ if not os.path.isdir(config_dir):
15
+ os.makedirs(config_dir)
16
+
17
+ last_update_check = datetime(1990, 1, 1)
18
+ file_path = os.path.join(config_dir, "data.yml")
19
+ if os.path.isfile(file_path):
20
+ try:
21
+ with open(file_path, "r") as f:
22
+ data = yaml.load(f, yaml.SafeLoader)
23
+ if data and "last_update_check" in data:
24
+ last_update_check = datetime.strptime(
25
+ data["last_update_check"], DATE_FORMAT
26
+ )
27
+ except (OSError, yaml.YAMLError, ValueError):
28
+ pass
29
+
30
+ # Check for updates if it has been a day since last check
31
+ if last_update_check + timedelta(days=1) < datetime.now():
32
+ try:
33
+ response = requests.get(
34
+ f"https://pypi.org/pypi/{PYPI_PACKAGE}/json",
35
+ timeout=5,
36
+ )
37
+ if response.ok:
38
+ data = response.json()
39
+ latest_version = data.get("info", {}).get("version", "0.0.0")
40
+ latest_tuple = tuple(map(int, latest_version.split(".")[:3]))
41
+ current_tuple = tuple(map(int, todocli.__version__.split(".")[:3]))
42
+
43
+ if latest_tuple > current_tuple:
44
+ print(
45
+ f"Update available: {todocli.__version__} -> {latest_version}. "
46
+ f'Run "pip install --upgrade {PYPI_PACKAGE}"'
47
+ )
48
+
49
+ with open(file_path, "w") as f:
50
+ yaml.dump(
51
+ {"last_update_check": datetime.now().strftime(DATE_FORMAT)}, f
52
+ )
53
+ except (requests.RequestException, ValueError, KeyError, OSError):
54
+ # Silently ignore update check failures
55
+ pass