taskcheck 1.0.0.post2__tar.gz → 1.1.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.
Files changed (24) hide show
  1. {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/PKG-INFO +6 -2
  2. taskcheck-1.0.0.post2/taskcheck.egg-info/PKG-INFO → taskcheck-1.1.0/README.md +5 -13
  3. {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/pyproject.toml +1 -1
  4. {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/taskcheck/__main__.py +5 -5
  5. {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/taskcheck/ical.py +7 -6
  6. {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/taskcheck/install.py +11 -1
  7. {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/taskcheck/report.py +15 -8
  8. taskcheck-1.0.0.post2/README.md → taskcheck-1.1.0/taskcheck.egg-info/PKG-INFO +17 -1
  9. {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/tests/test_common.py +11 -0
  10. taskcheck-1.1.0/tests/test_ical.py +189 -0
  11. {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/tests/test_parallel.py +88 -83
  12. {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/tests/test_report.py +37 -1
  13. taskcheck-1.0.0.post2/tests/test_ical.py +0 -171
  14. {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/LICENSE +0 -0
  15. {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/setup.cfg +0 -0
  16. {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/taskcheck/common.py +0 -0
  17. {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/taskcheck/parallel.py +0 -0
  18. {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/taskcheck.egg-info/SOURCES.txt +0 -0
  19. {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/taskcheck.egg-info/dependency_links.txt +0 -0
  20. {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/taskcheck.egg-info/entry_points.txt +0 -0
  21. {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/taskcheck.egg-info/requires.txt +0 -0
  22. {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/taskcheck.egg-info/top_level.txt +0 -0
  23. {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/tests/test_install.py +0 -0
  24. {taskcheck-1.0.0.post2 → taskcheck-1.1.0}/tests/test_main.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taskcheck
3
- Version: 1.0.0.post2
3
+ Version: 1.1.0
4
4
  Summary: Automatic scheduler for taskwarrior
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -32,6 +32,9 @@ This is a taskwarrior extension that automatically schedules your tasks based on
32
32
  estimated time, and calendar events, finding an optimal time to work on each task and match all your
33
33
  deadlines.
34
34
 
35
+ > [!IMPORTANT]
36
+ > This repo is actively maintained again
37
+
35
38
  ## Features
36
39
 
37
40
  - [x] **Use arbitrarily complex time maps for working hours**
@@ -44,7 +47,6 @@ deadlines.
44
47
  - [x] Simple, customizable reports for planned and unplanned tasks
45
48
  - [x] Emoji and attribute customization in reports
46
49
  - [ ] Use Google API to access calendars
47
- - [ ] Export tasks to iCal calendar and API calendars
48
50
 
49
51
  ## Install
50
52
 
@@ -138,6 +140,7 @@ expiration = 720 # In hours (720 hours = 30 days)
138
140
  include_unplanned = true
139
141
  additional_attributes = ["estimated", "due", "urgency"] # Extra attributes to show in the report
140
142
  additional_attributes_unplanned = ["due", "urgency"] # Extra attributes for unplanned tasks
143
+ unplanned_max_due = "30d" # Only show unplanned tasks due within this Taskwarrior-relative horizon; tasks without a due date are always shown
141
144
  emoji_keywords = {"meet"=":busts_in_silhouette:", "review"=":mag_right:"} # Map keywords to emoji
142
145
  ```
143
146
 
@@ -155,6 +158,7 @@ emoji_keywords = {"meet"=":busts_in_silhouette:", "review"=":mag_right:"} # Map
155
158
  - `include_unplanned`: Show unplanned tasks in a separate section.
156
159
  - `additional_attributes`: Extra columns to show in the report.
157
160
  - `additional_attributes_unplanned`: Extra columns for unplanned tasks.
161
+ - `unplanned_max_due`: Optional Taskwarrior-relative horizon (for example `30d`) for dated unplanned tasks. Tasks without a due date are always shown; omitting this setting applies no limit.
158
162
  - `emoji_keywords`: Map keywords in task descriptions to emoji.
159
163
 
160
164
  ## Algorithm
@@ -1,15 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: taskcheck
3
- Version: 1.0.0.post2
4
- Summary: Automatic scheduler for taskwarrior
5
- Requires-Python: >=3.11
6
- Description-Content-Type: text/markdown
7
- Requires-Dist: appdirs>=1.4.4
8
- Requires-Dist: icalendar>=6.0.1
9
- Requires-Dist: random-unicode-emoji>=2.8
10
- Requires-Dist: requests>=2.32.3
11
- Requires-Dist: rich>=13.9.4
12
-
13
1
  <div align="center">
14
2
 
15
3
  ```
@@ -32,6 +20,9 @@ This is a taskwarrior extension that automatically schedules your tasks based on
32
20
  estimated time, and calendar events, finding an optimal time to work on each task and match all your
33
21
  deadlines.
34
22
 
23
+ > [!IMPORTANT]
24
+ > This repo is actively maintained again
25
+
35
26
  ## Features
36
27
 
37
28
  - [x] **Use arbitrarily complex time maps for working hours**
@@ -44,7 +35,6 @@ deadlines.
44
35
  - [x] Simple, customizable reports for planned and unplanned tasks
45
36
  - [x] Emoji and attribute customization in reports
46
37
  - [ ] Use Google API to access calendars
47
- - [ ] Export tasks to iCal calendar and API calendars
48
38
 
49
39
  ## Install
50
40
 
@@ -138,6 +128,7 @@ expiration = 720 # In hours (720 hours = 30 days)
138
128
  include_unplanned = true
139
129
  additional_attributes = ["estimated", "due", "urgency"] # Extra attributes to show in the report
140
130
  additional_attributes_unplanned = ["due", "urgency"] # Extra attributes for unplanned tasks
131
+ unplanned_max_due = "30d" # Only show unplanned tasks due within this Taskwarrior-relative horizon; tasks without a due date are always shown
141
132
  emoji_keywords = {"meet"=":busts_in_silhouette:", "review"=":mag_right:"} # Map keywords to emoji
142
133
  ```
143
134
 
@@ -155,6 +146,7 @@ emoji_keywords = {"meet"=":busts_in_silhouette:", "review"=":mag_right:"} # Map
155
146
  - `include_unplanned`: Show unplanned tasks in a separate section.
156
147
  - `additional_attributes`: Extra columns to show in the report.
157
148
  - `additional_attributes_unplanned`: Extra columns for unplanned tasks.
149
+ - `unplanned_max_due`: Optional Taskwarrior-relative horizon (for example `30d`) for dated unplanned tasks. Tasks without a due date are always shown; omitting this setting applies no limit.
158
150
  - `emoji_keywords`: Map keywords in task descriptions to emoji.
159
151
 
160
152
  ## Algorithm
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "taskcheck"
3
- version = "1.0.0-2"
3
+ version = "1.1.0"
4
4
  description = "Automatic scheduler for taskwarrior"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -24,18 +24,18 @@ arg_parser.add_argument(
24
24
  "-s",
25
25
  "--schedule",
26
26
  action="store_true",
27
- help="Perform the scheduling algorithm, giving a schedule and a scheduling UDA and alerting for not completable tasks",
27
+ help="Perform the scheduling algorithm, giving a schedule and a scheduling UDA and alerting for not completable tasks.",
28
28
  )
29
29
  arg_parser.add_argument(
30
30
  "-f",
31
31
  "--force-update",
32
32
  action="store_true",
33
- help="Force update of all ical calendars by ignoring cache expiration",
33
+ help="Force update of all ical calendars by ignoring cache expiration.",
34
34
  )
35
35
  arg_parser.add_argument(
36
36
  "--taskrc",
37
37
  action="store",
38
- help="Set custom TASKRC directory for debugging purposes",
38
+ help="Set custom TASKRC directory for debugging purposes.",
39
39
  )
40
40
  arg_parser.add_argument(
41
41
  "--urgency-weight",
@@ -45,14 +45,14 @@ arg_parser.add_argument(
45
45
  arg_parser.add_argument(
46
46
  "--dry-run",
47
47
  action="store_true",
48
- help="Perform scheduling without modifying the Taskwarrior database, useful for testing",
48
+ help="Perform scheduling without modifying the Taskwarrior database, useful for testing.",
49
49
  )
50
50
  arg_parser.add_argument(
51
51
  "--no-auto-adjust-urgency",
52
52
  dest="auto_adjust_urgency",
53
53
  action="store_false",
54
54
  default=True,
55
- help="Disable automatic reduction of urgency weight … (default: enabled)",
55
+ help="Disable automatic reduction of urgency weight when tasks cannot be completed on time. (Enabled by default, meaning automatic reduction will occur.)",
56
56
  )
57
57
 
58
58
 
@@ -40,17 +40,18 @@ def parse_ical_events(ical_text, days_ahead, all_day, tz_name=None, verbose=Fals
40
40
  if not all_day:
41
41
  continue
42
42
  else:
43
- # all-day events should block since 00:00 and end at 23:59:59
44
43
  event_start = datetime(
45
44
  event_start.year,
46
45
  event_start.month,
47
46
  event_start.day,
48
- 0,
49
- 0,
50
- 0,
51
- 0,
52
47
  )
53
- event_end = event_start + timedelta(days=1) - timedelta(seconds=1)
48
+ event_end = datetime(
49
+ event_end.year,
50
+ event_end.month,
51
+ event_end.day,
52
+ )
53
+ if event_end <= event_start:
54
+ event_end = event_start + timedelta(days=1)
54
55
  if recurrence_id:
55
56
  if end_date >= recurrence_id.dt.date() >= today:
56
57
  # this was an occurrence of a recurring event
@@ -19,12 +19,22 @@ weight_urgency = 1.0
19
19
  include_unplanned = true
20
20
  additional_attributes = []
21
21
  additional_attributes_unplanned = []
22
+ # unplanned_max_due = "30d"
22
23
  emoji_keywords = {}
23
24
  """
24
25
 
25
26
 
26
27
  def apply_config(key, value):
27
- subprocess.run(["task", "config", key, value], stdout=subprocess.DEVNULL)
28
+ subprocess.run(
29
+ [
30
+ "task",
31
+ "rc.confirmation=0",
32
+ "config",
33
+ key,
34
+ value,
35
+ ],
36
+ stdout=subprocess.DEVNULL,
37
+ )
28
38
 
29
39
 
30
40
  def install():
@@ -194,6 +194,7 @@ def get_tasks(config, tasks, year, month, day):
194
194
  "id": task["id"],
195
195
  "project": task.get("project", ""),
196
196
  "description": task["description"],
197
+ "urgency": task["urgency"],
197
198
  "scheduling_day": f"{year}-{month}-{day}",
198
199
  "scheduling_hours": m.group(1),
199
200
  **{
@@ -265,22 +266,28 @@ def get_unplanned_tasks(config, tasks, taskrc=None):
265
266
  env=env,
266
267
  )
267
268
  tasks = json.loads(tasks.stdout)
268
- return tasks
269
+ max_due = config.get("unplanned_max_due")
270
+ if not max_due:
271
+ return tasks
272
+
273
+ due_limit = get_taskwarrior_date(max_due, taskrc=taskrc)
274
+ return [
275
+ task
276
+ for task in tasks
277
+ if not task.get("due")
278
+ or datetime.strptime(task["due"], "%Y%m%dT%H%M%SZ") <= due_limit
279
+ ]
269
280
 
270
281
 
271
282
  def generate_report(config, constraint, verbose=False, force_update=False, taskrc=None, scheduling_results=None):
272
283
  config = config["report"]
273
284
  console = Console()
274
285
 
275
- if scheduling_results is not None:
276
- # Use provided scheduling results (from dry-run mode)
277
- tasks = scheduling_results
278
- else:
279
- # Fetch tasks from Taskwarrior normally
280
- tasks = fetch_tasks(taskrc)
286
+ original_tasks = fetch_tasks(taskrc)
287
+ tasks = scheduling_results if scheduling_results is not None else original_tasks
281
288
 
282
289
  if config.get("include_unplanned"):
283
- unplanned_tasks = get_unplanned_tasks(config, tasks, taskrc=taskrc)
290
+ unplanned_tasks = get_unplanned_tasks(config, original_tasks, taskrc=taskrc)
284
291
  display_unplanned_tasks(console, config, unplanned_tasks)
285
292
 
286
293
  for year, month, day in get_days_in_constraint(constraint, taskrc=taskrc):
@@ -1,3 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: taskcheck
3
+ Version: 1.1.0
4
+ Summary: Automatic scheduler for taskwarrior
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: appdirs>=1.4.4
8
+ Requires-Dist: icalendar>=6.0.1
9
+ Requires-Dist: random-unicode-emoji>=2.8
10
+ Requires-Dist: requests>=2.32.3
11
+ Requires-Dist: rich>=13.9.4
12
+
1
13
  <div align="center">
2
14
 
3
15
  ```
@@ -20,6 +32,9 @@ This is a taskwarrior extension that automatically schedules your tasks based on
20
32
  estimated time, and calendar events, finding an optimal time to work on each task and match all your
21
33
  deadlines.
22
34
 
35
+ > [!IMPORTANT]
36
+ > This repo is actively maintained again
37
+
23
38
  ## Features
24
39
 
25
40
  - [x] **Use arbitrarily complex time maps for working hours**
@@ -32,7 +47,6 @@ deadlines.
32
47
  - [x] Simple, customizable reports for planned and unplanned tasks
33
48
  - [x] Emoji and attribute customization in reports
34
49
  - [ ] Use Google API to access calendars
35
- - [ ] Export tasks to iCal calendar and API calendars
36
50
 
37
51
  ## Install
38
52
 
@@ -126,6 +140,7 @@ expiration = 720 # In hours (720 hours = 30 days)
126
140
  include_unplanned = true
127
141
  additional_attributes = ["estimated", "due", "urgency"] # Extra attributes to show in the report
128
142
  additional_attributes_unplanned = ["due", "urgency"] # Extra attributes for unplanned tasks
143
+ unplanned_max_due = "30d" # Only show unplanned tasks due within this Taskwarrior-relative horizon; tasks without a due date are always shown
129
144
  emoji_keywords = {"meet"=":busts_in_silhouette:", "review"=":mag_right:"} # Map keywords to emoji
130
145
  ```
131
146
 
@@ -143,6 +158,7 @@ emoji_keywords = {"meet"=":busts_in_silhouette:", "review"=":mag_right:"} # Map
143
158
  - `include_unplanned`: Show unplanned tasks in a separate section.
144
159
  - `additional_attributes`: Extra columns to show in the report.
145
160
  - `additional_attributes_unplanned`: Extra columns for unplanned tasks.
161
+ - `unplanned_max_due`: Optional Taskwarrior-relative horizon (for example `30d`) for dated unplanned tasks. Tasks without a due date are always shown; omitting this setting applies no limit.
146
162
  - `emoji_keywords`: Map keywords in task descriptions to emoji.
147
163
 
148
164
  ## Algorithm
@@ -80,6 +80,17 @@ class TestAvailableHours:
80
80
  available = get_available_hours(time_map, date, calendars)
81
81
  assert available == 0.0
82
82
 
83
+ def test_get_available_hours_multi_day_all_day_event(self, sample_config):
84
+ time_map = sample_config["time_maps"]["work"]
85
+ calendars = [[{
86
+ "start": datetime(2023, 12, 5, 0, 0),
87
+ "end": datetime(2023, 12, 7, 0, 0),
88
+ }]]
89
+
90
+ assert get_available_hours(time_map, datetime(2023, 12, 5).date(), calendars) == 0.0
91
+ assert get_available_hours(time_map, datetime(2023, 12, 6).date(), calendars) == 0.0
92
+ assert get_available_hours(time_map, datetime(2023, 12, 7).date(), calendars) == 8.0
93
+
83
94
 
84
95
  class TestLongRangeTimeMap:
85
96
  def test_get_long_range_time_map(self, sample_config, sample_calendar_events):
@@ -0,0 +1,189 @@
1
+ import pytest
2
+ from datetime import datetime, timedelta
3
+ from unittest.mock import Mock, patch
4
+
5
+ from taskcheck.ical import fetch_ical_data, parse_ical_events, ical_to_dict, get_cache_filename
6
+
7
+
8
+ class TestIcalFetching:
9
+ @patch("requests.get")
10
+ def test_fetch_ical_data_success(self, mock_get, mock_ical_response):
11
+ mock_response = Mock()
12
+ mock_response.text = mock_ical_response
13
+ mock_response.raise_for_status = Mock()
14
+ mock_get.return_value = mock_response
15
+
16
+ assert fetch_ical_data("https://example.com/calendar.ics") == mock_ical_response
17
+
18
+ @patch("requests.get")
19
+ def test_fetch_ical_data_error(self, mock_get):
20
+ mock_response = Mock()
21
+ mock_response.raise_for_status.side_effect = Exception("HTTP Error")
22
+ mock_get.return_value = mock_response
23
+
24
+ with pytest.raises(Exception):
25
+ fetch_ical_data("https://example.com/calendar.ics")
26
+
27
+
28
+ class TestIcalParsing:
29
+ @patch("taskcheck.ical.datetime")
30
+ def test_parse_ical_events_simple(self, mock_datetime, mock_ical_response):
31
+ mock_datetime.now.return_value = datetime(2023, 12, 1, 12, 0, 0)
32
+ mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
33
+ events = parse_ical_events(mock_ical_response, days_ahead=7, all_day=False)
34
+ assert len(events) == 2
35
+
36
+ @patch("taskcheck.ical.datetime")
37
+ def test_parse_ical_events_with_timezone(self, mock_datetime, mock_ical_response):
38
+ mock_datetime.now.return_value = datetime(2023, 12, 1, 12, 0, 0)
39
+ mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
40
+ events = parse_ical_events(mock_ical_response, days_ahead=7, all_day=False, tz_name="America/New_York")
41
+ assert events and events[0]["start"].endswith(("-05:00", "-04:00"))
42
+
43
+ @patch("taskcheck.ical.datetime")
44
+ def test_parse_ical_events_recurring(self, mock_datetime):
45
+ mock_datetime.now.return_value = datetime(2023, 12, 1, 12, 0, 0)
46
+ mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
47
+ ical_text = """BEGIN:VCALENDAR
48
+ VERSION:2.0
49
+ PRODID:test
50
+ BEGIN:VEVENT
51
+ UID:recurring-event
52
+ DTSTART:20231205T140000Z
53
+ DTEND:20231205T150000Z
54
+ SUMMARY:Weekly Meeting
55
+ RRULE:FREQ=WEEKLY;COUNT=3
56
+ END:VEVENT
57
+ END:VCALENDAR"""
58
+ assert len(parse_ical_events(ical_text, days_ahead=21, all_day=False)) == 3
59
+
60
+ @patch("taskcheck.ical.datetime")
61
+ def test_parse_ical_events_all_day(self, mock_datetime):
62
+ mock_datetime.now.return_value = datetime(2023, 12, 1, 12, 0, 0)
63
+ mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
64
+ ical_text = """BEGIN:VCALENDAR
65
+ VERSION:2.0
66
+ PRODID:test
67
+ BEGIN:VEVENT
68
+ UID:all-day-event
69
+ DTSTART;VALUE=DATE:20231205
70
+ DTEND;VALUE=DATE:20231206
71
+ SUMMARY:All Day Event
72
+ END:VEVENT
73
+ END:VCALENDAR"""
74
+ assert len(parse_ical_events(ical_text, days_ahead=7, all_day=True)) == 1
75
+ assert parse_ical_events(ical_text, days_ahead=7, all_day=False) == []
76
+
77
+ @patch("taskcheck.ical.datetime")
78
+ def test_parse_multi_day_all_day_event_preserves_span(self, mock_datetime):
79
+ mock_datetime.now.return_value = datetime(2023, 12, 1, 12, 0, 0)
80
+ mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
81
+ ical_text = """BEGIN:VCALENDAR
82
+ VERSION:2.0
83
+ PRODID:test
84
+ BEGIN:VEVENT
85
+ UID:multi-day-event
86
+ DTSTART;VALUE=DATE:20231205
87
+ DTEND;VALUE=DATE:20231207
88
+ SUMMARY:Two Day Event
89
+ END:VEVENT
90
+ END:VCALENDAR"""
91
+ events = parse_ical_events(ical_text, days_ahead=7, all_day=True)
92
+ assert events == [{"start": "2023-12-05T00:00:00", "end": "2023-12-07T00:00:00"}]
93
+
94
+ @patch("taskcheck.ical.datetime")
95
+ def test_parse_ical_events_exdate_and_recurrence_id(self, mock_datetime):
96
+ mock_datetime.now.return_value = datetime(2023, 12, 1, 12, 0, 0)
97
+ mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
98
+ ical_text = """BEGIN:VCALENDAR
99
+ VERSION:2.0
100
+ PRODID:test
101
+ BEGIN:VEVENT
102
+ UID:rrule-exdate
103
+ DTSTART:20231205T140000Z
104
+ DTEND:20231205T150000Z
105
+ RRULE:FREQ=DAILY;COUNT=3
106
+ EXDATE:20231206T140000Z
107
+ END:VEVENT
108
+ BEGIN:VEVENT
109
+ UID:rrule-exception
110
+ RECURRENCE-ID:20231207T140000Z
111
+ DTSTART:20231207T160000Z
112
+ DTEND:20231207T170000Z
113
+ END:VEVENT
114
+ END:VCALENDAR"""
115
+ events = parse_ical_events(ical_text, days_ahead=10, all_day=False)
116
+ starts = {e["start"] for e in events}
117
+ assert len(events) == 2
118
+ assert any("2023-12-05T14:00:00+00:00" in s for s in starts)
119
+ assert any("2023-12-07T16:00:00+00:00" in s for s in starts)
120
+
121
+ @patch("taskcheck.ical.datetime")
122
+ def test_parse_ical_events_past_until_skipped(self, mock_datetime):
123
+ mock_datetime.now.return_value = datetime(2023, 12, 10, 12, 0, 0)
124
+ mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
125
+ ical_text = """BEGIN:VCALENDAR
126
+ VERSION:2.0
127
+ PRODID:test
128
+ BEGIN:VEVENT
129
+ UID:past-rule
130
+ DTSTART:20231201T140000Z
131
+ DTEND:20231201T150000Z
132
+ RRULE:FREQ=DAILY;UNTIL=20231205T000000Z
133
+ END:VEVENT
134
+ END:VCALENDAR"""
135
+ assert parse_ical_events(ical_text, days_ahead=7, all_day=False) == []
136
+
137
+
138
+ class TestIcalCaching:
139
+ def test_get_cache_filename(self):
140
+ filename = get_cache_filename("https://example.com/calendar.ics")
141
+ assert filename.suffix == ".json"
142
+ assert len(filename.stem) == 64
143
+
144
+ @patch("requests.get")
145
+ def test_ical_to_dict_with_cache(self, mock_get, temp_cache_dir, mock_ical_response):
146
+ mock_response = Mock()
147
+ mock_response.text = mock_ical_response
148
+ mock_response.raise_for_status = Mock()
149
+ mock_get.return_value = mock_response
150
+ url = "https://example.com/calendar.ics"
151
+ events1 = ical_to_dict(url, days_ahead=7, all_day=False, expiration=1.0)
152
+ events2 = ical_to_dict(url, days_ahead=7, all_day=False, expiration=1.0)
153
+ assert events1 == events2
154
+ assert mock_get.call_count == 1
155
+
156
+ @patch("requests.get")
157
+ def test_ical_to_dict_force_update(self, mock_get, temp_cache_dir, mock_ical_response):
158
+ mock_response = Mock()
159
+ mock_response.text = mock_ical_response
160
+ mock_response.raise_for_status = Mock()
161
+ mock_get.return_value = mock_response
162
+ url = "https://example.com/calendar.ics"
163
+ events1 = ical_to_dict(url, days_ahead=7, all_day=False, expiration=1.0)
164
+ events2 = ical_to_dict(url, days_ahead=7, all_day=False, expiration=1.0, force_update=True)
165
+ assert events1 == events2
166
+ assert mock_get.call_count == 2
167
+
168
+ @patch("requests.get")
169
+ def test_ical_to_dict_expired_cache_refreshes(self, mock_get, temp_cache_dir, mock_ical_response):
170
+ mock_response = Mock()
171
+ mock_response.text = mock_ical_response
172
+ mock_response.raise_for_status = Mock()
173
+ mock_get.return_value = mock_response
174
+ url = "https://example.com/calendar.ics"
175
+ ical_to_dict(url, days_ahead=7, all_day=False, expiration=1.0)
176
+ ical_to_dict(url, days_ahead=7, all_day=False, expiration=0.0)
177
+ assert mock_get.call_count == 2
178
+
179
+
180
+ class TestExceptionHandling:
181
+ @patch("requests.get")
182
+ def test_ical_to_dict_malformed_ical(self, mock_get, temp_cache_dir):
183
+ mock_response = Mock()
184
+ mock_response.text = "INVALID ICAL DATA"
185
+ mock_response.raise_for_status = Mock()
186
+ mock_get.return_value = mock_response
187
+
188
+ with pytest.raises(Exception):
189
+ ical_to_dict("https://example.com/calendar.ics", days_ahead=7, all_day=False)
@@ -1,4 +1,4 @@
1
- from datetime import datetime
1
+ from datetime import datetime, timedelta
2
2
  from unittest.mock import patch
3
3
 
4
4
  from taskcheck.parallel import (
@@ -121,10 +121,43 @@ class TestTimeAllocation:
121
121
 
122
122
  allocate_time_for_day(task_info, 0, coeffs, verbose=True, weight_urgency=1.0)
123
123
 
124
- # Should allocate time and update scheduling
125
124
  assert task_info["task-1"]["remaining_hours"] < 2.0
126
125
  assert len(task_info["task-1"]["scheduling"]) > 0
127
126
 
127
+ def test_allocate_time_for_day_wait_and_dependency_skip(self):
128
+ today = datetime.today().date()
129
+ wait_date = (today + timedelta(days=1)).strftime("%Y%m%dT%H%M%SZ")
130
+ task_info = {
131
+ "task-1": {
132
+ "task": {"id": 1, "uuid": "task-1", "estimated": "P2H", "wait": wait_date},
133
+ "remaining_hours": 2.0,
134
+ "task_time_map": [8.0],
135
+ "today_used_hours": 0.0,
136
+ "scheduling": {},
137
+ "urgency": 10.0,
138
+ "estimated_urgency": 8.0,
139
+ "due_urgency": 0.0,
140
+ "age_urgency": 1.0,
141
+ "started": False,
142
+ },
143
+ "task-2": {
144
+ "task": {"id": 2, "uuid": "task-2", "estimated": "P2H", "depends": ["task-1"]},
145
+ "remaining_hours": 2.0,
146
+ "task_time_map": [8.0],
147
+ "today_used_hours": 0.0,
148
+ "scheduling": {},
149
+ "urgency": 9.0,
150
+ "estimated_urgency": 8.0,
151
+ "due_urgency": 0.0,
152
+ "age_urgency": 1.0,
153
+ "started": False,
154
+ },
155
+ }
156
+ coeffs = UrgencyCoefficients({"P2H": 8.0}, False, 4.0, 365, 12, 2)
157
+ allocate_time_for_day(task_info, 0, coeffs, verbose=False, weight_urgency=1.0)
158
+ assert task_info["task-1"]["remaining_hours"] == 2.0
159
+ assert task_info["task-2"]["remaining_hours"] == 2.0
160
+
128
161
 
129
162
  class TestDependencies:
130
163
  def test_task_with_dependencies(self, sample_config):
@@ -237,7 +270,6 @@ class TestWeightConfiguration:
237
270
  mock_tasks.assert_called_once()
238
271
 
239
272
  def test_recompute_urgencies_with_weights(self):
240
- """Test that recompute_urgencies applies weights correctly."""
241
273
  tasks_remaining = {
242
274
  "task-1": {
243
275
  "task": {"uuid": "task-1", "id": 1},
@@ -250,29 +282,19 @@ class TestWeightConfiguration:
250
282
  }
251
283
  }
252
284
 
253
- coeffs = UrgencyCoefficients({"P1H": 5.0, "P2H": 8.0}, False, 0, 365, 12, 2)
285
+ coeffs = UrgencyCoefficients({"P2H": 8.0}, False, 0, 365, 12, 2)
254
286
  date = datetime.now().date()
255
- weight_urgency = 0.7
256
-
257
- # Store original urgency to calculate base
287
+ recompute_urgencies(tasks_remaining, coeffs, date, 0.7)
288
+ assert tasks_remaining["task-1"]["urgency"] >= tasks_remaining["task-1"]["due_urgency"]
258
289
 
259
- recompute_urgencies(tasks_remaining, coeffs, date, weight_urgency)
260
-
261
- # Check that weights were applied to the NEW urgency values (after recomputation)
262
- task_info = tasks_remaining["task-1"]
263
-
264
- # The function recomputes all urgency components, so we must use the new values after recomputation.
265
- # The actual implementation does:
266
- # base_urgency = new_urgency - new_due_urgency
267
- # weighted_urgency = base_urgency * weight_urgency + new_due_urgency
268
- base_urgency = (
269
- (task_info["urgency"] - task_info["due_urgency"]) / weight_urgency
270
- if weight_urgency != 0
271
- else 0
272
- )
273
- expected_urgency = base_urgency * weight_urgency + task_info["due_urgency"]
274
-
275
- assert abs(task_info["urgency"] - expected_urgency) < 0.01
290
+ def test_recompute_urgencies_inherit_and_cycle(self):
291
+ tasks_remaining = {
292
+ "a": {"task": {"uuid": "a", "depends": ["b"]}, "urgency": 1.0, "estimated_urgency": 0.0, "due_urgency": 0.0, "age_urgency": 0.0, "remaining_hours": 1.0, "started": False},
293
+ "b": {"task": {"uuid": "b", "depends": ["a"]}, "urgency": 2.0, "estimated_urgency": 0.0, "due_urgency": 0.0, "age_urgency": 0.0, "remaining_hours": 1.0, "started": False},
294
+ }
295
+ coeffs = UrgencyCoefficients({"P1H": 5.0}, True, 0, 365, 12, 2)
296
+ recompute_urgencies(tasks_remaining, coeffs, datetime.now().date(), 1.0)
297
+ assert tasks_remaining["a"]["urgency"] == tasks_remaining["b"]["urgency"]
276
298
 
277
299
 
278
300
  class TestMainSchedulingFunction:
@@ -468,69 +490,52 @@ class TestAutoAdjustUrgency:
468
490
  sample_config,
469
491
  test_taskrc,
470
492
  ):
471
- """Test that the final urgency weight message is printed when auto-adjust is used."""
472
- # Use relative dates based on current date
473
- from datetime import datetime, timedelta
474
-
475
493
  now = datetime.now()
476
- future_date_near = now + timedelta(days=3)
477
- future_date_far = now + timedelta(days=7)
478
-
479
- # Create tasks that will require at least one reduction in urgency weight
494
+ future_date = now + timedelta(days=3)
480
495
  overdue_tasks = [
481
- {
482
- "id": 1,
483
- "uuid": "task-1",
484
- "description": "Tight deadline",
485
- "estimated": "P16H", # 2 working days, due in 7 days
486
- "time_map": "work",
487
- "urgency": 20.0,
488
- "due": future_date_far.strftime(
489
- "%Y%m%dT%H%M%SZ"
490
- ), # Due in 7 days - tight deadline
491
- "status": "pending",
492
- "entry": now.strftime("%Y%m%dT%H%M%SZ"), # Created today
493
- },
494
- {
495
- "id": 2,
496
- "uuid": "task-2",
497
- "description": "Competing task",
498
- "estimated": "P16H", # 2 working days, due in 3 days
499
- "time_map": "work",
500
- "urgency": 15.0,
501
- "due": future_date_near.strftime(
502
- "%Y%m%dT%H%M%SZ"
503
- ), # Same deadline to create conflict
504
- "status": "pending",
505
- "entry": now.strftime("%Y%m%dT%H%M%SZ"), # Created today
506
- },
496
+ {"id": 1, "uuid": "task-1", "description": "Tight deadline", "estimated": "P16H", "time_map": "work", "urgency": 20.0, "due": future_date.strftime("%Y%m%dT%H%M%SZ"), "status": "pending", "entry": now.strftime("%Y%m%dT%H%M%SZ")},
497
+ {"id": 2, "uuid": "task-2", "description": "Competing task", "estimated": "P16H", "time_map": "work", "urgency": 15.0, "due": future_date.strftime("%Y%m%dT%H%M%SZ"), "status": "pending", "entry": now.strftime("%Y%m%dT%H%M%SZ")},
507
498
  ]
508
-
509
499
  mock_tasks.return_value = overdue_tasks
510
- mock_coeffs.return_value = UrgencyCoefficients(
511
- {"P16H": 10.0, "P8H": 8.0}, False, 4.0, 365, 12, 2
512
- )
500
+ mock_coeffs.return_value = UrgencyCoefficients({"P16H": 10.0, "P8H": 8.0}, False, 4.0, 365, 12, 2)
513
501
  mock_calendars.return_value = []
514
- # Mock enough available time that the task CAN be completed with weight reduction
515
- mock_long_range.return_value = ([4.0] * 7, 0.0) # 28 total hours available
516
-
502
+ mock_long_range.return_value = ([4.0] * 7, 0.0)
517
503
  with patch("taskcheck.parallel.console.print") as mock_console_print:
518
- check_tasks_parallel(
519
- sample_config,
520
- verbose=True,
521
- taskrc=test_taskrc,
522
- auto_adjust_urgency=True,
523
- )
504
+ check_tasks_parallel(sample_config, verbose=True, taskrc=test_taskrc, auto_adjust_urgency=True)
505
+ messages = [" ".join(str(arg).lower() for arg in call.args) for call in mock_console_print.call_args_list]
506
+ assert any("final urgency weight" in msg or "cannot find a solution" in msg for msg in messages)
524
507
 
525
- # Should print the final urgency weight used
526
- # Print all captured calls for debugging if the assertion fails
527
- found_final_weight = any(
528
- "final urgency weight"
529
- in " ".join(str(arg).lower() for arg in call.args)
530
- for call in mock_console_print.call_args_list
531
- )
532
- if not found_final_weight:
533
- print("Captured console.print calls for debug:")
534
- for call in mock_console_print.call_args_list:
535
- print(str(call))
536
- assert found_final_weight
508
+ @patch("taskcheck.parallel.get_calendars")
509
+ @patch("taskcheck.parallel.get_tasks")
510
+ @patch("taskcheck.parallel.get_urgency_coefficients")
511
+ @patch("taskcheck.parallel.get_long_range_time_map")
512
+ def test_check_tasks_parallel_dry_run_and_warning(
513
+ self,
514
+ mock_long_range,
515
+ mock_coeffs,
516
+ mock_tasks,
517
+ mock_calendars,
518
+ sample_config,
519
+ test_taskrc,
520
+ ):
521
+ now = datetime.now()
522
+ due = (now + timedelta(days=1)).strftime("%Y%m%dT%H%M%SZ")
523
+ mock_tasks.return_value = [{"id": 1, "uuid": "task-1", "description": "x", "estimated": "P2H", "time_map": "work", "urgency": 1.0, "due": due, "status": "pending", "entry": now.strftime("%Y%m%dT%H%M%SZ") }]
524
+ mock_coeffs.return_value = UrgencyCoefficients({"P2H": 8.0}, False, 4.0, 365, 12, 2)
525
+ mock_calendars.return_value = []
526
+ mock_long_range.return_value = ([0.5] * 7, 0.0)
527
+ with patch("taskcheck.parallel.console.print") as mock_print:
528
+ result = check_tasks_parallel(sample_config, verbose=False, taskrc=test_taskrc, dry_run=True, auto_adjust_urgency=False)
529
+ assert isinstance(result, list)
530
+ assert result
531
+ assert any("warning" in " ".join(str(a).lower() for a in call.args) for call in mock_print.call_args_list)
532
+
533
+ def test_update_tasks_with_scheduling_info_warns_when_late(self):
534
+ task_info = {
535
+ "u": {"task": {"id": 1, "description": "late", "due": "20231201T000000Z"}, "scheduling": {"2023-12-05": 2.0}}
536
+ }
537
+ with patch("taskcheck.parallel.subprocess.run") as mock_run, patch("taskcheck.parallel.console.print") as mock_print:
538
+ from taskcheck.parallel import update_tasks_with_scheduling_info
539
+ update_tasks_with_scheduling_info(task_info, verbose=False, taskrc="/tmp/taskrc")
540
+ mock_run.assert_called_once()
541
+ assert mock_print.called
@@ -111,6 +111,24 @@ class TestTaskFiltering:
111
111
  assert 'env' in call_args.kwargs
112
112
  assert call_args.kwargs['env']['TASKRC'] == test_taskrc
113
113
 
114
+ @patch('taskcheck.report.get_taskwarrior_date')
115
+ @patch('subprocess.run')
116
+ def test_get_unplanned_tasks_filters_due_horizon(self, mock_run, mock_date, sample_config, test_taskrc):
117
+ config = {**sample_config["report"], "unplanned_max_due": "30d"}
118
+ mock_date.return_value = datetime(2023, 12, 31)
119
+ mock_result = Mock()
120
+ mock_result.stdout = json.dumps([
121
+ {"id": 1, "description": "No deadline"},
122
+ {"id": 2, "description": "Due soon", "due": "20231215T120000Z"},
123
+ {"id": 3, "description": "Due later", "due": "20240115T120000Z"},
124
+ ])
125
+ mock_run.return_value = mock_result
126
+
127
+ result = get_unplanned_tasks(config, [], taskrc=test_taskrc)
128
+
129
+ assert [task["id"] for task in result] == [1, 2]
130
+ mock_date.assert_called_once_with("30d", taskrc=test_taskrc)
131
+
114
132
 
115
133
  class TestStringFormatting:
116
134
  def test_tostring_boolean(self):
@@ -197,7 +215,25 @@ class TestReportGeneration:
197
215
  }
198
216
  ]
199
217
 
200
- # Should run without error
201
218
  generate_report(sample_config, "today", verbose=True, taskrc=test_taskrc)
202
219
 
203
220
  mock_unplanned.assert_called_once_with(sample_config["report"], [], taskrc=test_taskrc)
221
+
222
+ @patch('taskcheck.report.display_unplanned_tasks')
223
+ @patch('taskcheck.report.get_unplanned_tasks')
224
+ @patch('taskcheck.report.get_days_in_constraint')
225
+ @patch('taskcheck.report.fetch_tasks')
226
+ def test_generate_report_dry_run_keeps_unplanned_block(self, mock_fetch, mock_days, mock_unplanned, mock_display, sample_config, test_taskrc):
227
+ mock_fetch.return_value = [
228
+ {"id": 1, "description": "Planned", "scheduling": "2023-12-05 - PT2H", "urgency": 1.0, "project": "x"},
229
+ {"id": 2, "description": "No plan", "estimated": "", "urgency": 2.0, "project": "y"},
230
+ ]
231
+ mock_days.return_value = [(2023, 12, 5)]
232
+ mock_unplanned.return_value = [{"id": 2, "description": "No plan", "urgency": 2.0, "project": "y"}]
233
+ scheduling_results = [{"id": 1, "description": "Planned", "scheduling_hours": "PT2H", "project": "x", "urgency": 1.0, "scheduling": "2023-12-05 - PT2H"}]
234
+
235
+ generate_report(sample_config, "today", verbose=False, taskrc=test_taskrc, scheduling_results=scheduling_results)
236
+
237
+ mock_fetch.assert_called_once_with(test_taskrc)
238
+ mock_unplanned.assert_called_once_with(sample_config["report"], mock_fetch.return_value, taskrc=test_taskrc)
239
+ mock_display.assert_called_once()
@@ -1,171 +0,0 @@
1
- import pytest
2
- from datetime import datetime, timedelta
3
- from unittest.mock import patch, Mock
4
- import json
5
- import tempfile
6
-
7
- from taskcheck.ical import (
8
- fetch_ical_data,
9
- parse_ical_events,
10
- ical_to_dict,
11
- get_cache_filename
12
- )
13
-
14
-
15
- class TestIcalFetching:
16
- @patch('requests.get')
17
- def test_fetch_ical_data_success(self, mock_get, mock_ical_response):
18
- mock_response = Mock()
19
- mock_response.text = mock_ical_response
20
- mock_response.raise_for_status = Mock()
21
- mock_get.return_value = mock_response
22
-
23
- result = fetch_ical_data("https://example.com/calendar.ics")
24
-
25
- assert result == mock_ical_response
26
- mock_get.assert_called_once_with("https://example.com/calendar.ics")
27
-
28
- @patch('requests.get')
29
- def test_fetch_ical_data_error(self, mock_get):
30
- mock_response = Mock()
31
- mock_response.raise_for_status.side_effect = Exception("HTTP Error")
32
- mock_get.return_value = mock_response
33
-
34
- with pytest.raises(Exception):
35
- fetch_ical_data("https://example.com/calendar.ics")
36
-
37
-
38
- class TestIcalParsing:
39
- @patch('taskcheck.ical.datetime')
40
- def test_parse_ical_events_simple(self, mock_datetime, mock_ical_response):
41
- # Mock datetime.now() to return a date that makes the test events valid
42
- mock_datetime.now.return_value = datetime(2023, 12, 1, 12, 0, 0)
43
- mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
44
-
45
- events = parse_ical_events(mock_ical_response, days_ahead=7, all_day=False)
46
-
47
- assert len(events) >= 1
48
- for event in events:
49
- assert "start" in event
50
- assert "end" in event
51
- assert isinstance(event["start"], str)
52
- assert isinstance(event["end"], str)
53
-
54
- @patch('taskcheck.ical.datetime')
55
- def test_parse_ical_events_with_timezone(self, mock_datetime, mock_ical_response):
56
- mock_datetime.now.return_value = datetime(2023, 12, 1, 12, 0, 0)
57
- mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
58
-
59
- events = parse_ical_events(
60
- mock_ical_response,
61
- days_ahead=7,
62
- all_day=False,
63
- tz_name="America/New_York"
64
- )
65
-
66
- assert len(events) >= 0
67
-
68
- @patch('taskcheck.ical.datetime')
69
- def test_parse_ical_events_recurring(self, mock_datetime):
70
- mock_datetime.now.return_value = datetime(2023, 12, 1, 12, 0, 0)
71
- mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
72
-
73
- ical_with_recurring = """BEGIN:VCALENDAR
74
- VERSION:2.0
75
- PRODID:test
76
- BEGIN:VEVENT
77
- UID:recurring-event
78
- DTSTART:20231205T140000Z
79
- DTEND:20231205T150000Z
80
- SUMMARY:Weekly Meeting
81
- RRULE:FREQ=WEEKLY;COUNT=3
82
- END:VEVENT
83
- END:VCALENDAR"""
84
-
85
- events = parse_ical_events(ical_with_recurring, days_ahead=21, all_day=False)
86
-
87
- # Should have 3 occurrences
88
- assert len(events) == 3
89
-
90
- @patch('taskcheck.ical.datetime')
91
- def test_parse_ical_events_all_day(self, mock_datetime):
92
- mock_datetime.now.return_value = datetime(2023, 12, 1, 12, 0, 0)
93
- mock_datetime.side_effect = lambda *args, **kw: datetime(*args, **kw)
94
-
95
- ical_all_day = """BEGIN:VCALENDAR
96
- VERSION:2.0
97
- PRODID:test
98
- BEGIN:VEVENT
99
- UID:all-day-event
100
- DTSTART;VALUE=DATE:20231205
101
- DTEND;VALUE=DATE:20231206
102
- SUMMARY:All Day Event
103
- END:VEVENT
104
- END:VCALENDAR"""
105
-
106
- events_include = parse_ical_events(ical_all_day, days_ahead=7, all_day=True)
107
- events_exclude = parse_ical_events(ical_all_day, days_ahead=7, all_day=False)
108
-
109
- assert len(events_include) >= len(events_exclude)
110
-
111
-
112
- class TestIcalCaching:
113
- def test_get_cache_filename(self):
114
- url = "https://example.com/calendar.ics"
115
- filename = get_cache_filename(url)
116
-
117
- assert filename.suffix == ".json"
118
- assert len(filename.stem) == 64 # SHA256 hash length
119
-
120
- @patch('requests.get')
121
- def test_ical_to_dict_with_cache(self, mock_get, temp_cache_dir, mock_ical_response):
122
- mock_response = Mock()
123
- mock_response.text = mock_ical_response
124
- mock_response.raise_for_status = Mock()
125
- mock_get.return_value = mock_response
126
-
127
- url = "https://example.com/calendar.ics"
128
-
129
- # First call should fetch and cache
130
- events1 = ical_to_dict(url, days_ahead=7, all_day=False, expiration=1.0)
131
-
132
- # Second call should use cache
133
- events2 = ical_to_dict(url, days_ahead=7, all_day=False, expiration=1.0)
134
-
135
- assert events1 == events2
136
- # Should only make one HTTP request
137
- assert mock_get.call_count == 1
138
-
139
- @patch('requests.get')
140
- def test_ical_to_dict_force_update(self, mock_get, temp_cache_dir, mock_ical_response):
141
- mock_response = Mock()
142
- mock_response.text = mock_ical_response
143
- mock_response.raise_for_status = Mock()
144
- mock_get.return_value = mock_response
145
-
146
- url = "https://example.com/calendar.ics"
147
-
148
- # First call
149
- events1 = ical_to_dict(url, days_ahead=7, all_day=False, expiration=1.0)
150
-
151
- # Force update should bypass cache
152
- events2 = ical_to_dict(url, days_ahead=7, all_day=False, expiration=1.0, force_update=True)
153
-
154
- assert events1 == events2
155
- # Should make two HTTP requests
156
- assert mock_get.call_count == 2
157
-
158
-
159
- class TestExceptionHandling:
160
- @patch('requests.get')
161
- def test_ical_to_dict_malformed_ical(self, mock_get, temp_cache_dir):
162
- mock_response = Mock()
163
- mock_response.text = "INVALID ICAL DATA"
164
- mock_response.raise_for_status = Mock()
165
- mock_get.return_value = mock_response
166
-
167
- url = "https://example.com/calendar.ics"
168
-
169
- # Should handle malformed iCal gracefully
170
- with pytest.raises(Exception):
171
- ical_to_dict(url, days_ahead=7, all_day=False)
File without changes
File without changes