tasko 0.0.1__tar.gz → 0.0.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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tasko
3
- Version: 0.0.1
3
+ Version: 0.0.2
4
4
  Summary: Personal task manager
5
5
  Author: pybass
6
6
  License-Expression: MIT
@@ -17,30 +17,20 @@ Description-Content-Type: text/markdown
17
17
 
18
18
  # tasko
19
19
 
20
- A personal task manager: a fast Textual TUI over local SQLite, with a minimal
21
- CLI for quick capture.
20
+ A personal task manager: a fast Textual TUI over local SQLite.
22
21
 
23
22
  ## Usage
24
23
 
25
- `tasko` with no arguments launches the TUI — the primary interface, where all
26
- task and project management lives.
27
-
28
- The CLI is deliberately minimal (see
29
- [docs/architecture.md](docs/architecture.md) for why):
30
-
31
- ```sh
32
- tasko add "fix nginx" -p infra # quick capture from the shell
33
- tasko list # a quick look without launching the TUI
34
- ```
24
+ `tasko` launches the TUI — the only interface, where all task and project
25
+ management lives.
35
26
 
36
27
  Data is a single SQLite file under `$XDG_DATA_HOME/tasko` (default
37
- `~/.local/share/tasko`); override the directory with `--data-dir` or the
38
- `TASKO_DATA_DIR` env var.
28
+ `~/.local/share/tasko`); override the directory with `--data-dir`.
39
29
 
40
30
  ## Design
41
31
 
42
- - [docs/architecture.md](docs/architecture.md) — layering (`core/` + `cli/` +
43
- `tui/`), the TUI-first UI strategy, tool choices.
32
+ - [docs/architecture.md](docs/architecture.md) — layering (`core/` + `tui/`),
33
+ the TUI-only UI strategy, tool choices.
44
34
  - [docs/data-model.md](docs/data-model.md) — entities, the full SQL schema,
45
35
  and the reasoning behind it.
46
36
  - [docs/non-goals.md](docs/non-goals.md) — features deliberately not built,
tasko-0.0.2/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # tasko
2
+
3
+ A personal task manager: a fast Textual TUI over local SQLite.
4
+
5
+ ## Usage
6
+
7
+ `tasko` launches the TUI — the only interface, where all task and project
8
+ management lives.
9
+
10
+ Data is a single SQLite file under `$XDG_DATA_HOME/tasko` (default
11
+ `~/.local/share/tasko`); override the directory with `--data-dir`.
12
+
13
+ ## Design
14
+
15
+ - [docs/architecture.md](docs/architecture.md) — layering (`core/` + `tui/`),
16
+ the TUI-only UI strategy, tool choices.
17
+ - [docs/data-model.md](docs/data-model.md) — entities, the full SQL schema,
18
+ and the reasoning behind it.
19
+ - [docs/non-goals.md](docs/non-goals.md) — features deliberately not built,
20
+ and why.
21
+
22
+ ## License
23
+
24
+ [MIT](LICENSE)
@@ -3,7 +3,7 @@
3
3
  # ============================================================================
4
4
  [project]
5
5
  name = "tasko"
6
- version = "0.0.1"
6
+ version = "0.0.2"
7
7
  description = "Personal task manager"
8
8
  readme = "README.md"
9
9
  authors = [{ name = "pybass" }]
@@ -25,7 +25,7 @@ Repository = "https://github.com/pybass/tasko"
25
25
  Issues = "https://github.com/pybass/tasko/issues"
26
26
 
27
27
  [project.scripts]
28
- tasko = "tasko.cli.main:main"
28
+ tasko = "tasko.main:main"
29
29
 
30
30
 
31
31
  # ============================================================================
@@ -48,7 +48,6 @@ dev = [
48
48
  "ruff~=0.15.22",
49
49
  # Type checking
50
50
  "mypy~=2.3.0",
51
- "ty~=0.0.60",
52
51
  # Testing & coverage
53
52
  "pytest~=9.1.1",
54
53
  "pytest-asyncio~=1.4.0",
@@ -122,7 +121,7 @@ docstring-code-format = true # keep code examples in docstrings formatted like
122
121
 
123
122
 
124
123
  # ============================================================================
125
- # Type checking — mypy (strict) + ty
124
+ # Type checking — mypy (strict)
126
125
  # ============================================================================
127
126
  [tool.mypy]
128
127
  python_version = "3.14"
@@ -137,16 +136,6 @@ disallow_untyped_decorators = false
137
136
  disallow_untyped_calls = false
138
137
  warn_unused_ignores = false
139
138
 
140
- [tool.ty.environment]
141
- python-version = "3.14"
142
-
143
- # Same intent as the mypy test override: silence test-only false positives.
144
- [[tool.ty.overrides]]
145
- include = ["tests/**"]
146
- [tool.ty.overrides.rules]
147
- possibly-missing-attribute = "ignore"
148
- invalid-assignment = "ignore"
149
-
150
139
 
151
140
  # ============================================================================
152
141
  # Spell checking — codespell
@@ -20,13 +20,15 @@ def _now() -> int:
20
20
  class Core:
21
21
  """The application without UI: owns the SQLite connection and every operation on it.
22
22
 
23
- Adapters (CLI, TUI) call these methods and show AppError messages to the user.
23
+ Adapters (the TUI) call these methods and show AppError messages to the user.
24
24
  """
25
25
 
26
26
  def __init__(self, db_path: Path) -> None:
27
27
  """Open the database (creating the file and its directory if needed) and migrate it."""
28
28
  db_path.parent.mkdir(parents=True, exist_ok=True)
29
- self._conn = sqlite3.connect(db_path) # single shared connection
29
+ # autocommit: every write is a single self-committing statement; anything that ever
30
+ # needs multi-statement atomicity must use explicit BEGIN/COMMIT (as _migrate does).
31
+ self._conn = sqlite3.connect(db_path, autocommit=True) # single shared connection
30
32
  self._conn.row_factory = sqlite3.Row
31
33
  self._conn.execute("PRAGMA journal_mode = WAL")
32
34
  self._conn.execute("PRAGMA busy_timeout = 5000")
@@ -68,7 +70,6 @@ class Core:
68
70
  ).fetchone()
69
71
  except sqlite3.IntegrityError as e: # foreign key: no such project
70
72
  raise AppError(f"Project #{project_id} not found.") from e
71
- self._conn.commit()
72
73
  return self.get_task(int(row["id"]))
73
74
 
74
75
  def get_task(self, task_id: int) -> Task:
@@ -121,7 +122,6 @@ class Core:
121
122
  "UPDATE tasks SET status = :status, done_at = :done_at, updated_at = :now WHERE id = :id",
122
123
  {"status": status, "done_at": done_at, "now": now, "id": task_id},
123
124
  )
124
- self._conn.commit()
125
125
  if cur.rowcount == 0:
126
126
  raise AppError(f"Task #{task_id} not found.")
127
127
 
@@ -134,7 +134,6 @@ class Core:
134
134
  "UPDATE tasks SET title = :title, updated_at = :now WHERE id = :id",
135
135
  {"title": title, "now": _now(), "id": task_id},
136
136
  )
137
- self._conn.commit()
138
137
  if cur.rowcount == 0:
139
138
  raise AppError(f"Task #{task_id} not found.")
140
139
 
@@ -146,7 +145,6 @@ class Core:
146
145
  "UPDATE tasks SET body = :body, updated_at = :now WHERE id = :id",
147
146
  {"body": body, "now": _now(), "id": task_id},
148
147
  )
149
- self._conn.commit()
150
148
  if cur.rowcount == 0:
151
149
  raise AppError(f"Task #{task_id} not found.")
152
150
 
@@ -156,7 +154,6 @@ class Core:
156
154
  "UPDATE tasks SET priority = :priority, updated_at = :now WHERE id = :id",
157
155
  {"priority": priority, "now": _now(), "id": task_id},
158
156
  )
159
- self._conn.commit()
160
157
  if cur.rowcount == 0:
161
158
  raise AppError(f"Task #{task_id} not found.")
162
159
 
@@ -169,14 +166,12 @@ class Core:
169
166
  )
170
167
  except sqlite3.IntegrityError as e: # foreign key: no such project
171
168
  raise AppError(f"Project #{project_id} not found.") from e
172
- self._conn.commit()
173
169
  if cur.rowcount == 0:
174
170
  raise AppError(f"Task #{task_id} not found.")
175
171
 
176
172
  def delete_task(self, task_id: int) -> None:
177
173
  """Delete a task permanently."""
178
174
  cur = self._conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
179
- self._conn.commit()
180
175
  if cur.rowcount == 0:
181
176
  raise AppError(f"Task #{task_id} not found.")
182
177
 
@@ -194,26 +189,16 @@ class Core:
194
189
  raise AppError(f"Project #{project_id} not found.")
195
190
  return Project(**dict(row))
196
191
 
197
- def get_project_by_name(self, name: str) -> Project:
198
- """Return a project by its unique name; raises AppError when it does not exist.
199
-
200
- For adapters whose input addresses projects by name (the CLI); everything else uses ids.
201
- """
202
- row = self._conn.execute("SELECT id, name FROM projects WHERE name = ?", (name,)).fetchone()
203
- if row is None:
204
- raise AppError(f"Project '{name}' not found.")
205
- return Project(**dict(row))
206
-
207
- def create_project(self, name: str) -> None:
208
- """Create a project with a unique non-empty name."""
192
+ def create_project(self, name: str) -> Project:
193
+ """Create a project with a unique non-empty name and return it."""
209
194
  name = name.strip()
210
195
  if not name:
211
196
  raise AppError("Project name cannot be empty.")
212
197
  try:
213
- self._conn.execute("INSERT INTO projects (name) VALUES (?)", (name,))
198
+ row = self._conn.execute("INSERT INTO projects (name) VALUES (?) RETURNING id", (name,)).fetchone()
214
199
  except sqlite3.IntegrityError as e:
215
200
  raise AppError(f"Project '{name}' already exists.") from e
216
- self._conn.commit()
201
+ return Project(id=row["id"], name=name)
217
202
 
218
203
  def rename_project(self, project_id: int, new_name: str) -> None:
219
204
  """Rename a project; the new name must be unique and non-empty."""
@@ -224,7 +209,6 @@ class Core:
224
209
  cur = self._conn.execute("UPDATE projects SET name = ? WHERE id = ?", (new_name, project_id))
225
210
  except sqlite3.IntegrityError as e:
226
211
  raise AppError(f"Project '{new_name}' already exists.") from e
227
- self._conn.commit()
228
212
  if cur.rowcount == 0:
229
213
  raise AppError(f"Project #{project_id} not found.")
230
214
 
@@ -233,7 +217,6 @@ class Core:
233
217
  if project_id == self.app_state().default_project_id:
234
218
  raise AppError("Cannot delete the default project; make another project the default first.")
235
219
  cur = self._conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
236
- self._conn.commit()
237
220
  if cur.rowcount == 0:
238
221
  raise AppError(f"Project #{project_id} not found.")
239
222
 
@@ -251,12 +234,10 @@ class Core:
251
234
  def set_selected_project(self, project_id: int | None) -> None:
252
235
  """Set the persisted project filter; None means all projects."""
253
236
  self._conn.execute("UPDATE app_state SET selected_project_id = ? WHERE id = 1", (project_id,))
254
- self._conn.commit()
255
237
 
256
238
  def set_theme(self, theme: str) -> None:
257
239
  """Persist the UI theme choice."""
258
240
  self._conn.execute("UPDATE app_state SET theme = ? WHERE id = 1", (theme,))
259
- self._conn.commit()
260
241
 
261
242
  def set_default_project(self, project_id: int) -> None:
262
243
  """Make the given project the default target for quick capture."""
@@ -264,4 +245,3 @@ class Core:
264
245
  self._conn.execute("UPDATE app_state SET default_project_id = ? WHERE id = 1", (project_id,))
265
246
  except sqlite3.IntegrityError as e: # foreign key: no such project
266
247
  raise AppError(f"Project #{project_id} not found.") from e
267
- self._conn.commit()
@@ -0,0 +1,33 @@
1
+ """Entry point: resolve the data directory, open the database, run the TUI."""
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from importlib.metadata import version
7
+ from pathlib import Path
8
+
9
+ from tasko.core.core import Core
10
+ from tasko.core.errors import AppError
11
+ from tasko.tui.app import TaskoApp
12
+
13
+
14
+ def main() -> None:
15
+ """Launch the TUI against the resolved database."""
16
+ parser = argparse.ArgumentParser(prog="tasko", description="Personal task manager.")
17
+ parser.add_argument("--version", action="version", version=version("tasko"))
18
+ parser.add_argument("--data-dir", type=Path, help="data directory (default: XDG data dir)")
19
+ args = parser.parse_args()
20
+ core = Core(_resolve_db_path(args.data_dir))
21
+ try:
22
+ TaskoApp(core).run()
23
+ except AppError as e:
24
+ sys.exit(f"error: {e}")
25
+ finally:
26
+ core.close()
27
+
28
+
29
+ def _resolve_db_path(data_dir: Path | None) -> Path:
30
+ """Database file path; without an explicit directory the XDG data dir is used."""
31
+ if data_dir is None:
32
+ data_dir = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / "tasko"
33
+ return data_dir / "tasko.db"
@@ -42,7 +42,7 @@ class TaskoApp(App[None]):
42
42
  def __init__(self, core: Core) -> None:
43
43
  """Wire the app to the shared core."""
44
44
  super().__init__()
45
- self._core = core # application core, shared with the CLI
45
+ self._core = core # application core, built by main() and owned by it
46
46
 
47
47
  def action_help(self) -> None:
48
48
  """Show the current screen's keys; screens opt in by defining a HELP_KEYS attribute."""
@@ -64,8 +64,9 @@ class TaskoApp(App[None]):
64
64
  def on_app_focus(self) -> None:
65
65
  """Reload the visible screen when the terminal regains focus.
66
66
 
67
- This closes the quick-capture loop: `tasko add` from a shell, alt-tab back, the
68
- list is fresh. Skipped while a dialog is on top — reloading under a modal could
67
+ The database can change under a running instance — a second tasko in another
68
+ terminal — so alt-tabbing back shows a fresh list rather than a stale one.
69
+ Skipped while a dialog is on top — reloading under a modal could
69
70
  fight the interaction in progress (r covers that case), and TaskDetailScreen.reload
70
71
  pops the top screen when its task is gone, which must never hit a dialog.
71
72
  """
@@ -46,6 +46,7 @@ class TaskListScreen(Screen[None]):
46
46
  ("j", "cursor_down", "Down"),
47
47
  ("k", "cursor_up", "Up"),
48
48
  ("a", "add", "Add"),
49
+ ("A", "add_with_project", "Add to project"),
49
50
  ("d", "toggle_done", "Done"),
50
51
  ("s", "toggle_doing", "Doing"),
51
52
  ("plus,equals_sign", "priority_up", "Priority up"), # = raises too: + is shifted on most layouts
@@ -64,6 +65,7 @@ class TaskListScreen(Screen[None]):
64
65
  ("↑ ↓ j k", "move the cursor"),
65
66
  ("enter", "open the task under the cursor"),
66
67
  ("a", "add a task into the cursor row's project"),
68
+ ("A", "add a task, choosing the project first"),
67
69
  ("d", "mark done / back to todo"),
68
70
  ("s", "mark doing / back to todo"),
69
71
  ("+ -", "raise / lower the priority"),
@@ -183,6 +185,44 @@ class TaskListScreen(Screen[None]):
183
185
 
184
186
  self.app.push_screen(InputDialog(f'New task in "{name}":'), on_result)
185
187
 
188
+ def action_add_with_project(self) -> None:
189
+ """Pick the target project from the full list, then prompt for a title.
190
+
191
+ The escape hatch for 'a': used when no visible row carries the wanted project.
192
+ """
193
+ options: list[tuple[str, str | Text]] = [(str(p.id), p.name) for p in self._core.list_projects()]
194
+ # Preselect the project plain 'a' would target, so Enter-Enter behaves like 'a'.
195
+ task_id = self._cursor_task_id()
196
+ if task_id is not None:
197
+ current = str(self._core.get_task(task_id).project_id)
198
+ else:
199
+ state = self._core.app_state()
200
+ current = str(state.selected_project_id or state.default_project_id)
201
+
202
+ def on_project(choice: str | None) -> None:
203
+ if choice is None:
204
+ return
205
+ project_id = int(choice)
206
+ name = self._core.get_project(project_id).name
207
+
208
+ def on_title(title: str | None) -> None:
209
+ if not title:
210
+ return
211
+ try:
212
+ self._core.add_task(title, project_id=project_id)
213
+ except AppError as e:
214
+ self.notify(str(e), severity="error")
215
+ return
216
+ self.reload()
217
+ # The task is invisible when a different project filter is active — confirm it landed.
218
+ filter_id = self._core.app_state().selected_project_id
219
+ if filter_id is not None and filter_id != project_id:
220
+ self.notify(f'Added to "{name}"')
221
+
222
+ self.app.push_screen(InputDialog(f'New task in "{name}":'), on_title)
223
+
224
+ self.app.push_screen(SelectDialog("Add task to project:", options, current), on_project)
225
+
186
226
  def _cursor_task_id(self) -> int | None:
187
227
  """Return the id of the task under the cursor, or None on an empty table.
188
228
 
@@ -297,7 +337,7 @@ class TaskListScreen(Screen[None]):
297
337
  self.reload()
298
338
 
299
339
  def action_refresh(self) -> None:
300
- """Reload the list: apply pending re-sorts/hides from d/s and pick up outside changes (e.g. CLI adds)."""
340
+ """Reload the list: apply pending re-sorts/hides from d/s and pick up changes from another instance."""
301
341
  self.reload()
302
342
 
303
343
  def action_focus_project(self) -> None:
tasko-0.0.1/README.md DELETED
@@ -1,34 +0,0 @@
1
- # tasko
2
-
3
- A personal task manager: a fast Textual TUI over local SQLite, with a minimal
4
- CLI for quick capture.
5
-
6
- ## Usage
7
-
8
- `tasko` with no arguments launches the TUI — the primary interface, where all
9
- task and project management lives.
10
-
11
- The CLI is deliberately minimal (see
12
- [docs/architecture.md](docs/architecture.md) for why):
13
-
14
- ```sh
15
- tasko add "fix nginx" -p infra # quick capture from the shell
16
- tasko list # a quick look without launching the TUI
17
- ```
18
-
19
- Data is a single SQLite file under `$XDG_DATA_HOME/tasko` (default
20
- `~/.local/share/tasko`); override the directory with `--data-dir` or the
21
- `TASKO_DATA_DIR` env var.
22
-
23
- ## Design
24
-
25
- - [docs/architecture.md](docs/architecture.md) — layering (`core/` + `cli/` +
26
- `tui/`), the TUI-first UI strategy, tool choices.
27
- - [docs/data-model.md](docs/data-model.md) — entities, the full SQL schema,
28
- and the reasoning behind it.
29
- - [docs/non-goals.md](docs/non-goals.md) — features deliberately not built,
30
- and why.
31
-
32
- ## License
33
-
34
- [MIT](LICENSE)
@@ -1 +0,0 @@
1
- """CLI adapter."""
@@ -1,89 +0,0 @@
1
- """CLI adapter: quick capture and a quick look at the list. The TUI is the primary interface."""
2
-
3
- # ruff: noqa: T201 -- printing to stdout is this module's purpose: every command's user-facing
4
- # output goes through print(); the rule targets stray debug prints, which cannot exist here.
5
-
6
- import argparse
7
- import os
8
- import sys
9
- from importlib.metadata import version
10
- from pathlib import Path
11
-
12
- from tasko.core.core import Core
13
- from tasko.core.errors import AppError
14
- from tasko.core.models import Priority, Status
15
-
16
- DATA_DIR_ENV_VAR = "TASKO_DATA_DIR" # overrides the default data directory
17
-
18
-
19
- def main() -> None:
20
- """Entry point: no subcommand launches the TUI; `add` and `list` serve capture and a quick look."""
21
- args = _build_parser().parse_args()
22
- core = Core(_resolve_db_path(args.data_dir))
23
- try:
24
- if args.command is None:
25
- # Lazy import: quick capture (`tasko add`) must not pay the TUI framework's import cost.
26
- from tasko.tui.app import TaskoApp # noqa: PLC0415
27
-
28
- TaskoApp(core).run()
29
- elif args.command == "add":
30
- _cmd_add(core, args)
31
- else:
32
- _cmd_list(core, args)
33
- except AppError as e:
34
- sys.exit(f"error: {e}")
35
- finally:
36
- core.close()
37
-
38
-
39
- def _resolve_db_path(data_dir: Path | None = None) -> Path:
40
- """Path to the SQLite database file, resolving the data directory as CLI arg → env var → XDG default."""
41
- if data_dir is None:
42
- env = os.environ.get(DATA_DIR_ENV_VAR)
43
- if env:
44
- data_dir = Path(env)
45
- else:
46
- xdg_data_home = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
47
- data_dir = xdg_data_home / "tasko"
48
- return data_dir / "tasko.db"
49
-
50
-
51
- def _build_parser() -> argparse.ArgumentParser:
52
- """Build the argument parser with the `add` and `list` subcommands."""
53
- parser = argparse.ArgumentParser(prog="tasko", description="Personal task manager.")
54
- parser.add_argument("--version", action="version", version=version("tasko"))
55
- parser.add_argument("--data-dir", type=Path, help=f"data directory (env: {DATA_DIR_ENV_VAR}; default: XDG data dir)")
56
- subparsers = parser.add_subparsers(dest="command")
57
-
58
- add = subparsers.add_parser("add", help="add a task")
59
- add.add_argument("title", help="task title")
60
- add.add_argument("-p", "--project", help="project name (default: the default project)")
61
- add.add_argument("-b", "--body", help="longer description")
62
- add.add_argument("--priority", type=Priority, choices=list(Priority), default=Priority.MEDIUM, help="task priority")
63
-
64
- list_ = subparsers.add_parser("list", help="list tasks (open ones by default)")
65
- list_.add_argument("-p", "--project", help="filter by project name")
66
- list_.add_argument("-s", "--status", type=Status, choices=list(Status), help="filter by status")
67
- list_.add_argument("--all", action="store_true", help="include done tasks")
68
- return parser
69
-
70
-
71
- def _cmd_add(core: Core, args: argparse.Namespace) -> None:
72
- """Create a task and confirm where it landed."""
73
- project_id = core.get_project_by_name(args.project).id if args.project is not None else None
74
- task = core.add_task(args.title, project_id=project_id, body=args.body, priority=args.priority)
75
- print(f"Task #{task.id} created in project '{task.project_name}'.")
76
-
77
-
78
- def _cmd_list(core: Core, args: argparse.Namespace) -> None:
79
- """Print tasks as an aligned table."""
80
- project_id = core.get_project_by_name(args.project).id if args.project is not None else None
81
- tasks = core.list_tasks(project_id=project_id, status=args.status, include_done=args.all)
82
- if not tasks:
83
- print("No tasks.")
84
- return
85
- header = ("ID", "Project", "Status", "Priority", "Title")
86
- rows = [(str(t.id), t.project_name, t.status, t.priority, t.title) for t in tasks]
87
- widths = [max(len(row[i]) for row in (header, *rows)) for i in range(len(header))]
88
- for row in (header, *rows):
89
- print(" ".join(cell.ljust(width) for cell, width in zip(row, widths, strict=True)).rstrip())
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes