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.
- microsoft_todo_cli-1.0.0.dist-info/METADATA +296 -0
- microsoft_todo_cli-1.0.0.dist-info/RECORD +37 -0
- microsoft_todo_cli-1.0.0.dist-info/WHEEL +5 -0
- microsoft_todo_cli-1.0.0.dist-info/entry_points.txt +2 -0
- microsoft_todo_cli-1.0.0.dist-info/licenses/LICENSE +22 -0
- microsoft_todo_cli-1.0.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/run_tests.py +39 -0
- tests/test_checklist_cli.py +118 -0
- tests/test_checklist_item_model.py +108 -0
- tests/test_checklist_wrapper.py +62 -0
- tests/test_cli_commands.py +436 -0
- tests/test_cli_output.py +169 -0
- tests/test_cli_url_integration.py +136 -0
- tests/test_datetime_parser.py +233 -0
- tests/test_filters.py +120 -0
- tests/test_json_output.py +223 -0
- tests/test_lst_output.py +135 -0
- tests/test_models.py +235 -0
- tests/test_odata_escape.py +175 -0
- tests/test_recurrence.py +159 -0
- tests/test_update_command.py +192 -0
- tests/test_utils.py +186 -0
- tests/test_wrapper.py +191 -0
- todocli/__init__.py +1 -0
- todocli/cli.py +1356 -0
- todocli/graphapi/__init__.py +0 -0
- todocli/graphapi/oauth.py +136 -0
- todocli/graphapi/wrapper.py +660 -0
- todocli/models/__init__.py +0 -0
- todocli/models/checklistitem.py +59 -0
- todocli/models/todolist.py +27 -0
- todocli/models/todotask.py +105 -0
- todocli/utils/__init__.py +0 -0
- todocli/utils/datetime_util.py +321 -0
- todocli/utils/recurrence_util.py +122 -0
- todocli/utils/update_checker.py +55 -0
tests/test_utils.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared test utilities and mock factories for todocli tests."""
|
|
3
|
+
|
|
4
|
+
from unittest.mock import MagicMock
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def make_mock_task(
|
|
9
|
+
title: str,
|
|
10
|
+
task_id: str = "tid",
|
|
11
|
+
importance: str = "normal",
|
|
12
|
+
status: str = "notStarted",
|
|
13
|
+
due_datetime: datetime = None,
|
|
14
|
+
reminder_datetime: datetime = None,
|
|
15
|
+
created_datetime: datetime = None,
|
|
16
|
+
):
|
|
17
|
+
"""Create a mock Task object for testing.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
title: Task title
|
|
21
|
+
task_id: Task ID (default: "tid")
|
|
22
|
+
importance: "low", "normal", or "high" (default: "normal")
|
|
23
|
+
status: Task status (default: "notStarted")
|
|
24
|
+
due_datetime: Optional due date
|
|
25
|
+
reminder_datetime: Optional reminder datetime
|
|
26
|
+
created_datetime: Creation datetime (default: 2026-01-01 10:00)
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
MagicMock configured as a Task
|
|
30
|
+
"""
|
|
31
|
+
task = MagicMock()
|
|
32
|
+
task.id = task_id
|
|
33
|
+
task.title = title
|
|
34
|
+
|
|
35
|
+
# Mock enum-like importance with .value attribute
|
|
36
|
+
task.importance = MagicMock()
|
|
37
|
+
task.importance.value = importance
|
|
38
|
+
|
|
39
|
+
# Mock enum-like status with .value attribute
|
|
40
|
+
task.status = MagicMock()
|
|
41
|
+
task.status.value = status
|
|
42
|
+
|
|
43
|
+
task.due_datetime = due_datetime
|
|
44
|
+
task.reminder_datetime = reminder_datetime
|
|
45
|
+
task.created_datetime = created_datetime or datetime(2026, 1, 1, 10, 0, 0)
|
|
46
|
+
task.completed_datetime = None
|
|
47
|
+
task.is_reminder_on = reminder_datetime is not None
|
|
48
|
+
task.last_modified_datetime = task.created_datetime
|
|
49
|
+
|
|
50
|
+
# Mock to_dict method
|
|
51
|
+
task.to_dict.return_value = {
|
|
52
|
+
"id": task_id,
|
|
53
|
+
"title": title,
|
|
54
|
+
"status": status,
|
|
55
|
+
"importance": importance,
|
|
56
|
+
"due_datetime": due_datetime.isoformat() if due_datetime else None,
|
|
57
|
+
"reminder_datetime": (
|
|
58
|
+
reminder_datetime.isoformat() if reminder_datetime else None
|
|
59
|
+
),
|
|
60
|
+
"created_datetime": task.created_datetime.isoformat(),
|
|
61
|
+
"completed_datetime": None,
|
|
62
|
+
"is_reminder_on": task.is_reminder_on,
|
|
63
|
+
"last_modified_datetime": task.created_datetime.isoformat(),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return task
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def make_mock_list(
|
|
70
|
+
display_name: str,
|
|
71
|
+
list_id: str = "lid",
|
|
72
|
+
is_owner: bool = True,
|
|
73
|
+
is_shared: bool = False,
|
|
74
|
+
):
|
|
75
|
+
"""Create a mock TodoList object for testing.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
display_name: List name
|
|
79
|
+
list_id: List ID (default: "lid")
|
|
80
|
+
is_owner: Whether user owns the list (default: True)
|
|
81
|
+
is_shared: Whether list is shared (default: False)
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
MagicMock configured as a TodoList
|
|
85
|
+
"""
|
|
86
|
+
lst = MagicMock()
|
|
87
|
+
lst.id = list_id
|
|
88
|
+
lst.display_name = display_name
|
|
89
|
+
lst.is_owner = is_owner
|
|
90
|
+
lst.is_shared = is_shared
|
|
91
|
+
lst.well_known_list_name = MagicMock()
|
|
92
|
+
lst.well_known_list_name.value = "none"
|
|
93
|
+
|
|
94
|
+
lst.to_dict.return_value = {
|
|
95
|
+
"id": list_id,
|
|
96
|
+
"display_name": display_name,
|
|
97
|
+
"is_owner": is_owner,
|
|
98
|
+
"is_shared": is_shared,
|
|
99
|
+
"well_known_list_name": "none",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return lst
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def make_mock_step(
|
|
106
|
+
name: str,
|
|
107
|
+
step_id: str = "sid",
|
|
108
|
+
is_checked: bool = False,
|
|
109
|
+
created_datetime: datetime = None,
|
|
110
|
+
):
|
|
111
|
+
"""Create a mock ChecklistItem object for testing.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
name: Step/checklist item name
|
|
115
|
+
step_id: Step ID (default: "sid")
|
|
116
|
+
is_checked: Whether step is completed (default: False)
|
|
117
|
+
created_datetime: Creation datetime (default: 2026-01-01 10:00)
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
MagicMock configured as a ChecklistItem
|
|
121
|
+
"""
|
|
122
|
+
step = MagicMock()
|
|
123
|
+
step.id = step_id
|
|
124
|
+
step.display_name = name
|
|
125
|
+
step.is_checked = is_checked
|
|
126
|
+
step.created_datetime = created_datetime or datetime(2026, 1, 1, 10, 0, 0)
|
|
127
|
+
step.checked_datetime = None
|
|
128
|
+
|
|
129
|
+
step.to_dict.return_value = {
|
|
130
|
+
"id": step_id,
|
|
131
|
+
"display_name": name,
|
|
132
|
+
"is_checked": is_checked,
|
|
133
|
+
"created_datetime": step.created_datetime.isoformat(),
|
|
134
|
+
"checked_datetime": None,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return step
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def make_mock_args(**kwargs):
|
|
141
|
+
"""Create mock argparse args with sensible defaults.
|
|
142
|
+
|
|
143
|
+
Commonly used defaults:
|
|
144
|
+
- json: False
|
|
145
|
+
- list_name: "Tasks"
|
|
146
|
+
- no_steps: False
|
|
147
|
+
- date_format: "eu"
|
|
148
|
+
- due_today: False
|
|
149
|
+
- overdue: False
|
|
150
|
+
- important: False
|
|
151
|
+
- list: None
|
|
152
|
+
- task_names: None
|
|
153
|
+
- yes: False
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
**kwargs: Override any default values
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
MagicMock configured as argparse Namespace
|
|
160
|
+
"""
|
|
161
|
+
args = MagicMock()
|
|
162
|
+
|
|
163
|
+
defaults = {
|
|
164
|
+
"json": False,
|
|
165
|
+
"list_name": "Tasks",
|
|
166
|
+
"no_steps": False,
|
|
167
|
+
"date_format": "eu",
|
|
168
|
+
"due_today": False,
|
|
169
|
+
"overdue": False,
|
|
170
|
+
"important": False,
|
|
171
|
+
"list": None,
|
|
172
|
+
"task_names": None,
|
|
173
|
+
"yes": False,
|
|
174
|
+
"reminder": None,
|
|
175
|
+
"due": None,
|
|
176
|
+
"recurrence": None,
|
|
177
|
+
"title": None,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for key, value in defaults.items():
|
|
181
|
+
setattr(args, key, value)
|
|
182
|
+
|
|
183
|
+
for key, value in kwargs.items():
|
|
184
|
+
setattr(args, key, value)
|
|
185
|
+
|
|
186
|
+
return args
|
tests/test_wrapper.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Unit tests for graph API wrapper module"""
|
|
3
|
+
|
|
4
|
+
import unittest
|
|
5
|
+
from unittest.mock import patch, MagicMock
|
|
6
|
+
import json
|
|
7
|
+
from todocli.graphapi.wrapper import (
|
|
8
|
+
ListNotFound,
|
|
9
|
+
TaskNotFoundByName,
|
|
10
|
+
TaskNotFoundByIndex,
|
|
11
|
+
StepNotFoundByIndex,
|
|
12
|
+
StepNotFoundByName,
|
|
13
|
+
BASE_URL,
|
|
14
|
+
BATCH_URL,
|
|
15
|
+
BATCH_MAX_REQUESTS,
|
|
16
|
+
get_task_id_by_name,
|
|
17
|
+
get_step_id,
|
|
18
|
+
get_checklist_items_batch,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestWrapperExceptions(unittest.TestCase):
|
|
23
|
+
"""Test custom exception classes"""
|
|
24
|
+
|
|
25
|
+
def test_list_not_found_exception(self):
|
|
26
|
+
"""Test ListNotFound exception message"""
|
|
27
|
+
list_name = "NonExistentList"
|
|
28
|
+
exc = ListNotFound(list_name)
|
|
29
|
+
|
|
30
|
+
self.assertIn(list_name, exc.message)
|
|
31
|
+
self.assertIn("could not be found", exc.message)
|
|
32
|
+
|
|
33
|
+
def test_task_not_found_by_name_exception(self):
|
|
34
|
+
"""Test TaskNotFoundByName exception message"""
|
|
35
|
+
task_name = "NonExistentTask"
|
|
36
|
+
list_name = "Personal"
|
|
37
|
+
exc = TaskNotFoundByName(task_name, list_name)
|
|
38
|
+
|
|
39
|
+
self.assertIn(task_name, exc.message)
|
|
40
|
+
self.assertIn(list_name, exc.message)
|
|
41
|
+
self.assertIn("could not be found", exc.message)
|
|
42
|
+
|
|
43
|
+
def test_task_not_found_by_index_exception(self):
|
|
44
|
+
"""Test TaskNotFoundByIndex exception message"""
|
|
45
|
+
task_index = 999
|
|
46
|
+
list_name = "Personal"
|
|
47
|
+
exc = TaskNotFoundByIndex(task_index, list_name)
|
|
48
|
+
|
|
49
|
+
self.assertIn(str(task_index), exc.message)
|
|
50
|
+
self.assertIn(list_name, exc.message)
|
|
51
|
+
self.assertIn("could not be found", exc.message)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestWrapperConstants(unittest.TestCase):
|
|
55
|
+
"""Test API endpoint URL constants"""
|
|
56
|
+
|
|
57
|
+
def test_base_url_format(self):
|
|
58
|
+
"""Test BASE_URL is correctly formatted"""
|
|
59
|
+
self.assertTrue(BASE_URL.startswith("https://graph.microsoft.com"))
|
|
60
|
+
self.assertIn("/me/todo/lists", BASE_URL)
|
|
61
|
+
|
|
62
|
+
def test_batch_url_format(self):
|
|
63
|
+
"""Test BATCH_URL is correctly formatted"""
|
|
64
|
+
self.assertTrue(BATCH_URL.startswith("https://graph.microsoft.com"))
|
|
65
|
+
self.assertIn("/$batch", BATCH_URL)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestGetTaskIdByName(unittest.TestCase):
|
|
69
|
+
"""Test get_task_id_by_name with int index and invalid types"""
|
|
70
|
+
|
|
71
|
+
@patch("todocli.graphapi.wrapper.get_tasks")
|
|
72
|
+
@patch("todocli.graphapi.wrapper.get_list_id_by_name")
|
|
73
|
+
def test_get_task_id_by_name_with_int_index(self, mock_get_list_id, mock_get_tasks):
|
|
74
|
+
mock_get_list_id.return_value = "list-id-123"
|
|
75
|
+
task0 = MagicMock()
|
|
76
|
+
task0.id = "task-id-0"
|
|
77
|
+
task1 = MagicMock()
|
|
78
|
+
task1.id = "task-id-1"
|
|
79
|
+
mock_get_tasks.return_value = [task0, task1]
|
|
80
|
+
|
|
81
|
+
result = get_task_id_by_name("Tasks", 1)
|
|
82
|
+
self.assertEqual(result, "task-id-1")
|
|
83
|
+
mock_get_tasks.assert_called_once_with(list_name="Tasks")
|
|
84
|
+
|
|
85
|
+
@patch("todocli.graphapi.wrapper.get_tasks")
|
|
86
|
+
@patch("todocli.graphapi.wrapper.get_list_id_by_name")
|
|
87
|
+
def test_get_task_id_by_name_with_invalid_index(
|
|
88
|
+
self, mock_get_list_id, mock_get_tasks
|
|
89
|
+
):
|
|
90
|
+
mock_get_list_id.return_value = "list-id-123"
|
|
91
|
+
mock_get_tasks.return_value = []
|
|
92
|
+
|
|
93
|
+
with self.assertRaises(TaskNotFoundByIndex):
|
|
94
|
+
get_task_id_by_name("Tasks", 5)
|
|
95
|
+
|
|
96
|
+
def test_get_task_id_by_name_with_invalid_type(self):
|
|
97
|
+
with self.assertRaises(TypeError):
|
|
98
|
+
get_task_id_by_name("Tasks", 3.14)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TestGetStepId(unittest.TestCase):
|
|
102
|
+
"""Test get_step_id with invalid types"""
|
|
103
|
+
|
|
104
|
+
@patch("todocli.graphapi.wrapper.get_checklist_items")
|
|
105
|
+
def test_get_step_id_with_invalid_type(self, mock_get_items):
|
|
106
|
+
with self.assertRaises(TypeError):
|
|
107
|
+
get_step_id("Tasks", "my task", 3.14, list_id="lid", task_id="tid")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TestGetChecklistItemsBatch(unittest.TestCase):
|
|
111
|
+
"""Test get_checklist_items_batch using $batch API"""
|
|
112
|
+
|
|
113
|
+
@patch("todocli.graphapi.wrapper.get_oauth_session")
|
|
114
|
+
def test_batch_single_task(self, mock_session):
|
|
115
|
+
batch_response = {
|
|
116
|
+
"responses": [
|
|
117
|
+
{
|
|
118
|
+
"id": "tid-1",
|
|
119
|
+
"status": 200,
|
|
120
|
+
"body": {
|
|
121
|
+
"value": [
|
|
122
|
+
{
|
|
123
|
+
"id": "step-1",
|
|
124
|
+
"displayName": "Buy eggs",
|
|
125
|
+
"isChecked": False,
|
|
126
|
+
"checkedDateTime": None,
|
|
127
|
+
"createdDateTime": "2026-01-01T00:00:00.0000000Z",
|
|
128
|
+
}
|
|
129
|
+
]
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
]
|
|
133
|
+
}
|
|
134
|
+
mock_resp = MagicMock()
|
|
135
|
+
mock_resp.ok = True
|
|
136
|
+
mock_resp.content = json.dumps(batch_response).encode()
|
|
137
|
+
mock_session.return_value.post.return_value = mock_resp
|
|
138
|
+
|
|
139
|
+
result = get_checklist_items_batch("lid-1", ["tid-1"])
|
|
140
|
+
self.assertIn("tid-1", result)
|
|
141
|
+
self.assertEqual(len(result["tid-1"]), 1)
|
|
142
|
+
self.assertEqual(result["tid-1"][0].display_name, "Buy eggs")
|
|
143
|
+
|
|
144
|
+
# Verify batch request format
|
|
145
|
+
call_args = mock_session.return_value.post.call_args
|
|
146
|
+
self.assertEqual(call_args.args[0], BATCH_URL)
|
|
147
|
+
req_body = call_args.kwargs["json"]
|
|
148
|
+
self.assertEqual(len(req_body["requests"]), 1)
|
|
149
|
+
self.assertEqual(req_body["requests"][0]["method"], "GET")
|
|
150
|
+
|
|
151
|
+
def test_batch_empty_tasks(self):
|
|
152
|
+
result = get_checklist_items_batch("lid-1", [])
|
|
153
|
+
self.assertEqual(result, {})
|
|
154
|
+
|
|
155
|
+
@patch("todocli.graphapi.wrapper.get_oauth_session")
|
|
156
|
+
def test_batch_chunking(self, mock_session):
|
|
157
|
+
task_ids = [f"tid-{i}" for i in range(25)]
|
|
158
|
+
|
|
159
|
+
def make_batch_response(chunk_ids):
|
|
160
|
+
return {
|
|
161
|
+
"responses": [
|
|
162
|
+
{"id": tid, "status": 200, "body": {"value": []}}
|
|
163
|
+
for tid in chunk_ids
|
|
164
|
+
]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
call_count = [0]
|
|
168
|
+
|
|
169
|
+
def mock_post(url, json=None):
|
|
170
|
+
resp = MagicMock()
|
|
171
|
+
resp.ok = True
|
|
172
|
+
chunk_ids = [r["id"] for r in json["requests"]]
|
|
173
|
+
resp.content = (
|
|
174
|
+
__import__("json").dumps(make_batch_response(chunk_ids)).encode()
|
|
175
|
+
)
|
|
176
|
+
call_count[0] += 1
|
|
177
|
+
return resp
|
|
178
|
+
|
|
179
|
+
mock_session.return_value.post.side_effect = mock_post
|
|
180
|
+
|
|
181
|
+
result = get_checklist_items_batch("lid-1", task_ids)
|
|
182
|
+
|
|
183
|
+
# Should have made 2 calls: 20 + 5
|
|
184
|
+
self.assertEqual(call_count[0], 2)
|
|
185
|
+
self.assertEqual(len(result), 25)
|
|
186
|
+
for tid in task_ids:
|
|
187
|
+
self.assertIn(tid, result)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
unittest.main()
|
todocli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|