Script to check Quartz CRON format

I had quite some issue with unproperly formatted CRON.
I wrote a script to bring them to light and fix them.
Here it is:

#!/usr/bin/env python3

import os
import re
import sys

# ANSI escape code for red text
RED = '\033[31m'
GREEN = '\033[32m'
RESET = '\033[0m'

quartz_cron_regex = r"""
^\s*(
    ($|\#|\w+\s*=|                                                              # Empty, comment, or environment variable
    (\?|\*|                                                                     # Match ? or *
    (?:[0-5]?\d)(?:(?:-|\/|,)(?:[0-5]?\d))?                                     # Match seconds or minutes
    (?:,(?:[0-5]?\d)(?:(?:-|\/|,)(?:[0-5]?\d))?)*)\s+                           # Allow lists of ranges
    (\?|\*|                                                                     # Minutes field
    (?:[0-5]?\d)(?:(?:-|\/|,)(?:[0-5]?\d))?
    (?:,(?:[0-5]?\d)(?:(?:-|\/|,)(?:[0-5]?\d))?)*)\s+                           # Allow lists of ranges
    (\?|\*|                                                                     # Hours field
    (?:[01]?\d|2[0-3])(?:(?:-|\/|,)(?:[01]?\d|2[0-3]))?
    (?:,(?:[01]?\d|2[0-3])(?:(?:-|\/|,)(?:[01]?\d|2[0-3]))?)*)\s+               # Lists and ranges
    (\?|\*|                                                                     # Day of month
    (?:0?[1-9]|[12]\d|3[01])(?:(?:-|\/|,)(?:0?[1-9]|[12]\d|3[01]))?
    (?:,(?:0?[1-9]|[12]\d|3[01])(?:(?:-|\/|,)(?:0?[1-9]|[12]\d|3[01]))?)*)\s+
    (\?|\*|                                                                     # Month field
    (?:[1-9]|1[012])(?:(?:-|\/|,)(?:[1-9]|1[012]))?
    (?:L|W)?(?:,(?:[1-9]|1[012])(?:(?:-|\/|,)(?:[1-9]|1[012]))?(?:L|W)?)*
    |\?|\*|(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)
    (?:(?:-)(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC))?
    (?:,(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)
    (?:(?:-)(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC))?)*)\s+
    (\?|\*|                                                                     # Day of week
    (?:[0-6])(?:(?:-|\/|,|\#)(?:[0-6]))?
    (?:L)?(?:,(?:[0-6])(?:(?:-|\/|,|\#)(?:[0-6]))?(?:L)?)*
    |\?|\*|(?:MON|TUE|WED|THU|FRI|SAT|SUN)
    (?:(?:-)(?:MON|TUE|WED|THU|FRI|SAT|SUN))?
    (?:,(?:MON|TUE|WED|THU|FRI|SAT|SUN)
    (?:(?:-)(?:MON|TUE|WED|THU|FRI|SAT|SUN))?)*))                               # Fix parentheses for day of week
    (|\s)+                                                                      # Optional whitespace
    (\?|\*|                                                                     # Year field
    (?:\d{4})(?:(?:-|\/|,)(?:\d{4}))?
    (?:,(?:\d{4})(?:(?:-|\/|,)(?:\d{4}))?)*)?
)$
"""

quartz_cron_pattern = re.compile(quartz_cron_regex, re.VERBOSE)


# Function to validate Quartz cron expressions
def validate_cron(cron_expr):
    if len(cron_expr.split()) == 7 and quartz_cron_pattern.match(cron_expr):
        return True
    else:
        return False

# Function to search for "Time cron" in files and validate cron expressions
def search_and_validate_crons(root_dir='.'):
    # Regex to find lines containing 'Time cron' and extract cron expression
    cron_pattern = re.compile(r'Time cron\s+"([^"]+)"')
    err_count = 0
    total_count = 0

    # Walk through all files in the given directory
    for root, dirs, files in os.walk(root_dir):
        rule_files = [f for f in files if f.endswith('.rules')]
        for file in rule_files:
            file_path = os.path.join(root, file)

            # Read file and search for cron expressions
            with open(file_path, 'r', encoding='utf-8') as f:
                for line_number, line in enumerate(f, start=1):
                    # Search for cron expressions
                    match = cron_pattern.search(line)
                    if match:
                        cron_expr = match.group(1)
                        # Validate the cron expression
                        total_count += 1
                        if validate_cron(cron_expr):
                            print(f"{file_path}:{line_number}\t: {GREEN}OK{RESET}\t{cron_expr}")
                        else:
                            print(f"{file_path}:{line_number}\t: {RED}ERR{RESET}\t{cron_expr}")
                            err_count += 1
    if err_count > 0:
        print(f"\n{RED}ERROR{RESET}: {err_count}/{total_count} Quartz cron expressions are invalid")
        sys.exit(1) # Exit with a non-zero status
    else:
        print(f"\n{GREEN}SUCCESS{RESET}: {total_count}/{total_count} valid Quartz cron expressions")
        sys.exit(0)

# Run the script
if __name__ == "__main__":
    search_and_validate_crons()

Copy it in your rules folder and chmod a+x check-cron.py.
You may then run ./check-cron.py. The output looks like:

./astro.rules:37 : OK - 0 0 8 * * *
./astro.rules:49 : OK - 0 00 20 * * *
./astro.rules:97 : NOT OK - 0 0 19 ? * *
./astro.rules:105 : NOT OK - 0 0 6 ? * *
[...]
ERROR: 31/45 cron expressions are invalid

With errors showing up in red.
If you run that from VScode, you may click on the file ref and VSCode will jump to the affected line, helping you to fix those quickly :slight_smile:

ok and not ok are reversed…
The first two cron expressions are wrong (no ? in day or weekday)
The last two cron expressions are correct.

They are not but there were indeed issues in the regex and it is rather incomplete. I am fixing them and I will post an update. Thanks for the heads up !

Maybe a bit too short… I meant the example, I did not dive deep into the code itself… :slight_smile:

I feared the day when I’d have to check all those cron expressions :slight_smile:
I updated the regex and the code above. Hopefully this is getting closer.
I don’t think I can check it 100% using a regex but at least, it should catch the most obvious errors.

This is howevber now obsolete with 4.3.2 for 2 reasons:

  • bad expressions will no longer chocke OH
  • bad expressions will show up as a WARNing in the logs