kicker 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.
kicker-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.3
2
+ Name: kicker
3
+ Version: 0.1.0
4
+ Summary: Lightweight job runner framework built on FastAPI and APScheduler with a simple web UI.
5
+ Author: ANdrey Morozov
6
+ Author-email: ANdrey Morozov <andrey@morozov.lv>
7
+ Requires-Dist: apscheduler>=3.11.2
8
+ Requires-Dist: fastapi>=0.136.1
9
+ Requires-Dist: uvicorn>=0.46.0
10
+ Requires-Python: >=3.14
11
+ Description-Content-Type: text/markdown
12
+
13
+ # kicker
14
+
15
+ Lightweight job runner built on FastAPI and APScheduler with a simple web UI.
16
+
17
+ ## Features
18
+
19
+ * Schedule jobs with APScheduler
20
+ * Run jobs manually from UI
21
+ * Pause/resume scheduler and individual jobs
22
+ * Supports both sync and async functions
23
+ * Simple HTML interface (no frontend framework)
24
+
25
+ ---
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install kicker
31
+ ```
32
+
33
+ ---
34
+
35
+ ## Quick Start
36
+
37
+ Create a project and define your jobs:
38
+
39
+ ```python
40
+ # jobs.py
41
+ import logging
42
+
43
+ logger = logging.getLogger("kicker")
44
+
45
+ def my_job():
46
+ logger.info("Hello from job")
47
+ ```
48
+
49
+ Run the app:
50
+
51
+ ```python
52
+ # main.py
53
+ from fastapi import FastAPI
54
+ from kicker import create_app
55
+
56
+ app = create_app()
57
+ ```
58
+
59
+ Or use your current pattern:
60
+
61
+ ```bash
62
+ uvicorn main:app --reload
63
+ ```
64
+
65
+ ---
66
+
67
+ ## Usage
68
+
69
+ Open in browser:
70
+
71
+ ```
72
+ http://localhost:8000/kicker
73
+ ```
74
+
75
+ From UI you can:
76
+
77
+ * Start / pause scheduler
78
+ * Run jobs manually
79
+ * Pause / resume individual jobs
80
+ * View execution logs
81
+
82
+ ---
83
+
84
+ ## Writing Jobs
85
+
86
+ ### Sync job
87
+
88
+ ```python
89
+ def my_job():
90
+ print("sync job")
91
+ ```
92
+
93
+ ### Async job
94
+
95
+ ```python
96
+ async def my_job():
97
+ await some_async_call()
98
+ ```
99
+
100
+ Both are supported. Sync jobs are executed in a thread pool automatically.
101
+
102
+ ---
103
+
104
+ ## Scheduling Jobs
105
+
106
+ Example:
107
+
108
+ ```python
109
+ scheduler.add_job(
110
+ my_job,
111
+ trigger="cron",
112
+ minute="*/5",
113
+ id="my_job"
114
+ )
115
+ ```
116
+
117
+ ---
118
+
119
+ ## Project Structure (example)
120
+
121
+ ```
122
+ my_project/
123
+ ├── main.py
124
+ └── jobs.py
125
+ ```
126
+
127
+ ---
128
+
129
+ ## Notes
130
+
131
+ * Scheduler runs in-process (not distributed)
132
+ * Running with multiple workers will duplicate job execution
133
+ * Designed for simple internal tools and automation
134
+
135
+ ---
136
+
137
+ ## Roadmap
138
+
139
+ * Job registration decorators
140
+ * Plugin system
141
+ * Persistent job stores
142
+ * Better UI
143
+
144
+ ---
145
+
146
+ ## License
147
+
148
+ MIT
kicker-0.1.0/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # kicker
2
+
3
+ Lightweight job runner built on FastAPI and APScheduler with a simple web UI.
4
+
5
+ ## Features
6
+
7
+ * Schedule jobs with APScheduler
8
+ * Run jobs manually from UI
9
+ * Pause/resume scheduler and individual jobs
10
+ * Supports both sync and async functions
11
+ * Simple HTML interface (no frontend framework)
12
+
13
+ ---
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ pip install kicker
19
+ ```
20
+
21
+ ---
22
+
23
+ ## Quick Start
24
+
25
+ Create a project and define your jobs:
26
+
27
+ ```python
28
+ # jobs.py
29
+ import logging
30
+
31
+ logger = logging.getLogger("kicker")
32
+
33
+ def my_job():
34
+ logger.info("Hello from job")
35
+ ```
36
+
37
+ Run the app:
38
+
39
+ ```python
40
+ # main.py
41
+ from fastapi import FastAPI
42
+ from kicker import create_app
43
+
44
+ app = create_app()
45
+ ```
46
+
47
+ Or use your current pattern:
48
+
49
+ ```bash
50
+ uvicorn main:app --reload
51
+ ```
52
+
53
+ ---
54
+
55
+ ## Usage
56
+
57
+ Open in browser:
58
+
59
+ ```
60
+ http://localhost:8000/kicker
61
+ ```
62
+
63
+ From UI you can:
64
+
65
+ * Start / pause scheduler
66
+ * Run jobs manually
67
+ * Pause / resume individual jobs
68
+ * View execution logs
69
+
70
+ ---
71
+
72
+ ## Writing Jobs
73
+
74
+ ### Sync job
75
+
76
+ ```python
77
+ def my_job():
78
+ print("sync job")
79
+ ```
80
+
81
+ ### Async job
82
+
83
+ ```python
84
+ async def my_job():
85
+ await some_async_call()
86
+ ```
87
+
88
+ Both are supported. Sync jobs are executed in a thread pool automatically.
89
+
90
+ ---
91
+
92
+ ## Scheduling Jobs
93
+
94
+ Example:
95
+
96
+ ```python
97
+ scheduler.add_job(
98
+ my_job,
99
+ trigger="cron",
100
+ minute="*/5",
101
+ id="my_job"
102
+ )
103
+ ```
104
+
105
+ ---
106
+
107
+ ## Project Structure (example)
108
+
109
+ ```
110
+ my_project/
111
+ ├── main.py
112
+ └── jobs.py
113
+ ```
114
+
115
+ ---
116
+
117
+ ## Notes
118
+
119
+ * Scheduler runs in-process (not distributed)
120
+ * Running with multiple workers will duplicate job execution
121
+ * Designed for simple internal tools and automation
122
+
123
+ ---
124
+
125
+ ## Roadmap
126
+
127
+ * Job registration decorators
128
+ * Plugin system
129
+ * Persistent job stores
130
+ * Better UI
131
+
132
+ ---
133
+
134
+ ## License
135
+
136
+ MIT
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "kicker"
3
+ version = "0.1.0"
4
+ description = "Lightweight job runner framework built on FastAPI and APScheduler with a simple web UI."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "ANdrey Morozov", email = "andrey@morozov.lv" }
8
+ ]
9
+ requires-python = ">=3.14"
10
+ dependencies = [
11
+ "apscheduler>=3.11.2",
12
+ "fastapi>=0.136.1",
13
+ "uvicorn>=0.46.0",
14
+ ]
15
+
16
+ [project.scripts]
17
+ kicker = "kicker:main"
18
+
19
+ [build-system]
20
+ requires = ["uv_build>=0.10.4,<0.11.0"]
21
+ build-backend = "uv_build"
22
+
23
+ [tool.hatch.build]
24
+ include = [
25
+ "kicker/py.typed",
26
+ ]
File without changes
@@ -0,0 +1 @@
1
+ from .echo import job_echo
@@ -0,0 +1,25 @@
1
+ import asyncio
2
+ import logging
3
+
4
+ from datetime import datetime
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ async def job_echo():
9
+ execution_time = datetime.now()
10
+ execution_time_str = execution_time.strftime("%Y-%m-%d %H:%M:%S")
11
+ function_name = job_echo.__name__
12
+
13
+ try:
14
+ logger.debug("`%s` starting at %s", function_name, execution_time_str)
15
+ ...
16
+ except BaseException as e:
17
+ logger.exception("Error in `%s`: %s", function_name, str(e))
18
+ return
19
+ else:
20
+ logger.info("`%s` executed as %s", function_name, execution_time_str)
21
+
22
+ # ------------------------------------------------------------------------------------
23
+
24
+ if __name__ == "__main__":
25
+ asyncio.run(job_echo())
@@ -0,0 +1,176 @@
1
+ import asyncio
2
+ import inspect
3
+ import logging
4
+
5
+ from collections import deque
6
+ from importlib.metadata import version
7
+ __version__ = version("kicker")
8
+
9
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
10
+ from apscheduler.schedulers.base import STATE_STOPPED, STATE_RUNNING, STATE_PAUSED
11
+ from contextlib import asynccontextmanager
12
+ from fastapi import FastAPI, Request, status
13
+ from fastapi.responses import HTMLResponse, RedirectResponse
14
+
15
+ from kicker.jobs import job_echo
16
+
17
+ def wrap_job(func):
18
+ if inspect.iscoroutinefunction(func):
19
+ return func
20
+
21
+ async def wrapper(*args, **kwargs):
22
+ loop = asyncio.get_running_loop()
23
+ return await loop.run_in_executor(None, func, *args)
24
+
25
+ return wrapper
26
+
27
+ class ExecutionLogHandler(logging.Handler):
28
+ def __init__(self, fast_api: FastAPI) -> None:
29
+ super().__init__()
30
+ self.app = fast_api
31
+
32
+ def emit(self, record):
33
+ msg = self.format(record)
34
+ self.app.state.execution_log.append(msg)
35
+
36
+ def setup_logging(verbose: bool, fast_api: FastAPI):
37
+ logger = logging.getLogger("kicker")
38
+ logger.setLevel(logging.DEBUG if verbose else logging.INFO)
39
+
40
+ handler = ExecutionLogHandler(fast_api)
41
+ formatter = logging.Formatter("[%(levelname)s] %(message)s")
42
+ handler.setFormatter(formatter)
43
+
44
+ logger.addHandler(handler)
45
+ logger.propagate = False
46
+
47
+
48
+ @asynccontextmanager
49
+ async def lifespan(fast_api: FastAPI):
50
+ fast_api.state.scheduler = AsyncIOScheduler()
51
+ fast_api.state.scheduler.start(paused=True)
52
+ fast_api.state.scheduler.add_job(wrap_job(job_echo), 'cron', day_of_week='mon-fri', hour='9-20/2', minute=0, id='job_echo')
53
+
54
+ fast_api.state.execution_log = deque(maxlen=1000)
55
+
56
+ setup_logging(True, fast_api)
57
+ yield
58
+ fast_api.state.scheduler.shutdown(wait=False)
59
+ app = FastAPI(
60
+ title="kicker", version=__version__, root_path="/kicker", lifespan=lifespan
61
+ )
62
+
63
+ @app.get("/", name="index", response_class=HTMLResponse)
64
+ async def index(request: Request):
65
+ scheduler = request.app.state.scheduler
66
+ execution_log = request.app.state.execution_log
67
+
68
+ scheduler_is_running = scheduler.state == STATE_RUNNING
69
+ scheduler_is_paused = scheduler.state == STATE_PAUSED
70
+
71
+ list_execution_logs_html = []
72
+ for log in reversed(execution_log):
73
+ list_execution_logs_html.append(f"<li>{log}</li>")
74
+
75
+ table_rows_html: list[str] = []
76
+ for job in scheduler.get_jobs():
77
+ table_rows_html.append(f"""
78
+ <tr>
79
+ <td>{job.name}</td>
80
+ <td><form action="{request.url_for('run_job', job_id=job.id)}" id="btn_run_{job.id}" method="get" style="display: inline;">
81
+ <input type="submit" value="run" {'disabled' if scheduler_is_paused else ''}>
82
+ </form></td>
83
+ <td><form action="{request.url_for('pause_job', job_id=job.id)}" id="btn_pause_{job.id}" method="get" style="display: inline;">
84
+ <input type="submit" value="pause" {'disabled' if scheduler_is_paused else ''}>
85
+ </form></td>
86
+ <td><form action="{request.url_for('resume_job', job_id=job.id)}" id="btn_resume_{job.id}" method="get" style="display: inline;">
87
+ <input type="submit" value="resume" {'disabled' if scheduler_is_paused else ''}>
88
+ </form></td>
89
+ <td>{job.next_run_time}</td>
90
+ </tr>""")
91
+
92
+ content = f"""
93
+ <!DOCTYPE html>
94
+ <html>
95
+ <head>
96
+ <title>Scheduler {__version__}</title>
97
+ </head>
98
+ <body>
99
+ <h1>Scheduler Status: {'Running' if scheduler_is_running else 'Paused'}</h1>
100
+
101
+ <form action="{request.url_for('start_scheduler')}" id="btn_start_sched" method="get" style="display: inline;">
102
+ <input type="submit" value="Start Scheduler" {'disabled' if scheduler_is_running else ''}>
103
+ </form><br><br>
104
+
105
+ <table>
106
+ {'\n'.join(table_rows_html)}
107
+ </table>
108
+ <br>
109
+
110
+ <form action="{request.url_for('pause_scheduler')}" id="btn_pause_sched" method="get" style="display: inline;">
111
+ <input type="submit" value="Pause Scheduler" {'disabled' if scheduler_is_paused else ''}>
112
+ </form>
113
+ <h2>Job Executions:</h2>
114
+ <ul>
115
+ {'\n'.join(list_execution_logs_html) if list_execution_logs_html else '<li>No executions yet.</li>'}
116
+ </ul>
117
+
118
+ <script>
119
+ document.addEventListener('DOMContentLoaded', function() {{
120
+
121
+ }});
122
+ </script>
123
+ </body>
124
+ </html>"""
125
+ return HTMLResponse(content=content, status_code=status.HTTP_200_OK)
126
+
127
+ @app.get("/run/{job_id}", name="run_job")
128
+ async def run_job(request: Request, job_id):
129
+ scheduler = request.app.state.scheduler
130
+
131
+ job = scheduler.get_job(job_id)
132
+ if job:
133
+ await job.func()
134
+ return RedirectResponse(
135
+ url=request.url_for("index"), status_code=status.HTTP_302_FOUND
136
+ )
137
+
138
+ @app.get('/pause/{job_id}', name="pause_job")
139
+ async def pause_job(request: Request, job_id):
140
+ scheduler = request.app.state.scheduler
141
+
142
+ scheduler.pause_job(job_id)
143
+ return RedirectResponse(
144
+ url=request.url_for("index"), status_code=status.HTTP_302_FOUND
145
+ )
146
+
147
+ @app.get('/resume/{job_id}', name="resume_job")
148
+ async def resume_job(request: Request, job_id):
149
+ scheduler = request.app.state.scheduler
150
+
151
+ scheduler.resume_job(job_id)
152
+ return RedirectResponse(
153
+ url=request.url_for("index"), status_code=status.HTTP_302_FOUND
154
+ )
155
+
156
+ @app.get('/start_scheduler', name="start_scheduler")
157
+ async def start_scheduler(request: Request):
158
+ scheduler = request.app.state.scheduler
159
+
160
+ if scheduler.state == STATE_STOPPED:
161
+ scheduler.start()
162
+ else:
163
+ scheduler.resume()
164
+ return RedirectResponse(
165
+ url=request.url_for("index"), status_code=status.HTTP_302_FOUND
166
+ )
167
+
168
+ @app.get('/pause_scheduler', name="pause_scheduler")
169
+ async def pause_scheduler(request: Request):
170
+ scheduler = request.app.state.scheduler
171
+
172
+ if scheduler.state == STATE_RUNNING:
173
+ scheduler.pause()
174
+ return RedirectResponse(
175
+ url=request.url_for("index"), status_code=status.HTTP_302_FOUND
176
+ )
@@ -0,0 +1 @@
1
+ # Marker file for PEP 561 typing