pyclifer 0.4.1__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.
Files changed (105) hide show
  1. pyclifer/__init__.py +159 -0
  2. pyclifer/apps/__init__.py +8 -0
  3. pyclifer/apps/demo/__init__.py +25 -0
  4. pyclifer/apps/demo/apps/__init__.py +0 -0
  5. pyclifer/apps/demo/apps/tasks/__init__.py +19 -0
  6. pyclifer/apps/demo/apps/tasks/commands/__init__.py +10 -0
  7. pyclifer/apps/demo/apps/tasks/commands/add.py +27 -0
  8. pyclifer/apps/demo/apps/tasks/commands/complete.py +12 -0
  9. pyclifer/apps/demo/apps/tasks/commands/delete.py +15 -0
  10. pyclifer/apps/demo/apps/tasks/commands/list.py +38 -0
  11. pyclifer/apps/demo/apps/tasks/commands/show.py +12 -0
  12. pyclifer/apps/demo/apps/tasks/commands/sync.py +16 -0
  13. pyclifer/apps/demo/apps/tasks/interfaces.py +208 -0
  14. pyclifer/apps/demo/apps/tasks/models.py +61 -0
  15. pyclifer/apps/demo/apps/tasks/renderers.py +152 -0
  16. pyclifer/apps/demo/apps/tasks/tables.py +5 -0
  17. pyclifer/apps/demo/apps/users/__init__.py +19 -0
  18. pyclifer/apps/demo/apps/users/commands/__init__.py +6 -0
  19. pyclifer/apps/demo/apps/users/commands/list.py +12 -0
  20. pyclifer/apps/demo/apps/users/commands/whoami.py +11 -0
  21. pyclifer/apps/demo/apps/users/interfaces.py +127 -0
  22. pyclifer/apps/demo/apps/users/models.py +18 -0
  23. pyclifer/apps/demo/apps/users/tables.py +5 -0
  24. pyclifer/apps/demo/commands/__init__.py +3 -0
  25. pyclifer/apps/demo/core/__init__.py +1 -0
  26. pyclifer/apps/demo/core/constants.py +9 -0
  27. pyclifer/apps/demo/core/context.py +28 -0
  28. pyclifer/apps/demo/core/options.py +12 -0
  29. pyclifer/apps/demo/core/storage.py +156 -0
  30. pyclifer/apps/demo/interfaces.py +24 -0
  31. pyclifer/apps/demo/models.py +12 -0
  32. pyclifer/apps/demo/tables.py +5 -0
  33. pyclifer/apps/project/__init__.py +14 -0
  34. pyclifer/apps/project/commands/__init__.py +9 -0
  35. pyclifer/apps/project/commands/add/__init__.py +19 -0
  36. pyclifer/apps/project/commands/add/app.py +27 -0
  37. pyclifer/apps/project/commands/add/command.py +19 -0
  38. pyclifer/apps/project/commands/add/group_cmd.py +14 -0
  39. pyclifer/apps/project/commands/add/integration.py +16 -0
  40. pyclifer/apps/project/commands/init.py +36 -0
  41. pyclifer/apps/project/interfaces.py +633 -0
  42. pyclifer/apps/project/renderers.py +121 -0
  43. pyclifer/apps/project/tables.py +57 -0
  44. pyclifer/apps/project/templates/app_commands_init.py.jinja2 +3 -0
  45. pyclifer/apps/project/templates/app_core_constants.py.jinja2 +1 -0
  46. pyclifer/apps/project/templates/app_core_context.py.jinja2 +10 -0
  47. pyclifer/apps/project/templates/app_core_init.py.jinja2 +1 -0
  48. pyclifer/apps/project/templates/app_core_options.py.jinja2 +1 -0
  49. pyclifer/apps/project/templates/app_init.py.jinja2 +19 -0
  50. pyclifer/apps/project/templates/app_init_flat.py.jinja2 +3 -0
  51. pyclifer/apps/project/templates/app_init_with_core.py.jinja2 +21 -0
  52. pyclifer/apps/project/templates/app_interfaces.py.jinja2 +26 -0
  53. pyclifer/apps/project/templates/app_interfaces_with_core.py.jinja2 +29 -0
  54. pyclifer/apps/project/templates/app_models.py.jinja2 +12 -0
  55. pyclifer/apps/project/templates/app_tables.py.jinja2 +5 -0
  56. pyclifer/apps/project/templates/command.py.jinja2 +11 -0
  57. pyclifer/apps/project/templates/gitignore.jinja2 +19 -0
  58. pyclifer/apps/project/templates/integration_package_client.py.jinja2 +8 -0
  59. pyclifer/apps/project/templates/integration_package_helpers.py.jinja2 +1 -0
  60. pyclifer/apps/project/templates/integration_package_init.py.jinja2 +10 -0
  61. pyclifer/apps/project/templates/integration_package_models.py.jinja2 +1 -0
  62. pyclifer/apps/project/templates/integration_simple.py.jinja2 +8 -0
  63. pyclifer/apps/project/templates/project_apps_init.py.jinja2 +3 -0
  64. pyclifer/apps/project/templates/project_cli.py.jinja2 +16 -0
  65. pyclifer/apps/project/templates/project_constants.py.jinja2 +1 -0
  66. pyclifer/apps/project/templates/project_context.py.jinja2 +11 -0
  67. pyclifer/apps/project/templates/project_integrations_init.py.jinja2 +1 -0
  68. pyclifer/apps/project/templates/project_options.py.jinja2 +1 -0
  69. pyclifer/apps/project/templates/project_package_init.py.jinja2 +1 -0
  70. pyclifer/apps/project/templates/pyproject_poetry.toml.jinja2 +48 -0
  71. pyclifer/apps/project/templates/pyproject_uv.toml.jinja2 +51 -0
  72. pyclifer/apps/project/templates/readme.md.jinja2 +41 -0
  73. pyclifer/apps/project/templates/tests_conftest.py.jinja2 +18 -0
  74. pyclifer/apps/project/templates/tests_init.py.jinja2 +0 -0
  75. pyclifer/cli.py +18 -0
  76. pyclifer/core/__init__.py +23 -0
  77. pyclifer/core/callbacks.py +42 -0
  78. pyclifer/core/classes.py +270 -0
  79. pyclifer/core/context.py +34 -0
  80. pyclifer/core/decorators.py +735 -0
  81. pyclifer/core/interfaces/__init__.py +5 -0
  82. pyclifer/core/interfaces/base.py +88 -0
  83. pyclifer/core/log/__init__.py +44 -0
  84. pyclifer/core/log/config.py +319 -0
  85. pyclifer/core/log/filters.py +169 -0
  86. pyclifer/core/log/formatters.py +45 -0
  87. pyclifer/core/log/handlers.py +71 -0
  88. pyclifer/core/log/levels.py +59 -0
  89. pyclifer/core/mixins/__init__.py +14 -0
  90. pyclifer/core/mixins/cli.py +70 -0
  91. pyclifer/core/mixins/output.py +284 -0
  92. pyclifer/core/mixins/response.py +150 -0
  93. pyclifer/core/mixins/rich.py +105 -0
  94. pyclifer/core/models.py +49 -0
  95. pyclifer/core/output/__init__.py +18 -0
  96. pyclifer/core/output/exit_codes.py +59 -0
  97. pyclifer/core/output/renderer.py +328 -0
  98. pyclifer/core/output/responses.py +229 -0
  99. pyclifer/core/output/tables.py +200 -0
  100. pyclifer/core/rich_help_config.py +119 -0
  101. pyclifer-0.4.1.dist-info/METADATA +202 -0
  102. pyclifer-0.4.1.dist-info/RECORD +105 -0
  103. pyclifer-0.4.1.dist-info/WHEEL +4 -0
  104. pyclifer-0.4.1.dist-info/entry_points.txt +2 -0
  105. pyclifer-0.4.1.dist-info/licenses/LICENSE +21 -0
pyclifer/__init__.py ADDED
@@ -0,0 +1,159 @@
1
+ """pyclifer — PYthon Command Line Interface Framework"""
2
+
3
+ __app_name__ = "pyclifer"
4
+ __version__ = "0.4.1"
5
+
6
+ from click_extra import (
7
+ BOOL,
8
+ FLOAT,
9
+ INT,
10
+ STRING,
11
+ UUID,
12
+ Abort,
13
+ BadParameter,
14
+ Choice,
15
+ ClickException,
16
+ DateTime,
17
+ File,
18
+ FloatRange,
19
+ IntRange,
20
+ TimerOption,
21
+ Tuple,
22
+ UsageError,
23
+ argument,
24
+ confirm,
25
+ confirmation_option,
26
+ echo,
27
+ get_current_context,
28
+ make_pass_decorator,
29
+ pass_context,
30
+ pass_obj,
31
+ password_option,
32
+ prompt,
33
+ secho,
34
+ style,
35
+ unstyle,
36
+ version_option,
37
+ )
38
+ from click_extra import (
39
+ Path as ClickPath,
40
+ )
41
+
42
+ from .core.classes import CustomConfigOption, PycliferGroup, PycliferOption
43
+ from .core.context import BaseContext
44
+ from .core.decorators import (
45
+ app_group,
46
+ command,
47
+ group,
48
+ option,
49
+ output_filter_option,
50
+ pagination_options,
51
+ returns_response,
52
+ )
53
+ from .core.interfaces import BaseInterface
54
+ from .core.log import (
55
+ PYCLIFER_LOG_LEVELS,
56
+ TRACE,
57
+ RichExtraFormatter,
58
+ RichExtraStreamHandler,
59
+ SecretsMasker,
60
+ add_trace_method,
61
+ configure_rich_logging,
62
+ get_configured_logger,
63
+ get_logger,
64
+ logger,
65
+ )
66
+ from .core.mixins import (
67
+ GlobalOptionsMixin,
68
+ HandleResponseMixin,
69
+ OutputFormatMixin,
70
+ RichHelpersMixin,
71
+ )
72
+ from .core.models import BaseModel
73
+ from .core.output import (
74
+ BaseRenderer,
75
+ CliTable,
76
+ CliTableColumn,
77
+ ExceptionTable,
78
+ ExitCode,
79
+ OperationResult,
80
+ PaginatedResponse,
81
+ Response,
82
+ ResponseRenderer,
83
+ )
84
+
85
+ __all__ = [
86
+ "__app_name__",
87
+ "__version__",
88
+ # methods
89
+ "app_group",
90
+ "group",
91
+ "command",
92
+ "option",
93
+ # click_extra re-exports
94
+ "argument",
95
+ "pass_context",
96
+ "pass_obj",
97
+ "make_pass_decorator",
98
+ "get_current_context",
99
+ "Choice",
100
+ "ClickPath",
101
+ "File",
102
+ "INT",
103
+ "FLOAT",
104
+ "BOOL",
105
+ "STRING",
106
+ "UUID",
107
+ "DateTime",
108
+ "Tuple",
109
+ "IntRange",
110
+ "FloatRange",
111
+ "echo",
112
+ "secho",
113
+ "style",
114
+ "unstyle",
115
+ "confirm",
116
+ "prompt",
117
+ "ClickException",
118
+ "BadParameter",
119
+ "UsageError",
120
+ "Abort",
121
+ "TimerOption",
122
+ "version_option",
123
+ "confirmation_option",
124
+ "password_option",
125
+ "output_filter_option",
126
+ "pagination_options",
127
+ "returns_response",
128
+ "get_logger",
129
+ "logger",
130
+ "add_trace_method",
131
+ "get_configured_logger",
132
+ "configure_rich_logging",
133
+ # class
134
+ "BaseContext",
135
+ "BaseModel",
136
+ "ExitCode",
137
+ "OperationResult",
138
+ "PaginatedResponse",
139
+ "Response",
140
+ "BaseInterface",
141
+ "BaseRenderer",
142
+ "ResponseRenderer",
143
+ "CliTable",
144
+ "CliTableColumn",
145
+ "ExceptionTable",
146
+ "CustomConfigOption",
147
+ "PycliferGroup",
148
+ "PycliferOption",
149
+ "GlobalOptionsMixin",
150
+ "HandleResponseMixin",
151
+ "OutputFormatMixin",
152
+ "RichHelpersMixin",
153
+ "RichExtraFormatter",
154
+ "RichExtraStreamHandler",
155
+ "SecretsMasker",
156
+ # Constants
157
+ "PYCLIFER_LOG_LEVELS",
158
+ "TRACE",
159
+ ]
@@ -0,0 +1,8 @@
1
+ """Folder apps in cli"""
2
+
3
+ # Import each group here so cli.py can wire them with add_command.
4
+ # Updated automatically by `pyclifer project add app`.
5
+ from .demo import demo
6
+ from .project import project
7
+
8
+ groups = [project, demo]
@@ -0,0 +1,25 @@
1
+ """Demo app group."""
2
+
3
+ from pyclifer import group
4
+
5
+ from .apps.tasks import tasks
6
+ from .apps.users import users
7
+ from .commands import commands
8
+ from .core.context import pass_demo_context
9
+ from .core.options import project_option
10
+
11
+ subgroups = [tasks, users]
12
+
13
+
14
+ @group()
15
+ @project_option
16
+ @pass_demo_context
17
+ def demo(ctx):
18
+ """Demo task manager — reference implementation of all pyclifer features."""
19
+
20
+
21
+ for grp in subgroups:
22
+ demo.add_command(grp)
23
+
24
+ for cmd in commands:
25
+ demo.add_command(cmd)
File without changes
@@ -0,0 +1,19 @@
1
+ """Tasks app group."""
2
+
3
+ from pyclifer import group
4
+
5
+ from .commands import commands
6
+
7
+ subgroups = []
8
+
9
+
10
+ @group()
11
+ def tasks():
12
+ """Tasks group."""
13
+
14
+
15
+ for grp in subgroups:
16
+ tasks.add_command(grp)
17
+
18
+ for cmd in commands:
19
+ tasks.add_command(cmd)
@@ -0,0 +1,10 @@
1
+ """Commands for the Tasks app."""
2
+
3
+ from .add import add
4
+ from .complete import complete
5
+ from .delete import delete
6
+ from .list import list
7
+ from .show import show
8
+ from .sync import sync
9
+
10
+ commands = [add, complete, delete, list, show, sync]
@@ -0,0 +1,27 @@
1
+ from pyclifer import DateTime, Response, command, option
2
+
3
+ from ....core.constants import PRIORITY_CHOICE
4
+ from ....core.context import pass_demo_context
5
+ from ..interfaces import TaskInterface
6
+
7
+
8
+ @command()
9
+ @option("--title", required=True, help="Task title.")
10
+ @option("--description", default="", help="Task description.")
11
+ @option("--priority", type=PRIORITY_CHOICE, default="medium", help="Task priority.")
12
+ @option("--due", type=DateTime(formats=["%Y-%m-%d"]), default=None, help="Due date (YYYY-MM-DD).")
13
+ @option("--tags", default="", help="Comma-separated list of tags.")
14
+ @option("--assignee", default="", help="Assignee username.")
15
+ @pass_demo_context
16
+ def add(ctx, title, description, priority, due, tags, assignee) -> Response:
17
+ """Add a new task."""
18
+ tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else []
19
+ return TaskInterface(ctx).respond(
20
+ "add_task",
21
+ title=title,
22
+ description=description,
23
+ priority=priority,
24
+ due_date=due.date() if due else None,
25
+ tags=tag_list,
26
+ assignee=assignee,
27
+ )
@@ -0,0 +1,12 @@
1
+ from pyclifer import Response, argument, command
2
+
3
+ from ....core.context import pass_demo_context
4
+ from ..interfaces import TaskInterface
5
+
6
+
7
+ @command()
8
+ @argument("task_id")
9
+ @pass_demo_context
10
+ def complete(ctx, task_id) -> Response:
11
+ """Mark a task as done."""
12
+ return TaskInterface(ctx).respond("complete_task", task_id=task_id)
@@ -0,0 +1,15 @@
1
+ from pyclifer import Abort, Response, argument, command, option
2
+
3
+ from ....core.context import pass_demo_context
4
+ from ..interfaces import TaskInterface
5
+
6
+
7
+ @command()
8
+ @argument("task_id")
9
+ @option("--yes", "-y", is_flag=True, default=False, help="Skip confirmation prompt.")
10
+ @pass_demo_context
11
+ def delete(ctx, task_id, yes) -> Response:
12
+ """Delete a task permanently."""
13
+ if not yes and not ctx.ask_confirmation(f"Delete task '{task_id}'?"):
14
+ raise Abort()
15
+ return TaskInterface(ctx).respond("delete_task", task_id=task_id)
@@ -0,0 +1,38 @@
1
+ from pyclifer import (
2
+ PaginatedResponse,
3
+ Response,
4
+ command,
5
+ option,
6
+ output_filter_option,
7
+ pagination_options,
8
+ )
9
+
10
+ from ....core.constants import PRIORITY_CHOICE, STATUS_CHOICE
11
+ from ....core.context import pass_demo_context
12
+ from ..interfaces import TaskInterface
13
+
14
+
15
+ @command()
16
+ @pagination_options()
17
+ @output_filter_option()
18
+ @option("--status", type=STATUS_CHOICE, default=None, help="Filter by status.")
19
+ @option("--priority", type=PRIORITY_CHOICE, default=None, help="Filter by priority.")
20
+ @pass_demo_context
21
+ def list(ctx, status, priority) -> Response:
22
+ """List all tasks with optional filtering and pagination."""
23
+ response = TaskInterface(ctx).respond("list_tasks", status=status, priority=priority)
24
+ page = ctx.click.meta.get("pyclifer.page", 1)
25
+ limit = ctx.click.meta.get("pyclifer.limit", 20)
26
+ results = response.data.get("results", [])
27
+ total = len(results)
28
+ start = (page - 1) * limit
29
+ return PaginatedResponse(
30
+ success=response.success,
31
+ message=response.message,
32
+ data={"results": results[start : start + limit]},
33
+ error_code=response.error_code,
34
+ renderer=response.renderer,
35
+ page=page,
36
+ limit=limit,
37
+ total=total,
38
+ )
@@ -0,0 +1,12 @@
1
+ from pyclifer import Response, argument, command
2
+
3
+ from ....core.context import pass_demo_context
4
+ from ..interfaces import TaskInterface
5
+
6
+
7
+ @command()
8
+ @argument("task_id")
9
+ @pass_demo_context
10
+ def show(ctx, task_id) -> Response:
11
+ """Show details of a specific task."""
12
+ return TaskInterface(ctx).respond("show_task", task_id=task_id)
@@ -0,0 +1,16 @@
1
+ from pyclifer import Response, command, option
2
+
3
+ from ....core.context import pass_demo_context
4
+ from ..interfaces import TaskInterface
5
+
6
+
7
+ @command()
8
+ @option(
9
+ "--source",
10
+ default="https://remote.example.com/tasks",
11
+ help="URL of the remote task source. Supports embedded credentials.",
12
+ )
13
+ @pass_demo_context
14
+ def sync(ctx, source) -> Response:
15
+ """Sync tasks from a remote source."""
16
+ return TaskInterface(ctx).respond("sync_tasks", source=source)
@@ -0,0 +1,208 @@
1
+ """Interface for the Tasks app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import time
7
+ import uuid
8
+ from collections.abc import Iterator
9
+
10
+ from pyclifer import BaseInterface, ExitCode, OperationResult, get_logger
11
+
12
+ from ...core.context import DemoContext
13
+ from .models import Task
14
+ from .renderers import (
15
+ TaskAddRenderer,
16
+ TaskCompleteRenderer,
17
+ TaskDeleteRenderer,
18
+ TaskDetailRenderer,
19
+ TaskListRenderer,
20
+ TaskSyncRenderer,
21
+ )
22
+
23
+ logger = get_logger(__name__)
24
+
25
+ _FAKE_SYNC_TITLES = [
26
+ "Fix login bug",
27
+ "Update documentation",
28
+ "Review open PRs",
29
+ "Deploy to staging",
30
+ "Write integration tests",
31
+ "Refactor auth module",
32
+ "Add rate limiting",
33
+ "Update dependencies",
34
+ ]
35
+
36
+
37
+ class TaskInterface(BaseInterface):
38
+ """Interface for Tasks business logic."""
39
+
40
+ ctx: DemoContext
41
+
42
+ renderers = {
43
+ "list_tasks": TaskListRenderer,
44
+ "add_task": TaskAddRenderer,
45
+ "show_task": TaskDetailRenderer,
46
+ "complete_task": TaskCompleteRenderer,
47
+ "delete_task": TaskDeleteRenderer,
48
+ "sync_tasks": TaskSyncRenderer,
49
+ # --- renderers --- (used by `pyclifer project add command` — do not remove)
50
+ }
51
+
52
+ def list_tasks(
53
+ self,
54
+ status: str | None = None,
55
+ priority: str | None = None,
56
+ ) -> list[OperationResult]:
57
+ """Return all tasks, optionally filtered by status and priority.
58
+
59
+ Pagination is handled at the command level via PaginatedResponse.
60
+
61
+ Args:
62
+ status: Only return tasks with this status. None means no filter.
63
+ priority: Only return tasks with this priority. None means no filter.
64
+
65
+ Returns:
66
+ One OperationResult per matching task.
67
+ """
68
+ tasks = self.ctx.storage.get_tasks()
69
+ if status:
70
+ tasks = [t for t in tasks if t.status == status]
71
+ if priority:
72
+ tasks = [t for t in tasks if t.priority == priority]
73
+ logger.debug("list tasks: %d results (status=%s priority=%s)", len(tasks), status, priority)
74
+ return [OperationResult.ok(item=t.id, data=t) for t in tasks]
75
+
76
+ def add_task(
77
+ self,
78
+ title: str = "",
79
+ description: str = "",
80
+ priority: str = "medium",
81
+ due_date: datetime.date | None = None,
82
+ tags: list[str] | None = None,
83
+ assignee: str = "",
84
+ ) -> list[OperationResult]:
85
+ """Create and persist a new task.
86
+
87
+ Args:
88
+ title: Short task title.
89
+ description: Optional longer description.
90
+ priority: One of lows, medium, high.
91
+ due_date: Optional due date.
92
+ tags: Optional list of tag strings.
93
+ assignee: Optional assignee name.
94
+
95
+ Returns:
96
+ A single OperationResult with the new task as data.
97
+ """
98
+ task = Task(
99
+ id=str(uuid.uuid4()),
100
+ title=title,
101
+ description=description,
102
+ priority=priority,
103
+ due_date=due_date,
104
+ tags=tags or [],
105
+ assignee=assignee,
106
+ created_at=datetime.datetime.now(),
107
+ )
108
+ self.ctx.storage.upsert_task(task)
109
+ logger.debug("add task: created %s", task.id)
110
+ return [OperationResult.ok(item=task.id, message=f"Task '{title}' created.", data=task)]
111
+
112
+ def show_task(self, task_id: str = "") -> list[OperationResult]:
113
+ """Return a single task by id.
114
+
115
+ Args:
116
+ task_id: UUID of the task to retrieve.
117
+
118
+ Returns:
119
+ A single OperationResult with the task as data, or an error result
120
+ with error_code 404 when not found.
121
+ """
122
+ task = self.ctx.storage.get_task(task_id)
123
+ if task is None:
124
+ return [
125
+ OperationResult.error(
126
+ item=task_id,
127
+ message=f"Task '{task_id}' not found.",
128
+ error_code=ExitCode.NOT_FOUND,
129
+ )
130
+ ]
131
+ return [OperationResult.ok(item=task.id, data=task)]
132
+
133
+ def complete_task(self, task_id: str = "") -> list[OperationResult]:
134
+ """Mark a task as done.
135
+
136
+ Args:
137
+ task_id: UUID of the task to complete.
138
+
139
+ Returns:
140
+ A successful result, or an error result when the task is not found
141
+ or already done.
142
+ """
143
+ task = self.ctx.storage.get_task(task_id)
144
+ if task is None:
145
+ return [
146
+ OperationResult.error(
147
+ item=task_id,
148
+ message=f"Task '{task_id}' not found.",
149
+ error_code=ExitCode.NOT_FOUND,
150
+ )
151
+ ]
152
+ if task.status == "done":
153
+ return [
154
+ OperationResult.error(item=task_id, message=f"Task '{task_id}' is already done.")
155
+ ]
156
+ task.status = "done"
157
+ self.ctx.storage.upsert_task(task)
158
+ logger.debug("complete task: %s marked done", task_id)
159
+ return [OperationResult.ok(item=task_id, message=f"Task '{task.title}' marked as done.")]
160
+
161
+ def delete_task(self, task_id: str = "") -> list[OperationResult]:
162
+ """Delete a task permanently.
163
+
164
+ Args:
165
+ task_id: UUID of the task to delete.
166
+
167
+ Returns:
168
+ A successful result, or an error result with error_code 404 when
169
+ the task is not found.
170
+ """
171
+ found = self.ctx.storage.delete_task(task_id)
172
+ if not found:
173
+ return [
174
+ OperationResult.error(
175
+ item=task_id,
176
+ message=f"Task '{task_id}' not found.",
177
+ error_code=ExitCode.NOT_FOUND,
178
+ )
179
+ ]
180
+ logger.debug("delete task: %s removed", task_id)
181
+ return [OperationResult.ok(item=task_id, message=f"Task '{task_id}' deleted.")]
182
+
183
+ def sync_tasks(
184
+ self, source: str = "https://remote.example.com/tasks"
185
+ ) -> Iterator[OperationResult]:
186
+ """Simulate a live sync from a remote source, yielding one result per task.
187
+
188
+ Yields one OperationResult every 0.1 s to drive the streaming renderer.
189
+ Credentials embedded in the URL are masked before logging.
190
+
191
+ Args:
192
+ source: URL of the remote task source. May contain embedded credentials.
193
+
194
+ Yields:
195
+ One OperationResult per imported task.
196
+ """
197
+ logger.debug("sync tasks from %s", source)
198
+ for title in _FAKE_SYNC_TITLES:
199
+ time.sleep(0.1)
200
+ task = Task(
201
+ id=str(uuid.uuid4()),
202
+ title=title,
203
+ created_at=datetime.datetime.now(),
204
+ )
205
+ self.ctx.storage.upsert_task(task)
206
+ yield OperationResult.ok(item=task.id, data=task, message=f"Synced: {title}")
207
+
208
+ # --- commands --- (used by `pyclifer project add command` — do not remove)
@@ -0,0 +1,61 @@
1
+ """Data models for the Tasks app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+
7
+ import pydantic
8
+
9
+ from pyclifer import BaseModel
10
+
11
+ from ...core.constants import PRIORITIES, STATUSES
12
+
13
+
14
+ class Task(BaseModel):
15
+ """Single task in the demo task manager."""
16
+
17
+ id: str
18
+ title: str
19
+ description: str = ""
20
+ priority: str = "medium"
21
+ status: str = "open"
22
+ due_date: datetime.date | None = None
23
+ tags: list[str] = []
24
+ assignee: str = ""
25
+ created_at: datetime.datetime
26
+
27
+ @pydantic.field_validator("priority")
28
+ @classmethod
29
+ def validate_priority(cls, v: str) -> str:
30
+ """Reject priority values outside the allowed set.
31
+
32
+ Args:
33
+ v: The raw priority string.
34
+
35
+ Returns:
36
+ The validated priority string.
37
+
38
+ Raises:
39
+ ValueError: When v is not in PRIORITIES.
40
+ """
41
+ if v not in PRIORITIES:
42
+ raise ValueError(f"priority must be one of {PRIORITIES}, got {v!r}")
43
+ return v
44
+
45
+ @pydantic.field_validator("status")
46
+ @classmethod
47
+ def validate_status(cls, v: str) -> str:
48
+ """Reject status values outside the allowed set.
49
+
50
+ Args:
51
+ v: The raw status string.
52
+
53
+ Returns:
54
+ The validated status string.
55
+
56
+ Raises:
57
+ ValueError: When v is not in STATUSES.
58
+ """
59
+ if v not in STATUSES:
60
+ raise ValueError(f"status must be one of {STATUSES}, got {v!r}")
61
+ return v