kicker 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.
- kicker/__init__.py +0 -0
- kicker/jobs/__init__.py +1 -0
- kicker/jobs/echo.py +25 -0
- kicker/main.py +176 -0
- kicker/py.typed +1 -0
- kicker-0.1.0.dist-info/METADATA +148 -0
- kicker-0.1.0.dist-info/RECORD +9 -0
- kicker-0.1.0.dist-info/WHEEL +4 -0
- kicker-0.1.0.dist-info/entry_points.txt +3 -0
kicker/__init__.py
ADDED
|
File without changes
|
kicker/jobs/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .echo import job_echo
|
kicker/jobs/echo.py
ADDED
|
@@ -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())
|
kicker/main.py
ADDED
|
@@ -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
|
+
)
|
kicker/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file for PEP 561 typing
|
|
@@ -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
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
kicker/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
kicker/jobs/__init__.py,sha256=iVelpeu-dGzP9hIW-kNHr4HFMe8-O7OSJKZ9kYrjWA0,26
|
|
3
|
+
kicker/jobs/echo.py,sha256=VZniDcsBHLRYwMXjnnr6CTsMhSQT2MgPa5QQbhyQdTg,713
|
|
4
|
+
kicker/main.py,sha256=MpXIgf2gt8FGtpD8Hra7ATDWEsu5KVao_xKu81ObYYU,5845
|
|
5
|
+
kicker/py.typed,sha256=gePzxl3QkLQT9wa9JkY4TUjZlhwvqlSs4sVxi_1I3M4,33
|
|
6
|
+
kicker-0.1.0.dist-info/WHEEL,sha256=o6xtdofIa8Zz80kUveEHMWeAWtEyZSzYS1bbyKDCgzA,80
|
|
7
|
+
kicker-0.1.0.dist-info/entry_points.txt,sha256=4ylRoxv8O3PnJ8OcslTx2dWNRIsFbaVO9YXZMqqO-0U,40
|
|
8
|
+
kicker-0.1.0.dist-info/METADATA,sha256=llZPx5jg-csvqNbNGi_fW1X3tTQhuWsTm_WD1xqVyf8,2067
|
|
9
|
+
kicker-0.1.0.dist-info/RECORD,,
|