raztodo 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.
- raztodo/__init__.py +0 -0
- raztodo/__main__.py +30 -0
- raztodo/application/__init__.py +0 -0
- raztodo/application/use_cases/__init__.py +0 -0
- raztodo/application/use_cases/create_task.py +60 -0
- raztodo/application/use_cases/delete_task.py +30 -0
- raztodo/application/use_cases/export_task.py +33 -0
- raztodo/application/use_cases/import_task.py +108 -0
- raztodo/application/use_cases/list_tasks.py +50 -0
- raztodo/application/use_cases/mark_task_done.py +31 -0
- raztodo/application/use_cases/migrate_tasks.py +37 -0
- raztodo/application/use_cases/search_tasks.py +37 -0
- raztodo/application/use_cases/update_task.py +49 -0
- raztodo/assets/logo.png +0 -0
- raztodo/assets/preview.gif +0 -0
- raztodo/domain/__init__.py +0 -0
- raztodo/domain/exceptions.py +200 -0
- raztodo/domain/task_entity.py +38 -0
- raztodo/domain/task_repository.py +170 -0
- raztodo/infrastructure/__init__.py +0 -0
- raztodo/infrastructure/container.py +36 -0
- raztodo/infrastructure/logger.py +16 -0
- raztodo/infrastructure/settings.py +31 -0
- raztodo/infrastructure/sqlite/__init__.py +0 -0
- raztodo/infrastructure/sqlite/connection.py +40 -0
- raztodo/infrastructure/sqlite/migrations.py +51 -0
- raztodo/infrastructure/sqlite/task_dao.py +163 -0
- raztodo/infrastructure/sqlite/task_mapper.py +43 -0
- raztodo/infrastructure/sqlite/task_repository.py +290 -0
- raztodo/infrastructure/sqlite/task_schema.py +94 -0
- raztodo/presentation/__init__.py +0 -0
- raztodo/presentation/cli/__init__.py +0 -0
- raztodo/presentation/cli/ansi.py +261 -0
- raztodo/presentation/cli/commands/__init__.py +0 -0
- raztodo/presentation/cli/commands/create_task_cmd.py +97 -0
- raztodo/presentation/cli/commands/delete_task_cmd.py +52 -0
- raztodo/presentation/cli/commands/export_task_cmd.py +48 -0
- raztodo/presentation/cli/commands/import_task_cmd.py +84 -0
- raztodo/presentation/cli/commands/list_tasks_cmd.py +149 -0
- raztodo/presentation/cli/commands/mark_task_done_cmd.py +67 -0
- raztodo/presentation/cli/commands/migrate_tasks_cmd.py +38 -0
- raztodo/presentation/cli/commands/search_tasks_cmd.py +99 -0
- raztodo/presentation/cli/commands/update_task_cmd.py +100 -0
- raztodo/presentation/cli/entrypoint.py +49 -0
- raztodo/presentation/cli/formatters.py +8 -0
- raztodo/presentation/cli/helpers.py +134 -0
- raztodo/presentation/cli/parser.py +68 -0
- raztodo/presentation/cli/router.py +81 -0
- raztodo-0.1.0.dist-info/METADATA +150 -0
- raztodo-0.1.0.dist-info/RECORD +53 -0
- raztodo-0.1.0.dist-info/WHEEL +4 -0
- raztodo-0.1.0.dist-info/entry_points.txt +2 -0
- raztodo-0.1.0.dist-info/licenses/LICENSE +21 -0
raztodo/__init__.py
ADDED
|
File without changes
|
raztodo/__main__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
from raztodo.domain.exceptions import RazTodoException
|
|
4
|
+
from raztodo.infrastructure.container import AppContainer
|
|
5
|
+
from raztodo.infrastructure.logger import get_logger
|
|
6
|
+
from raztodo.presentation.cli.entrypoint import run_cli
|
|
7
|
+
|
|
8
|
+
logger = get_logger("main")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> int:
|
|
12
|
+
container = AppContainer()
|
|
13
|
+
handler = container.task_handler
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
return run_cli(handler=handler)
|
|
17
|
+
except RazTodoException as e:
|
|
18
|
+
logger.exception(e)
|
|
19
|
+
print(f"[Error] {e}", file=sys.stderr)
|
|
20
|
+
return 1
|
|
21
|
+
except Exception as e:
|
|
22
|
+
logger.exception(e)
|
|
23
|
+
print("[Fatal] Unexpected error.", file=sys.stderr)
|
|
24
|
+
return 1
|
|
25
|
+
finally:
|
|
26
|
+
container.close_singleton()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
sys.exit(main())
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from raztodo.domain.exceptions import RazTodoException
|
|
2
|
+
from raztodo.domain.task_repository import TaskRepository
|
|
3
|
+
|
|
4
|
+
MAX_TITLE_LENGTH = 60
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CreateTaskUseCase:
|
|
8
|
+
"""
|
|
9
|
+
Handles creation of a new task with validation and repository interaction.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, repo: TaskRepository) -> None:
|
|
13
|
+
self.repo: TaskRepository = repo
|
|
14
|
+
|
|
15
|
+
def execute(
|
|
16
|
+
self,
|
|
17
|
+
title: str,
|
|
18
|
+
description: str = "",
|
|
19
|
+
priority: str = "",
|
|
20
|
+
due_date: str | None = None,
|
|
21
|
+
tags: list[str] | None = None,
|
|
22
|
+
project: str | None = None,
|
|
23
|
+
) -> int:
|
|
24
|
+
"""
|
|
25
|
+
Create a task after validating title and length.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
title: Task title (required, max 60 chars).
|
|
29
|
+
description: Optional task description.
|
|
30
|
+
priority: Optional task priority.
|
|
31
|
+
due_date: Optional due date in string format.
|
|
32
|
+
tags: Optional list of tags.
|
|
33
|
+
project: Optional project name.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
ID of the newly created task.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
RazTodoException: If validation fails or task creation fails.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
title_stripped: str = title.strip() if title else ""
|
|
43
|
+
if not title_stripped:
|
|
44
|
+
raise RazTodoException("Task title cannot be empty")
|
|
45
|
+
|
|
46
|
+
if len(title_stripped) > MAX_TITLE_LENGTH:
|
|
47
|
+
raise RazTodoException(
|
|
48
|
+
f"Task title too long. Maximum {MAX_TITLE_LENGTH} characters, "
|
|
49
|
+
f"provided {len(title_stripped)}"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
task_id: int | None = self.repo.add_task(
|
|
53
|
+
title_stripped, description, priority, due_date, tags, project
|
|
54
|
+
)
|
|
55
|
+
if not task_id:
|
|
56
|
+
raise RazTodoException(
|
|
57
|
+
f"Failed to create task. Title '{title_stripped}' might already exist or validation failed"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return task_id
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from raztodo.domain.exceptions import RazTodoException
|
|
2
|
+
from raztodo.domain.task_repository import TaskRepository
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DeleteTaskUseCase:
|
|
6
|
+
"""
|
|
7
|
+
Handles deletion of a task by ID via the repository.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, repo: TaskRepository) -> None:
|
|
11
|
+
self.repo: TaskRepository = repo
|
|
12
|
+
|
|
13
|
+
def execute(self, task_id: int) -> bool:
|
|
14
|
+
"""
|
|
15
|
+
Delete a task by its ID.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
task_id: ID of the task to delete.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
True if deletion was successful.
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
RazTodoException: If no task with the given ID exists.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
removed: int = self.repo.remove_task(task_id)
|
|
28
|
+
if removed <= 0:
|
|
29
|
+
raise RazTodoException(f"No task found with id {task_id}")
|
|
30
|
+
return True
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from raztodo.domain.exceptions import RazTodoException
|
|
2
|
+
from raztodo.domain.task_repository import TaskRepository
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ExportTasksUseCase:
|
|
6
|
+
"""
|
|
7
|
+
Handles exporting tasks to a specified file via the repository.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, repo: TaskRepository) -> None:
|
|
11
|
+
"""
|
|
12
|
+
Export all tasks to the given file path.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
filepath: Destination file path for exported tasks.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
True if export was successful.
|
|
19
|
+
|
|
20
|
+
Raises:
|
|
21
|
+
RazTodoException: If export fails (e.g., permission or disk issues).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
self.repo: TaskRepository = repo
|
|
25
|
+
|
|
26
|
+
def execute(self, filepath: str) -> bool:
|
|
27
|
+
success: bool = self.repo.export_tasks(filepath)
|
|
28
|
+
if not success:
|
|
29
|
+
raise RazTodoException(
|
|
30
|
+
f"Failed to export tasks to '{filepath}'. "
|
|
31
|
+
"Check file permissions and disk space."
|
|
32
|
+
)
|
|
33
|
+
return True
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from raztodo.domain.exceptions import RazTodoException
|
|
6
|
+
from raztodo.domain.task_entity import TaskEntity
|
|
7
|
+
from raztodo.domain.task_repository import TaskRepository
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ImportTasksUseCase:
|
|
11
|
+
"""
|
|
12
|
+
Handles importing tasks from a JSON file, with optional upsert support.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, repo: TaskRepository) -> None:
|
|
16
|
+
self.repo: TaskRepository = repo
|
|
17
|
+
|
|
18
|
+
def execute(self, filepath: str, upsert: bool = False) -> int | dict[str, int]:
|
|
19
|
+
"""
|
|
20
|
+
Import tasks from a file, optionally updating existing tasks.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
filepath: Path to the JSON file containing tasks.
|
|
24
|
+
upsert: If True, update existing tasks with matching titles.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Number of tasks imported, or a dict with counts of inserted and updated tasks if upsert is True.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
RazTodoException: If file is missing, unreadable, JSON is invalid, or import fails.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
if not os.path.exists(filepath):
|
|
35
|
+
raise RazTodoException(f"File not found: {filepath}")
|
|
36
|
+
|
|
37
|
+
if not os.access(filepath, os.R_OK):
|
|
38
|
+
raise RazTodoException(f"Permission denied: Cannot read '{filepath}'")
|
|
39
|
+
|
|
40
|
+
if not upsert:
|
|
41
|
+
return self.repo.import_tasks(filepath)
|
|
42
|
+
|
|
43
|
+
with open(filepath, encoding="utf-8") as f:
|
|
44
|
+
data: Any = json.load(f)
|
|
45
|
+
|
|
46
|
+
if not isinstance(data, list):
|
|
47
|
+
raise RazTodoException(
|
|
48
|
+
f"Expected JSON array in '{filepath}', got {type(data).__name__}"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
inserted: int = 0
|
|
52
|
+
updated: int = 0
|
|
53
|
+
for item in data:
|
|
54
|
+
if not isinstance(item, dict):
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
title: str = item.get("title", "").strip()
|
|
58
|
+
if not title:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
desc: str = item.get("description", "")
|
|
62
|
+
priority: str = item.get("priority") or ""
|
|
63
|
+
due_date: str | None = item.get("due_date")
|
|
64
|
+
tags: list[str] = item.get("tags") or []
|
|
65
|
+
project: str | None = item.get("project")
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
inserted_id: int | None = self.repo.add_task(
|
|
69
|
+
title, desc, priority, due_date, tags, project
|
|
70
|
+
)
|
|
71
|
+
if inserted_id:
|
|
72
|
+
if "done" in item:
|
|
73
|
+
self.repo.mark_done(inserted_id, bool(item["done"]))
|
|
74
|
+
inserted += 1
|
|
75
|
+
continue
|
|
76
|
+
except RazTodoException:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
matches: list[TaskEntity] = [
|
|
80
|
+
t for t in self.repo.search_tasks(title) if t.title == title
|
|
81
|
+
]
|
|
82
|
+
if matches:
|
|
83
|
+
task = matches[0]
|
|
84
|
+
self.repo.update_task(
|
|
85
|
+
task.id,
|
|
86
|
+
title=title,
|
|
87
|
+
description=desc,
|
|
88
|
+
priority=priority or None,
|
|
89
|
+
due_date=due_date,
|
|
90
|
+
tags=tags if tags else None,
|
|
91
|
+
project=project,
|
|
92
|
+
)
|
|
93
|
+
if "done" in item:
|
|
94
|
+
self.repo.mark_done(task.id, bool(item["done"]))
|
|
95
|
+
updated += 1
|
|
96
|
+
|
|
97
|
+
return {"inserted": inserted, "updated": updated}
|
|
98
|
+
|
|
99
|
+
except json.JSONDecodeError as e:
|
|
100
|
+
raise RazTodoException(
|
|
101
|
+
f"Invalid JSON format in '{filepath}': {e.msg} at line {e.lineno}"
|
|
102
|
+
) from e
|
|
103
|
+
except UnicodeDecodeError as e:
|
|
104
|
+
raise RazTodoException(f"File encoding error in '{filepath}': {e}") from e
|
|
105
|
+
except OSError as e:
|
|
106
|
+
raise RazTodoException(
|
|
107
|
+
f"I/O error while accessing '{filepath}': {e}"
|
|
108
|
+
) from e
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from raztodo.domain.task_entity import TaskEntity
|
|
2
|
+
from raztodo.domain.task_repository import TaskRepository
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ListTasksUseCase:
|
|
6
|
+
"""
|
|
7
|
+
Retrieves tasks from the repository with optional filtering and pagination.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, repo: TaskRepository) -> None:
|
|
11
|
+
self.repo: TaskRepository = repo
|
|
12
|
+
|
|
13
|
+
def execute(
|
|
14
|
+
self,
|
|
15
|
+
limit: int | None = None,
|
|
16
|
+
offset: int | None = None,
|
|
17
|
+
priority: str | None = None,
|
|
18
|
+
project: str | None = None,
|
|
19
|
+
done: bool | None = None,
|
|
20
|
+
tags: list[str] | None = None,
|
|
21
|
+
due_before: str | None = None,
|
|
22
|
+
due_after: str | None = None,
|
|
23
|
+
) -> list[TaskEntity]:
|
|
24
|
+
"""
|
|
25
|
+
List tasks with optional filters.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
limit: Maximum number of tasks to return.
|
|
29
|
+
offset: Number of tasks to skip.
|
|
30
|
+
priority: Filter by task priority.
|
|
31
|
+
project: Filter by project name.
|
|
32
|
+
done: Filter by completion status.
|
|
33
|
+
tags: Filter by tags.
|
|
34
|
+
due_before: Filter tasks due before this date.
|
|
35
|
+
due_after: Filter tasks due after this date.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
List of TaskEntity objects matching the filters.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
return self.repo.get_tasks(
|
|
42
|
+
limit=limit,
|
|
43
|
+
offset=offset,
|
|
44
|
+
priority=priority,
|
|
45
|
+
project=project,
|
|
46
|
+
done=done,
|
|
47
|
+
tags=tags,
|
|
48
|
+
due_before=due_before,
|
|
49
|
+
due_after=due_after,
|
|
50
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from raztodo.domain.exceptions import RazTodoException
|
|
2
|
+
from raztodo.domain.task_repository import TaskRepository
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class MarkDoneUseCase:
|
|
6
|
+
"""
|
|
7
|
+
Marks a task as done or undone via the repository.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, repo: TaskRepository) -> None:
|
|
11
|
+
self.repo: TaskRepository = repo
|
|
12
|
+
|
|
13
|
+
def execute(self, task_id: int, done: bool = True) -> bool:
|
|
14
|
+
"""
|
|
15
|
+
Update a task's completion status.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
task_id: ID of the task to update.
|
|
19
|
+
done: True to mark as done, False to mark as not done.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
True if the update was successful.
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
RazTodoException: If no task with the given ID exists.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
updated: int = self.repo.mark_done(task_id, done)
|
|
29
|
+
if not updated:
|
|
30
|
+
raise RazTodoException(f"No task found with id {task_id}")
|
|
31
|
+
return True
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from sqlite3 import Connection
|
|
3
|
+
|
|
4
|
+
from raztodo.infrastructure.sqlite.migrations import (
|
|
5
|
+
create_unique_title_index,
|
|
6
|
+
deduplicate_titles,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MigrateUseCase:
|
|
11
|
+
"""
|
|
12
|
+
Handles database migration tasks such as deduplicating titles and creating indexes.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, connection_factory: Callable[[], Connection]) -> None:
|
|
16
|
+
self._connection_factory: Callable[[], Connection] = connection_factory
|
|
17
|
+
|
|
18
|
+
def execute(self) -> dict[str, object]:
|
|
19
|
+
"""
|
|
20
|
+
Perform migration: fix duplicate task titles and create unique title index.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
A dictionary with migration results:
|
|
24
|
+
- 'duplicates_fixed': number of duplicate titles corrected
|
|
25
|
+
- 'unique_index': True if the unique index was created
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
Any exceptions from database operations are propagated.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
conn: Connection = self._connection_factory()
|
|
32
|
+
try:
|
|
33
|
+
updated: int = deduplicate_titles(conn)
|
|
34
|
+
create_unique_title_index(conn)
|
|
35
|
+
return {"duplicates_fixed": updated, "unique_index": True}
|
|
36
|
+
finally:
|
|
37
|
+
conn.close()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from raztodo.domain.task_entity import TaskEntity
|
|
2
|
+
from raztodo.domain.task_repository import TaskRepository
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SearchTasksUseCase:
|
|
6
|
+
"""
|
|
7
|
+
Searches tasks in the repository by keyword with optional filters.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, repo: TaskRepository) -> None:
|
|
11
|
+
self.repo: TaskRepository = repo
|
|
12
|
+
|
|
13
|
+
def execute(
|
|
14
|
+
self,
|
|
15
|
+
keyword: str,
|
|
16
|
+
priority: str | None = None,
|
|
17
|
+
project: str | None = None,
|
|
18
|
+
tags: list[str] | None = None,
|
|
19
|
+
) -> list[TaskEntity]:
|
|
20
|
+
"""
|
|
21
|
+
Search tasks matching a keyword and optional filters.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
keyword: Text to search in task titles/descriptions.
|
|
25
|
+
priority: Optional filter by task priority.
|
|
26
|
+
project: Optional filter by project name.
|
|
27
|
+
tags: Optional filter by tags.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
List of TaskEntity objects matching the search criteria.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
if not keyword.strip():
|
|
34
|
+
return []
|
|
35
|
+
return self.repo.search_tasks(
|
|
36
|
+
keyword, priority=priority, project=project, tags=tags
|
|
37
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from raztodo.domain.exceptions import RazTodoException
|
|
2
|
+
from raztodo.domain.task_repository import TaskRepository
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class UpdateTaskUseCase:
|
|
6
|
+
"""
|
|
7
|
+
Updates an existing task's attributes via the repository.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, repo: TaskRepository) -> None:
|
|
11
|
+
self.repo: TaskRepository = repo
|
|
12
|
+
|
|
13
|
+
def execute(
|
|
14
|
+
self,
|
|
15
|
+
task_id: int,
|
|
16
|
+
title: str | None = None,
|
|
17
|
+
description: str | None = None,
|
|
18
|
+
priority: str | None = None,
|
|
19
|
+
due_date: str | None = None,
|
|
20
|
+
tags: list[str] | None = None,
|
|
21
|
+
project: str | None = None,
|
|
22
|
+
) -> bool:
|
|
23
|
+
"""
|
|
24
|
+
Update task fields by ID.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
task_id: ID of the task to update.
|
|
28
|
+
title: Optional new title.
|
|
29
|
+
description: Optional new description.
|
|
30
|
+
priority: Optional new priority.
|
|
31
|
+
due_date: Optional new due date.
|
|
32
|
+
tags: Optional new list of tags.
|
|
33
|
+
project: Optional new project name.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
True if the update was successful.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
RazTodoException: If the task does not exist or no changes were provided.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
updated: int = self.repo.update_task(
|
|
43
|
+
task_id, title, description, priority, due_date, tags, project
|
|
44
|
+
)
|
|
45
|
+
if not updated:
|
|
46
|
+
raise RazTodoException(
|
|
47
|
+
f"No task found with id {task_id} or no changes provided"
|
|
48
|
+
)
|
|
49
|
+
return True
|
raztodo/assets/logo.png
ADDED
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
class RazTodoException(Exception):
|
|
2
|
+
"""Base exception class for all RazTodo domain-specific errors."""
|
|
3
|
+
|
|
4
|
+
pass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def default_message(base: str, **kwargs: str | None) -> str:
|
|
8
|
+
"""
|
|
9
|
+
Builds a formatted message by appending keyword arguments to a base message.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
base (str): The base error message.
|
|
13
|
+
**kwargs (Optional[str]): Optional key-value pairs providing context.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
str: The composed error message.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
parts = [f"{k}='{v}'" for k, v in kwargs.items() if v is not None]
|
|
20
|
+
return f"{base}: {', '.join(parts)}" if parts else base
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TaskNotFoundError(RazTodoException):
|
|
24
|
+
"""
|
|
25
|
+
Raised when a task with the specified identifier cannot be located.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
task_id (int): Identifier of the missing task.
|
|
29
|
+
message (Optional[str]): Custom message override.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, task_id: int, message: str | None = None) -> None:
|
|
33
|
+
self.task_id = task_id
|
|
34
|
+
super().__init__(message or f"No task found with id {task_id}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TaskValidationError(RazTodoException):
|
|
38
|
+
"""
|
|
39
|
+
Raised when a task fails validation rules.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
field (Optional[str]): Name of the invalid field, if applicable.
|
|
43
|
+
value (Optional[str]): Provided invalid value.
|
|
44
|
+
message (Optional[str]): Custom message override.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
field: str | None = None,
|
|
50
|
+
value: str | None = None,
|
|
51
|
+
message: str | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.field = field
|
|
54
|
+
self.value = value
|
|
55
|
+
|
|
56
|
+
if message is None:
|
|
57
|
+
if field and value is not None:
|
|
58
|
+
message = f"Validation failed for field '{field}': {value}"
|
|
59
|
+
elif field:
|
|
60
|
+
message = f"Validation failed for field '{field}'"
|
|
61
|
+
else:
|
|
62
|
+
message = "Task validation failed"
|
|
63
|
+
|
|
64
|
+
super().__init__(message)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DuplicateTaskError(RazTodoException):
|
|
68
|
+
"""
|
|
69
|
+
Raised when attempting to create a task with a title that already exists.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
title (str): Title of the conflicting task.
|
|
73
|
+
message (Optional[str]): Custom message override.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, title: str, message: str | None = None) -> None:
|
|
77
|
+
self.title = title
|
|
78
|
+
super().__init__(message or default_message("Task already exists", title=title))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class FileOperationError(RazTodoException):
|
|
82
|
+
"""
|
|
83
|
+
Raised when a file-related operation fails.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
filepath (Optional[str]): Path of the involved file.
|
|
87
|
+
message (Optional[str]): Custom message override.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, filepath: str | None = None, message: str | None = None) -> None:
|
|
91
|
+
self.filepath = filepath
|
|
92
|
+
super().__init__(
|
|
93
|
+
message or default_message("File operation failed", filepath=filepath)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TaskFileNotFoundError(FileOperationError):
|
|
98
|
+
"""
|
|
99
|
+
Raised when the expected task file is not found.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
filepath (str): Path of the missing file.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, filepath: str) -> None:
|
|
106
|
+
super().__init__(filepath=filepath, message=f"File not found: {filepath}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class FilePermissionError(FileOperationError):
|
|
110
|
+
"""
|
|
111
|
+
Raised when the system lacks permissions for a file operation.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
filepath (str): Path of the target file.
|
|
115
|
+
operation (str): Operation being attempted (e.g., read, write).
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(self, filepath: str, operation: str = "access") -> None:
|
|
119
|
+
super().__init__(
|
|
120
|
+
filepath=filepath,
|
|
121
|
+
message=f"Permission denied: Cannot {operation} '{filepath}'",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class InvalidFileFormatError(FileOperationError):
|
|
126
|
+
"""
|
|
127
|
+
Raised when a file has an invalid or unsupported format.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
filepath (Optional[str]): Path of the problematic file.
|
|
131
|
+
format_type (Optional[str]): File format that failed validation.
|
|
132
|
+
message (Optional[str]): Custom message override.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
filepath: str | None = None,
|
|
138
|
+
format_type: str | None = None,
|
|
139
|
+
message: str | None = None,
|
|
140
|
+
) -> None:
|
|
141
|
+
|
|
142
|
+
self.format_type = format_type
|
|
143
|
+
|
|
144
|
+
if message is None:
|
|
145
|
+
if filepath and format_type:
|
|
146
|
+
message = f"Invalid {format_type} format in file '{filepath}'"
|
|
147
|
+
elif filepath:
|
|
148
|
+
message = f"Invalid file format: {filepath}"
|
|
149
|
+
elif format_type:
|
|
150
|
+
message = f"Invalid {format_type} format"
|
|
151
|
+
else:
|
|
152
|
+
message = "Invalid file format"
|
|
153
|
+
|
|
154
|
+
super().__init__(filepath=filepath, message=message)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class DatabaseError(RazTodoException):
|
|
158
|
+
"""
|
|
159
|
+
Raised when a database operation fails.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
operation (Optional[str]): Type of operation attempted.
|
|
163
|
+
message (Optional[str]): Custom message override.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def __init__(
|
|
167
|
+
self, operation: str | None = None, message: str | None = None
|
|
168
|
+
) -> None:
|
|
169
|
+
self.operation = operation
|
|
170
|
+
super().__init__(
|
|
171
|
+
message or default_message("Database operation failed", operation=operation)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class DatabaseConnectionError(DatabaseError):
|
|
176
|
+
"""
|
|
177
|
+
Raised when a connection to the database cannot be established.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
message (Optional[str]): Custom message override.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def __init__(self, message: str | None = None) -> None:
|
|
184
|
+
super().__init__(
|
|
185
|
+
operation="connection",
|
|
186
|
+
message=message or "Failed to connect to database",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
ERROR_TYPE_MAP: dict[str, type[RazTodoException]] = {
|
|
191
|
+
"""Mapping of string error identifiers to their corresponding exception types."""
|
|
192
|
+
"not_found": TaskNotFoundError,
|
|
193
|
+
"validation": TaskValidationError,
|
|
194
|
+
"duplicate": DuplicateTaskError,
|
|
195
|
+
"permission": FilePermissionError,
|
|
196
|
+
"file_operation": FileOperationError,
|
|
197
|
+
"invalid_format": InvalidFileFormatError,
|
|
198
|
+
"file_not_found": TaskFileNotFoundError,
|
|
199
|
+
"database": DatabaseError,
|
|
200
|
+
}
|