whenly 0.1.0__tar.gz

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.
whenly-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: whenly
3
+ Version: 0.1.0
4
+ Summary: Lightweight persistent job scheduler for Python
5
+ Author: Teja
6
+ License: MIT
7
+ Keywords: scheduler,cron,jobs,tasks,periodic
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Operating System :: OS Independent
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: croniter>=1.3.0
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=7.0; extra == "dev"
23
+ Requires-Dist: pytest-cov; extra == "dev"
24
+ Requires-Dist: ruff; extra == "dev"
25
+
26
+ # whenly
27
+
28
+ Lightweight persistent job scheduler for Python — SQLite-backed, minimal dependencies.
29
+
30
+ ```python
31
+ from whenly import Scheduler
32
+
33
+ s = Scheduler()
34
+
35
+ @s.every(5, minutes)
36
+ def sync_data():
37
+ ...
38
+
39
+ @s.cron("0 9 * * MON")
40
+ def weekly_report():
41
+ ...
42
+
43
+ s.start() # background thread
44
+ ```
45
+
46
+ ## Features
47
+
48
+ - **SQLite persistence** — jobs survive restarts
49
+ - **Cron expressions** via croniter
50
+ - **Interval scheduling** — every N seconds/minutes/hours
51
+ - **One-off delayed jobs** — run once after a delay
52
+ - **Decorator or programmatic API**
53
+ - **Thread-safe** background runner
54
+ - **CLI** for basic management
55
+ - **Zero external dependencies** (except croniter for cron support)
56
+
57
+ ## Install
58
+
59
+ ```bash
60
+ pip install whenly
61
+ ```
62
+
63
+ ## Quick Start
64
+
65
+ ```python
66
+ from whenly import Scheduler
67
+
68
+ s = Scheduler()
69
+
70
+ # Interval jobs
71
+ @s.every(30, seconds)
72
+ def poll_api():
73
+ print("Polling...")
74
+
75
+ # Cron jobs
76
+ @s.cron("0 */2 * * *")
77
+ def cleanup():
78
+ print("Running cleanup...")
79
+
80
+ # One-off delayed job
81
+ s.later(10, minutes, send_notification, msg="Hello")
82
+
83
+ # Start the scheduler (non-blocking)
84
+ s.start()
85
+
86
+ # Or run blocking
87
+ # s.run()
88
+ ```
89
+
90
+ ### Programmatic API
91
+
92
+ ```python
93
+ s.add_job("poll", interval=60, unit="seconds", fn=poll_api)
94
+ s.add_job("cleanup", cron="0 3 * * *", fn=cleanup)
95
+ s.remove_job("poll")
96
+ s.list_jobs()
97
+ ```
98
+
99
+ ## CLI
100
+
101
+ ```bash
102
+ whenly list
103
+ whenly run
104
+ ```
105
+
106
+ ## License
107
+
108
+ MIT
whenly-0.1.0/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # whenly
2
+
3
+ Lightweight persistent job scheduler for Python — SQLite-backed, minimal dependencies.
4
+
5
+ ```python
6
+ from whenly import Scheduler
7
+
8
+ s = Scheduler()
9
+
10
+ @s.every(5, minutes)
11
+ def sync_data():
12
+ ...
13
+
14
+ @s.cron("0 9 * * MON")
15
+ def weekly_report():
16
+ ...
17
+
18
+ s.start() # background thread
19
+ ```
20
+
21
+ ## Features
22
+
23
+ - **SQLite persistence** — jobs survive restarts
24
+ - **Cron expressions** via croniter
25
+ - **Interval scheduling** — every N seconds/minutes/hours
26
+ - **One-off delayed jobs** — run once after a delay
27
+ - **Decorator or programmatic API**
28
+ - **Thread-safe** background runner
29
+ - **CLI** for basic management
30
+ - **Zero external dependencies** (except croniter for cron support)
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install whenly
36
+ ```
37
+
38
+ ## Quick Start
39
+
40
+ ```python
41
+ from whenly import Scheduler
42
+
43
+ s = Scheduler()
44
+
45
+ # Interval jobs
46
+ @s.every(30, seconds)
47
+ def poll_api():
48
+ print("Polling...")
49
+
50
+ # Cron jobs
51
+ @s.cron("0 */2 * * *")
52
+ def cleanup():
53
+ print("Running cleanup...")
54
+
55
+ # One-off delayed job
56
+ s.later(10, minutes, send_notification, msg="Hello")
57
+
58
+ # Start the scheduler (non-blocking)
59
+ s.start()
60
+
61
+ # Or run blocking
62
+ # s.run()
63
+ ```
64
+
65
+ ### Programmatic API
66
+
67
+ ```python
68
+ s.add_job("poll", interval=60, unit="seconds", fn=poll_api)
69
+ s.add_job("cleanup", cron="0 3 * * *", fn=cleanup)
70
+ s.remove_job("poll")
71
+ s.list_jobs()
72
+ ```
73
+
74
+ ## CLI
75
+
76
+ ```bash
77
+ whenly list
78
+ whenly run
79
+ ```
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "whenly"
7
+ version = "0.1.0"
8
+ description = "Lightweight persistent job scheduler for Python"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.10"
12
+ authors = [{name = "Teja"}]
13
+ keywords = ["scheduler", "cron", "jobs", "tasks", "periodic"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ "Operating System :: OS Independent",
25
+ ]
26
+ dependencies = [
27
+ "croniter>=1.3.0",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ dev = ["pytest>=7.0", "pytest-cov", "ruff"]
32
+
33
+ [project.scripts]
34
+ whenly = "whenly.cli:main"
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
41
+
42
+ [tool.ruff]
43
+ target-version = "py310"
44
+ line-length = 100
whenly-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """whenly — Lightweight persistent job scheduler."""
2
+
3
+ from .scheduler import Scheduler
4
+
5
+ __all__ = ["Scheduler"]
6
+ __version__ = "0.1.0"
@@ -0,0 +1,95 @@
1
+ """CLI for whenly."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from datetime import datetime, timezone
8
+
9
+ from .models import JobStatus
10
+ from .store import Store
11
+
12
+
13
+ def main(argv: list[str] | None = None) -> None:
14
+ parser = argparse.ArgumentParser(prog="whenly", description="Lightweight job scheduler")
15
+ parser.add_argument("--db", default="whenly.db", help="Database path")
16
+ sub = parser.add_subparsers(dest="command")
17
+
18
+ sub.add_parser("list", help="List all jobs and next run time")
19
+ sub.add_parser("history", help="Show job run history").add_argument("name")
20
+
21
+ run_parser = sub.add_parser("run", help="Manually trigger a job")
22
+ run_parser.add_argument("name")
23
+
24
+ args = parser.parse_args(argv)
25
+ if not args.command:
26
+ parser.print_help()
27
+ sys.exit(1)
28
+
29
+ store = Store(args.db)
30
+
31
+ if args.command == "list":
32
+ jobs = store.list_jobs()
33
+ if not jobs:
34
+ print("No jobs registered.")
35
+ return
36
+ for j in jobs:
37
+ next_run = j.next_run_at.strftime("%Y-%m-%d %H:%M:%S") if j.next_run_at else "—"
38
+ status = "✓" if j.enabled else "✗"
39
+ sched = _fmt_schedule(j)
40
+ print(f" {status} {j.name:<30} [{sched:<12}] next: {next_run}")
41
+
42
+ elif args.command == "history":
43
+ runs = store.get_runs(args.name, limit=20)
44
+ if not runs:
45
+ print(f"No runs found for '{args.name}'.")
46
+ return
47
+ for r in runs:
48
+ ts = r.started_at.strftime("%Y-%m-%d %H:%M:%S") if r.started_at else "?"
49
+ dur = f"{r.duration_seconds:.1f}s" if r.duration_seconds else "?"
50
+ status_icon = {
51
+ JobStatus.SUCCESS: "✓",
52
+ JobStatus.FAILED: "✗",
53
+ JobStatus.TIMEOUT: "⏱",
54
+ JobStatus.RUNNING: "⟳",
55
+ }.get(r.status, "?")
56
+ err = f" — {r.error_message.splitlines()[-1][:60]}" if r.error_message else ""
57
+ print(f" {status_icon} {ts} {dur:>8} {r.status.value:<8}{err}")
58
+
59
+ elif args.command == "run":
60
+ # For run, we need to try to import and execute the function
61
+ job = store.get_job(args.name)
62
+ if not job:
63
+ print(f"Job '{args.name}' not found.")
64
+ sys.exit(1)
65
+ try:
66
+ parts = job.func_path.rsplit(".", 1)
67
+ if len(parts) == 2:
68
+ import importlib
69
+ mod = importlib.import_module(parts[0])
70
+ func = getattr(mod, parts[1])
71
+ print(f"Running {args.name}...")
72
+ func()
73
+ print("Done.")
74
+ else:
75
+ print(f"Cannot resolve function: {job.func_path}")
76
+ sys.exit(1)
77
+ except Exception as e:
78
+ print(f"Error: {e}")
79
+ sys.exit(1)
80
+
81
+ store.close()
82
+
83
+
84
+ def _fmt_schedule(job) -> str:
85
+ if job.schedule_type.value == "interval":
86
+ return f"every {job.interval_seconds}s"
87
+ elif job.schedule_type.value == "cron":
88
+ return job.cron_expr or "?"
89
+ elif job.schedule_type.value == "once":
90
+ return f"once at {job.scheduled_for or '?'}"
91
+ return job.schedule_type.value
92
+
93
+
94
+ if __name__ == "__main__":
95
+ main()
@@ -0,0 +1,70 @@
1
+ """Data models for whenly."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+ from enum import Enum
9
+ from typing import Any
10
+
11
+
12
+ def _utcnow() -> datetime:
13
+ return datetime.utcnow()
14
+
15
+
16
+ def _uuid() -> str:
17
+ return uuid.uuid4().hex
18
+
19
+
20
+ class ScheduleType(str, Enum):
21
+ INTERVAL = "interval"
22
+ CRON = "cron"
23
+ ONCE = "once"
24
+
25
+
26
+ class MissedPolicy(str, Enum):
27
+ RUN_ONCE = "run_once"
28
+ RUN_ALL = "run_all"
29
+ SKIP = "skip"
30
+
31
+
32
+ class JobStatus(str, Enum):
33
+ SUCCESS = "success"
34
+ FAILED = "failed"
35
+ TIMEOUT = "timeout"
36
+ RUNNING = "running"
37
+ PENDING = "pending"
38
+
39
+
40
+ @dataclass
41
+ class Job:
42
+ name: str
43
+ func_path: str
44
+ schedule_type: ScheduleType
45
+ interval_seconds: int | float | None = None
46
+ cron_expr: str | None = None
47
+ scheduled_for: datetime | None = None
48
+ missed_policy: MissedPolicy = MissedPolicy.RUN_ONCE
49
+ max_concurrent: int = 1
50
+ timeout_seconds: int | None = None
51
+ enabled: bool = True
52
+ id: str = field(default_factory=_uuid)
53
+ next_run_at: datetime | None = None
54
+ last_run_at: datetime | None = None
55
+ created_at: datetime = field(default_factory=_utcnow)
56
+ updated_at: datetime = field(default_factory=_utcnow)
57
+ # Runtime only — not persisted
58
+ func: Any = field(default=None, repr=False, compare=False)
59
+
60
+
61
+ @dataclass
62
+ class JobRun:
63
+ job_id: str
64
+ status: JobStatus = JobStatus.PENDING
65
+ started_at: datetime | None = None
66
+ finished_at: datetime | None = None
67
+ duration_seconds: float | None = None
68
+ error_message: str | None = None
69
+ scheduled_for: datetime | None = None
70
+ id: str = field(default_factory=_uuid)
@@ -0,0 +1,113 @@
1
+ """Job execution engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ import traceback
8
+ from concurrent.futures import Future, ThreadPoolExecutor
9
+ from datetime import datetime
10
+ from typing import Any, Callable
11
+
12
+ from .models import Job, JobRun, JobStatus
13
+ from .store import Store
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class JobRunner:
19
+ """Executes jobs in a thread pool."""
20
+
21
+ def __init__(self, store: Store, max_workers: int = 10) -> None:
22
+ self._store = store
23
+ self._max_workers = max_workers
24
+ self._executor: ThreadPoolExecutor | None = None
25
+ self._futures: dict[str, Future[None]] = {}
26
+ self._lock = threading.Lock()
27
+
28
+ def _ensure_executor(self) -> ThreadPoolExecutor:
29
+ if self._executor is None:
30
+ self._executor = ThreadPoolExecutor(max_workers=self._max_workers)
31
+ return self._executor
32
+
33
+ def submit(self, job: Job, scheduled_for: datetime | None = None) -> bool:
34
+ """Submit a job for execution. Returns True if submitted."""
35
+ if not job.enabled:
36
+ return False
37
+ running = self._store.get_running_count(job.id)
38
+ if running >= job.max_concurrent:
39
+ logger.debug("Job %s at max concurrent (%d), skipping", job.name, job.max_concurrent)
40
+ return False
41
+
42
+ run = JobRun(
43
+ job_id=job.id,
44
+ status=JobStatus.RUNNING,
45
+ started_at=datetime.utcnow(),
46
+ scheduled_for=scheduled_for,
47
+ )
48
+ self._store.save_run(run)
49
+
50
+ executor = self._ensure_executor()
51
+ with self._lock:
52
+ self._futures[run.id] = executor.submit(self._execute, job, run)
53
+ return True
54
+
55
+ def _execute(self, job: Job, run: JobRun) -> None:
56
+ func: Callable[..., Any] = job.func
57
+ if func is None:
58
+ func = _resolve_func(job.func_path)
59
+ if func is None:
60
+ run.status = JobStatus.FAILED
61
+ run.error_message = f"Cannot resolve function: {job.func_path}"
62
+ self._store.update_run(run)
63
+ with self._lock:
64
+ self._futures.pop(run.id, None)
65
+ return
66
+
67
+ try:
68
+ args = getattr(job, '_args', ()) or ()
69
+ kwargs = getattr(job, '_kwargs', {}) or {}
70
+ result = func(*args, **kwargs)
71
+ # Check if result is a future we should wait on
72
+ if isinstance(result, Future):
73
+ result.result(timeout=job.timeout_seconds)
74
+ except Exception as exc:
75
+ if isinstance(exc, TimeoutError):
76
+ run.status = JobStatus.TIMEOUT
77
+ else:
78
+ run.status = JobStatus.FAILED
79
+ run.error_message = traceback.format_exc()
80
+ logger.error("Job %s failed: %s", job.name, exc)
81
+ finally:
82
+ run.finished_at = datetime.utcnow()
83
+ run.duration_seconds = (run.finished_at - (run.started_at or run.finished_at)).total_seconds()
84
+ if run.status == JobStatus.RUNNING:
85
+ run.status = JobStatus.SUCCESS
86
+ self._store.update_run(run)
87
+ self._store.update_last_run(job.id, run.finished_at)
88
+
89
+ with self._lock:
90
+ self._futures.pop(run.id, None)
91
+
92
+ def shutdown(self, wait: bool = True) -> None:
93
+ with self._lock:
94
+ executor = self._executor
95
+ self._executor = None
96
+ if executor is not None:
97
+ executor.shutdown(wait=wait)
98
+ with self._lock:
99
+ self._futures.clear()
100
+
101
+
102
+ def _resolve_func(func_path: str) -> Callable[..., Any] | None:
103
+ """Import a function from its dotted path (e.g. 'mymodule.jobs.sync_data')."""
104
+ try:
105
+ parts = func_path.rsplit(".", 1)
106
+ if len(parts) == 2:
107
+ mod_path, func_name = parts
108
+ import importlib
109
+ mod = importlib.import_module(mod_path)
110
+ return getattr(mod, func_name, None)
111
+ except Exception:
112
+ logger.debug("Cannot resolve %s", func_path, exc_info=True)
113
+ return None