diff --git a/.travis.yml b/.travis.yml index a62b0ecc..7f997721 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,18 +11,15 @@ addons: - service2.example.com - service3.example.com install: - - travis_fold start "Install.Pip.Package" && make install-cloud && make install-dev && pip3 show mockintosh && travis_fold end "Install.Pip.Package" - - travis_fold start "Build.Image" && make build && docker image ls mockintosh && travis_fold end "Build.Image" + - travis_fold start "Install.Pip.Package" && pip install -e .[cloud] && pip install -e .[dev] && pip3 show mockintosh && travis_fold end "Install.Pip.Package" + - travis_fold start "Build.Image" && docker build . -t mockintosh && docker image ls mockintosh && travis_fold end "Build.Image" script: - stty cols 120 - ./ps.sh & - - travis_fold start "Unit.Tests" && make test-with-coverage && travis_fold end "Unit.Tests" - - travis_fold start "StopContainers.Tests" && make stop-containers && travis_fold end "StopContainers.Tests" - - travis_fold start "Integration.Tests" && make test-integration && travis_fold end "Integration.Tests" + - travis_fold start "Unit.Tests" && coverage run -m pytest tests_unit && travis_fold end "Unit.Tests" && travis_fold start "Integration.Tests" && tests_integrated/acceptance.sh && travis_fold end "Integration.Tests" after_success: + - coverage report && codecov - if [[ $TRAVIS_TAG =~ ^([0-9]+\.?)+$ ]]; then git push --force https://${GH_TOKEN}@github.com/up9inc/mockintosh.git HEAD:gh-pages; else echo Not pushing "$TRAVIS_TAG"; fi - - make coverage-after - - codecov # after_failure: # - travis_fold start "server_log" && ( cat tests_integrated/server.log || echo No logfile) && travis_fold end "server_log" # the log from container deploy: diff --git a/docs/Changelog.md b/docs/Changelog.md index 417bc51f..e47c736a 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -1,6 +1,6 @@ # Changelog -## v0.13 - next +## v0.13 - 2021-09-17 - create Homebrew repo for Mac users - add `--sample-config` to CLI, to obtain small sample config easily diff --git a/mockintosh/__init__.py b/mockintosh/__init__.py index bcec92a8..0153e763 100644 --- a/mockintosh/__init__.py +++ b/mockintosh/__init__.py @@ -1,55 +1,19 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- - -""" -.. module:: __init__ - :synopsis: the top-level module of Mockintosh. -""" - -import argparse -import atexit import json import logging -import os -import shutil -import signal -import sys -from gettext import gettext -from os import path, environ -from typing import ( - Union, - Tuple, - List -) - -from prance import ValidationError -from prance.util.url import ResolutionError +from os import path +from typing import Union -from mockintosh.constants import PROGRAM from mockintosh.definition import Definition -from mockintosh.helpers import _nostderr, _import_from +from mockintosh.helpers import _nostderr from mockintosh.replicas import Request, Response # noqa: F401 -from mockintosh.servers import HttpServer, TornadoImpl -from mockintosh.templating import RenderingQueue, RenderingJob -from mockintosh.transpilers import OASToConfigTranspiler +from mockintosh.servers import TornadoImpl __location__ = path.abspath(path.dirname(__file__)) -with open(os.path.join(__location__, "res", "version.txt")) as fp: +with open(path.join(__location__, "res", "version.txt")) as fp: __version__ = fp.read().strip() -should_cov = environ.get('COVERAGE_PROCESS_START', False) -cov_no_run = environ.get('COVERAGE_NO_RUN', False) - - -class CustomArgumentParser(argparse.ArgumentParser): - def error(self, message): - self.print_help(sys.stderr) - args = {'prog': self.prog, 'message': message} - self.exit(2, gettext('\n%(prog)s: error: %(message)s\n') % args) - def get_schema(): - schema = None schema_path = path.join(__location__, 'schema.json') with open(schema_path, 'r') as file: schema_text = file.read() @@ -58,27 +22,6 @@ def get_schema(): return schema -def import_interceptors(interceptors): - imported_interceptors = [] - if interceptors is not None: - if 'unittest' in sys.modules.keys(): - tests_dir = path.join(__location__, '../tests') - sys.path.append(tests_dir) - for interceptor in interceptors: - module, name = interceptor[0].rsplit('.', 1) - imported_interceptors.append(_import_from(module, name)) - return imported_interceptors - - -def start_render_queue() -> Tuple[RenderingQueue, RenderingJob]: - queue = RenderingQueue() - t = RenderingJob(queue) - t.daemon = True - t.start() - - return queue, t - - def run( source: str, is_file: bool = True, @@ -113,166 +56,22 @@ def run( http_server.run() -def _gracefully_exit(num, frame): - atexit._run_exitfuncs() - if should_cov: # pragma: no cover - sys.exit() - - -def _cov_exit(cov): - if should_cov: - logging.debug('Stopping coverage') - cov.stop() - cov.save() # pragma: no cover - - -def _handle_cli_args_logging(args: list, fmt: str) -> None: - if args['quiet']: - logging.basicConfig(level=logging.WARNING, format=fmt) - logging.getLogger('pika').setLevel(logging.CRITICAL) - logging.getLogger('rsmq').setLevel(logging.CRITICAL) - elif args['verbose']: - logging.basicConfig(level=logging.DEBUG, format=fmt) - else: - logging.basicConfig(level=logging.INFO, format=fmt) - logging.getLogger('pika').setLevel(logging.CRITICAL) - logging.getLogger('rsmq').setLevel(logging.CRITICAL) - logging.getLogger('botocore').setLevel(logging.CRITICAL) - logging.getLogger('boto3').setLevel(logging.CRITICAL) - logging.getLogger('urllib3.connectionpool').setLevel(logging.CRITICAL) - - -def _handle_cli_args_logfile(args: list, fmt: str) -> None: - if args['logfile']: - handler = logging.FileHandler(args['logfile']) - handler.setFormatter(logging.Formatter(fmt)) - logging.getLogger('').addHandler(handler) +class Mockintosh: + def __init__(self, bind_address='', interceptors=(), debug=False) -> None: + super().__init__() + self.management = Management() + def run(self): + pass -def _handle_cli_args_tags(args: list) -> list: - tags = [] - if args['enable_tags']: - tags = args['enable_tags'].split(',') - return tags +class Service: + pass -def _handle_cli_args(args: list) -> Tuple[tuple, str, list]: - interceptors = import_interceptors(args['interceptor']) - address = args['bind'] if args['bind'] is not None else '' - tags = _handle_cli_args_tags(args) - fmt = "[%(asctime)s %(name)s %(levelname)s] %(message)s" - _handle_cli_args_logging(args, fmt) - _handle_cli_args_logfile(args, fmt) - - return interceptors, address, tags - - -def _handle_oas_input(source: str, convert_args: List[str], direct: bool = False) -> Union[str, dict]: - oas_transpiler = OASToConfigTranspiler(source, convert_args) - return oas_transpiler.transpile(direct=direct) - - -def initiate(): - if should_cov: # pragma: no cover - signal.signal(signal.SIGTERM, _gracefully_exit) - logging.debug('Starting coverage') - from coverage import Coverage - cov = Coverage(data_suffix=True, config_file='.coveragerc') - cov._warn_no_data = True - cov._warn_unimported_source = True - cov.start() - atexit.register(_cov_exit, cov) - - """The top-level method to serve as the entry point of Mockintosh. - - This method is the entry point defined in `setup.py` for the `mockintosh` executable that - placed a directory in `$PATH`. - - This method parses the command-line arguments and handles the top-level initiations accordingly. - """ - - ap = CustomArgumentParser(formatter_class=argparse.RawTextHelpFormatter) - ap.add_argument( - 'source', - help='Path to configuration file and (optional) a list of the service names\n' - 'to specify the services to be listened.', - nargs='+' - ) - ap.add_argument('-q', '--quiet', help='Less logging messages, only warnings and errors', action='store_true') - ap.add_argument('-v', '--verbose', help='More logging messages, including debug', action='store_true') - ap.add_argument( - '-i', - '--interceptor', - help='A list of interceptors to be called in .. format', - action='append', - nargs='+' - ) - ap.add_argument('-l', '--logfile', help='Also write log into a file', action='store') - ap.add_argument('-b', '--bind', help='Address to specify the network interface', action='store') - ap.add_argument( - '-c', - '--convert', - help='Convert an OpenAPI Specification (Swagger) 2.0 / 3.0 / 3.1 file to %s config. ' - 'Example: `$ mockintosh petstore.json -c dev.json json`' % PROGRAM.capitalize(), - action='store', - nargs='+', - metavar=('filename', 'format') - ) - ap.add_argument('--enable-tags', help='A comma separated list of tags to enable', action='store') - ap.add_argument('--sample-config', help='Writes sample config file to disk', action='store_true') - args = vars(ap.parse_args()) - - interceptors, address, tags = _handle_cli_args(args) - - if args['sample_config']: - fname = os.path.abspath(args['source'][0]) - shutil.copy(os.path.join(__location__, "res", "sample.yml"), fname) - logging.info("Created sample configuration file in %r", fname) - logging.info("To run it, use the following command:\n mockintosh %s", os.path.basename(fname)) - sys.exit(0) - - debug_mode = environ.get('DEBUG', False) or environ.get('MOCKINTOSH_DEBUG', False) - if debug_mode: - logging.debug('Tornado Web Server\'s debug mode is enabled!') - - source = args['source'][0] - services_list = args['source'][1:] - convert_args = args['convert'] - - load_override = None - - if convert_args: - if len(convert_args) < 2: - convert_args.append('yaml') - elif convert_args[1] != 'json': - convert_args[1] = 'yaml' - - logging.info( - "Converting OpenAPI Specification %s to ./%s in %s format...", - source, - convert_args[0], - convert_args[1].upper() - ) - target_path = _handle_oas_input(source, convert_args) - logging.info("The transpiled config %s is ready at %s", convert_args[1].upper(), target_path) - else: - try: - load_override = _handle_oas_input(source, ['config.yaml', 'yaml'], True) - logging.info("Automatically transpiled the config YAML from OpenAPI Specification.") - except (ValidationError, AttributeError): - logging.debug("The input is not a valid OpenAPI Specification, defaulting to Mockintosh config.") - except ResolutionError: # pragma: no cover - pass - logging.info("%s v%s is starting...", PROGRAM.capitalize(), __version__) +class Management(Service): + def set_config(self, config, services_list): + pass - if not cov_no_run: # pragma: no cover - run( - source, - debug=debug_mode, - interceptors=interceptors, - address=address, - services_list=services_list, - tags=tags, - load_override=load_override - ) + def set_enabled_tags(self, tags: list): + pass diff --git a/mockintosh/__main__.py b/mockintosh/__main__.py index 500431d8..343534f4 100644 --- a/mockintosh/__main__.py +++ b/mockintosh/__main__.py @@ -1,3 +1,183 @@ -from mockintosh import initiate +# !/usr/bin/python3 +# -*- coding: utf-8 -*- -initiate() +""" +.. module:: __init__ + :synopsis: the top-level module of Mockintosh. +""" + +import argparse +import logging +import os +import shutil +import sys +import tempfile +from collections import namedtuple +from gettext import gettext +from os import path, environ +from typing import Union, Tuple + +import yaml +from prance import ValidationError +from prance.util.url import ResolutionError + +from mockintosh import Mockintosh +from mockintosh.constants import PROGRAM +from mockintosh.exceptions import UnrecognizedConfigFileFormat +from mockintosh.helpers import _import_from +from mockintosh.replicas import Request, Response # noqa: F401 +from mockintosh.transpilers import OASToConfigTranspiler + +__location__ = path.abspath(path.dirname(__file__)) +with open(os.path.join(__location__, "res", "version.txt")) as fp: + __version__ = fp.read().strip() + + +class CustomArgumentParser(argparse.ArgumentParser): + def error(self, message): + self.print_help(sys.stderr) + args = {'prog': self.prog, 'message': message} + self.exit(2, gettext('\n%(prog)s: error: %(message)s\n') % args) + + +def _import_interceptors(interceptors): + imported_interceptors = [] + for interceptor in interceptors if interceptors else []: + module, name = interceptor[0].rsplit('.', 1) + imported_interceptors.append(_import_from(module, name)) + return imported_interceptors + + +def _configure_logging(quiet, verbose, logfile) -> None: + fmt = "[%(asctime)s %(name)s %(levelname)s] %(message)s" + if quiet: + logging.basicConfig(level=logging.WARNING, format=fmt) + logging.getLogger('pika').setLevel(logging.CRITICAL) + logging.getLogger('rsmq').setLevel(logging.CRITICAL) + elif verbose: + logging.basicConfig(level=logging.DEBUG, format=fmt) + else: + logging.basicConfig(level=logging.INFO, format=fmt) + logging.getLogger('pika').setLevel(logging.CRITICAL) + logging.getLogger('rsmq').setLevel(logging.CRITICAL) + logging.getLogger('botocore').setLevel(logging.CRITICAL) + logging.getLogger('boto3').setLevel(logging.CRITICAL) + logging.getLogger('urllib3.connectionpool').setLevel(logging.CRITICAL) + + if logfile: + handler = logging.FileHandler(logfile) + handler.setFormatter(logging.Formatter(fmt)) + logging.getLogger('').addHandler(handler) + + +def _handle_cli_args(args: namedtuple) -> Tuple[tuple, str, list]: + interceptors = _import_interceptors(args.interceptor) + address = args.bind if args.bind else '' + tags = args.enable_tags.split(',') if args.enable_tags else [] + _configure_logging(args.quiet, args.verbose, args.logfile) + + return tuple(interceptors), address, tags + + +def _handle_oas_input(source: str, convert_args: str, direct: bool = False) -> Union[str, dict]: + oas_transpiler = OASToConfigTranspiler(source, convert_args) + return oas_transpiler.transpile(direct=direct) + + +def _load_config(source): + with open(source, 'r') as file: + logging.info('Reading configuration file from path: %s', source) + + try: + return yaml.safe_load(file) + except (yaml.scanner.ScannerError, yaml.parser.ParserError) as e: + raise UnrecognizedConfigFileFormat('Configuration file is neither a JSON file nor a YAML file!', + source, str(e)) + + +def _configure_args(): + ap = CustomArgumentParser(formatter_class=argparse.RawTextHelpFormatter) + ap.add_argument( + 'source', + help='Path to configuration file and (optional) a list of the service names\n' + 'to specify the services to be listened.', + nargs='+' + ) + ap.add_argument('-q', '--quiet', help='Less logging messages, only warnings and errors', action='store_true') + ap.add_argument('-v', '--verbose', help='More logging messages, including debug', action='store_true') + ap.add_argument( + '-i', + '--interceptor', + help='A list of interceptors to be called in .. format', + action='append', + nargs='+' + ) + ap.add_argument('-l', '--logfile', help='Also write log into a file', action='store') + ap.add_argument('-b', '--bind', help='Address to specify the network interface', action='store') + ap.add_argument( + '-c', + '--convert', + help='Convert an OpenAPI Specification (Swagger) 2.0 / 3.0 / 3.1 file to %s config. ' + 'Example: `$ mockintosh petstore.json -c dev.json json`' % PROGRAM.capitalize(), + action='store', + nargs='+', + metavar=('filename', 'format') + ) + ap.add_argument('--enable-tags', help='A comma separated list of tags to enable', action='store') + ap.add_argument('--sample-config', help='Writes sample config file to disk', action='store_true') + return ap + + +def _initiate(args): + """The top-level method to serve as the entry point of Mockintosh. + + This method is the entry point defined in `setup.py` for the `mockintosh` executable that + placed a directory in `$PATH`. + + This method parses the command-line arguments and handles the top-level initiations accordingly. + """ + + interceptors, address, tags = _handle_cli_args(args) + + debug_mode = environ.get('DEBUG', False) or environ.get('MOCKINTOSH_DEBUG', False) + if debug_mode: + logging.debug('Tornado Web Server\'s debug mode is enabled!') + + source = args.source[0] + convert_args = args.convert + + if args.sample_config: + fname = os.path.abspath(source) + shutil.copy(os.path.join(__location__, "res", "sample.yml"), fname) + logging.info("Created sample configuration file in %r", fname) + logging.info("To run it, use the following command:\n mockintosh %s", os.path.basename(fname)) + elif convert_args: + logging.info("Converting OpenAPI Specification %r to %r...", source, convert_args[0]) + target_path = _handle_oas_input(source, convert_args[0]) + logging.info("The transpiled config is written to %s", target_path) + else: + try: + loaded_config = _handle_oas_input(source, tempfile.mktemp(prefix="converted_", suffix=".yaml"), True) + logging.info("Automatically transpiled the config YAML from OpenAPI Specification") + except (ValidationError, AttributeError, ResolutionError): + logging.debug("The input is not a valid OpenAPI Specification, defaulting to Mockintosh config.") + loaded_config = _load_config(source) + + logging.info("Mockintosh v%s is starting...", __version__) + + services_list = args.source[1:] + + controller = Mockintosh(bind_address=address, interceptors=interceptors, debug=debug_mode) + controller.management.set_config(loaded_config, services_list) + controller.management.set_enabled_tags(tags) + + controller.run() + + +def main(args=None): + args_parser = _configure_args() + _initiate(args_parser.parse_args(args)) + + +if __name__ == '__main__': + main() diff --git a/mockintosh/constants.py b/mockintosh/constants.py index 9b450a36..f8baf2fa 100644 --- a/mockintosh/constants.py +++ b/mockintosh/constants.py @@ -8,8 +8,7 @@ from os import environ - -PROGRAM = 'mockintosh' +PROGRAM = 'mockintosh' # FIXME: remove it PYBARS = 'Handlebars' JINJA = 'Jinja2' diff --git a/mockintosh/res/version.txt b/mockintosh/res/version.txt index c2bdb563..9f8e9b69 100644 --- a/mockintosh/res/version.txt +++ b/mockintosh/res/version.txt @@ -1 +1 @@ -0.13 \ No newline at end of file +1.0 \ No newline at end of file diff --git a/mockintosh/transpilers.py b/mockintosh/transpilers.py index 04744ce5..9f587815 100644 --- a/mockintosh/transpilers.py +++ b/mockintosh/transpilers.py @@ -6,19 +6,18 @@ :synopsis: module that contains config transpiler classes. """ +import json +import logging import re import sys -import json import tempfile -import logging -from os import getcwd, path -from urllib.parse import urlparse from collections import OrderedDict +from os import getcwd, path from typing import ( - List, Union, Tuple ) +from urllib.parse import urlparse import yaml from prance import ResolvingParser @@ -28,10 +27,11 @@ class OASToConfigTranspiler: - def __init__(self, source: str, convert_args: List[str]): + def __init__(self, source: str, convert_args: str): self.source = source self.data = None - self.target_filename, self.format = convert_args + self.target_filename = convert_args + self.format = 'json' if convert_args.lower().endswith(".json") else 'yaml' self.load() def load(self) -> None: @@ -54,7 +54,8 @@ def path_oas_to_handlebars(self, path: str) -> Tuple[str, Union[int, None]]: def _transpile_consumes(self, details: dict, endpoint: dict) -> dict: if 'consumes' in details and details['consumes']: - endpoint['headers']['Accept'] = '{{ headers_accept_%s }}' % re.sub(r'[^a-zA-Z0-9 \n\.]', '_', details['consumes'][0]) + endpoint['headers']['Accept'] = '{{ headers_accept_%s }}' % re.sub(r'[^a-zA-Z0-9 \n.]', '_', + details['consumes'][0]) return endpoint def _transpile_parameters(self, details: dict, endpoint: dict) -> dict: @@ -133,10 +134,10 @@ def _transpile_body_json_object(self, properties: dict, last_path_param_index: U return '{%s}' % body_json[:-2] def _transpile_body_json_value( - self, - _details: str, - last_path_param_index: Union[int, None] = None, - future: bool = False + self, + _details: dict, + last_path_param_index: Union[int, None] = None, + future: bool = False ) -> str: result = '' if _details['type'] == 'string': @@ -150,13 +151,14 @@ def _transpile_body_json_value( elif _details['type'] == 'array': result += self._transpile_body_json_array(_details, last_path_param_index=last_path_param_index) elif _details['type'] == 'object': - result += self._transpile_body_json_object(_details['properties'], last_path_param_index=last_path_param_index) + result += self._transpile_body_json_object(_details['properties'], + last_path_param_index=last_path_param_index) return result def _transpile_body_json_string( - self, - _format: Union[str, None] = None, - future: bool = False + self, + _format: Union[str, None] = None, + future: bool = False ) -> str: if _format == 'date-time': if future: @@ -192,20 +194,19 @@ def _transpile_body_json_boolean(self) -> str: return '{{ fake.boolean(chance_of_getting_true=50) | lower }}' def _transpile_body_json_array( - self, - _details: dict, - last_path_param_index: Union[int, None] = None + self, + _details: dict, + last_path_param_index: Union[int, None] = None ) -> str: - return '[{%% for n in range(range(100) | random) %%} %s {%% if not loop.last %%},{%% endif %%}{%% endfor %%}]' % ( - self._transpile_body_json_value(_details['items'], last_path_param_index=last_path_param_index) - ) + tpl = '[{%% for n in range(range(100) | random) %%} %s {%% if not loop.last %%},{%% endif %%}{%% endfor %%}]' + return tpl % (self._transpile_body_json_value(_details['items'], last_path_param_index=last_path_param_index)) def _transpile_responses( - self, - details: dict, - endpoint: dict, - content_type: Union[str, None], - last_path_param_index: Union[int, None] + self, + details: dict, + endpoint: dict, + content_type: Union[str, None], + last_path_param_index: Union[int, None] ) -> dict: if not isinstance(details, dict) or 'responses' not in details: return endpoint @@ -315,7 +316,6 @@ def transpile(self, direct: bool = False) -> Union[str, dict]: cwd = getcwd() target_path = path.join(cwd, self.target_filename) - file = None try: file = open(target_path, 'w') except PermissionError: diff --git a/setup.py b/setup.py index de21b684..d222d5d6 100644 --- a/setup.py +++ b/setup.py @@ -116,7 +116,7 @@ def read_requirements(): # pip to create the appropriate form of executable for the target platform. entry_points={ 'console_scripts': [ - 'mockintosh=mockintosh:initiate', + 'mockintosh=mockintosh.__main__:main', ], }, ext_modules=[] diff --git a/tests_integrated/acceptance.sh b/tests_integrated/acceptance.sh index a137ca72..73953433 100755 --- a/tests_integrated/acceptance.sh +++ b/tests_integrated/acceptance.sh @@ -6,6 +6,7 @@ docker run -d -it --net=host redis:latest docker run -it mockintosh --help docker run -it mockintosh --sample-config /tmp/sample.yml +docker run -it -v `pwd`:/tmp mockintosh --convert=/tmp/oas.yaml /tmp/tests_integrated/subdir/oas.json docker run -d --net=host -v `pwd`/tests_integrated:/tmp/tests_integrated \ -e PYTHONPATH=/tmp/tests_integrated mockintosh \ diff --git a/tests_unit/test_cli.py b/tests_unit/test_cli.py new file mode 100644 index 00000000..4194388d --- /dev/null +++ b/tests_unit/test_cli.py @@ -0,0 +1,29 @@ +import logging +import tempfile +import unittest +from os import path + +import mockintosh +from mockintosh.__main__ import main + +__location__ = path.abspath(path.dirname(__file__)) + +logging.basicConfig(level=logging.DEBUG) + + +class CLITests(unittest.TestCase): + def test_empty(self): + with self.assertRaises(SystemExit): + main([]) + + def test_sample_config(self): + main(["--sample-config", tempfile.mktemp()]) + + def test_oas_conversion(self): + main(["--convert=%s" % tempfile.mktemp(), __location__ + "/../tests_integrated/subdir/oas.json"]) + + def test_oas_serving(self): + main([__location__ + "/../tests_integrated/subdir/oas.json"]) + + def test_config_serving(self): + main([mockintosh.__location__ + "/res/sample.yml"])