pemexe 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.
- pem/__init__.py +0 -0
- pem/cli.py +385 -0
- pem/core/__init__.py +0 -0
- pem/core/executor.py +82 -0
- pem/core/scheduler.py +251 -0
- pem/db/__init__.py +0 -0
- pem/db/database.py +14 -0
- pem/db/models.py +51 -0
- pem/settings.py +3 -0
- pemexe-0.1.0.dist-info/METADATA +275 -0
- pemexe-0.1.0.dist-info/RECORD +15 -0
- pemexe-0.1.0.dist-info/WHEEL +5 -0
- pemexe-0.1.0.dist-info/entry_points.txt +2 -0
- pemexe-0.1.0.dist-info/licenses/LICENSE +21 -0
- pemexe-0.1.0.dist-info/top_level.txt +1 -0
pem/__init__.py
ADDED
|
File without changes
|
pem/cli.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from pem.core.executor import Executor
|
|
7
|
+
from pem.core.scheduler import scheduler_manager
|
|
8
|
+
from pem.db.database import SessionLocal, create_db_and_tables
|
|
9
|
+
from pem.db.models import Job
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="Python Execution Manager (pem)")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.callback(invoke_without_command=True)
|
|
15
|
+
def main(ctx: typer.Context) -> None:
|
|
16
|
+
create_db_and_tables()
|
|
17
|
+
# If no command is provided, show help
|
|
18
|
+
if ctx.invoked_subcommand is None:
|
|
19
|
+
typer.echo(ctx.get_help())
|
|
20
|
+
ctx.exit()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _add_job(name: str, path: str, job_type: str, dependencies: list[str] | None = None) -> Job:
|
|
24
|
+
"""Helper function to add a job to the database."""
|
|
25
|
+
db = SessionLocal()
|
|
26
|
+
try:
|
|
27
|
+
# Check if job with this name already exists
|
|
28
|
+
existing_job = db.query(Job).filter(Job.name == name).first()
|
|
29
|
+
if existing_job:
|
|
30
|
+
typer.secho(f"❌ Job '{name}' already exists.", fg=typer.colors.RED)
|
|
31
|
+
raise typer.Exit(code=1)
|
|
32
|
+
|
|
33
|
+
new_job = Job(name=name, path=path, job_type=job_type, dependencies=dependencies)
|
|
34
|
+
db.add(new_job)
|
|
35
|
+
db.commit()
|
|
36
|
+
db.refresh(new_job)
|
|
37
|
+
typer.secho(f"✅ {job_type.capitalize()} job '{name}' added.", fg=typer.colors.GREEN)
|
|
38
|
+
return new_job
|
|
39
|
+
finally:
|
|
40
|
+
db.close()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_job(name: str) -> Job | None:
|
|
44
|
+
"""Helper function to get a job from the database."""
|
|
45
|
+
db = SessionLocal()
|
|
46
|
+
try:
|
|
47
|
+
return db.query(Job).filter(Job.name == name).first()
|
|
48
|
+
finally:
|
|
49
|
+
db.close()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command("run")
|
|
53
|
+
def run_job(
|
|
54
|
+
name: Annotated[str, typer.Argument(help="Name of the job to run.")],
|
|
55
|
+
path: Annotated[
|
|
56
|
+
str | None,
|
|
57
|
+
typer.Option("--path", help="Path to script/project (required if job doesn't exist)."),
|
|
58
|
+
] = None,
|
|
59
|
+
script: Annotated[bool, typer.Option("-s", "--script", help="Add as script job type.")] = False,
|
|
60
|
+
project: Annotated[bool, typer.Option("-p", "--project", help="Add as project job type.")] = False,
|
|
61
|
+
dependencies: Annotated[
|
|
62
|
+
list[str] | None,
|
|
63
|
+
typer.Option("--with", "-w", help="Dependencies for script jobs."),
|
|
64
|
+
] = None,
|
|
65
|
+
add_only: Annotated[bool, typer.Option("--add-only", help="Only add the job, don't run it.")] = False,
|
|
66
|
+
run_only: Annotated[bool, typer.Option("--run-only", help="Only run existing job, don't add if missing.")] = False,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Run a job. Can add and run, or just add, or just run existing job."""
|
|
69
|
+
# Validate mutually exclusive options
|
|
70
|
+
if script and project:
|
|
71
|
+
typer.secho("❌ Cannot specify both --script and --project.", fg=typer.colors.RED)
|
|
72
|
+
raise typer.Exit(code=1)
|
|
73
|
+
|
|
74
|
+
if add_only and run_only:
|
|
75
|
+
typer.secho("❌ Cannot specify both --add-only and --run-only.", fg=typer.colors.RED)
|
|
76
|
+
raise typer.Exit(code=1)
|
|
77
|
+
|
|
78
|
+
# Get existing job
|
|
79
|
+
db = SessionLocal()
|
|
80
|
+
try:
|
|
81
|
+
job = db.query(Job).filter(Job.name == name).first()
|
|
82
|
+
finally:
|
|
83
|
+
db.close()
|
|
84
|
+
|
|
85
|
+
# If job doesn't exist and we're in run-only mode, error
|
|
86
|
+
if not job and run_only:
|
|
87
|
+
typer.secho(f"❌ Job '{name}' not found and --run-only specified.", fg=typer.colors.RED)
|
|
88
|
+
raise typer.Exit(code=1)
|
|
89
|
+
|
|
90
|
+
# If job doesn't exist, we need to add it
|
|
91
|
+
if not job:
|
|
92
|
+
if not path:
|
|
93
|
+
typer.secho("❌ Path is required when adding a new job.", fg=typer.colors.RED)
|
|
94
|
+
raise typer.Exit(code=1)
|
|
95
|
+
|
|
96
|
+
if not script and not project:
|
|
97
|
+
typer.secho("❌ Must specify either --script (-s) or --project (-p) for new jobs.", fg=typer.colors.RED)
|
|
98
|
+
raise typer.Exit(code=1)
|
|
99
|
+
|
|
100
|
+
job_type = "script" if script else "project"
|
|
101
|
+
|
|
102
|
+
# Validate dependencies only for scripts
|
|
103
|
+
if project and dependencies:
|
|
104
|
+
typer.secho("❌ Dependencies are only supported for script jobs.", fg=typer.colors.RED)
|
|
105
|
+
raise typer.Exit(code=1)
|
|
106
|
+
|
|
107
|
+
job = _add_job(name, path, job_type, dependencies)
|
|
108
|
+
|
|
109
|
+
# If we're only adding, stop here
|
|
110
|
+
if add_only:
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
# Run the job
|
|
114
|
+
async def run() -> None:
|
|
115
|
+
executor = Executor(job)
|
|
116
|
+
result = await executor.execute()
|
|
117
|
+
if result["status"] == "SUCCEEDED":
|
|
118
|
+
typer.secho(f"✅ Job '{name}' completed successfully.", fg=typer.colors.GREEN)
|
|
119
|
+
else:
|
|
120
|
+
typer.secho(f"❌ Job '{name}' failed with exit code {result['exit_code']}.", fg=typer.colors.RED)
|
|
121
|
+
typer.secho(f"📋 Log file: {result['log_path']}", fg=typer.colors.BLUE)
|
|
122
|
+
|
|
123
|
+
asyncio.run(run())
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.command("schedule")
|
|
127
|
+
def schedule_job(
|
|
128
|
+
name: Annotated[str, typer.Argument(help="Name of the job to schedule.")],
|
|
129
|
+
schedule_type: Annotated[
|
|
130
|
+
str,
|
|
131
|
+
typer.Option("--type", "-t", help="Schedule type: once, interval, cron, until_done"),
|
|
132
|
+
] = "once",
|
|
133
|
+
run_date: Annotated[
|
|
134
|
+
str | None,
|
|
135
|
+
typer.Option("--date", help="Run date (YYYY-MM-DD HH:MM:SS) for 'once' type"),
|
|
136
|
+
] = None,
|
|
137
|
+
seconds: Annotated[int, typer.Option("--seconds", help="Seconds for interval type")] = 0,
|
|
138
|
+
minutes: Annotated[int, typer.Option("--minutes", help="Minutes for interval type")] = 0,
|
|
139
|
+
hours: Annotated[int, typer.Option("--hours", help="Hours for interval type")] = 0,
|
|
140
|
+
days: Annotated[int, typer.Option("--days", help="Days for interval type")] = 0,
|
|
141
|
+
cron_minute: Annotated[str | None, typer.Option("--cron-minute", help="Cron minute (0-59)")] = None,
|
|
142
|
+
cron_hour: Annotated[str | None, typer.Option("--cron-hour", help="Cron hour (0-23)")] = None,
|
|
143
|
+
cron_day: Annotated[str | None, typer.Option("--cron-day", help="Cron day of month (1-31)")] = None,
|
|
144
|
+
cron_month: Annotated[str | None, typer.Option("--cron-month", help="Cron month (1-12)")] = None,
|
|
145
|
+
cron_dow: Annotated[str | None, typer.Option("--cron-dow", help="Cron day of week (0-6, 0=Sunday)")] = None,
|
|
146
|
+
max_retries: Annotated[int, typer.Option("--max-retries", help="Max retries for until_done type")] = 10,
|
|
147
|
+
retry_interval: Annotated[
|
|
148
|
+
int,
|
|
149
|
+
typer.Option("--retry-interval", help="Retry interval in seconds for until_done"),
|
|
150
|
+
] = 60,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Schedule a job to run in the background."""
|
|
153
|
+
db = SessionLocal()
|
|
154
|
+
try:
|
|
155
|
+
job = db.query(Job).filter(Job.name == name).first()
|
|
156
|
+
if not job:
|
|
157
|
+
typer.secho(f"❌ Job '{name}' not found.", fg=typer.colors.RED)
|
|
158
|
+
raise typer.Exit(code=1)
|
|
159
|
+
|
|
160
|
+
# Validate schedule type
|
|
161
|
+
valid_types = ["once", "interval", "cron", "until_done"]
|
|
162
|
+
if schedule_type not in valid_types:
|
|
163
|
+
typer.secho(f"❌ Invalid schedule type. Must be one of: {', '.join(valid_types)}", fg=typer.colors.RED)
|
|
164
|
+
raise typer.Exit(code=1)
|
|
165
|
+
|
|
166
|
+
# Prepare scheduling arguments
|
|
167
|
+
schedule_kwargs = {}
|
|
168
|
+
|
|
169
|
+
if schedule_type == "once":
|
|
170
|
+
if not run_date:
|
|
171
|
+
typer.secho("❌ --date is required for 'once' schedule type", fg=typer.colors.RED)
|
|
172
|
+
raise typer.Exit(code=1)
|
|
173
|
+
schedule_kwargs["run_date"] = run_date
|
|
174
|
+
|
|
175
|
+
elif schedule_type == "interval":
|
|
176
|
+
if not any([seconds, minutes, hours, days]):
|
|
177
|
+
typer.secho(
|
|
178
|
+
"❌ At least one interval parameter (seconds, minutes, hours, days) is required",
|
|
179
|
+
fg=typer.colors.RED,
|
|
180
|
+
)
|
|
181
|
+
raise typer.Exit(code=1)
|
|
182
|
+
schedule_kwargs.update({"seconds": seconds, "minutes": minutes, "hours": hours, "days": days})
|
|
183
|
+
|
|
184
|
+
elif schedule_type == "cron":
|
|
185
|
+
cron_params = {
|
|
186
|
+
"minute": cron_minute,
|
|
187
|
+
"hour": cron_hour,
|
|
188
|
+
"day": cron_day,
|
|
189
|
+
"month": cron_month,
|
|
190
|
+
"day_of_week": cron_dow,
|
|
191
|
+
}
|
|
192
|
+
# Only include non-None values
|
|
193
|
+
schedule_kwargs.update({k: v for k, v in cron_params.items() if v is not None})
|
|
194
|
+
if not schedule_kwargs:
|
|
195
|
+
typer.secho("❌ At least one cron parameter is required", fg=typer.colors.RED)
|
|
196
|
+
raise typer.Exit(code=1)
|
|
197
|
+
|
|
198
|
+
elif schedule_type == "until_done":
|
|
199
|
+
schedule_kwargs.update({"max_retries": max_retries, "retry_interval": retry_interval})
|
|
200
|
+
|
|
201
|
+
# Schedule the job
|
|
202
|
+
scheduler_job_id = scheduler_manager.schedule_job(job.id, schedule_type, **schedule_kwargs) # type: ignore
|
|
203
|
+
|
|
204
|
+
typer.secho(f"✅ Job '{name}' scheduled successfully.", fg=typer.colors.GREEN)
|
|
205
|
+
typer.secho(f"📅 Scheduler ID: {scheduler_job_id}", fg=typer.colors.BLUE)
|
|
206
|
+
|
|
207
|
+
if schedule_type == "until_done":
|
|
208
|
+
typer.secho(
|
|
209
|
+
f"🔄 Job will run repeatedly until successful (max {max_retries} attempts)",
|
|
210
|
+
fg=typer.colors.YELLOW,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
finally:
|
|
214
|
+
db.close()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@app.command("list")
|
|
218
|
+
def list_jobs(
|
|
219
|
+
scheduled: Annotated[
|
|
220
|
+
bool,
|
|
221
|
+
typer.Option("--scheduled", "-s", help="Show scheduled jobs instead of stored jobs"),
|
|
222
|
+
] = False,
|
|
223
|
+
) -> None:
|
|
224
|
+
"""Lists all jobs managed by pem (stored) or scheduled jobs (with --scheduled)."""
|
|
225
|
+
if scheduled:
|
|
226
|
+
# List scheduled jobs
|
|
227
|
+
scheduled_jobs = scheduler_manager.list_scheduled_jobs()
|
|
228
|
+
|
|
229
|
+
if not scheduled_jobs:
|
|
230
|
+
typer.secho("No scheduled jobs found.", fg=typer.colors.YELLOW)
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
typer.secho("Scheduled Jobs:", fg=typer.colors.BLUE)
|
|
234
|
+
for job in scheduled_jobs:
|
|
235
|
+
typer.secho(f"- {job['id']}", fg=typer.colors.WHITE)
|
|
236
|
+
typer.secho(f" Next run: {job['next_run']}", fg=typer.colors.CYAN)
|
|
237
|
+
typer.secho(f" Trigger: {job['trigger']}", fg=typer.colors.CYAN)
|
|
238
|
+
if "start_time" in job:
|
|
239
|
+
typer.secho(f" Started: {job['start_time']}", fg=typer.colors.CYAN)
|
|
240
|
+
if "max_retries" in job:
|
|
241
|
+
typer.secho(f" Max retries: {job['max_retries']}", fg=typer.colors.CYAN)
|
|
242
|
+
typer.secho("")
|
|
243
|
+
else:
|
|
244
|
+
# List stored jobs
|
|
245
|
+
db = SessionLocal()
|
|
246
|
+
try:
|
|
247
|
+
if not (jobs := db.query(Job).all()):
|
|
248
|
+
typer.secho("No jobs found.", fg=typer.colors.YELLOW)
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
typer.secho("Jobs:", fg=typer.colors.BLUE)
|
|
252
|
+
for job in jobs:
|
|
253
|
+
status = "✅ enabled" if job.is_enabled else "❌ disabled"
|
|
254
|
+
dependencies = f", deps: {job.dependencies}" if job.dependencies else ""
|
|
255
|
+
typer.secho(
|
|
256
|
+
f"- {job.name} ({job.job_type}) - Path: {job.path}{dependencies} - {status}",
|
|
257
|
+
fg=typer.colors.GREEN if job.is_enabled else typer.colors.RED,
|
|
258
|
+
)
|
|
259
|
+
finally:
|
|
260
|
+
db.close()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@app.command("cancel")
|
|
264
|
+
def cancel_scheduled_job(scheduler_job_id: Annotated[str, typer.Argument(help="Scheduler job ID to cancel.")]) -> None:
|
|
265
|
+
"""Cancel a scheduled job."""
|
|
266
|
+
success = scheduler_manager.cancel_job(scheduler_job_id)
|
|
267
|
+
|
|
268
|
+
if success:
|
|
269
|
+
typer.secho(f"✅ Scheduled job '{scheduler_job_id}' cancelled successfully.", fg=typer.colors.GREEN)
|
|
270
|
+
else:
|
|
271
|
+
typer.secho(f"❌ Failed to cancel scheduled job '{scheduler_job_id}'. Job not found.", fg=typer.colors.RED)
|
|
272
|
+
raise typer.Exit(code=1)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@app.command("show")
|
|
276
|
+
def show_job(name: Annotated[str, typer.Argument(help="Name of the job to show.")]) -> None:
|
|
277
|
+
"""Shows detailed information about a specific job."""
|
|
278
|
+
db = SessionLocal()
|
|
279
|
+
try:
|
|
280
|
+
job = db.query(Job).filter(Job.name == name).first()
|
|
281
|
+
if not job:
|
|
282
|
+
typer.secho(f"❌ Job '{name}' not found.", fg=typer.colors.RED)
|
|
283
|
+
raise typer.Exit(code=1)
|
|
284
|
+
|
|
285
|
+
typer.secho("Job Details:", fg=typer.colors.BLUE)
|
|
286
|
+
typer.secho(f" Name: {job.name}", fg=typer.colors.WHITE)
|
|
287
|
+
typer.secho(f" Type: {job.job_type}", fg=typer.colors.WHITE)
|
|
288
|
+
typer.secho(f" Path: {job.path}", fg=typer.colors.WHITE)
|
|
289
|
+
typer.secho(f" Dependencies: {job.dependencies or 'None'}", fg=typer.colors.WHITE)
|
|
290
|
+
typer.secho(f" Enabled: {'Yes' if job.is_enabled else 'No'}", fg=typer.colors.WHITE)
|
|
291
|
+
typer.secho(f" ID: {job.id}", fg=typer.colors.WHITE)
|
|
292
|
+
finally:
|
|
293
|
+
db.close()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@app.command("update")
|
|
297
|
+
def update_job(
|
|
298
|
+
name: Annotated[str, typer.Argument(help="Name of the job to update.")],
|
|
299
|
+
new_name: Annotated[str | None, typer.Option("--name", "-n")] = None,
|
|
300
|
+
path: Annotated[str | None, typer.Option("--path", "-p")] = None,
|
|
301
|
+
dependencies: Annotated[list[str] | None, typer.Option("--with", "-w")] = None,
|
|
302
|
+
enabled: Annotated[bool | None, typer.Option("--enabled/--disabled")] = None,
|
|
303
|
+
) -> None:
|
|
304
|
+
"""Updates an existing job."""
|
|
305
|
+
db = SessionLocal()
|
|
306
|
+
try:
|
|
307
|
+
job = db.query(Job).filter(Job.name == name).first()
|
|
308
|
+
if not job:
|
|
309
|
+
typer.secho(f"❌ Job '{name}' not found.", fg=typer.colors.RED)
|
|
310
|
+
raise typer.Exit(code=1)
|
|
311
|
+
|
|
312
|
+
# Update fields if provided
|
|
313
|
+
if new_name:
|
|
314
|
+
job.name = new_name
|
|
315
|
+
if path:
|
|
316
|
+
job.path = path
|
|
317
|
+
if dependencies is not None:
|
|
318
|
+
job.dependencies = dependencies
|
|
319
|
+
if enabled is not None:
|
|
320
|
+
job.is_enabled = enabled
|
|
321
|
+
|
|
322
|
+
db.commit()
|
|
323
|
+
typer.secho(f"✅ Job '{name}' updated successfully.", fg=typer.colors.GREEN)
|
|
324
|
+
finally:
|
|
325
|
+
db.close()
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@app.command("delete")
|
|
329
|
+
def delete_job(
|
|
330
|
+
name: Annotated[str, typer.Argument(help="Name of the job to delete.")],
|
|
331
|
+
force: Annotated[bool, typer.Option("--force", "-f")] = False,
|
|
332
|
+
) -> None:
|
|
333
|
+
"""Deletes a job."""
|
|
334
|
+
db = SessionLocal()
|
|
335
|
+
try:
|
|
336
|
+
job = db.query(Job).filter(Job.name == name).first()
|
|
337
|
+
if not job:
|
|
338
|
+
typer.secho(f"❌ Job '{name}' not found.", fg=typer.colors.RED)
|
|
339
|
+
raise typer.Exit(code=1)
|
|
340
|
+
|
|
341
|
+
if not force:
|
|
342
|
+
confirm = typer.confirm(f"Are you sure you want to delete job '{name}'?")
|
|
343
|
+
if not confirm:
|
|
344
|
+
typer.secho("❌ Operation cancelled.", fg=typer.colors.YELLOW)
|
|
345
|
+
return
|
|
346
|
+
|
|
347
|
+
db.delete(job)
|
|
348
|
+
db.commit()
|
|
349
|
+
typer.secho(f"✅ Job '{name}' deleted successfully.", fg=typer.colors.GREEN)
|
|
350
|
+
finally:
|
|
351
|
+
db.close()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@app.command("enable")
|
|
355
|
+
def enable_job(name: Annotated[str, typer.Argument(help="Name of the job to enable.")]) -> None:
|
|
356
|
+
"""Enables a job."""
|
|
357
|
+
db = SessionLocal()
|
|
358
|
+
try:
|
|
359
|
+
job = db.query(Job).filter(Job.name == name).first()
|
|
360
|
+
if not job:
|
|
361
|
+
typer.secho(f"❌ Job '{name}' not found.", fg=typer.colors.RED)
|
|
362
|
+
raise typer.Exit(code=1)
|
|
363
|
+
|
|
364
|
+
job.is_enabled = True
|
|
365
|
+
db.commit()
|
|
366
|
+
typer.secho(f"✅ Job '{name}' enabled.", fg=typer.colors.GREEN)
|
|
367
|
+
finally:
|
|
368
|
+
db.close()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@app.command("disable")
|
|
372
|
+
def disable_job(name: Annotated[str, typer.Argument(help="Name of the job to disable.")]) -> None:
|
|
373
|
+
"""Disables a job."""
|
|
374
|
+
db = SessionLocal()
|
|
375
|
+
try:
|
|
376
|
+
job = db.query(Job).filter(Job.name == name).first()
|
|
377
|
+
if not job:
|
|
378
|
+
typer.secho(f"❌ Job '{name}' not found.", fg=typer.colors.RED)
|
|
379
|
+
raise typer.Exit(code=1)
|
|
380
|
+
|
|
381
|
+
job.is_enabled = False
|
|
382
|
+
db.commit()
|
|
383
|
+
typer.secho(f"✅ Job '{name}' disabled.", fg=typer.colors.GREEN)
|
|
384
|
+
finally:
|
|
385
|
+
db.close()
|
pem/core/__init__.py
ADDED
|
File without changes
|
pem/core/executor.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import subprocess
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pem.db.models import Job
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Executor:
|
|
11
|
+
"""Unified executor for handling both script and project jobs."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, job: Job) -> None:
|
|
14
|
+
self.job = job
|
|
15
|
+
self.logs_dir = Path("./logs").resolve()
|
|
16
|
+
self.logs_dir.mkdir(exist_ok=True)
|
|
17
|
+
|
|
18
|
+
# Set up paths based on job type
|
|
19
|
+
if self.job.job_type == "script":
|
|
20
|
+
self.script_path = Path(self.job.path).resolve()
|
|
21
|
+
elif self.job.job_type == "project":
|
|
22
|
+
self.project_path = Path(self.job.path).resolve()
|
|
23
|
+
self.venv_path = self.project_path / ".pem_venv"
|
|
24
|
+
else:
|
|
25
|
+
msg = f"Unsupported job type: {self.job.job_type}, it must be either 'script' or 'project'."
|
|
26
|
+
raise ValueError(msg)
|
|
27
|
+
|
|
28
|
+
async def _run_command(self, command: list[str], log_file_handle, cwd: Path | None = None) -> int:
|
|
29
|
+
"""Run a command and write output to log file."""
|
|
30
|
+
process = await asyncio.create_subprocess_exec(
|
|
31
|
+
*command,
|
|
32
|
+
stdout=log_file_handle,
|
|
33
|
+
stderr=subprocess.STDOUT,
|
|
34
|
+
cwd=cwd,
|
|
35
|
+
)
|
|
36
|
+
await process.wait()
|
|
37
|
+
return process.returncode or 0
|
|
38
|
+
|
|
39
|
+
async def _execute_script(self, log_file) -> int:
|
|
40
|
+
"""Execute a script job using 'uv run'."""
|
|
41
|
+
command = ["uv", "run"] # TODO: Add python version
|
|
42
|
+
if self.job.dependencies:
|
|
43
|
+
for dep in self.job.dependencies:
|
|
44
|
+
command.extend(["--with", dep])
|
|
45
|
+
command.extend(["--script", str(self.script_path)])
|
|
46
|
+
|
|
47
|
+
log_file.write(f"--- Running command: {' '.join(command)} ---\n\n")
|
|
48
|
+
return await self._run_command(command, log_file)
|
|
49
|
+
|
|
50
|
+
async def _execute_project(self, log_file) -> int:
|
|
51
|
+
"""Execute a project job using a dedicated venv."""
|
|
52
|
+
await self._run_command(
|
|
53
|
+
["uv", "sync", "--directory", str(self.venv_path)],
|
|
54
|
+
log_file,
|
|
55
|
+
self.project_path,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return await self._run_command(
|
|
59
|
+
["uv", "run", "--directory", str(self.venv_path), "main.py"], # TODO: get python file
|
|
60
|
+
log_file,
|
|
61
|
+
self.project_path,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async def execute(self) -> dict[str, Any]:
|
|
65
|
+
"""Execute the job based on its type."""
|
|
66
|
+
start_time = datetime.utcnow()
|
|
67
|
+
log_filename = f"{self.job.name}_{start_time.strftime('%Y%m%d_%H%M%S')}.log"
|
|
68
|
+
log_path = self.logs_dir / log_filename
|
|
69
|
+
|
|
70
|
+
with open(log_path, "w") as log_file:
|
|
71
|
+
log_file.write(f"--- Starting execution at {start_time.isoformat()} ---\n")
|
|
72
|
+
|
|
73
|
+
if self.job.job_type == "script":
|
|
74
|
+
exit_code = await self._execute_script(log_file)
|
|
75
|
+
elif self.job.job_type == "project":
|
|
76
|
+
exit_code = await self._execute_project(log_file)
|
|
77
|
+
else:
|
|
78
|
+
msg = f"Unsupported job type: {self.job.job_type}"
|
|
79
|
+
raise ValueError(msg)
|
|
80
|
+
|
|
81
|
+
status = "SUCCEEDED" if exit_code == 0 else "FAILED"
|
|
82
|
+
return {"status": status, "exit_code": exit_code, "log_path": str(log_path)}
|
pem/core/scheduler.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Scheduler manager for handling background job execution."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import threading
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
|
10
|
+
from apscheduler.schedulers.background import BackgroundScheduler
|
|
11
|
+
|
|
12
|
+
from pem.core.executor import Executor
|
|
13
|
+
from pem.db.database import SessionLocal
|
|
14
|
+
from pem.db.models import Job
|
|
15
|
+
from pem.settings import DATABASE_URL
|
|
16
|
+
|
|
17
|
+
ScheduleType = Literal["once", "interval", "cron", "until_done"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def execute_job_standalone(job_id: int) -> dict[str, Any]:
|
|
21
|
+
"""Standalone function to execute a job (for scheduler serialization)."""
|
|
22
|
+
loop = asyncio.new_event_loop()
|
|
23
|
+
asyncio.set_event_loop(loop)
|
|
24
|
+
try:
|
|
25
|
+
return loop.run_until_complete(_execute_job_async(job_id))
|
|
26
|
+
finally:
|
|
27
|
+
loop.close()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def _execute_job_async(job_id: int) -> dict[str, Any]:
|
|
31
|
+
"""Execute a job asynchronously."""
|
|
32
|
+
db = SessionLocal()
|
|
33
|
+
try:
|
|
34
|
+
job = db.query(Job).filter(Job.id == job_id).first()
|
|
35
|
+
if not job:
|
|
36
|
+
return {"status": "FAILED", "error": "Job not found"}
|
|
37
|
+
|
|
38
|
+
executor = Executor(job)
|
|
39
|
+
return await executor.execute()
|
|
40
|
+
finally:
|
|
41
|
+
db.close()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def execute_until_done_standalone(job_id: int, max_retries: int = 10, retry_interval: int = 60) -> None:
|
|
45
|
+
"""Standalone function to execute a job repeatedly until it succeeds."""
|
|
46
|
+
attempt = 0
|
|
47
|
+
while attempt < max_retries:
|
|
48
|
+
attempt += 1
|
|
49
|
+
|
|
50
|
+
result = execute_job_standalone(job_id)
|
|
51
|
+
|
|
52
|
+
if result["status"] == "SUCCEEDED":
|
|
53
|
+
break
|
|
54
|
+
if attempt < max_retries:
|
|
55
|
+
threading.Event().wait(retry_interval)
|
|
56
|
+
else:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SchedulerManager:
|
|
61
|
+
"""Manages background scheduling of jobs."""
|
|
62
|
+
|
|
63
|
+
def __init__(self) -> None:
|
|
64
|
+
self.scheduler = BackgroundScheduler()
|
|
65
|
+
self.scheduler.add_jobstore(SQLAlchemyJobStore(url=DATABASE_URL), "default")
|
|
66
|
+
self.running_jobs = {} # Track running until_done jobs
|
|
67
|
+
self._setup_scheduler()
|
|
68
|
+
|
|
69
|
+
def _setup_scheduler(self) -> None:
|
|
70
|
+
"""Initialize and start the scheduler."""
|
|
71
|
+
with contextlib.suppress(Exception):
|
|
72
|
+
self.scheduler.start()
|
|
73
|
+
|
|
74
|
+
def shutdown(self) -> None:
|
|
75
|
+
"""Shutdown the scheduler."""
|
|
76
|
+
if self.scheduler.running:
|
|
77
|
+
self.scheduler.shutdown()
|
|
78
|
+
|
|
79
|
+
async def _execute_job_async(self, job_id: int) -> dict[str, Any]:
|
|
80
|
+
"""Execute a job asynchronously."""
|
|
81
|
+
db = SessionLocal()
|
|
82
|
+
try:
|
|
83
|
+
job = db.query(Job).filter(Job.id == job_id).first()
|
|
84
|
+
if not job:
|
|
85
|
+
return {"status": "FAILED", "error": "Job not found"}
|
|
86
|
+
|
|
87
|
+
executor = Executor(job)
|
|
88
|
+
return await executor.execute()
|
|
89
|
+
finally:
|
|
90
|
+
db.close()
|
|
91
|
+
|
|
92
|
+
def _execute_job_sync(self, job_id: int) -> dict[str, Any]:
|
|
93
|
+
"""Execute a job synchronously (wrapper for scheduler)."""
|
|
94
|
+
loop = asyncio.new_event_loop()
|
|
95
|
+
asyncio.set_event_loop(loop)
|
|
96
|
+
try:
|
|
97
|
+
return loop.run_until_complete(self._execute_job_async(job_id))
|
|
98
|
+
finally:
|
|
99
|
+
loop.close()
|
|
100
|
+
|
|
101
|
+
def _execute_until_done(self, job_id: int, max_retries: int = 10, retry_interval: int = 60) -> None:
|
|
102
|
+
"""Execute a job repeatedly until it succeeds or max retries reached."""
|
|
103
|
+
attempt = 0
|
|
104
|
+
while attempt < max_retries:
|
|
105
|
+
attempt += 1
|
|
106
|
+
|
|
107
|
+
result = self._execute_job_sync(job_id)
|
|
108
|
+
|
|
109
|
+
if result["status"] == "SUCCEEDED":
|
|
110
|
+
# Remove from running jobs tracker
|
|
111
|
+
if job_id in self.running_jobs:
|
|
112
|
+
del self.running_jobs[job_id]
|
|
113
|
+
break
|
|
114
|
+
if attempt < max_retries:
|
|
115
|
+
threading.Event().wait(retry_interval)
|
|
116
|
+
elif job_id in self.running_jobs:
|
|
117
|
+
del self.running_jobs[job_id]
|
|
118
|
+
|
|
119
|
+
def schedule_job(self, job_id: int, schedule_type: ScheduleType, **kwargs) -> str:
|
|
120
|
+
"""Schedule a job with different scheduling options.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
job_id: The job ID to schedule
|
|
124
|
+
schedule_type: Type of scheduling (once, interval, cron, until_done)
|
|
125
|
+
**kwargs: Additional scheduling parameters
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
scheduler_job_id: The APScheduler job ID
|
|
129
|
+
|
|
130
|
+
"""
|
|
131
|
+
scheduler_job_id = f"pem_job_{job_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
|
132
|
+
|
|
133
|
+
if schedule_type == "once":
|
|
134
|
+
# Schedule to run once at a specific time
|
|
135
|
+
run_date = kwargs.get("run_date")
|
|
136
|
+
if isinstance(run_date, str):
|
|
137
|
+
run_date = datetime.fromisoformat(run_date)
|
|
138
|
+
|
|
139
|
+
self.scheduler.add_job(
|
|
140
|
+
func=execute_job_standalone,
|
|
141
|
+
trigger="date",
|
|
142
|
+
run_date=run_date,
|
|
143
|
+
args=[job_id],
|
|
144
|
+
id=scheduler_job_id,
|
|
145
|
+
replace_existing=True,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
elif schedule_type == "interval":
|
|
149
|
+
# Schedule to run at regular intervals
|
|
150
|
+
seconds = kwargs.get("seconds", 0)
|
|
151
|
+
minutes = kwargs.get("minutes", 0)
|
|
152
|
+
hours = kwargs.get("hours", 0)
|
|
153
|
+
days = kwargs.get("days", 0)
|
|
154
|
+
|
|
155
|
+
self.scheduler.add_job(
|
|
156
|
+
func=execute_job_standalone,
|
|
157
|
+
trigger="interval",
|
|
158
|
+
seconds=seconds,
|
|
159
|
+
minutes=minutes,
|
|
160
|
+
hours=hours,
|
|
161
|
+
days=days,
|
|
162
|
+
args=[job_id],
|
|
163
|
+
id=scheduler_job_id,
|
|
164
|
+
replace_existing=True,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
elif schedule_type == "cron":
|
|
168
|
+
# Schedule using cron expression
|
|
169
|
+
cron_kwargs = {
|
|
170
|
+
k: v for k, v in kwargs.items() if k in ["second", "minute", "hour", "day", "month", "day_of_week"]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
self.scheduler.add_job(
|
|
174
|
+
func=execute_job_standalone,
|
|
175
|
+
trigger="cron",
|
|
176
|
+
args=[job_id],
|
|
177
|
+
id=scheduler_job_id,
|
|
178
|
+
replace_existing=True,
|
|
179
|
+
**cron_kwargs,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
elif schedule_type == "until_done":
|
|
183
|
+
# Run in background thread until success
|
|
184
|
+
max_retries = kwargs.get("max_retries", 10)
|
|
185
|
+
retry_interval = kwargs.get("retry_interval", 60)
|
|
186
|
+
|
|
187
|
+
# Track this job
|
|
188
|
+
self.running_jobs[job_id] = {
|
|
189
|
+
"scheduler_job_id": scheduler_job_id,
|
|
190
|
+
"start_time": datetime.now(),
|
|
191
|
+
"max_retries": max_retries,
|
|
192
|
+
"retry_interval": retry_interval,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
# Run in a separate thread
|
|
196
|
+
thread = threading.Thread(
|
|
197
|
+
target=execute_until_done_standalone,
|
|
198
|
+
args=[job_id, max_retries, retry_interval],
|
|
199
|
+
daemon=True,
|
|
200
|
+
)
|
|
201
|
+
thread.start()
|
|
202
|
+
|
|
203
|
+
return scheduler_job_id
|
|
204
|
+
|
|
205
|
+
def list_scheduled_jobs(self) -> list[dict]:
|
|
206
|
+
"""List all scheduled jobs."""
|
|
207
|
+
jobs = []
|
|
208
|
+
for job in self.scheduler.get_jobs():
|
|
209
|
+
if job.id.startswith("pem_job_"):
|
|
210
|
+
jobs.append(
|
|
211
|
+
{
|
|
212
|
+
"id": job.id,
|
|
213
|
+
"next_run": job.next_run_time,
|
|
214
|
+
"trigger": str(job.trigger),
|
|
215
|
+
"func": job.func.__name__,
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Add until_done jobs
|
|
220
|
+
for info in self.running_jobs.values():
|
|
221
|
+
jobs.append(
|
|
222
|
+
{
|
|
223
|
+
"id": info["scheduler_job_id"],
|
|
224
|
+
"next_run": "running until done",
|
|
225
|
+
"trigger": "until_done",
|
|
226
|
+
"func": "_execute_until_done",
|
|
227
|
+
"start_time": info["start_time"],
|
|
228
|
+
"max_retries": info["max_retries"],
|
|
229
|
+
},
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return jobs
|
|
233
|
+
|
|
234
|
+
def cancel_job(self, scheduler_job_id: str) -> bool:
|
|
235
|
+
"""Cancel a scheduled job."""
|
|
236
|
+
try:
|
|
237
|
+
# Check if it's an until_done job
|
|
238
|
+
for job_id, info in list(self.running_jobs.items()):
|
|
239
|
+
if info["scheduler_job_id"] == scheduler_job_id:
|
|
240
|
+
del self.running_jobs[job_id]
|
|
241
|
+
return True
|
|
242
|
+
|
|
243
|
+
# Remove from scheduler
|
|
244
|
+
self.scheduler.remove_job(scheduler_job_id)
|
|
245
|
+
return True
|
|
246
|
+
except Exception:
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# Global scheduler instance
|
|
251
|
+
scheduler_manager = SchedulerManager()
|
pem/db/__init__.py
ADDED
|
File without changes
|
pem/db/database.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from sqlalchemy import create_engine
|
|
2
|
+
from sqlalchemy.orm import declarative_base, sessionmaker
|
|
3
|
+
|
|
4
|
+
from pem.settings import DATABASE_URL
|
|
5
|
+
|
|
6
|
+
Base = declarative_base()
|
|
7
|
+
|
|
8
|
+
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
|
9
|
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_db_and_tables() -> None:
|
|
13
|
+
"""Creates the database and tables if they don't exist."""
|
|
14
|
+
Base.metadata.create_all(bind=engine)
|
pem/db/models.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import (
|
|
4
|
+
JSON,
|
|
5
|
+
Boolean,
|
|
6
|
+
DateTime,
|
|
7
|
+
ForeignKey,
|
|
8
|
+
Integer,
|
|
9
|
+
String,
|
|
10
|
+
)
|
|
11
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
12
|
+
|
|
13
|
+
from pem.db.database import Base
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Job(Base):
|
|
17
|
+
"""Represents a job that can be executed."""
|
|
18
|
+
|
|
19
|
+
__tablename__ = "jobs"
|
|
20
|
+
|
|
21
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
22
|
+
name: Mapped[str] = mapped_column(String, unique=True, nullable=False, index=True)
|
|
23
|
+
job_type: Mapped[str] = mapped_column(String, nullable=False)
|
|
24
|
+
path: Mapped[str] = mapped_column(String, nullable=False)
|
|
25
|
+
dependencies: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
|
26
|
+
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
27
|
+
|
|
28
|
+
runs: Mapped[list["ExecutionRun"]] = relationship(
|
|
29
|
+
"ExecutionRun",
|
|
30
|
+
back_populates="job",
|
|
31
|
+
cascade="all, delete-orphan",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ExecutionRun(Base):
|
|
36
|
+
"""Represents a single execution of a job."""
|
|
37
|
+
|
|
38
|
+
__tablename__ = "execution_runs"
|
|
39
|
+
|
|
40
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
41
|
+
job_id: Mapped[int] = mapped_column(Integer, ForeignKey("jobs.id"), nullable=False)
|
|
42
|
+
start_time: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
|
|
43
|
+
end_time: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True)
|
|
44
|
+
status: Mapped[str] = mapped_column(String, nullable=False)
|
|
45
|
+
exit_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
46
|
+
log_path: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
47
|
+
|
|
48
|
+
job: Mapped["Job"] = relationship(
|
|
49
|
+
"Job",
|
|
50
|
+
back_populates="runs",
|
|
51
|
+
)
|
pem/settings.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pemexe
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 🚀 Python Execution Manager - A powerful, modern tool for managing, scheduling, and executing Python scripts and projects with comprehensive logging, flexible scheduling, and dependency management. Perfect for DevOps, data science, and automation workflows.
|
|
5
|
+
Author: Arian Omrani
|
|
6
|
+
Project-URL: Homepage, https://github.com/arian24b/pem
|
|
7
|
+
Project-URL: Issues, https://github.com/arian24b/pem/issues
|
|
8
|
+
Keywords: python,scheduler,automation,devops,task-runner,cron,execution,management,scripts,projects,cli,background-jobs,logging,dependencies
|
|
9
|
+
Requires-Python: >=3.13
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: apscheduler>=3.11.0
|
|
13
|
+
Requires-Dist: sqlalchemy>=2.0.41
|
|
14
|
+
Requires-Dist: typer>=0.16.0
|
|
15
|
+
Requires-Dist: uv>=0.8.2
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# PEM - Python Execution Manager 🚀
|
|
19
|
+
|
|
20
|
+
**A powerful, modern Python tool for managing, scheduling, and executing Python scripts and projects with ease.**
|
|
21
|
+
|
|
22
|
+
[](https://www.python.org/downloads/)
|
|
23
|
+
[](LICENSE)
|
|
24
|
+
[](https://github.com/astral-sh/uv)
|
|
25
|
+
|
|
26
|
+
## ✨ What is PEM?
|
|
27
|
+
|
|
28
|
+
PEM is your all-in-one solution for Python execution management. Whether you're running one-off scripts, managing complex projects, or scheduling automated tasks, PEM makes it simple and reliable.
|
|
29
|
+
|
|
30
|
+
### 🎯 Perfect for:
|
|
31
|
+
- **DevOps Engineers** managing deployment scripts and automation
|
|
32
|
+
- **Data Scientists** running scheduled data processing jobs
|
|
33
|
+
- **Python Developers** organizing and executing multiple projects
|
|
34
|
+
- **System Administrators** automating maintenance tasks
|
|
35
|
+
- **Anyone** who needs reliable Python script execution with logging
|
|
36
|
+
|
|
37
|
+
## 🌟 Key Features
|
|
38
|
+
|
|
39
|
+
### 📋 **Job Management**
|
|
40
|
+
- **Two Job Types**: Handle both standalone scripts and full Python projects
|
|
41
|
+
- **Dependency Management**: Automatically install and manage script dependencies using `uv`
|
|
42
|
+
- **Smart Execution**: Uses `uv run` for fast, isolated script execution
|
|
43
|
+
- **Project Support**: Full support for Python projects with virtual environments
|
|
44
|
+
|
|
45
|
+
### ⏰ **Flexible Scheduling**
|
|
46
|
+
- **Multiple Schedule Types**:
|
|
47
|
+
- `once` - Run at a specific date/time
|
|
48
|
+
- `interval` - Run every X seconds/minutes/hours/days
|
|
49
|
+
- `cron` - Use cron expressions for complex schedules
|
|
50
|
+
- `until_done` - Keep retrying until the job succeeds
|
|
51
|
+
- **Background Execution**: All scheduled jobs run in the background
|
|
52
|
+
- **Persistent Storage**: Schedules survive system restarts
|
|
53
|
+
|
|
54
|
+
### 📊 **Comprehensive Logging & Tracking**
|
|
55
|
+
- **Detailed Logs**: Every execution is logged with timestamps and output
|
|
56
|
+
- **Execution History**: Track all job runs with status, exit codes, and timing
|
|
57
|
+
- **SQLite Database**: Local storage for jobs, schedules, and execution history
|
|
58
|
+
- **Real-time Status**: See what's running, what succeeded, and what failed
|
|
59
|
+
|
|
60
|
+
### 🛠 **Developer-Friendly CLI**
|
|
61
|
+
- **Intuitive Commands**: Simple, memorable command structure
|
|
62
|
+
- **Rich Output**: Colored terminal output with emojis for better UX
|
|
63
|
+
- **Flexible Options**: Extensive command-line options for all use cases
|
|
64
|
+
- **Built with Typer**: Modern, type-safe CLI framework
|
|
65
|
+
|
|
66
|
+
## 🚀 Quick Start
|
|
67
|
+
|
|
68
|
+
### Installation
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Install PEM
|
|
72
|
+
pip install pem
|
|
73
|
+
|
|
74
|
+
# Or using uv (recommended)
|
|
75
|
+
uv add pem
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Basic Usage
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# Run a Python script with dependencies
|
|
82
|
+
pem run my-data-script --script --path ./data_processor.py --with pandas --with requests
|
|
83
|
+
|
|
84
|
+
# Add a project job
|
|
85
|
+
pem run my-web-app --project --path ./my-flask-app --add-only
|
|
86
|
+
|
|
87
|
+
# Schedule a job to run every hour
|
|
88
|
+
pem schedule my-data-script --type interval --hours 1
|
|
89
|
+
|
|
90
|
+
# List all jobs
|
|
91
|
+
pem list
|
|
92
|
+
|
|
93
|
+
# View job execution history
|
|
94
|
+
pem history my-data-script
|
|
95
|
+
|
|
96
|
+
# Check scheduler status
|
|
97
|
+
pem status
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## 📖 Detailed Usage
|
|
101
|
+
|
|
102
|
+
### Managing Jobs
|
|
103
|
+
|
|
104
|
+
#### Script Jobs
|
|
105
|
+
Perfect for standalone Python scripts with specific dependencies:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
# Add and run a script with dependencies
|
|
109
|
+
pem run data-processor \
|
|
110
|
+
--script \
|
|
111
|
+
--path ./scripts/process_data.py \
|
|
112
|
+
--with pandas \
|
|
113
|
+
--with requests \
|
|
114
|
+
--with beautifulsoup4
|
|
115
|
+
|
|
116
|
+
# Just add the job without running
|
|
117
|
+
pem run backup-script --script --path ./backup.py --add-only
|
|
118
|
+
|
|
119
|
+
# Run an existing job
|
|
120
|
+
pem run backup-script --run-only
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
#### Project Jobs
|
|
124
|
+
For full Python projects with their own environments:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
# Add a Python project
|
|
128
|
+
pem run my-api --project --path ./my-fastapi-project --add-only
|
|
129
|
+
|
|
130
|
+
# Run the project (will use project's own dependencies)
|
|
131
|
+
pem run my-api --run-only
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Scheduling Jobs
|
|
135
|
+
|
|
136
|
+
#### One-time Execution
|
|
137
|
+
```bash
|
|
138
|
+
# Run once at a specific time
|
|
139
|
+
pem schedule backup-job --type once --date "2025-12-31 23:59:59"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
#### Interval-based Scheduling
|
|
143
|
+
```bash
|
|
144
|
+
# Every 30 minutes
|
|
145
|
+
pem schedule data-sync --type interval --minutes 30
|
|
146
|
+
|
|
147
|
+
# Every 2 hours
|
|
148
|
+
pem schedule hourly-report --type interval --hours 2
|
|
149
|
+
|
|
150
|
+
# Every day
|
|
151
|
+
pem schedule daily-backup --type interval --days 1
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
#### Cron-style Scheduling
|
|
155
|
+
```bash
|
|
156
|
+
# Every weekday at 9 AM
|
|
157
|
+
pem schedule morning-report --type cron --cron-hour 9 --cron-dow 1-5
|
|
158
|
+
|
|
159
|
+
# Every Monday at midnight
|
|
160
|
+
pem schedule weekly-cleanup --type cron --cron-hour 0 --cron-dow 1
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
#### Retry Until Success
|
|
164
|
+
```bash
|
|
165
|
+
# Keep trying every 5 minutes until it succeeds (max 20 attempts)
|
|
166
|
+
pem schedule critical-task --type until_done --retry-interval 300 --max-retries 20
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Monitoring & Management
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
# List all jobs
|
|
173
|
+
pem list
|
|
174
|
+
|
|
175
|
+
# Show job details
|
|
176
|
+
pem show my-job
|
|
177
|
+
|
|
178
|
+
# View execution history
|
|
179
|
+
pem history my-job
|
|
180
|
+
|
|
181
|
+
# Check what's scheduled and running
|
|
182
|
+
pem status
|
|
183
|
+
|
|
184
|
+
# Enable/disable jobs
|
|
185
|
+
pem enable my-job
|
|
186
|
+
pem disable my-job
|
|
187
|
+
|
|
188
|
+
# Remove jobs
|
|
189
|
+
pem remove my-job
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## 🏗 Architecture
|
|
193
|
+
|
|
194
|
+
PEM is built with modern Python best practices:
|
|
195
|
+
|
|
196
|
+
- **SQLAlchemy**: Robust database ORM for job and execution tracking
|
|
197
|
+
- **APScheduler**: Reliable job scheduling with multiple backends
|
|
198
|
+
- **Typer**: Type-safe, intuitive CLI framework
|
|
199
|
+
- **uv**: Fast Python package management and script execution
|
|
200
|
+
- **AsyncIO**: Efficient asynchronous execution
|
|
201
|
+
|
|
202
|
+
## 📁 Project Structure
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
pem/
|
|
206
|
+
├── pem/
|
|
207
|
+
│ ├── cli.py # Command-line interface
|
|
208
|
+
│ ├── main.py # Application entry point
|
|
209
|
+
│ ├── settings.py # Configuration
|
|
210
|
+
│ ├── core/
|
|
211
|
+
│ │ ├── executor.py # Job execution engine
|
|
212
|
+
│ │ └── scheduler.py # Background scheduling
|
|
213
|
+
│ └── db/
|
|
214
|
+
│ ├── database.py # Database configuration
|
|
215
|
+
│ └── models.py # SQLAlchemy models
|
|
216
|
+
├── logs/ # Execution logs
|
|
217
|
+
├── pem.db # SQLite database
|
|
218
|
+
└── pyproject.toml # Project configuration
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## 🔧 Configuration
|
|
222
|
+
|
|
223
|
+
PEM works out of the box with sensible defaults:
|
|
224
|
+
|
|
225
|
+
- **Database**: SQLite (`pem.db`) in the current directory
|
|
226
|
+
- **Logs**: Stored in `./logs/` directory
|
|
227
|
+
- **Virtual Environments**: Created in project directories as `.pem_venv`
|
|
228
|
+
|
|
229
|
+
## 🤝 Contributing
|
|
230
|
+
|
|
231
|
+
We welcome contributions! Here's how to get started:
|
|
232
|
+
|
|
233
|
+
1. Fork the repository
|
|
234
|
+
2. Create a feature branch: `git checkout -b my-new-feature`
|
|
235
|
+
3. Make your changes and add tests
|
|
236
|
+
4. Run the test suite: `pytest`
|
|
237
|
+
5. Submit a pull request
|
|
238
|
+
|
|
239
|
+
## 📝 License
|
|
240
|
+
|
|
241
|
+
MIT License - see [LICENSE](LICENSE) file for details.
|
|
242
|
+
|
|
243
|
+
## 🙋♀️ Support
|
|
244
|
+
|
|
245
|
+
- **Issues**: [GitHub Issues](https://github.com/arian24b/pem/issues)
|
|
246
|
+
- **Discussions**: [GitHub Discussions](https://github.com/arian24b/pem/discussions)
|
|
247
|
+
|
|
248
|
+
## 🚀 Why Choose PEM?
|
|
249
|
+
|
|
250
|
+
### vs. Cron
|
|
251
|
+
- ✅ Cross-platform (works on Windows, macOS, Linux)
|
|
252
|
+
- ✅ Dependency management built-in
|
|
253
|
+
- ✅ Detailed logging and execution history
|
|
254
|
+
- ✅ Easy job management with CLI
|
|
255
|
+
- ✅ Python-native (no shell scripting needed)
|
|
256
|
+
|
|
257
|
+
### vs. Other Task Runners
|
|
258
|
+
- ✅ Specifically designed for Python
|
|
259
|
+
- ✅ No complex configuration files
|
|
260
|
+
- ✅ Built-in dependency isolation
|
|
261
|
+
- ✅ Simple CLI interface
|
|
262
|
+
- ✅ Local database storage (no external dependencies)
|
|
263
|
+
|
|
264
|
+
### vs. Manual Script Running
|
|
265
|
+
- ✅ Automated scheduling
|
|
266
|
+
- ✅ Execution history and monitoring
|
|
267
|
+
- ✅ Dependency management
|
|
268
|
+
- ✅ Error handling and retries
|
|
269
|
+
- ✅ Centralized job management
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
**Made with ❤️ by [Arian Omrani](https://github.com/arian24b)**
|
|
274
|
+
|
|
275
|
+
*PEM - Making Python execution management simple, reliable, and powerful.*
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
pem/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
pem/cli.py,sha256=JtYNdbBvWVQ6kom5wlcB80jMxHvYkRpCXwOwXtHCZIs,15076
|
|
3
|
+
pem/settings.py,sha256=a0_cnoFKfLVsRI38QFy28VR9nYdhoUoQVTCHRzHfDEI,89
|
|
4
|
+
pem/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
pem/core/executor.py,sha256=BfZLcFonYsiT2O1BxDzXLc0Fek7MwzguSN_I8_pQZGA,3136
|
|
6
|
+
pem/core/scheduler.py,sha256=v-q3UQ6jXiHiWFOvUp3X460cnCbRcGe2hEL814dM7yo,8406
|
|
7
|
+
pem/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
pem/db/database.py,sha256=OEw6E2Skc_Ke2IbnyxttdIFj8AsYNGbjmE1LhS0BnrE,461
|
|
9
|
+
pem/db/models.py,sha256=xjAMz0WjnoYYBCiSoiNr9gnGcDr7wGs8tA7Dnl4-gpY,1656
|
|
10
|
+
pemexe-0.1.0.dist-info/licenses/LICENSE,sha256=VqnjBysK1s6FLvTJ_k8ZvwDLfLm9gXnlGh-2Hj4E09I,1069
|
|
11
|
+
pemexe-0.1.0.dist-info/METADATA,sha256=AnO6_odghYExyDtpER6NyRQ3-DqeOEp8rzazkbJ2qGU,8249
|
|
12
|
+
pemexe-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
13
|
+
pemexe-0.1.0.dist-info/entry_points.txt,sha256=ZG_c5NlLtD6OQ8fSXsHqxjcLEWN6vdFOoezGN0nOG4Y,36
|
|
14
|
+
pemexe-0.1.0.dist-info/top_level.txt,sha256=cCeDNat8sNE5-evKrL-T6orV9hSCb4HxRUSB9Aq_rHo,4
|
|
15
|
+
pemexe-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Arian Omrani
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pem
|