qbit-cli 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.
- qbit/__init__.py +4 -0
- qbit/cli.py +105 -0
- qbit/config.py +25 -0
- qbit/storage.py +38 -0
- qbit/todo.py +78 -0
- qbit_cli-0.1.0.dist-info/METADATA +103 -0
- qbit_cli-0.1.0.dist-info/RECORD +10 -0
- qbit_cli-0.1.0.dist-info/WHEEL +5 -0
- qbit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- qbit_cli-0.1.0.dist-info/top_level.txt +1 -0
qbit/__init__.py
ADDED
qbit/cli.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Command-line interface for qbit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Sequence
|
|
8
|
+
|
|
9
|
+
from .config import default_data_file
|
|
10
|
+
from .storage import TodoStorage
|
|
11
|
+
from .todo import TodoList
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
15
|
+
parser = argparse.ArgumentParser(prog="qbit", description="Manage your todos from the CLI")
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"--data-file",
|
|
18
|
+
type=Path,
|
|
19
|
+
default=None,
|
|
20
|
+
help="Override the default todo data file path.",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
24
|
+
|
|
25
|
+
add_parser = subparsers.add_parser("add", help="Add a new todo")
|
|
26
|
+
add_parser.add_argument("text", help="Todo text")
|
|
27
|
+
|
|
28
|
+
subparsers.add_parser("list", help="List todos")
|
|
29
|
+
|
|
30
|
+
done_parser = subparsers.add_parser("done", help="Mark a todo as completed")
|
|
31
|
+
done_parser.add_argument("id", type=int, help="Todo id")
|
|
32
|
+
|
|
33
|
+
delete_parser = subparsers.add_parser("delete", help="Delete a todo")
|
|
34
|
+
delete_parser.add_argument("id", type=int, help="Todo id")
|
|
35
|
+
|
|
36
|
+
subparsers.add_parser("clear", help="Clear completed todos")
|
|
37
|
+
|
|
38
|
+
return parser
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
42
|
+
parser = build_parser()
|
|
43
|
+
args = parser.parse_args(argv)
|
|
44
|
+
|
|
45
|
+
data_file = args.data_file if args.data_file else default_data_file()
|
|
46
|
+
storage = TodoStorage(data_file)
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
todo_list = TodoList(storage.load())
|
|
50
|
+
except ValueError as exc:
|
|
51
|
+
print(f"Error: {exc}")
|
|
52
|
+
return 2
|
|
53
|
+
|
|
54
|
+
if args.command == "add":
|
|
55
|
+
try:
|
|
56
|
+
item = todo_list.add(args.text)
|
|
57
|
+
except ValueError as exc:
|
|
58
|
+
print(f"Error: {exc}")
|
|
59
|
+
return 2
|
|
60
|
+
storage.save(todo_list.items())
|
|
61
|
+
print(f"Added [{item.id}] {item.text}")
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
if args.command == "list":
|
|
65
|
+
items = todo_list.items()
|
|
66
|
+
if not items:
|
|
67
|
+
print("No todos yet.")
|
|
68
|
+
return 0
|
|
69
|
+
for item in items:
|
|
70
|
+
status = "x" if item.done else " "
|
|
71
|
+
print(f"[{item.id}] [{status}] {item.text}")
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
if args.command == "done":
|
|
75
|
+
try:
|
|
76
|
+
item = todo_list.mark_done(args.id)
|
|
77
|
+
except KeyError as exc:
|
|
78
|
+
print(f"Error: {exc}")
|
|
79
|
+
return 2
|
|
80
|
+
storage.save(todo_list.items())
|
|
81
|
+
print(f"Completed [{item.id}] {item.text}")
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
if args.command == "delete":
|
|
85
|
+
try:
|
|
86
|
+
item = todo_list.delete(args.id)
|
|
87
|
+
except KeyError as exc:
|
|
88
|
+
print(f"Error: {exc}")
|
|
89
|
+
return 2
|
|
90
|
+
storage.save(todo_list.items())
|
|
91
|
+
print(f"Deleted [{item.id}] {item.text}")
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
if args.command == "clear":
|
|
95
|
+
removed = todo_list.clear_completed()
|
|
96
|
+
storage.save(todo_list.items())
|
|
97
|
+
print(f"Removed {removed} completed todo(s)")
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
parser.print_help()
|
|
101
|
+
return 2
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
raise SystemExit(main())
|
qbit/config.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Configuration and OS-aware path helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
APP_NAME = "qbit"
|
|
10
|
+
DEFAULT_FILE_NAME = "todos.json"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def default_data_file() -> Path:
|
|
14
|
+
"""Return the default todo data file path for the current OS."""
|
|
15
|
+
if sys.platform.startswith("win"):
|
|
16
|
+
appdata = os.environ.get("APPDATA")
|
|
17
|
+
base_dir = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming"
|
|
18
|
+
return base_dir / APP_NAME / DEFAULT_FILE_NAME
|
|
19
|
+
|
|
20
|
+
if sys.platform == "darwin":
|
|
21
|
+
return Path.home() / "Library" / "Application Support" / APP_NAME / DEFAULT_FILE_NAME
|
|
22
|
+
|
|
23
|
+
xdg_data_home = os.environ.get("XDG_DATA_HOME")
|
|
24
|
+
base_dir = Path(xdg_data_home) if xdg_data_home else Path.home() / ".local" / "share"
|
|
25
|
+
return base_dir / APP_NAME / DEFAULT_FILE_NAME
|
qbit/storage.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Persistence layer for todo data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .todo import TodoItem
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TodoStorage:
|
|
12
|
+
"""Reads and writes todos from a JSON file."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, data_file: Path) -> None:
|
|
15
|
+
self._data_file = data_file
|
|
16
|
+
|
|
17
|
+
def load(self) -> list[TodoItem]:
|
|
18
|
+
if not self._data_file.exists():
|
|
19
|
+
return []
|
|
20
|
+
|
|
21
|
+
with self._data_file.open("r", encoding="utf-8") as handle:
|
|
22
|
+
payload = json.load(handle)
|
|
23
|
+
|
|
24
|
+
if not isinstance(payload, list):
|
|
25
|
+
raise ValueError("Corrupted data: expected a JSON list.")
|
|
26
|
+
|
|
27
|
+
return [TodoItem.from_dict(item) for item in payload]
|
|
28
|
+
|
|
29
|
+
def save(self, items: list[TodoItem]) -> None:
|
|
30
|
+
self._data_file.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
temp_file = self._data_file.with_suffix(self._data_file.suffix + ".tmp")
|
|
32
|
+
|
|
33
|
+
serialized = [item.to_dict() for item in items]
|
|
34
|
+
with temp_file.open("w", encoding="utf-8") as handle:
|
|
35
|
+
json.dump(serialized, handle, indent=2)
|
|
36
|
+
handle.write("\n")
|
|
37
|
+
|
|
38
|
+
temp_file.replace(self._data_file)
|
qbit/todo.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Core todo domain logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, asdict
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class TodoItem:
|
|
11
|
+
"""A single todo item."""
|
|
12
|
+
|
|
13
|
+
id: int
|
|
14
|
+
text: str
|
|
15
|
+
done: bool = False
|
|
16
|
+
created_at: str = ""
|
|
17
|
+
|
|
18
|
+
def to_dict(self) -> dict:
|
|
19
|
+
return asdict(self)
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_dict(cls, payload: dict) -> "TodoItem":
|
|
23
|
+
return cls(
|
|
24
|
+
id=int(payload["id"]),
|
|
25
|
+
text=str(payload["text"]),
|
|
26
|
+
done=bool(payload.get("done", False)),
|
|
27
|
+
created_at=str(payload.get("created_at", "")),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TodoList:
|
|
32
|
+
"""Mutable list of todo items with stable integer IDs."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, items: list[TodoItem] | None = None) -> None:
|
|
35
|
+
self._items = items[:] if items else []
|
|
36
|
+
|
|
37
|
+
def items(self) -> list[TodoItem]:
|
|
38
|
+
return self._items[:]
|
|
39
|
+
|
|
40
|
+
def add(self, text: str) -> TodoItem:
|
|
41
|
+
text = text.strip()
|
|
42
|
+
if not text:
|
|
43
|
+
raise ValueError("Todo text cannot be empty.")
|
|
44
|
+
|
|
45
|
+
item = TodoItem(
|
|
46
|
+
id=self._next_id(),
|
|
47
|
+
text=text,
|
|
48
|
+
done=False,
|
|
49
|
+
created_at=datetime.now(timezone.utc).isoformat(),
|
|
50
|
+
)
|
|
51
|
+
self._items.append(item)
|
|
52
|
+
return item
|
|
53
|
+
|
|
54
|
+
def mark_done(self, item_id: int) -> TodoItem:
|
|
55
|
+
item = self._find(item_id)
|
|
56
|
+
item.done = True
|
|
57
|
+
return item
|
|
58
|
+
|
|
59
|
+
def delete(self, item_id: int) -> TodoItem:
|
|
60
|
+
item = self._find(item_id)
|
|
61
|
+
self._items = [existing for existing in self._items if existing.id != item_id]
|
|
62
|
+
return item
|
|
63
|
+
|
|
64
|
+
def clear_completed(self) -> int:
|
|
65
|
+
before = len(self._items)
|
|
66
|
+
self._items = [item for item in self._items if not item.done]
|
|
67
|
+
return before - len(self._items)
|
|
68
|
+
|
|
69
|
+
def _next_id(self) -> int:
|
|
70
|
+
if not self._items:
|
|
71
|
+
return 1
|
|
72
|
+
return max(item.id for item in self._items) + 1
|
|
73
|
+
|
|
74
|
+
def _find(self, item_id: int) -> TodoItem:
|
|
75
|
+
for item in self._items:
|
|
76
|
+
if item.id == item_id:
|
|
77
|
+
return item
|
|
78
|
+
raise KeyError(f"Todo with id {item_id} was not found.")
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qbit-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight CLI todo list manager
|
|
5
|
+
Author: qbit contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: todo,cli,task-manager
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# qbit
|
|
23
|
+
|
|
24
|
+
A lightweight CLI todo app for your terminal.
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- Add tasks
|
|
29
|
+
- List tasks
|
|
30
|
+
- Mark tasks as done
|
|
31
|
+
- Delete tasks
|
|
32
|
+
- Clear completed tasks
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
### Local editable install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install -e .
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Install from built wheel
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
python -m build
|
|
46
|
+
pip install dist/qbit-0.1.0-py3-none-any.whl
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Release to TestPyPI and PyPI
|
|
50
|
+
|
|
51
|
+
Install Twine:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install twine build
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Build artifacts:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
python -m build
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Upload to TestPyPI:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
python -m twine upload --repository testpypi dist/*
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Test install from TestPyPI:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple qbit
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Upload to PyPI:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
python -m twine upload dist/*
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Usage
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
qbit add "Buy milk"
|
|
85
|
+
qbit list
|
|
86
|
+
qbit done 1
|
|
87
|
+
qbit delete 1
|
|
88
|
+
qbit clear
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Data location
|
|
92
|
+
|
|
93
|
+
qbit stores data in an OS-specific user data directory:
|
|
94
|
+
|
|
95
|
+
- Windows: `%APPDATA%/qbit/todos.json`
|
|
96
|
+
- macOS: `~/Library/Application Support/qbit/todos.json`
|
|
97
|
+
- Linux: `$XDG_DATA_HOME/qbit/todos.json` or `~/.local/share/qbit/todos.json`
|
|
98
|
+
|
|
99
|
+
You can override the file path with:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
qbit --data-file /path/to/todos.json list
|
|
103
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
qbit/__init__.py,sha256=OfgglIGcAFrxJZG8INOUhqAEMD_kFrrJDJJIDgbiiy4,73
|
|
2
|
+
qbit/cli.py,sha256=AasEpN2AOFIk9zBKrosbHrQtLLVBNe9dyxxOSum0mEI,3062
|
|
3
|
+
qbit/config.py,sha256=AaQNEm-ZEIv8FaXQ4f9EWBgwtKm560omaVg6s5x1dJY,856
|
|
4
|
+
qbit/storage.py,sha256=w4fimloSxX0VsXMPaMHNN0NCRHFqVhGZnOUlWjfgGjw,1169
|
|
5
|
+
qbit/todo.py,sha256=Dd9auyuWAbLuuMpGEMM1TgXPMaCyHaZrQoDUYhZAgoY,2209
|
|
6
|
+
qbit_cli-0.1.0.dist-info/METADATA,sha256=MocPgXGml-ZfVf9iP1WGSmOOtleOLFTuTeRZ287QEVs,2048
|
|
7
|
+
qbit_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
qbit_cli-0.1.0.dist-info/entry_points.txt,sha256=sIjiORpO06Jn_Mk5JxT_EFy0I6z2qCP1d-eJFKpjX7g,39
|
|
9
|
+
qbit_cli-0.1.0.dist-info/top_level.txt,sha256=uOAbec6EXWbkbDq98NXUpTZfYs-7d-BM943WGSvTY-8,5
|
|
10
|
+
qbit_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
qbit
|