diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..8d56bed --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,10 @@ +coverage: + status: + project: + default: + # basic + target: auto + threshold: 0% + +ignore: + - tests # - tests diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..fc11e8c --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,43 @@ +name: mechanical-markdown + +on: + push: + branches: + - main + tags: + - v* + pull_request: + branches: + - main + - release-* + +jobs: + build: + runs-on: ubuntu-latest + env: + PYTHON_VER: 3.7 + TWINE_USERNAME: ${{ secrets.PYPI_UPLOAD_USER }} + TWINE_PASSWORD: ${{ secrets.PYPI_UPLOAD_PASS }} + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ env.PYTHON_VER }} + uses: actions/setup-python@v2 + with: + python-version: ${{ env.PYTHON_VER }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine tox + - name: Run Linter + run: | + tox -e flake8 + - name: Run unit-tests + run: | + tox -e py37 + - name: Upload test coverage + uses: codecov/codecov-action@v1 + - name: Build and publish mechanical-markdown + if: github.event_name != 'pull_request' + run: | + python setup.py sdist bdist_wheel + twine upload dist/* diff --git a/README.md b/README.md index 2c6843d..40e878c 100644 --- a/README.md +++ b/README.md @@ -1 +1,76 @@ -# mechanical-markdown \ No newline at end of file +# Mechanical Markdown + +[![codecov](https://codecov.io/gh/wcs1only/mechanical-markdown/branch/main/graph/badge.svg)](https://codecov.io/gh/wcs1only/mechanical-markdown) + +If you are using markdown to create tutorials for your users, these markdown files will often be a series of shell commands that a user will copy and paste into their shell of choice, along with detailed text description of what each command is doing. + +If you are regularly releasing software and having to manually verify your tutorials by copy pasting commands into a terminal every time you create a release, this is the package for you. + +The mechanical-markdown package is a python library and corresponding shell script that allow you to run annotated markdown tutorials in an automated fashion. It will execute your markdown tutorials and verify the ouput according to expected stdout/stderr that you can embed directly into your markdown tutorials. + +# Installing + +This package requires a working python3 environment. You can install it using pip: + +```bash +pip install mechanical-markdown +``` + +This will install the python module, and create the ```mm.py``` CLI script. + +# Quick Start + +Check out the [examples](./examples) for some quick and easy examples. + +# Usage + +## CLI + +A command line utility called ```mm.py``` is included with this package. + +```bash +% mm.py --help +usage: mm.py [-h] [--dry-run] [--manual] [--shell SHELL_CMD] markdown_file + +Auto validate markdown documentation + +positional arguments: + markdown_file + +optional arguments: + -h, --help show this help message and exit + --dry-run, -d Print out the commands we would run based on markdown_file + --manual, -m If your markdown_file contains manual validation steps, pause for user input + --shell SHELL_CMD, -s SHELL_CMD + Specify a different shell to use +``` + +## API + +Creating a MechanicalMarkdown instance from a string which contains a markdown document: +```python +from mechanical_markdown import MechanicalMarkdow + +mm = MechanicalMarkdown(markdown_string) +``` + +MechanicalMarkdown methods + +```python +# Returns a string describing the commands that would be run +output = mm.dryrun(default_shell='bash -c') +print(ouput) + +# Run the commands in the order they were specified and return a boolean for succes or failure +# Also returns a report summarizing what was run and stdout/sterr for each command +success, report = exectute_steps(manual, default_shell='bash -c') +print(report) + + +``` + +# Contributing + +Issues and contributions are always welcome! Please make sure your submissions have appropriate unit tests (see tests/). + +This project was created to support [dapr/quickstarts](https://github.com/dapr/quickstarts). We're sharing it with the hope that it might be as usefull for somebody else as it was for us. \ No newline at end of file diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..1cc28fb --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,7 @@ +setuptools +mypy>=0.761 +mypy-extensions>=0.4.3 +flake8>=3.7.9 +tox == 3.15.0 +coverage >= 5.3 +codecov >= 1.4.0 diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..2433b45 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,68 @@ + +# Quick Start + +Once you have the package installed, if you want to try out some simple examples, navigate to the examples/ directory and have a look at start.md: + + # Quick Start Example + + This is an example markdown file with an annotated test command + + + + ```bash + echo "test" + ``` + + + + This unannotated command will not be run: + ```bash + echo "A command that will not be run" + ``` + +You can do a dry run to print out exactly what commands will be run using the '-d' flag. + +```bash +mm.py -d start.md +``` +You'll see output like the following: + +``` +Would run the following validation steps: +Step: First Step + commands to run with 'bash -c': + `echo "test"` + Expected stdout: + test + Expected stderr: + +``` + +Now you can run the steps and verify the output: + +```bash +mm.py start.md +``` + +The script will parse the markdown, execute the annotated commands, and then print a report like this: + +``` +Running shell 'bash -c' with command: `echo "test"` +Step: First Step + command: `echo "test"` + return_code: 0 + Expected stdout: + test + Actual stdout: + test + + Expected stderr: + Actual stderr: +``` + +If anything unexpected happens, you will get report +of what went wrong, and mm.py will return non-zero. diff --git a/examples/start.md b/examples/start.md new file mode 100644 index 0000000..80edcbe --- /dev/null +++ b/examples/start.md @@ -0,0 +1,20 @@ +# Quick Start Example + +This is an example markdown file with an annotated test command + + + +```bash +echo "test" +``` + + + +This unannotated command will not be run: +```bash +echo "A command that will not be run" +``` diff --git a/mechanical_markdown/__init__.py b/mechanical_markdown/__init__.py new file mode 100644 index 0000000..3554fa8 --- /dev/null +++ b/mechanical_markdown/__init__.py @@ -0,0 +1,10 @@ +""" +Copyright (c) Microsoft Corporation. +Licensed under the MIT License. +""" + +__version__ = '0.1.6' + +from mechanical_markdown.recipe import Recipe as MechanicalMarkdown + +__all__ = [MechanicalMarkdown] diff --git a/mechanical_markdown/__main__.py b/mechanical_markdown/__main__.py new file mode 100644 index 0000000..e538eb1 --- /dev/null +++ b/mechanical_markdown/__main__.py @@ -0,0 +1,40 @@ +from mechanical_markdown import MechanicalMarkdown + +import sys +import argparse + + +def main(): + parse_args = argparse.ArgumentParser(description='Auto validate markdown documentation') + parse_args.add_argument('markdown_file', metavar='markdown_file', nargs=1, type=argparse.FileType('r')) + parse_args.add_argument('--dry-run', '-d', + dest='dry_run', + action='store_true', + help='Print out the commands we would run based on markdown_file') + parse_args.add_argument('--manual', '-m', + dest='manual', + action='store_true', + help='If your markdown_file contains manual validation steps, pause for user input') + parse_args.add_argument('--shell', '-s', + dest='shell_cmd', + default='bash -c', + help='Specify a different shell to use') + args = parse_args.parse_args() + + body = args.markdown_file[0].read() + r = MechanicalMarkdown(body) + success = True + + if args.dry_run: + print("Would run the following validation steps:") + print(r.dryrun(args.shell_cmd)) + sys.exit(0) + + success, report = r.exectute_steps(args.manual, args.shell_cmd) + print(report) + if not success: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/mechanical_markdown/command.py b/mechanical_markdown/command.py new file mode 100644 index 0000000..4a8c76e --- /dev/null +++ b/mechanical_markdown/command.py @@ -0,0 +1,43 @@ + +""" +Copyright (c) Microsoft Corporation. +Licensed under the MIT License. +""" + +import os +import time + +from subprocess import Popen, PIPE, TimeoutExpired + + +class Command: + def __init__(self, command_string): + self.command = command_string + self.process = None + self.return_code = -1 + self.output = {'stdout': '', 'stderr': ''} + + def wait_or_timeout(self, timeout): + if self.process is None: + return + try: + self.output['stdout'], self.output['stderr'] = self.process.communicate(timeout=timeout) + except TimeoutExpired: + self.process.terminate() + time.sleep(10) + self.process.kill() + try: + self.output['stdout'], self.output['stderr'] = self.process.communicate(timeout=timeout) + except TimeoutExpired: + pass + + self.return_code = self.process.returncode + + def run(self, cwd, env, shell): + args_list = shell.split() + args_list.append(self.command) + pwd = os.getcwd() + os.chdir(cwd) + print("Running shell '{}' with command: `{}`".format(shell, self.command)) + self.process = Popen(args_list, stdout=PIPE, stderr=PIPE, universal_newlines=True, env=env) + os.chdir(pwd) diff --git a/mechanical_markdown/parsers.py b/mechanical_markdown/parsers.py new file mode 100644 index 0000000..ecf4be0 --- /dev/null +++ b/mechanical_markdown/parsers.py @@ -0,0 +1,57 @@ + +""" +Copyright (c) Microsoft Corporation. +Licensed under the MIT License. +""" + +import yaml + +from html.parser import HTMLParser +from mistune import Renderer +from mechanical_markdown.step import Step + +start_token = 'STEP' +end_token = 'END_STEP' + + +class HTMLCommentParser(HTMLParser): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.comment_text = "" + + def handle_comment(self, comment): + self.comment_text += comment + + +class RecipeParser(Renderer): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.current_step = None + self.all_steps = [] + + def block_code(self, text, lang): + if lang is not None and lang.strip() in ('bash', 'sh') and self.current_step is not None: + self.current_step.add_command_block(text) + return "" + + def block_html(self, text): + comment_parser = HTMLCommentParser() + comment_parser.feed(text) + + comment_body = comment_parser.comment_text + if comment_body.find(end_token) >= 0: + if self.current_step is None: + return "" + self.all_steps.append(self.current_step) + self.current_step = None + return "" + + start_pos = comment_body.find(start_token) + + if start_pos < 0: + return "" + + start_pos += len(start_token) + self.current_step = Step(yaml.safe_load(comment_body[start_pos:])) + + return "" diff --git a/mechanical_markdown/recipe.py b/mechanical_markdown/recipe.py new file mode 100644 index 0000000..1df9229 --- /dev/null +++ b/mechanical_markdown/recipe.py @@ -0,0 +1,41 @@ + +""" +Copyright (c) Microsoft Corporation. +Licensed under the MIT License. +""" + +from mistune import Markdown +from mechanical_markdown.parsers import RecipeParser + + +class Recipe: + def __init__(self, markdown): + parser = RecipeParser() + md = Markdown(parser, extensions=('fenced-code',)) + md(markdown) + self.all_steps = parser.all_steps + + def exectute_steps(self, manual, default_shell='bash -c'): + success = True + report = "" + for step in self.all_steps: + if not step.run_all_commands(manual, default_shell): + success = False + break + + for step in self.all_steps: + if not step.wait_for_all_background_commands(): + success = False + + s, r = step.validate_and_report() + if not s: + success = False + report += r + return success, report + + def dryrun(self, default_shell='bash -c'): + retstr = "" + for step in self.all_steps: + retstr += step.dryrun(default_shell) + + return retstr diff --git a/mechanical_markdown/step.py b/mechanical_markdown/step.py new file mode 100644 index 0000000..a645b86 --- /dev/null +++ b/mechanical_markdown/step.py @@ -0,0 +1,117 @@ + +""" +Copyright (c) Microsoft Corporation. +Licensed under the MIT License. +""" + +import os +import time + +from mechanical_markdown.command import Command +from termcolor import colored + +default_timeout_seconds = 60 + + +class Step: + def __init__(self, parameters): + self.observed_output = { + "stdout": "", + "stderr": "" + } + self.return_values = "" + self.commands = [] + self.background = False if "background" not in parameters else parameters["background"] + self.sleep = 0 if "sleep" not in parameters else parameters["sleep"] + self.name = "" if "name" not in parameters else parameters["name"] + self.expected_lines = {"stdout": [], "stderr": []} + if "expected_stdout_lines" in parameters and parameters["expected_stdout_lines"] is not None: + self.expected_lines['stdout'] = parameters["expected_stdout_lines"] + if "expected_stderr_lines" in parameters and parameters["expected_stderr_lines"] is not None: + self.expected_lines['stderr'] = parameters["expected_stderr_lines"] + self.working_dir = os.getcwd() if "working_dir" not in parameters else parameters["working_dir"] + self.timeout = default_timeout_seconds if "timeout_seconds" not in parameters else parameters["timeout_seconds"] + self.env = dict(os.environ, **parameters['env']) if "env" in parameters else os.environ + self.pause_message = None if "manual_pause_message" not in parameters else parameters["manual_pause_message"] + + def add_command_block(self, block): + self.commands.append(Command(block.strip())) + + def run_all_commands(self, manual, shell): + if manual and self.pause_message is not None: + try: + while True: + if input(self.pause_message + "\nType 'x' to exit\n") == 'x': + break + except KeyboardInterrupt: + pass + + for command in self.commands: + command.run(self.working_dir, self.env, shell) + if not self.background: + command.wait_or_timeout(self.timeout) + if command.return_code != 0: + return False + if self.sleep: + time.sleep(self.sleep) + + return True + + def dryrun(self, shell): + retstr = "Step: {}\n".format(self.name) + + retstr += "\tcommands to run with '{}':\n".format(shell) + for c in self.commands: + retstr += "\t\t`{}`\n".format(c.command) + + for out in 'stdout', 'stderr': + retstr += "\tExpected {}:\n".format(out) + for expected in self.expected_lines[out]: + retstr += "\t\t{}\n".format(expected) + + return retstr + "\n" + + def wait_for_all_background_commands(self): + success = True + for command in self.commands: + if self.background: + command.wait_or_timeout(self.timeout) + if command.return_code != 0: + success = False + return success + + def validate_and_report(self): + success = True + report = "" + if self.name != "": + report += "Step: {}\n".format(self.name) + + for c in self.commands: + if c.process is not None: + color = 'green' + if c.return_code != 0: + color = 'red' + report += "\tcommand: `{}`\n\treturn_code: {}\n".format(c.command, colored(c.return_code, color)) + + for out in 'stdout', 'stderr': + report += "\tExpected {}:\n".format(out) + for expected in self.expected_lines[out]: + report += '\t\t' + expected + '\n' + report += "\tActual {}:\n".format(out) + + for c in self.commands: + if c.process is not None: + for line in c.output[out].split("\n"): + if len(self.expected_lines[out]) and self.expected_lines[out][0] == line: + report += "\t\t{}\n".format(colored(line, 'green')) + self.expected_lines[out] = self.expected_lines[out][1:] + else: + report += "\t\t{}\n".format(line) + + if len(self.expected_lines[out]): + success = False + report += colored("\tERROR expected lines not found:", 'red') + "\n" + for line in self.expected_lines[out]: + report += "\t\t" + colored(line, 'red') + "\n" + + return success, report diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..6deafc2 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 120 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..1e22a99 --- /dev/null +++ b/setup.py @@ -0,0 +1,36 @@ +from setuptools import setup, find_packages + +# The text of the README file +README = "" + +try: + with open("README.md", "r") as fh: + README = fh.read() +except FileNotFoundError: + pass + +# This call to setup() does all the work +setup( + name="mechanical-markdown", + version="0.1.6", + description="Run markdown recipes as shell scripts", + long_description=README, + long_description_content_type="text/markdown", + url="https://github.com/wcs1only/mechanical-markdown", + author="Charlie Stanley", + author_email="Charlie.Stanley@microsoft.com", + license="MIT", + classifiers=[ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7" + ], + packages=find_packages(exclude='tests'), + include_package_data=True, + install_requires=["termcolor", "pyyaml", "mistune"], + entry_points={ + "console_scripts": [ + "mm.py = mechanical_markdown.__main__:main" + ] + }, +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_mechanical_markdown.py b/tests/test_mechanical_markdown.py new file mode 100644 index 0000000..a491a92 --- /dev/null +++ b/tests/test_mechanical_markdown.py @@ -0,0 +1,439 @@ + +""" +Copyright (c) Microsoft Corporation. +Licensed under the MIT License. +""" + +import os +import subprocess +import unittest + +from mechanical_markdown import MechanicalMarkdown +from unittest.mock import patch, MagicMock, call + + +class MechanicalMarkdownTests(unittest.TestCase): + def setUp(self): + self.command_ouputs = [] + + self.process_mock = MagicMock() + + def pop_command(timeout=None): + stdout, stderr, return_code = self.command_ouputs.pop(0) + self.process_mock.returncode = return_code + return (stdout, stderr) + + self.process_mock.communicate.side_effect = pop_command + + self.popen_mock = MagicMock() + self.popen_mock.return_value = self.process_mock + + self.patcher = patch('mechanical_markdown.command.Popen', self.popen_mock) + self.patcher.start() + + def prep_command_ouput(self, stdout, stderr, return_code): + self.command_ouputs.append((stdout, stderr, return_code)) + + def tearDown(self): + self.patcher.stop() + + def test_basic_success(self): + test_data = """ + + +```bash +echo "test" +``` + + +""" + self.prep_command_ouput("test", "", 0) + mm = MechanicalMarkdown(test_data) + success, report = mm.exectute_steps(False) + self.assertTrue(success) + self.popen_mock.assert_called_with(['bash', '-c', 'echo "test"'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + env=os.environ) + + @patch("mechanical_markdown.command.os.chdir") + def test_working_dir_success(self, chdir_mock): + test_data = """ + + +```bash +echo "test" +``` + + +""" + self.prep_command_ouput("test", "", 0) + mm = MechanicalMarkdown(test_data) + success, report = mm.exectute_steps(False) + self.assertTrue(success) + self.popen_mock.assert_called_with(['bash', '-c', 'echo "test"'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + env=os.environ) + chdir_mock.assert_has_calls([call("./foo"), call(os.getcwd())]) + + @patch("mechanical_markdown.step.time.sleep") + def test_sleep_is_honored(self, sleep_mock): + test_data = """ + + +```bash +echo "test" +``` + + +""" + self.prep_command_ouput("test", "", 0) + mm = MechanicalMarkdown(test_data) + success, report = mm.exectute_steps(False) + self.assertTrue(success) + self.popen_mock.assert_called_with(['bash', '-c', 'echo "test"'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + env=os.environ) + sleep_mock.assert_called_with(10) + + def test_env(self): + test_data = """ + + +```bash +echo "test" +``` + + +""" + + expected_env = {"ENVA": "foo", "ENVB": "bar"} + self.prep_command_ouput("test", "", 0) + mm = MechanicalMarkdown(test_data) + success, report = mm.exectute_steps(False) + self.assertTrue(success) + self.popen_mock.assert_called_with(['bash', '-c', 'echo "test"'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + env=dict(os.environ, **expected_env)) + + def test_background_success(self): + test_data = """ + + +```bash +echo "test" +``` + + +""" + self.prep_command_ouput("test", "", 0) + mm = MechanicalMarkdown(test_data) + success = mm.all_steps[0].run_all_commands(False, "bash -c") + self.assertTrue(success) + self.popen_mock.assert_called_with(['bash', '-c', 'echo "test"'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + env=os.environ) + self.process_mock.communicate.assert_not_called() + + success = mm.all_steps[0].wait_for_all_background_commands() + self.assertTrue(success) + self.process_mock.communicate.assert_called_with(timeout=60) + + def test_background_failure(self): + test_data = """ + + +```bash +echo "test" +``` + + +""" + self.prep_command_ouput("test", "", 1) + mm = MechanicalMarkdown(test_data) + success = mm.all_steps[0].run_all_commands(False, "bash -c") + self.assertTrue(success) + self.popen_mock.assert_called_with(['bash', '-c', 'echo "test"'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + env=os.environ) + self.process_mock.communicate.assert_not_called() + + success = mm.all_steps[0].wait_for_all_background_commands() + self.assertFalse(success) + self.process_mock.communicate.assert_called_with(timeout=60) + + def test_failure_halts_further_executions(self): + test_data = """ + + +```bash +echo "test" +``` + +```bash +echo "test2" +``` + + +""" + self.prep_command_ouput("test", "", 1) + self.prep_command_ouput("test2", "", 0) + mm = MechanicalMarkdown(test_data) + success, report = mm.exectute_steps(False) + self.assertFalse(success) + self.popen_mock.assert_called_once_with(['bash', '-c', 'echo "test"'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + env=os.environ) + + def test_missing_expected_line_causes_failure(self): + test_data = """ + + +```bash +echo "test" +``` + + +""" + self.prep_command_ouput("test", "", 0) + mm = MechanicalMarkdown(test_data) + success, report = mm.exectute_steps(False) + self.assertFalse(success) + self.popen_mock.assert_called_once_with(['bash', '-c', 'echo "test"'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + env=os.environ) + + def test_expected_lines_succeed_when_matched(self): + test_data = """ + + +```bash +echo "test" +echo "test2" +echo "error" 1>&2 +``` + + +""" + self.prep_command_ouput("test\ntest2", "error", 0) + mm = MechanicalMarkdown(test_data) + success, report = mm.exectute_steps(False) + self.assertTrue(success) + calls = [call(['bash', '-c', 'echo "test"\necho "test2"\necho "error" 1>&2'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + env=os.environ), + call().communicate(timeout=60)] + self.popen_mock.assert_has_calls(calls) + + def test_timeout_is_respected(self): + test_data = """ + + +```bash +echo "test" +``` + + +""" + self.prep_command_ouput("test", "", 0) + mm = MechanicalMarkdown(test_data) + success, report = mm.exectute_steps(False) + self.assertTrue(success) + calls = [call(['bash', '-c', 'echo "test"'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + env=os.environ), + call().communicate(timeout=5)] + self.popen_mock.assert_has_calls(calls) + + def test_dryrun(self): + test_data = """ + + +```bash +echo "test" +echo "test2" +``` + + + + + +```bash +echo "foo" +echo "bar" >2 +``` + + +""" + self.prep_command_ouput("test", "", 0) + mm = MechanicalMarkdown(test_data) + output = mm.dryrun() + + expected_output = """Step: basic test +\tcommands to run with 'bash -c': +\t\t`echo "test" +echo "test2"` +\tExpected stdout: +\t\ttest +\t\ttest2 +\tExpected stderr: + +Step: step 2 +\tcommands to run with 'bash -c': +\t\t`echo "foo" +echo "bar" >2` +\tExpected stdout: +\t\tfoo +\tExpected stderr: +\t\tbar + +""" + self.assertEqual(expected_output, output) + self.popen_mock.assert_not_called() + + @patch("mechanical_markdown.command.time.sleep") + def test_timed_out_processes_are_killed(self, sleep_mock): + test_data = """ + + +```bash +echo "test" +``` + + +""" + + def raise_timeout(timeout=60): + raise subprocess.TimeoutExpired("foo", 60.0) + + self.process_mock.communicate.side_effect = raise_timeout + self.prep_command_ouput("test", "", 0) + mm = MechanicalMarkdown(test_data) + success, report = mm.exectute_steps(False) + self.assertFalse(success) + self.popen_mock.assert_called_with(['bash', '-c', 'echo "test"'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + env=os.environ) + self.process_mock.terminate.assert_called() + self.process_mock.kill.assert_called() + self.process_mock.communicate.assert_has_calls([call(timeout=60), call(timeout=60)]) + + @patch("builtins.input") + def test_pause_waits_for_user_input(self, input_mock): + test_data = """ + + +```bash +echo "test" +``` + + +""" + + self.prep_command_ouput("test", "", 0) + input_mock.return_value = 'x' + mm = MechanicalMarkdown(test_data) + success, report = mm.exectute_steps(True) + input_mock.assert_called_with("Stop Here\nType 'x' to exit\n") + self.assertTrue(success) + self.popen_mock.assert_called_with(['bash', '-c', 'echo "test"'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + env=os.environ) + + def test_different_shell(self): + test_data = """ + + +```bash +echo "test" +``` + + +""" + self.prep_command_ouput("test", "", 0) + mm = MechanicalMarkdown(test_data) + success, report = mm.exectute_steps(False, default_shell='cmd /c') + self.assertTrue(success) + self.popen_mock.assert_called_with(['cmd', '/c', 'echo "test"'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + env=os.environ) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..515ce18 --- /dev/null +++ b/tox.ini @@ -0,0 +1,27 @@ +[tox] +skipsdist = True +minversion = 3.8.0 +envlist = + py37, + flake8, + mypy, + +[testenv] +setenv = + PYTHONDONTWRITEBYTECODE=1 +deps = -rdev-requirements.txt +commands = + coverage run -m unittest discover -v ./tests + coverage xml + python3 setup.py install + mm.py examples/start.md +commands_pre = + pip3 install -e {toxinidir}/ + +[testenv:flake8] +basepython = python3.7 +usedevelop = False +deps = flake8 +commands = + flake8 --config setup.cfg . +