Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for jobs using cron #126

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
28 changes: 27 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ rich = ">=10.11.0"
shellingham = ">=1.5.4"
jsonschema = ">=4"
paramiko = "^3.5.0"
croniter = "^5.0.1"

[tool.poetry.group.dev.dependencies]
containers-sugar = ">=1.11.1"
Expand Down
145 changes: 100 additions & 45 deletions src/makim/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
"""Cli functions to define the arguments and to call Makim."""
"""CLI functions to define the arguments and call Makim."""

from __future__ import annotations

import os
import sys

from typing import Any, cast

import typer

from makim import __version__
from makim.cli.auto_generator import (
create_dynamic_command,
Expand All @@ -17,54 +15,55 @@
)
from makim.cli.config import CLI_ROOT_FLAGS_VALUES_COUNT, extract_root_config
from makim.core import Makim
from rich.table import Table
from rich.console import Console

app = typer.Typer(
help=(
'Makim is a tool that helps you to organize '
'and simplify your helper commands.'
"Makim is a tool that helps you to organize "
"and simplify your helper commands."
),
epilog=(
'If you have any problem, open an issue at: '
'https://github.com/osl-incubator/makim'
"If you have any problem, open an issue at: "
"https://github.com/osl-incubator/makim"
),
)

makim: Makim = Makim()


@app.callback(invoke_without_command=True)
def main(
ctx: typer.Context,
version: bool = typer.Option(
None,
'--version',
'-v',
"--version",
"-v",
is_flag=True,
help='Show the version and exit',
help="Show the version and exit",
),
file: str = typer.Option(
'.makim.yaml',
'--file',
help='Makim config file',
".makim.yaml",
"--file",
help="Makim config file",
),
dry_run: bool = typer.Option(
None,
'--dry-run',
"--dry-run",
is_flag=True,
help='Execute the command in dry mode',
help="Execute the command in dry mode",
),
verbose: bool = typer.Option(
None,
'--verbose',
"--verbose",
is_flag=True,
help='Execute the command in verbose mode',
help="Execute the command in verbose mode",
),
) -> None:
"""Process envers for specific flags, otherwise show the help menu."""
typer.echo(f'Makim file: {file}')
"""Process top-level flags; otherwise, show the help menu."""
typer.echo(f"Makim file: {file}")

if version:
typer.echo(f'Version: {__version__}')
typer.echo(f"Version: {__version__}")
raise typer.Exit()

if ctx.invoked_subcommand is None:
Expand All @@ -79,14 +78,14 @@ def _get_command_from_cli() -> str:
This function is based on `CLI_ROOT_FLAGS_VALUES_COUNT`.
"""
params = sys.argv[1:]
command = ''
command = ""

try:
idx = 0
while idx < len(params):
arg = params[idx]
if arg not in CLI_ROOT_FLAGS_VALUES_COUNT:
command = f'flag `{arg}`' if arg.startswith('--') else arg
command = f"flag `{arg}`" if arg.startswith("--") else arg
break

idx += 1 + CLI_ROOT_FLAGS_VALUES_COUNT[arg]
Expand All @@ -97,59 +96,115 @@ def _get_command_from_cli() -> str:


def run_app() -> None:
"""Run the typer app."""
"""Run the Typer app."""
root_config = extract_root_config()

config_file_path = cast(str, root_config.get('file', '.makim.yaml'))
config_file_path = cast(str, root_config.get("file", ".makim.yaml"))

cli_completion_words = [
w for w in os.getenv('COMP_WORDS', '').split('\n') if w
w for w in os.getenv("COMP_WORDS", "").split("\n") if w
]

if not makim._check_makim_file(config_file_path) and cli_completion_words:
# autocomplete call
# Autocomplete call
root_config = extract_root_config(cli_completion_words)
config_file_path = cast(str, root_config.get('file', '.makim.yaml'))
config_file_path = cast(str, root_config.get("file", ".makim.yaml"))
if not makim._check_makim_file(config_file_path):
return

makim.load(
file=config_file_path,
dry_run=cast(bool, root_config.get('dry_run', False)),
verbose=cast(bool, root_config.get('verbose', False)),
dry_run=cast(bool, root_config.get("dry_run", False)),
verbose=cast(bool, root_config.get("verbose", False)),
)

# create tasks data
# Create tasks data
tasks: dict[str, Any] = {}
for group_name, group_data in makim.global_data.get('groups', {}).items():
for task_name, task_data in group_data.get('tasks', {}).items():
tasks[f'{group_name}.{task_name}'] = task_data
for group_name, group_data in makim.global_data.get("groups", {}).items():
for task_name, task_data in group_data.get("tasks", {}).items():
tasks[f"{group_name}.{task_name}"] = task_data

# Add dynamically cron commands to Typer app
if 'scheduler' in makim.global_data:
# Add dynamically created cron commands
if "scheduler" in makim.global_data:
typer_cron = typer.Typer(
help='Tasks Scheduler',
help="Tasks Scheduler",
invoke_without_command=True,
)

for schedule_name, schedule_params in makim.global_data.get(
'scheduler', {}
"scheduler", {}
).items():
create_dynamic_command_cron(
makim, typer_cron, schedule_name, schedule_params or {}
)

# Add cron command
app.add_typer(typer_cron, name='cron', rich_help_panel='Extensions')

# Add dynamically commands to Typer app
@typer_cron.command(help="List all scheduled tasks")
def list() -> None:
"""List all scheduled tasks."""
if not makim.scheduler:
typer.echo("No scheduled tasks configured.")
return

console = Console()
table = Table(show_header=True, header_style="bold")
table.add_column("Name")
table.add_column("Next Run")

jobs = makim.scheduler.list_jobs()
for job in jobs:
table.add_row(
job['name'],
job['next_run_time'] or "Not scheduled"
)

console.print(table)

@typer_cron.command(help="Start a scheduler by its name")
def start(name: str) -> None:
"""Start (enable) a scheduled task."""
if not makim.scheduler:
typer.echo("No scheduler configured.")
return

try:
schedule_config = makim.global_data.get("scheduler", {}).get(name)
if not schedule_config:
typer.echo(f"No configuration found for schedule '{name}'")
return

makim.scheduler.add_job(
name=name,
schedule=schedule_config["schedule"],
task=schedule_config["task"],
args=schedule_config.get("args", {})
)
typer.echo(f"Successfully started schedule '{name}'")
except Exception as e:
typer.echo(f"Failed to start schedule '{name}': {e}", err=True)

@typer_cron.command(help="Stop a scheduler by its name")
def stop(name: str) -> None:
"""Stop (disable) a scheduled task."""
if not makim.scheduler:
typer.echo("No scheduler configured.")
return

try:
makim.scheduler.remove_job(name)
typer.echo(f"Successfully stopped schedule '{name}'")
except Exception as e:
typer.echo(f"Failed to stop schedule '{name}': {e}", err=True)

app.add_typer(typer_cron, name="cron", rich_help_panel="Extensions")

# Add dynamically created commands to the Typer app
for name, args in tasks.items():
create_dynamic_command(makim, app, name, args)

try:
app()
except SystemExit as e:
# code 2 means code not found
# Code 2 means command not found
error_code = 2
if e.code != error_code:
raise e
Expand All @@ -163,11 +218,11 @@ def run_app() -> None:

typer.secho(
f"Command {command_used} not found. Did you mean '{suggestion}'?",
fg='red',
fg="red",
)

raise e


if __name__ == '__main__':
if __name__ == "__main__":
run_app()
28 changes: 26 additions & 2 deletions src/makim/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@

from makim.console import get_terminal_size
from makim.logs import MakimError, MakimLogs
from makim.scheduler import MakimScheduler

MAKIM_CURRENT_PATH = Path(__file__).parent

Expand Down Expand Up @@ -132,6 +133,7 @@ class Makim:
task_name: str = ''
task_data: dict[str, Any] = {}
ssh_config: dict[str, Any] = {}
scheduler: Optional[MakimScheduler] = None

def __init__(self) -> None:
"""Prepare the Makim class with the default configuration."""
Expand All @@ -145,6 +147,7 @@ def __init__(self) -> None:
self.shell_app = sh.xonsh
self.shell_args: list[str] = []
self.tmp_suffix: str = '.makim'
self.scheduler = None

def _call_shell_app(self, cmd: str) -> None:
self._load_shell_app()
Expand Down Expand Up @@ -385,7 +388,28 @@ def _load_config_data(self) -> None:
self.ssh_config = self.global_data.get('hosts', {})

self._validate_config()


if 'scheduler' in self.global_data:
if self.scheduler is None:
self.scheduler = MakimScheduler(self)

# Load scheduler configurations
for name, config in self.global_data['scheduler'].items():
schedule = config.get('schedule')
task = config.get('task')
args = config.get('args', {})

if schedule and task:
try:
self.scheduler.add_job(name, schedule, task, args)
except Exception as e:
MakimLogs.print_info(f"Failed to load scheduler {name}: {e}")

def shutdown(self) -> None:
"""Cleanup resources before exit."""
if self.scheduler:
self.scheduler.shutdown()

def _resolve_working_directory(self, scope: str) -> Optional[Path]:
scope_options = ('global', 'group', 'task')
if scope not in scope_options:
Expand Down Expand Up @@ -866,4 +890,4 @@ def run(self, args: dict[str, Any]) -> None:

self._run_hooks(args, 'pre-run')
self._run_command(args)
self._run_hooks(args, 'post-run')
self._run_hooks(args, 'post-run')
3 changes: 3 additions & 0 deletions src/makim/logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ class MakimError(Enum):
SSH_CONNECTION_ERROR = 16
SSH_EXECUTION_ERROR = 17
REMOTE_HOST_NOT_FOUND = 18
SCHEDULER_JOB_ERROR = 19
SCHEDULER_JOB_NOT_FOUND = 20
SCHEDULER_INVALID_SCHEDULE = 21


class MakimLogs:
Expand Down
Loading
Loading