taskcheck 1.3.0__tar.gz → 1.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {taskcheck-1.3.0/taskcheck.egg-info → taskcheck-1.4.0}/PKG-INFO +7 -4
- {taskcheck-1.3.0 → taskcheck-1.4.0}/README.md +6 -3
- {taskcheck-1.3.0 → taskcheck-1.4.0}/pyproject.toml +1 -1
- {taskcheck-1.3.0 → taskcheck-1.4.0}/taskcheck/__main__.py +13 -0
- {taskcheck-1.3.0 → taskcheck-1.4.0}/taskcheck/install.py +3 -0
- {taskcheck-1.3.0 → taskcheck-1.4.0}/taskcheck/parallel.py +132 -45
- taskcheck-1.4.0/taskcheck/undo.py +84 -0
- {taskcheck-1.3.0 → taskcheck-1.4.0/taskcheck.egg-info}/PKG-INFO +7 -4
- {taskcheck-1.3.0 → taskcheck-1.4.0}/taskcheck.egg-info/SOURCES.txt +3 -1
- {taskcheck-1.3.0 → taskcheck-1.4.0}/tests/test_install.py +2 -1
- {taskcheck-1.3.0 → taskcheck-1.4.0}/tests/test_main.py +27 -1
- {taskcheck-1.3.0 → taskcheck-1.4.0}/tests/test_parallel.py +112 -26
- taskcheck-1.4.0/tests/test_undo.py +130 -0
- {taskcheck-1.3.0 → taskcheck-1.4.0}/LICENSE +0 -0
- {taskcheck-1.3.0 → taskcheck-1.4.0}/setup.cfg +0 -0
- {taskcheck-1.3.0 → taskcheck-1.4.0}/taskcheck/common.py +0 -0
- {taskcheck-1.3.0 → taskcheck-1.4.0}/taskcheck/google_calendar.py +0 -0
- {taskcheck-1.3.0 → taskcheck-1.4.0}/taskcheck/ical.py +0 -0
- {taskcheck-1.3.0 → taskcheck-1.4.0}/taskcheck/report.py +0 -0
- {taskcheck-1.3.0 → taskcheck-1.4.0}/taskcheck.egg-info/dependency_links.txt +0 -0
- {taskcheck-1.3.0 → taskcheck-1.4.0}/taskcheck.egg-info/entry_points.txt +0 -0
- {taskcheck-1.3.0 → taskcheck-1.4.0}/taskcheck.egg-info/requires.txt +0 -0
- {taskcheck-1.3.0 → taskcheck-1.4.0}/taskcheck.egg-info/top_level.txt +0 -0
- {taskcheck-1.3.0 → taskcheck-1.4.0}/tests/test_common.py +0 -0
- {taskcheck-1.3.0 → taskcheck-1.4.0}/tests/test_google_calendar.py +0 -0
- {taskcheck-1.3.0 → taskcheck-1.4.0}/tests/test_ical.py +0 -0
- {taskcheck-1.3.0 → taskcheck-1.4.0}/tests/test_report.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: taskcheck
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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,10 +1,12 @@
|
|
|
1
1
|
import tomllib
|
|
2
2
|
import argparse
|
|
3
|
+
from importlib.metadata import version
|
|
3
4
|
|
|
4
5
|
from taskcheck.parallel import check_tasks_parallel
|
|
5
6
|
from taskcheck.common import config_dir
|
|
6
7
|
|
|
7
8
|
arg_parser = argparse.ArgumentParser()
|
|
9
|
+
arg_parser.add_argument("--version", action="version", version=f"taskcheck {version('taskcheck')}")
|
|
8
10
|
arg_parser.add_argument(
|
|
9
11
|
"-v", "--verbose", action="store_true", help="Increase output verbosity."
|
|
10
12
|
)
|
|
@@ -26,6 +28,11 @@ arg_parser.add_argument(
|
|
|
26
28
|
action="store_true",
|
|
27
29
|
help="Perform the scheduling algorithm, giving a schedule and a scheduling UDA and alerting for not completable tasks.",
|
|
28
30
|
)
|
|
31
|
+
arg_parser.add_argument(
|
|
32
|
+
"--undo",
|
|
33
|
+
action="store_true",
|
|
34
|
+
help="Restore task scheduling fields from the most recent backup.",
|
|
35
|
+
)
|
|
29
36
|
arg_parser.add_argument(
|
|
30
37
|
"-f",
|
|
31
38
|
"--force-update",
|
|
@@ -105,6 +112,12 @@ def main():
|
|
|
105
112
|
install()
|
|
106
113
|
return
|
|
107
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
|
+
|
|
108
121
|
if getattr(args, "add_google_calendar", False) is True:
|
|
109
122
|
from taskcheck.google_calendar import add_google_calendar
|
|
110
123
|
|
|
@@ -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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
522
|
+
tasks_remaining, urgency_coefficients, date, weight_urgency, urgency_overrides=None
|
|
439
523
|
):
|
|
440
524
|
"""Recompute urgency simulating that today is `date`."""
|
|
441
|
-
|
|
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
|
-
|
|
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"]):
|
|
@@ -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
|
+
Version: 1.4.0
|
|
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
|
-
##
|
|
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
|
-
|
|
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 = []
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
from importlib.metadata import version
|
|
2
2
|
from unittest.mock import patch, Mock, mock_open
|
|
3
3
|
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
4
6
|
from taskcheck.__main__ import main, load_config, arg_parser
|
|
5
7
|
|
|
6
8
|
|
|
@@ -13,6 +15,7 @@ class TestArgumentParsing:
|
|
|
13
15
|
assert args.install is False
|
|
14
16
|
assert args.report is None
|
|
15
17
|
assert args.schedule is False
|
|
18
|
+
assert args.undo is False
|
|
16
19
|
assert args.force_update is False
|
|
17
20
|
assert args.taskrc is None
|
|
18
21
|
assert args.urgency_weight is None
|
|
@@ -28,6 +31,7 @@ class TestArgumentParsing:
|
|
|
28
31
|
"-r",
|
|
29
32
|
"today",
|
|
30
33
|
"-s",
|
|
34
|
+
"--undo",
|
|
31
35
|
"-f",
|
|
32
36
|
"--taskrc",
|
|
33
37
|
"/custom/path",
|
|
@@ -40,6 +44,7 @@ class TestArgumentParsing:
|
|
|
40
44
|
assert args.install is True
|
|
41
45
|
assert args.report == "today"
|
|
42
46
|
assert args.schedule is True
|
|
47
|
+
assert args.undo is True
|
|
43
48
|
assert args.force_update is True
|
|
44
49
|
assert args.taskrc == "/custom/path"
|
|
45
50
|
assert args.urgency_weight == 0.7
|
|
@@ -99,6 +104,17 @@ class TestArgumentParsing:
|
|
|
99
104
|
args = arg_parser.parse_args(["--no-auto-adjust-urgency"])
|
|
100
105
|
assert args.auto_adjust_urgency is False
|
|
101
106
|
|
|
107
|
+
def test_version_argument(self, capsys):
|
|
108
|
+
with pytest.raises(SystemExit, match="0"):
|
|
109
|
+
arg_parser.parse_args(["--version"])
|
|
110
|
+
|
|
111
|
+
assert capsys.readouterr().out == f"taskcheck {version('taskcheck')}\n"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_undo_argument(self):
|
|
115
|
+
args = arg_parser.parse_args(["--undo"])
|
|
116
|
+
assert args.undo is True
|
|
117
|
+
|
|
102
118
|
|
|
103
119
|
class TestConfigLoading:
|
|
104
120
|
@patch(
|
|
@@ -167,6 +183,16 @@ class TestMainFunction:
|
|
|
167
183
|
|
|
168
184
|
mock_install.assert_called_once()
|
|
169
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
|
+
|
|
170
196
|
@patch("taskcheck.__main__.load_config")
|
|
171
197
|
@patch("taskcheck.__main__.check_tasks_parallel")
|
|
172
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
|
|
299
|
+
def test_recompute_urgencies_applies_total_urgency_override(self):
|
|
291
300
|
tasks_remaining = {
|
|
292
|
-
|
|
293
|
-
"task": {"uuid":
|
|
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
|
-
|
|
318
|
+
urgency_overrides={"overridden": 42.1},
|
|
311
319
|
)
|
|
312
320
|
|
|
313
|
-
assert tasks_remaining["
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
492
|
-
|
|
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
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
|
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
|
-
|
|
532
|
-
|
|
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 = {
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|