vikunja-python 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.
File without changes
File without changes
@@ -0,0 +1,264 @@
1
+ import asyncio
2
+ import os
3
+ from typing import Optional, List
4
+ import typer
5
+ import httpx
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+ from rich.panel import Panel
9
+ from rich import print as rprint
10
+ from dotenv import load_dotenv
11
+
12
+ from vikunja_python.core.client import VikunjaClient
13
+ from vikunja_python.core.models.task import Task
14
+ from vikunja_python.core.models.project import Project
15
+
16
+ load_dotenv()
17
+
18
+ app = typer.Typer(help="Vikunja CLI - Manage your tasks from the terminal")
19
+ console = Console()
20
+
21
+ def get_client():
22
+ base_url = os.getenv("VIKUNJA_URL")
23
+ token = os.getenv("VIKUNJA_API_TOKEN")
24
+ if not base_url or not token:
25
+ rprint("[bold red]Error:[/bold red] VIKUNJA_URL and VIKUNJA_API_TOKEN must be set in .env")
26
+ raise typer.Exit(1)
27
+ return VikunjaClient(base_url, token)
28
+
29
+ @app.command()
30
+ def list_tasks(
31
+ project_id: Optional[int] = typer.Option(None, "--project-id", "-p", help="Project ID to list tasks from"),
32
+ page: int = typer.Option(1, help="Page number"),
33
+ per_page: int = typer.Option(20, help="Items per page"),
34
+ filter: Optional[str] = typer.Option(None, help="Vikunja filter string"),
35
+ expand: Optional[List[str]] = typer.Option(
36
+ None,
37
+ help=(
38
+ "Fields to expand: 'subtasks', 'comments', 'reactions', 'buckets', 'comment_count', 'is_unread'. "
39
+ "Note: list-tasks returns descriptions and assignees by default."
40
+ )
41
+ )
42
+ ):
43
+ """List tasks with rich formatting."""
44
+ async def _list():
45
+ async with get_client() as client:
46
+ params = {"page": page, "per_page": per_page}
47
+ if filter: params["filter"] = filter
48
+ if expand: params["expand"] = expand
49
+
50
+ if project_id is None:
51
+ path = "/tasks"
52
+ elif project_id < 0:
53
+ path = "/tasks"
54
+ params["project_id"] = project_id
55
+ else:
56
+ path = f"/projects/{project_id}/tasks"
57
+ data = await client.request("GET", path, params=params)
58
+
59
+ if isinstance(data, dict) and "error" in data:
60
+ rprint(f"[bold red]Error:[/bold red] {data['error']}")
61
+ return
62
+
63
+ table = Table(title="Vikunja Tasks")
64
+ table.add_column("ID", style="cyan", no_wrap=True)
65
+ table.add_column("Status", width=6)
66
+ table.add_column("Title", style="magenta")
67
+ table.add_column("Assignees", style="green")
68
+ table.add_column("Due Date", style="yellow")
69
+ table.add_column("Labels", style="blue")
70
+
71
+ tasks = [Task(**item) for item in data]
72
+ for t in tasks:
73
+ status = "[green]DONE[/green]" if t.done else "[yellow]TODO[/yellow]"
74
+ due = t.due_date.strftime("%Y-%m-%d %H:%M") if t.due_date else ""
75
+ labels = ", ".join(l.title for l in t.labels) if t.labels else ""
76
+ assignees = ", ".join(u.username for u in t.assignees) if t.assignees else ""
77
+
78
+ table.add_row(str(t.id), status, t.title, assignees, due, labels)
79
+ if t.description:
80
+ # Add description preview in a dimmed style
81
+ desc = t.description.split('\n')[0]
82
+ if len(desc) > 80: desc = desc[:77] + "..."
83
+ table.add_row("", "", f"[dim] Desc: {desc}[/dim]", "", "", "")
84
+
85
+ console.print(table)
86
+
87
+ asyncio.run(_list())
88
+
89
+ @app.command()
90
+ def list_saved_filter(
91
+ filter_id: int = typer.Option(..., "--filter-id", "-f", help="Saved filter ID (negative, e.g., -2 for Due in 3 Days, -3 Overdue, -4 Due Today)"),
92
+ page: int = typer.Option(1, help="Page number"),
93
+ per_page: int = typer.Option(50, help="Items per page"),
94
+ ):
95
+ """List tasks from a saved filter."""
96
+ async def _list():
97
+ async with get_client() as client:
98
+ params = {"page": page, "per_page": per_page, "project_id": filter_id}
99
+ data = await client.request("GET", "/tasks", params=params)
100
+ if isinstance(data, dict) and "error" in data:
101
+ rprint(f"[bold red]Error:[/bold red] {data['error']}")
102
+ return
103
+
104
+ table = Table(title=f"Saved Filter {filter_id}")
105
+ table.add_column("ID", style="cyan", no_wrap=True)
106
+ table.add_column("Status", width=6)
107
+ table.add_column("Title", style="magenta")
108
+ table.add_column("Due Date", style="yellow")
109
+ table.add_column("Labels", style="blue")
110
+
111
+ tasks = [Task(**item) for item in data]
112
+ for t in tasks:
113
+ status = "[green]DONE[/green]" if t.done else "[yellow]TODO[/yellow]"
114
+ due = t.due_date.strftime("%Y-%m-%d %H:%M") if t.due_date else ""
115
+ labels = ", ".join(l.title for l in t.labels) if t.labels else ""
116
+ table.add_row(str(t.id), status, t.title, due, labels)
117
+
118
+ console.print(table)
119
+ asyncio.run(_list())
120
+
121
+ @app.command()
122
+ def summary(
123
+ top_n: int = typer.Option(5, "--top", "-t", help="Number of top tasks to show per filter"),
124
+ ):
125
+ """Quick dashboard summary — counts and top tasks for overdue, due today, due soon."""
126
+ async def _summary():
127
+ async with get_client() as client:
128
+ data = await client.get_dashboard_summary()
129
+ rprint(f"[bold white]Vikunja Task Summary: {data['total']} urgent[/bold white]")
130
+ for section in ["overdue", "due_today", "due_soon"]:
131
+ s = data["filters"].get(section, {})
132
+ labels = {"overdue": "🔴 Overdue", "due_today": "🟡 Due Today", "due_soon": "🟢 Due in 3 Days"}
133
+ rprint(f"\n[bold]{labels.get(section, section)} ({s.get('count', 0)})[/bold]")
134
+ for t in s.get("tasks", [])[:top_n]:
135
+ due = t.get("due_date", "")
136
+ if due and due.startswith("0001"):
137
+ due = ""
138
+ elif due and len(due) > 10:
139
+ due = due[:10]
140
+ due_str = f" ({due})" if due else ""
141
+ rprint(f" #{t['id']} {t['title']}{due_str}")
142
+ if not s.get("tasks"):
143
+ rprint(" [dim]None 🎉[/dim]")
144
+ asyncio.run(_summary())
145
+
146
+ @app.command()
147
+ def get_project(project_id: int):
148
+ """Get project details and views."""
149
+ async def _get():
150
+ async with get_client() as client:
151
+ data = await client.request("GET", f"/projects/{project_id}")
152
+ if isinstance(data, dict) and "error" in data:
153
+ rprint(f"[bold red]Error:[/bold red] {data['error']}")
154
+ return
155
+
156
+ p = Project(**data)
157
+ rprint(Panel(f"[bold cyan]Project:[/bold cyan] {p.title} (ID: {p.id})\n[dim]{p.description or 'No description'}[/dim]"))
158
+
159
+ if p.views:
160
+ table = Table(title="Project Views")
161
+ table.add_column("ID", style="cyan")
162
+ table.add_column("Title", style="magenta")
163
+ table.add_column("Kind", style="yellow")
164
+ for v in p.views:
165
+ table.add_row(str(v.get('id')), v.get('title'), v.get('view_kind'))
166
+ console.print(table)
167
+ asyncio.run(_get())
168
+
169
+ @app.command()
170
+ def list_view_tasks(project_id: int, view_id: int):
171
+ """List all tasks in a specific project view with full descriptions."""
172
+ async def _list():
173
+ async with get_client() as client:
174
+ data = await client.request("GET", f"/projects/{project_id}/views/{view_id}/tasks")
175
+ if isinstance(data, dict) and "error" in data:
176
+ rprint(f"[bold red]Error:[/bold red] {data['error']}")
177
+ return
178
+
179
+ # Flatten buckets if needed
180
+ all_tasks = []
181
+ if isinstance(data, list) and len(data) > 0:
182
+ if "tasks" in data[0]:
183
+ for bucket in data:
184
+ for t_item in bucket.get("tasks", []):
185
+ all_tasks.append(Task(**t_item))
186
+ else:
187
+ all_tasks = [Task(**item) for item in data]
188
+
189
+ for t in all_tasks:
190
+ status = "[green]DONE[/green]" if t.done else "[yellow]TODO[/yellow]"
191
+ rprint(f"[bold cyan]ID: {t.id}[/bold cyan] {status} [bold magenta]{t.title}[/bold magenta]")
192
+ if t.assignees:
193
+ rprint(f"[dim] Assignees: {', '.join(u.username for u in t.assignees)}[/dim]")
194
+ if t.description:
195
+ rprint(Panel(t.description, subtitle="Description", border_style="dim"))
196
+ rprint("-" * 20)
197
+ asyncio.run(_list())
198
+
199
+ @app.command()
200
+ def get_task(task_id: int):
201
+ """Get full details for a single task."""
202
+ async def _get():
203
+ async with get_client() as client:
204
+ data = await client.request("GET", f"/tasks/{task_id}")
205
+ if isinstance(data, dict) and "error" in data:
206
+ rprint(f"[bold red]Error:[/bold red] {data['error']}")
207
+ return
208
+
209
+ t = Task(**data)
210
+ status = "[bold green]DONE[/bold green]" if t.done else "[bold yellow]TODO[/bold yellow]"
211
+
212
+ rprint(Panel(
213
+ f"[bold cyan]ID:[/bold cyan] {t.id} {status}\n"
214
+ f"[bold cyan]Title:[/bold cyan] {t.title}\n"
215
+ f"[bold cyan]Project:[/bold cyan] {t.project_id}\n"
216
+ f"[bold cyan]Due:[/bold cyan] {t.due_date or 'None'}\n"
217
+ f"[bold cyan]Priority:[/bold cyan] {t.priority}\n"
218
+ f"[bold cyan]Labels:[/bold cyan] {', '.join(l.title for l in t.labels) if t.labels else 'None'}\n"
219
+ f"[bold cyan]Assignees:[/bold cyan] {', '.join(u.username for u in t.assignees) if t.assignees else 'None'}\n\n"
220
+ f"[bold white]Description:[/bold white]\n{t.description or 'No description'}",
221
+ title=f"Task {t.id}",
222
+ border_style="blue"
223
+ ))
224
+ asyncio.run(_get())
225
+
226
+ @app.command()
227
+ def create_task(
228
+ title: str,
229
+ project_id: int,
230
+ description: Optional[str] = typer.Option(None, help="Task description"),
231
+ due_date: Optional[str] = typer.Option(None, help="Due date (natural language ok)")
232
+ ):
233
+ """Create a new task."""
234
+ async def _create():
235
+ async with get_client() as client:
236
+ payload = {"title": title}
237
+ if description: payload["description"] = description
238
+ if due_date: payload["due_date"] = due_date
239
+
240
+ data = await client.request("PUT", f"/projects/{project_id}/tasks", json=payload)
241
+ if isinstance(data, dict) and "error" in data:
242
+ rprint(f"[bold red]Error:[/bold red] {data['error']}")
243
+ else:
244
+ rprint(f"[bold green]Task created![/bold green] ID: {data['id']}")
245
+
246
+ asyncio.run(_create())
247
+
248
+ @app.command()
249
+ def create_label(title: str, hex_color: Optional[str] = typer.Option(None, help="Label color")):
250
+ """Create a new label."""
251
+ async def _create():
252
+ async with get_client() as client:
253
+ payload = {"title": title}
254
+ if hex_color: payload["hex_color"] = hex_color
255
+ data = await client.request("PUT", "/labels", json=payload)
256
+ if isinstance(data, dict) and "error" in data:
257
+ rprint(f"[bold red]Error:[/bold red] {data['error']}")
258
+ else:
259
+ rprint(f"[bold green]Label created![/bold green] ID: {data['id']} - {data['title']}")
260
+
261
+ asyncio.run(_create())
262
+
263
+ if __name__ == "__main__":
264
+ app()
File without changes
@@ -0,0 +1,106 @@
1
+ import os
2
+ import sys
3
+ import logging
4
+ from typing import Optional
5
+ import httpx
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+ # Set up logging for CLI/MCP
9
+ def setup_logging(is_mcp: bool = False):
10
+ # Support VIKUNJA_DEBUG=true or 1 to enable DEBUG logs
11
+ debug_val = os.getenv("VIKUNJA_DEBUG", "").lower()
12
+ level = logging.DEBUG if debug_val in ("1", "true", "yes", "on") else logging.INFO
13
+
14
+ if is_mcp:
15
+ # MCP must log to stderr to avoid corrupting JSON-RPC on stdout
16
+ logging.basicConfig(
17
+ level=level,
18
+ format="%(levelname)s: %(message)s",
19
+ stream=sys.stderr,
20
+ force=True # Ensure we override any default handlers from libraries
21
+ )
22
+ else:
23
+ logging.basicConfig(
24
+ level=level,
25
+ format="%(levelname)s: %(message)s",
26
+ force=True
27
+ )
28
+
29
+ class VikunjaClient:
30
+ """
31
+ Core HTTP client for Vikunja API.
32
+ Handles both JWT and API Key authentication.
33
+ """
34
+ def __init__(self, base_url: str, token: str, is_api_key: bool = True):
35
+ self.base_url = base_url.rstrip("/")
36
+ self.token = token
37
+ self.is_api_key = is_api_key
38
+
39
+ headers = {
40
+ "Authorization": f"Bearer {token}",
41
+ "Content-Type": "application/json"
42
+ }
43
+ # Respect SSL_CERT_FILE explicitly — more reliable than relying on
44
+ # certifi's env-var propagation which can be lost in subprocess spawns.
45
+ verify = os.environ.get("SSL_CERT_FILE") or os.environ.get("REQUESTS_CA_BUNDLE") or True
46
+ self.client = httpx.AsyncClient(
47
+ base_url=self.base_url,
48
+ headers=headers,
49
+ timeout=30.0,
50
+ verify=verify,
51
+ )
52
+
53
+ async def __aenter__(self):
54
+ return self
55
+
56
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
57
+ await self.client.aclose()
58
+
59
+ async def request(self, method: str, path: str, **kwargs):
60
+ """Wrapper for httpx requests with error handling."""
61
+ try:
62
+ resp = await self.client.request(method, path, **kwargs)
63
+ resp.raise_for_status()
64
+ if resp.status_code == 204:
65
+ return None
66
+ return resp.json()
67
+ except httpx.HTTPStatusError as e:
68
+ # Return structured error for LLM/CLI to digest
69
+ error_data = {"error": str(e), "status_code": e.response.status_code}
70
+ try:
71
+ error_data["details"] = e.response.json()
72
+ except:
73
+ error_data["details"] = e.response.text
74
+ return error_data
75
+ except Exception as e:
76
+ return {"error": str(e), "status_code": 500}
77
+
78
+ async def get_dashboard_summary(self) -> dict:
79
+ """Quick polling summary for ePaper dashboard. Queries saved filters in parallel."""
80
+ filters = {
81
+ "overdue": -3,
82
+ "due_today": -4,
83
+ "due_soon": -2,
84
+ }
85
+ import asyncio
86
+ async def fetch(label: str, filter_id: int) -> dict:
87
+ data = await self.request("GET", "/tasks", params={
88
+ "page": 1, "per_page": 10, "project_id": filter_id
89
+ })
90
+ if isinstance(data, dict) and "error" in data:
91
+ return {"label": label, "count": 0, "tasks": [], "error": data["error"]}
92
+ tasks = data if isinstance(data, list) else []
93
+ return {
94
+ "label": label,
95
+ "count": len(tasks),
96
+ "tasks": [
97
+ {"id": t.get("id"), "title": t.get("title"),
98
+ "due_date": t.get("due_date"), "priority": t.get("priority", 0)}
99
+ for t in tasks[:10]
100
+ ]
101
+ }
102
+ results = await asyncio.gather(*[
103
+ fetch(label, fid) for label, fid in filters.items()
104
+ ])
105
+ total = sum(r["count"] for r in results)
106
+ return {"total": total, "filters": {r["label"]: r for r in results}}