diff --git a/fig/cli/command.py b/fig/cli/command.py index 6efe83b62..fd266d03b 100644 --- a/fig/cli/command.py +++ b/fig/cli/command.py @@ -12,9 +12,10 @@ from ..packages import six from ..project import Project from ..service import ConfigError from .docopt_command import DocoptCommand -from .formatter import Formatter -from .utils import cached_property, docker_url, call_silently, is_mac, is_ubuntu +from .utils import docker_url, call_silently, is_mac, is_ubuntu +from . import verbose_proxy from . import errors +from .. import __version__ log = logging.getLogger(__name__) @@ -22,10 +23,6 @@ log = logging.getLogger(__name__) class Command(DocoptCommand): base_dir = '.' - def __init__(self): - self._yaml_path = os.environ.get('FIG_FILE', None) - self.explicit_project_name = None - def dispatch(self, *args, **kwargs): try: super(Command, self).dispatch(*args, **kwargs) @@ -40,60 +37,70 @@ class Command(DocoptCommand): elif call_silently(['which', 'docker-osx']) == 0: raise errors.ConnectionErrorDockerOSX() else: - raise errors.ConnectionErrorGeneric(self.client.base_url) + raise errors.ConnectionErrorGeneric(self.get_client().base_url) - def perform_command(self, options, *args, **kwargs): - if options['--file'] is not None: - self.yaml_path = os.path.join(self.base_dir, options['--file']) - if options['--project-name'] is not None: - self.explicit_project_name = options['--project-name'] - return super(Command, self).perform_command(options, *args, **kwargs) + def perform_command(self, options, handler, command_options): + explicit_config_path = options.get('--file') or os.environ.get('FIG_FILE') + project = self.get_project( + self.get_config_path(explicit_config_path), + project_name=options.get('--project-name'), + verbose=options.get('--verbose')) - @cached_property - def client(self): - return Client(docker_url()) + handler(project, command_options) - @cached_property - def project(self): + def get_client(self, verbose=False): + client = Client(docker_url()) + if verbose: + version_info = six.iteritems(client.version()) + log.info("Fig version %s", __version__) + log.info("Docker base_url: %s", client.base_url) + log.info("Docker version: %s", + ", ".join("%s=%s" % item for item in version_info)) + return verbose_proxy.VerboseProxy('docker', client) + return client + + def get_config(self, config_path): try: - config = yaml.safe_load(open(self.yaml_path)) + with open(config_path, 'r') as fh: + return yaml.safe_load(fh) except IOError as e: if e.errno == errno.ENOENT: raise errors.FigFileNotFound(os.path.basename(e.filename)) raise errors.UserError(six.text_type(e)) + def get_project(self, config_path, project_name=None, verbose=False): try: - return Project.from_config(self.project_name, config, self.client) + return Project.from_config( + self.get_project_name(config_path, project_name), + self.get_config(config_path), + self.get_client(verbose=verbose)) except ConfigError as e: raise errors.UserError(six.text_type(e)) - @cached_property - def project_name(self): - project = os.path.basename(os.path.dirname(os.path.abspath(self.yaml_path))) - if self.explicit_project_name is not None: - project = self.explicit_project_name - project = re.sub(r'[^a-zA-Z0-9]', '', project) - if not project: - project = 'default' - return project + def get_project_name(self, config_path, project_name=None): + def normalize_name(name): + return re.sub(r'[^a-zA-Z0-9]', '', name) - @cached_property - def formatter(self): - return Formatter() + if project_name is not None: + return normalize_name(project_name) - @cached_property - def yaml_path(self): - if self._yaml_path is not None: - return self._yaml_path - elif os.path.exists(os.path.join(self.base_dir, 'fig.yaml')): + project = os.path.basename(os.path.dirname(os.path.abspath(config_path))) + if project: + return normalize_name(project) - log.warning("Fig just read the file 'fig.yaml' on startup, rather than 'fig.yml'") - log.warning("Please be aware that fig.yml the expected extension in most cases, and using .yaml can cause compatibility issues in future") + return 'default' + + def get_config_path(self, file_path=None): + if file_path: + return os.path.join(self.base_dir, file_path) + + if os.path.exists(os.path.join(self.base_dir, 'fig.yaml')): + log.warning("Fig just read the file 'fig.yaml' on startup, rather " + "than 'fig.yml'") + log.warning("Please be aware that fig.yml the expected extension " + "in most cases, and using .yaml can cause compatibility " + "issues in future") return os.path.join(self.base_dir, 'fig.yaml') - else: - return os.path.join(self.base_dir, 'fig.yml') - @yaml_path.setter - def yaml_path(self, value): - self._yaml_path = value + return os.path.join(self.base_dir, 'fig.yml') diff --git a/fig/cli/docopt_command.py b/fig/cli/docopt_command.py index cbb8e5303..8105d3b3f 100644 --- a/fig/cli/docopt_command.py +++ b/fig/cli/docopt_command.py @@ -23,7 +23,7 @@ class DocoptCommand(object): def dispatch(self, argv, global_options): self.perform_command(*self.parse(argv, global_options)) - def perform_command(self, options, command, handler, command_options): + def perform_command(self, options, handler, command_options): handler(command_options) def parse(self, argv, global_options): @@ -43,7 +43,7 @@ class DocoptCommand(object): raise NoSuchCommand(command, self) command_options = docopt_full_help(docstring, options['ARGS'], options_first=True) - return (options, command, handler, command_options) + return options, handler, command_options class NoSuchCommand(Exception): diff --git a/fig/cli/main.py b/fig/cli/main.py index 585922850..eb19d46d8 100644 --- a/fig/cli/main.py +++ b/fig/cli/main.py @@ -98,7 +98,7 @@ class TopLevelCommand(Command): options['version'] = "fig %s" % __version__ return options - def build(self, options): + def build(self, project, options): """ Build or rebuild services. @@ -112,9 +112,9 @@ class TopLevelCommand(Command): --no-cache Do not use cache when building the image. """ no_cache = bool(options.get('--no-cache', False)) - self.project.build(service_names=options['SERVICE'], no_cache=no_cache) + project.build(service_names=options['SERVICE'], no_cache=no_cache) - def help(self, options): + def help(self, project, options): """ Get help on a command. @@ -125,15 +125,15 @@ class TopLevelCommand(Command): raise NoSuchCommand(command, self) raise SystemExit(getdoc(getattr(self, command))) - def kill(self, options): + def kill(self, project, options): """ Force stop service containers. Usage: kill [SERVICE...] """ - self.project.kill(service_names=options['SERVICE']) + project.kill(service_names=options['SERVICE']) - def logs(self, options): + def logs(self, project, options): """ View output from containers. @@ -142,14 +142,13 @@ class TopLevelCommand(Command): Options: --no-color Produce monochrome output. """ - containers = self.project.containers(service_names=options['SERVICE'], stopped=True) + containers = project.containers(service_names=options['SERVICE'], stopped=True) monochrome = options['--no-color'] - print("Attaching to", list_containers(containers)) LogPrinter(containers, attach_params={'logs': True}, monochrome=monochrome).run() - def ps(self, options): + def ps(self, project, options): """ List containers. @@ -158,7 +157,7 @@ class TopLevelCommand(Command): Options: -q Only display IDs """ - containers = self.project.containers(service_names=options['SERVICE'], stopped=True) + self.project.containers(service_names=options['SERVICE'], one_off=True) + containers = project.containers(service_names=options['SERVICE'], stopped=True) + project.containers(service_names=options['SERVICE'], one_off=True) if options['-q']: for container in containers: @@ -183,7 +182,7 @@ class TopLevelCommand(Command): ]) print(Formatter().table(headers, rows)) - def rm(self, options): + def rm(self, project, options): """ Remove stopped service containers. @@ -193,21 +192,21 @@ class TopLevelCommand(Command): --force Don't ask to confirm removal -v Remove volumes associated with containers """ - all_containers = self.project.containers(service_names=options['SERVICE'], stopped=True) + all_containers = project.containers(service_names=options['SERVICE'], stopped=True) stopped_containers = [c for c in all_containers if not c.is_running] if len(stopped_containers) > 0: print("Going to remove", list_containers(stopped_containers)) if options.get('--force') \ or yesno("Are you sure? [yN] ", default=False): - self.project.remove_stopped( + project.remove_stopped( service_names=options['SERVICE'], v=options.get('-v', False) ) else: print("No stopped containers") - def run(self, options): + def run(self, project, options): """ Run a one-off command on a service. @@ -229,14 +228,13 @@ class TopLevelCommand(Command): --rm Remove container after run. Ignored in detached mode. --no-deps Don't start linked services. """ - - service = self.project.get_service(options['SERVICE']) + service = project.get_service(options['SERVICE']) if not options['--no-deps']: deps = service.get_linked_names() if len(deps) > 0: - self.project.up( + project.up( service_names=deps, start_links=True, recreate=False, @@ -262,14 +260,14 @@ class TopLevelCommand(Command): print(container.name) else: service.start_container(container, ports=None, one_off=True) - dockerpty.start(self.client, container.id) + dockerpty.start(project.client, container.id) exit_code = container.wait() if options['--rm']: log.info("Removing %s..." % container.name) - self.client.remove_container(container.id) + project.client.remove_container(container.id) sys.exit(exit_code) - def scale(self, options): + def scale(self, project, options): """ Set number of containers to run for a service. @@ -290,19 +288,24 @@ class TopLevelCommand(Command): raise UserError('Number of containers for service "%s" is not a ' 'number' % service_name) try: - self.project.get_service(service_name).scale(num) + project.get_service(service_name).scale(num) except CannotBeScaledError: - raise UserError('Service "%s" cannot be scaled because it specifies a port on the host. If multiple containers for this service were created, the port would clash.\n\nRemove the ":" from the port definition in fig.yml so Docker can choose a random port for each container.' % service_name) + raise UserError( + 'Service "%s" cannot be scaled because it specifies a port ' + 'on the host. If multiple containers for this service were ' + 'created, the port would clash.\n\nRemove the ":" from the ' + 'port definition in fig.yml so Docker can choose a random ' + 'port for each container.' % service_name) - def start(self, options): + def start(self, project, options): """ Start existing containers. Usage: start [SERVICE...] """ - self.project.start(service_names=options['SERVICE']) + project.start(service_names=options['SERVICE']) - def stop(self, options): + def stop(self, project, options): """ Stop running containers without removing them. @@ -310,9 +313,9 @@ class TopLevelCommand(Command): Usage: stop [SERVICE...] """ - self.project.stop(service_names=options['SERVICE']) + project.stop(service_names=options['SERVICE']) - def up(self, options): + def up(self, project, options): """ Build, (re)create, start and attach to containers for a service. @@ -343,13 +346,13 @@ class TopLevelCommand(Command): recreate = not options['--no-recreate'] service_names = options['SERVICE'] - self.project.up( + project.up( service_names=service_names, start_links=start_links, recreate=recreate ) - to_attach = [c for s in self.project.get_services(service_names) for c in s.containers()] + to_attach = [c for s in project.get_services(service_names) for c in s.containers()] if not detached: print("Attaching to", list_containers(to_attach)) @@ -359,12 +362,12 @@ class TopLevelCommand(Command): log_printer.run() finally: def handler(signal, frame): - self.project.kill(service_names=service_names) + project.kill(service_names=service_names) sys.exit(0) signal.signal(signal.SIGINT, handler) print("Gracefully stopping... (press Ctrl+C again to force)") - self.project.stop(service_names=service_names) + project.stop(service_names=service_names) def list_containers(containers): diff --git a/fig/cli/utils.py b/fig/cli/utils.py index cc9435bcf..af16e2449 100644 --- a/fig/cli/utils.py +++ b/fig/cli/utils.py @@ -7,25 +7,6 @@ import subprocess import platform -def cached_property(f): - """ - returns a cached property that is calculated by function f - http://code.activestate.com/recipes/576563-cached-property/ - """ - def get(self): - try: - return self._property_cache[f] - except AttributeError: - self._property_cache = {} - x = self._property_cache[f] = f(self) - return x - except KeyError: - x = self._property_cache[f] = f(self) - return x - - return property(get) - - def yesno(prompt, default=None): """ Prompt the user for a yes or no. diff --git a/fig/cli/verbose_proxy.py b/fig/cli/verbose_proxy.py new file mode 100644 index 000000000..939cc5320 --- /dev/null +++ b/fig/cli/verbose_proxy.py @@ -0,0 +1,58 @@ + +import functools +from itertools import chain +import logging +import pprint + +from fig.packages import six + + +def format_call(args, kwargs): + args = (repr(a) for a in args) + kwargs = ("{0!s}={1!r}".format(*item) for item in six.iteritems(kwargs)) + return "({0})".format(", ".join(chain(args, kwargs))) + + +def format_return(result, max_lines): + if isinstance(result, (list, tuple, set)): + return "({0} with {1} items)".format(type(result).__name__, len(result)) + + if result: + lines = pprint.pformat(result).split('\n') + extra = '\n...' if len(lines) > max_lines else '' + return '\n'.join(lines[:max_lines]) + extra + + return result + + +class VerboseProxy(object): + """Proxy all function calls to another class and log method name, arguments + and return values for each call. + """ + + def __init__(self, obj_name, obj, log_name=None, max_lines=10): + self.obj_name = obj_name + self.obj = obj + self.max_lines = max_lines + self.log = logging.getLogger(log_name or __name__) + + def __getattr__(self, name): + attr = getattr(self.obj, name) + + if not six.callable(attr): + return attr + + return functools.partial(self.proxy_callable, name) + + def proxy_callable(self, call_name, *args, **kwargs): + self.log.info("%s %s <- %s", + self.obj_name, + call_name, + format_call(args, kwargs)) + + result = getattr(self.obj, call_name)(*args, **kwargs) + self.log.info("%s %s -> %s", + self.obj_name, + call_name, + format_return(result, self.max_lines)) + return result diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index 931b867a0..d0c8585ea 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -5,6 +5,7 @@ from fig.cli.main import TopLevelCommand from fig.packages.six import StringIO import sys + class CLITestCase(DockerClientTestCase): def setUp(self): super(CLITestCase, self).setUp() @@ -15,12 +16,16 @@ class CLITestCase(DockerClientTestCase): def tearDown(self): sys.exit = self.old_sys_exit - self.command.project.kill() - self.command.project.remove_stopped() + self.project.kill() + self.project.remove_stopped() + + @property + def project(self): + return self.command.get_project(self.command.get_config_path()) @patch('sys.stdout', new_callable=StringIO) def test_ps(self, mock_stdout): - self.command.project.get_service('simple').create_container() + self.project.get_service('simple').create_container() self.command.dispatch(['ps'], None) self.assertIn('simplefigfile_simple_1', mock_stdout.getvalue()) @@ -64,17 +69,17 @@ class CLITestCase(DockerClientTestCase): def test_up(self): self.command.dispatch(['up', '-d'], None) - service = self.command.project.get_service('simple') - another = self.command.project.get_service('another') + service = self.project.get_service('simple') + another = self.project.get_service('another') self.assertEqual(len(service.containers()), 1) self.assertEqual(len(another.containers()), 1) def test_up_with_links(self): self.command.base_dir = 'tests/fixtures/links-figfile' self.command.dispatch(['up', '-d', 'web'], None) - web = self.command.project.get_service('web') - db = self.command.project.get_service('db') - console = self.command.project.get_service('console') + web = self.project.get_service('web') + db = self.project.get_service('db') + console = self.project.get_service('console') self.assertEqual(len(web.containers()), 1) self.assertEqual(len(db.containers()), 1) self.assertEqual(len(console.containers()), 0) @@ -82,16 +87,16 @@ class CLITestCase(DockerClientTestCase): def test_up_with_no_deps(self): self.command.base_dir = 'tests/fixtures/links-figfile' self.command.dispatch(['up', '-d', '--no-deps', 'web'], None) - web = self.command.project.get_service('web') - db = self.command.project.get_service('db') - console = self.command.project.get_service('console') + web = self.project.get_service('web') + db = self.project.get_service('db') + console = self.project.get_service('console') self.assertEqual(len(web.containers()), 1) self.assertEqual(len(db.containers()), 0) self.assertEqual(len(console.containers()), 0) def test_up_with_recreate(self): self.command.dispatch(['up', '-d'], None) - service = self.command.project.get_service('simple') + service = self.project.get_service('simple') self.assertEqual(len(service.containers()), 1) old_ids = [c.id for c in service.containers()] @@ -105,7 +110,7 @@ class CLITestCase(DockerClientTestCase): def test_up_with_keep_old(self): self.command.dispatch(['up', '-d'], None) - service = self.command.project.get_service('simple') + service = self.project.get_service('simple') self.assertEqual(len(service.containers()), 1) old_ids = [c.id for c in service.containers()] @@ -117,19 +122,18 @@ class CLITestCase(DockerClientTestCase): self.assertEqual(old_ids, new_ids) - @patch('dockerpty.start') def test_run_service_without_links(self, mock_stdout): self.command.base_dir = 'tests/fixtures/links-figfile' self.command.dispatch(['run', 'console', '/bin/true'], None) - self.assertEqual(len(self.command.project.containers()), 0) + self.assertEqual(len(self.project.containers()), 0) @patch('dockerpty.start') def test_run_service_with_links(self, __): self.command.base_dir = 'tests/fixtures/links-figfile' self.command.dispatch(['run', 'web', '/bin/true'], None) - db = self.command.project.get_service('db') - console = self.command.project.get_service('console') + db = self.project.get_service('db') + console = self.project.get_service('console') self.assertEqual(len(db.containers()), 1) self.assertEqual(len(console.containers()), 0) @@ -137,14 +141,14 @@ class CLITestCase(DockerClientTestCase): def test_run_with_no_deps(self, __): self.command.base_dir = 'tests/fixtures/links-figfile' self.command.dispatch(['run', '--no-deps', 'web', '/bin/true'], None) - db = self.command.project.get_service('db') + db = self.project.get_service('db') self.assertEqual(len(db.containers()), 0) @patch('dockerpty.start') def test_run_does_not_recreate_linked_containers(self, __): self.command.base_dir = 'tests/fixtures/links-figfile' self.command.dispatch(['up', '-d', 'db'], None) - db = self.command.project.get_service('db') + db = self.project.get_service('db') self.assertEqual(len(db.containers()), 1) old_ids = [c.id for c in db.containers()] @@ -161,11 +165,11 @@ class CLITestCase(DockerClientTestCase): self.command.base_dir = 'tests/fixtures/commands-figfile' self.client.build('tests/fixtures/simple-dockerfile', tag='figtest_test') - for c in self.command.project.containers(stopped=True, one_off=True): + for c in self.project.containers(stopped=True, one_off=True): c.remove() self.command.dispatch(['run', 'implicit'], None) - service = self.command.project.get_service('implicit') + service = self.project.get_service('implicit') containers = service.containers(stopped=True, one_off=True) self.assertEqual( [c.human_readable_command for c in containers], @@ -173,7 +177,7 @@ class CLITestCase(DockerClientTestCase): ) self.command.dispatch(['run', 'explicit'], None) - service = self.command.project.get_service('explicit') + service = self.project.get_service('explicit') containers = service.containers(stopped=True, one_off=True) self.assertEqual( [c.human_readable_command for c in containers], @@ -181,7 +185,7 @@ class CLITestCase(DockerClientTestCase): ) def test_rm(self): - service = self.command.project.get_service('simple') + service = self.project.get_service('simple') service.create_container() service.kill() self.assertEqual(len(service.containers(stopped=True)), 1) @@ -189,24 +193,23 @@ class CLITestCase(DockerClientTestCase): self.assertEqual(len(service.containers(stopped=True)), 0) def test_scale(self): - project = self.command.project + project = self.project - self.command.scale({'SERVICE=NUM': ['simple=1']}) + self.command.scale(project, {'SERVICE=NUM': ['simple=1']}) self.assertEqual(len(project.get_service('simple').containers()), 1) - self.command.scale({'SERVICE=NUM': ['simple=3', 'another=2']}) + self.command.scale(project, {'SERVICE=NUM': ['simple=3', 'another=2']}) self.assertEqual(len(project.get_service('simple').containers()), 3) self.assertEqual(len(project.get_service('another').containers()), 2) - self.command.scale({'SERVICE=NUM': ['simple=1', 'another=1']}) + self.command.scale(project, {'SERVICE=NUM': ['simple=1', 'another=1']}) self.assertEqual(len(project.get_service('simple').containers()), 1) self.assertEqual(len(project.get_service('another').containers()), 1) - self.command.scale({'SERVICE=NUM': ['simple=1', 'another=1']}) + self.command.scale(project, {'SERVICE=NUM': ['simple=1', 'another=1']}) self.assertEqual(len(project.get_service('simple').containers()), 1) self.assertEqual(len(project.get_service('another').containers()), 1) - self.command.scale({'SERVICE=NUM': ['simple=0', 'another=0']}) + self.command.scale(project, {'SERVICE=NUM': ['simple=0', 'another=0']}) self.assertEqual(len(project.get_service('simple').containers()), 0) self.assertEqual(len(project.get_service('another').containers()), 0) - diff --git a/tests/unit/cli/__init__.py b/tests/unit/cli/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/cli/verbose_proxy_test.py b/tests/unit/cli/verbose_proxy_test.py new file mode 100644 index 000000000..b17c610db --- /dev/null +++ b/tests/unit/cli/verbose_proxy_test.py @@ -0,0 +1,30 @@ +from __future__ import unicode_literals +from __future__ import absolute_import +from tests import unittest + +from fig.cli import verbose_proxy + + +class VerboseProxy(unittest.TestCase): + + def test_format_call(self): + expected = "(u'arg1', True, key=u'value')" + actual = verbose_proxy.format_call( + ("arg1", True), + {'key': 'value'}) + + self.assertEqual(expected, actual) + + def test_format_return_sequence(self): + expected = "(list with 10 items)" + actual = verbose_proxy.format_return(list(range(10)), 2) + self.assertEqual(expected, actual) + + def test_format_return(self): + expected = "{u'Id': u'ok'}" + actual = verbose_proxy.format_return({'Id': 'ok'}, 2) + self.assertEqual(expected, actual) + + def test_format_return_no_result(self): + actual = verbose_proxy.format_return(None, 2) + self.assertEqual(None, actual) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index c69235793..488e78926 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -4,6 +4,8 @@ import logging import os from .. import unittest +import mock + from fig.cli import main from fig.cli.main import TopLevelCommand from fig.packages.six import StringIO @@ -16,24 +18,37 @@ class CLITestCase(unittest.TestCase): try: os.chdir('tests/fixtures/simple-figfile') command = TopLevelCommand() - self.assertEquals('simplefigfile', command.project_name) + project_name = command.get_project_name(command.get_config_path()) + self.assertEquals('simplefigfile', project_name) finally: os.chdir(cwd) def test_project_name_with_explicit_base_dir(self): command = TopLevelCommand() command.base_dir = 'tests/fixtures/simple-figfile' - self.assertEquals('simplefigfile', command.project_name) + project_name = command.get_project_name(command.get_config_path()) + self.assertEquals('simplefigfile', project_name) def test_project_name_with_explicit_project_name(self): command = TopLevelCommand() - command.explicit_project_name = 'explicit-project-name' - self.assertEquals('explicitprojectname', command.project_name) + name = 'explicit-project-name' + project_name = command.get_project_name(None, project_name=name) + self.assertEquals('explicitprojectname', project_name) def test_yaml_filename_check(self): command = TopLevelCommand() command.base_dir = 'tests/fixtures/longer-filename-figfile' - self.assertTrue(command.project.get_service('definedinyamlnotyml')) + with mock.patch('fig.cli.command.log', autospec=True) as mock_log: + self.assertTrue(command.get_config_path()) + self.assertEqual(mock_log.warning.call_count, 2) + + def test_get_project(self): + command = TopLevelCommand() + command.base_dir = 'tests/fixtures/longer-filename-figfile' + project = command.get_project(command.get_config_path()) + self.assertEqual(project.name, 'longerfilenamefigfile') + self.assertTrue(project.client) + self.assertTrue(project.services) def test_help(self): command = TopLevelCommand()