job.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. # -*- coding: utf-8 -*-
  2. """
  3. plan.job
  4. ~~~~~~~~
  5. Job classes for Plan.
  6. :copyright: (c) 2014 by Shipeng Feng.
  7. :license: BSD, see LICENSE for more details.
  8. """
  9. import sys
  10. import re
  11. import collections
  12. from .output import Output
  13. from .exceptions import ParseError, ValidationError
  14. from ._compat import iteritems
  15. # Time types
  16. MINUTE = "minute"
  17. HOUR = "hour"
  18. DAY = "day of month"
  19. MONTH = "month"
  20. WEEK = "day of week"
  21. MONTH_MAP = {
  22. "jan": "1",
  23. "feb": "2",
  24. "mar": "3",
  25. "apr": "4",
  26. "may": "5",
  27. "jun": "6",
  28. "jul": "7",
  29. "aug": "8",
  30. "sep": "9",
  31. "oct": "10",
  32. "nov": "11",
  33. "dec": "12"
  34. }
  35. WEEK_MAP = {
  36. "sun": "0",
  37. "mon": "1",
  38. "tue": "2",
  39. "wed": "3",
  40. "thu": "4",
  41. "fri": "5",
  42. "sat": "6"
  43. }
  44. CRON_TIME_SYNTAX_RE = re.compile(r"^.+\s+.+\s+.+\s+.+\s+.+$")
  45. PREDEFINED_DEFINITIONS = set(["yearly", "annually", "monthly", "weekly",
  46. "daily", "hourly", "reboot"])
  47. def is_month(time):
  48. """Tell whether time is one of the month names.
  49. :param time: a string of time.
  50. """
  51. return time[:3].lower() in MONTH_MAP
  52. def is_week(time):
  53. """Tell whether time is one of the days of week.
  54. :param time: a string of time.
  55. """
  56. return time[:3].lower() in WEEK_MAP or \
  57. time.lower() in ('weekday', 'weekend')
  58. def get_frequency(every):
  59. """Get frequency value from one every type value:
  60. >>> get_frequency('3.day')
  61. 3
  62. :param every: One every type value.
  63. """
  64. return int(every[:every.find('.')])
  65. def get_moment(at):
  66. """Get moment value from one at type value:
  67. >>> get_moment('minute.1')
  68. 1
  69. :param at: One at type value.
  70. """
  71. return int(at[at.find('.') + 1:])
  72. class Job(object):
  73. """The plan job base class.
  74. :param task: this is what the job does.
  75. :param every: how often does the job run.
  76. :param at: when does the job run.
  77. :param path: the path you want to run the task on,
  78. default to be current working directory.
  79. :param environment: the environment you want to run the task under.
  80. :param output: the output redirection for the task.
  81. """
  82. def __init__(self, task, every, at=None, path=None,
  83. environment=None, output=None):
  84. self.task = task
  85. self.every = every
  86. self.at = at
  87. self.path = path
  88. self.environment = environment
  89. self.output = str(Output(output))
  90. @property
  91. def env(self):
  92. if not self.environment:
  93. return ''
  94. kv_pairs = []
  95. for k, v in iteritems(self.environment):
  96. kv_pairs.append('='.join((k, v)))
  97. return ' '.join(kv_pairs)
  98. @property
  99. def main_template(self):
  100. """The main job template.
  101. """
  102. return "{task}"
  103. def task_template(self):
  104. """The task template. You should implement this in your own job type.
  105. The default template is::
  106. 'cd {path} && {environment} {task} {output}'
  107. """
  108. return 'cd {path} && {environment} {task} {output}'
  109. def process_template(self, template):
  110. """Process template content. Drop multiple spaces in a row and strip
  111. it.
  112. """
  113. template = re.sub(r'\s+', r' ', template)
  114. template = template.strip()
  115. return template
  116. def produce_frequency_time(self, frequency, maximum, start=0):
  117. """Translate frequency into comma separated times.
  118. """
  119. # how many units one time type have
  120. length = maximum - start + 1
  121. # if every is the same with unit length
  122. if frequency == length:
  123. return str(start)
  124. # else if every is one unit, we use '*'
  125. elif frequency == 1:
  126. return '*'
  127. # otherwise, we make steps comma separated
  128. else:
  129. times = list(range(start, maximum + 1, frequency))
  130. if length % frequency:
  131. del times[0]
  132. times = map(str, times)
  133. return ','.join(times)
  134. def parse_month(self, month):
  135. """Parses month into month numbers. Month can only occur in
  136. every value.
  137. :param month: this parameter can be the following values:
  138. jan feb mar apr may jun jul aug sep oct nov dec
  139. and all of those full month names(case insenstive)
  140. or <int:n>.month
  141. """
  142. if '.' in month:
  143. frequency = get_frequency(month)
  144. return self.produce_frequency_time(frequency, 12, 1)
  145. else:
  146. month = month[:3].lower()
  147. return MONTH_MAP[month]
  148. def parse_week(self, week):
  149. """Parses day of week name into week numbers.
  150. :param week: this parameter can be the following values:
  151. sun mon tue wed thu fri sat
  152. sunday monday tuesday wednesday thursday friday
  153. saturday
  154. weekday weedend(case insenstive)
  155. """
  156. if week.lower() == "weekday":
  157. return "1,2,3,4,5"
  158. elif week.lower() == "weekend":
  159. return "6,0"
  160. else:
  161. week = week[:3].lower()
  162. return WEEK_MAP[week]
  163. def parse_every(self):
  164. """Parse every value.
  165. :return: every_type.
  166. """
  167. every = self.every
  168. if '.minute' in every:
  169. every_type, frequency = MINUTE, get_frequency(every)
  170. if frequency not in range(1, 61):
  171. raise ParseError("Your every value %s is invalid, out of"
  172. " minute range[1-60]" % every)
  173. elif '.hour' in every:
  174. every_type, frequency = HOUR, get_frequency(every)
  175. if frequency not in range(1, 25):
  176. raise ParseError("Your every value %s is invalid, out of"
  177. " hour range[1-24]" % every)
  178. elif '.day' in every:
  179. every_type, frequency = DAY, get_frequency(every)
  180. if frequency not in range(1, 32):
  181. raise ParseError("Your every value %s is invalid, out of"
  182. " month day range[1-31]" % every)
  183. elif '.month' in every or is_month(every):
  184. every_type = MONTH
  185. if '.' in every:
  186. frequency = get_frequency(every)
  187. if frequency not in range(1, 13):
  188. raise ParseError("Your every value %s is invalid, out of"
  189. " month range[1-12]" % every)
  190. elif '.year' in every:
  191. every_type, frequency = MONTH, get_frequency(every)
  192. if frequency not in range(1, 2):
  193. raise ParseError("Your every value %s is invalid, out of"
  194. " year range[1]" % every)
  195. # Just handle months internally
  196. self.every = "12.months"
  197. elif is_week(every):
  198. every_type = WEEK
  199. else:
  200. raise ParseError("Your every value %s is invalid" % every)
  201. return every_type
  202. def preprocess_at(self, at):
  203. """Do preprocess for at value, just modify "12:12" style moment into
  204. "hour.12 minute.12" style moment value.
  205. :param at: The at value you want to do preprocess.
  206. """
  207. ats = at.split(' ')
  208. processed_ats = []
  209. for at in ats:
  210. if ':' in at:
  211. hour, minute = at.split(':')[:2]
  212. if minute.startswith('0') and len(minute) >= 2:
  213. minute = minute[1]
  214. hour = 'hour.' + hour
  215. minute = 'minute.' + minute
  216. processed_ats.append(hour)
  217. processed_ats.append(minute)
  218. else:
  219. processed_ats.append(at)
  220. return ' '.join(processed_ats)
  221. def parse_at(self):
  222. """Parse at value into (at_type, moment) pairs.
  223. """
  224. pairs = dict()
  225. if not self.at:
  226. return pairs
  227. processed_at = self.preprocess_at(self.at)
  228. ats = processed_at.split(' ')
  229. at_map = collections.defaultdict(list)
  230. # Parse at value into (at_type, moments_list) pairs.
  231. # One same at_type can have multiple moments like:
  232. # at = "minute.5 minute.10 hour.2"
  233. for at in ats:
  234. if 'minute.' in at:
  235. at_type, moment = MINUTE, get_moment(at)
  236. if moment not in range(60):
  237. raise ParseError("Your at value %s is invalid"
  238. " out of minute range[0-59]" % self.at)
  239. elif 'hour.' in at:
  240. at_type, moment = HOUR, get_moment(at)
  241. if moment not in range(24):
  242. raise ParseError("Your at value %s is invalid"
  243. " out of hour range[0-23]" % self.at)
  244. elif 'day.' in at:
  245. at_type, moment = DAY, get_moment(at)
  246. if moment not in range(1, 32):
  247. raise ParseError("Your at value %s is invalid"
  248. " out of month day range[1-31]" % self.at)
  249. elif 'month.' in at or 'year.' in at:
  250. raise ParseError("Your at value %s is invalid"
  251. " can not set month or year" % self.at)
  252. elif is_week(at):
  253. at_type = WEEK
  254. moment = self.parse_week(at)
  255. else:
  256. raise ParseError("Your at value %s is invalid" % self.at)
  257. if moment not in at_map[at_type]:
  258. at_map[at_type].append(moment)
  259. # comma seperate same at_type moments
  260. for at_type, moments in iteritems(at_map):
  261. moments = map(str, moments)
  262. pairs[at_type] = ','.join(moments)
  263. return pairs
  264. def validate_time(self):
  265. """Validate every and at value.
  266. every can be::
  267. [1-60].minute [1-24].hour [1-31].day
  268. [1-12].month [1].year
  269. jan feb mar apr may jun jul aug sep oct nov dec
  270. sun mon tue wed thu fri sat weekday weekend
  271. or any fullname of month names and day of week names
  272. (case insensitive)
  273. at::
  274. when every is minute, can not be set
  275. when every is hour, can be minute.[0-59]
  276. when every is day of month, can be minute.[0-59], hour.[0-23]
  277. when every is month, can be day.[1-31], day of week,
  278. minute.[0-59], hour.[0-23]
  279. when every is day of week, can be minute.[0-59], hour.[0-23]
  280. at can also be multiple at values seperated by one space.
  281. """
  282. every_type, every = self.parse_every(), self.every
  283. ats = self.parse_at()
  284. if every_type == MINUTE:
  285. if ats:
  286. raise ValidationError("at can not be set when every is"
  287. " minute related")
  288. elif every_type == HOUR:
  289. for at_type in ats:
  290. if at_type not in (MINUTE):
  291. raise ValidationError("%s can not be set when every is"
  292. " hour related" % at_type)
  293. elif every_type == DAY:
  294. for at_type in ats:
  295. if at_type not in (MINUTE, HOUR):
  296. raise ValidationError("%s can not be set when every is"
  297. " month day related" % at_type)
  298. elif every_type == MONTH:
  299. for at_type in ats:
  300. if at_type not in (MINUTE, HOUR, DAY, WEEK):
  301. raise ValidationError("%s can not be set when every is"
  302. " month related" % at_type)
  303. elif every_type == WEEK:
  304. for at_type in ats:
  305. if at_type not in (MINUTE, HOUR):
  306. raise ValidationError("%s can not be set when every is"
  307. " week day related" % at_type)
  308. return every_type, every, ats
  309. def parse_time(self):
  310. """Parse every and at into cron time syntax::
  311. # * * * * * command to execute
  312. # ┬ ┬ ┬ ┬ ┬
  313. # │ │ │ │ │
  314. # │ │ │ │ │
  315. # │ │ │ │ └─── day of week (0 - 7) (0 to 6 are Sunday to Saturday)
  316. # │ │ │ └───── month (1 - 12)
  317. # │ │ └─────── day of month (1 - 31)
  318. # │ └───────── hour (0 - 23)
  319. # └─────────── minute (0 - 59)
  320. """
  321. every_type, every, ats = self.validate_time()
  322. time = ['*'] * 5
  323. if every_type == MINUTE:
  324. frequency = get_frequency(every)
  325. time[0] = self.produce_frequency_time(frequency, 59)
  326. elif every_type == HOUR:
  327. frequency = get_frequency(every)
  328. time[0] = ats.get(MINUTE, '0')
  329. time[1] = self.produce_frequency_time(frequency, 23)
  330. elif every_type == DAY:
  331. frequency = get_frequency(every)
  332. time[0] = ats.get(MINUTE, '0')
  333. time[1] = ats.get(HOUR, '0')
  334. time[2] = self.produce_frequency_time(frequency, 31, 1)
  335. elif every_type == MONTH:
  336. time[0] = ats.get(MINUTE, '0')
  337. time[1] = ats.get(HOUR, '0')
  338. time[2] = ats.get(DAY, '1')
  339. time[3] = self.parse_month(every)
  340. time[4] = ats.get(WEEK, '*')
  341. else:
  342. time[0] = ats.get(MINUTE, '0')
  343. time[1] = ats.get(HOUR, '0')
  344. time[4] = self.parse_week(every)
  345. return ' '.join(time)
  346. @property
  347. def task_in_cron_syntax(self):
  348. """Cron content task part.
  349. """
  350. kwargs = {
  351. "path": self.path,
  352. "environment": self.env,
  353. "task": self.task,
  354. "output": self.output
  355. }
  356. task = self.task_template().format(**kwargs)
  357. task = self.process_template(task)
  358. main = self.main_template.format(task=task)
  359. return self.process_template(main)
  360. @property
  361. def time_in_cron_syntax(self):
  362. """Cron content time part.
  363. """
  364. if CRON_TIME_SYNTAX_RE.match(self.every):
  365. return self.every
  366. elif self.every in PREDEFINED_DEFINITIONS:
  367. return "@%s" % self.every
  368. else:
  369. return self.parse_time()
  370. @property
  371. def cron(self):
  372. """Job in cron syntax."""
  373. return ' '.join([self.time_in_cron_syntax, self.task_in_cron_syntax])
  374. class CommandJob(Job):
  375. """The command job.
  376. """
  377. def task_template(self):
  378. """Template::
  379. '{task} {output}'
  380. """
  381. return '{task} {output}'
  382. class ScriptJob(Job):
  383. """The script job.
  384. """
  385. def task_template(self):
  386. """Template::
  387. 'cd {path} && {environment} %s {task} {output}' % sys.executable
  388. """
  389. return 'cd {path} && {environment} %s {task} {output}' % sys.executable
  390. class ModuleJob(Job):
  391. """The module job.
  392. """
  393. def task_template(self):
  394. """Template::
  395. '{environment} %s -m {task} {output}' % sys.executable
  396. """
  397. return '{environment} %s -m {task} {output}' % sys.executable
  398. class RawJob(Job):
  399. """The raw job.
  400. """
  401. def task_template(self):
  402. """Template::
  403. '{task}'
  404. """
  405. return '{task}'