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 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,3 @@
1
+ """Configuration settings for the PEM application."""
2
+
3
+ DATABASE_URL = "sqlite:///pem.db"
@@ -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
+ [![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/)
23
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
24
+ [![Built with uv](https://img.shields.io/badge/built%20with-uv-blue)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pem = pem.cli:app
@@ -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