tsk-todo 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tsk/__init__.py ADDED
File without changes
tsk/cli.py ADDED
@@ -0,0 +1,202 @@
1
+ import sys
2
+ from collections.abc import Callable, Sequence
3
+ from dataclasses import replace
4
+ from datetime import UTC, datetime
5
+ from typing import TextIO
6
+
7
+ from tsk.dates import describe
8
+ from tsk.model import Task, TaskNotFound, Tasks
9
+ from tsk.parse import (
10
+ Add,
11
+ Attributes,
12
+ Command,
13
+ Done,
14
+ Edit,
15
+ Help,
16
+ Listing,
17
+ Note,
18
+ ParseError,
19
+ Remove,
20
+ Show,
21
+ Version,
22
+ parse,
23
+ )
24
+ from tsk.query import Query
25
+ from tsk.render import Renderer, ink_for
26
+ from tsk.store import Store, StoreError, default_path
27
+
28
+ HELP = """tsk, a small task manager
29
+
30
+ usage
31
+ tsk list pending tasks
32
+ tsk add <text> [attrs] add a task
33
+ tsk done <id>... complete tasks
34
+ tsk rm <id>... delete tasks
35
+ tsk edit <id> <attrs> change a task
36
+ tsk note <id> <text> attach a note
37
+ tsk show <id> show one task in full
38
+ tsk list [filters] list tasks
39
+
40
+ attributes
41
+ @project set the project, bare @ clears it
42
+ +tag add a tag
43
+ -tag remove a tag, when editing
44
+ due:<when> set a due date, bare due: clears it
45
+ p:h|m|l set priority
46
+ every:<repeat> reschedule on completion
47
+
48
+ filters
49
+ @project +tag -tag match on project and tags
50
+ due:<when> due on or before a date
51
+ overdue past due
52
+ done completed tasks
53
+ all every task
54
+ <word> search descriptions and notes
55
+
56
+ when
57
+ today, tomorrow, yesterday
58
+ monday to sunday, or mon to sun
59
+ 3d, 2w, 1m, 1y
60
+ eow, eom, eoy
61
+ 2026-08-01
62
+
63
+ repeat
64
+ daily, weekdays, weekly, monthly, yearly
65
+
66
+ examples
67
+ tsk add water the plants @home +garden due:tomorrow every:weekly
68
+ tsk add fix the login bug @work p:h due:friday
69
+ tsk +garden
70
+ tsk done 3
71
+ tsk edit 3 due: p:l"""
72
+
73
+
74
+ def _utcnow() -> datetime:
75
+ return datetime.now(UTC)
76
+
77
+
78
+ class App:
79
+ def __init__(
80
+ self,
81
+ store: Store,
82
+ out: TextIO,
83
+ err: TextIO,
84
+ now: Callable[[], datetime] = _utcnow,
85
+ ) -> None:
86
+ self._store = store
87
+ self._out = out
88
+ self._err = err
89
+ self._clock = now
90
+ self._today = now().astimezone().date()
91
+ self._renderer = Renderer(ink_for(out), today=self._today)
92
+
93
+ def run(self, argv: Sequence[str]) -> int:
94
+ try:
95
+ return self._execute(parse(argv, today=self._today))
96
+ except (ParseError, StoreError) as error:
97
+ return self._fail(str(error))
98
+ except TaskNotFound as error:
99
+ return self._fail(f"no task with id {error.args[0]}")
100
+
101
+ def _execute(self, command: Command) -> int:
102
+ match command:
103
+ case Help():
104
+ return self._emit(HELP)
105
+ case Version():
106
+ return self._emit(f"tsk {_installed_version()}")
107
+ case Listing(query):
108
+ return self._list(query)
109
+ case Show(task_id):
110
+ return self._show(task_id)
111
+ case Add(attributes):
112
+ return self._add(attributes)
113
+ case Done(ids):
114
+ return self._done(ids)
115
+ case Remove(ids):
116
+ return self._remove(ids)
117
+ case Edit(task_id, attributes):
118
+ return self._edit(task_id, attributes)
119
+ case Note(task_id, text):
120
+ return self._note(task_id, text)
121
+
122
+ def _list(self, query: Query) -> int:
123
+ tasks = self._store.read()
124
+ found = sorted(
125
+ (task for task in tasks if query.matches(task, self._today)),
126
+ key=Task.ordering,
127
+ )
128
+ return self._emit(self._renderer.table(found))
129
+
130
+ def _show(self, task_id: int) -> int:
131
+ return self._emit(self._renderer.detail(self._store.read().find(task_id)))
132
+
133
+ def _add(self, attributes: Attributes) -> int:
134
+ tasks = self._store.read()
135
+ blank = Task(id=tasks.next_id(), description="", created=self._clock())
136
+ task = attributes.apply(blank)
137
+ tasks.append(task)
138
+ self._store.write(tasks)
139
+ return self._emit(f"added {task.id}: {task.description}")
140
+
141
+ def _done(self, ids: Sequence[int]) -> int:
142
+ tasks = self._store.read()
143
+ lines: list[str] = []
144
+ for task_id in ids:
145
+ done, successor = tasks.find(task_id).complete(
146
+ now=self._clock(), today=self._today
147
+ )
148
+ tasks.update(done)
149
+ lines.append(f"done {done.id}: {done.description}")
150
+ if successor is not None:
151
+ lines.append(self._reschedule(successor, tasks))
152
+ self._store.write(tasks)
153
+ return self._emit("\n".join(lines))
154
+
155
+ def _reschedule(self, successor: Task, tasks: Tasks) -> str:
156
+ scheduled = replace(successor, id=tasks.next_id())
157
+ tasks.append(scheduled)
158
+ when = describe(scheduled.due, self._today) if scheduled.due else "no date"
159
+ return f" repeats as {scheduled.id}, due {when}"
160
+
161
+ def _remove(self, ids: Sequence[int]) -> int:
162
+ tasks = self._store.read()
163
+ lines: list[str] = []
164
+ for task_id in ids:
165
+ task = tasks.find(task_id)
166
+ tasks.discard(task_id)
167
+ lines.append(f"removed {task.id}: {task.description}")
168
+ self._store.write(tasks)
169
+ return self._emit("\n".join(lines))
170
+
171
+ def _edit(self, task_id: int, attributes: Attributes) -> int:
172
+ tasks = self._store.read()
173
+ task = attributes.apply(tasks.find(task_id))
174
+ tasks.update(task)
175
+ self._store.write(tasks)
176
+ return self._emit(f"updated {task.id}: {task.description}")
177
+
178
+ def _note(self, task_id: int, text: str) -> int:
179
+ tasks = self._store.read()
180
+ task = tasks.find(task_id).annotate(text)
181
+ tasks.update(task)
182
+ self._store.write(tasks)
183
+ return self._emit(f"noted on {task.id}: {task.description}")
184
+
185
+ def _emit(self, message: str) -> int:
186
+ print(message, file=self._out)
187
+ return 0
188
+
189
+ def _fail(self, message: str) -> int:
190
+ print(f"tsk: {message}", file=self._err)
191
+ return 1
192
+
193
+
194
+ def _installed_version() -> str:
195
+ from importlib.metadata import version
196
+
197
+ return version("tsk-todo")
198
+
199
+
200
+ def main() -> int:
201
+ app = App(Store(default_path()), out=sys.stdout, err=sys.stderr)
202
+ return app.run(sys.argv[1:])
tsk/dates.py ADDED
@@ -0,0 +1,122 @@
1
+ import calendar
2
+ import re
3
+ from collections.abc import Callable
4
+ from datetime import date, timedelta
5
+
6
+ _ISO_DATE = re.compile(r"\d{4}-\d{2}-\d{2}\Z")
7
+ _OFFSET = re.compile(r"(\d+)([dwmy])\Z")
8
+ _WEEKDAY_NAMES = (
9
+ "monday",
10
+ "tuesday",
11
+ "wednesday",
12
+ "thursday",
13
+ "friday",
14
+ "saturday",
15
+ "sunday",
16
+ )
17
+ _DAYS_PER_YEAR = 365
18
+ _SATURDAY = 5
19
+
20
+
21
+ class DateError(ValueError):
22
+ """Raised when a phrase cannot be read as a date."""
23
+
24
+
25
+ def parse_date(phrase: str, today: date) -> date:
26
+ text = phrase.strip().lower()
27
+ for resolve in (_from_iso, _from_name, _from_weekday, _from_offset):
28
+ when = resolve(text, today)
29
+ if when is not None:
30
+ return when
31
+ raise DateError(f"not a date: {phrase!r}")
32
+
33
+
34
+ def describe(when: date, today: date) -> str:
35
+ days = (when - today).days
36
+ if days == 0:
37
+ return "today"
38
+ if days == 1:
39
+ return "tomorrow"
40
+ if days == -1:
41
+ return "yesterday"
42
+ if abs(days) > _DAYS_PER_YEAR:
43
+ return when.isoformat()
44
+ span = _span(abs(days))
45
+ return f"in {span}" if days > 0 else f"{span} ago"
46
+
47
+
48
+ def _span(days: int) -> str:
49
+ if days < 7:
50
+ return _counted(days, "day")
51
+ if days < 30:
52
+ return _counted(days // 7, "week")
53
+ return _counted(days // 30, "month")
54
+
55
+
56
+ def _counted(count: int, unit: str) -> str:
57
+ return f"{count} {unit}" if count == 1 else f"{count} {unit}s"
58
+
59
+
60
+ def _from_iso(text: str, today: date) -> date | None:
61
+ if not _ISO_DATE.match(text):
62
+ return None
63
+ try:
64
+ return date.fromisoformat(text)
65
+ except ValueError:
66
+ return None
67
+
68
+
69
+ def _from_name(text: str, today: date) -> date | None:
70
+ resolve = _NAMED.get(text)
71
+ return resolve(today) if resolve else None
72
+
73
+
74
+ def _from_weekday(text: str, today: date) -> date | None:
75
+ for index, name in enumerate(_WEEKDAY_NAMES):
76
+ if text in (name, name[:3]):
77
+ return today + timedelta(days=(index - today.weekday()) % 7 or 7)
78
+ return None
79
+
80
+
81
+ def _from_offset(text: str, today: date) -> date | None:
82
+ match = _OFFSET.match(text)
83
+ if match is None:
84
+ return None
85
+ count, unit = int(match[1]), match[2]
86
+ if unit == "d":
87
+ return today + timedelta(days=count)
88
+ if unit == "w":
89
+ return today + timedelta(weeks=count)
90
+ return add_months(today, count * 12 if unit == "y" else count)
91
+
92
+
93
+ def add_months(start: date, months: int) -> date:
94
+ month_index = start.month - 1 + months
95
+ year = start.year + month_index // 12
96
+ month = month_index % 12 + 1
97
+ return date(year, month, min(start.day, calendar.monthrange(year, month)[1]))
98
+
99
+
100
+ def _end_of_week(today: date) -> date:
101
+ return today + timedelta(days=6 - today.weekday())
102
+
103
+
104
+ def _end_of_month(today: date) -> date:
105
+ return today.replace(day=calendar.monthrange(today.year, today.month)[1])
106
+
107
+
108
+ def next_weekday(after: date) -> date:
109
+ when = after + timedelta(days=1)
110
+ while when.weekday() >= _SATURDAY:
111
+ when += timedelta(days=1)
112
+ return when
113
+
114
+
115
+ _NAMED: dict[str, Callable[[date], date]] = {
116
+ "today": lambda today: today,
117
+ "tomorrow": lambda today: today + timedelta(days=1),
118
+ "yesterday": lambda today: today - timedelta(days=1),
119
+ "eow": _end_of_week,
120
+ "eom": _end_of_month,
121
+ "eoy": lambda today: today.replace(month=12, day=31),
122
+ }
tsk/model.py ADDED
@@ -0,0 +1,137 @@
1
+ from collections.abc import Callable, Iterable, Iterator
2
+ from dataclasses import dataclass, field, replace
3
+ from datetime import UTC, date, datetime, timedelta
4
+ from enum import StrEnum
5
+
6
+ from tsk.dates import add_months, next_weekday
7
+
8
+
9
+ class Status(StrEnum):
10
+ PENDING = "pending"
11
+ DONE = "done"
12
+
13
+
14
+ class Priority(StrEnum):
15
+ HIGH = "high"
16
+ MEDIUM = "medium"
17
+ LOW = "low"
18
+
19
+ @property
20
+ def rank(self) -> int:
21
+ return list(Priority).index(self)
22
+
23
+
24
+ class Recurrence(StrEnum):
25
+ DAILY = "daily"
26
+ WEEKDAYS = "weekdays"
27
+ WEEKLY = "weekly"
28
+ MONTHLY = "monthly"
29
+ YEARLY = "yearly"
30
+
31
+ def after(self, when: date) -> date:
32
+ return _ADVANCE[self](when)
33
+
34
+ def upcoming(self, after: date, today: date) -> date:
35
+ when = self.after(after)
36
+ while when <= today:
37
+ when = self.after(when)
38
+ return when
39
+
40
+
41
+ _ADVANCE: dict[Recurrence, Callable[[date], date]] = {
42
+ Recurrence.DAILY: lambda when: when + timedelta(days=1),
43
+ Recurrence.WEEKDAYS: next_weekday,
44
+ Recurrence.WEEKLY: lambda when: when + timedelta(weeks=1),
45
+ Recurrence.MONTHLY: lambda when: add_months(when, 1),
46
+ Recurrence.YEARLY: lambda when: add_months(when, 12),
47
+ }
48
+
49
+
50
+ def _utcnow() -> datetime:
51
+ return datetime.now(UTC)
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class Task:
56
+ id: int
57
+ description: str
58
+ status: Status = Status.PENDING
59
+ project: str | None = None
60
+ tags: frozenset[str] = frozenset()
61
+ due: date | None = None
62
+ priority: Priority | None = None
63
+ every: Recurrence | None = None
64
+ notes: tuple[str, ...] = ()
65
+ created: datetime = field(default_factory=_utcnow)
66
+ completed: datetime | None = None
67
+
68
+ def complete(self, now: datetime, today: date) -> tuple["Task", "Task | None"]:
69
+ done = replace(self, status=Status.DONE, completed=now)
70
+ return done, self._successor(now, today)
71
+
72
+ def annotate(self, note: str) -> "Task":
73
+ return replace(self, notes=(*self.notes, note))
74
+
75
+ def is_overdue(self, today: date) -> bool:
76
+ return (
77
+ self.status is Status.PENDING and self.due is not None and self.due < today
78
+ )
79
+
80
+ def ordering(self) -> tuple[date, int, int]:
81
+ return (
82
+ self.due or date.max,
83
+ self.priority.rank if self.priority else len(Priority),
84
+ self.id,
85
+ )
86
+
87
+ def _successor(self, now: datetime, today: date) -> "Task | None":
88
+ if self.every is None or self.due is None:
89
+ return None
90
+ return replace(
91
+ self,
92
+ due=self.every.upcoming(self.due, today),
93
+ notes=(),
94
+ created=now,
95
+ completed=None,
96
+ )
97
+
98
+
99
+ class TaskNotFound(LookupError):
100
+ """Raised when no task carries the given id."""
101
+
102
+
103
+ class Tasks:
104
+ def __init__(self, items: Iterable[Task] = (), issued: int = 0) -> None:
105
+ self._items = list(items)
106
+ self._issued = max([issued, *(task.id for task in self._items)])
107
+
108
+ def __iter__(self) -> Iterator[Task]:
109
+ return iter(self._items)
110
+
111
+ def __len__(self) -> int:
112
+ return len(self._items)
113
+
114
+ @property
115
+ def issued(self) -> int:
116
+ return self._issued
117
+
118
+ def next_id(self) -> int:
119
+ self._issued += 1
120
+ return self._issued
121
+
122
+ def find(self, task_id: int) -> Task:
123
+ for task in self._items:
124
+ if task.id == task_id:
125
+ return task
126
+ raise TaskNotFound(task_id)
127
+
128
+ def append(self, task: Task) -> None:
129
+ self._items.append(task)
130
+
131
+ def update(self, task: Task) -> None:
132
+ self.find(task.id)
133
+ self._items = [task if item.id == task.id else item for item in self._items]
134
+
135
+ def discard(self, task_id: int) -> None:
136
+ self.find(task_id)
137
+ self._items = [item for item in self._items if item.id != task_id]
tsk/parse.py ADDED
@@ -0,0 +1,272 @@
1
+ from collections.abc import Callable, Mapping, Sequence
2
+ from dataclasses import dataclass, field, replace
3
+ from datetime import date
4
+ from typing import Any
5
+
6
+ from tsk.dates import DateError, parse_date
7
+ from tsk.model import Priority, Recurrence, Status, Task
8
+ from tsk.query import Query
9
+
10
+
11
+ class ParseError(ValueError):
12
+ """Raised when a command line cannot be understood."""
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Attributes:
17
+ words: tuple[str, ...] = ()
18
+ changes: Mapping[str, Any] = field(default_factory=dict)
19
+ add_tags: frozenset[str] = frozenset()
20
+ remove_tags: frozenset[str] = frozenset()
21
+
22
+ def apply(self, task: Task) -> Task:
23
+ updates = dict(self.changes)
24
+ if self.words:
25
+ updates["description"] = " ".join(self.words)
26
+ tags = (task.tags | self.add_tags) - self.remove_tags
27
+ return replace(task, tags=tags, **updates)
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Add:
32
+ attributes: Attributes
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Listing:
37
+ query: Query
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Done:
42
+ ids: tuple[int, ...]
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Remove:
47
+ ids: tuple[int, ...]
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Edit:
52
+ id: int
53
+ attributes: Attributes
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Note:
58
+ id: int
59
+ text: str
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class Show:
64
+ id: int
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Help:
69
+ pass
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class Version:
74
+ pass
75
+
76
+
77
+ Command = Add | Listing | Done | Remove | Edit | Note | Show | Help | Version
78
+
79
+ _HELP_WORDS = frozenset({"help", "--help", "-h"})
80
+ _VERSION_WORDS = frozenset({"version", "--version"})
81
+
82
+
83
+ def parse(argv: Sequence[str], today: date) -> Command:
84
+ if not argv:
85
+ return Listing(Query())
86
+ head, *rest = argv
87
+ if head in _HELP_WORDS:
88
+ return Help()
89
+ if head in _VERSION_WORDS:
90
+ return Version()
91
+ verb = _VERBS.get(head)
92
+ if verb is not None:
93
+ return verb(rest, today)
94
+ if len(argv) == 1 and _is_id(head):
95
+ return Show(int(head))
96
+ return _listing(argv, today)
97
+
98
+
99
+ def _add(tokens: Sequence[str], today: date) -> Command:
100
+ attributes = _attributes(tokens, today, removals=False)
101
+ if not attributes.words:
102
+ raise ParseError("add needs a description")
103
+ return Add(attributes)
104
+
105
+
106
+ def _done(tokens: Sequence[str], today: date) -> Command:
107
+ return Done(_ids(tokens, "done"))
108
+
109
+
110
+ def _remove(tokens: Sequence[str], today: date) -> Command:
111
+ return Remove(_ids(tokens, "rm"))
112
+
113
+
114
+ def _edit(tokens: Sequence[str], today: date) -> Command:
115
+ task_id, rest = _leading_id(tokens, "edit")
116
+ if not rest:
117
+ raise ParseError("edit needs something to change")
118
+ return Edit(task_id, _attributes(rest, today, removals=True))
119
+
120
+
121
+ def _note(tokens: Sequence[str], today: date) -> Command:
122
+ task_id, rest = _leading_id(tokens, "note")
123
+ if not rest:
124
+ raise ParseError("note needs some text")
125
+ return Note(task_id, " ".join(rest))
126
+
127
+
128
+ def _show(tokens: Sequence[str], today: date) -> Command:
129
+ ids = _ids(tokens, "show")
130
+ if len(ids) > 1:
131
+ raise ParseError("show takes a single task id")
132
+ return Show(ids[0])
133
+
134
+
135
+ def _listing(tokens: Sequence[str], today: date) -> Command:
136
+ status: Status | None = Status.PENDING
137
+ tags: set[str] = set()
138
+ without_tags: set[str] = set()
139
+ terms: list[str] = []
140
+ project: str | None = None
141
+ due_by: date | None = None
142
+ overdue = False
143
+
144
+ for token in tokens:
145
+ key, separator, value = token.partition(":")
146
+ if token == "done":
147
+ status = Status.DONE
148
+ elif token == "all":
149
+ status = None
150
+ elif token == "overdue":
151
+ overdue = True
152
+ elif _prefixed(token, "@"):
153
+ project = token[1:]
154
+ elif _prefixed(token, "+"):
155
+ tags.add(token[1:])
156
+ elif _prefixed(token, "-"):
157
+ without_tags.add(token[1:])
158
+ elif separator and key == "due":
159
+ due_by = _read_due(value, today)
160
+ else:
161
+ terms.append(token)
162
+
163
+ return Listing(
164
+ Query(
165
+ status=status,
166
+ tags=frozenset(tags),
167
+ without_tags=frozenset(without_tags),
168
+ project=project,
169
+ due_by=due_by,
170
+ overdue=overdue,
171
+ terms=tuple(terms),
172
+ )
173
+ )
174
+
175
+
176
+ def _attributes(tokens: Sequence[str], today: date, *, removals: bool) -> Attributes:
177
+ words: list[str] = []
178
+ changes: dict[str, Any] = {}
179
+ add_tags: set[str] = set()
180
+ remove_tags: set[str] = set()
181
+
182
+ for token in tokens:
183
+ key, separator, value = token.partition(":")
184
+ if token == "@":
185
+ changes["project"] = None
186
+ elif _prefixed(token, "@"):
187
+ changes["project"] = token[1:]
188
+ elif _prefixed(token, "+"):
189
+ add_tags.add(token[1:])
190
+ elif removals and _prefixed(token, "-"):
191
+ remove_tags.add(token[1:])
192
+ elif separator and key in _ATTRIBUTES:
193
+ name, read = _ATTRIBUTES[key]
194
+ changes[name] = read(value, today)
195
+ else:
196
+ words.append(token)
197
+
198
+ return Attributes(
199
+ tuple(words), changes, frozenset(add_tags), frozenset(remove_tags)
200
+ )
201
+
202
+
203
+ def _prefixed(token: str, marker: str) -> bool:
204
+ return len(token) > 1 and token.startswith(marker)
205
+
206
+
207
+ def _is_id(token: str) -> bool:
208
+ return token.isdigit()
209
+
210
+
211
+ def _ids(tokens: Sequence[str], verb: str) -> tuple[int, ...]:
212
+ if not tokens:
213
+ raise ParseError(f"{verb} needs a task id")
214
+ for token in tokens:
215
+ if not _is_id(token):
216
+ raise ParseError(f"not a task id: {token!r}")
217
+ return tuple(int(token) for token in tokens)
218
+
219
+
220
+ def _leading_id(tokens: Sequence[str], verb: str) -> tuple[int, Sequence[str]]:
221
+ if not tokens:
222
+ raise ParseError(f"{verb} needs a task id")
223
+ head, *rest = tokens
224
+ if not _is_id(head):
225
+ raise ParseError(f"not a task id: {head!r}")
226
+ return int(head), rest
227
+
228
+
229
+ def _read_due(value: str, today: date) -> date | None:
230
+ if not value:
231
+ return None
232
+ try:
233
+ return parse_date(value, today)
234
+ except DateError as error:
235
+ raise ParseError(str(error)) from error
236
+
237
+
238
+ def _read_priority(value: str, today: date) -> Priority | None:
239
+ if not value:
240
+ return None
241
+ for priority in Priority:
242
+ if value in (priority.value, priority.value[0]):
243
+ return priority
244
+ choices = ", ".join(item.value[0] for item in Priority)
245
+ raise ParseError(f"not a priority: {value!r} (use {choices})")
246
+
247
+
248
+ def _read_recurrence(value: str, today: date) -> Recurrence | None:
249
+ if not value:
250
+ return None
251
+ try:
252
+ return Recurrence(value)
253
+ except ValueError:
254
+ choices = ", ".join(item.value for item in Recurrence)
255
+ raise ParseError(f"not a repeat: {value!r} (use {choices})") from None
256
+
257
+
258
+ _ATTRIBUTES: dict[str, tuple[str, Callable[[str, date], Any]]] = {
259
+ "due": ("due", _read_due),
260
+ "p": ("priority", _read_priority),
261
+ "every": ("every", _read_recurrence),
262
+ }
263
+
264
+ _VERBS: dict[str, Callable[[Sequence[str], date], Command]] = {
265
+ "add": _add,
266
+ "list": _listing,
267
+ "done": _done,
268
+ "rm": _remove,
269
+ "edit": _edit,
270
+ "note": _note,
271
+ "show": _show,
272
+ }
tsk/query.py ADDED
@@ -0,0 +1,47 @@
1
+ from dataclasses import dataclass
2
+ from datetime import date
3
+
4
+ from tsk.model import Status, Task
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class Query:
9
+ status: Status | None = Status.PENDING
10
+ tags: frozenset[str] = frozenset()
11
+ without_tags: frozenset[str] = frozenset()
12
+ project: str | None = None
13
+ due_by: date | None = None
14
+ overdue: bool = False
15
+ terms: tuple[str, ...] = ()
16
+
17
+ def matches(self, task: Task, today: date) -> bool:
18
+ return all(
19
+ test(task, today)
20
+ for test in (
21
+ self._status_matches,
22
+ self._tags_match,
23
+ self._project_matches,
24
+ self._dates_match,
25
+ self._terms_match,
26
+ )
27
+ )
28
+
29
+ def _status_matches(self, task: Task, today: date) -> bool:
30
+ return self.status is None or task.status is self.status
31
+
32
+ def _tags_match(self, task: Task, today: date) -> bool:
33
+ return self.tags <= task.tags and not (self.without_tags & task.tags)
34
+
35
+ def _project_matches(self, task: Task, today: date) -> bool:
36
+ return self.project is None or task.project == self.project
37
+
38
+ def _dates_match(self, task: Task, today: date) -> bool:
39
+ if self.overdue and not task.is_overdue(today):
40
+ return False
41
+ if self.due_by is None:
42
+ return True
43
+ return task.due is not None and task.due <= self.due_by
44
+
45
+ def _terms_match(self, task: Task, today: date) -> bool:
46
+ haystack = " ".join((task.description, *task.notes)).casefold()
47
+ return all(term.casefold() in haystack for term in self.terms)
tsk/render.py ADDED
@@ -0,0 +1,128 @@
1
+ import os
2
+ from collections.abc import Iterator, Sequence
3
+ from datetime import date
4
+ from typing import TextIO
5
+
6
+ from tsk.dates import describe
7
+ from tsk.model import Priority, Status, Task
8
+
9
+ _CODES = {
10
+ "bold": "1",
11
+ "dim": "2",
12
+ "red": "31",
13
+ "green": "32",
14
+ "yellow": "33",
15
+ "cyan": "36",
16
+ "magenta": "35",
17
+ }
18
+ _PRIORITY_STYLES = {
19
+ Priority.HIGH: "red",
20
+ Priority.MEDIUM: "yellow",
21
+ Priority.LOW: "dim",
22
+ }
23
+ _GAP = " "
24
+ _LABEL_WIDTH = 11
25
+
26
+
27
+ class Ink:
28
+ def __init__(self, *, enabled: bool) -> None:
29
+ self._enabled = enabled
30
+
31
+ def paint(self, text: str, style: str) -> str:
32
+ if not self._enabled or not text.strip():
33
+ return text
34
+ return f"\x1b[{_CODES[style]}m{text}\x1b[0m"
35
+
36
+
37
+ def ink_for(stream: TextIO) -> Ink:
38
+ return Ink(enabled=stream.isatty() and "NO_COLOR" not in os.environ)
39
+
40
+
41
+ class _Layout:
42
+ def __init__(self, tasks: Sequence[Task], today: date) -> None:
43
+ spans = [describe(task.due, today) for task in tasks if task.due]
44
+ self.id_width = max(len(str(task.id)) for task in tasks)
45
+ self.due_width = max((len(span) for span in spans), default=0)
46
+ self.show_due = bool(spans)
47
+ self.show_priority = any(task.priority for task in tasks)
48
+
49
+
50
+ class Renderer:
51
+ def __init__(self, ink: Ink, today: date) -> None:
52
+ self._ink = ink
53
+ self._today = today
54
+
55
+ def table(self, tasks: Sequence[Task]) -> str:
56
+ if not tasks:
57
+ return "nothing to do"
58
+ layout = _Layout(tasks, self._today)
59
+ return "\n".join(self._row(task, layout) for task in tasks)
60
+
61
+ def detail(self, task: Task) -> str:
62
+ lines = [f"{self._ink.paint(str(task.id), 'dim')}{_GAP}{task.description}", ""]
63
+ lines.extend(
64
+ f"{_GAP}{self._ink.paint(label.ljust(_LABEL_WIDTH), 'dim')}{value}"
65
+ for label, value in self._facts(task)
66
+ )
67
+ if task.notes:
68
+ lines.extend(["", f"{_GAP}{self._ink.paint('notes', 'dim')}"])
69
+ lines.extend(f"{_GAP} {note}" for note in task.notes)
70
+ return "\n".join(lines)
71
+
72
+ def _row(self, task: Task, layout: _Layout) -> str:
73
+ cells = [self._ink.paint(str(task.id).rjust(layout.id_width), "dim")]
74
+ if layout.show_priority:
75
+ cells.append(self._priority_cell(task))
76
+ if layout.show_due:
77
+ cells.append(self._due_cell(task, layout.due_width))
78
+ cells.append(self._summary(task))
79
+ return _GAP.join(cells)
80
+
81
+ def _priority_cell(self, task: Task) -> str:
82
+ if task.priority is None:
83
+ return " "
84
+ letter = task.priority.value[0].upper()
85
+ return self._ink.paint(letter, _PRIORITY_STYLES[task.priority])
86
+
87
+ def _due_cell(self, task: Task, width: int) -> str:
88
+ if task.due is None:
89
+ return " " * width
90
+ span = describe(task.due, self._today).ljust(width)
91
+ return self._ink.paint(span, self._due_style(task))
92
+
93
+ def _due_style(self, task: Task) -> str:
94
+ if task.is_overdue(self._today):
95
+ return "red"
96
+ if task.due == self._today and task.status is Status.PENDING:
97
+ return "yellow"
98
+ return "dim"
99
+
100
+ def _summary(self, task: Task) -> str:
101
+ description = task.description
102
+ if task.status is Status.DONE:
103
+ description = self._ink.paint(description, "dim")
104
+ parts = [description]
105
+ if task.project:
106
+ parts.append(self._ink.paint(f"@{task.project}", "cyan"))
107
+ parts.extend(self._ink.paint(f"+{tag}", "magenta") for tag in sorted(task.tags))
108
+ if task.every:
109
+ parts.append(self._ink.paint(f"every:{task.every.value}", "dim"))
110
+ if task.notes:
111
+ parts.append(self._ink.paint("*", "dim"))
112
+ return " ".join(parts)
113
+
114
+ def _facts(self, task: Task) -> Iterator[tuple[str, str]]:
115
+ yield "status", task.status.value
116
+ if task.project:
117
+ yield "project", task.project
118
+ if task.tags:
119
+ yield "tags", ", ".join(sorted(task.tags))
120
+ if task.due:
121
+ yield "due", f"{describe(task.due, self._today)} ({task.due.isoformat()})"
122
+ if task.priority:
123
+ yield "priority", task.priority.value
124
+ if task.every:
125
+ yield "repeats", task.every.value
126
+ yield "created", task.created.date().isoformat()
127
+ if task.completed:
128
+ yield "completed", task.completed.date().isoformat()
tsk/store.py ADDED
@@ -0,0 +1,125 @@
1
+ import json
2
+ import os
3
+ import tempfile
4
+ from datetime import date, datetime
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from tsk.model import Priority, Recurrence, Status, Task, Tasks
9
+
10
+
11
+ class StoreError(RuntimeError):
12
+ """Raised when the task file cannot be used."""
13
+
14
+
15
+ class Store:
16
+ VERSION = 1
17
+
18
+ def __init__(self, path: Path) -> None:
19
+ self.path = path
20
+
21
+ def read(self) -> Tasks:
22
+ if not self.path.exists():
23
+ return Tasks()
24
+ document = self._document()
25
+ if document.get("version", 0) > self.VERSION:
26
+ raise StoreError(f"{self.path} was written by a newer version of tsk")
27
+ try:
28
+ return Tasks(
29
+ (_revive(entry) for entry in document.get("tasks", ())),
30
+ issued=document.get("issued", 0),
31
+ )
32
+ except (KeyError, TypeError, ValueError) as error:
33
+ raise StoreError(f"{self.path} is unreadable: {error}") from error
34
+
35
+ def write(self, tasks: Tasks) -> None:
36
+ document = {
37
+ "version": self.VERSION,
38
+ "issued": tasks.issued,
39
+ "tasks": [_record(task) for task in tasks],
40
+ }
41
+ _write_atomically(self.path, json.dumps(document, indent=2) + "\n")
42
+
43
+ def _document(self) -> dict[str, Any]:
44
+ try:
45
+ document = json.loads(self.path.read_text(encoding="utf-8"))
46
+ except (json.JSONDecodeError, UnicodeDecodeError) as error:
47
+ raise StoreError(f"{self.path} is unreadable: {error}") from error
48
+ if not isinstance(document, dict):
49
+ raise StoreError(f"{self.path} is unreadable: expected a JSON object")
50
+ return document
51
+
52
+
53
+ def default_path() -> Path:
54
+ override = os.environ.get("TSK_DATA")
55
+ if override:
56
+ return Path(override)
57
+ data_home = os.environ.get("XDG_DATA_HOME")
58
+ root = Path(data_home) if data_home else Path.home() / ".local" / "share"
59
+ return root / "tsk" / "tasks.json"
60
+
61
+
62
+ def _record(task: Task) -> dict[str, Any]:
63
+ always: dict[str, Any] = {
64
+ "id": task.id,
65
+ "description": task.description,
66
+ "status": task.status.value,
67
+ "created": task.created.isoformat(),
68
+ }
69
+ sometimes: dict[str, Any] = {
70
+ "project": task.project,
71
+ "tags": sorted(task.tags),
72
+ "due": task.due.isoformat() if task.due else None,
73
+ "priority": task.priority.value if task.priority else None,
74
+ "every": task.every.value if task.every else None,
75
+ "notes": list(task.notes),
76
+ "completed": task.completed.isoformat() if task.completed else None,
77
+ }
78
+ return always | {key: value for key, value in sometimes.items() if value}
79
+
80
+
81
+ def _revive(entry: dict[str, Any]) -> Task:
82
+ return Task(
83
+ id=entry["id"],
84
+ description=entry["description"],
85
+ status=Status(entry["status"]),
86
+ project=entry.get("project"),
87
+ tags=frozenset(entry.get("tags", ())),
88
+ due=date.fromisoformat(entry["due"]) if "due" in entry else None,
89
+ priority=Priority(entry["priority"]) if "priority" in entry else None,
90
+ every=Recurrence(entry["every"]) if "every" in entry else None,
91
+ notes=tuple(entry.get("notes", ())),
92
+ created=datetime.fromisoformat(entry["created"]),
93
+ completed=(
94
+ datetime.fromisoformat(entry["completed"]) if "completed" in entry else None
95
+ ),
96
+ )
97
+
98
+
99
+ def _write_atomically(path: Path, text: str) -> None:
100
+ path.parent.mkdir(parents=True, exist_ok=True)
101
+ inherited = path.stat().st_mode & 0o777 if path.exists() else None
102
+ handle, name = tempfile.mkstemp(dir=path.parent, prefix=path.name, suffix=".tmp")
103
+ temporary = Path(name)
104
+ try:
105
+ with os.fdopen(handle, "w", encoding="utf-8") as opened:
106
+ opened.write(text)
107
+ opened.flush()
108
+ os.fsync(opened.fileno())
109
+ if inherited is not None:
110
+ temporary.chmod(inherited)
111
+ temporary.replace(path)
112
+ _fsync_directory(path.parent)
113
+ except BaseException:
114
+ temporary.unlink(missing_ok=True)
115
+ raise
116
+
117
+
118
+ def _fsync_directory(directory: Path) -> None:
119
+ if os.name != "posix":
120
+ return
121
+ handle = os.open(directory, os.O_RDONLY)
122
+ try:
123
+ os.fsync(handle)
124
+ finally:
125
+ os.close(handle)
@@ -0,0 +1,270 @@
1
+ Metadata-Version: 2.4
2
+ Name: tsk-todo
3
+ Version: 0.1.0
4
+ Summary: Minimal todo CLI for the terminal. Pure Python, zero dependencies.
5
+ Keywords: cli,todo,task,task-manager,productivity,terminal,gtd
6
+ Author: michaelrbarley
7
+ Author-email: michaelrbarley <301309661+michaelrbarley@users.noreply.github.com>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: End Users/Desktop
12
+ Classifier: Operating System :: POSIX
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Topic :: Utilities
15
+ Requires-Python: >=3.11
16
+ Project-URL: Homepage, https://github.com/michaelrbarley/tsk-todo
17
+ Project-URL: Source, https://github.com/michaelrbarley/tsk-todo
18
+ Project-URL: Issues, https://github.com/michaelrbarley/tsk-todo/issues
19
+ Description-Content-Type: text/markdown
20
+
21
+ # tsk
22
+
23
+ A small task manager for the terminal.
24
+
25
+ ```
26
+ $ tsk add water the plants @home +garden due:tomorrow every:weekly
27
+ added 1: water the plants
28
+
29
+ $ tsk add fix the login bug @work +urgent due:yesterday p:h
30
+ added 2: fix the login bug
31
+
32
+ $ tsk
33
+ 2 H yesterday fix the login bug @work +urgent
34
+ 1 tomorrow water the plants @home +garden every:weekly
35
+ ```
36
+
37
+ No daemon, no config file, no dependencies. Your tasks live in one JSON file you can read.
38
+
39
+ ## Install
40
+
41
+ Needs Python 3.11 or newer.
42
+
43
+ ```
44
+ git clone https://github.com/michaelrbarley/tsk-todo
45
+ cd tsk-todo
46
+ uv tool install .
47
+ ```
48
+
49
+ The command is `tsk`. The package is `tsk-todo`, because `tsk` on PyPI belongs to something else.
50
+
51
+ Or run it from the clone without installing anything:
52
+
53
+ ```
54
+ uv run tsk
55
+ ```
56
+
57
+ ## The idea
58
+
59
+ You type what you mean and `tsk` works out which parts are metadata:
60
+
61
+ ```
62
+ tsk add renew the passport @admin +boring due:eom p:h
63
+ ```
64
+
65
+ `@admin` is the project, `+boring` is a tag, `due:eom` is the end of this month, `p:h` is high
66
+ priority. Everything left over is the task itself.
67
+
68
+ Only `due:`, `p:` and `every:` are read as attributes, so ordinary text stays ordinary:
69
+
70
+ ```
71
+ $ tsk add email bob@example.com about the invoice
72
+ $ tsk add fix the -v flag
73
+ $ tsk add ticket: replace the boiler
74
+ ```
75
+
76
+ None of those pick up a project, a tag or an attribute. If it is not a form `tsk` knows, it is
77
+ part of the description.
78
+
79
+ ## Commands
80
+
81
+ ```
82
+ tsk list pending tasks
83
+ tsk add <text> [attrs] add a task
84
+ tsk done <id>... complete tasks
85
+ tsk rm <id>... delete tasks
86
+ tsk edit <id> <attrs> change a task
87
+ tsk note <id> <text> attach a note
88
+ tsk show <id> show one task in full
89
+ tsk list [filters] list tasks
90
+ ```
91
+
92
+ `tsk 3` on its own is shorthand for `tsk show 3`.
93
+
94
+ `done` and `rm` take several ids at once: `tsk done 2 5 9`.
95
+
96
+ ## Attributes
97
+
98
+ Use these when adding or editing.
99
+
100
+ ```
101
+ @project set the project
102
+ +tag add a tag
103
+ -tag remove a tag, when editing
104
+ due:<when> set a due date
105
+ p:h|m|l set priority, high medium or low
106
+ every:<how> reschedule on completion
107
+ ```
108
+
109
+ Priority also accepts the full word, so `p:high` and `p:h` are the same.
110
+
111
+ Editing changes only what you name. Everything else is left alone.
112
+
113
+ ```
114
+ $ tsk edit 3 due:friday +urgent
115
+ updated 3: read the book
116
+ ```
117
+
118
+ To clear something, give it no value:
119
+
120
+ ```
121
+ $ tsk edit 3 due: p: every:
122
+ ```
123
+
124
+ A bare `@` clears the project. A leading `-` removes a tag.
125
+
126
+ ## Filters
127
+
128
+ ```
129
+ tsk +garden tagged garden
130
+ tsk -work not tagged work
131
+ tsk @home in the home project
132
+ tsk overdue past due
133
+ tsk due:friday due on or before friday
134
+ tsk done completed tasks
135
+ tsk all everything
136
+ tsk plants search descriptions and notes
137
+ ```
138
+
139
+ Filters combine, and `list` is optional:
140
+
141
+ ```
142
+ $ tsk @home +garden overdue
143
+ $ tsk list @work due:eow
144
+ ```
145
+
146
+ Bare words search. `tsk list dentist` finds anything with dentist in the description or in a note.
147
+
148
+ ## Dates
149
+
150
+ Anywhere a date is wanted:
151
+
152
+ ```
153
+ today, tomorrow, yesterday
154
+ monday .. sunday, or mon .. sun
155
+ 3d, 2w, 1m, 1y
156
+ eow, eom, eoy
157
+ 2026-08-01
158
+ ```
159
+
160
+ Weekday names mean the next one coming. On a Thursday, `due:thursday` is a week away, not today.
161
+
162
+ Month and year offsets land on a real date. `1m` from the 31st of January is the 28th of February,
163
+ not the 3rd of March.
164
+
165
+ Due dates are plain calendar dates. `due:friday` means Friday wherever you happen to be, and it
166
+ does not drift when you cross a timezone.
167
+
168
+ ## Repeats
169
+
170
+ ```
171
+ every:daily
172
+ every:weekdays
173
+ every:weekly
174
+ every:monthly
175
+ every:yearly
176
+ ```
177
+
178
+ A repeating task needs a due date. When you complete it, the next one is created for you:
179
+
180
+ ```
181
+ $ tsk done 1
182
+ done 1: water the plants
183
+ repeats as 5, due in 1 week
184
+ ```
185
+
186
+ The next date is worked out from the old due date, then rolled forward until it is in the future.
187
+ Finish a daily task five days late and the next one is due tomorrow, not last week. You never come
188
+ back to a pile of overdue copies.
189
+
190
+ Notes do not carry over. The completed task keeps them.
191
+
192
+ ## Notes
193
+
194
+ ```
195
+ $ tsk note 3 the ferns look worse than the rest
196
+ noted on 3: water the plants
197
+ ```
198
+
199
+ Tasks with notes are marked with a `*` in the list. `tsk show 3` prints them.
200
+
201
+ ## Ordering
202
+
203
+ Tasks sort by due date, then priority, then id. That is the whole rule. Nothing is scored or
204
+ weighted behind your back, so the order is always one you can predict and explain.
205
+
206
+ Undated tasks sort last. They are not urgent, they are just there.
207
+
208
+ ## Your data
209
+
210
+ One JSON file, at `~/.local/share/tsk/tasks.json` unless you say otherwise:
211
+
212
+ ```json
213
+ {
214
+ "version": 1,
215
+ "issued": 7,
216
+ "tasks": [
217
+ {
218
+ "id": 2,
219
+ "description": "fix the login bug",
220
+ "status": "pending",
221
+ "created": "2026-07-16T14:49:48+00:00",
222
+ "project": "work",
223
+ "tags": ["urgent"],
224
+ "due": "2026-07-15",
225
+ "priority": "high"
226
+ }
227
+ ]
228
+ }
229
+ ```
230
+
231
+ Set `TSK_DATA` to put it somewhere else, or `XDG_DATA_HOME` to move the lot. Keep it in a git repo
232
+ if you want history. Tags are written in a fixed order and absent fields are left out, so diffs
233
+ stay small.
234
+
235
+ Writes are atomic. The file is written beside itself and then moved into place, so an interrupted
236
+ write cannot leave you with half a file. Existing permissions are preserved.
237
+
238
+ Ids are never reused. Once id 3 has been handed out, no later task gets it, even if you delete task
239
+ 3 the same day. The id you read a minute ago is still the task you thought it was.
240
+
241
+ ## Colour
242
+
243
+ On when writing to a terminal, off when piped. Set `NO_COLOR` to turn it off entirely.
244
+
245
+ Overdue is red, due today is yellow, projects are cyan, tags are magenta.
246
+
247
+ ## What it does not do
248
+
249
+ There is no urgency score, no dependency graph, no user defined attributes, no hooks, no custom
250
+ report definitions and no sync. Each of those either failed the question "would I miss this" or
251
+ costs far more than it returns for a tool this size.
252
+
253
+ If you want a task on the calendar, `due:` is where it goes. If you want to find it later, tag it.
254
+ That is most of task management.
255
+
256
+ ## Development
257
+
258
+ ```
259
+ uv sync
260
+ uv run pytest
261
+ uv run ruff check .
262
+ uv run ruff format .
263
+ uv run mypy
264
+ ```
265
+
266
+ The suite runs in well under a second. It is worth keeping it that way.
267
+
268
+ ## Licence
269
+
270
+ MIT
@@ -0,0 +1,12 @@
1
+ tsk/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ tsk/cli.py,sha256=pUHfrYHousIZH9BUjPnyZJoGQ5Vn_iA_gk1Nj2nq3vo,6380
3
+ tsk/dates.py,sha256=zJdUj6xsY3J6qVJChfdOax2DaUEH36ojSxwbQ6hCg3U,3288
4
+ tsk/model.py,sha256=f2AmnSMkb9XlQCO4mTj08RJ2oQtaIFM7TIF82p1ERoo,3802
5
+ tsk/parse.py,sha256=KtMyW9DVFOe0iXf40FS8irQCc1vPJMkP2k-sNXI7Lck,7155
6
+ tsk/query.py,sha256=Yk-OS9ZTf6m7CQcUjWY3yq33LxvC1GBRDDj-DOv8-6Q,1605
7
+ tsk/render.py,sha256=W3zThco2idEgGrcUZU6Gt-XNKpwQZXKLRtHGBp88te8,4422
8
+ tsk/store.py,sha256=xv8PU5qLopqtOHgmgVxCa9XVPvSWTkOIO18ety51ePc,4258
9
+ tsk_todo-0.1.0.dist-info/WHEEL,sha256=YR4QUxGCbsyoOnWBVTv-p1I4yc3seKGPev46mvmc8Cg,81
10
+ tsk_todo-0.1.0.dist-info/entry_points.txt,sha256=rCSY00ZIUbG_BgUBegQaaOtWtxo1CZa7l0mid9rDnNo,38
11
+ tsk_todo-0.1.0.dist-info/METADATA,sha256=p6dV4KLZ6FncImpA4sNXxJVQbRt5fE126NTZa7trxAc,7083
12
+ tsk_todo-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.29
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ tsk = tsk.cli:main
3
+