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,660 @@
1
+ """
2
+ For implementation details, refer to this source:
3
+ https://docs.microsoft.com/en-us/graph/api/resources/todo-overview?view=graph-rest-1.0
4
+ """
5
+
6
+ import json
7
+ from datetime import datetime
8
+ from typing import Union
9
+
10
+ from todocli.models.todolist import TodoList
11
+ from todocli.models.todotask import Task, TaskImportance, TaskStatus
12
+ from todocli.models.checklistitem import ChecklistItem
13
+ from todocli.graphapi.oauth import get_oauth_session
14
+
15
+ from todocli.utils.datetime_util import datetime_to_api_timestamp
16
+
17
+ BASE_API = "https://graph.microsoft.com/v1.0"
18
+ BASE_RELATE_URL = "/me/todo/lists"
19
+ BASE_URL = f"{BASE_API}{BASE_RELATE_URL}"
20
+ BATCH_URL = f"{BASE_API}/$batch"
21
+
22
+
23
+ def _require_list(list_name, list_id):
24
+ """Validate that list_name or list_id is provided."""
25
+ if list_name is None and list_id is None:
26
+ raise ValueError("You must provide list_name or list_id")
27
+
28
+
29
+ def _require_task(task_name, task_id):
30
+ """Validate that task_name or task_id is provided."""
31
+ if task_name is None and task_id is None:
32
+ raise ValueError("You must provide task_name or task_id")
33
+
34
+
35
+ def _require_step(step_name):
36
+ """Validate that step_name is provided."""
37
+ if step_name is None:
38
+ raise ValueError("You must provide step_name")
39
+
40
+
41
+ class ListNotFound(Exception):
42
+ def __init__(self, list_name):
43
+ self.message = "List with name '{}' could not be found".format(list_name)
44
+ super(ListNotFound, self).__init__(self.message)
45
+
46
+
47
+ class TaskNotFoundByName(Exception):
48
+ def __init__(self, task_name, list_name):
49
+ self.message = "Task with name '{}' could not be found in list '{}'".format(
50
+ task_name, list_name
51
+ )
52
+ super(TaskNotFoundByName, self).__init__(self.message)
53
+
54
+
55
+ class TaskNotFoundByIndex(Exception):
56
+ def __init__(self, task_index, list_name):
57
+ self.message = "Task with index '{}' could not be found in list '{}'".format(
58
+ task_index, list_name
59
+ )
60
+ super(TaskNotFoundByIndex, self).__init__(self.message)
61
+
62
+
63
+ class StepNotFoundByName(Exception):
64
+ def __init__(self, step_name, task_name):
65
+ self.message = "Step with name '{}' could not be found in task '{}'".format(
66
+ step_name, task_name
67
+ )
68
+ super(StepNotFoundByName, self).__init__(self.message)
69
+
70
+
71
+ class StepNotFoundByIndex(Exception):
72
+ def __init__(self, step_index, task_name):
73
+ self.message = "Step with index '{}' could not be found in task '{}'".format(
74
+ step_index, task_name
75
+ )
76
+ super(StepNotFoundByIndex, self).__init__(self.message)
77
+
78
+
79
+ def parse_response(response):
80
+ return json.loads(response.content.decode())["value"]
81
+
82
+
83
+ def get_lists():
84
+ session = get_oauth_session()
85
+ response = session.get(BASE_URL)
86
+ response_value = parse_response(response)
87
+ return [TodoList(x) for x in response_value]
88
+
89
+
90
+ def create_list(title: str):
91
+ """Create a new list. Returns (list_id, list_name)."""
92
+ request_body = {"displayName": title}
93
+ session = get_oauth_session()
94
+ response = session.post(BASE_URL, json=request_body)
95
+ if response.ok:
96
+ data = json.loads(response.content.decode())
97
+ return data.get("id", ""), data.get("displayName", "")
98
+ response.raise_for_status()
99
+
100
+
101
+ def rename_list(old_title: str, new_title: str):
102
+ """Rename a list. Returns (list_id, new_title)."""
103
+ list_id = get_list_id_by_name(old_title)
104
+ request_body = {"displayName": new_title}
105
+ session = get_oauth_session()
106
+ response = session.patch(f"{BASE_URL}/{list_id}", json=request_body)
107
+ if response.ok:
108
+ data = json.loads(response.content.decode())
109
+ return data.get("id", ""), data.get("displayName", "")
110
+ response.raise_for_status()
111
+
112
+
113
+ def delete_list(list_name: str = None, list_id: str = None):
114
+ """Delete a list. Returns list_id."""
115
+ _require_list(list_name, list_id)
116
+
117
+ if list_id is None:
118
+ list_id = get_list_id_by_name(list_name)
119
+
120
+ endpoint = f"{BASE_URL}/{list_id}"
121
+ session = get_oauth_session()
122
+ response = session.delete(endpoint)
123
+ if response.ok:
124
+ return list_id
125
+ response.raise_for_status()
126
+
127
+
128
+ def get_tasks(
129
+ list_name: str = None,
130
+ list_id: str = None,
131
+ num_tasks: int = 100,
132
+ include_completed: bool = False,
133
+ only_completed: bool = False,
134
+ ):
135
+ """Fetch tasks from a list.
136
+
137
+ Args:
138
+ list_name: Name of the list
139
+ list_id: ID of the list (alternative to list_name)
140
+ num_tasks: Maximum number of tasks to return
141
+ include_completed: If True, include completed tasks
142
+ only_completed: If True, return only completed tasks
143
+ """
144
+ _require_list(list_name, list_id)
145
+
146
+ # For compatibility with cli
147
+ if list_id is None:
148
+ list_id = get_list_id_by_name(list_name)
149
+
150
+ if only_completed:
151
+ endpoint = (
152
+ f"{BASE_URL}/{list_id}/tasks?$filter=status eq 'completed'&$top={num_tasks}"
153
+ )
154
+ elif include_completed:
155
+ endpoint = f"{BASE_URL}/{list_id}/tasks?$top={num_tasks}"
156
+ else:
157
+ endpoint = (
158
+ f"{BASE_URL}/{list_id}/tasks?$filter=status ne 'completed'&$top={num_tasks}"
159
+ )
160
+
161
+ session = get_oauth_session()
162
+ response = session.get(endpoint)
163
+ response_value = parse_response(response)
164
+ return [Task(x) for x in response_value]
165
+
166
+
167
+ def create_task(
168
+ task_name: str,
169
+ list_name: str | None = None,
170
+ list_id: str | None = None,
171
+ reminder_datetime: datetime | None = None,
172
+ due_datetime: datetime | None = None,
173
+ important: bool = False,
174
+ recurrence: dict | None = None,
175
+ ):
176
+ _require_list(list_name, list_id)
177
+
178
+ # For compatibility with cli
179
+ if list_id is None:
180
+ list_id = get_list_id_by_name(list_name)
181
+
182
+ # The Graph API requires dueDateTime when recurrence is set
183
+ if due_datetime is None and recurrence is not None:
184
+ due_datetime = datetime.now()
185
+
186
+ endpoint = f"{BASE_URL}/{list_id}/tasks"
187
+ request_body = {
188
+ "title": task_name,
189
+ "reminderDateTime": datetime_to_api_timestamp(reminder_datetime),
190
+ "dueDateTime": datetime_to_api_timestamp(due_datetime),
191
+ "importance": TaskImportance.HIGH if important else TaskImportance.NORMAL,
192
+ "recurrence": recurrence,
193
+ }
194
+ session = get_oauth_session()
195
+ response = session.post(endpoint, json=request_body)
196
+ if response.ok:
197
+ return json.loads(response.content.decode())["id"]
198
+ else:
199
+ response.raise_for_status()
200
+
201
+
202
+ def complete_task(
203
+ list_name: str = None,
204
+ task_name: Union[str, int] = None,
205
+ list_id: str = None,
206
+ task_id: str = None,
207
+ ):
208
+ """Mark a task as completed. Returns (task_id, task_title)."""
209
+ _require_list(list_name, list_id)
210
+ _require_task(task_name, task_id)
211
+
212
+ # For compatibility with cli
213
+ if list_id is None:
214
+ list_id = get_list_id_by_name(list_name)
215
+ if task_id is None:
216
+ task_id = get_task_id_by_name(list_name, task_name)
217
+
218
+ endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}"
219
+ request_body = {
220
+ "status": TaskStatus.COMPLETED,
221
+ "completedDateTime": datetime_to_api_timestamp(datetime.now()),
222
+ }
223
+ session = get_oauth_session()
224
+ response = session.patch(endpoint, json=request_body)
225
+ if response.ok:
226
+ data = json.loads(response.content.decode())
227
+ return task_id, data.get("title", "")
228
+ response.raise_for_status()
229
+
230
+
231
+ def uncomplete_task(
232
+ list_name: str = None,
233
+ task_name: Union[str, int] = None,
234
+ list_id: str = None,
235
+ task_id: str = None,
236
+ ):
237
+ """Mark a completed task as not completed. Returns (task_id, task_title)."""
238
+ _require_list(list_name, list_id)
239
+ _require_task(task_name, task_id)
240
+
241
+ if list_id is None:
242
+ list_id = get_list_id_by_name(list_name)
243
+ if task_id is None:
244
+ task_id = get_task_id_by_name(list_name, task_name)
245
+
246
+ endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}"
247
+ request_body = {
248
+ "status": TaskStatus.NOT_STARTED,
249
+ "completedDateTime": None,
250
+ }
251
+ session = get_oauth_session()
252
+ response = session.patch(endpoint, json=request_body)
253
+ if response.ok:
254
+ data = json.loads(response.content.decode())
255
+ return task_id, data.get("title", "")
256
+ response.raise_for_status()
257
+
258
+
259
+ def complete_tasks(list_id, task_ids=None):
260
+ if task_ids is None:
261
+ task_ids = []
262
+ body = {"requests": []}
263
+ for task_id in task_ids:
264
+ body["requests"].append(
265
+ {
266
+ "id": task_id,
267
+ "method": "PATCH",
268
+ "url": f"{BASE_RELATE_URL}/{list_id}/tasks/{task_id}",
269
+ "headers": {"Content-Type": "application/json"},
270
+ "body": {
271
+ "status": TaskStatus.COMPLETED,
272
+ "completedDateTime": datetime_to_api_timestamp(datetime.now()),
273
+ },
274
+ }
275
+ )
276
+ session = get_oauth_session()
277
+ response = session.post(BATCH_URL, json=body)
278
+ return True if response.ok else response.raise_for_status()
279
+
280
+
281
+ def remove_task(
282
+ list_name: str = None,
283
+ task_name: Union[str, int] = None,
284
+ list_id: str = None,
285
+ task_id: str = None,
286
+ ):
287
+ """Delete a task. Returns (task_id, task_title)."""
288
+ _require_list(list_name, list_id)
289
+ _require_task(task_name, task_id)
290
+
291
+ if list_id is None:
292
+ list_id = get_list_id_by_name(list_name)
293
+ if task_id is None:
294
+ task_id = get_task_id_by_name(list_name, task_name)
295
+
296
+ # Fetch task title before deletion
297
+ task = get_task(list_id=list_id, task_id=task_id)
298
+ task_title = task.title
299
+
300
+ endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}"
301
+ session = get_oauth_session()
302
+ response = session.delete(endpoint)
303
+ if response.ok:
304
+ return task_id, task_title
305
+ response.raise_for_status()
306
+
307
+
308
+ def update_task(
309
+ list_name: str = None,
310
+ task_name: Union[str, int] = None,
311
+ list_id: str = None,
312
+ task_id: str = None,
313
+ title: str | None = None,
314
+ due_datetime: datetime | None = None,
315
+ reminder_datetime: datetime | None = None,
316
+ important: bool | None = None,
317
+ recurrence: dict | None = None,
318
+ clear_due: bool = False,
319
+ clear_reminder: bool = False,
320
+ clear_recurrence: bool = False,
321
+ ):
322
+ """Update a task. Returns (task_id, task_title)."""
323
+ _require_list(list_name, list_id)
324
+ _require_task(task_name, task_id)
325
+
326
+ if list_id is None:
327
+ list_id = get_list_id_by_name(list_name)
328
+ if task_id is None:
329
+ task_id = get_task_id_by_name(list_name, task_name)
330
+
331
+ request_body = {}
332
+ if title is not None:
333
+ request_body["title"] = title
334
+ if clear_due:
335
+ request_body["dueDateTime"] = None
336
+ elif due_datetime is not None:
337
+ request_body["dueDateTime"] = datetime_to_api_timestamp(due_datetime)
338
+ if clear_reminder:
339
+ request_body["reminderDateTime"] = None
340
+ request_body["isReminderOn"] = False
341
+ elif reminder_datetime is not None:
342
+ request_body["reminderDateTime"] = datetime_to_api_timestamp(reminder_datetime)
343
+ if important is not None:
344
+ request_body["importance"] = (
345
+ TaskImportance.HIGH if important else TaskImportance.NORMAL
346
+ )
347
+ if clear_recurrence:
348
+ request_body["recurrence"] = None
349
+ elif recurrence is not None:
350
+ request_body["recurrence"] = recurrence
351
+
352
+ if not request_body:
353
+ raise ValueError("No fields to update")
354
+
355
+ endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}"
356
+ session = get_oauth_session()
357
+ response = session.patch(endpoint, json=request_body)
358
+ if response.ok:
359
+ data = json.loads(response.content.decode())
360
+ return task_id, data.get("title", "")
361
+ response.raise_for_status()
362
+
363
+
364
+ def get_list_id_by_name(list_name: str) -> str:
365
+ """Get list ID by exact name match."""
366
+ escaped_name = _escape_odata_string(list_name)
367
+ endpoint = f"{BASE_URL}?$filter=displayName eq '{escaped_name}'"
368
+ session = get_oauth_session()
369
+ response = session.get(endpoint)
370
+ response_value = parse_response(response)
371
+ try:
372
+ return response_value[0]["id"]
373
+ except IndexError:
374
+ raise ListNotFound(list_name)
375
+
376
+
377
+ def _escape_odata_string(value: str) -> str:
378
+ """Escape a string for use in an OData $filter expression embedded in a URL.
379
+
380
+ Single quotes are doubled per OData spec.
381
+
382
+ Certain characters have special meaning in URLs and are not
383
+ percent-encoded by the HTTP client when they appear in a
384
+ raw query string:
385
+ '#' — fragment identifier (truncates the URL)
386
+ '&' — query-parameter separator
387
+ '+' — interpreted as space in query strings
388
+
389
+ These must be double-percent-encoded so that the first decode
390
+ pass (HTTP/transport layer) yields the standard percent-encoded
391
+ form, and the second pass (OData parser) yields the literal
392
+ character. E.g. '#' → '%2523' → '%23' → '#'.
393
+
394
+ Reference: https://learn.microsoft.com/en-us/answers/questions/
395
+ 432875/how-do-you-escape-the-octothorpe-number-pound-hashtag-
396
+ symbol-in-a-graph-api-odata-search-string
397
+ """
398
+ return (
399
+ value.replace("'", "''")
400
+ .replace("#", "%2523")
401
+ .replace("&", "%2526")
402
+ .replace("+", "%252B")
403
+ )
404
+
405
+
406
+ def get_task_id_by_name(list_name: str, task_name: str):
407
+ if isinstance(task_name, str):
408
+ try:
409
+ list_id = get_list_id_by_name(list_name)
410
+ escaped_name = _escape_odata_string(task_name)
411
+ endpoint = f"{BASE_URL}/{list_id}/tasks?$filter=title eq '{escaped_name}'"
412
+ session = get_oauth_session()
413
+ response = session.get(endpoint)
414
+ response_value = parse_response(response)
415
+ return [Task(x) for x in response_value][0].id
416
+ except IndexError:
417
+ raise TaskNotFoundByName(task_name, list_name)
418
+ elif isinstance(task_name, int):
419
+ tasks = get_tasks(list_name=list_name)
420
+ try:
421
+ return tasks[task_name].id
422
+ except IndexError:
423
+ raise TaskNotFoundByIndex(task_name, list_name)
424
+ else:
425
+ raise TypeError(f"task_name must be str or int, got {type(task_name).__name__}")
426
+
427
+
428
+ def get_task(
429
+ list_name: str = None,
430
+ task_name: Union[str, int] = None,
431
+ list_id: str = None,
432
+ task_id: str = None,
433
+ ):
434
+ """Fetch a single task with all details."""
435
+ _require_list(list_name, list_id)
436
+ _require_task(task_name, task_id)
437
+
438
+ if list_id is None:
439
+ list_id = get_list_id_by_name(list_name)
440
+ if task_id is None:
441
+ task_id = get_task_id_by_name(list_name, task_name)
442
+
443
+ endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}"
444
+ session = get_oauth_session()
445
+ response = session.get(endpoint)
446
+ if response.ok:
447
+ return Task(json.loads(response.content.decode()))
448
+ response.raise_for_status()
449
+
450
+
451
+ def get_checklist_items(
452
+ list_name: str = None,
453
+ task_name: Union[str, int] = None,
454
+ list_id: str = None,
455
+ task_id: str = None,
456
+ ):
457
+ _require_list(list_name, list_id)
458
+ _require_task(task_name, task_id)
459
+
460
+ if list_id is None:
461
+ list_id = get_list_id_by_name(list_name)
462
+ if task_id is None:
463
+ task_id = get_task_id_by_name(list_name, task_name)
464
+
465
+ endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}/checklistItems"
466
+ session = get_oauth_session()
467
+ response = session.get(endpoint)
468
+ response_value = parse_response(response)
469
+ return [ChecklistItem(x) for x in response_value]
470
+
471
+
472
+ BATCH_MAX_REQUESTS = 20
473
+
474
+
475
+ def get_checklist_items_batch(list_id: str, task_ids: list[str]):
476
+ """Fetch checklist items for multiple tasks using $batch API.
477
+
478
+ Returns dict mapping task_id -> list[ChecklistItem].
479
+ """
480
+ if not task_ids:
481
+ return {}
482
+
483
+ result = {}
484
+ session = get_oauth_session()
485
+
486
+ # Chunk into groups of BATCH_MAX_REQUESTS
487
+ for i in range(0, len(task_ids), BATCH_MAX_REQUESTS):
488
+ chunk = task_ids[i : i + BATCH_MAX_REQUESTS]
489
+ body = {
490
+ "requests": [
491
+ {
492
+ "id": task_id,
493
+ "method": "GET",
494
+ "url": f"{BASE_RELATE_URL}/{list_id}/tasks/{task_id}/checklistItems",
495
+ }
496
+ for task_id in chunk
497
+ ]
498
+ }
499
+ response = session.post(BATCH_URL, json=body)
500
+ if not response.ok:
501
+ response.raise_for_status()
502
+
503
+ batch_response = json.loads(response.content.decode())
504
+ for resp in batch_response.get("responses", []):
505
+ tid = resp["id"]
506
+ if resp.get("status") == 200:
507
+ items = resp.get("body", {}).get("value", [])
508
+ result[tid] = [ChecklistItem(x) for x in items]
509
+ else:
510
+ result[tid] = []
511
+
512
+ return result
513
+
514
+
515
+ def create_checklist_item(
516
+ step_name: str,
517
+ list_name: str = None,
518
+ task_name: Union[str, int] = None,
519
+ list_id: str = None,
520
+ task_id: str = None,
521
+ ):
522
+ _require_list(list_name, list_id)
523
+ _require_task(task_name, task_id)
524
+
525
+ if list_id is None:
526
+ list_id = get_list_id_by_name(list_name)
527
+ if task_id is None:
528
+ task_id = get_task_id_by_name(list_name, task_name)
529
+
530
+ endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}/checklistItems"
531
+ request_body = {"displayName": step_name}
532
+ session = get_oauth_session()
533
+ response = session.post(endpoint, json=request_body)
534
+ if response.ok:
535
+ data = json.loads(response.content.decode())
536
+ return data.get("id", ""), data.get("displayName", "")
537
+ response.raise_for_status()
538
+
539
+
540
+ def complete_checklist_item(
541
+ list_name: str = None,
542
+ task_name: Union[str, int] = None,
543
+ step_name: Union[str, int] = None,
544
+ list_id: str = None,
545
+ task_id: str = None,
546
+ step_id: str = None,
547
+ ):
548
+ _require_list(list_name, list_id)
549
+ _require_task(task_name, task_id)
550
+ if step_id is None:
551
+ _require_step(step_name)
552
+
553
+ if list_id is None:
554
+ list_id = get_list_id_by_name(list_name)
555
+ if task_id is None:
556
+ task_id = get_task_id_by_name(list_name, task_name)
557
+ if step_id is None:
558
+ step_id = get_step_id(
559
+ list_name, task_name, step_name, list_id=list_id, task_id=task_id
560
+ )
561
+
562
+ endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}/checklistItems/{step_id}"
563
+ request_body = {"isChecked": True}
564
+ session = get_oauth_session()
565
+ response = session.patch(endpoint, json=request_body)
566
+ if response.ok:
567
+ data = json.loads(response.content.decode())
568
+ return step_id, data.get("displayName", "")
569
+ response.raise_for_status()
570
+
571
+
572
+ def uncomplete_checklist_item(
573
+ list_name: str = None,
574
+ task_name: Union[str, int] = None,
575
+ step_name: Union[str, int] = None,
576
+ list_id: str = None,
577
+ task_id: str = None,
578
+ step_id: str = None,
579
+ ):
580
+ """Mark a checked step as unchecked."""
581
+ _require_list(list_name, list_id)
582
+ _require_task(task_name, task_id)
583
+ if step_id is None:
584
+ _require_step(step_name)
585
+
586
+ if list_id is None:
587
+ list_id = get_list_id_by_name(list_name)
588
+ if task_id is None:
589
+ task_id = get_task_id_by_name(list_name, task_name)
590
+ if step_id is None:
591
+ step_id = get_step_id(
592
+ list_name, task_name, step_name, list_id=list_id, task_id=task_id
593
+ )
594
+
595
+ endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}/checklistItems/{step_id}"
596
+ request_body = {"isChecked": False}
597
+ session = get_oauth_session()
598
+ response = session.patch(endpoint, json=request_body)
599
+ if response.ok:
600
+ data = json.loads(response.content.decode())
601
+ return step_id, data.get("displayName", "")
602
+ response.raise_for_status()
603
+
604
+
605
+ def delete_checklist_item(
606
+ list_name: str = None,
607
+ task_name: Union[str, int] = None,
608
+ step_name: Union[str, int] = None,
609
+ list_id: str = None,
610
+ task_id: str = None,
611
+ step_id: str = None,
612
+ ):
613
+ _require_list(list_name, list_id)
614
+ _require_task(task_name, task_id)
615
+ if step_id is None:
616
+ _require_step(step_name)
617
+
618
+ if list_id is None:
619
+ list_id = get_list_id_by_name(list_name)
620
+ if task_id is None:
621
+ task_id = get_task_id_by_name(list_name, task_name)
622
+ if step_id is None:
623
+ step_id = get_step_id(
624
+ list_name, task_name, step_name, list_id=list_id, task_id=task_id
625
+ )
626
+
627
+ endpoint = f"{BASE_URL}/{list_id}/tasks/{task_id}/checklistItems/{step_id}"
628
+ session = get_oauth_session()
629
+ response = session.delete(endpoint)
630
+ if response.ok:
631
+ return step_id
632
+ response.raise_for_status()
633
+
634
+
635
+ def get_step_id(
636
+ list_name: str,
637
+ task_name: Union[str, int],
638
+ step_name: Union[str, int],
639
+ list_id: str = None,
640
+ task_id: str = None,
641
+ ):
642
+ if list_id is None:
643
+ list_id = get_list_id_by_name(list_name)
644
+ if task_id is None:
645
+ task_id = get_task_id_by_name(list_name, task_name)
646
+
647
+ items = get_checklist_items(list_id=list_id, task_id=task_id)
648
+
649
+ if isinstance(step_name, int):
650
+ try:
651
+ return items[step_name].id
652
+ except IndexError:
653
+ raise StepNotFoundByIndex(step_name, task_name)
654
+ elif isinstance(step_name, str):
655
+ for item in items:
656
+ if item.display_name == step_name:
657
+ return item.id
658
+ raise StepNotFoundByName(step_name, task_name)
659
+ else:
660
+ raise TypeError(f"step_name must be str or int, got {type(step_name).__name__}")
File without changes