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.
- pyclifer/__init__.py +159 -0
- pyclifer/apps/__init__.py +8 -0
- pyclifer/apps/demo/__init__.py +25 -0
- pyclifer/apps/demo/apps/__init__.py +0 -0
- pyclifer/apps/demo/apps/tasks/__init__.py +19 -0
- pyclifer/apps/demo/apps/tasks/commands/__init__.py +10 -0
- pyclifer/apps/demo/apps/tasks/commands/add.py +27 -0
- pyclifer/apps/demo/apps/tasks/commands/complete.py +12 -0
- pyclifer/apps/demo/apps/tasks/commands/delete.py +15 -0
- pyclifer/apps/demo/apps/tasks/commands/list.py +38 -0
- pyclifer/apps/demo/apps/tasks/commands/show.py +12 -0
- pyclifer/apps/demo/apps/tasks/commands/sync.py +16 -0
- pyclifer/apps/demo/apps/tasks/interfaces.py +208 -0
- pyclifer/apps/demo/apps/tasks/models.py +61 -0
- pyclifer/apps/demo/apps/tasks/renderers.py +152 -0
- pyclifer/apps/demo/apps/tasks/tables.py +5 -0
- pyclifer/apps/demo/apps/users/__init__.py +19 -0
- pyclifer/apps/demo/apps/users/commands/__init__.py +6 -0
- pyclifer/apps/demo/apps/users/commands/list.py +12 -0
- pyclifer/apps/demo/apps/users/commands/whoami.py +11 -0
- pyclifer/apps/demo/apps/users/interfaces.py +127 -0
- pyclifer/apps/demo/apps/users/models.py +18 -0
- pyclifer/apps/demo/apps/users/tables.py +5 -0
- pyclifer/apps/demo/commands/__init__.py +3 -0
- pyclifer/apps/demo/core/__init__.py +1 -0
- pyclifer/apps/demo/core/constants.py +9 -0
- pyclifer/apps/demo/core/context.py +28 -0
- pyclifer/apps/demo/core/options.py +12 -0
- pyclifer/apps/demo/core/storage.py +156 -0
- pyclifer/apps/demo/interfaces.py +24 -0
- pyclifer/apps/demo/models.py +12 -0
- pyclifer/apps/demo/tables.py +5 -0
- pyclifer/apps/project/__init__.py +14 -0
- pyclifer/apps/project/commands/__init__.py +9 -0
- pyclifer/apps/project/commands/add/__init__.py +19 -0
- pyclifer/apps/project/commands/add/app.py +27 -0
- pyclifer/apps/project/commands/add/command.py +19 -0
- pyclifer/apps/project/commands/add/group_cmd.py +14 -0
- pyclifer/apps/project/commands/add/integration.py +16 -0
- pyclifer/apps/project/commands/init.py +36 -0
- pyclifer/apps/project/interfaces.py +633 -0
- pyclifer/apps/project/renderers.py +121 -0
- pyclifer/apps/project/tables.py +57 -0
- pyclifer/apps/project/templates/app_commands_init.py.jinja2 +3 -0
- pyclifer/apps/project/templates/app_core_constants.py.jinja2 +1 -0
- pyclifer/apps/project/templates/app_core_context.py.jinja2 +10 -0
- pyclifer/apps/project/templates/app_core_init.py.jinja2 +1 -0
- pyclifer/apps/project/templates/app_core_options.py.jinja2 +1 -0
- pyclifer/apps/project/templates/app_init.py.jinja2 +19 -0
- pyclifer/apps/project/templates/app_init_flat.py.jinja2 +3 -0
- pyclifer/apps/project/templates/app_init_with_core.py.jinja2 +21 -0
- pyclifer/apps/project/templates/app_interfaces.py.jinja2 +26 -0
- pyclifer/apps/project/templates/app_interfaces_with_core.py.jinja2 +29 -0
- pyclifer/apps/project/templates/app_models.py.jinja2 +12 -0
- pyclifer/apps/project/templates/app_tables.py.jinja2 +5 -0
- pyclifer/apps/project/templates/command.py.jinja2 +11 -0
- pyclifer/apps/project/templates/gitignore.jinja2 +19 -0
- pyclifer/apps/project/templates/integration_package_client.py.jinja2 +8 -0
- pyclifer/apps/project/templates/integration_package_helpers.py.jinja2 +1 -0
- pyclifer/apps/project/templates/integration_package_init.py.jinja2 +10 -0
- pyclifer/apps/project/templates/integration_package_models.py.jinja2 +1 -0
- pyclifer/apps/project/templates/integration_simple.py.jinja2 +8 -0
- pyclifer/apps/project/templates/project_apps_init.py.jinja2 +3 -0
- pyclifer/apps/project/templates/project_cli.py.jinja2 +16 -0
- pyclifer/apps/project/templates/project_constants.py.jinja2 +1 -0
- pyclifer/apps/project/templates/project_context.py.jinja2 +11 -0
- pyclifer/apps/project/templates/project_integrations_init.py.jinja2 +1 -0
- pyclifer/apps/project/templates/project_options.py.jinja2 +1 -0
- pyclifer/apps/project/templates/project_package_init.py.jinja2 +1 -0
- pyclifer/apps/project/templates/pyproject_poetry.toml.jinja2 +48 -0
- pyclifer/apps/project/templates/pyproject_uv.toml.jinja2 +51 -0
- pyclifer/apps/project/templates/readme.md.jinja2 +41 -0
- pyclifer/apps/project/templates/tests_conftest.py.jinja2 +18 -0
- pyclifer/apps/project/templates/tests_init.py.jinja2 +0 -0
- pyclifer/cli.py +18 -0
- pyclifer/core/__init__.py +23 -0
- pyclifer/core/callbacks.py +42 -0
- pyclifer/core/classes.py +270 -0
- pyclifer/core/context.py +34 -0
- pyclifer/core/decorators.py +735 -0
- pyclifer/core/interfaces/__init__.py +5 -0
- pyclifer/core/interfaces/base.py +88 -0
- pyclifer/core/log/__init__.py +44 -0
- pyclifer/core/log/config.py +319 -0
- pyclifer/core/log/filters.py +169 -0
- pyclifer/core/log/formatters.py +45 -0
- pyclifer/core/log/handlers.py +71 -0
- pyclifer/core/log/levels.py +59 -0
- pyclifer/core/mixins/__init__.py +14 -0
- pyclifer/core/mixins/cli.py +70 -0
- pyclifer/core/mixins/output.py +284 -0
- pyclifer/core/mixins/response.py +150 -0
- pyclifer/core/mixins/rich.py +105 -0
- pyclifer/core/models.py +49 -0
- pyclifer/core/output/__init__.py +18 -0
- pyclifer/core/output/exit_codes.py +59 -0
- pyclifer/core/output/renderer.py +328 -0
- pyclifer/core/output/responses.py +229 -0
- pyclifer/core/output/tables.py +200 -0
- pyclifer/core/rich_help_config.py +119 -0
- pyclifer-0.4.1.dist-info/METADATA +202 -0
- pyclifer-0.4.1.dist-info/RECORD +105 -0
- pyclifer-0.4.1.dist-info/WHEEL +4 -0
- pyclifer-0.4.1.dist-info/entry_points.txt +2 -0
- 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,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,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
|