core.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. # -*- coding: utf-8 -*-
  2. """
  3. The MIT License
  4. Copyright (c) 2013 Helgi Þorbjörnsson
  5. Permission is hereby granted, free of charge, to any person obtaining a copy
  6. of this software and associated documentation files (the "Software"), to deal
  7. in the Software without restriction, including without limitation the rights
  8. to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  9. copies of the Software, and to permit persons to whom the Software is
  10. furnished to do so, subject to the following conditions:
  11. The above copyright notice and this permission notice shall be included in
  12. all copies or substantial portions of the Software.
  13. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  14. IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  15. FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  16. AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  17. LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  18. OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
  19. THE SOFTWARE.
  20. """
  21. import subprocess
  22. import time
  23. import os
  24. from os.path import isfile, split, join
  25. import tempfile
  26. import threading
  27. class Command(object):
  28. @classmethod
  29. def run(cls, command, timeout=None, cwd=None, env=None, debug=None):
  30. """
  31. Runs a given command on the system within a set time period, providing an easy way to access
  32. command output as it happens without waiting for the command to finish running.
  33. :type list
  34. :param command: Should be a list that contains the command that should be ran on the given
  35. system. The only whitespaces that can occur is for paths that use a backslash
  36. to escape it appropriately
  37. :type int
  38. :param timeout: Specificed in seconds. If a command outruns the timeout then the command and
  39. its child processes will be terminated. The default is to run
  40. :type string
  41. :param cwd: If cwd is set then the current directory will be changed to cwd before it is executed.
  42. Note that this directory is not considered when searching the executable, so you
  43. can’t specify the program’s path relative to cwd.
  44. :type dict
  45. :param env: A dict of any ENV variables that should be combined into the OS ENV that will help
  46. the command to run successfully. Note that more often than not the command run
  47. does not have the same ENV variables available as your shell by default and as such
  48. require some assistance.
  49. :type function
  50. :param debug: A function (also a class function) can be passed in here and all output, line by line,
  51. from the command being run will be passed to it as it gets outputted to stdout.
  52. This allows for things such as logging (using the built in python logging lib)
  53. what is happening on long running commands or redirect output of a tail -f call
  54. as lines get outputted without having to wait till the command finishes.
  55. :return returns :class:`Command.Response` that contains the exit code and the output from the command
  56. """
  57. # Merge together the system ENV details and the passed in ones if any
  58. environ = dict(os.environ)
  59. environ.update(env or {})
  60. # Check if the executable is executable and in fact exists
  61. which(command[0], environ)
  62. # Use tempfile to get past a limitation with subprocess.PIPE and 64kb.
  63. # Also to have a file to plug into the track generator
  64. outputtmp = tempfile.NamedTemporaryFile()
  65. try:
  66. # Verify debug is in fact a function we can call
  67. debug_output = False
  68. if debug and (callable(debug) or hasattr(debug, "__call__")):
  69. debug_output = True
  70. if debug_output:
  71. def track(thefile, shutdown=None):
  72. """Process the temp file until a valid line is found and return it"""
  73. thefile.seek(0, 2)
  74. while True:
  75. if shutdown and shutdown.isSet():
  76. break
  77. line = thefile.readline()
  78. if not line:
  79. time.sleep(0.00001)
  80. continue
  81. yield line
  82. def track_run(output, debug, shutdown_event):
  83. """Wrap track and pass information on the fly to the debug process"""
  84. for line in track(output, shutdown_event):
  85. debug(line.rstrip())
  86. # Run the track generator in a separate thread so we can run the command
  87. shutdown_event = threading.Event()
  88. thread = threading.Thread(
  89. target=track_run,
  90. args=(open(outputtmp.name, 'rb'), debug, shutdown_event),
  91. name='Monitoring'
  92. )
  93. thread.start()
  94. def target():
  95. # Run the actual command
  96. cls.process = subprocess.Popen(
  97. command,
  98. universal_newlines=True,
  99. shell=False,
  100. env=environ,
  101. cwd=cwd,
  102. preexec_fn=os.setsid,
  103. stdout=outputtmp,
  104. stderr=outputtmp
  105. )
  106. cls.process.communicate()
  107. # Deal with timeout
  108. thread = threading.Thread(target=target, name='Command Runner')
  109. thread.start()
  110. thread.join(timeout)
  111. if thread.is_alive():
  112. cls.process.terminate()
  113. thread.join()
  114. if thread.is_alive():
  115. cls.process.kill()
  116. # Prime the response
  117. response = Response
  118. response.command = command
  119. response.exit = cls.process.returncode
  120. # Fetch from the temp file
  121. outputtmp.seek(0, 0)
  122. response.output = outputtmp.read().strip()
  123. outputtmp.close()
  124. if response.exit < 0:
  125. raise CommandException("command ('%s') was terminated by signal: %s"
  126. % (' '.join(command), -response.exit),
  127. response.exit,
  128. response.output)
  129. if response.exit > 0:
  130. raise CommandException("command ('%s') exited with value: %s\n\n%s"
  131. % (' '.join(command), str(response.exit), response.output),
  132. response.exit,
  133. response.output)
  134. return response
  135. except OSError as error:
  136. # Use fake exit code since we can't get the accurate one from this
  137. raise CommandException("command failed: %s" % error, 1, error)
  138. except subprocess.CalledProcessError as error:
  139. raise CommandException(error.output, error.returncode, error.output)
  140. finally:
  141. if debug_output:
  142. shutdown_event.set()
  143. class CommandException(Exception):
  144. """
  145. Class for commanbd exceptions. Beside a specific error message it also stores the
  146. return code and the output of the command
  147. :type string
  148. :param Class specific message
  149. :type int
  150. :param exit_code: Exit code of the failed program (default: 1)
  151. :type string
  152. :param output: Any output associated with the failure from the program ran (default: None)
  153. """
  154. def __init__(self, message, exit_code=1, output=None):
  155. Exception.__init__(self, message)
  156. self.message = message
  157. self.exit = exit_code
  158. if output is None:
  159. output = message
  160. self.output = output
  161. def __str__(self):
  162. return repr(self.message)
  163. class Response(object):
  164. """Contain the response information for a given command"""
  165. exit = 0
  166. output = ''
  167. command = []
  168. def which(program, environ=None):
  169. """
  170. Find out if an executable exists in the supplied PATH.
  171. If so, the absolute path to the executable is returned.
  172. If not, an exception is raised.
  173. :type string
  174. :param program: Executable to be checked for
  175. :param dict
  176. :param environ: Any additional ENV variables required, specifically PATH
  177. :return string|:class:`command.CommandException` Returns the location if found, otherwise raises exception
  178. """
  179. def is_exe(path):
  180. """
  181. Helper method to check if a file exists and is executable
  182. """
  183. return isfile(path) and os.access(path, os.X_OK)
  184. if program is None:
  185. raise CommandException("Invalid program name passed")
  186. fpath, fname = split(program)
  187. if fpath:
  188. if is_exe(program):
  189. return program
  190. else:
  191. if environ is None:
  192. environ = os.environ
  193. for path in environ['PATH'].split(os.pathsep):
  194. exe_file = join(path, program)
  195. if is_exe(exe_file):
  196. return exe_file
  197. raise CommandException("Could not find %s" % program)
  198. def run(command, timeout=None, cwd=None, env=None, debug=None):
  199. """
  200. Runs a given command on the system within a set time period, providing an easy way to access
  201. command output as it happens without waiting for the command to finish running.
  202. :type list
  203. :param command: Should be a list that contains the command that should be ran on the given
  204. system. The only whitespaces that can occur is for paths that use a backslash
  205. to escape it appropriately
  206. :type int
  207. :param timeout: Specificed in seconds. If a command outruns the timeout then the command and
  208. its child processes will be terminated. The default is to run
  209. :type string
  210. :param cwd: If cwd is set then the current directory will be changed to cwd before it is executed.
  211. Note that this directory is not considered when searching the executable, so you
  212. can’t specify the program’s path relative to cwd.
  213. :type dict
  214. :param env: A dict of any ENV variables that should be combined into the OS ENV that will help
  215. the command to run successfully. Note that more often than not the command run
  216. does not have the same ENV variables available as your shell by default and as such
  217. require some assistance.
  218. :type function
  219. :param debug: A function (also a class function) can be passed in here and all output, line by line,
  220. from the command being run will be passed to it as it gets outputted to stdout.
  221. This allows for things such as logging (using the built in python logging lib)
  222. what is happening on long running commands or redirect output of a tail -f call
  223. as lines get outputted without having to wait till the command finishes.
  224. :return returns :class:`Command.Response` that contains the exit code and the output from the command
  225. """
  226. return Command.run(command, timeout=timeout, cwd=cwd, env=env, debug=debug)