taskcheck 1.3.1__tar.gz → 1.4.1__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 (27) hide show
  1. {taskcheck-1.3.1/taskcheck.egg-info → taskcheck-1.4.1}/PKG-INFO +7 -4
  2. {taskcheck-1.3.1 → taskcheck-1.4.1}/README.md +6 -3
  3. {taskcheck-1.3.1 → taskcheck-1.4.1}/pyproject.toml +1 -1
  4. {taskcheck-1.3.1 → taskcheck-1.4.1}/taskcheck/__main__.py +11 -0
  5. {taskcheck-1.3.1 → taskcheck-1.4.1}/taskcheck/install.py +3 -0
  6. {taskcheck-1.3.1 → taskcheck-1.4.1}/taskcheck/parallel.py +132 -45
  7. {taskcheck-1.3.1 → taskcheck-1.4.1}/taskcheck/report.py +1 -1
  8. taskcheck-1.4.1/taskcheck/undo.py +84 -0
  9. {taskcheck-1.3.1 → taskcheck-1.4.1/taskcheck.egg-info}/PKG-INFO +7 -4
  10. {taskcheck-1.3.1 → taskcheck-1.4.1}/taskcheck.egg-info/SOURCES.txt +3 -1
  11. {taskcheck-1.3.1 → taskcheck-1.4.1}/tests/test_install.py +2 -1
  12. {taskcheck-1.3.1 → taskcheck-1.4.1}/tests/test_main.py +18 -0
  13. {taskcheck-1.3.1 → taskcheck-1.4.1}/tests/test_parallel.py +112 -26
  14. {taskcheck-1.3.1 → taskcheck-1.4.1}/tests/test_report.py +11 -0
  15. taskcheck-1.4.1/tests/test_undo.py +130 -0
  16. {taskcheck-1.3.1 → taskcheck-1.4.1}/LICENSE +0 -0
  17. {taskcheck-1.3.1 → taskcheck-1.4.1}/setup.cfg +0 -0
  18. {taskcheck-1.3.1 → taskcheck-1.4.1}/taskcheck/common.py +0 -0
  19. {taskcheck-1.3.1 → taskcheck-1.4.1}/taskcheck/google_calendar.py +0 -0
  20. {taskcheck-1.3.1 → taskcheck-1.4.1}/taskcheck/ical.py +0 -0
  21. {taskcheck-1.3.1 → taskcheck-1.4.1}/taskcheck.egg-info/dependency_links.txt +0 -0
  22. {taskcheck-1.3.1 → taskcheck-1.4.1}/taskcheck.egg-info/entry_points.txt +0 -0
  23. {taskcheck-1.3.1 → taskcheck-1.4.1}/taskcheck.egg-info/requires.txt +0 -0
  24. {taskcheck-1.3.1 → taskcheck-1.4.1}/taskcheck.egg-info/top_level.txt +0 -0
  25. {taskcheck-1.3.1 → taskcheck-1.4.1}/tests/test_common.py +0 -0
  26. {taskcheck-1.3.1 → taskcheck-1.4.1}/tests/test_google_calendar.py +0 -0
  27. {taskcheck-1.3.1 → taskcheck-1.4.1}/tests/test_ical.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taskcheck
3
- Version: 1.3.1
3
+ Version: 1.4.1
4
4
  Summary: Automatic scheduler for taskwarrior
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -33,6 +33,7 @@ Use it if you want to:
33
33
  - 🧭 Support complex working-hour maps
34
34
  - ⏱️ Consider urgency, due dates, and dependencies
35
35
  - 🧪 Dry-run mode to preview changes
36
+ - ↩️ Undo the latest schedule update
36
37
  - 🧠 Auto-adjust urgency when deadlines cannot be met
37
38
  - 🔄 Force-refresh iCal calendars
38
39
  - 📊 Custom reports for planned and unplanned tasks
@@ -46,6 +47,7 @@ Use it if you want to:
46
47
  3. add `estimated` + `time_map` UDAs
47
48
  4. edit `~/.config/task/taskcheck.toml` (see [Reference](#Reference))
48
49
  5. `taskcheck --schedule`
50
+ 6. `taskcheck --undo` restores the scheduling fields changed by the latest run
49
51
 
50
52
  ## How it works
51
53
 
@@ -88,11 +90,10 @@ In short:
88
90
  - it installs required Taskwarrior settings if you confirm the first prompt
89
91
  - it installs optional urgency/report tuning if you confirm the second prompt
90
92
  - it can also create the default config file if you confirm the third prompt
91
- - it does **not** schedule tasks or fetch calendars
92
93
  - scheduling happens with `taskcheck --schedule`
93
94
  - all flags/config keys/settings are documented in `REFERENCE.md`
94
95
 
95
- ## Algorithm
96
+ ## They say it's an AI
96
97
 
97
98
  The algorithm simulates a workday one chunk at a time.
98
99
 
@@ -104,7 +105,9 @@ For `today`, taskcheck skips past hours.
104
105
 
105
106
  The default work chunk is 2 hours (or less if the task is shorter); you can tune it with the Taskwarrior UDA `min_block`.
106
107
 
107
- If a task would finish after its `due_date`, taskcheck retries. It keeps that task's current non-due urgency weight, then lowers the weight by 0.1 only for tasks that have not missed a deadline. Due urgency is never reduced. Retries stop when deadlines are respected or unflagged tasks reach a weight of 0.
108
+ Before writing a schedule, taskcheck backs up the affected tasks' `scheduled`, `completion_date`, and `scheduling` fields under the Taskwarrior data directory. `taskcheck --undo` restores and consumes the newest backup. The `[scheduler]` option `n_undos` controls retention and defaults to `1`; set it to `0` to disable backups.
109
+
110
+ If a task would finish after its due date, taskcheck retries with a total-urgency override. Each retry moves the task immediately above the task with the smallest greater urgency by adding `urgency_epsilon` (default: `0.1`). At rank 1, it adds epsilon for up to `max_top_urgency_rounds` retries (default: `10`), then tries once at urgency `1000`. If the task is still late, taskcheck stops raising it and optimizes the other overdue tasks.
108
111
 
109
112
  ## Tips and Tricks
110
113
 
@@ -18,6 +18,7 @@ Use it if you want to:
18
18
  - 🧭 Support complex working-hour maps
19
19
  - ⏱️ Consider urgency, due dates, and dependencies
20
20
  - 🧪 Dry-run mode to preview changes
21
+ - ↩️ Undo the latest schedule update
21
22
  - 🧠 Auto-adjust urgency when deadlines cannot be met
22
23
  - 🔄 Force-refresh iCal calendars
23
24
  - 📊 Custom reports for planned and unplanned tasks
@@ -31,6 +32,7 @@ Use it if you want to:
31
32
  3. add `estimated` + `time_map` UDAs
32
33
  4. edit `~/.config/task/taskcheck.toml` (see [Reference](#Reference))
33
34
  5. `taskcheck --schedule`
35
+ 6. `taskcheck --undo` restores the scheduling fields changed by the latest run
34
36
 
35
37
  ## How it works
36
38
 
@@ -73,11 +75,10 @@ In short:
73
75
  - it installs required Taskwarrior settings if you confirm the first prompt
74
76
  - it installs optional urgency/report tuning if you confirm the second prompt
75
77
  - it can also create the default config file if you confirm the third prompt
76
- - it does **not** schedule tasks or fetch calendars
77
78
  - scheduling happens with `taskcheck --schedule`
78
79
  - all flags/config keys/settings are documented in `REFERENCE.md`
79
80
 
80
- ## Algorithm
81
+ ## They say it's an AI
81
82
 
82
83
  The algorithm simulates a workday one chunk at a time.
83
84
 
@@ -89,7 +90,9 @@ For `today`, taskcheck skips past hours.
89
90
 
90
91
  The default work chunk is 2 hours (or less if the task is shorter); you can tune it with the Taskwarrior UDA `min_block`.
91
92
 
92
- If a task would finish after its `due_date`, taskcheck retries. It keeps that task's current non-due urgency weight, then lowers the weight by 0.1 only for tasks that have not missed a deadline. Due urgency is never reduced. Retries stop when deadlines are respected or unflagged tasks reach a weight of 0.
93
+ Before writing a schedule, taskcheck backs up the affected tasks' `scheduled`, `completion_date`, and `scheduling` fields under the Taskwarrior data directory. `taskcheck --undo` restores and consumes the newest backup. The `[scheduler]` option `n_undos` controls retention and defaults to `1`; set it to `0` to disable backups.
94
+
95
+ If a task would finish after its due date, taskcheck retries with a total-urgency override. Each retry moves the task immediately above the task with the smallest greater urgency by adding `urgency_epsilon` (default: `0.1`). At rank 1, it adds epsilon for up to `max_top_urgency_rounds` retries (default: `10`), then tries once at urgency `1000`. If the task is still late, taskcheck stops raising it and optimizes the other overdue tasks.
93
96
 
94
97
  ## Tips and Tricks
95
98
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "taskcheck"
3
- version = "1.3.1"
3
+ version = "1.4.1"
4
4
  description = "Automatic scheduler for taskwarrior"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -28,6 +28,11 @@ arg_parser.add_argument(
28
28
  action="store_true",
29
29
  help="Perform the scheduling algorithm, giving a schedule and a scheduling UDA and alerting for not completable tasks.",
30
30
  )
31
+ arg_parser.add_argument(
32
+ "--undo",
33
+ action="store_true",
34
+ help="Restore task scheduling fields from the most recent backup.",
35
+ )
31
36
  arg_parser.add_argument(
32
37
  "-f",
33
38
  "--force-update",
@@ -107,6 +112,12 @@ def main():
107
112
  install()
108
113
  return
109
114
 
115
+ if getattr(args, "undo", False) is True:
116
+ from taskcheck.undo import restore_latest_backup
117
+
118
+ restore_latest_backup(taskrc=args.taskrc)
119
+ return
120
+
110
121
  if getattr(args, "add_google_calendar", False) is True:
111
122
  from taskcheck.google_calendar import add_google_calendar
112
123
 
@@ -14,6 +14,9 @@ friday = [[9, 12.30], [14, 17]]
14
14
  [scheduler]
15
15
  days_ahead = 365
16
16
  weight_urgency = 1.0
17
+ urgency_epsilon = 0.1
18
+ max_top_urgency_rounds = 10
19
+ n_undos = 1
17
20
 
18
21
  [report]
19
22
  include_unplanned = true
@@ -4,6 +4,9 @@ import subprocess
4
4
  from dataclasses import dataclass
5
5
  from datetime import datetime, timedelta
6
6
 
7
+ from rich.table import Table
8
+
9
+ from taskcheck.undo import create_undo_backup
7
10
  from taskcheck.common import (
8
11
  AVOID_STATUS,
9
12
  console,
@@ -27,6 +30,58 @@ class UrgencyCoefficients:
27
30
  urgency_age: float
28
31
 
29
32
 
33
+ def advance_urgency_override(
34
+ tasks_overdue,
35
+ task_info,
36
+ urgency_overrides,
37
+ adjustment_states,
38
+ epsilon,
39
+ max_top_rounds,
40
+ ):
41
+ def effective_urgency(task_uuid):
42
+ return urgency_overrides.get(
43
+ task_uuid,
44
+ float(
45
+ task_info[task_uuid]["task"].get(
46
+ "urgency", task_info[task_uuid]["urgency"]
47
+ )
48
+ ),
49
+ )
50
+
51
+ overdue_uuids = sorted(
52
+ (task["uuid"] for task in tasks_overdue),
53
+ key=effective_urgency,
54
+ reverse=True,
55
+ )
56
+ for task_uuid in overdue_uuids:
57
+ state = adjustment_states.setdefault(
58
+ task_uuid,
59
+ {"top_rounds": 0, "fallback_used": False, "exhausted": False},
60
+ )
61
+ if state["exhausted"]:
62
+ continue
63
+ if state["fallback_used"]:
64
+ state["exhausted"] = True
65
+ continue
66
+
67
+ current_urgency = effective_urgency(task_uuid)
68
+ greater_urgencies = [
69
+ effective_urgency(uuid)
70
+ for uuid in task_info
71
+ if uuid != task_uuid and effective_urgency(uuid) > current_urgency
72
+ ]
73
+ if greater_urgencies:
74
+ urgency_overrides[task_uuid] = min(greater_urgencies) + epsilon
75
+ elif state["top_rounds"] < max_top_rounds:
76
+ urgency_overrides[task_uuid] = current_urgency + epsilon
77
+ state["top_rounds"] += 1
78
+ else:
79
+ urgency_overrides[task_uuid] = 1000.0
80
+ state["fallback_used"] = True
81
+ return task_uuid
82
+ return None
83
+
84
+
30
85
  def get_urgency_coefficients(taskrc=None):
31
86
  """
32
87
  Retrieves urgency coefficients from Taskwarrior configurations.
@@ -110,21 +165,22 @@ def check_tasks_parallel(
110
165
  calendars = get_calendars(config, verbose=verbose, force_update=force_update)
111
166
  urgency_coefficients = get_urgency_coefficients(taskrc=taskrc)
112
167
 
113
- current_weight = initial_weight_urgency
114
- frozen_weights = {}
168
+ urgency_epsilon = float(config["scheduler"].get("urgency_epsilon", 0.1))
169
+ max_top_rounds = int(config["scheduler"].get("max_top_urgency_rounds", 10))
170
+ if urgency_epsilon <= 0:
171
+ raise ValueError("scheduler.urgency_epsilon must be greater than 0")
172
+ if max_top_rounds < 0:
173
+ raise ValueError("scheduler.max_top_urgency_rounds must be at least 0")
115
174
 
116
- # Always re-initialize task_info inside the loop to avoid mutated state
175
+ urgency_overrides = {}
176
+ adjustment_states = {}
177
+ adjusted = False
117
178
  task_info_original = initialize_task_info(
118
179
  tasks, time_maps, days_ahead, urgency_coefficients, calendars
119
180
  )
120
181
  task_info = copy.deepcopy(task_info_original)
121
182
  tasks_overdue = []
122
- while current_weight >= 0.0:
123
- if verbose and auto_adjust_urgency and current_weight < initial_weight_urgency:
124
- console.print(
125
- f"[yellow]Retrying with urgency weight: {current_weight:.1f}[/yellow]"
126
- )
127
-
183
+ while True:
128
184
  task_info = copy.deepcopy(task_info_original)
129
185
  for day_offset in range(days_ahead):
130
186
  allocate_time_for_day(
@@ -132,60 +188,86 @@ def check_tasks_parallel(
132
188
  day_offset,
133
189
  urgency_coefficients,
134
190
  verbose,
135
- current_weight,
136
- frozen_weights,
191
+ initial_weight_urgency,
192
+ urgency_overrides,
137
193
  )
138
194
 
139
- # Check if any tasks cannot be completed on time
140
195
  tasks_overdue = []
141
196
  for info in task_info.values():
142
197
  task = info["task"]
143
198
  due = task.get("due")
199
+ if due is None:
200
+ continue
144
201
 
145
- if due is not None:
146
- due_dt = datetime.strptime(due, "%Y%m%dT%H%M%SZ")
147
-
148
- # Case 1: Task has no scheduling at all and has remaining hours
149
- if not info["scheduling"] and info["remaining_hours"] > 0:
202
+ due_dt = datetime.strptime(due, "%Y%m%dT%H%M%SZ")
203
+ if not info["scheduling"] and info["remaining_hours"] > 0:
204
+ tasks_overdue.append(task)
205
+ elif info["scheduling"]:
206
+ end_date = max(info["scheduling"])
207
+ end_date_dt = datetime.strptime(end_date, "%Y-%m-%d")
208
+ if end_date_dt > due_dt:
150
209
  tasks_overdue.append(task)
151
- # Case 2: Task is scheduled but completion date is after due date
152
- elif info["scheduling"]:
153
- scheduled_dates = sorted(info["scheduling"].keys())
154
- if scheduled_dates:
155
- end_date = scheduled_dates[-1]
156
- end_date_dt = datetime.strptime(end_date, "%Y-%m-%d")
157
- if end_date_dt > due_dt:
158
- tasks_overdue.append(task)
159
-
160
- # If no tasks are overdue or auto-adjust is disabled, break the loop
210
+
161
211
  if not tasks_overdue or not auto_adjust_urgency:
162
212
  break
163
213
 
164
- for task in tasks_overdue:
165
- frozen_weights.setdefault(task["uuid"], current_weight)
166
-
167
214
  if verbose:
168
215
  console.print(
169
216
  f"[red]{len(tasks_overdue)} task(s) cannot be completed on time[/red]"
170
217
  )
171
218
 
172
- current_weight = round(current_weight - 0.1, 1)
173
-
174
- if current_weight < 0.0:
219
+ adjusted_uuid = advance_urgency_override(
220
+ tasks_overdue,
221
+ task_info,
222
+ urgency_overrides,
223
+ adjustment_states,
224
+ urgency_epsilon,
225
+ max_top_rounds,
226
+ )
227
+ if adjusted_uuid is None:
175
228
  break
229
+ adjusted = True
230
+ if verbose:
231
+ console.print(
232
+ f"[yellow]Retrying task {task_info[adjusted_uuid]['task']['id']} "
233
+ f"with total urgency {urgency_overrides[adjusted_uuid]:.2f}[/yellow]"
234
+ )
176
235
 
177
- # After loop completes, check if we still have overdue tasks
178
- if auto_adjust_urgency and current_weight < initial_weight_urgency:
236
+ if adjusted:
237
+ changed_tasks = []
238
+ for uuid, override in urgency_overrides.items():
239
+ original = task_info_original[uuid]["urgency"]
240
+ if override != original:
241
+ changed_tasks.append((uuid, override - original, original, override))
179
242
  if tasks_overdue:
180
- # We tried to adjust but still have overdue tasks
181
243
  console.print(
182
244
  "[red]Warning: cannot find a solution after per-task urgency adjustment[/red]"
183
245
  )
184
246
  else:
185
- # We successfully resolved overdue tasks by adjusting weight
186
247
  console.print(
187
- f"[green]Resolved deadlines with per-task urgency adjustment[/green]"
248
+ "[green]Resolved deadlines with per-task urgency adjustment[/green]"
249
+ )
250
+ if changed_tasks:
251
+ console.print(
252
+ "[yellow]Scheduling priority was increased for these tasks. "
253
+ "Difference is final overridden urgency minus original urgency.[/yellow]"
188
254
  )
255
+ table = Table(header_style="bold yellow", style="yellow")
256
+ table.add_column("Task")
257
+ table.add_column("Original urgency", justify="right")
258
+ table.add_column("Overridden urgency", justify="right")
259
+ table.add_column("Difference", justify="right")
260
+ for uuid, delta, original, override in sorted(
261
+ changed_tasks, key=lambda item: item[1], reverse=True
262
+ ):
263
+ task = task_info_original[uuid]["task"]
264
+ table.add_row(
265
+ f"#{task['id']} {task['description']}",
266
+ f"{original:.2f}",
267
+ f"{override:.2f}",
268
+ f"+{delta:.2f}",
269
+ )
270
+ console.print(table)
189
271
 
190
272
  if dry_run:
191
273
  # Generate JSON output instead of updating tasks
@@ -232,7 +314,9 @@ def check_tasks_parallel(
232
314
 
233
315
  return scheduling_results
234
316
  else:
235
- # Normal operation - update tasks in Taskwarrior
317
+ create_undo_backup(
318
+ task_info, config["scheduler"].get("n_undos", 1), taskrc
319
+ )
236
320
  update_tasks_with_scheduling_info(task_info, verbose, taskrc)
237
321
 
238
322
  return None
@@ -279,7 +363,7 @@ def allocate_time_for_day(
279
363
  urgency_coefficients,
280
364
  verbose,
281
365
  weight_urgency,
282
- frozen_weights=None,
366
+ urgency_overrides=None,
283
367
  ):
284
368
  date = datetime.today().date() + timedelta(days=day_offset)
285
369
  total_available_hours = compute_total_available_hours(task_info, day_offset)
@@ -297,7 +381,7 @@ def allocate_time_for_day(
297
381
  urgency_coefficients,
298
382
  date,
299
383
  weight_urgency,
300
- frozen_weights,
384
+ urgency_overrides,
301
385
  )
302
386
  sorted_task_ids = sorted(
303
387
  tasks_remaining.keys(),
@@ -435,10 +519,10 @@ def update_urgency(info, urgency_key, urgency_compute_fn, urgency_coefficients,
435
519
 
436
520
 
437
521
  def recompute_urgencies(
438
- tasks_remaining, urgency_coefficients, date, weight_urgency, frozen_weights=None
522
+ tasks_remaining, urgency_coefficients, date, weight_urgency, urgency_overrides=None
439
523
  ):
440
524
  """Recompute urgency simulating that today is `date`."""
441
- frozen_weights = frozen_weights or {}
525
+ urgency_overrides = urgency_overrides or {}
442
526
  # Recompute estimated urgencies as before
443
527
  for info in tasks_remaining.values():
444
528
  # Update estimated urgency
@@ -452,8 +536,7 @@ def recompute_urgencies(
452
536
 
453
537
  # Apply weights to create a combined score
454
538
  base_urgency = info["urgency"] - info["due_urgency"]
455
- task_weight = frozen_weights.get(info["task"]["uuid"], weight_urgency)
456
- base_urgency *= task_weight
539
+ base_urgency *= weight_urgency
457
540
  # Always add due_urgency, only weight estimated and age
458
541
  weighted_urgency = base_urgency + info["due_urgency"]
459
542
  info["urgency"] = weighted_urgency
@@ -497,6 +580,10 @@ def recompute_urgencies(
497
580
  max_urgency = get_max_urgency(info, visited)
498
581
  info["urgency"] = max_urgency # Update the task's urgency
499
582
 
583
+ for task_uuid, urgency in urgency_overrides.items():
584
+ if task_uuid in tasks_remaining:
585
+ tasks_remaining[task_uuid]["urgency"] = urgency
586
+
500
587
 
501
588
  def allocate_time_to_task(info, day_offset, day_remaining_hours):
502
589
  if day_offset >= len(info["task_time_map"]):
@@ -182,7 +182,7 @@ def get_tasks(config, tasks, year, month, day):
182
182
  regex = re.compile(rf"{year:04d}-{month:02d}-{day:02d} - ([PDTHM0-9]+)")
183
183
  valid_tasks = []
184
184
  for task in tasks:
185
- for line in task["scheduling"].split("\n"):
185
+ for line in task.get("scheduling", "").split("\n"):
186
186
  m = regex.match(line)
187
187
  if m:
188
188
  due = task.get("due", "")
@@ -0,0 +1,84 @@
1
+ import json
2
+ import subprocess
3
+ import time
4
+ from pathlib import Path
5
+
6
+ from taskcheck.common import get_task_env
7
+
8
+
9
+ MANAGED_FIELDS = ("scheduled", "completion_date", "scheduling")
10
+
11
+
12
+ def get_task_data_dir(taskrc=None):
13
+ result = subprocess.run(
14
+ ["task", "_get", "rc.data.location"],
15
+ capture_output=True,
16
+ text=True,
17
+ env=get_task_env(taskrc),
18
+ check=False,
19
+ )
20
+ location = result.stdout.strip()
21
+ if not location:
22
+ raise RuntimeError("Taskwarrior data location is not configured")
23
+ return Path(location).expanduser()
24
+
25
+
26
+ def get_undo_dir(taskrc=None):
27
+ return get_task_data_dir(taskrc) / "taskcheck" / "undos"
28
+
29
+
30
+ def create_undo_backup(task_info, n_undos, taskrc=None):
31
+ n_undos = int(n_undos)
32
+ if n_undos <= 0:
33
+ return None
34
+
35
+ tasks = []
36
+ for info in task_info.values():
37
+ if not info["scheduling"]:
38
+ continue
39
+ task = info["task"]
40
+ snapshot = {"uuid": task["uuid"]}
41
+ snapshot.update({field: task.get(field) for field in MANAGED_FIELDS})
42
+ tasks.append(snapshot)
43
+ if not tasks:
44
+ return None
45
+
46
+ undo_dir = get_undo_dir(taskrc)
47
+ undo_dir.mkdir(parents=True, exist_ok=True)
48
+ backup = undo_dir / f"undo-{time.time_ns()}.json"
49
+ temporary = backup.with_suffix(".tmp")
50
+ temporary.write_text(json.dumps({"tasks": tasks}, indent=2) + "\n")
51
+ temporary.replace(backup)
52
+
53
+ backups = sorted(undo_dir.glob("undo-*.json"))
54
+ for expired in backups[:-n_undos]:
55
+ expired.unlink()
56
+ return backup
57
+
58
+
59
+ def restore_latest_backup(taskrc=None):
60
+ undo_dir = get_undo_dir(taskrc)
61
+ backups = sorted(undo_dir.glob("undo-*.json")) if undo_dir.exists() else []
62
+ if not backups:
63
+ print(
64
+ "No undo backup found. Set [scheduler] n_undos to at least 1 and run "
65
+ "taskcheck --schedule before using --undo."
66
+ )
67
+ return False
68
+
69
+ backup = backups[-1]
70
+ snapshot = json.loads(backup.read_text())
71
+ env = get_task_env(taskrc)
72
+ for task in snapshot["tasks"]:
73
+ fields = [f"{field}:{task.get(field) or ''}" for field in MANAGED_FIELDS]
74
+ subprocess.run(
75
+ ["task", task["uuid"], "modify", *fields],
76
+ capture_output=True,
77
+ text=True,
78
+ env=env,
79
+ check=True,
80
+ )
81
+
82
+ backup.unlink()
83
+ print(f"Restored {len(snapshot['tasks'])} task(s) from the latest backup.")
84
+ return True
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taskcheck
3
- Version: 1.3.1
3
+ Version: 1.4.1
4
4
  Summary: Automatic scheduler for taskwarrior
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -33,6 +33,7 @@ Use it if you want to:
33
33
  - 🧭 Support complex working-hour maps
34
34
  - ⏱️ Consider urgency, due dates, and dependencies
35
35
  - 🧪 Dry-run mode to preview changes
36
+ - ↩️ Undo the latest schedule update
36
37
  - 🧠 Auto-adjust urgency when deadlines cannot be met
37
38
  - 🔄 Force-refresh iCal calendars
38
39
  - 📊 Custom reports for planned and unplanned tasks
@@ -46,6 +47,7 @@ Use it if you want to:
46
47
  3. add `estimated` + `time_map` UDAs
47
48
  4. edit `~/.config/task/taskcheck.toml` (see [Reference](#Reference))
48
49
  5. `taskcheck --schedule`
50
+ 6. `taskcheck --undo` restores the scheduling fields changed by the latest run
49
51
 
50
52
  ## How it works
51
53
 
@@ -88,11 +90,10 @@ In short:
88
90
  - it installs required Taskwarrior settings if you confirm the first prompt
89
91
  - it installs optional urgency/report tuning if you confirm the second prompt
90
92
  - it can also create the default config file if you confirm the third prompt
91
- - it does **not** schedule tasks or fetch calendars
92
93
  - scheduling happens with `taskcheck --schedule`
93
94
  - all flags/config keys/settings are documented in `REFERENCE.md`
94
95
 
95
- ## Algorithm
96
+ ## They say it's an AI
96
97
 
97
98
  The algorithm simulates a workday one chunk at a time.
98
99
 
@@ -104,7 +105,9 @@ For `today`, taskcheck skips past hours.
104
105
 
105
106
  The default work chunk is 2 hours (or less if the task is shorter); you can tune it with the Taskwarrior UDA `min_block`.
106
107
 
107
- If a task would finish after its `due_date`, taskcheck retries. It keeps that task's current non-due urgency weight, then lowers the weight by 0.1 only for tasks that have not missed a deadline. Due urgency is never reduced. Retries stop when deadlines are respected or unflagged tasks reach a weight of 0.
108
+ Before writing a schedule, taskcheck backs up the affected tasks' `scheduled`, `completion_date`, and `scheduling` fields under the Taskwarrior data directory. `taskcheck --undo` restores and consumes the newest backup. The `[scheduler]` option `n_undos` controls retention and defaults to `1`; set it to `0` to disable backups.
109
+
110
+ If a task would finish after its due date, taskcheck retries with a total-urgency override. Each retry moves the task immediately above the task with the smallest greater urgency by adding `urgency_epsilon` (default: `0.1`). At rank 1, it adds epsilon for up to `max_top_urgency_rounds` retries (default: `10`), then tries once at urgency `1000`. If the task is still late, taskcheck stops raising it and optimizes the other overdue tasks.
108
111
 
109
112
  ## Tips and Tricks
110
113
 
@@ -8,6 +8,7 @@ taskcheck/ical.py
8
8
  taskcheck/install.py
9
9
  taskcheck/parallel.py
10
10
  taskcheck/report.py
11
+ taskcheck/undo.py
11
12
  taskcheck.egg-info/PKG-INFO
12
13
  taskcheck.egg-info/SOURCES.txt
13
14
  taskcheck.egg-info/dependency_links.txt
@@ -20,4 +21,5 @@ tests/test_ical.py
20
21
  tests/test_install.py
21
22
  tests/test_main.py
22
23
  tests/test_parallel.py
23
- tests/test_report.py
24
+ tests/test_report.py
25
+ tests/test_undo.py
@@ -52,8 +52,9 @@ class TestIntegration:
52
52
 
53
53
  @patch('taskcheck.parallel.get_calendars')
54
54
  @patch('taskcheck.parallel.get_tasks')
55
+ @patch('taskcheck.parallel.create_undo_backup')
55
56
  @patch('subprocess.run')
56
- def test_full_scheduling_workflow(self, mock_run, mock_tasks, mock_calendars, sample_config, sample_tasks):
57
+ def test_full_scheduling_workflow(self, mock_run, mock_backup, mock_tasks, mock_calendars, sample_config, sample_tasks):
57
58
  """Test the complete scheduling workflow."""
58
59
  mock_tasks.return_value = sample_tasks
59
60
  mock_calendars.return_value = []
@@ -15,6 +15,7 @@ class TestArgumentParsing:
15
15
  assert args.install is False
16
16
  assert args.report is None
17
17
  assert args.schedule is False
18
+ assert args.undo is False
18
19
  assert args.force_update is False
19
20
  assert args.taskrc is None
20
21
  assert args.urgency_weight is None
@@ -30,6 +31,7 @@ class TestArgumentParsing:
30
31
  "-r",
31
32
  "today",
32
33
  "-s",
34
+ "--undo",
33
35
  "-f",
34
36
  "--taskrc",
35
37
  "/custom/path",
@@ -42,6 +44,7 @@ class TestArgumentParsing:
42
44
  assert args.install is True
43
45
  assert args.report == "today"
44
46
  assert args.schedule is True
47
+ assert args.undo is True
45
48
  assert args.force_update is True
46
49
  assert args.taskrc == "/custom/path"
47
50
  assert args.urgency_weight == 0.7
@@ -108,6 +111,11 @@ class TestArgumentParsing:
108
111
  assert capsys.readouterr().out == f"taskcheck {version('taskcheck')}\n"
109
112
 
110
113
 
114
+ def test_undo_argument(self):
115
+ args = arg_parser.parse_args(["--undo"])
116
+ assert args.undo is True
117
+
118
+
111
119
  class TestConfigLoading:
112
120
  @patch(
113
121
  "builtins.open",
@@ -175,6 +183,16 @@ class TestMainFunction:
175
183
 
176
184
  mock_install.assert_called_once()
177
185
 
186
+ @patch("taskcheck.undo.restore_latest_backup")
187
+ def test_main_undo_returns_before_scheduling(self, mock_restore):
188
+ with patch(
189
+ "sys.argv",
190
+ ["taskcheck", "--undo", "--schedule", "--taskrc", "/custom/task"],
191
+ ):
192
+ main()
193
+
194
+ mock_restore.assert_called_once_with(taskrc="/custom/task")
195
+
178
196
  @patch("taskcheck.__main__.load_config")
179
197
  @patch("taskcheck.__main__.check_tasks_parallel")
180
198
  def test_main_schedule_command(
@@ -1,6 +1,8 @@
1
1
  from datetime import datetime, timedelta
2
2
  from unittest.mock import patch
3
3
 
4
+ import pytest
5
+
4
6
  from taskcheck.parallel import (
5
7
  get_urgency_coefficients,
6
8
  check_tasks_parallel,
@@ -10,10 +12,17 @@ from taskcheck.parallel import (
10
12
  urgency_age,
11
13
  urgency_estimated,
12
14
  recompute_urgencies,
15
+ advance_urgency_override,
13
16
  UrgencyCoefficients,
14
17
  )
15
18
 
16
19
 
20
+ @pytest.fixture(autouse=True)
21
+ def mock_undo_backup():
22
+ with patch("taskcheck.parallel.create_undo_backup") as mock_backup:
23
+ yield mock_backup
24
+
25
+
17
26
  class TestUrgencyCoefficients:
18
27
  def test_get_urgency_coefficients(self, mock_task_export_with_taskrc, test_taskrc):
19
28
  coeffs = get_urgency_coefficients(taskrc=test_taskrc)
@@ -287,10 +296,10 @@ class TestWeightConfiguration:
287
296
  recompute_urgencies(tasks_remaining, coeffs, date, 0.7)
288
297
  assert tasks_remaining["task-1"]["urgency"] >= tasks_remaining["task-1"]["due_urgency"]
289
298
 
290
- def test_recompute_urgencies_uses_frozen_weight_per_task(self):
299
+ def test_recompute_urgencies_applies_total_urgency_override(self):
291
300
  tasks_remaining = {
292
- uuid: {
293
- "task": {"uuid": uuid, "id": index, "entry": "20240101T000000Z"},
301
+ "overridden": {
302
+ "task": {"uuid": "overridden", "id": 1, "entry": "20240101T000000Z"},
294
303
  "urgency": 10.0,
295
304
  "estimated_urgency": 5.0,
296
305
  "due_urgency": 0.0,
@@ -298,7 +307,6 @@ class TestWeightConfiguration:
298
307
  "remaining_hours": 2.0,
299
308
  "started": False,
300
309
  }
301
- for index, uuid in enumerate(("frozen", "unfrozen"), start=1)
302
310
  }
303
311
  coeffs = UrgencyCoefficients({"P2H": 5.0}, False, 0, 365, 0, 0)
304
312
 
@@ -307,11 +315,10 @@ class TestWeightConfiguration:
307
315
  coeffs,
308
316
  datetime(2024, 1, 1).date(),
309
317
  0.2,
310
- frozen_weights={"frozen": 0.7},
318
+ urgency_overrides={"overridden": 42.1},
311
319
  )
312
320
 
313
- assert tasks_remaining["frozen"]["urgency"] == 7.0
314
- assert tasks_remaining["unfrozen"]["urgency"] == 2.0
321
+ assert tasks_remaining["overridden"]["urgency"] == 42.1
315
322
 
316
323
  def test_recompute_urgencies_inherit_and_cycle(self):
317
324
  tasks_remaining = {
@@ -328,8 +335,10 @@ class TestMainSchedulingFunction:
328
335
  @patch("taskcheck.parallel.get_tasks")
329
336
  @patch("taskcheck.parallel.get_urgency_coefficients")
330
337
  @patch("taskcheck.parallel.update_tasks_with_scheduling_info")
338
+ @patch("taskcheck.parallel.create_undo_backup")
331
339
  def test_check_tasks_parallel(
332
340
  self,
341
+ mock_backup,
333
342
  mock_update,
334
343
  mock_coeffs,
335
344
  mock_tasks,
@@ -343,16 +352,76 @@ class TestMainSchedulingFunction:
343
352
  {"P1H": 5.0, "P2H": 8.0, "P3H": 10.0}, False, 4.0, 365, 12, 2
344
353
  )
345
354
  mock_calendars.return_value = []
355
+ writes = []
356
+ mock_backup.side_effect = lambda *args: writes.append("backup")
357
+ mock_update.side_effect = lambda *args: writes.append("update")
346
358
 
347
359
  check_tasks_parallel(sample_config, verbose=True, taskrc=test_taskrc)
348
360
 
349
361
  mock_tasks.assert_called_once_with(taskrc=test_taskrc)
350
362
  mock_coeffs.assert_called_once_with(taskrc=test_taskrc)
351
363
  mock_calendars.assert_called_once()
364
+ mock_backup.assert_called_once()
365
+ assert mock_backup.call_args.args[1:] == (1, test_taskrc)
352
366
  mock_update.assert_called_once()
367
+ assert writes == ["backup", "update"]
353
368
 
354
369
 
355
370
  class TestAutoAdjustUrgency:
371
+ def test_urgency_override_moves_up_one_rank_then_uses_bounded_top_rounds(self):
372
+ task_info = {
373
+ "t1": {"task": {"uuid": "t1"}, "urgency": 1.0},
374
+ "t2": {"task": {"uuid": "t2"}, "urgency": 2.0},
375
+ "t3": {"task": {"uuid": "t3"}, "urgency": 3.0},
376
+ }
377
+ overdue = [task_info["t1"]["task"]]
378
+ overrides = {}
379
+ states = {}
380
+ values = []
381
+
382
+ for _ in range(5):
383
+ adjusted_uuid = advance_urgency_override(
384
+ overdue, task_info, overrides, states, epsilon=0.1, max_top_rounds=2
385
+ )
386
+ values.append(overrides["t1"])
387
+ task_info["t1"]["urgency"] = overrides["t1"]
388
+
389
+ assert adjusted_uuid == "t1"
390
+ assert values == pytest.approx([2.1, 3.1, 3.2, 3.3, 1000.0])
391
+ assert (
392
+ advance_urgency_override(
393
+ overdue, task_info, overrides, states, epsilon=0.1, max_top_rounds=2
394
+ )
395
+ is None
396
+ )
397
+
398
+ def test_exhausted_task_does_not_block_other_overdue_tasks(self):
399
+ task_info = {
400
+ "t1": {"task": {"uuid": "t1"}, "urgency": 1000.0},
401
+ "t2": {"task": {"uuid": "t2"}, "urgency": 1.0},
402
+ "t3": {"task": {"uuid": "t3"}, "urgency": 2.0},
403
+ }
404
+ overrides = {"t1": 1000.0}
405
+ states = {}
406
+ overdue = [task_info["t1"]["task"], task_info["t2"]["task"]]
407
+
408
+ for _ in range(3):
409
+ advance_urgency_override(
410
+ [task_info["t1"]["task"]],
411
+ task_info,
412
+ overrides,
413
+ states,
414
+ epsilon=0.1,
415
+ max_top_rounds=1,
416
+ )
417
+
418
+ adjusted_uuid = advance_urgency_override(
419
+ overdue, task_info, overrides, states, epsilon=0.1, max_top_rounds=1
420
+ )
421
+
422
+ assert adjusted_uuid == "t2"
423
+ assert overrides["t2"] == 2.1
424
+
356
425
  @patch("taskcheck.parallel.get_calendars")
357
426
  @patch("taskcheck.parallel.get_tasks")
358
427
  @patch("taskcheck.parallel.get_urgency_coefficients")
@@ -366,7 +435,7 @@ class TestAutoAdjustUrgency:
366
435
  sample_config,
367
436
  test_taskrc,
368
437
  ):
369
- """Test that auto-adjust reduces urgency weight when tasks are overdue."""
438
+ """Test that auto-adjust retries when tasks are overdue."""
370
439
  # Create tasks with tight deadlines that will cause conflicts
371
440
  overdue_tasks = [
372
441
  {
@@ -437,7 +506,7 @@ class TestAutoAdjustUrgency:
437
506
  @patch("taskcheck.parallel.get_urgency_coefficients")
438
507
  @patch("taskcheck.parallel.get_long_range_time_map")
439
508
  @patch("taskcheck.parallel.update_tasks_with_scheduling_info")
440
- def test_auto_adjust_urgency_weight_reduction(
509
+ def test_auto_adjust_urgency_stops_after_fallback(
441
510
  self,
442
511
  mock_update,
443
512
  mock_long_range,
@@ -447,7 +516,7 @@ class TestAutoAdjustUrgency:
447
516
  sample_config,
448
517
  test_taskrc,
449
518
  ):
450
- """Test that auto_adjust_urgency reduces the urgency weight and stops at 0.0."""
519
+ """Test that auto-adjust stops after bounded rank-1 and fallback retries."""
451
520
  # Use relative dates based on current date
452
521
  from datetime import datetime, timedelta
453
522
 
@@ -476,10 +545,9 @@ class TestAutoAdjustUrgency:
476
545
  {"P24H": 10.0}, False, 4.0, 365, 12, 2
477
546
  )
478
547
  mock_calendars.return_value = []
479
- # Mock no available time at all to make it truly impossible
480
548
  mock_long_range.return_value = ([0.0] * 365, 0.0)
549
+ sample_config["scheduler"]["max_top_urgency_rounds"] = 2
481
550
 
482
- # Patch the console.print to capture output
483
551
  with patch("taskcheck.parallel.console.print") as mock_console_print:
484
552
  check_tasks_parallel(
485
553
  sample_config,
@@ -488,25 +556,21 @@ class TestAutoAdjustUrgency:
488
556
  auto_adjust_urgency=True,
489
557
  )
490
558
 
491
- # Should print a warning about not finding a solution
492
- # Print all captured calls for debugging if the assertion fails
493
- found_warning = any(
494
- "cannot find a solution"
495
- in " ".join(str(arg).lower() for arg in call.args)
559
+ messages = [
560
+ " ".join(str(arg).lower() for arg in call.args)
496
561
  for call in mock_console_print.call_args_list
497
- )
498
- if not found_warning:
499
- print("Captured console.print calls for debug:")
500
- for call in mock_console_print.call_args_list:
501
- print(str(call))
502
- assert found_warning
562
+ ]
563
+ retries = [message for message in messages if "retrying task" in message]
564
+ assert len(retries) == 3
565
+ assert "1000.00" in retries[-1]
566
+ assert any("cannot find a solution" in message for message in messages)
503
567
 
504
568
  @patch("taskcheck.parallel.get_calendars")
505
569
  @patch("taskcheck.parallel.get_tasks")
506
570
  @patch("taskcheck.parallel.get_urgency_coefficients")
507
571
  @patch("taskcheck.parallel.get_long_range_time_map")
508
572
  @patch("taskcheck.parallel.update_tasks_with_scheduling_info")
509
- def test_auto_adjust_urgency_final_weight_message(
573
+ def test_auto_adjust_urgency_final_message(
510
574
  self,
511
575
  mock_update,
512
576
  mock_long_range,
@@ -528,8 +592,28 @@ class TestAutoAdjustUrgency:
528
592
  mock_long_range.return_value = ([4.0] * 7, 0.0)
529
593
  with patch("taskcheck.parallel.console.print") as mock_console_print:
530
594
  check_tasks_parallel(sample_config, verbose=True, taskrc=test_taskrc, auto_adjust_urgency=True)
531
- messages = [" ".join(str(arg).lower() for arg in call.args) for call in mock_console_print.call_args_list]
532
- assert any("per-task urgency adjustment" in msg for msg in messages)
595
+ calls = mock_console_print.call_args_list
596
+ messages = [" ".join(str(arg).lower() for arg in call.args) for call in calls]
597
+ warning_index = next(
598
+ index
599
+ for index, message in enumerate(messages)
600
+ if "per-task urgency adjustment" in message
601
+ )
602
+ intro_index = next(
603
+ index
604
+ for index, message in enumerate(messages)
605
+ if "difference is final overridden urgency" in message
606
+ )
607
+ table = calls[intro_index + 1].args[0]
608
+ assert warning_index < intro_index
609
+ assert [column.header for column in table.columns] == [
610
+ "Task",
611
+ "Original urgency",
612
+ "Overridden urgency",
613
+ "Difference",
614
+ ]
615
+ differences = [float(value) for value in table.columns[-1]._cells]
616
+ assert differences == sorted(differences, reverse=True)
533
617
 
534
618
  @patch("taskcheck.parallel.get_calendars")
535
619
  @patch("taskcheck.parallel.get_tasks")
@@ -543,6 +627,7 @@ class TestAutoAdjustUrgency:
543
627
  mock_calendars,
544
628
  sample_config,
545
629
  test_taskrc,
630
+ mock_undo_backup,
546
631
  ):
547
632
  now = datetime.now()
548
633
  due = (now + timedelta(days=1)).strftime("%Y%m%dT%H%M%SZ")
@@ -555,6 +640,7 @@ class TestAutoAdjustUrgency:
555
640
  assert isinstance(result, list)
556
641
  assert result
557
642
  assert any("warning" in " ".join(str(a).lower() for a in call.args) for call in mock_print.call_args_list)
643
+ mock_undo_backup.assert_not_called()
558
644
 
559
645
  def test_update_tasks_with_scheduling_info_warns_when_late(self):
560
646
  task_info = {
@@ -89,6 +89,17 @@ class TestTaskFiltering:
89
89
 
90
90
  assert len(result) == 2
91
91
  assert result[0]["urgency"] >= result[1]["urgency"] # Sorted by urgency
92
+
93
+ def test_get_tasks_ignores_tasks_without_scheduling(self, sample_config):
94
+ result = get_tasks(
95
+ sample_config["report"],
96
+ [{"id": 1, "description": "Unplanned task", "urgency": 10.0}],
97
+ 2023,
98
+ 12,
99
+ 5,
100
+ )
101
+
102
+ assert result == []
92
103
 
93
104
  @patch('subprocess.run')
94
105
  def test_get_unplanned_tasks(self, mock_run, sample_config, test_taskrc):
@@ -0,0 +1,130 @@
1
+ import json
2
+ import subprocess
3
+ from pathlib import Path
4
+ from unittest.mock import Mock, patch
5
+
6
+ import pytest
7
+
8
+ from taskcheck.undo import create_undo_backup, restore_latest_backup
9
+
10
+
11
+ def task_info(scheduled=None, completion_date=None, scheduling=None):
12
+ task = {"uuid": "task-uuid", "id": 1}
13
+ if scheduled is not None:
14
+ task["scheduled"] = scheduled
15
+ if completion_date is not None:
16
+ task["completion_date"] = completion_date
17
+ if scheduling is not None:
18
+ task["scheduling"] = scheduling
19
+ return {"task-uuid": {"task": task, "scheduling": {"2025-01-02": 2.0}}}
20
+
21
+
22
+ def test_create_backup_saves_managed_fields_and_keeps_only_latest(tmp_path):
23
+ with patch("taskcheck.undo.get_task_data_dir", return_value=tmp_path):
24
+ first = create_undo_backup(
25
+ task_info("20250101T000000Z", "20250102T000000Z", "old"), 1
26
+ )
27
+ second = create_undo_backup(task_info(), 1)
28
+
29
+ backups = list((tmp_path / "taskcheck" / "undos").glob("*.json"))
30
+ assert backups == [second]
31
+ assert not first.exists()
32
+ assert json.loads(second.read_text())["tasks"] == [
33
+ {
34
+ "uuid": "task-uuid",
35
+ "scheduled": None,
36
+ "completion_date": None,
37
+ "scheduling": None,
38
+ }
39
+ ]
40
+
41
+
42
+ def test_create_backup_skips_tasks_without_scheduling_and_disabled_retention(tmp_path):
43
+ info = task_info()
44
+ info["task-uuid"]["scheduling"] = {}
45
+
46
+ with patch("taskcheck.undo.get_task_data_dir", return_value=tmp_path):
47
+ assert create_undo_backup(info, 1) is None
48
+ assert create_undo_backup(task_info(), 0) is None
49
+
50
+ assert not (tmp_path / "taskcheck" / "undos").exists()
51
+
52
+
53
+ def test_restore_latest_backup_restores_fields_and_consumes_file(tmp_path):
54
+ backup_dir = tmp_path / "taskcheck" / "undos"
55
+ backup_dir.mkdir(parents=True)
56
+ older = backup_dir / "undo-1000000000000000000.json"
57
+ older.write_text(json.dumps({"tasks": [{"uuid": "older-task"}]}))
58
+ backup = backup_dir / "undo-2000000000000000000.json"
59
+ backup.write_text(
60
+ json.dumps(
61
+ {
62
+ "tasks": [
63
+ {
64
+ "uuid": "task-uuid",
65
+ "scheduled": "20250101T000000Z",
66
+ "completion_date": None,
67
+ "scheduling": "old schedule",
68
+ }
69
+ ]
70
+ }
71
+ )
72
+ )
73
+
74
+ with patch("taskcheck.undo.get_task_data_dir", return_value=tmp_path), patch(
75
+ "taskcheck.undo.subprocess.run"
76
+ ) as mock_run:
77
+ assert restore_latest_backup(taskrc="/custom/task") is True
78
+
79
+ command = mock_run.call_args.args[0]
80
+ kwargs = mock_run.call_args.kwargs
81
+ assert command == [
82
+ "task",
83
+ "task-uuid",
84
+ "modify",
85
+ "scheduled:20250101T000000Z",
86
+ "completion_date:",
87
+ "scheduling:old schedule",
88
+ ]
89
+ assert kwargs["env"]["TASKRC"] == "/custom/task"
90
+ assert kwargs["env"]["TASKDATA"] == "/custom/task"
91
+ assert kwargs["check"] is True
92
+ assert not backup.exists()
93
+ assert older.exists()
94
+
95
+
96
+ def test_restore_without_backup_tells_user_to_enable_n_undos(tmp_path, capsys):
97
+ with patch("taskcheck.undo.get_task_data_dir", return_value=tmp_path):
98
+ assert restore_latest_backup() is False
99
+
100
+ assert "n_undos" in capsys.readouterr().out
101
+
102
+
103
+ def test_failed_restore_keeps_backup(tmp_path):
104
+ backup_dir = tmp_path / "taskcheck" / "undos"
105
+ backup_dir.mkdir(parents=True)
106
+ backup = backup_dir / "undo-1.json"
107
+ backup.write_text(json.dumps({"tasks": [{"uuid": "task-uuid"}]}))
108
+
109
+ with patch("taskcheck.undo.get_task_data_dir", return_value=tmp_path), patch(
110
+ "taskcheck.undo.subprocess.run",
111
+ side_effect=subprocess.CalledProcessError(1, ["task"]),
112
+ ), pytest.raises(subprocess.CalledProcessError):
113
+ restore_latest_backup()
114
+
115
+ assert backup.exists()
116
+
117
+
118
+ def test_get_task_data_dir_uses_taskwarrior_configuration():
119
+ result = Mock(stdout="/data/tasks\n")
120
+ with patch("taskcheck.undo.subprocess.run", return_value=result) as mock_run:
121
+ from taskcheck.undo import get_task_data_dir
122
+
123
+ assert get_task_data_dir("/custom/task") == Path("/data/tasks")
124
+
125
+ command = mock_run.call_args.args[0]
126
+ kwargs = mock_run.call_args.kwargs
127
+ assert command == ["task", "_get", "rc.data.location"]
128
+ assert kwargs["env"]["TASKRC"] == "/custom/task"
129
+ assert kwargs["env"]["TASKDATA"] == "/custom/task"
130
+ assert kwargs["check"] is False
File without changes
File without changes
File without changes
File without changes
File without changes