~yerinalexey/pcrond

ref: b8852edc2de0242ed1e6aa99345a41e94052c4a3 pcrond/pcrond/job.py -rw-r--r-- 6.9 KiB
b8852edc — Luca Vercelli setup 3 years ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
MONTH_OFFSET = {'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'may': 5, 'jun': 6,
        'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov':11, 'dec':12}
WEEK_OFFSET = {'sun': 0, 'mon': 1, 'tue': 2, 'wed': 3, 'thu': 4, 'fri': 5,
        'sat': 6}
ALIASES = {
    '@yearly':  '0 0 1 1 *',
    '@annually':  '0 0 1 1 *',
    '@monthly': '0 0 1 * *',
    '@weekly':  '0 0 * * 0',
    '@daily':   '0 0 * * *',
    '@hourly':  '0 * * * *',
}

class Job(object):
    """
    A periodic job as used by :class:`Scheduler`.
    """
    def __init__(self, crontab, job_func=None, scheduler=None):
        """
        Constructor
        :param crontab:
            string containing crontab pattern
            Its tokens may be either: 1 (if alias), 5 (without year token), 6 (with year token)
        :param job_func:
            the job 0-ary function to run
            if None, you should set it later
        :param scheduler:
            scheduler to register with
            if None, you should set it later
        """
        self.job_func = job_func
        self.scheduler = scheduler
        self.running = False

        if crontab in ALIASES.keys():
            crontab = ALIASES[crontab]

        crontab_lst = crontab.split()
        
        if crontab_lst[0] in ALIASES.keys():
            raise ValueError("Cannot mix @Aliases and other tokens")
        
        if len(crontab_lst) == 5:
            crontab_lst.append("*")
        if len(crontab_lst) != 6:
            raise ValueError("Each crontab pattern *must* contain either 5 or 6 items")
        self.allowed_min = self._parse_min(crontab_lst[0])
        self.allowed_hours = self._parse_hour(crontab_lst[1])
        self.allowed_days_of_month = self._parse_day_in_month(crontab_lst[2])
        self.allowed_months = self._parse_month(crontab_lst[3])
        self.allowed_days_of_week = self._parse_day_in_week(crontab_lst[4])
        self.allowed_years = self._parse_year(crontab_lst[5])
        
        self.allowed_last_day_of_month = True if -1 in self.allowed_days_of_month else False
        
        if -1 in self.allowed_years:
            raise ValueError("Wrong format '%s' : 'L' is meaningless talking about Years" % crontab_lst[5])
        
        self.crontab_pattern = crontab_lst

    def _parse_token(self, token, offsets):
        if token in offsets.keys():
            newtoken = offsets[token]
            try:
                return int(newtoken)
            except ValueError:
                raise ValueError("token %s maps to %s, however the latter is not an integer" % (token, newtoken))
        try:
            return int(token)
        except ValueError:
            raise ValueError("token %s is not an integer, nor it is a known constant" % token)
            
    
    def _parse_common(self, s, maxval, offsets={}):
        """
        generate a set of integers, corresponding to "allowed values".
        Work for minute, hours, weeks, month, ad days of week, because they are all "similar".
        Does not work very well for years and days of month
        Supported formats: "*", "*/3", "1,2,3", "L", "1,2-5,jul,10-L", "50-10"
        :param maxval: es. 60 for minutes, 12 for month, ...
        :param offsets: a dict mapping names (es. "mar") to their offsets (es. 2).
        """
        if "L" not in offsets:
            offsets["L"] = maxval-1
        if s == "*":
            return range(0, maxval)     #every minute
        elif s.startswith("*/"):        #every tot minutes
            try:
                step = int(s[2:])
            except ValueError:
                raise ValueError("Wrong format '%s' - expecting an integer after '*/'" % s)
            return range(0, maxval, step)
        else:                           #at given minutes
            ranges = s.split(",")                       #   ["1","2-5","jul","10-L"]
            ranges = [x.split("-") for x in ranges]     #   [["1"],["2","5"],["aug"], ["10","L"]]
            ranges = [[self._parse_token(w, offsets) for w in x] for x in ranges]  #   [[1],[2,5],[7], [10,11]]
            #DEBUG
            #import pdb
            #pdb.set_trace()
            if max([len(x) for x in ranges]) > 2:
                raise ValueError("Wrong format '%s' - a string x-y-z is meaningless" % s)
            ranges1 = [x for x in ranges if len(x)==1]                                             # [[1], [7]]
            ranges1.extend([range(x[0], x[1]+1) for x in ranges if len(x)==2 and x[0]<=x[1]])      # [[2,3,4,5], [10, 11]]
            ranges1.extend([range(x[0], maxval) for x in ranges if len(x)==2 and x[0]>x[1]])       # [] in this case
            ranges1.extend([range(0, x[1]+1) for x in ranges if len(x)==2 and x[0]>x[1]])          # [] in this case
            return set([z for rng in ranges1 for z in rng])

    def _parse_min(self, s):
        return self._parse_common(s, 60)

    def _parse_hour(self, s):
        return self._parse_common(s, 24)

    def _parse_month(self, s):
        return self._parse_common(s, 12, MONTH_OFFSET)

    def _parse_day_in_week(self, s):
        return self._parse_common(s, 7, WEEK_OFFSET)

    def _parse_year(self, s):
        """ to put things simple, I assume a range of years between 0 and 3000 ... This is mostly useless. """
        return self._parse_common(s, 3000, {"L" : -1})

    def _parse_day_in_month(self, s):
        return self._parse_common(s, 31, {"L" : -1})    #this works by chance

    def _check_day_in_month(self, now):
        if self.allowed_last_day_of_month:
            #this is a hack for avoiding to calculate "L" when not needed
            import calendar
            last_day_of_month = calendar.monthrange(now.year, now.month)[1]
            if now.day == last_day_of_month:
                return True;
        return now.day in self.allowed_days_of_month;

    def __lt__(self, other):
        """
        Periodic Jobs are sortable based on the scheduled time they
        run next.
        """
        return self.next_run < other.next_run

    def should_run(self):
        """
        :return: ``True`` if the job should be run now.
        """
        import datetime
        now = datetime.datetime.now()
        #FIXME was: return datetime.datetime.now() >= self.next_run
        return not self.running \
            and now.year in self.allowed_years \
            and now.month in self.allowed_months \
            and now.weekday() in self.allowed_days_in_week \
            and now.hour in self.allowed_hours \
            and now.minute in self.allowed_minutes \
            and self._check_day_in_month(now);

    def run(self):
        """
        Run the job.
        :return: The return value returned by the `job_func`
        """
        self.running = True
        ret = self.job_func()
        self.running = false
        return ret
        
    def run_if_should(self):
        """
        Run the job if needed.
        :return: The return value returned by the `job_func`
        """
        if self.should_run():
            logger.info('Running job %s', self)
            return self.run()