Commit 3a743b41 authored by Martin Owens's avatar Martin Owens 🕘
Browse files

Remove specials argument from write and move to module variable

To better control the output and make output consistant
accross multuple crontabs and multiple ways of writing
output, we move the specials mode to crontab.SPECIALS_CONVERSION
and document it.
parent fed70d58
Loading
Loading
Loading
Loading
+19 −1
Original line number Diff line number Diff line
@@ -58,6 +58,8 @@ Case Meaning
@midnight   0 0 * * *  
=========== ============

To control the output of specials see `SPECIALS_CONVERSION` below.

How to Use the Module
=====================

@@ -280,11 +282,27 @@ Each job can also have a list of environment variables::
        job.env['NEW_VAR'] = 'A'
        print(job.env)

Specials Mode
=============

You may need to control the output of special schedules such as `@hourly`. This can be done with the `crontab.SPECIALS_CONVERSION` package variable.

Enable conversion to a special on write (default)::

    crontab.SPECIALS_CONVERSION = True

Enabled conversion to normal output on write::

    crontab.SPECIALS_CONVERSION = False

Do no conversion and write whatever was read in::

    crontab.SPECIALS_CONVERSION = None

Proceeding Unit Confusion
=========================

It is sometimes logical to think that job.hour.every(2) will set all proceeding
It is sometimes logical to think that `job.hour.every(2)` will set all proceeding
units to '0' and thus result in "0 \*/2 * * \*". Instead you are controlling
only the hours units and the minute column is unaffected. The real result would
be "\* \*/2 * * \*" and maybe unexpected to those unfamiliar with crontabs.
+10 −13
Original line number Diff line number Diff line
#
# Copyright 2021, Martin Owens <doctormo@gmail.com>
# Copyright 2025, Martin Owens <doctormo@gmail.com>
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
@@ -100,7 +100,7 @@ from collections import OrderedDict
from shutil import which

__pkgname__ = 'python-crontab'
__version__ = '3.2.0'
__version__ = '3.3.0'

ITEMREX = re.compile(r'^\s*([^@#\s]+)\s+([^@#\s]+)\s+([^@#\s]+)\s+([^@#\s]+)'
                     r'\s+([^@#\s]+)\s+([^\n]*?)(\s+#\s*([^\n]*)|$)')
@@ -112,6 +112,7 @@ WEEK_ENUM = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
MONTH_ENUM = [None, 'jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug',
              'sep', 'oct', 'nov', 'dec']

SPECIALS_CONVERSION = True
SPECIALS = {"reboot":   '@reboot',
            "hourly":   '0 * * * *',
            "daily":    '0 0 * * *',
@@ -433,14 +434,10 @@ class CronTab:
            sleep(cadence)
            count += 1

    def render(self, errors=False, specials=True):
    def render(self, errors=False):
        """Render this crontab as it would be in the crontab.

        errors - Should we not comment out invalid entries and cause errors?
        specials - Turn known times into keywords such as "@daily"
            True - (default) force all values to be converted (unless SYSTEMV)
            False - force all values back from being a keyword
            None - don't change the special keyword use
        """
        crons = []
        for line in self.lines:
@@ -454,7 +451,7 @@ class CronTab:
            elif isinstance(line, CronItem):
                if not line.is_valid() and not errors:
                    line.enabled = False
                crons.append(line.render(specials=specials).strip())
                crons.append(line.render().strip())

        # Environment variables are attached to cron lines so order will
        # always work no matter how you add lines in the middle of the stack.
@@ -727,7 +724,7 @@ class CronItem:
        """Return true if this job is valid"""
        return self.valid

    def render(self, specials=True):
    def render(self):
        """Render this set cron-job to a string"""
        if not self.is_valid() and self.enabled:
            raise ValueError('Refusing to render invalid crontab.'
@@ -738,7 +735,7 @@ class CronItem:
            if not self.user:
                raise ValueError("Job to system-cron format, no user set!")
            user = self.user + ' '
        rend = self.slices.render(specials=specials)
        rend = self.slices.render()
        result = f"{rend} {user}{command}"
        if self.stdin:
            result += ' %' + self.stdin.replace('\n', '%')
@@ -1059,14 +1056,14 @@ class CronSlices(list):
        """Return just numbered parts of this crontab"""
        return ' '.join([str(s) for s in self])

    def render(self, specials=True):
    def render(self):
        "Return just the first part of a cron job (the numbers or special)"
        slices = self.clean_render()
        if self.special and specials is not False:
        if self.special and SPECIALS_CONVERSION is not False:
            if self.special == '@reboot' or \
                    SPECIALS[self.special.strip('@')] == slices:
                return self.special
        if not SYSTEMV and specials is True:
        if not SYSTEMV and SPECIALS_CONVERSION is True:
            for (name, value) in SPECIALS.items():
                if value == slices and name not in SPECIAL_IGNORE:
                    return f"@{name}"
+3 −5
Original line number Diff line number Diff line
#!/usr/bin/env python
#
# Copyright (C) 2008-2018 Martin Owens
# Copyright (C) 2008-2025 Martin Owens
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
@@ -20,7 +20,6 @@

import os
from setuptools import setup
from crontab import __version__, __pkgname__

# remove MANIFEST. distutils doesn't properly update it when the
# contents of directories change.
@@ -35,8 +34,8 @@ with open('README.rst') as fhl:
RELEASE = "1"

setup(
    name             = __pkgname__,
    version          = __version__,
    name             = 'python-crontab',
    version          = '3.3.0',
    release          = RELEASE,
    description      = 'Python Crontab API',
    long_description = description,
@@ -59,7 +58,6 @@ setup(
        'Intended Audience :: Developers',
        'Intended Audience :: Information Technology',
        'Intended Audience :: System Administrators',
        'License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)',
        'Operating System :: POSIX',
        'Operating System :: POSIX :: Linux',
        'Operating System :: POSIX :: SunOS/Solaris',
+16 −4
Original line number Diff line number Diff line
@@ -214,11 +214,23 @@ class UseTestCase(unittest.TestCase):
        """Rendering can be done without specials"""
        cronitem = crontab.CronItem('true')
        cronitem.setall('0 0 * * *')
        self.assertEqual(cronitem.render(specials=True), '@daily true')
        self.assertEqual(cronitem.render(specials=None), '0 0 * * * true')
        crontab.SPECIALS_CONVERSION = True
        self.assertEqual(cronitem.render(), '@daily true')
        crontab.SPECIALS_CONVERSION = False
        self.assertEqual(cronitem.render(), '0 0 * * * true')
        crontab.SPECIALS_CONVERSION = None
        self.assertEqual(cronitem.render(), '0 0 * * * true')

    def test_22_slice_special_convert(self):
        """Render of specials global control"""
        cronitem = crontab.CronItem('true')
        cronitem.setall('@daily')
        self.assertEqual(cronitem.render(specials=None), '@daily true')
        self.assertEqual(cronitem.render(specials=False), '0 0 * * * true')
        crontab.SPECIALS_CONVERSION = True
        self.assertEqual(cronitem.render(), '@daily true')
        crontab.SPECIALS_CONVERSION = False
        self.assertEqual(cronitem.render(), '0 0 * * * true')
        crontab.SPECIALS_CONVERSION = None
        self.assertEqual(cronitem.render(), '@daily true')

    def test_25_process(self):
        """Test opening pipes"""