task-tracker-py 0.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.
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.1
2
+ Name: task-tracker-py
3
+ Version: 0.1.0
4
+ Summary: task-tracker-py: the .tracker/tasks/ format linter and CLI, stdlib only
5
+ Home-page: https://gitlab.com/meehai/task-tracker
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8.4; extra == "dev"
11
+
12
+ # task
13
+
14
+ A CLI for `.tracker/tasks/` task trackers: one dir per task, one `TASK.md` each. **Stdlib only, no
15
+ regular expressions.**
16
+
17
+ ## Format
18
+
19
+ ```
20
+ .tracker/tasks/{open,closed}/<ID>-<slug>/TASK.md (attachments allowed beside it)
21
+ ```
22
+
23
+ ```
24
+ # Task title
25
+
26
+ **Created**: 2026-04-21
27
+ **Priority**: 2
28
+ **Closed**: n/a
29
+
30
+ ... free form description ...
31
+ ```
32
+
33
+ - The dir is `<ID>-<anything>`; only the ID before the first dash is read (`01-` is ID 1).
34
+ - IDs are integers 1..N, unique across `open/` + `closed/`, no gaps: deleting a task moves the highest-ID
35
+ task into the hole.
36
+ - The header fields come in any order, each once, followed by an empty line.
37
+ - `**Created**` is an ISO date (or datetime). `**Closed**` is `n/a` in `open/`, and an ISO date no earlier
38
+ than `**Created**` in `closed/`.
39
+ - `**Priority**` is a positive integer, higher is more urgent.
40
+ - Files directly in `open/` or `closed/` (e.g. `.gitkeep`) are ignored.
41
+
42
+ `task.Task` is one task, validated in `__post_init__` (wrong type: `TypeError`, bad value: `ValueError`). It can
43
+ be built in memory: its `TASK.md` need not exist, but its `extra_files` must. `task_from_path(task_dir)` reads a
44
+ task dir into a `Task`.
45
+
46
+ ## `task/task.py [--dir <tasks_dir>] {ls,create,check}`
47
+
48
+ `--dir` defaults to `./.tracker/tasks`. Logs go through `logging`; set `TASK_LOGLEVEL` (default `INFO`).
49
+
50
+ ```bash
51
+ python task/task.py ls # open tasks, by priority then ID
52
+ python task/task.py ls --include_closed # plus closed ones
53
+ python -m task.task -d .tracker/tasks ls # same, via the installed package (`pip install .`)
54
+ ```
55
+
56
+ `check` parses every task (so any format violation raises), then raises `ValueError` on duplicate IDs, an ID
57
+ gap (IDs must be 1..N), or a task whose `**Closed**` does not match its `open/` / `closed/` dir. `create` is
58
+ not implemented yet (task 1).
59
+
60
+ ## Tests
61
+
62
+ ```bash
63
+ python -m pytest test/
64
+ ```
65
+
66
+ One broken rule per case:
67
+
68
+ - `test/unit/test_task.py` — `Task` field rules, on in-memory tasks.
69
+ - `test/unit/test_task_from_path.py` — the on-disk format: dir name, header, dates, extra files; `main` on a
70
+ throwaway tracker.
71
+ - `test/unit/test_task_check.py` — `check` in-process (`handle_command`) on in-memory tasks: IDs and
72
+ open/closed status.
73
+
74
+ `python test/lint/run_all.py` runs ruff and pylint.
75
+
76
+ ## Acknowledgments
77
+
78
+ Inspired by tsoding's [tatr](https://github.com/tsoding/tatr): tasks as plain files in the repo, driven by
79
+ a small CLI.
@@ -0,0 +1,68 @@
1
+ # task
2
+
3
+ A CLI for `.tracker/tasks/` task trackers: one dir per task, one `TASK.md` each. **Stdlib only, no
4
+ regular expressions.**
5
+
6
+ ## Format
7
+
8
+ ```
9
+ .tracker/tasks/{open,closed}/<ID>-<slug>/TASK.md (attachments allowed beside it)
10
+ ```
11
+
12
+ ```
13
+ # Task title
14
+
15
+ **Created**: 2026-04-21
16
+ **Priority**: 2
17
+ **Closed**: n/a
18
+
19
+ ... free form description ...
20
+ ```
21
+
22
+ - The dir is `<ID>-<anything>`; only the ID before the first dash is read (`01-` is ID 1).
23
+ - IDs are integers 1..N, unique across `open/` + `closed/`, no gaps: deleting a task moves the highest-ID
24
+ task into the hole.
25
+ - The header fields come in any order, each once, followed by an empty line.
26
+ - `**Created**` is an ISO date (or datetime). `**Closed**` is `n/a` in `open/`, and an ISO date no earlier
27
+ than `**Created**` in `closed/`.
28
+ - `**Priority**` is a positive integer, higher is more urgent.
29
+ - Files directly in `open/` or `closed/` (e.g. `.gitkeep`) are ignored.
30
+
31
+ `task.Task` is one task, validated in `__post_init__` (wrong type: `TypeError`, bad value: `ValueError`). It can
32
+ be built in memory: its `TASK.md` need not exist, but its `extra_files` must. `task_from_path(task_dir)` reads a
33
+ task dir into a `Task`.
34
+
35
+ ## `task/task.py [--dir <tasks_dir>] {ls,create,check}`
36
+
37
+ `--dir` defaults to `./.tracker/tasks`. Logs go through `logging`; set `TASK_LOGLEVEL` (default `INFO`).
38
+
39
+ ```bash
40
+ python task/task.py ls # open tasks, by priority then ID
41
+ python task/task.py ls --include_closed # plus closed ones
42
+ python -m task.task -d .tracker/tasks ls # same, via the installed package (`pip install .`)
43
+ ```
44
+
45
+ `check` parses every task (so any format violation raises), then raises `ValueError` on duplicate IDs, an ID
46
+ gap (IDs must be 1..N), or a task whose `**Closed**` does not match its `open/` / `closed/` dir. `create` is
47
+ not implemented yet (task 1).
48
+
49
+ ## Tests
50
+
51
+ ```bash
52
+ python -m pytest test/
53
+ ```
54
+
55
+ One broken rule per case:
56
+
57
+ - `test/unit/test_task.py` — `Task` field rules, on in-memory tasks.
58
+ - `test/unit/test_task_from_path.py` — the on-disk format: dir name, header, dates, extra files; `main` on a
59
+ throwaway tracker.
60
+ - `test/unit/test_task_check.py` — `check` in-process (`handle_command`) on in-memory tasks: IDs and
61
+ open/closed status.
62
+
63
+ `python test/lint/run_all.py` runs ruff and pylint.
64
+
65
+ ## Acknowledgments
66
+
67
+ Inspired by tsoding's [tatr](https://github.com/tsoding/tatr): tasks as plain files in the repo, driven by
68
+ a small CLI.
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,32 @@
1
+ """setup.py -- note use setuptools==73.0.1; older versions fuck up the data files, newer versions include resources."""
2
+ from pathlib import Path
3
+ from setuptools import setup, find_packages
4
+
5
+ NAME = "task-tracker-py"
6
+ VERSION = "0.1.0"
7
+ DESCRIPTION = "task-tracker-py: the .tracker/tasks/ format linter and CLI, stdlib only"
8
+ URL = "https://gitlab.com/meehai/task-tracker"
9
+
10
+ CWD = Path(__file__).absolute().parent
11
+ with open(CWD / "README.md", "r", encoding="utf-8") as fh:
12
+ long_description = fh.read()
13
+
14
+ REQUIRED_DEV = [
15
+ "pytest>=8.4",
16
+ ]
17
+
18
+ setup(
19
+ name=NAME,
20
+ version=VERSION,
21
+ description=DESCRIPTION,
22
+ long_description=long_description,
23
+ long_description_content_type="text/markdown",
24
+ url=URL,
25
+ packages=find_packages(exclude=["test", "test.*"]),
26
+ install_requires=[], # no dependencies, just stdlib
27
+ extras_require={"dev": REQUIRED_DEV},
28
+ dependency_links=[],
29
+ license="MIT",
30
+ python_requires=">=3.11",
31
+ scripts=[],
32
+ )
@@ -0,0 +1,7 @@
1
+ """task - the `.tracker/tasks/` tooling for this repo and the libraries vendored beside it.
2
+
3
+ Stdlib only, no third-party deps, and no regular expressions: the two formats here (a task's header
4
+ field, a task dir name) are parsed by hand so the accepted grammar is visible in the code.
5
+
6
+ Today it ships `check_tasks`; the `task` CLI grows beside it (task-tracker #1).
7
+ """
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env python3
2
+ """task.py
3
+ CLI for integrating with the task manager. Inspired by tsoding's tatr.
4
+ Tasks follows this format: tasks/open/ID-name/TASK.md file.
5
+ IDs are contiguous integers starting from 1.
6
+
7
+ Task header:
8
+ ```
9
+ # Task title
10
+
11
+ **Created**: 2026-04-21
12
+ **Priority**: 2
13
+ **Closed**: n/a
14
+
15
+ ... free form description ...
16
+ ```
17
+ """
18
+ from __future__ import annotations
19
+ from argparse import ArgumentParser, Namespace
20
+ from pathlib import Path
21
+ from dataclasses import dataclass, field
22
+ from datetime import datetime
23
+ import logging
24
+ import os
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ MAX_TASK_ID = 1_000_000
29
+ COMMANDS = {"ls", "create", "check", "close"}
30
+
31
+ @dataclass
32
+ class Task:
33
+ """The task dataclass representing the in-memory object of a TASK.md file"""
34
+ task_path: Path # Path to TASK.md. For in-memory tasks (tests) can be missing
35
+ task_id: int # Task ids are positive integers (and contiguous)
36
+ task_name: str # The task name as defined in the TASK.md file
37
+ created_at: datetime # Datetime when the task was created
38
+ priority: int # Priority of the task. Positive integer. Higher is better
39
+ description: str # Textual description of the task in the TASK.md file
40
+ closed_at: datetime | str = "n/a" # When the task was closed, if any
41
+ extra_files: list[Path] = field(default_factory=list) # Extra files in the directory where TASK.md is defined
42
+
43
+ def __post_init__(self):
44
+ if not isinstance(self.task_path, Path):
45
+ raise TypeError(f"Task path must be a path: {self.task_path}")
46
+ if self.task_path.name != "TASK.md":
47
+ raise ValueError(f"Task path must point to a TASK.md file: {self.task_path}")
48
+ if not isinstance(self.task_id, int):
49
+ raise TypeError(f"{self.task_path} - Task id must be an integer, got: {self.task_id}")
50
+ if not 0 < self.task_id < MAX_TASK_ID:
51
+ raise ValueError(f"{self.task_path} - Task id must be as positive integer, got: {self.task_id}")
52
+ if not isinstance(self.task_name, str):
53
+ raise TypeError(f"{self.task_path} - The task name must be a string, got: {self.task_name}")
54
+ if len(self.task_name) == 0:
55
+ raise ValueError(f"{self.task_path} - The task name cannot be empty")
56
+ if not isinstance(self.created_at, datetime):
57
+ raise TypeError(f"{self.task_path} - created_at must be datetime, got {self.created_at}")
58
+ if not isinstance(self.priority, int):
59
+ raise TypeError(f"{self.task_path} - The priority must be an integer: {self.priority}")
60
+ if self.priority <= 0:
61
+ raise ValueError(f"{self.task_path} - The priority must be positive: {self.priority}")
62
+ if not isinstance(self.closed_at, datetime) and self.closed_at != "n/a":
63
+ raise TypeError(f"{self.task_path} - closed_at must be datetime or 'n/a', got {self.closed_at}")
64
+ if isinstance(self.closed_at, datetime) and self.closed_at < self.created_at:
65
+ raise ValueError(f"{self.task_path} - Task closed before it was opened: {self.closed_at} {self.created_at}")
66
+ if not isinstance(self.description, str):
67
+ raise TypeError(f"{self.task_path} - description must be a free-form text, got: {self.description}")
68
+ if not isinstance(self.extra_files, list) or not all(isinstance(x, Path) for x in self.extra_files):
69
+ raise TypeError(f"{self.task_path} - extra files must be a list of paths: {self.extra_files}")
70
+ if any(not x.exists() for x in self.extra_files):
71
+ raise FileNotFoundError(f"{self.task_path} - one or more extra files does not exist: {self.extra_files}")
72
+
73
+ @property
74
+ def is_open(self) -> bool:
75
+ """Returns true if the task is open, false otherwise. Uses closed_at as reference."""
76
+ return self.closed_at == "n/a"
77
+
78
+ def task_from_path(task_dir: Path) -> Task:
79
+ """Creates a task from a file path, e.g. a single TASK.md"""
80
+ if not (task_file := task_dir / "TASK.md").exists():
81
+ raise FileNotFoundError(f"Task file does not exist in '{task_file}'")
82
+ with open(task_file, "r", encoding="utf-8") as fp:
83
+ lines = fp.readlines()
84
+ extra_files: list[Path] = [f for f in task_dir.iterdir() if f.is_file() and f.name != "TASK.md"]
85
+
86
+ task_id = int(task_dir.name.split("-")[0])
87
+ created_at, priority, description, closed_at = None, None, None, None
88
+
89
+ if len(lines) == 0 or not lines[0].startswith("# "):
90
+ raise ValueError(f"'task_name' is not the first line: {lines} {task_file=}")
91
+ task_name = lines[0][2:-1] # Get rid of '# ' and \n
92
+
93
+ n_lines_read = 1
94
+ try:
95
+ for line in map(lambda x: x.strip(), lines[1:]):
96
+ if created_at is not None and priority is not None and closed_at is not None:
97
+ break
98
+ n_lines_read += 1
99
+ if line == "":
100
+ continue
101
+ if line.startswith("**Created**"):
102
+ if created_at is not None:
103
+ raise ValueError(f"'Created' was already set: {created_at} {task_file=}")
104
+ created_at = datetime.fromisoformat(line[13:])
105
+ elif line.startswith("**Priority**"):
106
+ if priority is not None:
107
+ raise ValueError(f"'Priority' was already set: {priority} {task_file=}")
108
+ priority = int(line[13:])
109
+ elif line.startswith("**Closed**"):
110
+ if closed_at is not None:
111
+ raise ValueError(f"'Closed' was already set: {closed_at} {task_file=}")
112
+ closed_at = line[12:] if line[12:] == "n/a" else datetime.fromisoformat(line[12:])
113
+ except (TypeError, ValueError) as e:
114
+ raise type(e)(f"{task_file=} {e}") from e
115
+
116
+ if created_at is None or priority is None or closed_at is None:
117
+ raise ValueError(f"Header is invalid: {task_file=}. One is missing: {created_at=} {priority=} {closed_at=}")
118
+
119
+ if len(lines) > n_lines_read and lines[n_lines_read] != "\n":
120
+ raise ValueError(f"Expected empty line, got: {lines[n_lines_read]!r} ({task_file=})")
121
+
122
+ description = "".join(lines[n_lines_read:])
123
+ return Task(task_file, task_id, task_name, created_at, priority, description, closed_at, extra_files)
124
+
125
+ def _print_table(tasks: list[Task]):
126
+ header = ("ID", "Name", "Priority", "Created")
127
+ rows = []
128
+ for t in tasks:
129
+ t_name = f"{t.task_name[0:78]}.." if len(t.task_name) > 80 else t.task_name
130
+ rows.append((str(t.task_id), t_name, str(t.priority), t.created_at.date().isoformat()))
131
+ widths = [max(len(row[i]) for row in [header, *rows]) for i in range(len(header))]
132
+ for row in [header, tuple("-" * w for w in widths), *rows]:
133
+ print(" ".join(cell.ljust(w) for cell, w in zip(row, widths)))
134
+
135
+ def handle_command(args: Namespace, open_tasks: list[Task], closed_tasks: list[Task]):
136
+ """Handles a command. Different function than main, so we can test with in-memory tasks."""
137
+ if args.command == "ls":
138
+ print("== Open tasks ==")
139
+ _print_table(sorted(open_tasks, key=lambda t: (-t.priority, t.task_id)))
140
+ if args.include_closed:
141
+ print("\n== Closed tasks ==")
142
+ _print_table(closed_tasks)
143
+
144
+ if args.command == "create":
145
+ raise NotImplementedError(args.command)
146
+
147
+ if args.command == "check":
148
+ # IDs are unique across open/ + closed/ and contiguous 1..N
149
+ ids = [t.task_id for t in [*open_tasks, *closed_tasks]]
150
+ if dups := sorted({i for i in ids if ids.count(i) > 1}):
151
+ raise ValueError(f"Duplicate ids: {dups}")
152
+ if missing := sorted(set(range(1, max(ids, default=0) + 1)) - set(ids)):
153
+ raise ValueError(f"There are gaps in the ids. First missing: {missing[0]}. All missing: {missing}")
154
+
155
+ # Check that closed_at is respected in a task being open/closed
156
+ for task in open_tasks:
157
+ if not task.is_open:
158
+ raise ValueError(f"Task {task.task_path.resolve()} is in open/ folder, but should be in closed/")
159
+ for task in closed_tasks:
160
+ if task.is_open:
161
+ raise ValueError(f"Task {task.task_path.resolve()} is in closed/ folder, but should be in open/")
162
+
163
+ logger.info(f"Task format OK ({len(ids)} tasks)")
164
+
165
+ if args.command == "close":
166
+ raise NotImplementedError(args.command)
167
+
168
+ def main(args: Namespace):
169
+ """main fn"""
170
+ logger.debug(f"Reading tasks from '{args.dir}'")
171
+
172
+ if sorted([f.name for f in args.dir.iterdir()]) != ["closed", "open"]:
173
+ raise ValueError(f"Wrong tasks dir: {args.dir}")
174
+ open_tasks = [task_from_path(pth) for pth in (args.dir / "open").iterdir() if pth.is_dir()]
175
+ closed_tasks = [task_from_path(pth) for pth in (args.dir / "closed").iterdir() if pth.is_dir()]
176
+
177
+ logger.debug(f"Read {len(open_tasks)} open tasks and {len(closed_tasks)} closed tasks")
178
+ handle_command(args, open_tasks, closed_tasks)
179
+
180
+ if __name__ == "__main__":
181
+ level_name = os.environ.get("TASK_LOGLEVEL", "INFO").upper()
182
+ logging.basicConfig(level=level_name, format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s")
183
+
184
+ parser = ArgumentParser()
185
+ parser.add_argument("--dir", "-d", type=Path, help="Path to the tasks dir", default=Path.cwd() / ".tracker/tasks")
186
+ parser.add_argument("command", choices=COMMANDS, help="The command you want to run with the tasks (e.g. ls)")
187
+ parser.add_argument("--include_closed", "-c", action="store_true", help="Include closed when listing tasks (ls)")
188
+ arg = parser.parse_args()
189
+ main(arg)
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.1
2
+ Name: task-tracker-py
3
+ Version: 0.1.0
4
+ Summary: task-tracker-py: the .tracker/tasks/ format linter and CLI, stdlib only
5
+ Home-page: https://gitlab.com/meehai/task-tracker
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8.4; extra == "dev"
11
+
12
+ # task
13
+
14
+ A CLI for `.tracker/tasks/` task trackers: one dir per task, one `TASK.md` each. **Stdlib only, no
15
+ regular expressions.**
16
+
17
+ ## Format
18
+
19
+ ```
20
+ .tracker/tasks/{open,closed}/<ID>-<slug>/TASK.md (attachments allowed beside it)
21
+ ```
22
+
23
+ ```
24
+ # Task title
25
+
26
+ **Created**: 2026-04-21
27
+ **Priority**: 2
28
+ **Closed**: n/a
29
+
30
+ ... free form description ...
31
+ ```
32
+
33
+ - The dir is `<ID>-<anything>`; only the ID before the first dash is read (`01-` is ID 1).
34
+ - IDs are integers 1..N, unique across `open/` + `closed/`, no gaps: deleting a task moves the highest-ID
35
+ task into the hole.
36
+ - The header fields come in any order, each once, followed by an empty line.
37
+ - `**Created**` is an ISO date (or datetime). `**Closed**` is `n/a` in `open/`, and an ISO date no earlier
38
+ than `**Created**` in `closed/`.
39
+ - `**Priority**` is a positive integer, higher is more urgent.
40
+ - Files directly in `open/` or `closed/` (e.g. `.gitkeep`) are ignored.
41
+
42
+ `task.Task` is one task, validated in `__post_init__` (wrong type: `TypeError`, bad value: `ValueError`). It can
43
+ be built in memory: its `TASK.md` need not exist, but its `extra_files` must. `task_from_path(task_dir)` reads a
44
+ task dir into a `Task`.
45
+
46
+ ## `task/task.py [--dir <tasks_dir>] {ls,create,check}`
47
+
48
+ `--dir` defaults to `./.tracker/tasks`. Logs go through `logging`; set `TASK_LOGLEVEL` (default `INFO`).
49
+
50
+ ```bash
51
+ python task/task.py ls # open tasks, by priority then ID
52
+ python task/task.py ls --include_closed # plus closed ones
53
+ python -m task.task -d .tracker/tasks ls # same, via the installed package (`pip install .`)
54
+ ```
55
+
56
+ `check` parses every task (so any format violation raises), then raises `ValueError` on duplicate IDs, an ID
57
+ gap (IDs must be 1..N), or a task whose `**Closed**` does not match its `open/` / `closed/` dir. `create` is
58
+ not implemented yet (task 1).
59
+
60
+ ## Tests
61
+
62
+ ```bash
63
+ python -m pytest test/
64
+ ```
65
+
66
+ One broken rule per case:
67
+
68
+ - `test/unit/test_task.py` — `Task` field rules, on in-memory tasks.
69
+ - `test/unit/test_task_from_path.py` — the on-disk format: dir name, header, dates, extra files; `main` on a
70
+ throwaway tracker.
71
+ - `test/unit/test_task_check.py` — `check` in-process (`handle_command`) on in-memory tasks: IDs and
72
+ open/closed status.
73
+
74
+ `python test/lint/run_all.py` runs ruff and pylint.
75
+
76
+ ## Acknowledgments
77
+
78
+ Inspired by tsoding's [tatr](https://github.com/tsoding/tatr): tasks as plain files in the repo, driven by
79
+ a small CLI.
@@ -0,0 +1,9 @@
1
+ README.md
2
+ setup.py
3
+ task/__init__.py
4
+ task/task.py
5
+ task_tracker_py.egg-info/PKG-INFO
6
+ task_tracker_py.egg-info/SOURCES.txt
7
+ task_tracker_py.egg-info/dependency_links.txt
8
+ task_tracker_py.egg-info/requires.txt
9
+ task_tracker_py.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+
2
+ [dev]
3
+ pytest>=8.4