core.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. # -*- coding: utf-8 -*-
  2. """
  3. plan.core
  4. ~~~~~~~~~
  5. Core classes for Plan.
  6. :copyright: (c) 2014 by Shipeng Feng.
  7. :license: BSD, see LICENSE for more details.
  8. """
  9. import re
  10. import os
  11. import tempfile
  12. import shlex
  13. import subprocess
  14. from .commands import Echo
  15. from .job import CommandJob, ScriptJob, ModuleJob, RawJob
  16. from .output import Output
  17. from ._compat import string_types, get_binary_content
  18. from .exceptions import PlanError
  19. from .utils import communicate_process
  20. class Plan(object):
  21. """The central object where you register jobs. One Plan instance should
  22. manage a group of jobs.
  23. :param name: the unique identity for this plan object, default to be main
  24. :param path: the global path you want to run the task on.
  25. :param environment: the global crontab job bash environment.
  26. :param output: the global crontab job output logfile for this object.
  27. :param user: the user you want to run `crontab` command with.
  28. """
  29. def __init__(self, name="main", path=None, environment=None,
  30. output=None, user=None):
  31. self.name = name
  32. if path is None:
  33. self.path = os.getcwd()
  34. else:
  35. self.path = path
  36. self.environment = environment
  37. self.output = str(Output(output))
  38. self.user = user
  39. # All commands should be executed before run
  40. self.bootstrap_commands = []
  41. # All environment settings on this Plan object
  42. self.envs = {}
  43. # All jobs registered on this Plan object
  44. self.jobs = []
  45. def bootstrap(self, command_or_commands):
  46. """Register bootstrap commands.
  47. :param command_or_commands: One command or a list of commands.
  48. """
  49. if isinstance(command_or_commands, string_types):
  50. self.bootstrap_commands.append(command_or_commands)
  51. elif isinstance(command_or_commands, list):
  52. self.bootstrap_commands.extend(command_or_commands)
  53. def env(self, variable, value):
  54. """Add one environment variable for this Plan object in the crontab.
  55. .. versionadded:: 0.5
  56. :param variable: environment variable name.
  57. :param value: environment variable value.
  58. """
  59. self.envs[variable] = value
  60. def command(self, *args, **kwargs):
  61. """Register one command, takes the same parameters as
  62. :class:`~plan.Job`."""
  63. job = CommandJob(*args, **kwargs)
  64. self.job(job)
  65. def script(self, *args, **kwargs):
  66. """Register one script, takes the same parameters as
  67. :class:`~plan.Job`."""
  68. job = ScriptJob(*args, **kwargs)
  69. self.job(job)
  70. def module(self, *args, **kwargs):
  71. """Register one module, takes the same parameters as
  72. :class:`~plan.Job`."""
  73. job = ModuleJob(*args, **kwargs)
  74. self.job(job)
  75. def raw(self, *args, **kwargs):
  76. """Register one raw job, takes the same parameters as
  77. :class:`~plan.Job`."""
  78. job = RawJob(*args, **kwargs)
  79. self.job(job)
  80. def job(self, job):
  81. """Register one job.
  82. :param job: one :class:`~plan.Job` instance.
  83. """
  84. if self.path and not job.path:
  85. job.path = self.path
  86. if self.environment and not job.environment:
  87. job.environment = self.environment
  88. if self.output and not job.output:
  89. job.output = self.output
  90. self.jobs.append(job)
  91. @property
  92. def comment_begin(self):
  93. """Comment begin content for this object, this will be added before
  94. the actual cron syntax jobs content. Different name is used to
  95. distinguish different Plan object, so we can locate the cronfile
  96. content corresponding to this object.
  97. """
  98. return "# Begin Plan generated jobs for: %s" % self.name
  99. @property
  100. def environment_variables(self):
  101. """Return a list of crontab environment settings's cron syntax
  102. content.
  103. .. versionadded:: 0.5
  104. """
  105. variables = []
  106. for variable, value in self.envs.items():
  107. if value is not None:
  108. value = '"%s"' % str(value)
  109. variables.append("%s=%s" % (str(variable), value))
  110. return variables
  111. @property
  112. def crons(self):
  113. """Return a list of registered jobs's cron syntax content."""
  114. return [job.cron for job in self.jobs]
  115. @property
  116. def comment_end(self):
  117. return "# End Plan generated jobs for: %s" % self.name
  118. @property
  119. def cron_content(self):
  120. """Your schedule jobs converted to cron syntax."""
  121. return "\n".join([self.comment_begin] + self.environment_variables +
  122. self.crons + [self.comment_end]) + "\n"
  123. def _write_to_crontab(self, action, content):
  124. """The inside method used to modify the current crontab cronfile.
  125. This will write the content into current crontab cronfile.
  126. :param action: the action that is done, could be written, updated or
  127. cleared.
  128. :param content: the content that is written to the crontab cronfile.
  129. """
  130. # make sure at most 3 '\n' in a row
  131. content = re.sub(r'\n{4,}', r'\n\n\n', content)
  132. # strip
  133. content = content.strip()
  134. if content:
  135. content += "\n"
  136. tmp_cronfile = tempfile.NamedTemporaryFile()
  137. tmp_cronfile.write(get_binary_content(content))
  138. tmp_cronfile.flush()
  139. # command used to write crontab
  140. # $ crontab -u username cronfile
  141. command = ['crontab']
  142. if self.user:
  143. command.extend(["-u", str(self.user)])
  144. command.append(tmp_cronfile.name)
  145. try:
  146. output, error, returncode = communicate_process(command)
  147. if returncode != 0:
  148. raise PlanError("couldn't write crontab; try running check to "
  149. "ensure your cronfile is valid.")
  150. except OSError:
  151. raise PlanError("couldn't write crontab; please make sure you "
  152. "have crontab installed")
  153. else:
  154. if action:
  155. Echo.write("crontab file %s" % action)
  156. finally:
  157. tmp_cronfile.close()
  158. def write_crontab(self):
  159. """Write the crontab cronfile with this object's cron content, used
  160. by run_type `write`. This will replace the whole cronfile.
  161. """
  162. self._write_to_crontab("written", self.cron_content)
  163. def read_crontab(self):
  164. """Get the current working crontab cronfile content."""
  165. command = ['crontab', '-l']
  166. if self.user:
  167. command.extend(["-u", str(self.user)])
  168. try:
  169. r = communicate_process(command, universal_newlines=True)
  170. output, error, returncode = r
  171. if returncode != 0:
  172. raise PlanError("couldn't read crontab")
  173. except OSError:
  174. raise PlanError("couldn't read crontab; please make sure you "
  175. "have crontab installed")
  176. return output
  177. def update_crontab(self, update_type):
  178. """Update the current cronfile, used by run_type `update` or `clear`.
  179. This will find the block inside cronfile corresponding to this Plan
  180. object and replace it.
  181. :param update_type: update or clear, if you choose update, the block
  182. corresponding to this plan object will be replaced
  183. with the new cron job entries, otherwise, they
  184. will be wiped.
  185. """
  186. current_crontab = self.read_crontab()
  187. if update_type == "update":
  188. action = "updated"
  189. crontab_content = self.cron_content
  190. elif update_type == "clear":
  191. action = "cleared"
  192. crontab_content = ''
  193. # Check for unbegined or unended block
  194. comment_begin_re = re.compile(r"^%s$" % self.comment_begin, re.M)
  195. comment_end_re = re.compile(r"^%s$" % self.comment_end, re.M)
  196. cron_block_re = re.compile(r"^%s$.+^%s$" %
  197. (self.comment_begin, self.comment_end),
  198. re.M | re.S)
  199. comment_begin_match = comment_begin_re.search(current_crontab)
  200. comment_end_match = comment_end_re.search(current_crontab)
  201. if comment_begin_match and not comment_end_match:
  202. raise PlanError("Your crontab file is not ended, it contains "
  203. "'%s', but no '%s'" % (self.comment_begin,
  204. self.comment_end))
  205. elif not comment_begin_match and comment_end_match:
  206. raise PlanError("Your crontab file has no begining, it contains "
  207. "'%s', but no '%s'" % (self.comment_end,
  208. self.comment_begin))
  209. # Found our existing block and replace it with the new one
  210. # Otherwise, append out new cron jobs after others
  211. if comment_begin_match and comment_end_match:
  212. updated_content = cron_block_re.sub(crontab_content,
  213. current_crontab)
  214. else:
  215. updated_content = "\n\n".join((current_crontab, crontab_content))
  216. # Write the updated cronfile back to crontab
  217. self._write_to_crontab(action, updated_content)
  218. def run_bootstrap_commands(self):
  219. """Run bootstrap commands.
  220. """
  221. if self.bootstrap_commands:
  222. Echo.secho("Starting bootstrap...", fg="green")
  223. for command in self.bootstrap_commands:
  224. command = shlex.split(command)
  225. subprocess.call(command)
  226. Echo.secho("Bootstrap finished!\n\n", fg="green")
  227. def run(self, run_type="check"):
  228. """Use this to do any action on this Plan object.
  229. :param run_type: The running type, one of ("check", "write",
  230. "update", "clear"), default to be "check"
  231. """
  232. self.run_bootstrap_commands()
  233. if run_type == "update" or run_type == "clear":
  234. self.update_crontab(run_type)
  235. elif run_type == "write":
  236. self.write_crontab()
  237. else:
  238. Echo.echo(self.cron_content)
  239. Echo.message("Your crontab file was not updated.")
  240. def __call__(self, run_type="check"):
  241. """Shortcut for :meth:`run`."""
  242. self.run(run_type)