taskcheck 0.1.0.post1__tar.gz → 0.1.2__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.
@@ -1,13 +1,12 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: taskcheck
3
- Version: 0.1.0.post1
3
+ Version: 0.1.2
4
4
  Summary: Automatic scheduler for taskwarrior
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
7
  License-File: LICENSE
8
8
  Requires-Dist: appdirs>=1.4.4
9
9
  Requires-Dist: icalendar>=6.0.1
10
- Requires-Dist: requests-cache>=1.2.1
11
10
  Requires-Dist: requests>=2.32.3
12
11
 
13
12
 
@@ -72,6 +71,7 @@ days_ahead = 1000 # how far go with the schedule (lower values make the computat
72
71
  [calendars.1]
73
72
  url = "https://your/url/to/calendar.ics"
74
73
  expiration = 0.08 # in hours (0.08 hours =~ 5 minutes)
74
+ timezone = "Europe/Rome" # if set, force timezone for this calendar; timezone values are TZ identifiers (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)
75
75
 
76
76
  [calendars.holidays]
77
77
  url = "https://www.officeholidays.com/ics-clean/italy/milan"
@@ -92,16 +92,21 @@ uda.completion_date.type = date
92
92
  uda.completion_date.label = Expected Completion Date
93
93
 
94
94
  # Adjust urgency for taskcheck
95
- urgency.uda.estimated.coefficient = 1.0 # Increase urgency for tasks with longer estimated durations
96
- urgency.waiting.coefficient = 0.0 # you will use `wait:` to avoid scheduling tasks too much sooner
95
+ # you will use `wait:` to avoid scheduling tasks too much sooner
96
+ urgency.waiting.coefficient = 0.0
97
+ # you will use `active:` to prioritize tasks already started even if the estimated amount is decreased
98
+ urgency.active.coefficient = 3.0
99
+ urgency.scheduled.coefficient = 0.0 # tasks that are not scheduled may still be urgent
97
100
 
98
101
  # we need to be able to modify recurrent tasks without prompt
99
102
  recurrence.confirmation=no
100
103
 
101
104
  # suggested
102
- urgency.inherit=1
105
+ urgency.inherit=1 # use dependencies to automatically prioritize tasks
103
106
  urgency.blocking.coefficient=2
104
107
 
108
+ # give urgeny values to the estimated time
109
+ # this is ln(estimation)
105
110
  urgency.uda.estimated.PT1H.coefficient = 1
106
111
  urgency.uda.estimated.PT2H.coefficient = 2.32
107
112
  urgency.uda.estimated.PT3H.coefficient = 3.67
@@ -138,11 +143,15 @@ urgency.uda.estimated.PT33H.coefficient = 11.73
138
143
  urgency.uda.estimated.PT34H.coefficient = 11.81
139
144
  urgency.uda.estimated.PT35H.coefficient = 11.89
140
145
  urgency.uda.estimated.PT36H.coefficient = 11.96
146
+ # maximum duration is 36 hours
147
+
141
148
  ```
142
149
 
143
150
  ## CLI Options
144
151
 
145
152
  ```
153
+
146
154
  -v, --verbose: increase output verbosity
147
155
  -t, --today: how much time has been already used today (default: 0)
156
+
148
157
  ```
@@ -60,6 +60,7 @@ days_ahead = 1000 # how far go with the schedule (lower values make the computat
60
60
  [calendars.1]
61
61
  url = "https://your/url/to/calendar.ics"
62
62
  expiration = 0.08 # in hours (0.08 hours =~ 5 minutes)
63
+ timezone = "Europe/Rome" # if set, force timezone for this calendar; timezone values are TZ identifiers (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)
63
64
 
64
65
  [calendars.holidays]
65
66
  url = "https://www.officeholidays.com/ics-clean/italy/milan"
@@ -80,16 +81,21 @@ uda.completion_date.type = date
80
81
  uda.completion_date.label = Expected Completion Date
81
82
 
82
83
  # Adjust urgency for taskcheck
83
- urgency.uda.estimated.coefficient = 1.0 # Increase urgency for tasks with longer estimated durations
84
- urgency.waiting.coefficient = 0.0 # you will use `wait:` to avoid scheduling tasks too much sooner
84
+ # you will use `wait:` to avoid scheduling tasks too much sooner
85
+ urgency.waiting.coefficient = 0.0
86
+ # you will use `active:` to prioritize tasks already started even if the estimated amount is decreased
87
+ urgency.active.coefficient = 3.0
88
+ urgency.scheduled.coefficient = 0.0 # tasks that are not scheduled may still be urgent
85
89
 
86
90
  # we need to be able to modify recurrent tasks without prompt
87
91
  recurrence.confirmation=no
88
92
 
89
93
  # suggested
90
- urgency.inherit=1
94
+ urgency.inherit=1 # use dependencies to automatically prioritize tasks
91
95
  urgency.blocking.coefficient=2
92
96
 
97
+ # give urgeny values to the estimated time
98
+ # this is ln(estimation)
93
99
  urgency.uda.estimated.PT1H.coefficient = 1
94
100
  urgency.uda.estimated.PT2H.coefficient = 2.32
95
101
  urgency.uda.estimated.PT3H.coefficient = 3.67
@@ -126,11 +132,15 @@ urgency.uda.estimated.PT33H.coefficient = 11.73
126
132
  urgency.uda.estimated.PT34H.coefficient = 11.81
127
133
  urgency.uda.estimated.PT35H.coefficient = 11.89
128
134
  urgency.uda.estimated.PT36H.coefficient = 11.96
135
+ # maximum duration is 36 hours
136
+
129
137
  ```
130
138
 
131
139
  ## CLI Options
132
140
 
133
141
  ```
142
+
134
143
  -v, --verbose: increase output verbosity
135
144
  -t, --today: how much time has been already used today (default: 0)
145
+
136
146
  ```
@@ -1,15 +1,10 @@
1
1
  [project]
2
2
  name = "taskcheck"
3
- version = "0.1.0-1"
3
+ version = "0.1.2"
4
4
  description = "Automatic scheduler for taskwarrior"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
7
- dependencies = [
8
- "appdirs>=1.4.4",
9
- "icalendar>=6.0.1",
10
- "requests-cache>=1.2.1",
11
- "requests>=2.32.3",
12
- ]
7
+ dependencies = ["appdirs>=1.4.4", "icalendar>=6.0.1", "requests>=2.32.3"]
13
8
 
14
9
  [project.scripts]
15
10
  taskcheck = "taskcheck.__main__:main"
@@ -54,11 +54,11 @@ def _hours_to_decimal(hour):
54
54
 
55
55
  def _hours_to_time(hour):
56
56
  hours = int(hour)
57
- minutes = int((hour - hours) * 60)
57
+ minutes = int((hour - hours) * 100)
58
58
  return datetime.strptime(f"{hours}:{minutes}", "%H:%M").time()
59
59
 
60
60
 
61
- def _time_to_hours(time):
61
+ def _time_to_decimal(time):
62
62
  return time.hour + time.minute / 60
63
63
 
64
64
 
@@ -75,19 +75,37 @@ def get_available_hours(time_map, date, calendars):
75
75
  # so let's convert them to datetime.time objects
76
76
  schedule_start = _hours_to_time(schedule_start)
77
77
  schedule_end = _hours_to_time(schedule_end)
78
+ schedule_blocked_hours = 0
78
79
  for calendar in calendars:
79
80
  for event in calendar:
81
+ # we use str to make object serializable as jsons
82
+ if isinstance(event["start"], str):
83
+ event["start"] = datetime.fromisoformat(event["start"])
84
+ if isinstance(event["end"], str):
85
+ event["end"] = datetime.fromisoformat(event["end"])
86
+
80
87
  if event["start"].date() > date:
81
88
  break
82
89
  elif event["end"].date() < date:
83
90
  continue
91
+
84
92
  # check if the event overlaps with one of the working hours
85
93
  event_start = event["start"].time()
86
94
  event_end = event["end"].time()
95
+ if event["start"].date() < date:
96
+ event_start = datetime(date.year, date.month, date.day, 0, 0).time()
97
+ if event["end"].date() > date:
98
+ event_end = datetime(date.year, date.month, date.day, 23, 59).time()
99
+
87
100
  if event_start < schedule_end and event_end > schedule_start:
88
- blocked_hours += _time_to_hours(
101
+ schedule_blocked_hours += _time_to_decimal(
89
102
  min(schedule_end, event_end)
90
- ) - _time_to_hours(max(schedule_start, event_start))
103
+ ) - _time_to_decimal(max(schedule_start, event_start))
104
+ if args.verbose and schedule_blocked_hours > 0:
105
+ print(
106
+ f"Blocked hours on {date} between {schedule_start} and {schedule_end}: {schedule_blocked_hours}"
107
+ )
108
+ blocked_hours += schedule_blocked_hours
91
109
  available_hours -= blocked_hours
92
110
  return available_hours
93
111
 
@@ -141,7 +159,7 @@ def schedule_task_on_day(
141
159
  # we can schedule task on this day
142
160
  employable_hours = task_time_map[day_offset] - used_hours[day_offset]
143
161
  current_date = today + timedelta(days=day_offset)
144
- if wait and current_date < wait:
162
+ if wait and current_date <= wait:
145
163
  if args.verbose:
146
164
  print(f"Skipping date {current_date} because of wait date {wait}")
147
165
  return start_date, end_date, task_remaining_hours, is_starting
@@ -198,6 +216,7 @@ def get_calendars(config):
198
216
  all_day=calendar["event_all_day_is_blocking"],
199
217
  expiration=calendar["expiration"],
200
218
  verbose=args.verbose,
219
+ tz_name=calendar.get("timezone"),
201
220
  )
202
221
  calendar.sort(key=lambda e: e["start"])
203
222
  calendars.append(calendar)
@@ -243,10 +262,10 @@ def check_tasks_sequentially(tasks, config):
243
262
  f"Task {task['id']} ('{task['description']}') has no estimated time or time map: {estimated_hours}, {time_map_names}"
244
263
  )
245
264
  continue
246
- # if args.verbose:
247
- # print(
248
- # f"Checking task {task['id']} ('{task['description']}') with estimated hours: {estimated_hours} and wait date: {wait_date}"
249
- # )
265
+ if args.verbose:
266
+ print(
267
+ f"Checking task {task['id']} ('{task['description']}') with estimated hours: {estimated_hours} and wait date: {wait_date}"
268
+ )
250
269
 
251
270
  task_remaining_hours = estimated_hours
252
271
  task_time_map = get_long_range_time_map(
@@ -0,0 +1,153 @@
1
+ import hashlib
2
+ import json
3
+ from icalendar import Calendar
4
+ from datetime import datetime, timedelta
5
+ from dateutil.rrule import rruleset, rrulestr
6
+ import requests
7
+ from pathlib import Path
8
+ import appdirs
9
+ import zoneinfo
10
+
11
+ import time
12
+
13
+ CACHE = Path(appdirs.user_cache_dir("taskcheck"))
14
+
15
+
16
+ def fetch_ical_data(url):
17
+ response = requests.get(url)
18
+ response.raise_for_status()
19
+ return response.text
20
+
21
+
22
+ def parse_ical_events(ical_text, days_ahead, all_day, tz_name=None, verbose=False):
23
+ cal = Calendar.from_ical(ical_text)
24
+ today = datetime.now().date()
25
+ end_date = today + timedelta(days=days_ahead)
26
+
27
+ events = []
28
+ exceptions = set()
29
+
30
+ for component in cal.walk():
31
+ if component.name == "VEVENT":
32
+ event_start = component.get("dtstart").dt
33
+ event_end = component.get("dtend").dt
34
+ recurrence_rule = component.get("rrule")
35
+ recurrence_id = component.get("recurrence-id")
36
+
37
+ # skip all-day events if not requested
38
+ is_all_day = not hasattr(event_start, "hour") or event_start == event_end
39
+ if is_all_day:
40
+ if not all_day:
41
+ continue
42
+ else:
43
+ # all-day events should block since 00:00 and end at 23:59:59
44
+ event_start = datetime(
45
+ event_start.year,
46
+ event_start.month,
47
+ event_start.day,
48
+ 0,
49
+ 0,
50
+ 0,
51
+ 0,
52
+ )
53
+ event_end = event_start + timedelta(days=1) - timedelta(seconds=1)
54
+ if recurrence_id:
55
+ if end_date >= recurrence_id.dt.date() >= today:
56
+ # this was an occurrence of a recurring event
57
+ # but has been moved, let's record the exception that must be removed
58
+ exceptions.add(recurrence_id.dt.isoformat())
59
+
60
+ occurrences = rruleset()
61
+ if recurrence_rule:
62
+ # avoid handling rules that are already ended
63
+ # and don't create events beyond end_date
64
+ until = recurrence_rule.get("UNTIL")
65
+ if until is not None:
66
+ until = until[0].date()
67
+ if until < today:
68
+ continue
69
+ rrule = rrulestr(
70
+ str(recurrence_rule.to_ical(), "utf-8"),
71
+ dtstart=event_start,
72
+ )
73
+ occurrences.rrule(rrule) # type: ignore
74
+ # exceptions to recurring events
75
+ excdates = component.get("exdate")
76
+ if excdates is not None:
77
+ if isinstance(excdates, list):
78
+ # concatenate all the .dts into one list
79
+ excdates = [e for exdate in excdates for e in exdate.dts]
80
+ else:
81
+ excdates = excdates.dts
82
+ for excdate in excdates:
83
+ if end_date >= excdate.dt.date() >= today:
84
+ exceptions.add(excdate.dt.isoformat())
85
+ else:
86
+ # if event is not recurring, add it as a single event
87
+ if event_start.date() >= today and event_end.date() <= end_date:
88
+ occurrences.rdate(event_start)
89
+
90
+ for occurrence in occurrences:
91
+ if tz_name is not None:
92
+ occurrence = occurrence.astimezone(zoneinfo.ZoneInfo(tz_name))
93
+ end = occurrence + (event_end - event_start)
94
+ if occurrence.date() >= today and end.date() <= end_date:
95
+ events.append(
96
+ {
97
+ # "summary": component.get("summary"),
98
+ "start": occurrence.isoformat(),
99
+ "end": end.isoformat(),
100
+ }
101
+ )
102
+ elif end.date() > end_date:
103
+ break
104
+
105
+ with_exc_len = len(events)
106
+ events = [event for event in events if event["start"] not in exceptions]
107
+ if verbose:
108
+ print(
109
+ f"Removed {with_exc_len - len(events)} events that are exceptions to recurring events"
110
+ )
111
+ events.sort(key=lambda x: x["start"])
112
+ return events
113
+
114
+
115
+ def get_cache_filename(url):
116
+ hash_object = hashlib.sha256(url.encode())
117
+ return CACHE / f"{hash_object.hexdigest()}.json"
118
+
119
+
120
+ def ical_to_dict(
121
+ url, days_ahead=7, all_day=False, expiration=0.25, verbose=False, tz_name=None
122
+ ):
123
+ CACHE.mkdir(exist_ok=True, parents=True)
124
+ cache_file = CACHE / get_cache_filename(url)
125
+ current_time = time.time()
126
+
127
+ # Check if cache file exists and is not expired
128
+ if cache_file.exists():
129
+ cache_mtime = cache_file.stat().st_mtime
130
+ if current_time - cache_mtime < expiration * 3600:
131
+ with open(cache_file, "r") as f:
132
+ events = json.load(f)
133
+ if verbose:
134
+ print("Loaded events from cache")
135
+ return events
136
+
137
+ # Fetch and parse iCal data
138
+ ttt = time.time()
139
+ ical_text = fetch_ical_data(url)
140
+ if verbose:
141
+ print("Time taken to fetch ical data: ", time.time() - ttt)
142
+ ttt = time.time()
143
+ events = parse_ical_events(
144
+ ical_text, days_ahead, all_day, tz_name=tz_name, verbose=verbose
145
+ )
146
+ if verbose:
147
+ print("Time taken to parse ical data: ", time.time() - ttt)
148
+
149
+ # Save events to cache
150
+ with open(cache_file, "w") as f:
151
+ json.dump(events, f)
152
+
153
+ return events
@@ -1,13 +1,12 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: taskcheck
3
- Version: 0.1.0.post1
3
+ Version: 0.1.2
4
4
  Summary: Automatic scheduler for taskwarrior
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
7
  License-File: LICENSE
8
8
  Requires-Dist: appdirs>=1.4.4
9
9
  Requires-Dist: icalendar>=6.0.1
10
- Requires-Dist: requests-cache>=1.2.1
11
10
  Requires-Dist: requests>=2.32.3
12
11
 
13
12
 
@@ -72,6 +71,7 @@ days_ahead = 1000 # how far go with the schedule (lower values make the computat
72
71
  [calendars.1]
73
72
  url = "https://your/url/to/calendar.ics"
74
73
  expiration = 0.08 # in hours (0.08 hours =~ 5 minutes)
74
+ timezone = "Europe/Rome" # if set, force timezone for this calendar; timezone values are TZ identifiers (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)
75
75
 
76
76
  [calendars.holidays]
77
77
  url = "https://www.officeholidays.com/ics-clean/italy/milan"
@@ -92,16 +92,21 @@ uda.completion_date.type = date
92
92
  uda.completion_date.label = Expected Completion Date
93
93
 
94
94
  # Adjust urgency for taskcheck
95
- urgency.uda.estimated.coefficient = 1.0 # Increase urgency for tasks with longer estimated durations
96
- urgency.waiting.coefficient = 0.0 # you will use `wait:` to avoid scheduling tasks too much sooner
95
+ # you will use `wait:` to avoid scheduling tasks too much sooner
96
+ urgency.waiting.coefficient = 0.0
97
+ # you will use `active:` to prioritize tasks already started even if the estimated amount is decreased
98
+ urgency.active.coefficient = 3.0
99
+ urgency.scheduled.coefficient = 0.0 # tasks that are not scheduled may still be urgent
97
100
 
98
101
  # we need to be able to modify recurrent tasks without prompt
99
102
  recurrence.confirmation=no
100
103
 
101
104
  # suggested
102
- urgency.inherit=1
105
+ urgency.inherit=1 # use dependencies to automatically prioritize tasks
103
106
  urgency.blocking.coefficient=2
104
107
 
108
+ # give urgeny values to the estimated time
109
+ # this is ln(estimation)
105
110
  urgency.uda.estimated.PT1H.coefficient = 1
106
111
  urgency.uda.estimated.PT2H.coefficient = 2.32
107
112
  urgency.uda.estimated.PT3H.coefficient = 3.67
@@ -138,11 +143,15 @@ urgency.uda.estimated.PT33H.coefficient = 11.73
138
143
  urgency.uda.estimated.PT34H.coefficient = 11.81
139
144
  urgency.uda.estimated.PT35H.coefficient = 11.89
140
145
  urgency.uda.estimated.PT36H.coefficient = 11.96
146
+ # maximum duration is 36 hours
147
+
141
148
  ```
142
149
 
143
150
  ## CLI Options
144
151
 
145
152
  ```
153
+
146
154
  -v, --verbose: increase output verbosity
147
155
  -t, --today: how much time has been already used today (default: 0)
156
+
148
157
  ```
@@ -1,4 +1,3 @@
1
1
  appdirs>=1.4.4
2
2
  icalendar>=6.0.1
3
- requests-cache>=1.2.1
4
3
  requests>=2.32.3
@@ -1,88 +0,0 @@
1
- from icalendar import Calendar
2
- from datetime import datetime, timedelta
3
- from dateutil.rrule import rruleset, rrulestr
4
- from requests_cache import CachedSession
5
-
6
- import time
7
-
8
-
9
- def fetch_ical_data(url, expiration, verbose):
10
- session = CachedSession(
11
- "taskcheck",
12
- use_cache_dir=True, # Save files in the default user cache dir
13
- cache_control=True, # Use Cache-Control response headers for expiration, if available
14
- urls_expire_after={url: expiration}, # Set expiration time
15
- allowable_codes=[
16
- 200,
17
- ],
18
- allowable_methods=["GET", "POST"], # Cache whatever HTTP methods you want
19
- stale_if_error=True, # In case of request errors, use stale cache data if possible
20
- )
21
- ttt = time.time()
22
- response = session.get(url)
23
- if verbose:
24
- print("Time taken to fetch ical data: ", time.time() - ttt)
25
- response.raise_for_status()
26
- return response.text
27
-
28
-
29
- def parse_ical_events(ical_text, days_ahead, all_day):
30
- cal = Calendar.from_ical(ical_text)
31
- today = datetime.now().date()
32
- end_date = today + timedelta(days=days_ahead)
33
-
34
- events = []
35
-
36
- for component in cal.walk():
37
- if component.name == "VEVENT":
38
- event_start = component.get("dtstart").dt
39
- event_end = component.get("dtend").dt
40
- recurrence_rule = component.get("rrule")
41
- recurrence_id = component.get("recurrence-id")
42
-
43
- # skip all-day events if not requested
44
- is_all_day = not hasattr(event_start, "hour") or event_start == event_end
45
- if is_all_day:
46
- if not all_day:
47
- continue
48
- else:
49
- # all-day events should block since 00:00 and end at 23:59:59
50
- event_start = datetime(
51
- event_start.year,
52
- event_start.month,
53
- event_start.day,
54
- 0,
55
- 0,
56
- 0,
57
- 0,
58
- )
59
- event_end = event_start + timedelta(days=1) - timedelta(seconds=1)
60
- if recurrence_id:
61
- continue # Ignore recurrence exceptions here for simplicity
62
-
63
- event_dates = rruleset()
64
- if recurrence_rule:
65
- rrule = rrulestr(
66
- str(recurrence_rule.to_ical(), "utf-8"), dtstart=event_start
67
- )
68
- event_dates.rrule(rrule) # type: ignore
69
- else:
70
- event_dates.rdate(event_start)
71
-
72
- for event_date in event_dates:
73
- if today <= event_date.date() <= end_date:
74
- events.append(
75
- {
76
- "summary": component.get("summary"),
77
- "start": event_date,
78
- "end": event_end,
79
- }
80
- )
81
-
82
- return events
83
-
84
-
85
- def ical_to_dict(url, days_ahead=7, all_day=False, expiration=0.25, verbose=False):
86
- ical_text = fetch_ical_data(url, expiration * 3600, verbose)
87
- events = parse_ical_events(ical_text, days_ahead, all_day)
88
- return events
File without changes
File without changes