taskwatch 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.
Files changed (43) hide show
  1. taskwatch/__init__.py +3 -0
  2. taskwatch/cli/__init__.py +0 -0
  3. taskwatch/cli/log.py +128 -0
  4. taskwatch/cli/main.py +219 -0
  5. taskwatch/cli/report.py +104 -0
  6. taskwatch/cli/run.py +23 -0
  7. taskwatch/cli/task.py +349 -0
  8. taskwatch/core/__init__.py +0 -0
  9. taskwatch/core/executor.py +232 -0
  10. taskwatch/core/models.py +654 -0
  11. taskwatch/core/notifier.py +298 -0
  12. taskwatch/core/reporter.py +273 -0
  13. taskwatch/core/scheduler.py +345 -0
  14. taskwatch/utils/__init__.py +0 -0
  15. taskwatch/utils/config.py +288 -0
  16. taskwatch/utils/logger.py +49 -0
  17. taskwatch/web/__init__.py +0 -0
  18. taskwatch/web/api/__init__.py +0 -0
  19. taskwatch/web/api/reports.py +141 -0
  20. taskwatch/web/api/runs.py +74 -0
  21. taskwatch/web/api/settings.py +97 -0
  22. taskwatch/web/api/stats.py +24 -0
  23. taskwatch/web/api/tasks.py +203 -0
  24. taskwatch/web/app.py +75 -0
  25. taskwatch/web/routes/__init__.py +0 -0
  26. taskwatch/web/routes/dashboard.py +33 -0
  27. taskwatch/web/routes/logs.py +30 -0
  28. taskwatch/web/routes/reports.py +36 -0
  29. taskwatch/web/routes/settings.py +20 -0
  30. taskwatch/web/routes/tasks.py +71 -0
  31. taskwatch/web/static/css/style.css +32 -0
  32. taskwatch/web/templates/base.html +106 -0
  33. taskwatch/web/templates/dashboard.html +192 -0
  34. taskwatch/web/templates/logs.html +333 -0
  35. taskwatch/web/templates/reports.html +185 -0
  36. taskwatch/web/templates/settings.html +222 -0
  37. taskwatch/web/templates/task_detail.html +142 -0
  38. taskwatch/web/templates/tasks.html +306 -0
  39. taskwatch-0.1.0.dist-info/METADATA +499 -0
  40. taskwatch-0.1.0.dist-info/RECORD +43 -0
  41. taskwatch-0.1.0.dist-info/WHEEL +5 -0
  42. taskwatch-0.1.0.dist-info/entry_points.txt +3 -0
  43. taskwatch-0.1.0.dist-info/top_level.txt +1 -0
taskwatch/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """TaskWatch - A lightweight local task scheduler with CLI, web UI, and email reporting."""
2
+
3
+ __version__ = "0.3.0"
File without changes
taskwatch/cli/log.py ADDED
@@ -0,0 +1,128 @@
1
+ """Log viewing CLI commands."""
2
+
3
+ import time
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from taskwatch.core.models import Database
12
+ from taskwatch.utils.config import get_config
13
+
14
+ console = Console()
15
+ log_app = typer.Typer(help="Log viewing commands", no_args_is_help=True)
16
+
17
+
18
+ @log_app.callback(invoke_without_command=True)
19
+ def view_log(
20
+ task_id: int = typer.Argument(..., help="Task ID to view logs for"),
21
+ tail: int = typer.Option(20, "--tail", "-n", help="Number of recent lines to show"),
22
+ follow: bool = typer.Option(False, "--follow", "-f", help="Follow log output in real-time"),
23
+ run_id: Optional[int] = typer.Option(None, "--run-id", help="Specific run ID to view"),
24
+ ):
25
+ """View task logs."""
26
+ db = Database().connect()
27
+ config = get_config()
28
+
29
+ task = db.get_task(task_id)
30
+ if task is None:
31
+ console.print(f"[red]Task {task_id} not found.[/red]")
32
+ raise typer.Exit(1)
33
+
34
+ if run_id:
35
+ # View specific run log
36
+ run = db.get_run(run_id)
37
+ if run is None or run["task_id"] != task_id:
38
+ console.print(f"[red]Run {run_id} not found for task {task_id}.[/red]")
39
+ raise typer.Exit(1)
40
+
41
+ _display_run_log(run, config, tail)
42
+ else:
43
+ # View recent runs
44
+ runs = db.get_recent_runs(task_id, limit=10)
45
+ if not runs:
46
+ console.print(f"[yellow]No runs found for task '{task['name']}'.[/yellow]")
47
+ db.close()
48
+ return
49
+
50
+ # Show recent runs table
51
+ table = Table(title=f"Recent Runs for '{task['name']}'", show_lines=True)
52
+ table.add_column("Run ID", style="cyan", width=7)
53
+ table.add_column("Start Time", style="white", width=20)
54
+ table.add_column("Status", style="yellow", width=10)
55
+ table.add_column("Duration", style="green", width=10)
56
+ table.add_column("Log File", style="blue", width=40)
57
+
58
+ for r in runs:
59
+ duration_s = f"{r['duration'] / 1000:.1f}s" if r.get("duration") else "-"
60
+ status_color = {
61
+ "success": "green",
62
+ "failed": "red",
63
+ "timeout": "red",
64
+ "running": "yellow",
65
+ "killed": "red",
66
+ }.get(r.get("status", ""), "white")
67
+ table.add_row(
68
+ str(r["id"]),
69
+ r.get("start_time", "-")[:19],
70
+ f"[{status_color}]{r.get('status', '-')}[/{status_color}]",
71
+ duration_s,
72
+ r.get("log_file", "-"),
73
+ )
74
+
75
+ console.print(table)
76
+
77
+ # Show the latest run's log
78
+ latest = runs[0]
79
+ if latest.get("log_file") and Path(latest["log_file"]).exists():
80
+ console.print(f"\n[bold]Latest log (Run #{latest['id']}):[/bold]")
81
+ _display_run_log(latest, config, tail, follow)
82
+
83
+ db.close()
84
+
85
+
86
+ def _display_run_log(
87
+ run: dict,
88
+ config,
89
+ tail: int = 20,
90
+ follow: bool = False,
91
+ ) -> None:
92
+ """Display a run's log content."""
93
+ log_file = run.get("log_file")
94
+ if log_file and Path(log_file).exists():
95
+ path = Path(log_file)
96
+ if follow:
97
+ _follow_file(path)
98
+ else:
99
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
100
+ display_lines = lines[-tail:] if len(lines) > tail else lines
101
+ for line in display_lines:
102
+ console.print(line)
103
+ else:
104
+ # Fallback to database summaries
105
+ if run.get("stdout_summary"):
106
+ console.print("[bold]STDOUT:[/bold]")
107
+ console.print(run["stdout_summary"][:tail * 100])
108
+ if run.get("stderr_summary"):
109
+ console.print("[bold red]STDERR:[/bold red]")
110
+ console.print(run["stderr_summary"][:tail * 100])
111
+ if not run.get("stdout_summary") and not run.get("stderr_summary"):
112
+ console.print("[yellow]No log content available.[/yellow]")
113
+
114
+
115
+ def _follow_file(path: Path) -> None:
116
+ """Follow a log file in real-time (like tail -f)."""
117
+ try:
118
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
119
+ # Seek to end
120
+ f.seek(0, 2)
121
+ while True:
122
+ line = f.readline()
123
+ if line:
124
+ console.print(line, end="")
125
+ else:
126
+ time.sleep(0.5)
127
+ except KeyboardInterrupt:
128
+ pass
taskwatch/cli/main.py ADDED
@@ -0,0 +1,219 @@
1
+ """TaskWatch CLI main application (Typer)."""
2
+
3
+ import typer
4
+
5
+ app = typer.Typer(
6
+ name="taskwatch",
7
+ help="TaskWatch - A lightweight local task scheduler with CLI, web UI, and email reporting.",
8
+ no_args_is_help=True,
9
+ add_completion=False,
10
+ )
11
+
12
+
13
+ @app.callback()
14
+ def main():
15
+ """TaskWatch - Lightweight task scheduler and monitor."""
16
+ pass
17
+
18
+
19
+ @app.command(name="init")
20
+ def init_cmd():
21
+ """Initialize TaskWatch: create directories, database, and config file."""
22
+ from pathlib import Path
23
+ from taskwatch.utils.config import _get_default_dir, Config
24
+ from taskwatch.core.models import Database
25
+ from taskwatch.utils.logger import get_logger
26
+
27
+ logger = get_logger()
28
+
29
+ # Resolve the working directory
30
+ work_dir = _get_default_dir()
31
+
32
+ # Create directory
33
+ work_dir.mkdir(parents=True, exist_ok=True)
34
+ logger.info(f"Created directory: {work_dir}")
35
+
36
+ # Create log directory
37
+ log_dir = work_dir / "logs"
38
+ log_dir.mkdir(parents=True, exist_ok=True)
39
+ logger.info(f"Created log directory: {log_dir}")
40
+
41
+ # Initialize database
42
+ db_path = work_dir / "taskwatch.db"
43
+ db = Database(db_path)
44
+ db.connect()
45
+ db.initialize()
46
+ db.close()
47
+ logger.info(f"Initialized database: {db_path}")
48
+
49
+ # Create default config
50
+ config_path = work_dir / "config.toml"
51
+ if not config_path.exists():
52
+ config = Config(config_path)
53
+ config.load()
54
+ config.save()
55
+ logger.info(f"Created config file: {config_path}")
56
+ else:
57
+ logger.info(f"Config file already exists: {config_path}")
58
+
59
+ # Interactive SMTP setup
60
+ smtp_setup = typer.confirm("Would you like to configure SMTP for email alerts?", default=False)
61
+ if smtp_setup:
62
+ config = Config(config_path).load()
63
+
64
+ host = typer.prompt("SMTP server host", default="smtp.gmail.com")
65
+ port = typer.prompt("SMTP server port", default=587, type=int)
66
+ username = typer.prompt("SMTP username (email address)")
67
+ password = typer.prompt("SMTP password", hide_input=True)
68
+ from_addr = typer.prompt("From email address", default=username)
69
+ to_addrs_str = typer.prompt("Recipient email addresses (comma-separated)")
70
+
71
+ config.set("smtp", "enabled", value=True)
72
+ config.set("smtp", "host", value=host)
73
+ config.set("smtp", "port", value=port)
74
+ config.set("smtp", "username", value=username)
75
+ config.set("smtp", "password", value=password)
76
+ config.set("smtp", "from_addr", value=from_addr)
77
+ config.set("smtp", "to_addrs", value=[a.strip() for a in to_addrs_str.split(",")])
78
+ config.save()
79
+
80
+ logger.info("SMTP configuration saved")
81
+
82
+ # Test email
83
+ test_mail = typer.confirm("Send a test email to verify configuration?", default=False)
84
+ if test_mail:
85
+ from taskwatch.core.notifier import Notifier
86
+ notifier = Notifier()
87
+ if notifier.send_test_email():
88
+ typer.echo("Test email sent successfully!")
89
+ else:
90
+ typer.echo("Failed to send test email. Please check your configuration.", err=True)
91
+
92
+ typer.echo("\nTaskWatch initialized successfully!")
93
+ typer.echo(f" Data directory: {work_dir}")
94
+ typer.echo(f" Config file: {config_path}")
95
+ typer.echo(f" Database: {db_path}")
96
+ typer.echo("\nNext steps:")
97
+ typer.echo(" tw task add --name 'my_task' --command 'echo hello' --schedule '0 9 * * *'")
98
+ typer.echo(" tw run")
99
+
100
+
101
+ @app.command(name="config")
102
+ def config_cmd(
103
+ key: str = typer.Argument(None, help="Config key (e.g., 'smtp.host')"),
104
+ value: str = typer.Argument(None, help="Config value to set"),
105
+ list_all: bool = typer.Option(False, "--list", "-l", help="List all configuration"),
106
+ ):
107
+ """View or modify TaskWatch configuration."""
108
+ from taskwatch.utils.config import get_config, reload_config
109
+
110
+ config = reload_config()
111
+
112
+ if list_all or key is None:
113
+ import json
114
+ typer.echo(json.dumps(config.data, indent=2, ensure_ascii=False))
115
+ return
116
+
117
+ keys = key.split(".")
118
+ if value is None:
119
+ # Get value
120
+ val = config.get(*keys)
121
+ if val is None:
122
+ typer.echo(f"Key '{key}' not found", err=True)
123
+ raise typer.Exit(1)
124
+ typer.echo(f"{key} = {val}")
125
+ else:
126
+ # Set value
127
+ parsed_value = value
128
+ if value.lower() in ("true", "false"):
129
+ parsed_value = value.lower() == "true"
130
+ elif value.isdigit():
131
+ parsed_value = int(value)
132
+ else:
133
+ try:
134
+ parsed_value = float(value)
135
+ except ValueError:
136
+ pass
137
+
138
+ config.set(*keys, value=parsed_value)
139
+ config.save()
140
+ typer.echo(f"Set {key} = {parsed_value}")
141
+
142
+
143
+ @app.command(name="mail")
144
+ def mail_cmd(
145
+ action: str = typer.Argument(..., help="Mail action: 'test'"),
146
+ ):
147
+ """Mail-related commands. Use 'mail test' to test SMTP configuration."""
148
+ if action == "test":
149
+ from taskwatch.core.notifier import Notifier
150
+ notifier = Notifier()
151
+ if not notifier.is_configured:
152
+ typer.echo("SMTP is not configured. Run 'tw init' or 'tw config' to set up SMTP.", err=True)
153
+ raise typer.Exit(1)
154
+ if notifier.send_test_email():
155
+ typer.echo("Test email sent successfully!")
156
+ else:
157
+ typer.echo("Failed to send test email.", err=True)
158
+ raise typer.Exit(1)
159
+ else:
160
+ typer.echo(f"Unknown mail action: {action}. Use 'mail test'.", err=True)
161
+ raise typer.Exit(1)
162
+
163
+
164
+ @app.command(name="web")
165
+ def web_cmd(
166
+ host: str = typer.Option("127.0.0.1", "--host", help="Host to bind to"),
167
+ port: int = typer.Option(8899, "--port", "-p", help="Port to listen on"),
168
+ no_browser: bool = typer.Option(False, "--no-browser", help="Don't auto-open browser"),
169
+ ):
170
+ """Start the TaskWatch web management interface."""
171
+ from taskwatch.utils.config import get_config
172
+ from taskwatch.utils.logger import get_logger
173
+
174
+ logger = get_logger()
175
+
176
+ config = get_config()
177
+ actual_host = host or config.web_host
178
+ actual_port = port or config.web_port
179
+
180
+ logger.info(f"Starting TaskWatch web server on {actual_host}:{actual_port}")
181
+
182
+ # Auto-open browser
183
+ open_browser = not no_browser and config.web_auto_open_browser
184
+ if open_browser:
185
+ import webbrowser
186
+ import threading
187
+
188
+ def _open():
189
+ import time
190
+ time.sleep(1.5)
191
+ webbrowser.open(f"http://{actual_host}:{actual_port}")
192
+
193
+ threading.Thread(target=_open, daemon=True).start()
194
+
195
+ # Start uvicorn
196
+ import uvicorn
197
+ uvicorn.run(
198
+ "taskwatch.web.app:create_app",
199
+ host=actual_host,
200
+ port=actual_port,
201
+ factory=True,
202
+ log_level="info",
203
+ )
204
+
205
+
206
+ # Register sub-commands from other modules
207
+ from taskwatch.cli.task import task_app
208
+ from taskwatch.cli.run import run_app
209
+ from taskwatch.cli.log import log_app
210
+ from taskwatch.cli.report import report_app
211
+
212
+ app.add_typer(task_app, name="task", help="Task management commands")
213
+ app.add_typer(run_app, name="run", help="Run the scheduler")
214
+ app.add_typer(log_app, name="log", help="View task logs")
215
+ app.add_typer(report_app, name="report", help="Generate reports")
216
+
217
+
218
+ if __name__ == "__main__":
219
+ app()
@@ -0,0 +1,104 @@
1
+ """Report commands for TaskWatch CLI."""
2
+
3
+ import typer
4
+
5
+ report_app = typer.Typer(help="Report generation commands", no_args_is_help=True)
6
+
7
+
8
+ @report_app.command("daily")
9
+ def generate_daily(
10
+ date: str = typer.Argument(None, help="Date in YYYY-MM-DD format (default: today)"),
11
+ send: bool = typer.Option(False, "--send", "-s", help="Send report via email after generation"),
12
+ ):
13
+ """Generate a daily report."""
14
+ from taskwatch.core.models import Database
15
+ from taskwatch.core.reporter import Reporter
16
+ from taskwatch.core.notifier import Notifier
17
+ from taskwatch.utils.logger import get_logger
18
+
19
+ logger = get_logger()
20
+
21
+ db = Database().connect()
22
+ reporter = Reporter(db)
23
+ result = reporter.generate_daily_report(date)
24
+
25
+ if result is None:
26
+ db.close()
27
+ typer.echo("No data available for daily report.")
28
+ raise typer.Exit(1)
29
+
30
+ typer.echo(f"Daily report generated (ID: {result['report_id']})")
31
+ typer.echo(f" Date: {result['date']}")
32
+ typer.echo(f" Total: {result['total']}, Success: {result['success_count']}, Fail: {result['fail_count']}")
33
+ typer.echo(f" Success rate: {result['success_rate']}%")
34
+
35
+ if send:
36
+ notifier = Notifier(db)
37
+ if not notifier.is_configured:
38
+ db.close()
39
+ typer.echo("SMTP not configured, cannot send report.", err=True)
40
+ raise typer.Exit(1)
41
+
42
+ from datetime import datetime
43
+ html = result["html_content"]
44
+ subject = f"[TaskWatch] 日报 - {result['date']}"
45
+ recipients = notifier._get_recipients()
46
+ success = notifier._send_email(recipients, subject, html) if recipients else False
47
+
48
+ db.update_report(result["report_id"], {
49
+ "sent_status": "success" if success else "failed",
50
+ "sent_at": datetime.now().isoformat(),
51
+ })
52
+ typer.echo(f" Email sent: {'OK' if success else 'FAILED'}")
53
+
54
+ db.close()
55
+
56
+
57
+ @report_app.command("weekly")
58
+ def generate_weekly(
59
+ start_date: str = typer.Argument(None, help="Start date in YYYY-MM-DD format"),
60
+ end_date: str = typer.Argument(None, help="End date in YYYY-MM-DD format"),
61
+ send: bool = typer.Option(False, "--send", "-s", help="Send report via email after generation"),
62
+ ):
63
+ """Generate a weekly report."""
64
+ from taskwatch.core.models import Database
65
+ from taskwatch.core.reporter import Reporter
66
+ from taskwatch.core.notifier import Notifier
67
+ from taskwatch.utils.logger import get_logger
68
+
69
+ logger = get_logger()
70
+
71
+ db = Database().connect()
72
+ reporter = Reporter(db)
73
+ result = reporter.generate_weekly_report(start_date, end_date)
74
+
75
+ if result is None:
76
+ db.close()
77
+ typer.echo("No data available for weekly report.")
78
+ raise typer.Exit(1)
79
+
80
+ typer.echo(f"Weekly report generated (ID: {result['report_id']})")
81
+ typer.echo(f" Period: {result['start_date']} ~ {result['end_date']}")
82
+ typer.echo(f" Total: {result['total']}, Success: {result['success_count']}")
83
+ typer.echo(f" Success rate: {result['success_rate']}%")
84
+
85
+ if send:
86
+ notifier = Notifier(db)
87
+ if not notifier.is_configured:
88
+ db.close()
89
+ typer.echo("SMTP not configured, cannot send report.", err=True)
90
+ raise typer.Exit(1)
91
+
92
+ from datetime import datetime
93
+ html = result["html_content"]
94
+ subject = f"[TaskWatch] 周报 - {result['start_date']} ~ {result['end_date']}"
95
+ recipients = notifier._get_recipients()
96
+ success = notifier._send_email(recipients, subject, html) if recipients else False
97
+
98
+ db.update_report(result["report_id"], {
99
+ "sent_status": "success" if success else "failed",
100
+ "sent_at": datetime.now().isoformat(),
101
+ })
102
+ typer.echo(f" Email sent: {'OK' if success else 'FAILED'}")
103
+
104
+ db.close()
taskwatch/cli/run.py ADDED
@@ -0,0 +1,23 @@
1
+ """Run command for TaskWatch CLI - starts the scheduler."""
2
+
3
+ import typer
4
+
5
+ run_app = typer.Typer(help="Scheduler commands", no_args_is_help=True)
6
+
7
+
8
+ @run_app.callback(invoke_without_command=True)
9
+ def run_scheduler(
10
+ daemon: bool = typer.Option(False, "--daemon", "-d", help="Run as background daemon (Unix-like only)"),
11
+ ):
12
+ """Start the task scheduler."""
13
+ from taskwatch.core.scheduler import TaskScheduler
14
+ from taskwatch.utils.logger import get_logger
15
+
16
+ logger = get_logger()
17
+
18
+ scheduler = TaskScheduler()
19
+
20
+ if daemon:
21
+ scheduler.run_daemon()
22
+ else:
23
+ scheduler.run_forever()