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.
- task_tracker_py-0.1.0/PKG-INFO +79 -0
- task_tracker_py-0.1.0/README.md +68 -0
- task_tracker_py-0.1.0/setup.cfg +4 -0
- task_tracker_py-0.1.0/setup.py +32 -0
- task_tracker_py-0.1.0/task/__init__.py +7 -0
- task_tracker_py-0.1.0/task/task.py +189 -0
- task_tracker_py-0.1.0/task_tracker_py.egg-info/PKG-INFO +79 -0
- task_tracker_py-0.1.0/task_tracker_py.egg-info/SOURCES.txt +9 -0
- task_tracker_py-0.1.0/task_tracker_py.egg-info/dependency_links.txt +1 -0
- task_tracker_py-0.1.0/task_tracker_py.egg-info/requires.txt +3 -0
- task_tracker_py-0.1.0/task_tracker_py.egg-info/top_level.txt +1 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
task
|